特殊的注入

二次注入

二次注入本质上是信任边界不清晰的问题,最容易发生在靠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自行转义的,那么是否可能存在漏洞呢?是的,根据前文所介绍的内容,我们很容易就可以联想到宽字节注入。其实,在PHP 5.3.6之前,PDO确实存在宽字节注入的问题。为了方便同学们尝试,在这里放出较为完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
<?php
$pdo = newPDO("mysql:host=127.0.0.1;dbname=nowcoder;charset=gbk","root","123456
");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->query('SET NAMES GBK');
$var = $_GET['name'];
$query = "SELECT * FROM nowcoder WHERE name = ?";
$stmt = $pdo->prepare($query);
$stmt->execute(array($var));
$r = $stmt->fetch();
print_r($r);
?>

此时只需在地址栏中输入:

1
http://localhost/pdo.php?name=%bf%27%20UNION%20SELECT%20user(),version()--%20a

页面上就会暴露出当前数据库用户名与数据库版本(由于使用了联合查询,要求前后SELECT的列数相同,这里的nowcoder表***存在两列),成功实现了宽字节注入。此代码在LNMP、MySQL 5.5.62、PHP 5.2.17下实测成功。由于前文详细讲述了宽字节注入的原理及细节,在这里就不进行细讲了,有兴趣的同学可以找《PDO防注入原理分析以及使用PDO的注意事项》这篇文章来看。虽然这是PDO一个巨大的破绽,但其条件较为苛刻,仍使用PHP低版本且使用GBK作为数据库连接层字符集的场景应该已经不多了。

PHP的预编译内藏乾坤,那么Java的会不会也存在这样的问题呢?同样做个实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String JDBC_DRIVER = "com.mysql.jdbc.Driver";
String DB_URL = "jdbc:mysql://localhost:3306/test";
try{
    Class.forName(JDBC_DRIVER);
    Connection conn = DriverManager.getConnection(DB_URL,USER,PASS);
    String sql = "SELECT * FROM user WHERE id = ?";
    String Parameter = "1'";
    PreparedStatement ps = conn.prepareStatement(sql);
    ps.setString(1, Parameter);
    ps.executeQuery();
} catch(ClassNotFoundException e) {
    e.printStackTrace();
} catch(SQLException e) {
    e.printStackTrace();
}

截取部分日志如下:

1
2
3
4
5
6
7
8
Id   Command    Argument
------------------------
3479Connect    root@localhoston test using TCP/IP
3479Query    SHOW COLLATION
3479Query    SET NAMES latin1
3479Query    SET character_set_results = NULL
3479Query    SET autocommit=1
3479Execute    SELECT * FROM user WHERE id = '1\''

可以发现,使用了PrepareStatement却并未出现预编译的日志!原来,Java中也是默认由其本身进行转义后直接运行无危害语句的,若想使用真正的预编译,必须在连接数据库时加上useServerPrepStmts=true。如下,修改数据库链接字符串:

再次执行,截取部分日志如下:

1
2
3
4
5
6
7
8
9
Id   Command    Argument
------------------------
3480Connect    root@localhoston test using TCP/IP
3480Query    SHOW COLLATION
3480Query    SET NAMES latin1
3480Query    SET character_set_results = NULL
3480Query    SET autocommit=1
3480Prepare    SELECT * FROM user WHERE id = ?
3480Execute    SELECT * FROM user WHERE id = '1\''

可以看到日志中第8行已进行预编译。

了解到PHP与Java中关于预编译的一些知识后,我们来揭晓预编译可以防止SQL注入的原因。网上对此的回答大多都停留在预编译会将传入的数据只当做数据,而不当做SQL语句的层面,非常的浅显。为什么预编译会将数据只当成数据?它是如何进行时别的呢?事实是,它不需要进行识别。

通常来说,一条SQL语句从传入到运行经历了生成语法树、执行计划优化、执行这几个阶段。在预编译过程中,数据库首先接收到带有预编译占位符?的SQL语句,解析生成语法树(Lex),并缓存在cache中,然后接收对应的参数信息,从cache中取出语法树设置参数,然后再进行优化和执行。由于参数信息传入前语法树就已生成,执行的语法结构也就无法因参数而改变,自然也就杜绝了SQL注入的出现。这样一个深刻而简单的原因,相信已经解答了我们最开始的疑问。

漫谈ORM

说起ORM框架,最容易使人联想到的便是Spring Data JPA、Hibernate和Mybatis这些Java类库,其中坑比较多的是Mybatis。Mybatis在Java白盒代码审计中有一个著名的漏洞,就是在XML Mapper中使用绑定变量会导致SQL注入,因为SQL的底层处理是直接拼接,而#的底层处理才是预编译参数绑定。那么这些开发者为什么不全用#反而使用$?难道他们傻吗?真正的原因是,使用#绑定的变量在很多场景是不生效的。下面我们就来对此进行详细分析。

写过Mybatis插件(***)的开发者知道,Mybatis会将XML Mapper中的绑定符转换为真正的预编译占位符,并将变量依次与其绑定,如:

1
2
3
4
<select id="getUsers"resultType="User">
    SELECT * FROM nowcoder
    WHERE id=${id}
</select>

Mybatis会处理为:

1
SELECT * FROM nowcoder WHERE id = ?

真正的参数值储存在Parameters中,并且会提供一个名为ParameterMappings的Array提供占位符们与Parameters中值的绑定关系。假设有这样一个Mapper:

1
2
3
4
<select id="getUsers"resultType="User">
    SELECT * FROM nowcoder
    WHERE name LIKE '%#{name}%'
</select>

Mybatis便会处理为:

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

然而这样的预编译方式是错误的,mysql-connector会爆一个找不到占位符的错误。通过阅读mysql-connector处理预编译语句的源码,我们得知其会逐个字符检索占位符?,同时会计算字符是在引号内还是引号外,引号内的?是不算做占位符的。mysql-connector的用意很明显,就是为了避免字符串中正常的?被解析为占位符,这也导致了LIKE后无法正常预编译的结果。此时很多开发者会使用拼接的方式来解决这个问题,对应到Mybatis中也就变成了使用$。

真正的LIKE预编译可以这么写:

1
SELECT * FROM nowcoder WHERE name LIKE CONCAT('%', ?, '%')

这样占位符?在引号外,自然也就被成功检测到了。对应到Mybatis中,便是:

1
2
3
4
<select id="getUsers"resultType="User">
    SELECT * FROM nowcoder
    WHERE name LIKE CONCAT('%', #{name}, '%')
</select>

与此相同的还有IN语句,MySQL中不存在这样的预编译语法:

1
SELECT * FROM nowcoder WHERE id IN ?

因此,也就不能使用类似于IN #{ids}的Mapper。正确的IN语句预编译如下:

1
SELECT * FROM nowcoder WHERE id IN (?, ?, ?, ?)

首先要知道IN后的参数中有多少个元素,然后在语句中写入相同个数的占位符?,这一点在直接使用PreparedStatement时非常繁琐,需要编写额外的逻辑获取中元素个数后拼接占位符,Mybatis对此提供了foreach标签:

1
2
3
4
5
6
7
<select id="getUsers"resultType="User">
    SELECT * FROM nowcoder
    WHERE id IN
    <foreach collection="ids"index="index"item="id"separator=","open="("close=")">
        #{id}
    </foreach>
</select>

另外,表名与列名是不能被预编译的,这是由于在预编译生成语法树的过程中,预处理器在检查解析后的语法树时,会确定数据表和数据列是否存在,此两者必须为具体值,不能被占位符?所替代,这就导致了ORDER BY、GROUP BY后同样不能使用#,只能使用$。而对于此场景防止SQL注入,不建议使用过滤或转义手段,而是将表名、列名定义为常量,当用户输入匹配某一常量时,再将此常量传入SQL语句中,否则就使用默认值。

上述情况中,LIKE/IN的场景下一般写***失效,而表名/列名动态传入的场景下没有#的对应写法,导致开发者使用$,这是安全从业者和开发者共同需要注意的地方。

预编译的破绽

上个章节解释了LIKE '%?%'不生效的原因,这样的常规预编译不生效会带来很多拼接。那么由Mybatis推广开来,往往预编译不容易办到或办不到的场景,在日常渗透测试与代码审计中更应引起我们的关注。总结如下:

  1. 白盒审计中PDO、PreparedStatement中开发者直接拼接SQL语句的行为,很多开发者以为使用了安全的类库就保证了安全,殊不知错误的用法仍会导致漏洞。
  2. 白盒审计中ORDER BY后的表名动态传入的SQL语句;渗透测试中允许用户传入按某个字段进行排序的行为,这很有可能是直接拼接的。
  3. 白盒审计中ORDER BY后排序方式(ASC/DESC)动态传入的SQL语句;渗透测试中允许用户选择正序倒序排列的行为,需要抓包查看是否直接传入ASC/DESC,若是则很有可能存在拼接行为。
  4. 白盒审计中模糊查询是否拼接;渗透测试中针对搜索行为进行SQL注入测试。
  5. 白盒审计中IN语句后是否拼接。

这样有针对性的进行试探和检查,能更有效的帮助我们找到漏洞。

结语

本篇文章深刻剖析了SQL注入的原理,介绍了几个较为实用的绕过WAF的方式,最重要的是,对预编译的底层及攻防进行了详细介绍,这是目前常见资料中较为少见的。笔者可以负责任的说,虽然SQL注入已经在各个方面得到了有效控制,但在企业内网,注入问题仍然层出不穷。对于注入的透彻理解,将会非常有助于在安全面试中脱颖而出。本系列将会致力于这一点,在深度和广度上对一个大厂安全工程师应掌握的安全知识进行详细介绍。

*本文作者为牛油百度实习内推,已获得展示授权