特殊的注入

二次注入

二次注入本质上是信任边界不清晰的问题,最容易发生在靠WAF苟活的祖传代码上。一般来说,WAF拦截的是用户输入,但如果攻击来自于数据库呢?很多开发者并没有意识到,从数据库中读取到的数据其实也是处于信任边界之外的,如果代码本身对于SQL注入攻击没有应对措施,很容易被二次注入。

如更新用户名的一个场景:

1
2
3
$username = $_POST['username'];
$id = $_SESSION['id'];
mysql_query("UPDATE nowcoder SET uname = '$username' WHERE id = $id");

如果整个Web应用外没有WAF进行防御,这将是一个典型的UPDATE报错注入。当黑客将用户名设为' or updatexml(1,concat(0x7e,(user())),0) or ',所执行的SQL语句就会变为:

1
UPDATE nowcoder SET uname = ''or updatexml(1,concat(0x7e,(user())),0) or ''WHERE id = 1

从而在报错提示中爆出当前数据库用户名:

1
ERROR 1105(HY000): XPATH syntax error: '~root@localhost'

当Web应用外层有WAF存在时,会检查用户输入,为了兼顾用户体验,不做过滤或拦截处理,而是转义引号,执行的SQL语句变为:

1
UPDATE nowcoder SET uname = '\' or updatexml(1,concat(0x7e,(user())),0) or \''WHERE id = 1

可以看到黑客传入的攻击Payload已失效,'被转义为'后已经单纯变为一个单引号字符,不再具有闭合其他单引号的功能,于是整个Payload完全变为了一个字符串。若此时有另外一处获取用户写过的文章标题的功能由于需要跨表查询用到了这个用户名:

1
2
3
4
5
$id = $_SESSION['id'];
//嵌套查询
mysql_query("SELECT title FROM passage WHERE uname = (SELECT uname FROM nowcoder WHERE id = $id)");
//或是使用JOIN
mysql_query("SELECT title FROM passage JOIN nowcoder WHERE passage.uname = nowcoder.uname AND nowcoder.id = $id");

以上的代码都是没问题的,但这里仅仅是举个简单易懂的例子,实际情况中,可能这个SQL非常复杂,可能是受一些场景限制,导致程序员不得不先将uname的值从nowcoder中取出来,再丢进passage表中查询:

1
2
3
4
5
$id = $_SESSION['id'];
$query = mysql_query("SELECT uname FROM nowcoder WHERE id = $id");
$res = mysql_fetch_assoc($query);
$uname = $res['uname'];
mysql_query("SELECT title FROM passage WHERE uname = '$uname'");

此时第4行中$uname的值为刚刚存入的' or updatexml(1,concat(0x7e,(user())),0) or ',那个具有闭合功能的单引号又回来了!
图片说明
于是最后执行:

1
SELECT title FROM passage WHERE uname = ''or updatexml(1,concat(0x7e,(user())),0) or '';

成功绕过WAF爆出当前用户名。

宽字节注入

宽字节在如今UTF-8编码盛行的时代已经很少露面了,但宽字节注入却是安全工程师校招面试中面试官最爱问的问题之一,在我所经历的几十场面试中,有60%以上涉及到了对宽字节注入的理解。

宽字节注入本质上是多字节编码的问题,很多PHP程序喜欢使用addslashes对用户输入进行转义,如果用户输入中存在',则会被转义为',失去闭合其他引号的功能。但反斜杠也是可以被转义的,若可以在'前添加一个反斜杠,使其变为\',第二个反斜杠便会因为被转义而失去了转义引号的功能,使得引号逃逸,重新拥有了闭合其他引号的能力,因此,吃掉转义引号的反斜杠是宽字节注入的根本
最常规的宽字节注入场景便是当使用addslashes防御SQL注入,且Mysql数据库的连接层编码被设为GBK的时候:

1
2
3
mysql_query("SET NAMES 'gbk'");
$id = addslashes($_GET['id']);
mysql_query("SELECT * FROM nowcoder WHERE id = $id");

黑客在地址栏传入%df%27,其中%27是'的URL编码。当addslashes侦测到引号时,进行转义,添加一个反斜杠(%5c),变为%df%5c%27。而Mysql在解析SQL语句时使用了多字节的GBK编码,当首个字节(高位)的ASCII码大于128时,就被认为是一个汉字,每个汉字占两字节,而%df正好符合这一要求。于是%df%5c%27被解析为%df%5c%27,即運',成功吃掉转义引号的反斜杠,造成单引号逃逸。
图片说明
这里并没有使用上面反斜杠转义反斜杠的方法,而是直接使引号前的反斜杠成为多字节编码中汉字的低位,从而达到吃掉反斜杠的目的。想要通过这种方式造成宽字节注入,必须保证数据库连接层使用多字节编码解析SQL语句,还要保证这种编码的字符集中存在低位是0x5C的字符(0X5C是反斜杠)。GB2312与UTF8虽然是多字节编码,但它们都不存在低位为0X5C的字符,也就不能造成宽字节注入。

那么,将Mysql连接层编码设为UTF-8就可以完全避免宽字节注入了吗?看下面这个例子:

1
2
3
mysql_query("SET NAMES 'UTF-8'"); 
$id =iconv("GBK", "UTF-8", addslashes($_GET['id']));
mysql_query("SELECT * FROM nowcoder WHERE id = $id");

这里Mysql连接层使用了UTF-8编码,为了避免乱码,使用iconv函数将用户提交的GBK字符经addslashes过滤后,先转为UTF-8,再拼入SQL语句,此时黑客只需在地址栏传入%e5%5c%27,即可再次使单引号逃逸,过成如下:
图片说明
真正防御宽字节注入的方法,是在设置Mysql连接层编码为UTF-8的同时,使用mysql_real_escape_string函数来替代addslashes函数过滤用户输入,两者的不同之处在于mysql_real_escape_string会考虑当前Mysql连接层编码的字符集,避免出现宽字节注入的情况。

预编译时代的注入

预编译底层原理

安全面试中经常会有这样的问题:如何防御SQL注入?除了之前所列举的WAF、过滤转义等方式,预编译参数化查询才是最好的防御方式。

如今已经是预编译时代,上述手动用户输入的防SQL注入方式普遍存在于老代码中,新项目通常使用参数绑定的方式来处理SQL语句,如PHP的PDO,Java的PreparedStatement,或使用一些诸如Spring Data JPA、Hibernate、Mybatis的ORM框架。通俗的讲,预编译防止SQL注入的原理是提前编译SQL语句,将所有的用户输入都当做『数据』,而非『语法』,相信这也是大多数人所知晓的。一个面试官曾在面试中提出这样的问题:为什么预编译能让传入的数据只能是数据,它的底层原理是怎样的?

我们先从PHP的PDO说起。PDO针对预编译提供了两种模式:本地预编译和模拟预编译。模拟预编译并不是真正的预编译,它为了兼容一些不支持预编译的数据库,由PDO对用户输入转义后,拼接到SQL语句中,再将完整的语句交由数据库执行。这种行为可以理解为是新瓶装旧酒,它的预编译由PDO完成而非MySQL,并且这是PDO的默认模式

执行以下PHP代码:

1
2
3
$stmt = $PDO -> prepare("SELECT * FROM nowcoder WHERE id = ?");
$stmt -> bindParam(1, $_GET['id']);
$stmt -> execute();

开启MySQL日志功能,并抓取SQL执行日志。

1
2
3
4
5
Id   Command    Argument
------------------------
4170Connect    root@localhoston nowcoder
4170Query    SELECT * FROM nowcoder WHERE id = '1\''
4170Quit

如日志所示,数据库内部处理的整个过程只有连接、查询、退出三个操作,并没有预编译的过程。让我们开启本地预编译模式:

1
2
3
4
$PDO -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$stmt = $PDO -> prepare("SELECT * FROM nowcoder WHERE id = ?");
$stmt -> bindParam(1, $_GET['id']);
$stmt -> execute();

执行后再次查看日志:

1
2
3
4
5
6
7
Id   Command    Argument
------------------------
4171Connect    root@localhoston nowcoder
4171Prepare    SELECT * FROM nowcoder WHERE id = ?
4171Execute    SELECT * FROM nowcoder WHERE id = '1\''
4171Close stmt   
4171Quit

如日志所示,整个流程分为五步,分别为连接、预编译、传入参数并执行、关闭预编译语句、退出。Java也面临同样的问题,虽然PreparedStatement名义上是SQL注入,但如果不深入了解,一般开发者并不会知晓其实所谓的预编译默认都是模拟预编译,即由PreparedStatement亲自将SQL语句转义为无危害的语句后,直接交由数据库执行。

那么既然默认情况下的模拟预编译是由PDO自行转义的,那么是否可能存在漏洞呢