目录
前言
模拟预编译
真正的预编译
预编译中存在的SQL注入
宽字节
没有进行参数绑定
无法预编译的位置
前言
相信学习过SQL注入的小伙伴都知道防御SQL注入最好的方法,就是使用预编译也就是PDO是可以非常好的防御SQL注入的,但是如果错误的设置了PDO,即使是预编译也存在注入的可能,那么下面我会参考大佬的文章来学习+复现一下如何正确的使用预编译防御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下的注入可能(其实这种思路不同语言是共通的)
模拟预编译
后端页面代码:
<?php
$username = $_POST['username']; // 接收username
# 建立数据库连接
header("Content-Type:text/html;charset=utf-8");
$dbs = "mysql:host=127.0.0.1;dbname=sort";
$dbname = "root";
$passwd = "root";
// 创建连接,选择数据库,检测连接
try{
$conn = new PDO($dbs, $dbname, $passwd);
echo "连接成功<br/>";
}
catch (PDOException $e){
die ("Error!: " . $e->getMessage() . "<br/>");
}
# 设置预编译语句,绑定参数,这里使用命名占位符
$stmt = $conn->prepare("select fraction from fraction where name = :username");
$stmt->bindParam(":username",$username);
$stmt->execute();
if($fraction = $stmt->fetch(PDO::FETCH_ASSOC)){
echo '查询成功';
echo '<br/>';
echo '学生:'.$username;
echo '<br/>';
# echo '分数:'.$fraction;
print_r("分数".$fraction[fraction]);
}
else{
}
$conn=null; # 关闭链接
?>
执行查询name="mechoy"
,查看数据库日志:
27 Connect root@localhost on sort using TCP/IP # 建立连接
27 Query select fraction from fraction where name = 'mechoy' # 执行查询
27 Quit # 结束
从日志来看,没有prepare和execute,只是执行了一个查询的SQL语句,并没有进行预编译。
显然,PDO默认情况下使用的是模拟预编译。
模拟预编译是防止某些数据库不支持预编译而设置的(如sqllite与低版本MySQL)。
如果模拟预处理开启,那么客户端程序内部会模拟MySQL数据库中的参数绑定这一过程。
也就是说,程序会在内部模拟prepare的过程,当执行execute时,再将拼接后的完整SQL语句发送给MySQL数据库执行。
而想要真正使用预编译,首先需要数据库支持预编译,再在代码中加入
$conn -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
# bool PDO::setAttribute ( int $attribute , mixed $value ) 设置数据库句柄属性。
# PDO::ATTR_EMULATE_PREPARES 启用或禁用预处理语句的模拟。 有些驱动不支持或有限度地支持本地预处理。使用此设置强制PDO总是模拟预处理语句(如果为 TRUE ),或试着使用本地预处理语句(如果为 FALSE )。如果驱动不能成功预处理当前查询,它将总是回到模拟预处理语句上。需要 bool 类型。
#这里在PHP5.2.17时无效,暂未找到原因
#更改版本为PHP5.6.9时生效
再执行查询name="mechoy"
,查看数据库日志:
4 Connect root@localhost on sort using TCP/IP
4 Prepare select fraction from fraction where name = ?
4 Execute select fraction from fraction where name = 'mechoy'
4 Close stmt
4 Quit
# 可以看到当PDO::ATTR_EMULATE_PREPARES设置为false时,取消了模拟预处理,采用本地预处理
也可以使用下列这种方式:
后端代码:
<?php
$username = $_POST['username'];
$db = new PDO("mysql:host=localhost;dbname=test", "root", "root");
$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;
?>
然后我们使用Firefox浏览器POST方式提交一个数据:
不出意外的查出了值,我们去日志看看预编译对我们传入的值做了什么处理:
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= '\'admin\''
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= 'admin\' union select database()#'
2023-10-22T15:34:50.357303Z 11 Quit
那为什么开发者要做一个虚假的预编译呢,那是因为一个参数——PDO::ATTR_EMULATE_PREPARES,这个选项用来配置PDO是否使用模拟预编译,默认是true,因此默认情况下PDO采用的是模拟预编译模式,设置成false以后,才会使用真正的预编译。
开启这个选项主要是用来兼容部分不支持预编译的数据库(如sqllite与低版本MySQL),对于模拟预编译,会由客户端程序内部参数绑定这一过程(而不是数据库),内部prepare之后再将拼接的sql语句发给数据库执行。
真正的预编译
使用下列代码就是使用的真正的预编译了
<?php
$username = $_POST['username'];
$db = new PDO("mysql:host=localhost;dbname=test", "root", "root");
$db -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$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;
?>
我们再次使用上面的那种方式来使用POST查询一下:
可以看到这里的执行结果是和上面的模拟预编译的结果是一样的,那么再来看看日志:
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= 'admin'
这时数据库中执行的顺序变成了:先连接,然后准备语句,用问号?占位,接着用输入替换问号?执行语句,专业点的说法叫做:
-
建立连接;
-
构建语法树;
-
执行
这也是为什么我们之前说的,预编译的作用是让整个语句的功能已经提前定死,消除了sql语句的歧义。
当我们输入username= ‘admin’同样会没有任何输出
那么再来看看日志:
我们看一下数据库的日志:
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= '\'admin\''
这时我们再输入注入语句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= 'admin\' 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语句了呢
后端代码如果为:
<?php
$username = $_POST['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 test where username= :username");
$stmt->bindParam(':username', $username);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
var_dump($result);
$db = null;
?>
然后我们输入一下内容就可以利用宽字节绕过预处理实现注入
username=admin%df%27%20union%20select%20database();#
通过抓包可以看到这里确实将转义字符闭合为一个特殊字符了
这个语句在navicat里是能正常执行的,但我并没有在网页上获得输出,是因为预编译只会输出第一条语句,因此后面union的执行结果无法输出
这里如果使用真预编译就不会出现上面的这种问题
因此相比于模拟预编译,真编译的安全性大的多,现在可能的几种针对预编译的注入方法也都是在模拟预编译下实现的。
没有进行参数绑定
没有参数绑定的预编译等于没有预编译,无论是真编译还是模拟预编译,没有参数绑定等于没编译,并且由于DPO默认支持堆叠注入,我们可以通过堆叠注入先插入值然后查询插入的值获取输出结果。
后端代码:
<?php
$id = $_POST['id'];
$dbs = "mysql:host=localhost;dbname=test";
$dbname = "root";
$passwd = "root";
$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; # 关闭链接
?>
可以看到代码中的id没有进行参数绑定
那么我们可以尝试POST提交一下使用堆叠注入来将要查询的东西插入到user表中的两个字段中,然后在进行查询:
然后再POST传入的值:
然后访问该id:
就可以看到对应插入的值了
无法预编译的位置
上面提到过,order by的后面是没法预编译的,因此遇到可控排序功能一般一注一个准,下面我们来通过日志研究一下这到底是为什么
后端代码:
<?php
$col = $_POST['col'];
$dbs = "mysql:host=localhost;dbname=test";
$dbname = "root";
$passwd = "root";
$conn = new PDO($dbs, $dbname, $passwd);
$conn -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
# 预处理语句(这里会自动加上单引号)
$stmt = $conn->prepare("SELECT * FROM user 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注入,一般都能成功。
总而言之就一个思路,不能加引号的位置就不能预编译。
这里我们就可以看出预编译很明显的缺陷,当然,我们也不能错怪预编译的设计者们,因为这玩意儿本来设计之初就不是给你防注入,是用来在大批量查询时减少语法树构造的,因此出现差错也是可以理解的,当然这种差错就给了黑客可乘之机。
参考链接:
奇安信攻防社区-SQL注入&预编译 (butian.net)
预编译与sql注入 – fushulingのblog