Java SpringBoot框架代码审计三 - SQL注入

SQL注入介绍

SQL注入是代审中最容易找的漏洞之一,一般都在固定的模块存放SQL语句,只需在这些SQL语句中搜寻是否拼接参数即可。

案例项目使用Mybatis作为数据持久层框架,进行数据库的各种操作。MyBatis的主要思想是将程序中的大量SQL语句剥离出来,配置在配置文件当中,实现SQL的灵活配置。配置文件常存放在src/main/resources/mapper中,配置文件命名为ExampleMapper.xml

项目的第一个issue中看到已经有人提出SQL注入漏洞,并给出poc: http://127.0.0.1:28089/search?goodsCategoryId=&keyword=\%')) UNION ALL SELECT NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,CONCAT(0x7176627871,IFNULL(CAST(CURRENT_USER() AS CHAR),0x20),0x7162786b71),NULL,NULL#&orderBy=default

SQLi-issue

随后作者对该SQL注入点进行修复,commit中将like模糊查询处的${keyword}改为#{keyword}

fix-sqli-ommit

SQL注入产生原理

为什么将${}改为#{}就能防止SQL注入呢?

MyBatis官方文档中有如下叙述:

#{}告诉 MyBatis 创建一个预编译语句(PreparedStatement)参数,在 JDBC 中,这样的一个参数在 SQL 中会由一个“?”来标识,并被传递到一个新的预处理语句中,就像这样:

1
2
3
4
// 近似的 JDBC 代码,非 MyBatis 代码...
String selectPerson = "SELECT * FROM PERSON WHERE ID=?";
PreparedStatement ps = conn.prepareStatement(selectPerson);
ps.setInt(1,id);

prepareStatement

${} 仅仅是纯粹的 string 替换,在动态 SQL 解析阶段将会进行变量替换,类似于直接替换字符串,会导致SQL注入产生。

官方也给出警示:

用这种方式接受用户的输入,并将其用于语句中的参数是不安全的,会导致潜在的 SQL 注入攻击,因此要么不允许用户输入这些字段,要么自行转义并检验

开发的原则是能使用#{}的地方,一定使用#{}。但是SQL语句中存在无法使用#{}的场景,因为使用#{}会在原本的字段加上引号'',导致SQL语句报错。不能使用#{}的场景我们需要特别注意,此处极易产生SQL注入。

不能使用#{}的场景有:

  1. 表名/字段名
  2. order by/group by
  3. like模糊查询
  4. in

以表名为例,使用#{}替换字符串时会带上单引号 '',这会导致 sql 语法错误,例如:

1
select * from #{tableName} where name = #{name};

预编译之后的sql 变为:

1
select * from ? where name = ?;

假设我们传入的参数为 tableName = “user” , name = “username”,那么在占位符进行变量替换后,sql 语句变为

1
select * from 'user' where name='username';

上述 sql 语句是存在语法错误的,表名不能加单引号 ''(不过反引号 ``是可以的)。

不过表名一般不会通过用户传入,即使是用户传入,由于Mybatis的查询机制,并不会产生SQL注入。

避免使用${}的方法

  1. 表名/字段名

尽量直接使用表名和字段名,如果有动态查询的需求时,将表名和字段名限定在指定字符。

1
tableName = tableName.replaceAll("[^a-zA-Z0-9+]", "");
  1. order by/group by

order by后

修复方法是推荐开发在Java层面做映射,设置一个字段/表名数组,仅允许用户传入索引值。这样保证传入的字段或者表名都在白名单里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
query.append(" ORDER BY ");    
String[] orderByFields = orderByComparator.getOrderByFields();

for (int i = 0; i < orderByFields.length; i++) {
query.append("appSetPersonal.");
query.append(orderByFields[i]);

if (i + 1 < orderByFields.length) {
if (orderByComparator.isAscending() ^ previous) {
query.append(" ASC, ");
} else {
query.append(" DESC, ");
}

} else if (orderByComparator.isAscending() ^ previous) {
query.append(" ASC");
} else {
query.append(" DESC");
}

或者将传入的值限定在指定字符,如

1
orderByField = orderByField.replaceAll("[^a-zA-Z0-9+]", "");
  1. like模糊查询
1
2
3
4
5
6
7
8
mysql: 
select * from goods where goods_name like CONCAT('%',#{param},'%')

oracle:
select * from goods where goods_name like '%'||#{param}||'%'

mssql:
select * from goods where goods_name like '%'+#{param}+'%'
  1. in
1
2
3
4
select * from goods where id in 
<foreach collection="ids" item="item" open="(" separator="," close=")">
#{item}
</foreach>

SQL注入代码审计

知道了SQL注入产生的原理,那么找漏洞就非常简单了。SQL注入是代码审计中最好找的漏洞之一,只需分析SQL语句发现拼接,再逆向追踪拼接参数用户是否可控。不用被代码的多层调用所干扰。

SQL注入审计过程:

  1. 在Dao层(Mybaits在Mapper中,Mybatis也有注解写SQL的方式,但很少用),查看SQL语句是否使用拼接,关注${}
1
2
3
<select id="getUID" parameterType="string" reusltType="User">
select * from user where uid=${uid}
</select>

其他SQL拼接:

1
2
3
4
5
6
7
8
9
10
11
# JDBC中的拼接,关注+:
sqlString.append("select * from user where uid='"+ UID +"'");
# JDBC中的预编译:
sqlString.append("select * from user where uid= ?");

# Hibernate中的拼接:
sql.append("select * from user where uid = '" + UID + "'");
List result = session.createQuery(queryString).list();
# Hibernate中的预编译
sql.append("select * from user where uid = :UID");
paramters.setString("UID", UID);
  1. 若存在拼接参数,则逆向追踪拼接的参数传入过程,逆向追踪参数的路径大致为Mapper -> Dao -> ServiceImpl -> Controller

⚠️ 并不是全部的${}拼接都会产生漏洞的,有以下几种情况是不存在SQL注入的:

  1. param不是用户传参进来的
  2. param不是字符类型,比如说parameter为int类型,只能传入数字,就没法产生SQL注入
  3. param在过程中已经转义或过滤字符,但是在Mybaits的SQL语句中看不出来,需考虑是否能绕过

参数追踪

以文章开头的SQL注入为例,来进行keyword参数逆向追踪过程

/src/main/resources/mapper/NewBeeMallGoodsMapper.xml:70,94存在${}拼接

sqli-in-like

可以看到在like后面使用了concat拼接,这是因为mapper.xml是使用mybatis-generator自动生成的,产生的like语句和in语句默认使用#{},但这里使用${}拼接字符可能是由于作者修改功能时更改,导致SQL注入漏洞产生。

同文件第三行,可以看到namespace为ltd.newbee.mall.dao.NewBeeMallGoodsMapper

/src/main/resources/mapper/NewBeeMallGoodsMapper.xml:3

mapper-namespace

找到ltd.newbee.mall.dao.NewBeeMallGoodsMapper,根据xml中的select id找到findNewBeeMallGoodsListfindNewBeeMallGoodsListBySearch这两个方法。

/src/main/java/ltd/newbee/mall/dao/NewBeeMallGoodsMapper.java

NewBeeMallGoodsMapper

查看findNewBeeMallGoodsListBySearch方法的引用,追踪该处引用(Sublime将鼠标放在方法上可直接查看引用,IntelliJ IDEA可右键“Find Usages”或Option/Alt+F7查看引用)

find-usages

追踪到searchNewBeeMallGoods方法,这里是Service层,主要负责业务模块逻辑处理。Service层中有两种类,一是Service,用来声明接口;二是ServiceImpl,作为实现类实现接口中的方法。当前类NewBeeMallGoodsServiceImpl中的Impl就是implement(实现)中的impl。

由于Service中都是接口,审计时一般直接查看ServiceImpl,忽视Service

/src/main/java/ltd/newbee/mall/service/impl/NewBeeMallGoodsServiceImpl.java:73

NewBeeMallGoodsServiceImpl

再追踪searchNewBeeMallGoods方法引用,来到searchPage方法。这里是Controller层,负责业务模块流程的控制,获取用户传来的参数后调用Service层的接口来控制业务流程。

SpringBoot使用注解来控制URL路径,searchNewBeeMallGoods使用@GetMapping({"/search", "/search.html"})表明接收来自/search/search.html的get请求。

/src/main/java/ltd/newbee/mall/controller/mall/GoodsController.java:57

GoodsController

从48行可以看到,keyword为字符串类型,可以传入任意字符。51行将params.get("keyword")中的值赋给keyword变量,仅做了非空判断。

其中if判断注释写着“去掉空格”,并不是将keyword参数中的空格去掉,而是去掉空格之后进行非空判断,不用考虑SQL注入绕过空格的方法。

1
2
//对keyword做过滤 去掉空格
if (params.containsKey("keyword") && !StringUtils.isEmpty((params.get("keyword") + "").trim()))

整个参数追踪就到这,还有一个需要注意的地方就是看看应用中是否存在过滤器,过滤器是否会将特殊字符拦截。该应用没有针对SQL注入的过滤器,所以追踪完参数,可以确定该处SQL拼接存在注入漏洞。

总结

对于SQL注入的审计,在Mapper中搜寻是否存在${}拼接的情况,尤其注意order by、group by、like、in。找到拼接后再逆向追踪参数,判断参数是否可控,是否是字符类型,检查是否存在过滤器过滤SQL字符。

参考链接

https://mybatis.org/mybatis-3/zh/sqlmap-xml.html#select

https://segmentfault.com/a/1190000004617028

https://c0d3p1ut0s.github.io/MyBatis%E6%A1%86%E6%9E%B6%E4%B8%AD%E5%B8%B8%E8%A7%81%E7%9A%84SQL%E6%B3%A8%E5%85%A5/