浅谈预编译之于SQL注入防御
本文最后更新于 2024年10月22日 晚上
前言
一提到SQL注入的防御,首先想到的就是预编译咯,但是我之前并没有仔细研究过预编译,于是今天来谈一谈预编译是如何防御SQL注入的?预编译真的能防御所有的SQL注入吗?
以php+mysql为例
SQL语句的执行流程
在执行 SQL 语句时,数据库通常经历以下步骤:
- 解析(Parsing):数据库接收到 SQL 语句后,首先对其进行关键字、标识符、运算符的分解,然后进行解析和语法分析。这一步骤的目的是检查 SQL 语句的语法是否正确,并将其转换为一个解析树或语法树。
- 预处理(Preprocessing):解析后的 SQL 语句会进行预处理,包括检查语句中涉及的表和字段是否存在,用户是否有足够的权限来执行该操作,同时检查语义的正确性等。
- 优化(Optimization):在这个阶段,数据库优化器会根据数据库的统计信息和索引等信息,生成一个高效的查询执行计划。优化的目的是选择最优的执行路径,以提高查询性能。
- 生成执行计划(Execution Plan Generation):优化器生成的执行计划被传递给执行引擎,执行引擎会将其转化为具体的操作步骤和访问路径。
- 执行(Execution):执行引擎根据生成的执行计划,实际执行 SQL 语句,包括访问数据、进行计算和更新数据等操作。
- 返回结果(Result Return):执行完成后,结果会被返回给用户。对于查询操作,结果通常是数据集;对于修改操作,结果通常是操作成功或失败的状态。
- 事务管理(Transaction Management):如果 SQL 语句是在事务中执行的,数据库会在执行完成后根据事务的状态(如提交或回滚)来确保数据的一致性和完整性。
总结起来就是:先进行SQL执行语句的编译,再执行编译好的SQL语句,再反馈执行结果
SQL注入产生的原理
以这样一个查询用户信息的SQL注入为例:
1 |
|
直接接收GET参数key,从users_account表中查询user_info字段,如果key正确就返回对应用户的个人信息
正常的查询是这样:输入正确key,返回对应的用户信息
用户输入正确的key:123456
1 |
|
但是恶意的查询是这样:输入恶意SQL片段,返回所有用户信息
用户输入SQL注入的pyload:1 or 1=1
1 |
|
这样就从语义上,改变了整个SQL语句的执行逻辑,由于**where key = $_GET[‘key’]**此处用户完全可控,又毫无过滤,于是攻击者可插入SQL语句片段来构造出一个完整的恶意的查询,改变了原来的SQL语句的执行逻辑,改变了语法树,从而达到攻击者的恶意目的
什么是预编译
预编译语句是一种在执行 SQL 语句之前,将 SQL 语句的结构和查询逻辑预先处理的技术。它与直接执行 SQL 语句相比,具有以下优点:
- 防止 SQL 注入:预编译语句将 SQL 语句的结构和数据分开处理,数据作为参数传递,而不是直接嵌入到 SQL 语句中。这种方式有效地防止了 SQL 注入攻击。
- 提高性能:当使用预编译语句时,数据库可以缓存和重用查询的执行计划,从而提高查询性能,尤其是在相同的查询结构但不同的参数被多次执行时。
预编译的工作流程
- 准备语句:SQL 语句的结构被发送到数据库服务器,数据库服务器解析并预处理这些语句,生成一个查询执行计划。
- 绑定参数:在预编译阶段,参数占位符(如
?
或:param
)被用作数据的占位符,实际的参数值在执行阶段绑定到这些占位符。 - 执行语句:将实际的参数值传递给预编译语句,然后执行查询,数据库使用之前生成的执行计划来处理实际的数据。
- 获取结果:查询执行后,结果返回给 PHP 脚本,可以进一步处理或显示。
预编译代码示例
使用 PDO 的预编译语句
1 |
|
使用 MySQLi 的预编译语句
1 |
|
预编译为什么能防御SQL注入
预编译技术是提前写好了SQL语句的语法结构,或者叫”模板”,所有的用户输入都只是当作参数插入,导致所有试图改变SQL语法结构的SQL注入payload都只是一个参数而已,无法逃逸出来,无法对SQL的语法结构、执行逻辑造成任何影响
SQL注入之所以能成功是因为,可控之处改变了整个SQL语句的语法结构,改变了整个SQL语句的执行逻辑,也就是可以自由控制”模板”
但是如果使用了预编译,语法结构早已提前设定好,无法被修改或影响其执行逻辑,所有的攻击语句都失效了,都只是一个参数,一串字符串而已
没有使用预编译:1 or 1=1
1 |
|
此处的 1 or 1=1 ,就成功的改变了SQL语句的语法结构,改变了执行逻辑
使用了预编译:1 or 1=1
1 |
|
实际执行时,SQL语句的语法结构早已设定好,无法被影响,这里的【1 or 1=1】仅仅是一个参数,一串字符串,不会被解析为SQL语句的一部分
预编译演示
- 本地创建测试数据库和表,随意填充一点数据
- 开启日志记录
1 |
|
general_log显示的是是否开启日志功能,general_log_file显示的是日志位置。如果是off的话可以使用
1 |
|
demo-1(无预编译)
1 |
|
若填写正确的key,会返回对应的user_info
看看日志
1 |
|
填写SQL注入payload:1 or 1=1(数字型)
可以看到,直接注出了所有用户数据
看看日志
1 |
|
demo-2(有预编译-模拟预编译模式)
1 |
|
填写SQL注入payload:1 or 1=1(数字型)
注入失败,预编译起到了防御作用
看看日志
1 |
|
可以看到$key处本来是数字型的注入点,没有引号,但是日志中实际执行时,key是被引号包裹了的,这才防御了SQL注入,我们尝试一下手动闭合看看呢?
填写SQL注入payload:1’ or 1=1 – +
仍然注入失败
看看日志
1 |
|
可以看到,这里自动把引号转义了,所以导致注入失败,恶意语句没有逃逸出来影响语法结构
但是大家有发现有什么不对劲的地方吗?
这次注入失败是因为这里自动的把引号转义了,通过观察日志可以发现,这里其实压根就没有执行预编译的过程!为什么呢?因为前文明明说了预编译的流程应该是:预编译 SQL 语句->绑定参数->执行查询,而这里的日志可以看出压根就没有这些预编译 SQL 语句和绑定参数的过程!完全是和没有使用预编译的日志是一样的!唯一不同的就只是使用了一个自动转义引号,但预编译的核心应该是参数化,这一点没有体现出来,这是为什么呢?
原来这个demo-2的写法只是默认的模拟预编译模式,也被称作虚假的预编译,因为他在sql执行的过程中其实根本没有参数绑定、预编译的过程,本质上只是对符号做了过滤
PDO::ATTR_EMULATE_PREPARES,这个选项用来配置PDO是否使用模拟预编译,默认是true,因此默认情况下PDO采用的是模拟预编译模式,设置成false以后,才会使用真正的预编译。
为什么开发者要搞一个这个默认的模拟预编译的模式呢?
原来开启这个选项主要是用来兼容部分不支持预编译的数据库(如sqllite与低版本MySQL),对于模拟预编译,会由客户端程序内部参数绑定这一过程(而不是数据库),内部prepare之后再将拼接的sql语句发给数据库执行。
接下来就修改demo,修改为真正的预编译模式
demo-3(有预编译-真正的预编译模式)
只需加一行代码
1 |
|
1 |
|
填写SQL注入payload:1 or 1=1(数字型)
注入失败
看看日志
1 |
|
哈哈,这下才对嘛,这下在日志中看到了预编译的特征
尝试手动闭合一下引号呢:1’ or 1=1 – +
注入失败
看看日志
1 |
|
其实,预编译是为了提高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 |
|
这个查询会返回名字中包含”a”的所有用户,但是,由于通配符的存在,参数值是不确定的,因此无法进行预编译。当执行如下语句时,会报错
1 |
|
报错
1 |
|
宽字节注入
demo-4(有预编译-模拟预编译模式-宽字节编码集)
- 版本信息:php-5.2.17+Apache,版本太高网站会无法显示注入得到的数据
1 |
|
输入payload:1%df%27or 1=1 – +
成功宽字节注入,绕过了模拟预编译模式下的引号转义,使得引号逃逸出来,影响了SQL的语法结构
看看日志
1 |
|
demo-5(有预编译-真正的预编译模式-宽字节编码集)
- 版本信息:php-5.2.17+Apache,版本太高的话网站会无法显示注入得到的数据
1 |
|
输入payload:1%df%27or 1=1 – +
也是成功注入了?什么情况?
看看日志
1 |
|
?真正的预编译模式下的日志居然和模拟预编译模式下的日志一样?
经测试应该是php版本太低了,没有真正进行预编译处理
尝试切换高一点的版本
demo-4(有预编译-模拟预编译模式-宽字节编码集)
版本信息:php-5.3.29-NTS+Apache
输入payload:1%df%27or 1=1 – +
无数据显示,但是日志中其实是成功逃逸出来了,成功绕过了引号转义,只是网站因为php版本太高的原因无法显示注入得到的数据
看看日志
1 |
|
在Navicat都能注入成功
- demo-5(有预编译-真正的预编译模式-宽字节编码集)
- 版本信息:php-5.3.29+Apache,版本太高网站会无法显示注入得到的数据
输入payload:1%df%27or 1=1 – +
注入失败,原理是预编译起了作用
看看日志
1 |
|