2015年7月

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的时候就回报错误,这会带来许多麻烦(这也是导致那些测试出错的部分原因)。

为nginx配置安全的SSL证书

最近Google放了一个大招,在所有基于Chromium的浏览器中把SHA-1签名认证的https证书全部加入了连接可能不具备私密性警告。于是绿色的锁头变成灰色,还带一个黄色的大三角。如下图:

1125923241.jpg

想了解关于这个警告的含义和来龙去脉,请移驾Fredrik Luo的博客,上面有又详细又通俗的解释。现在是时候该升级一下服务器的TLS安全设置了。

对于使用SHA-1签名的证书的网站,唯一的方法是生成更高强度的哈希并重新签名。可以用下面的命令生成新的CSR,并提交给你的CA进行reissue操作:

openssl req -new -key theOriginPrivate.key -out example.com.csr -sha256

好吧,尽管安全要求只是至少升级到SHA-2,但干脆直接上SHA-256或者SHA-384算了,反正碰撞的可能性更低,而且现代计算机做个正向计算简直没难度。致使用CNNIC做根证书签名的用户:你们还是赶快换CA吧,CNNIC整个证书链都是SHA-1的,光升级你的CSR没用。

一般的域名验证证书只需通过电子邮件就能验证完成,和申请新证书基本一样。建议取得新的签名证书文件之后不要着急安装,等几个小时再安装。因为新的证书有效期是从重新签名当时起算的,可能有的客户的设备时钟不准,马上换装可能导致他们的设备报告证书不合法。一般新的证书签发后,旧的证书还能继续用三天,三天后负责任的CA会把你的旧证书写到已吊销列表中。

换装了新证书后,顺便找了个叫ssllabs的网站测试https的安全情况,用他们主页上的Test Your Server功能可以测试你的网站https的安全性和兼容性。测试结果里Signature algorithm一栏透露了证书的签名算法,我的已经是SHA256了。

650315878.jpg

nginx使用OpenSSL作为底层加密库,所以影响安全性的Cipher Suites设定至关重要,如果设置得安全性极高,可能失去对早期版本浏览器的兼容性而流失用户;反之如果设置的安全性过低,又会陷用户于易受攻击的状态并被新版浏览器嫌弃。在cipherli.st上提供了包括nginx在内的常用Web服务器安装设置,可惜这些设置对早期版本的浏览器兼容得不太好,几经周拆和尝试,我终于敲定使用以下设置:

ssl_certificate /etc/ssl/public.crt;
ssl_certificate_key /etc/ssl/private.key;
ssl_dhparam /etc/ssl/certs/dhparam.pem;
ssl_ciphers EECDH+AESGCM:AES256+EECDH:EDH+AESGCM:AES256+EDH:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;

其中第三行那个dhparam的设置,是因为nginx会自动使用OpenSSL默认的1024位的DH Pool,这在现在已被认为是不安全的,所以需要自己生成一个新的2048位的DH Pool。

openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048

本来cipherli.st推荐的是4096位的DH Pool,这当然能更安全,但无奈Java 7并不支持超过2048位的DH Pool,所以只好退而求其次,所幸2048位的DH Pool也算阶段性安全。

上面的配置并不支持Windows XP上的IE6,考虑到IE6默认设置只能用已经被证实有重大安全问题的SSL3.0,而坚持不升级的用户多半也不知道https为何物,更不知道怎么把TLS1.0打开,所以还是放弃这些用户吧。