这周浏览技术文章时,看到一个关于 SpringBoot + Vue 实现的后台管理系统的分享,阅读量还挺高。出于好奇,我便下载了源码进行分析,结果发现其中存在的安全漏洞还真不少。尤其是在 SQL 防护方面,开发者虽然引入了一套过滤机制,但由于配置逻辑不当,最终还是导致了 SQL注入 漏洞的产生。

在征得原作者同意后,我决定将本次代码审计的思路整理出来,与各位开发者朋友分享探讨。

SQL注入分析
MyBatis-Plus 框架对绝大多数 SQL 场景都采用了预编译处理,安全性较高。但对于动态表名、ORDER BY 等需要拼接 SQL 的场景,若配置不当,依然会存在风险。本次要记录的,便是在一次代码审计中遇到的“奇特”案例:明明对请求参数进行了非常严格的黑名单过滤,却因为代码逻辑设计不当,仍然导致了 SQL 注入。
分页功能中的SQL注入
首先查看项目的 pom.xml 文件,确认其持久层框架为 MyBatis-Plus。

在查阅工具类时,我发现项目中有一个专门用于过滤 SQL 注入的类 SQLFilter。

那么,这个过滤器具体在哪些地方被调用呢?通过 IDE 的查找引用功能,我发现只在 com.utils.Query 类中有 4 处调用。

跟进 com.utils.Query 类查看,发现它有两个重载的构造方法,分别接收不同类型的分页参数:
JQPageInfo:封装了分页参数的实体类。
Map<String, Object> params:Map 集合类型的参数。

虽然入参不同,但其防御 SQL 注入的逻辑是相同的:都是对 sidx 和 order 参数调用 SQLFilter.sqlInject() 进行过滤,然后将处理后的参数封装到 Page 对象中。


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;
}
}
乍一看,这里的过滤逻辑似乎很完美。但奇怪的是,我翻阅了几个 SQL 映射文件(XML),发现都没有直接使用到这个 Query 类生成的 page 对象。

那么,实际的查询流程是怎样的呢?我们从页面中找一个带分页的查询请求来分析,例如:
/jixiaokaohe/page?page=1&limit=10&sort=id
这个请求会进入对应的 Controller -> com.controller.JixiaokaoheController#page 方法。可以看到,它调用了 jixiaokaoheService.queryPage 进行查询,并使用 MPUtil.sort 方法来自定义一个查询条件包装器 wrapper。

继续跟进到 Service 实现层 com.service.impl.JixiaokaoheServiceImpl#queryPage。这里确实使用了我们之前看到的、带有 SQL 过滤的 Query 构造方法。

继续向下跟踪调用链,发现最终的 SQL 实现依赖的是那个自定义的 wrapper 来构建查询条件。



问题的关键就在这个 MPUtil.sort 方法里。跟进去查看 com.utils.MPUtil#sort 的源码,发现它直接对传入的 sort 参数进行了字符串拼接,并且完全没有经过任何过滤!

漏洞点找到了,我们来构造一个 POC 测试一下:
page=1&limit=10&sort=id and updatexml(1,concat(0x7e,database()),0)#
发送请求,成功触发数据库报错,证实了 SQL 注入漏洞的存在。

其他潜在的SQL注入点
MyBatis-Plus 在 MyBatis 的基础上增强,因此 MyBatis 中 #{}(预编译)和 ${}(拼接)的 SQL 编写方式在 MyBatis-Plus 中同样适用。直接在项目中搜索 ${,可以发现多处使用了 SQL 拼接的地方。

我们选取其中一处进行分析。这是一个用于提醒计数的 SQL 片段,其中的表名、列名等均采用了 ${} 拼接。

一路向上追溯调用链:
com.service.impl.CommonServiceImpl#remindCount

最终到达 Controller 层 com.controller.CommonController#remindCount。发现整个过程对传入的参数没有任何过滤。

根据接口定义,构造 POC 如下(注意 type 参数需符合上下文逻辑,此处仅演示注入Payload):
users union select group_concat(SCHEMA_NAME) from information_schema.SCHEMATA
发送请求,成功获取数据库名信息,验证了该处也存在 SQL注入 漏洞。

SQL注入问题总结
在处理动态表名、ORDER BY 等需要 SQL 拼接的场景时,代码实现必须格外仔细。总体而言,在 MyBatis-Plus 框架中挖掘 SQL 注入漏洞,有时比在原生 MyBatis 中更具挑战性,需要审计人员更加耐心和细致。
任意用户密码重置漏洞
在审计过程中,还无意发现一个“后门”性质的功能。一个重置密码的接口,竟然可以不进行任何权限校验就直接调用!
关键在于 @IgnoreAuth 这个注解,它的作用就是跳过权限认证。方法逻辑很简单:根据用户名查找用户,如果用户存在,则将其密码重置为 123456。

直接构造请求即可重置任意用户密码:
GET /springboot57n6g/users/resetPass?username=admin HTTP/1.1
Host: localhost:8081
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
Accept: application/json, text/plain, */*
sec-ch-ua: "Chromium";v="122", "Not(A Brand";v="24", "Google Chrome";v="122"
sec-ch-ua-platform: "Windows"
sec-ch-ua-mobile: ?0
Accept-Encoding: gzip, deflate, br
Sec-Fetch-Site: same-origin
Sec-Fetch-Dest: empty
Referer: http://localhost:8081/
Sec-Fetch-Mode: cors
Accept-Language: zh-CN,zh;q=0.9
请求成功,管理员密码被重置。

任意文件上传漏洞

漏洞分析
文件上传功能在 com.controller.FileController#upload 中实现。它通过截取文件名最后一个点来获取后缀,然后结合时间戳生成新文件名。但整个过程没有对文件内容、后缀或 MIME 类型做任何安全检查,导致攻击者可以上传任意恶意文件(如 Webshell)。

任意文件下载漏洞

漏洞分析
文件下载功能在 com.controller.FileController#download 中实现。关键问题在于第94行(根据截图上下文)的文件路径拼接逻辑:静态目录/upload/filename。由于 filename 参数完全由用户控制,且没有进行路径穿越过滤(如 ../),导致攻击者可以通过目录穿越下载服务器上的任意文件。

总结
本次对一套 Spring Boot 系统的代码审计 揭示了一个深刻的教训:开发者在注重功能实现的同时,必须将安全考量融入到设计编码的每一个环节。就像那个密码重置接口,开发者可能只考虑了“便捷性”,却完全忽略了其带来的巨大安全风险。安全防护是一个整体,一处疏漏便可能导致全盘皆失。希望这次的案例分析能为大家在开发中敲响警钟,写出更健壮、更安全的代码。
如果你想与更多开发者交流此类安全与开发实践,欢迎访问 云栈社区 进行探讨。