Java OWASP 中的 SQL 注入代码审计
Drunkbaby Lv6

Java SQL 注入

Java 中的 SQL 注入代码审计

代码审计基于这个项目 https://github.com/JoyChou93/java-sec-code/

0x01 前言

出于一些个人原因,决定回过头来再过一遍 Java 基础的代码审计

也不说那么多没什么营养的话了,直接开始正文。

0x02 由于 jdbc 拼接不当导致的 SQL 注入

jdbc 存在两种方法执行 SQL 语句,分别为 PreparedStatement 和 Statement。

普通的 Statement 的语句一般会这样写:

1
2
3
4
5
String checkUserQuery = "select userid from sql_challenge_users where userid = '" + username_reg + "'";  

Statement statement = connection.createStatement();

ResultSet resultSet = statement.executeQuery(checkUserQuery);

Statement 会直接拼接 SQL 语句,注意!,99.99999% 的 Statement 直接拼接,都会导致 SQL 注入。

相比 Statement,还有一种执行 SQL 语句的方式是 PreparedStatement,它会对 SQL 语句进行预编译,一般长这样。

1
2
3
4
5
6
var statement = connection.prepareStatement("select password from sql_challenge_users where userid = ? and password = ?"); 

statement.setString(1, username_login);
statement.setString(2, password_login);

var resultSet = statement.executeQuery();

Statement SQL 注入

我们下个断点在 SQLI 这里,66 行,然后访问 /sqli/jdbc/vuln?username=123,看一下发生了什么。

因为执行查询是我们的 SQL 语句,现在的 sql 变量的值是 ————
select * from users where username = '123'

攻击方式的话,用万能密码攻击即可。

原理就不说了,太基础了,就是 SQL 语句拼接而已。

攻击的话:payload 如下

1
username=123' or '1'='1

Statement SQL 注入的修复手段:预编译

后面 /jdbc/sec 接口,就是修好的代码,我们不妨也打断点看看它的工作流程。

payload 直接用 admin' or '1'='1

这个时候的 sql 语句始终如一都是 select * from users where username = ?;那么预编译到底是怎么实现的呢?我们的 username 是何时放进去呗查询的呢?

不妨再深入一点跟进看一下。跟进 executeQuery()

进来之后,第 762 行,看着像是 sql 语句的查询,跟进去 getOriginalSql() 方法看看。

得到的结果是屁都没有,继续往下,768 行,是 executeInternal() 方法,一般 Internal 结尾的方法都是一些内部处理的方法,跟进。

这个 executeInternal() 方法主要做了两件事:第一件事:返回查询结果,第二件事:返回查询时间。如图

这些信息都包含在返回的 result 里面。

所以我们能够很清楚的看到,在预编译当中,输入和 SQL 语句是完完全全分开的

总结 JDBC 易产生漏洞点

未使用占位符

  • PreparedStatement 只有在使用”?”作为占位符才能预防sql注入,直接拼接仍会存在sql注入漏洞

使用 in 语句

删除语句中可能会存在此类语句,由于无法确定delIds含有对象个数而直接拼接sql语句,造成sql注入。

1
String sql = "delete from users where id in("+delIds+"); //存在sql注入
  • 解决方法为遍历传入的 对象个数,使用“?”占位符。

使用 like 语句

  • 使用like语句直接拼接会造成sql注入
1
String sql = "select * from users where password like '%" + con + "%'"; //存在sql注入

%和_

  • 没有手动过滤 %

预编译是不能处理这个符号的, 所以需要手动过滤,否则会造成慢查询,造成 dos。

Order by、from 等关键字无法预编译

通过上面对使用 in 关键字和 like 关键字发现,只需要对要传参的位置使用占位符进行预编译时似乎就可以完全防止 SQL 注入,然而事实并非如此,当使用 order by 语句时是无法使用预编译的,原因是 order by 子句后面需要加字段名或者字段位置,而字段名是不能带引号的,否则就会被认为是一个字符串而不是字段名,简单来说就是会报错。

然而使用 PreapareStatement 将会强制给参数加上’,所以,在使用 order by 语句时就必须得使用拼接的 Statement,所以就会造成 SQL 注入,这里其实算是 SQL 的原生问题了;

解决问题的话需要进行手动过滤。

1
String sql = "Select * from news where title =?" + "order by '" + time + "' asc"

0x03 Mybatis 下的 SQL 注入

我们的 SQL 语句一般是写在 Mapper 里面的,正常的应该是 Controller 层调 Service 层调 pojo 层,SQL 语句是写在 Mapper 文件里面的。所以如果是从代码审计的角度来看的话,我们可以直接来看 Mapper 层的代码。

mybatis 下的 SQL 注入

mybatis 下的 SQL 注入主要是这一种情况:${Parameter}

  • ${Parameter}

有类似的一些 CMS 0day 漏洞,比如 RuoYi <= 4.6.1 的 SQL 注入漏洞。就是这样导致的

对应的这个项目当中,有问题的 SQL 语句如下

1
2
3
4
5
6
7
8
9
//Mybatis 
@Select("select * from users where username = '${username}'")
List<User> findByUserNameVuln01(@Param("username") String username);

//Mybatis
@GetMapping("/mybatis/vuln01")
public List<User> mybatisVuln01(@RequestParam("username") String username) {
return userMapper.findByUserNameVuln01(username);
}

这里是存在漏洞的,因为 ${username} 的方式就是直接拼接,和之前的 jdbc 是一样的。

payload 如下

1
admin' or '1'='1

这里这道题目,怎么说呢;和我们平常见到的 mybatis 语句很不一样,我们平常的 SQL 语句都是写在 xxxMapper.xml 里面的,但是这个里面不是这样的。

测试一下

mybatis 下的 SQL 注入防护:预编译

  • 这种 SQL 注入的防护 ———— 预编译,其实是这一种方式 #{Parameter}

代码如下

1
2
3
4
@GetMapping("/mybatis/sec01")
public User mybatisSec01(@RequestParam("username") String username) {
return userMapper.findByUserName(username);
}

Mapper 层代码

1
2
3
//Mybatis 
@Select("select * from users where username = #{username}")
User findByUserName(@Param("username") String username);

这样就防住了

MyBatis易产生SQL注入的三种情况

like 关键字的模糊查询

在这种情况下使用 #{} 程序会报错,新手程序员就把 #号 改成了 $,这就照样导致了拼接的问题了。

  • 源码如下
1
2
3
<select id="findByUserNameVuln02" parameterType="String" resultMap="User">
select * from users where username like '%${_parameter}%'
</select>

正确写法如下:

1
2
3
<select id="findByUserNamesec" parameterType="String" resultMap="User">
select * from users where username like concat('%',#{_parameter}, '%')
</select>

正确写法:

1
2
3
4
5
6
mysql:
select * from users where username like concat('%',#{username},'%')
oracle:
select * from users where username like '%'||#{username}||'%'
sqlserver:
select * from users where username like '%'+#{username}+'%'

使用 in 语句

使用in语句时直接使用 #{} 会报错,可能会存在使用 ${} 直接拼接,造成sql注入

这个项目里面并没有这一模块,所以需要自行添加,XML 和 接口编写如下

1
2
3
<select id="findByUserNameVuln04" parameterType="String" resultMap="User">
select * from users where id in (${id})
</select>
1
2
3
4
5
// http://localhost:8080/sqli/mybatis/vuln04?id=1)%20or%201=1%23
@GetMapping("/mybatis/vuln04")
public List<User> mybatisVuln04(@RequestParam("id") String id) {
return userMapper.findByUserNameVuln04(id);
}

测试成功!

这里,当时 RuoYi 的漏洞就是这一种,in 语句使用 ${} 的拼接

正确用法为使用 foreach,而不是将#替换为$

这里前面的写法和 Vuln04 差不多,需要我们修改 userMapper.xml 的内容。mybatis 为了防止这种现象的 SQL 注入,

定义接口:

1
2
3
4
@GetMapping("/mybatis/sec04")  
public List<User> mybatisSec04(@RequestParam("id") List id){
return userMapper.findByIdSec04(id);
}

写个 Mapper

1
List<User> findByIdSec04(@Param("id") List id);

接着是 UserMapper.xml

1
2
3
4
5
6
<select id="findByIdSec04" parameterType="String" resultMap="User">  
SELECT
* from users WHERE id IN <foreach collection="id" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</select>

如此便可以成功防护啦!

原理上,会把每一个查找的字符都进行分割,我们正确的输入可以是 id=1,2,3,4;当然,这里就涉及到其他的逻辑问题了。

使用 order by 语句

和JDBC同理,使用 #{} 方式传参会导致order by语句失效,所以使用order by语句的时候还是需要做好过滤

在项目里面说的挺明白的,加了个 Filter,我这里就不再赘述了。

关于 mybatis 中的运行原理

https://www.cnblogs.com/cxuanBlog/p/12248536.html;
cy 一下,有空再过一遍。

0x04 Mybatis-Plus 的 SQL 注入探讨

  • 为了方便各位师傅们学习,我这里自己简单写了一个项目。

本来是可以直接 fork joychou 大师傅的项目的,但是 mybatis 和 mybatis_plus 不太好兼容,所以就自己写一个小项目帮助师傅们理解

https://github.com/Drun1baby/JavaSecurityLearning/tree/main/JavaSecurity/OWASP%20TOP10

里面的 MybatisPluSqli module 就是对应的项目。

/mybatis_plus/test 是一个关于 mybatis-plus 的测试接口,若部署成功,访问如图

我们后续讲到的这些有问题的方法,都是基于用户可控输入的情况。

使用 apply 直接拼接 SQL 语句

理想的 apply 漏洞场景

我们可以先看一种纯拼接的手法,但是讲道理,实际开发里面不可能这么写的。

1
2
3
4
5
6
@RequestMapping("/mybatis_plus/mpVuln02")  
public List<Employee> mpVuln02( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.apply("id="+id);
return employeeMapper.selectList(wrapper);
}

首先实际开发中不可能会用 selectList 这个方法,这完全就是自己给自己找坑。

我拿这个例子出来给师傅们看只是简单说明一下 SQL 注入里面的 apply 漏洞

实际情况的 apply 场景

代码如下

1
2
3
4
5
6
7
@RequestMapping("/mybatis_plus/mpVuln01")  
public Employee mpVuln01(String name, String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.eq("name",name).apply("id="+id);
Employee employee = employeeMapper.selectOne(wrapper);
return employee;
}

这是比较接近实际的情况,首先 apply() 方法算是一个多参请求,我们需要通过 id 与 name 来确定这个数据(虽然实际开发肯定不会告诉你 id)

这里我们的 payload 要这么打

1
?name=drunkbaby&id=1%20and%20extractvalue(1,concat(0x7e,(select%20database()),0x7e))

是只有报错注入才能打通的,如果我们只是简单的 1' or '1'='1 这种方式是不可以的。

因为这个地方

1
Employee employee = employeeMapper.selectOne(wrapper);  

我们的结果是 selectOne(),只是搞出一个,而万能密码是导出所有的数据,明显是会报错的,所以这里只能用报错注入爆数据。

虽然表面是 500,但是有时候这种方式是可行的,因为我们看到报错的地方爆出了数据库名,比如在服务器对 500 报错,是打印错误消息的情况下,就可以成功进行 SQL 注入了!

关于 apply 场景的防护

  • 用预编译来;我们修对应 /Vuln02 的洞
1
2
3
4
5
6
@RequestMapping("/mybatis_plus/mpSec02")  
public List<Employee> mpSec02( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.apply("id={0}",id);
return employeeMapper.selectList(wrapper);
}

很简单,要 apply 的地方加上 {0} 即可。

防护成功了,对应的 SQL 语句其实是这样的

last 方法产生的 SQL 注入

last() 方法经过重写,有两个方法,如下

1
2
last(String lastSql)
last(boolean condition, String lastSql)

也就是说,在 lastSql 里面我们是可以直接写 SQL 语句的,编写一个新的接口

1
2
3
4
5
6
@RequestMapping("/mybatis_plus/mpVuln03")  
public List<Employee> mpVuln03( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.last("order by " + id);
return employeeMapper.selectList(wrapper);
}

从根本上来说,也是拼接产生的问题

我们用 payload 打:

1
?id=1%20or%201=1

exists/notExists 拼接产生的SQL 注入

1
2
3
4
5
exists(String existsSql)
exists(boolean condition, String existsSql)

notExists(String notExistsSql)
notExists(boolean condition, String notExistsSql)

一共是这四个方法

原理也比较简单,后续的内容如果没有特别,就不再赘述了

exists 的接口

1
2
3
4
5
6
@RequestMapping("/mybatis_plus/mpVuln04")  
public List<Employee> mpVuln04( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.exists("select * from employees where id = " + id);
return employeeMapper.selectList(wrapper);
}

NotExists 的接口

1
2
3
4
5
6
@RequestMapping("/mybatis_plus/mpVuln05")  
public List<Employee> mpVuln05( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.notExists("select * from employees where id = " + id);
return employeeMapper.selectList(wrapper);
}

having 语句

1
2
having(String sqlHaving, Object... params)
having(boolean condition, String sqlHaving, Object... params)

接口如下

1
2
3
4
5
6
@RequestMapping("/mybatis_plus/mpVuln06")  
public List<Employee> mpVuln06( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.select().groupBy("id").having("id >" + id);
return employeeMapper.selectList(wrapper);
}

order by 语句

  • orderBy
1
orderBy(boolean condition, boolean isAsc, R... columns)
  • orderByAsc
1
2
orderByAsc(R... columns)
orderByAsc(boolean condition, R... columns)
  • orderByDesc
1
orderByDesc(R... columns)

这和之前说的 mybatis 里面的问题是一样的,因为 order by 的时候不能预编译,所以会出现问题

三个接口的写法大同小异,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public List<Employee> orderby01( String id) {  
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.select().orderBy(true, true, id);
return employeeMapper.selectList(wrapper);
}

@RequestMapping("/mybatis_plus/orderby02")
public List<Employee> orderby02( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.select().orderByAsc(id);
return employeeMapper.selectList(wrapper);
}

@RequestMapping("/mybatis_plus/orderby03")
public List<Employee> orderby03( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.select().orderByDesc(id);
return employeeMapper.selectList(wrapper);
}

group By

1
2
groupBy(R... columns)
groupBy(boolean condition, R... columns)

order by 的原理是一样的

接口如下

1
2
3
4
5
6
@RequestMapping("/mybatis_plus/groupBy")  
public List<Employee> groupBy( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.select().groupBy(id);
return employeeMapper.selectList(wrapper);
}

inSql/notInSql

1
2
3
4
5
inSql(R column, String inValue)
inSql(boolean condition, R column, String inValue)

notInSql(R column, String inValue)
notInSql(boolean condition, R column, String inValue)

对应的两个接口

inSql 的接口

1
2
3
4
5
6
@RequestMapping("/mybatis_plus/insql")  
public List<Employee> inSql( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.select().inSql(id, "select * from employees where id >" + id);
return employeeMapper.selectList(wrapper);
}

notInSql 的接口

1
2
3
4
5
6
@RequestMapping("/mybatis_plus/notinSql")  
public List<Employee> notinSql( String id) {
QueryWrapper<Employee> wrapper = new QueryWrapper<>();
wrapper.select().notInSql(id, "select * from employees where id >" + id);
return employeeMapper.selectList(wrapper);
}

关于 Wrapper 自定义 SQL

基本和上面的一样,就不再赘述了。

分页插件的 SQL 注入情况

漏洞点比较之前少很多,主要集中于两个地方。

一个是分页插件自带的 addOrder() 方法,另外一个是之前就有问题的 order by 方法。

  • 配置分页插件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.drunkbaby.config;  

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

/**
* 注册插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {

MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加分页插件
PaginationInnerInterceptor pageInterceptor = new PaginationInnerInterceptor();
// 设置请求的页面大于最大页后操作,true调回到首页,false继续请求。默认false
pageInterceptor.setOverflow(false);
// 单页分页条数限制,默认无限制
pageInterceptor.setMaxLimit(500L);
// 设置数据库类型
pageInterceptor.setDbType(DbType.MYSQL);

interceptor.addInnerInterceptor(pageInterceptor);
return interceptor;
}

}

这里我新建了一个 Person 相关的 ORM 操作,这样的话我们后续的操作就不会存在干扰,至于 pojo 那些怎么做,我这里就不赘述了。直接看存在漏洞的接口。

addOrder()

1
2
3
4
5
6
7
8
9
@RequestMapping("/mybatis_plus/PageVul01")  
public List<Person> mybatisPlusPageVuln01(Long page, Long size, String id){
QueryWrapper<Person> queryWrapper = new QueryWrapper<>();
Page<Person> personPage = new Page<>(1,2);
personPage.addOrder(OrderItem.asc(id));
IPage<Person> iPage= personMapper.selectPage(personPage, queryWrapper);
List<Person> people = iPage.getRecords();
return people;
}

这里的 Page<Person> personPage = new Page<>(1,2); 的参数由自己定义

这里对应的 payload 其实,比较有讲究:

1
2
3
4
?id=1%20and%20extractvalue(1,concat(0x7e,(select%20database()),0x7e)))

// 或者是
?id=1' and sleep(5)

必须是通过盲注的形式,如果是普通的注入,是不会有回显的;因为这里分页查找,size 就把你的数据数量限定死了,如果超过这个数据就会报错,所以只能盲注。

pagehelper

这里的原理就和 order by 一样,不赘述了

因为Order by排序时不能进行预编译处理,所以在使用插件时需要额外注意如下function,同样会存在SQL注入风险:

  • com.github.pagehelper.Page
    • 主要是setOrderBy(java.lang.String)方法
  • com.github.pagehelper.page.PageMethod
    • 主要是startPage(int,int,java.lang.String)方法
  • com.github.pagehelper.PageHelper
    • 主要是startPage(int,int,java.lang.String)方法

mybatis Plus SQL 注入的修复

  • 看了很多资料,基本上没有太好的防御手段,只有写 Filter 比较靠谱,我这里写了一个集成各种 Filter 的,比如 XSS。SQL 注入等漏洞的过滤器。

https://github.com/Drun1baby/JavaSecFilters

0x05 Hibernate 框架下的 SQL 注入

Hibernate 是一个开放源代码的对象关系映射框架,它对JDBC进行了非常轻量级的对象封装,使得 Java 程序员可以随心所欲的使用对象编程思维来操纵数据库。

Hibernate 可以使用 hql 来执行 SQL 语句,也可以直接执行 SQL 语句,无论是哪种方式都有可能导致 SQL 注入

HQL

hql 语句就和 PHP 里面的语句非常相似,和 JDBC 的也非常相似,所以这个理解起来比较简单,主要是防护措施。

1
String hql = "from People where username = '" + username + "' and password = '" + password + "'";

这种拼接方式存在 SQL 注入

正确使用以下几种 HQL 参数绑定的方式可以有效避免注入的产生:

1.命名参数(named parameter)

1
2
3
4
Query<User> query = session.createQuery("from users name = ?1", User.class);
String parameter = "g1ts";
Query<User> query = session.createQuery("from users name = :name", User.class);
query.setParameter("name", parameter);

2.位置参数(Positional parameter)

1
2
3
String parameter = "g1ts";
Query<User> query = session.createQuery("from users name = ?1", User.class);
query.setParameter(1, parameter);

3.命名参数列表(named parameter list)

1
2
3
List<String> names = Arrays.asList("g1ts", "g2ts");
Query<User> query = session.createQuery("from users where name in (:names)", User.class);
query.setParameter("names", names);

4.类实例(JavaBean)

1
2
3
user1.setName("g1ts");
Query<User> query = session.createQuery("from users where name =:name", User.class);
query.setProperties(user1);

5.HQL拼接方法

这种方式是最常用,而且容易忽视且容易被注入的,通常做法就是对参数的特殊字符进行过滤,推荐大家使用 Spring工具包的StringEscapeUtils.escapeSql()方法对参数进行过滤:

1
2
3
4
5
import org.apache.commons.lang.StringEscapeUtils;
public static void main(String[] args) {
String str = StringEscapeUtils.escapeSql("'");
System.out.println(str);
}

SQL

Hibernate支持使用原生SQL语句执行,所以其风险和JDBC是一致的,直接使用拼接的方法时会导致SQL注入

语句如下:

1
Query<People> query = session.createNativeQuery("select * from user where username = '" + username + "' and password = '" + password + "'");

正确写法:

1
2
3
String parameter = "g1ts";
Query<User> query = session.createNativeQuery("select * from user where name = :name");
query.setParameter("name",parameter);

0x06 参考资料

https://xz.aliyun.com/t/11672

 评论