0x01 前言
本周分析了一个基于SpringBoot+Vue实现的后台管理系统源码,发现其中存在多处安全隐患。尤为典型的是,开发者在SQL注入防护上虽然配置了黑名单过滤,但由于代码逻辑缺陷,最终仍导致了注入漏洞。

图1:某公众号分享的SpringBoot+Vue系统文章,阅读量较高
在征得原作者同意后,本文将分享本次代码审计中关于SQL注入漏洞的发现思路。

图2:与项目作者沟通,获准分享技术思路
0x02 SQL注入漏洞分析
MyBatis-Plus框架虽然对绝大多数SQL场景进行了预编译处理,但在动态表名、ORDER BY等需要字符串拼接的场景下,如果配置不当,仍然会引发注入风险。本次审计就遇到了一个典型案例:在请求参数黑名单过滤十分严格的情况下,由于后端代码逻辑不当,导致了SQL注入。
分页功能中的SQL注入
通过项目pom.xml文件可知,该项目持久层使用的是MyBatis-Plus框架。

图3:项目依赖中包含mybatis-plus
在审计工具类时,发现项目提供了一个SQLFilter类用于防御SQL注入。

图4:SQLFilter.sqlInject方法通过黑名单过滤危险关键词
全局搜索该方法调用点,发现仅在com.utils.Query类的构造方法中被调用了4次。

图5:在Query类中找到了sqlInject方法的调用点
跟进com.utils.Query类,发现其有两个重载的构造方法,分别接收JQPageInfo和Map<String, Object>类型参数,但核心防御逻辑一致:都对sidx和order参数进行过滤处理后,封装成Page对象。

图6:Query类的构造方法接收分页参数

图7:使用SQLFilter过滤sidx和order参数

图8:Map参数构造方法中的过滤逻辑
Query类源码如下:
package com.utils;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import com.baomidou.mybatisplus.plugins.Page;
/**
* 查询参数
*/
public class Query<T> extends LinkedHashMap<String, Object> {
private static final long serialVersionUID = 1L;
/**
* mybatis-plus分页参数
*/
private Page<T> page;
/**
* 当前页码
*/
private int currPage = 1;
/**
* 每页条数
*/
private int limit = 10;
public Query(JQPageInfo pageInfo){
//分页参数
if(pageInfo.getPage()!= null){
currPage = pageInfo.getPage();
}
if(pageInfo.getLimit()!= null){
limit = pageInfo.getLimit();
}
//防止SQL注入(因为sidx、order是通过拼接SQL实现排序的,会有SQL注入风险)
String sidx = SQLFilter.sqlInject(pageInfo.getSidx());
String order = SQLFilter.sqlInject(pageInfo.getOrder());
//mybatis-plus分页
this.page = new Page<>(currPage, limit);
//排序
if(StringUtils.isNotBlank(sidx) && StringUtils.isNotBlank(order)){
this.page.setOrderByField(sidx);
this.page.setAsc("ASC".equalsIgnoreCase(order));
}
}
public Query(Map<String, Object> params){
this.putAll(params);
//分页参数
if(params.get("page") != null){
currPage = Integer.parseInt((String)params.get("page"));
}
if(params.get("limit") != null){
limit = Integer.parseInt((String)params.get("limit"));
}
this.put("offset", (currPage - 1) * limit);
this.put("page", currPage);
this.put("limit", limit);
//防止SQL注入(因为sidx、order是通过拼接SQL实现排序的,会有SQL注入风险)
String sidx = SQLFilter.sqlInject((String)params.get("sidx"));
String order = SQLFilter.sqlInject((String)params.get("order"));
this.put("sidx", sidx);
this.put("order", order);
//mybatis-plus分页
this.page = new Page<>(currPage, limit);
//排序
if(StringUtils.isNotBlank(sidx) && StringUtils.isNotBlank(order)){
this.page.setOrderByField(sidx);
this.page.setAsc("ASC".equalsIgnoreCase(order));
}
}
public Page<T> getPage(){
return page;
}
public int getCurrPage(){
return currPage;
}
public int getLimit(){
return limit;
}
}
看似完美的过滤逻辑,但审计MyBatis的XML映射文件时,却发现这些查询并未使用Query对象生成的page对象进行排序。

图9:XML中的查询语句并未直接使用Page对象
那么真实的排序逻辑是如何实现的呢?我们从一个带分页的请求入手分析:
/jixiaokaohe/page?page=1&limit=10&sort=id
跟进到对应的Controller:com.controller.JixiaokaoheController#page。可以看到,它调用了jixiaokaoheService.queryPage进行查询,并使用MPUtil.sort方法自定义了一个Wrapper条件包装器。

图10:Controller层调用服务方法并传入排序参数
继续跟进服务实现类com.service.impl.JixiaokaoheServiceImpl#queryPage。这里确实使用了我们之前看到的、带有过滤逻辑的Query构造方法。

图11:Service层使用Query类处理参数
但关键点在于后续的调用链。继续向下跟踪,发现SQL的最终执行依赖于Mapper中定义的selectListView方法。

图12:Mapper接口方法定义

图13:XML中的SQL使用${ew.sqlSegment}拼接条件
可以看到,最终的SQL查询条件是通过${ew.sqlSegment}动态拼接的,而这个Wrapper的构建逻辑在MPUtil.sort方法中。

图14:使用MPUtil工具类生成排序Wrapper
核心漏洞点就在这里。跟进com.utils.MPUtil#sort方法,发现该方法直接对sort参数进行了字符串拼接,并且没有进行任何过滤!

图15:sort方法中未过滤参数直接拼接,导致注入
构造Payload进行测试:
page=1&limit=10&sort=id and updatexml(1,concat(0x7e,database()),0)#

图16:发送Payload后,数据库报错信息泄露,证明注入成功
其他SQL注入点分析
MyBatis-Plus在MyBatis的基础上增强,因此MyBatis中#{}预编译和${}拼接的特性同样存在。直接在XML文件中搜索${,发现了多处使用字符串拼接的场景。

图17:项目存在多处SQL拼接点
以一处提醒功能的SQL为例进行分析,该SQL中table和column字段直接使用了${}拼接。

图18:XML中${table}和${column}存在拼接风险
向上追踪调用链:com.service.impl.CommonServiceImpl#remindCount -> com.controller.CommonController#remindCount,发现全程未对传入的table和column参数进行过滤。

图19:Service层调用通用查询方法

图20:Controller层接口,参数未过滤
根据接口构造Payload进行测试:
/remind/{table}/{column}?type=1&remindStart=2024-01-01&remindEnd=2024-04-01
将{table}替换为注入语句:
users union select group_concat(SCHEMA_NAME) from information_schema.SCHEMATA

图21:利用参数拼接实现Union查询,注入成功
SQL注入漏洞总结
在对动态表名、ORDER BY等需要拼接SQL的场景进行开发时,必须格外仔细。本次代码审计案例表明,即使在工具类中配置了严格的黑名单过滤,如果业务逻辑流未能正确调用过滤方法,防护便会形同虚设。总体而言,在MyBatis-Plus框架中挖掘SQL注入漏洞,需要审计人员对代码执行流有更清晰的跟踪和更高的耐心。
0x03 任意用户密码重置
审计中无意发现一个功能设计存在缺陷的接口。该重置密码接口使用了@IgnoreAuth注解,意味着无需任何权限认证即可调用。

图22:忽略鉴权的重置密码方法
接口逻辑是根据用户名查询用户,然后将其密码硬编码重置为“123456”。这导致攻击者可以重置任意用户的密码。
GET /springboot57n6g/users/resetPass?username=admin HTTP/1.1

图23:未授权请求成功将admin用户密码重置
0x04 任意文件上传

图24:文件上传功能的HTTP请求
分析com.controller.FileController#upload方法,发现其仅通过最后一个点来截取文件后缀,并结合时间生成新文件名,但未对文件内容、文件头或后缀名进行任何安全检查,导致可上传任意文件。

图25:上传逻辑缺少文件类型检查
0x05 任意文件下载

图26:利用文件名参数进行目录穿越
分析com.controller.FileController#download方法,发现文件路径由固定目录/upload/与用户可控的filename参数直接拼接而成。由于未对filename中的路径遍历符(如../)进行过滤,导致可下载服务器上的任意文件。

图27:下载逻辑中存在路径拼接漏洞
0x06 总结
本案例揭示了在快速开发过程中,开发者容易专注于功能实现而忽略安全边界检查。无论是SQL注入过滤逻辑的“绕路而行”,还是未授权接口、不安全的文件操作,都源于对用户输入缺乏充分的信任和验证。安全应当贯穿于软件开发的每一个环节,而非事后补救。希望本次在云栈社区的分享能为开发者们敲响警钟,在追求功能完善的同时,务必筑牢安全防线。