这是一篇翻译的文章,可以点击查看原文。
简单来说不能,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默认支持的编码中有五种符合这一要求:big5
、cp932
、gb2312
、gbk
还有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.20、5.0.22和5.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)
或者
- 没用有弱点的连接字符集编码(比如只使用
utf8
、latin1
、ascii
等等)
或者
- 启用了
NO_BACKSLASH_ESCAPES
SQL模式
那么你100%安全。
其它情况下,即使你用了PDO的prepare方法也依然存在SQL注入风险。
补充
我正在慢慢编写一个补丁程序让未来版本的PHP默认不要使用模拟prepare方法。导致我进展缓慢的原因在于实在太多测试阻止我这么做。其中一个问题是模拟prepare功能只会在执行的时候返回SQL语法错误,然而真正的prepare可能会在进行prepare的时候就回报错误,这会带来许多麻烦(这也是导致那些测试出错的部分原因)。