“CVE-2023-25330”挂在安全扫描报告上是什么感觉,经历过的人应该都懂:明明项目在跑,功能也正常,但漏洞清单里就是有个红彤彤的MybatisPlus SQL注入在等你处理。这个编号对应的正是MybatisPlus多租户插件对租户ID拼接不当导致的注入风险,官方在3.5.3.2版本修复。我连续处理过好几个Spring Boot项目的这个CVE,从依赖定位、代码排查到最终的升级和加固,踩了不少坑,也把整个流程摸透了。这篇文章就把完整的处理思路和实操步骤写出来,给正在被扫描报告折磨的Java开发同学一个可以直接抄作业的参考。
1. 漏洞原理与影响范围:先搞清楚CVE-2023-25330到底坏在哪
1.1 多租户插件是怎么把SQL搞出洞的
MybatisPlus这个框架,用的人都知道,核心价值就是“省事”。尤其是它的多租户插件(TenantLineInnerInterceptor),对做SaaS系统的团队来说简直是救星:业务代码里写SELECT * FROM orders,插件会自动在SQL后面拼上WHERE tenant_id = ?,把租户隔离这件事从业务代码里剥离出去。听起来很完美,问题恰恰出在“自动拼接”这步上。
CVE-2023-25330描述的场景,简单说就是旧版本在把租户ID拼进SQL时,没有严格走PreparedStatement的参数占位符,而是把传入的值直接拼接到了SQL字符串里。打一个比方:正常操作是“你先告诉我卡号,我通过内部系统去查”;漏洞版本是“你把卡号写在一张纸条上,我直接把这个纸条当查询条件贴到屏幕上”——如果纸条上写的不是卡号而是别的东西,那屏幕上显示出来的就是别的东西。
放到SQL场景里就更直观了。如果租户ID来自外部请求参数,攻击者传入一个类似1 or 1=1的值,旧版本可能就把SQL拼成了WHERE tenant_id = 1 or 1=1,这个条件恒为真,等于把整个表的租户隔离条带全掀了。所有租户的数据一次性暴露,轻则越权查看,重则配合其他操作拖库。
1.2 哪些项目真的会中招
这里要强调一个容易被忽略的点:不是所有用了MybatisPlus的项目都受这个CVE影响。我在排查时见过不少团队,一看到CVE编号就全员紧张,结果代码里根本没启用多租户插件,那就是扫描器“宁杀错不放过”的误报。真正触发这个漏洞需要同时满足几个条件,判断逻辑如下:
| 判断条件 | 是否受影响 |
|---|---|
| 使用MybatisPlus 3.5.3.1及以前版本,且启用了多租户插件 | 受影响,需立即处理 |
| 使用3.5.3.2及以后修复版本 | 不受影响 |
| 使用了MybatisPlus但没启用多租户插件 | 本CVE不适用,但需留意其他SQL注入风险 |
| 启用了多租户插件,但租户ID来自登录态、Token等可信来源 | 风险可控,需确认整条链路 |
判断的关键在于两点:版本号、租户插件是否开启。版本号可以去Maven仓库对照看,至于租户插件,直接从配置代码里查就行,后面会详细讲。
1.3 漏洞的实际危害不只是数据泄露
这个漏洞一旦被利用,租户隔离条件失效,后果是“数据越权”和“批量拖库”双重叠加。对于SaaS平台来说,租户隔离是底线,租户A能看到租户B的数据,这对信任体系是毁灭性的。更麻烦的是,如果应用对错误信息处理不友好,攻击者还能通过报错信息不断探测数据库结构,为后续攻击铺路。所以这个CVE虽然触发条件有限制,但危害等级不低,排查和修复都不能拖。
2. 自查三步走:先确认你的项目是否踩雷
2.1 第一步:快速定位当前MybatisPlus版本
处理安全问题的第一步永远是“确认现状”。版本号判断最直接的方式是看依赖树。Maven项目执行:
mvn dependency:tree -Dincludes=com.baomidou:mybatis-plus-boot-starterGradle项目执行:
gradle dependencyInsight --dependency mybatis-plus-boot-starter如果你用的是Maven多模块项目,建议在根目录执行完整依赖树,增加-Dverbose参数可以看到依赖传递路径,防止子模块各自引了不同版本。
还有一种情况是代码仓库里没有直接写MybatisPlus版本号,而是通过父POM或BOM统一管理。这时候可以进本地Maven仓库看实际jar包的版本目录,或者直接解压jar包:
unzip -p ~/.m2/repository/com/baomidou/mybatis-plus-core/3.5.3.1/mybatis-plus-core-3.5.3.1.jar META-INF/maven/com.baomidou/mybatis-plus-core/pom.properties注意mybatis-plus-boot-starter和mybatis-plus-core的版本可能不完全一致,排查时两个坐标最好都确认。
2.2 第二步:检查多租户插件是否真的启用了
版本判断之后,接着查代码。找到配置MybatisPlus拦截器的类,一般是MybatisPlusConfig或者类似的Configuration类,看里面有没有类似这样的配置:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 关键代码:租户插件 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { @Override public Expression getTenantId() { // 返回当前租户ID return new LongValue(TenantContextHolder.getTenantId()); } @Override public String getTenantIdColumn() { return "tenant_id"; } @Override public boolean ignoreTable(String tableName) { // 忽略某些表 return false; } })); return interceptor; }如果项目里压根没有这段配置,那么CVE-2023-25330对你暂不构成威胁,可以先松一口气。但注意“暂时”两个字——MybatisPlus在其他使用场景下依然可能因为${}拼接、自定义SQL写法不当等问题引入注入风险,不能掉以轻心。
2.3 第三步:追查租户ID到底从哪里来
这一步最关键,也最容易被忽略。很多团队虽然在用租户插件,但租户ID的获取方式五花八门。我见过最危险的写法是从前端表单或者URL参数直接取:
String tenantId = request.getParameter("tenantId"); TenantContextHolder.setTenantId(tenantId);这种写法在旧版本下等于给攻击者递刀子。租户ID必须来自后端可信来源:登录态、Token解析出的用户归属租户、网关层经过服务间认证后写入的Header,或者中台服务通过内部RPC传递的上下文。从外部请求直接读取租户ID,本身就是设计缺陷,即使版本升级了,也应该顺手改掉。
3. 解决方案实操:升级、加固与回归验证
3.1 首选方案:升级到修复版本
官方在3.5.3.2版本修复了多租户插件对租户ID的拼接问题。升级方式是改依赖版本,以最常见的Maven为例:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency>Gradle项目则改成:
implementation 'com.baomidou:mybatis-plus-boot-starter:3.5.3.2'如果你还单独引用了mybatis-plus-core、mybatis-plus-extension等子模块,建议一并对齐到相同版本,避免出现两个子包的版本不一致,导致运行时行为无法预测。
升级完成后,必须做一轮回归测试。别以为版本号升了就万事大吉,MybatisPlus在3.5.x系列里行为上有些细节变化,尤其是租户插件和分页插件的解析逻辑。我建议至少覆盖这几项:
- 用两个不同租户的账号互查数据,确认租户隔离依然生效,这是最重要的;
- 分页查询是否正常,COUNT语句是否正确生成,SQL日志里看租户条件有没有进COUNT;
- 自定义SQL(尤其是XML里手写的复杂SQL)是否还能正常拼接租户条件;
- 乐观锁、逻辑删除、动态表名等插件是否协同正常。
3.2 代码层加固:让租户ID对不可信输入彻底绝缘
升级只是修掉了框架层的洞,如果业务代码里的“口子”还开着,下一次扫描可能又会有新漏洞出现。所以我的建议是升级的同时,顺手把租户ID的获取链路重构一遍。
第一步,做一个统一的租户上下文管理器。网上有很多现成的TenantContextHolder写法,我这里给一个加白名单校验的版本:
public class TenantContextHolder { private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>(); private static final Pattern TENANT_PATTERN = Pattern.compile("^[0-9]{1,10}$"); public static void setTenantId(String tenantId) { if (tenantId == null || !TENANT_PATTERN.matcher(tenantId).matches()) { throw new IllegalArgumentException("非法租户ID"); } CURRENT_TENANT.set(tenantId); } public static Long getTenantId() { String tenantId = CURRENT_TENANT.get(); return tenantId == null ? null : Long.parseLong(tenantId); } public static void clear() { CURRENT_TENANT.remove(); } }如果你的租户ID不是数字,而是类似企业编号的字符串,可以把正则改成^[A-Za-z0-9_-]{1,32}$,总之要保证只允许预期格式的字符通过。
第二步,在请求入口处设置租户ID。推荐用拦截器或Filter统一处理,从登录态或Token解析出租户,设置到上下文里:
public class TenantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从登录态或token中解析租户ID,绝不从request参数获取 LoginUser user = UserContext.getCurrentUser(); TenantContextHolder.setTenantId(user.getTenantId()); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantContextHolder.clear(); } }线程池、异步任务场景还需要关注ThreadLocal的传递和清理,避免线程复用时租户串号。如果项目里用的是ExecutorService,建议在任务提交时显式捕获租户ID,新线程启动时再放入上下文。
3.3 防御纵深:数据库权限收缩与SQL审计兜底
框架升级和代码加固解决的是“入口”问题,更底层的防线是数据库账号权限。即便SQL被注入成功了,如果数据库账号只有查询权限,没有DDL权限,攻击者能做的事也会被限制很多。顺手的建议是:
- 业务账号只授予CRUD权限,不授予DLL(表结构变更)权限;
- 高权限账号仅用于数据库维护,不在应用配置里使用;
- 可以的话,针对租户ID在数据库层再加一层视图限制,或者在网关/WAF层面对
tenantId参数的异常关键字做拦截。
SQL审计这块,推荐开启MySQL的general_log或者使用云厂商的SQL审计能力。审计日志不是为了预防漏洞,而是为了事后追溯。真出了安全问题,有日志和没日志,处理效率天差地别。MybatisPlus项目可以在日志配置里打开SQL打印(mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl),平时开发用,线上配合日志框架的INFO级别策略做局部开启即可。
3.4 升级后分页失效与COUNT异常怎么排查
升级到3.5.3.2之后,有同学反馈“分页查询的COUNT语句不对了”,或者“查出来的总数不对”,更有甚者直接分页失效。这个锅不能全甩给漏洞修复,更多是MybatisPlus拦截器执行顺序和SQL解析规则的“老问题”。
MybatisPlus 3.5.x的插件体系是InnerInterceptor链,执行顺序就是addInnerInterceptor的添加顺序。租户插件和分页插件的顺序会影响最终SQL的生成。通常建议先添加租户插件,再添加分页插件,这样租户条件能先拼进SQL,分页插件再基于已带租户条件的SQL生成分页语句和COUNT语句。如果顺序颠倒,可能出现COUNT语句里少了租户条件,导致分页总数包含了其他租户数据。
如果项目里恰好配置顺序没问题但还是异常,建议把SQL日志打开,对比升级前后的输出。常见的情况是复杂子查询、UNION查询在租户插件解析时会改写错,此时可以针对特定Mapper方法使用@InterceptorIgnore(tenantLine = "true")注解跳过租户解析,但前提是这条SQL本身就是跨租户的统计SQL,且你已经做了权限控制。注意这是“逃生门”,不能滥用。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
我把实际操作中遇到过的典型问题整理成速查表,方便对照排查:
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 扫描报告仍报CVE-2023-25330 | 依赖未真正升级,存在多个模块引用了旧版本 | 全局BOM统一版本,用dependency:tree逐个核对 |
| 升级后租户条件不生效 | TenantLineHandler的ignoreTable配置把表排除了 | 检查ignoreTable逻辑,确认没有误拦截核心表 |
| 多数据源下部分数据源没执行租户插件 | 只有主数据源配置了拦截器 | 检查每个数据源的MybatisPlus配置,保持一致 |
| 自定义SQL执行报错 | 复杂SQL在租户解析时改写失败 | 简化SQL,或使用注解忽略租户解析并做权限兜底 |
| 分页查询COUNT结果异常 | 拦截器顺序不对或COUNT SQL解析异常 | 调整拦截器顺序,确认租户条件进入了COUNT语句 |
| 安全扫描虽然报SQL注入但定位不到代码 | 依赖传递导致版本判断错误 | 分析依赖树,定位实际生效版本,排除误报 |
4.2 在测试环境如何验证修复效果
验证“修好了”这件事,不能只看版本号,最好能在测试环境实际验证一下。这里分享一个安全的、在授权范围内做的验证思路,重点不是教你怎么攻击,而是教你怎么判断漏洞是否存在。
打开MybatisPlus的SQL日志输出,然后在测试环境构造一个请求,把租户ID参数传成带单引号的特殊值,比如1' AND '1'='1。如果项目还在旧版本,SQL日志里能看到租户条件直接拼接进了SQL字符串,打印出来的语句和参数绑定是“揉在一起”的;修复后的版本会走PreparedStatement机制,SQL日志显示WHERE tenant_id = ?,参数被单独绑定为1' AND '1'='1(显示为参数值,不再是SQL片段)。
这个验证方法的核心观察点是“拼接”还是“参数绑定”,不需要真正执行攻击语句,对系统也没有破坏性。前提是——必须在你自己的测试环境、自己的账号授权范围内操作,线上环境绝对不要做任何尝试。
4.3 暂时无法升级时的临时加固方案
有些老项目升级代价确实大,依赖冲突多、二方包没跟进、回归成本高,一时半会儿升不上去。这时候可以先做临时加固,但心里要有数:这是“创可贴”,不是“根治”,后续还是得排期升级。
临时方案可以组合使用:
- 在统一网关或Filter层面对
tenantId入参做白名单校验,非预期格式直接返回400,从入口把异常的租户ID挡掉; - 对租户ID加签名防篡改,比如服务端用一个密钥对租户ID生成HMAC签名,请求带上签名,服务端校验通过后才使用该租户ID;
- 数据库账号权限收缩,移除
SELECT INTO OUTFILE、多语句执行等危险能力; - 借助数据库防火墙或安全组,拦截包含
or、union、select、sleep等关键字的异常SQL; - 安全扫描的结果不强制要求当天清零的话,可以同步推动业务方优化租户ID的获取链路,先把设计缺陷堵住。
这些措施能在一定程度上降低被利用的风险,但它们都是“外挂”式防御。真正要让CVE编号从扫描报告里消失,还得是升级到修复版本,从根上解决问题。
4.4 易踩的坑:别把告警当瑕疵,也别把误报当危机
最后聊一个处理安全问题时的心态问题。这个CVE的告警在安全团队和研发团队之间的“翻译”过程里特别容易失真。安全团队看到“MybatisPlus SQL注入”会非常紧张,要求立即整改;研发团队一查,发现项目没用多租户插件,可能觉得扫描器在无理取闹,两边就容易扯皮。
我的经验是,接到告警后先别急着辩解,按上面的自查流程走一遍:定位版本、查插件配置、看租户ID链路。把每一步的结论记录下来,形成一份简单的处置说明。如果确认是误报,把依赖树和代码截图发过去,安全团队也能理解;如果确实中招了,这份说明就是后续整改的起点。处理安全问题的核心不是争论“该不该改”,而是快速确认“改哪里、怎么改、怎么验证”。
从我开始接触这个CVE到现在,处理的几个项目路径都差不多:升级版本、收敛租户ID来源、做了一遍SQL日志审计。做完之后最大的感触是,这个漏洞本身修复起来并不复杂,真正的价值在于借着这次机会把项目里SQL注入的隐患系统性排查了一遍。安全扫描虽然烦人,但每次告警都是一次免费的代码评审,用好了反而能逼着团队把基础安全做扎实。