浅谈预编译之于SQL注入防御

本文最后更新于 2024年8月13日 晚上

前言

一提到SQL注入的防御,首先想到的就是预编译咯,但是我之前并没有仔细研究过预编译,于是今天来谈一谈预编译是如何防御SQL注入的?预编译真的能防御所有的SQL注入吗?

以php+mysql为例

SQL语句的执行流程

在执行 SQL 语句时,数据库通常经历以下步骤:

  1. 解析(Parsing):数据库接收到 SQL 语句后,首先对其进行关键字、标识符、运算符的分解,然后进行解析和语法分析。这一步骤的目的是检查 SQL 语句的语法是否正确,并将其转换为一个解析树或语法树。
  2. 预处理(Preprocessing):解析后的 SQL 语句会进行预处理,包括检查语句中涉及的表和字段是否存在,用户是否有足够的权限来执行该操作,同时检查语义的正确性等。
  3. 优化(Optimization):在这个阶段,数据库优化器会根据数据库的统计信息和索引等信息,生成一个高效的查询执行计划。优化的目的是选择最优的执行路径,以提高查询性能。
  4. 生成执行计划(Execution Plan Generation):优化器生成的执行计划被传递给执行引擎,执行引擎会将其转化为具体的操作步骤和访问路径。
  5. 执行(Execution):执行引擎根据生成的执行计划,实际执行 SQL 语句,包括访问数据、进行计算和更新数据等操作。
  6. 返回结果(Result Return):执行完成后,结果会被返回给用户。对于查询操作,结果通常是数据集;对于修改操作,结果通常是操作成功或失败的状态。
  7. 事务管理(Transaction Management):如果 SQL 语句是在事务中执行的,数据库会在执行完成后根据事务的状态(如提交或回滚)来确保数据的一致性和完整性。

总结起来就是:先进行SQL执行语句的编译,再执行编译好的SQL语句,再反馈执行结果

SQL注入产生的原理

以这样一个查询用户信息的SQL注入为例:

1
select user_info from users_account where key = $_GET['key']

直接接收GET参数key,从users_account表中查询user_info字段,如果key正确就返回对应用户的个人信息

正常的查询是这样:输入正确key,返回对应的用户信息

用户输入正确的key:123456

1
select user_info from users_account where key = 123456

但是恶意的查询是这样:输入恶意SQL片段,返回所有用户信息

用户输入SQL注入的pyload:1 or 1=1

1
select user_info from users_account where key = 1 or 1=1 

这样就从语义上,改变了整个SQL语句的执行逻辑,由于**where key = $_GET[‘key’]**此处用户完全可控,又毫无过滤,于是攻击者可插入SQL语句片段来构造出一个完整的恶意的查询,改变了原来的SQL语句的执行逻辑,改变了语法树,从而达到攻击者的恶意目的

什么是预编译

预编译语句是一种在执行 SQL 语句之前,将 SQL 语句的结构和查询逻辑预先处理的技术。它与直接执行 SQL 语句相比,具有以下优点:

  1. 防止 SQL 注入:预编译语句将 SQL 语句的结构和数据分开处理,数据作为参数传递,而不是直接嵌入到 SQL 语句中。这种方式有效地防止了 SQL 注入攻击。
  2. 提高性能:当使用预编译语句时,数据库可以缓存和重用查询的执行计划,从而提高查询性能,尤其是在相同的查询结构但不同的参数被多次执行时。

预编译的工作流程

  1. 准备语句:SQL 语句的结构被发送到数据库服务器,数据库服务器解析并预处理这些语句,生成一个查询执行计划。
  2. 绑定参数:在预编译阶段,参数占位符(如 ?:param)被用作数据的占位符,实际的参数值在执行阶段绑定到这些占位符。
  3. 执行语句:将实际的参数值传递给预编译语句,然后执行查询,数据库使用之前生成的执行计划来处理实际的数据。
  4. 获取结果:查询执行后,结果返回给 PHP 脚本,可以进一步处理或显示。

预编译代码示例

使用 PDO 的预编译语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
// 创建 PDO 实例
$pdo = new PDO('mysql:host=localhost;dbname=testdb', 'username', 'password');

// 预编译 SQL 语句
$stmt = $pdo->prepare('select user_info from users_account where `key` = :key');

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

// 设置参数值
$key = $_GET['key'];

// 执行查询
$stmt->execute();

// 获取结果
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);

print_r($results);
?>

使用 MySQLi 的预编译语句

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
28
29
30
31
<?php
// 创建 MySQLi 实例
$mysqli = new mysqli('localhost', 'username', 'password', 'testdb');

// 检查连接
if ($mysqli->connect_error) {
die('Connect Error (' . $mysqli->connect_errno . ') ' . $mysqli->connect_error);
}

// 预编译 SQL 语句
$stmt = $mysqli->prepare('select user_info from users_account where `key` = ?');

// 绑定参数
$stmt->bind_param('s', $key);

// 设置参数值
$key = $_GET['key'];

// 执行查询
$stmt->execute();

// 获取结果
$result = $stmt->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC);

print_r($rows);

// 关闭语句和连接
$stmt->close();
$mysqli->close();
?>

预编译为什么能防御SQL注入

预编译技术是提前写好了SQL语句的语法结构,或者叫”模板”,所有的用户输入都只是当作参数插入,导致所有试图改变SQL语法结构的SQL注入payload都只是一个参数而已,无法逃逸出来,无法对SQL的语法结构、执行逻辑造成任何影响

SQL注入之所以能成功是因为,可控之处改变了整个SQL语句的语法结构,改变了整个SQL语句的执行逻辑,也就是可以自由控制”模板”

但是如果使用了预编译,语法结构早已提前设定好,无法被修改或影响其执行逻辑,所有的攻击语句都失效了,都只是一个参数,一串字符串而已

没有使用预编译:1 or 1=1

1
select user_info from users_account where key =  1 or 1=1 

此处的 1 or 1=1 ,就成功的改变了SQL语句的语法结构,改变了执行逻辑

使用了预编译:1 or 1=1

1
2
$stmt = $mysqli->prepare('select user_info from users_account where `key` = ?');
select user_info from users_account where key =1 or 1=1

实际执行时,SQL语句的语法结构早已设定好,无法被影响,这里的【1 or 1=1】仅仅是一个参数,一串字符串,不会被解析为SQL语句的一部分

预编译演示

  1. 本地创建测试数据库和表,随意填充一点数据
  1. 开启日志记录
1
show variables like 'general%';

general_log显示的是是否开启日志功能,general_log_file显示的是日志位置。如果是off的话可以使用

1
set GLOBAL general_log = 1;

demo-1(无预编译)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
// 创建 PDO 实例
$pdo = new PDO('mysql:host=localhost;dbname=testdb', 'username', 'password');

// 从用户输入中获取数据
$key = $_GET['key'];

// 直接将用户输入插入到 SQL 查询中
$sql = "SELECT user_info FROM users_account WHERE `key` = $key";

// 执行查询
$results = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);

// 输出结果
print_r($results);
?>

若填写正确的key,会返回对应的user_info

看看日志

1
2
3
240813 16:10:13	   19 Connect	root@localhost on test
19 Query SELECT user_info FROM users_account WHERE `key` = 'f4yKaqSLq7'
19 Quit

填写SQL注入payload:1 or 1=1(数字型)

可以看到,直接注出了所有用户数据

看看日志

1
2
3
240813 16:12:06	   21 Connect	root@localhost on test
21 Query SELECT user_info FROM users_account WHERE `key` = 1 or 1=1
21 Quit

demo-2(有预编译-模拟预编译模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
// 创建 PDO 实例
$pdo = new PDO('mysql:host=localhost;dbname=testdb', 'username', 'password');

// 预编译 SQL 语句
$stmt = $pdo->prepare('select user_info from users_account where `key` = :key');

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

// 设置参数值
$key = $_GET['key'];

// 执行查询
$stmt->execute();

// 获取结果
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);

print_r($results);
?>

填写SQL注入payload:1 or 1=1(数字型)

注入失败,预编译起到了防御作用

看看日志

1
2
3
240813 16:15:26	   23 Connect	root@localhost on test
23 Query select user_info from users_account where `key` = '1 or 1=1'
23 Quit

可以看到$key处本来是数字型的注入点,没有引号,但是日志中实际执行时,key是被引号包裹了的,这才防御了SQL注入,我们尝试一下手动闭合看看呢?

填写SQL注入payload:1’ or 1=1 – +

仍然注入失败

看看日志

1
2
3
240813 16:20:04	   24 Connect	root@localhost on test
24 Query select user_info from users_account where `key` = '1\' or 1=1 -- '
24 Quit

可以看到,这里自动把引号转义了,所以导致注入失败,恶意语句没有逃逸出来影响语法结构

但是大家有发现有什么不对劲的地方吗?

这次注入失败是因为这里自动的把引号转义了,通过观察日志可以发现,这里其实压根就没有执行预编译的过程!为什么呢?因为前文明明说了预编译的流程应该是:预编译 SQL 语句->绑定参数->执行查询,而这里的日志可以看出压根就没有这些预编译 SQL 语句和绑定参数的过程!完全是和没有使用预编译的日志是一样的!唯一不同的就只是使用了一个自动转义引号,但预编译的核心应该是参数化,这一点没有体现出来,这是为什么呢?

原来这个demo-2的写法只是默认的模拟预编译模式,也被称作虚假的预编译,因为他在sql执行的过程中其实根本没有参数绑定、预编译的过程,本质上只是对符号做了过滤

PDO::ATTR_EMULATE_PREPARES,这个选项用来配置PDO是否使用模拟预编译默认是true因此默认情况下PDO采用的是模拟预编译模式,设置成false以后,才会使用真正的预编译

为什么开发者要搞一个这个默认的模拟预编译的模式呢?

原来开启这个选项主要是用来兼容部分不支持预编译的数据库(如sqllite与低版本MySQL),对于模拟预编译,会由客户端程序内部参数绑定这一过程(而不是数据库),内部prepare之后再将拼接的sql语句发给数据库执行

接下来就修改demo,修改为真正的预编译模式

demo-3(有预编译-真正的预编译模式)

只需加一行代码

1
setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
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
// 创建 PDO 实例
$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', 'root');

//开启真正的预编译模式
$pdo -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

// 预编译 SQL 语句
$stmt = $pdo->prepare('select user_info from users_account where `key` = :key');

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

// 设置参数值
$key = $_GET['key'];

// 执行查询
$stmt->execute();

// 获取结果
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);

print_r($results);
?>

填写SQL注入payload:1 or 1=1(数字型)

注入失败

看看日志

1
2
3
4
5
240813 16:44:14	   25 Connect	root@localhost on test
25 Prepare select user_info from users_account where `key` = ?
25 Execute select user_info from users_account where `key` = '1 or 1=1'
25 Close stmt
25 Quit

哈哈,这下才对嘛,这下在日志中看到了预编译的特征

尝试手动闭合一下引号呢:1’ or 1=1 – +

注入失败

看看日志

1
2
3
4
5
240813 16:48:26	   26 Connect	root@localhost on test
26 Prepare select user_info from users_account where `key` = ?
26 Execute select user_info from users_account where `key` = '1\' or 1=1 -- '
26 Close stmt
26 Quit

其实,预编译是为了提高MySQL的运行效率而诞生的(而不是专门为了防止sql注入),因为它可以先构建语法树然后带入查询参数,避免了每次执行都要构建语法树的繁琐,从而达到了一次编译、多次执行的效果,对于数据量以及查询量较大的数据库能极大提高运行效率

预编译的局限性与注入场景

动态表名和列名

如果使用了预编译,在生成语法树的过程中,预处理器在进一步检查解析后的语法树时,还会检查数据表和数据列是否存在,若不存在,则会返回错误。因此数据表和数据列不能被占位符所替代,必须是一个确定的值。但在很多业务场景中,表名、列名需要作为一个变量存在,因此这部分仍需由加号进行SQL语句的动态拼接,若表名是由外部传入且可控的,仍会造成SQL注入。于是动态表名和列名的场景无法使用预编译来防御SQL注入

order by

order by后一般是接字段名,而字段名是不能带引号的,比如 order by username;如果带上引号成了order by ‘username’,那username就是一个字符串不是字段名了,那么查询结果其实就等同于order by NULL或者order by TRUE了,就不再是我们希望的按照username排序的逻辑了。因此无论是order by还是group by,他们后面的参数都是不能带引号的

而预编译中参数绑定的过程会自动给它们带上引号,这就导致这些位置上的参数是不能被预编译的,因为它的执行结果是错误的。于是order by后面也无法使用预编译来防御SQL注入

模糊查询

预编译的参数需要是一个具体的唯一的参数来进行参数绑定,而模糊查询的参数本就是不确定的,是符合通配符的一系列值

预编译的占位符只能用于替换具体的值,而不能用于替换SQL语句中的其他结构,如通配符或标识符

1
SELECT * FROM users WHERE name LIKE '%a%'

这个查询会返回名字中包含”a”的所有用户,但是,由于通配符的存在,参数值是不确定的,因此无法进行预编译。当执行如下语句时,会报错

1
SELECT * FROM users WHERE name LIKE '%?%'

报错

1
Parameter index out of range (1 > number of parameters, which is 0)

宽字节注入

demo-4(有预编译-模拟预编译模式-宽字节编码集)

  • 版本信息:php-5.2.17+Apache,版本太高网站会无法显示注入得到的数据
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
// 创建 PDO 实例
$pdo = new PDO('mysql:host=localhost;dbname=test;charset=gbk', 'root', 'root');

$pdo->query('SET NAMES GBK');

// 预编译 SQL 语句
$stmt = $pdo->prepare('select user_info from users_account where `key` = :key');


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

// 设置参数值
$key = $_GET['key'];

// 执行查询
$stmt->execute();

// 获取结果
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);

print_r($results);
?>

输入payload:1%df%27or 1=1 – +

成功宽字节注入,绕过了模拟预编译模式下的引号转义,使得引号逃逸出来,影响了SQL的语法结构

看看日志

1
2
3
4
240813 20:06:52	   21 Connect	root@localhost on test
21 Query SET NAMES GBK
21 Query select user_info from users_account where `key` = '1運\\' or 1=1 -- '
21 Quit

demo-5(有预编译-真正的预编译模式-宽字节编码集)

  • 版本信息:php-5.2.17+Apache,版本太高的话网站会无法显示注入得到的数据
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
<?php
// 创建 PDO 实例
$pdo = new PDO('mysql:host=localhost;dbname=test;charset=gbk', 'root', 'root');

$pdo->query('SET NAMES GBK');

// 开启真正的预编译模式
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

// 预编译 SQL 语句
$stmt = $pdo->prepare('SELECT user_info FROM users_account WHERE `key` = :key');

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

// 设置参数值
$key = $_GET['key'];

// 执行查询
$stmt->execute();

// 获取结果
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);

print_r($results);
?>

输入payload:1%df%27or 1=1 – +

也是成功注入了?什么情况?

看看日志

1
2
3
4
240813 20:37:01	    5 Connect	root@localhost on test
5 Query SET NAMES GBK
5 Query SELECT user_info FROM users_account WHERE `key` = '1運\\' or 1=1 -- '
5 Quit

?真正的预编译模式下的日志居然和模拟预编译模式下的日志一样?

经测试应该是php版本太低了,没有真正进行预编译处理

尝试切换高一点的版本

  • demo-4(有预编译-模拟预编译模式-宽字节编码集)

  • 版本信息:php-5.3.29-NTS+Apache

输入payload:1%df%27or 1=1 – +

无数据显示,但是日志中其实是成功逃逸出来了,成功绕过了引号转义,只是网站因为php版本太高的原因无法显示注入得到的数据

看看日志

1
2
3
4
240813 20:44:36	    5 Connect	root@localhost on test
5 Query SET NAMES GBK
5 Query SELECT user_info FROM users_account WHERE `key` = '1\運' or 1=1 -- '
5 Quit

在Navicat都能注入成功

  • demo-5(有预编译-真正的预编译模式-宽字节编码集)
  • 版本信息:php-5.3.29+Apache,版本太高网站会无法显示注入得到的数据

输入payload:1%df%27or 1=1 – +

注入失败,原理是预编译起了作用

看看日志

1
2
3
4
5
240813 20:43:35	    3 Connect	root@localhost on test
3 Query SET NAMES GBK
3 Prepare SELECT user_info FROM users_account WHERE `key` = ?
3 Close stmt
3 Quit