分类 翻译 下的文章

PDO prepare足以防止SQL注入吗?

这是一篇翻译的文章,可以点击查看原文


简单来说不能,PDO prepare无法防御全部的SQL注入攻击。比如一些潜在的边缘情况。

我调整了这个答案以适应PDO的场景……

完整的答案是不太容易,这篇文章证明了这种攻击的可能。

攻击实现

那么,接下来我来演示这种攻击……

$pdo->query('SET NAMES gbk');
$var = "\xbf\x27 OR 1=1 /*";
$query = 'SELECT * FROM test WHERE name = ? LIMIT 1';
$stmt = $pdo->prepare($query);
$stmt->execute(array($var));

某些情况下,上面的代码会返回多于一行数据。让我们剖析一下这里到底发生了什么:

1. 选择一个编码集

$pdo->query('SET NAMES gbk');

要让这种攻击生效,我们需要服务编码和连接编码两者都把'以ASCII形式编码,也就是0x27并且这种编码中要存在某个字符以ASCII的\也就是0x5c结尾。事实上,MySQL5.6默认支持的编码中有五种符合这一要求:big5cp932gb2312gbk还有sjis。我们这里用gbk举例。

现在,请格外注意这里的SET NAMES指令,它改变了服务器上的字符集。当然还有另一种方法修改字符集,我们后面会谈到。

2. 要嵌入的字符串

我们用于演示SQL注入的字符串以0xbf27开头。在gbk编码中,这是个不合法的多字节字符,而在latin1编码中,它是字符串¿'。注意,不论latin1还是gbk编码,0x27都是半角单引号'字符。

我们选择这个字串的原因在于,如果我们对它调用addslashes(),我们会在'字符前插入一个ASCII码的\字符,即0x5c。然后我们得到字符串编码是0xbf5c27,在gbk编码中,这是两个字符0xbf5c,后面接着0x27。换句话说,这是一个合法的字符后面跟着一个没转义的'。不过我们没调用addslashes(),那么会进行下一步。

3. $stmt->execute()

这里有一件重要的事情需要搞清楚的是,PDO在默认情况下并不会真的对语句做prepare,而是会模拟这一行为(对于MySQL)。也就是说,PDO会在内部构建查询语句,对每个绑定的字符串调用mysql_real_escape_string()(MySQL的C语言API中的函数)。

C语言API调用mysql_real_escape_string()addslashes()的不同点在于前者知道MySQL连接的字符集,所以它可以正确地根据服务器要使用的字符集进行转义。然而,正因为这一点,MySQL客户端会使用latin1进行转义——因为我们从来没有通知它切换字符集的事情。我们的确告诉服务器我们在用gbk,可是客户端仍以为是latin1

这样的情况下调用mysql_real_escape_string()时会插入反斜杠\,而那个挂单的'就会被转义出来!实际上,如果我们以gbk编码查看$var的值是:

縗' OR 1=1 /*

以上就是进行攻击的环境要求。

4. 生成的查询语句

这部分只是为了完整,下面是生成的SQL查询语句:

SELECT * FROM test WHERE name = '縗' OR 1=1 /*' LIMIT 1

恭喜,你刚刚完成了一次针对PDO prepare程序的攻击。

简单的修复方法

值得注意的是你可以简单地通过禁用prepare语句模拟功能来防御这种攻击:

$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

这样一般导致PDO执行真正的prepare操作。(数据会独立于SQL语句传送给服务器。)不过还是得小心PDO会在本地的MySQL无法完成prepare时悄悄地恢复使用模拟prepare语句的状态。具体情况在MySQL手册中已然列出,请注意选择对于你服务器版本的手册。

正确的修复方法

这里真正的问题在于我们没有调用MySQL的C语言API中的mysql_set_charset(),而是使用了SET NAMES语句。如果正确调用了mysql_set_charset(),那么使用2006年之后发布的MySQL版本都将安全。

如果你仍在使用更早版本的MySQL,那么mysql_real_escape_string()中的一个bug会导致它把类似我们例子中的不合法的多字节字符串当作单字节字符串加以处理,这样即便客户端正确地指定了连接编码集和服务器编码集,仍会存在被攻击的风险。这个bug在MySQL 4.1.205.0.225.1.11中得以修复。

不过最糟的问题在于PHP 5.3.6之前版本的PDO压根就没暴露C语言API中的mysql_set_charset()方法,所以早于此版本的用户根本不可能阻止这种类型的攻击。现在PDO以一个DSN参数的形式暴露了这个接口,应该用这种方法取代SET NAMES……

其它好办法

话说回来,这种攻击能生效的前提条件是数据库连接使用了薄弱的字符编码。utf8mb4是一种不存在此类弱点的字符集编码方法,而且它能支持所有的Unicode字符——你应该考虑切换到这种编码上。可惜只有MySQL 5.5.3以上版本支持这一编码。另一个可以考虑的替代字符集编码是utf8,它也不存在前述弱点,而且能支持所有Unicode基本多文种平面中的字符

此外,你也可以启用NO_BACKSLASH_ESCAPES SQL模式,这会改变mysql_real_escape_string()的内部行为(也包括其它东西)。当这个模式启动时,0x27会被替换为0x2727而不是0x5c27,这样转义后输出的字符在所有的薄弱的编码字符集中都不会和前面的字符连成合法的存在(比如0xbf27转义出来还是0xbf27),最终服务器就会拒绝这个非法的字符串。不过请看看@eggyal的回答以了解这种SQL模式下的别的漏洞(尽管与PDO无关)。

安全的例子

以下例子是安全的:

mysql_query('SET NAMES utf8');
$var = mysql_real_escape_string("\xbf\x27 OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");

因为服务器期待utf8……

mysql_set_charset('gbk');
$var = mysql_real_escape_string("\xbf\x27 OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");

因为已经正确地设置和服务器匹配的客户端字符集。

$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$pdo->query('SET NAMES gbk');
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));

因为关掉了prepare模拟。

$pdo = new PDO('mysql:host=localhost;dbname=testdb;charset=gbk', $user, $password);
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));

因为已经正确地设置字符集。

$mysqli->query('SET NAMES gbk');
$stmt = $mysqli->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$param = "\xbf\x27 OR 1=1 /*";
$stmt->bind_param('s', $param);
$stmt->execute();

因为MySQLi总是执行真正的prepare操作。

总结一下

如果你:

  • 使用了现代版本的MySQL(晚期版本的5.1,所有的5.5,所有的5.6等等)并且使用了PDO的DSN里的charset参数(PHP ≥ 5.3.6)

或者

  • 没用有弱点的连接字符集编码(比如只使用utf8latin1ascii等等)

或者

  • 启用了NO_BACKSLASH_ESCAPES SQL模式

那么你100%安全。

其它情况下,即使你用了PDO的prepare方法也依然存在SQL注入风险

补充

我正在慢慢编写一个补丁程序让未来版本的PHP默认不要使用模拟prepare方法。导致我进展缓慢的原因在于实在太多测试阻止我这么做。其中一个问题是模拟prepare功能只会在执行的时候返回SQL语法错误,然而真正的prepare可能会在进行prepare的时候就回报错误,这会带来许多麻烦(这也是导致那些测试出错的部分原因)。

某用户在被Rails开发组驳回其漏洞报告之后黑掉了Github网站来演示该漏洞

这是一篇翻译的文章(原文链接),已经是老掉牙的旧闻了,不过感觉这件事很有意思,所以翻译过来。

某用户在被Rails开发组驳回其漏洞报告之后黑掉了Github网站来演示该漏洞

作者:Lucian Constantin, IDG News Service 2012年3月5日

一名用户于上周日黑掉了Ruby on Rails托管在GitHub上代码仓库和Bug跟踪系统,以向Rails开发组展示问题有多么严重。

Ruby on Rails一般简称作Rails,是一种逐渐流行的Ruby语言的Web开发框架,它的设计目标是让开发人员专注于构建应用程序,而无需考虑底层的工作原理。

GitHub是基于Rails框架开发的最受欢迎的Web站点之一,它是一个大型代码托管和协作开发平台,Ruby on Rails项目的代码库和Bug跟踪系统也托管在GitHub上。

上周二,一个名叫Egor Homakov的俄国用户报告了一个Rails框架中存在的漏洞。利用该漏洞,黑客可以从Web表单直接向Rails程序的数据库中注入未经验证的数据,就像SQL注入一样。

这个问题与Rails中的mass assignment功能有关,滥用此功能会导致站点不安全。滥用这一功能的可能性早在几年前就已经知晓,但Rails开发组认为,定义哪些属性可以被这个功能修改的限制条件是应用开发人员的责任。

这个问题的实质是Rails开发人员究竟应该对这一功能应用黑名单策略还是白名单策略。到底是应该默认允许所有属性修改,然后让开发人员定义不许修改的黑名单(现在就是这样的策略);还是应该默认阻止所有属性的修改,再由程序员仔细考虑安全问题之后,定义允许修改的白名单。

很遗憾,历史一再证明把安全决策推给用户极不明智,这往往导致大量的不安全程序在线上运行,这也是Homakov声称问题持续存在了数年的原因。

在尝试说服Rails开发组该功能应该默认关闭未果之后,Homakov决定演示这个漏洞的存在是多么的普遍,即使最成功的Rails应用之一GitHub中也存在这个漏洞导致的安全问题。

星期天,Homakov利用这个漏洞向Ruby on Rails在GitHub上的Bug跟踪系统中加入了一条非法条目,该条目的创建时间居然是1001年后的未来。然后他又利用这个漏洞把他自己的公钥注入GitHub的数据库中,替换了一名Rails开发组成员的的公钥,从而取得了Rails官方代码库的提交权限。

“太平洋时间上午8:49,一名GitHub用户利用公钥更新表单中存在的安全漏洞把他的公钥加入了Rails组织,”——GitHub开发人员Tom Preston-Werner在一篇周日发表的博文中如是说——“后来他推送了一个新文件到该项目中以演示这个安全漏洞。”

GitHub在不到一小时后修复了这个漏洞,并暂时冻结了Homakov的帐号以调查他的行为。GitHub团队在不久后确认了他并无恶意,随即解冻了他的帐号。

“在对攻击行为进行调查的同时,我们对GitHub的代码库展开了彻底的审查,以确保没有其它地方存在相同问题,”Preston-Werner说道“审查工作仍在进行,而我则要确保我们有相应的流程制度避免此类问题再次发生。”

这一事件引起了大量关注,Rails开发组现在愿意更多地讨论这一问题,以期找到解决方案。然而,由于这个问题已经公开,不安全的Rails应用程序遭到攻击的风险也更高了。


我的感想

因为我不是Ruby程序员,所以我也是最近才知道这个发生在三年前的事件。在了解了mass assignment的功能之后,发现这和PHP早年的register global引发的安全问题简直一模一样。在GitHub的讨论中,网友DrPizza的留言很有道理: Insecure-by-default means insecure. (默认设置不安全就意味着不安全)。很难指望程序员在加班加点写完调通功能代码之后再去研究一下手册,发现“啊,我用的方法不安全”一般他们都是直接提交然后就回家睡觉了。只有等到某一天网站被攻击了造成了损失,他可能才会在求助别人之后得知当时犯下了多大的错误。

当年PHP的作法就是从某个版本开始,把register global默认关闭,然后在该项配置旁边写了一堆注释强调开启这个功能的潜在风险,以及不开这个功能的替代方案。

理解PHP的AES加密

这是一篇翻译的文章,原文在这里。为排版效果,有改动。

理解PHP的AES加密

这一段PHP代码给出了PHP的AES加密的一个基本轮廓

首先要了解的东西是下面几个常量:

MCRYPT_RIJNDAEL_128
MCRYPT_RIJNDAEL_192
MCRYPT_RIJNDAEL_256

你可能认为MCRYPT_RIJNDAEL_256意味着256位加密技术,但其实不是的。这三个选项定义的是Rijndael加密过程中所使用的块尺寸(block-size),而与密钥长度(即所谓的加密强度)完全无关。(后面会解释AES加密中的加密强度如何设置。)

Rijndael是一种块加密算法,它在一系列互不相关的数据块上分别进行操作。所以必须对数据进行补位操作,以保证被加密的数据长度正好是块尺寸的整数倍。(PHP使用NULL字节进行补位) 因此,如果你指定了MCRYPT_RIJNDAEL_256,你加密输出的数据长度一定是32字节(256位)的整数倍;如果你指定了MCRYPT_RIJNDAEL_128,你的加密输出则一定是16字节的整数倍。

注意:严格地说,AES和Rijndael并不完全等价(尽管实践中它们经常互换使用),因为Rijndael支持更大范围的块尺寸和密钥长度;AES使用固定的128位块尺寸,而密钥长度可以是128位、192位或者256位。Rijndael的块尺寸和密钥长度只要求使用32位的整数倍,最小128位,最大256位。

简而言之:如果你想和AES兼容,请永远使用MCRYPT_RIJNDAEL_128。

那么,第一步自然是初始化加密器对象

$cipher = mcrypt_module_open(MCRYPT_RIJNDAEL_128, '', MCRYPT_MODE_CBC, '');

我们使用CBC模式(cipher-block chaining)进行加密。块加密模式详见这里。要使用CBC模式,需要提供一个初始向量(IV)。初始向量的长度必须和块尺寸相当(不一定等于密钥长度)。既然我们的块尺寸是128位,所以IV也应该是128位(16字节)。也就是说,对于AES加密方法,初始向量总是16字节,不论加密强度如何。

这里是验证初始向量长度的代码:

$iv_size = mcrypt_enc_get_iv_size($cipher);
printf("iv_size = %d\n",$iv_size);

那么在PHP中,如何进行256位而不是128位的AES加密?答案是使用32字节长的密钥。

譬如:

$key256 = '12345678901234561234567890123456';
$key128 = '1234567890123456';

我们的128位初始向量既可以用于128位加密,又可以用于256位加密。

$iv =  '1234567890123456';

printf("iv: %s\n",bin2hex($iv));
printf("key256: %s\n",bin2hex($key256));
printf("key128: %s\n",bin2hex($key128));

这是要加密的原文:

$cleartext = 'The quick brown fox jumped over the lazy dog';
printf("plainText: %s\n\n",$cleartext);

mcrypt_generic_init函数初始化的时候,需要同时提供密钥和初始向量。密钥的长度决定了我们是在进行128位、192位还是256位加密。这里我们先进行256位加密:

if (mcrypt_generic_init($cipher, $key256, $iv) != -1)
{
    // 如果$cleartext的长度并非恰好是块尺寸的整数倍,PHP会用NULL字符进行补位
    $cipherText = mcrypt_generic($cipher,$cleartext );
    mcrypt_generic_deinit($cipher);

    // 以十六进制显示最终结果:
    printf("256-bit encrypted result:\n%s\n\n",bin2hex($cipherText));
}

这次我们进行128位加密:

if (mcrypt_generic_init($cipher, $key128, $iv) != -1)
{
    // 如果$cleartext的长度并非恰好是块尺寸的整数倍,PHP会用NULL字符进行补位
    $cipherText = mcrypt_generic($cipher,$cleartext );
    mcrypt_generic_deinit($cipher);

    // 以十六进制显示最终结果:
    printf("128-bit encrypted result:\n%s\n\n",bin2hex($cipherText));
}

结果

你可以用下列测试来指导针对你自己的AES实现进行的测试

256位密钥CBC模式

IV = '1234567890123456'
 (hex: 31323334353637383930313233343536)
Key = '12345678901234561234567890123456'
 (hex: 3132333435363738393031323334353631323334353637383930313233343536)
PlainText:
 'The quick brown fox jumped over the lazy dog'
CipherText(hex):
 2fddc3abec692e1572d9b7d629172a05caf230bc7c8fd2d26ccfd65f9c54526984f7cb1c4326ef058cd7bee3967299e3

128位密钥CBC模式

IV = '1234567890123456'
 (hex: 31323334353637383930313233343536)
Key = '1234567890123456'
 (hex: 31323334353637383930313233343536)
PlainText:
 'The quick brown fox jumped over the lazy dog'
CipherText(hex):
 f78176ae8dfe84578529208d30f446bbb29a64dc388b5c0b63140a4f316b3f341fe7d3b1a3cc5113c81ef8dd714a1c99