ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis-Plus SQL注入漏洞CVE-2023-25330排查与修复实战

MyBatis-Plus SQL注入漏洞CVE-2023-25330排查与修复实战 年初团队做例行安全巡检扫描器报了一个高危MyBatis-Plus SQL注入漏洞编号CVE-2023-25330。看到这个标题的第一反应可能很多人和我一样——MyBatis-Plus不是一直强调“防注入”吗为什么还会出SQL注入而且这个洞还和“多租户插件”绑定在一起排查起来比普通注入更隐蔽。这篇文章就围绕这个CVE把漏洞出在哪儿、怎么自查、怎么修复、升级之后遇到的各种坑一次讲清楚。不管你是老项目维护者、后端开发还是安全负责人这套方案拿过去就能用。先说结论这不是MyBatis-Plus在“参数预编译”环节出的问题而是它在帮你自动拼接SQL片段时因为底层SQL解析器对某些复杂语法识别不完整导致攻击者有机会把自己的SQL片段“混进”拼接逻辑里。想彻底解决不能只盯着某个Filter或者输入校验必须从版本升级、代码规范、拦截器兜底三个层面一起做。1. 漏洞出在哪包装器语法与SQL解析器之间的缝隙1.1 为什么一个“防注入框架”会出注入漏洞MyBatis-Plus的日常使用中开发人员基本都走QueryWrapper、LambdaQueryWrapper这些API设计之初就把“参数预编译”做掉了你传什么值MyBatis就用?占位符绑什么值正常情况下不可能被注入。但在实际项目里总有那么几个需求没法用标准API表达比如动态排序、动态拼条件、自定义子查询这时候很多人会用apply(字段 值)、last(order by 排序字段)、having(...)这些方法。问题就在这里。apply、last、having本质上就是给你留的“半成品SQL拼接口子”它不负责预编译只是把字符串原封不动地拼进SQL。MyBatis-Plus本身知道这个用法有风险所以在文档里反复强调“不要传用户输入进去”。但真实项目里谁管得住最典型的例子就是排序字段前端传一个orderBy参数后端图省事直接queryWrapper.last(order by orderBy)这行代码一写SQL注入的窗户就打开了。CVE-2023-25330利用的正是这条链路。攻击者不直接通过?占位符下手而是通过那些能拼字符串的方法把union select、子查询、注释符号等塞进SQL再借助JSqlParser在解析重构SQL时的漏洞让恶意片段“穿透”到真正执行语句里。1.2 CVE-2023-25330的触发位置与实际危害这个漏洞最初被重点关注是因为它和多租户插件TenantLineInnerInterceptor强相关。多租户插件的原理是你写任何SQL它都会在底层用JSqlParser解析一遍然后自动往表后面追加租户条件比如SELECT * FROM orders WHERE status 1 -- 自动变成 SELECT * FROM orders WHERE status 1 AND tenant_id 1001这套逻辑在“普通SQL”上运行得很稳但JSqlParser不是万能的遇到union select、多层嵌套子查询、某些特殊注释写法时解析器可能找错位置或者把不该忽略的内容忽略掉。攻击者如果已经能控制某个动态拼接点再结合这个解析缺陷就能让追加的tenant_id条件被绕过甚至直接把攻击SQL拼进去执行。实际危害分两种情况普通业务SQL注入可以绕过条件查询、拖出其他表的数据。多租户隔离被绕过在SaaS系统里租户A有可能查到租户B的数据。这个比单纯的数据泄露更严重因为破坏的是整个权限隔离模型。所以修复这个漏洞不只是“补个版本号”那么简单还要回头审查所有用到多租户插件、分页插件、乐观锁插件的SQL解析场景。1.3 能被利用的最小示例看懂原理就够我整理一个最简化的危险写法示例方便理解// 危险orderBy 来自前端且未做白名单 String orderBy request.getParameter(orderBy); QueryWrapperUser wrapper new QueryWrapper(); wrapper.last(order by orderBy); userMapper.selectList(wrapper);攻击者把orderBy构造为id; select username, password from sys_user --拼接之后的SQL变成了SELECT * FROM user order by id; select username, password from sys_user --如果数据库连接串允许多语句执行这条SQL就变成了两条。即使不允许多语句攻击者也可以构造id, (select 1 from (select count(*) from sys_user) a)这种基于报错或子查询的盲注语句。这个示例不是完整的CVE利用代码只是为了说明高危漏洞从来不孤立存在它总是从一个“不规范写法”开始。所以后面讲修复方案时代码规范部分才是核心。2. 自查与影响评估先确认你的项目是否在风险范围2.1 影响版本与修复版本对照根据漏洞披露信息和官方修复记录受影响的情况大致如下MyBatis-Plus版本是否受影响建议处理3.5.3.1及以下受影响升级到3.5.3.2及以上建议直接到3.5.43.5.3.2已修复可继续使用但建议保持最新补丁3.5.4 - 3.5.7不受该CVE影响关注官方公告及时跟进3.5.9不受影响注意JSqlParser依赖拆分调整这里要提醒一句MyBatis-Plus从3.5.9版本开始把JSqlParser相关能力拆成了独立的mybatis-plus-jsqlparser支持库。如果你的项目用了分页插件、租户插件这类依赖SQL解析的组件升级到3.5.9之后需要额外引入对应依赖否则运行期会直接报“找不到类”之类的错误。这个问题后面单独讲。2.2 三条命令快速定位项目里的MP版本先别急着改代码第一件事是确认你们项目到底用的哪个版本。我一般按下面顺序查方式一Maven项目查看依赖树mvn dependency:tree -Dincludescom.baomidou:mybatis-plus-boot-starter方式二Gradle项目gradle dependencies --configuration runtimeClasspath | grep mybatis-plus方式三IDEA直接看在IDEA右侧的Maven面板里搜mybatis-plus能看到实际解析出来的版本号这个方法最直观适合不熟命令行的同事。另外还有一个跑起来才能确认的方式在项目里打印一下运行时常量。System.out.println(MybatisPlusVersion.getVersion());我用这种命令排查过好几个老项目发现了很多“pom里写3.4.2但子模块被其他依赖覆盖成3.5.1”的情况。所以一定要查“最终生效版本”不要只看你自己pom里的声明。2.3 如何判断是否已经被人利用过确认版本在风险范围后下一步是翻日志和数据库判断有没有被人扫过。需要重点查的特征有SQL日志里出现order by后面跟着union select、sleep(、benchmark(等函数调用。数据库日志里出现大量SQL语法报错尤其是unexpected token、check the manual that corresponds to your MySQL server version这类错误通常是有自动化工具在盲注测试。表里莫名其妙多了数据或者sys_user这种敏感表有大批量查询记录。注意一个细节很多自动化扫描工具不会真的执行恶意SQL它们会先发一个不破坏数据的探测语句比如/orderByid%20and%2012 /orderByid%20and%2011然后对比两次响应内容是否不同。这种痕迹只会出现在访问日志里而且看起来像正常参数。如果你在Nginx或网关日志里看到大量and 11、and 12、sleep(3)这种参数基本可以断定有人扫过但有没有注入成功还需要结合数据库日志和慢查询日志进一步核实。3. 正式修复升级依赖与兼容性处理3.1 升级到修复版本的具体操作最稳妥的修复方式就是升级版本。以Maven项目为例如果你用的是mybatis-plus-boot-starter直接改版本号dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.4/version /dependency如果你用的是mybatis-plus核心包dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus/artifactId version3.5.4/version /dependency如果你现在已经在3.5.9版本规划上需要额外加JSqlParser支持包dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-jsqlparser/artifactId version3.5.9/version /dependency升级之后建议在测试环境把核心业务链路完整回归一遍。不要觉得只是“小幅升级”就跳过回归MP的解析器版本升级会影响到分页、租户、动态表名这些依赖底层解析的组件。3.2 升级后常见的不兼容问题我实际遇到过的几个升级“后遗症”先列出来给各位打个预防针分页插件解析SQL失败升级之后分页查询总数统计有时会报错SQL日志里出现unsupported token或者unknown token。原因是新版JSqlParser对某些数据库方言的解析更严格以前能容忍的写法现在不认了。解决办法是检查自定义的count SQL或者临时关掉优化PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setOptimizeCountSql(false);租户插件漏加租户条件如果你的多租户是依赖TenantLineInnerInterceptor自动追加条件的升级后要重点检查“含有子查询的SQL”是否还正常追加租户条件。我遇到过一次一条用EXISTS子查询的SQL在升级后没有自动追加租户条件导致跨租户数据可见。这类问题靠代码审查很难发现必须靠测试用例去覆盖。自定义类型处理器失效升级JSqlParser相关依赖后如果你自定义过ISqlParser解析器它的包名或方法签名可能变了需要同步调整。最简单的方式是跑一遍单测看自定义解析器有没有加载成功。3.3 无法立即升级时的临时缓解手段有些老项目业务重、排期紧不能马上升版本。这种时候可以先用临时方案降低风险但要清楚这只是“止血”不是根治。临时措施我建议这样做把TenantLineInnerInterceptor暂时拆掉改成在业务代码里手动拼接租户条件缺点是改动量大但能先断掉攻击面。写一个全局过滤器拦截所有写orderBy、apply、last的请求参数拒绝含union、select、from、--、;等关键词的输入。数据库连接层面关掉多语句执行具体到MySQL是在JDBC连接串上不配置allowMultiQueriestrue这个参数很多项目默认没开但如果有DBA在中间层开了要立刻关掉。提示临时方案只能降低被利用概率不能保证绝对安全。只要排期允许还是要把升级正式做掉。4. 代码侧加固把注入路径从根上堵死4.1 高危方法清单这些API不要直接吃用户输入升级版本只是补了已知漏洞但代码里的不规范写法不清理未来新的CVE还会打在同一批代码上。先整理一份高危方法清单建议贴到团队Wiki里方法/场景风险等级说明wrapper.apply(String)高危参数会直接拼入WHERE片段禁止吃用户输入wrapper.last(String)高危常用在动态排序容易成为注入口wrapper.having(String)高危拼接逻辑同apply同样禁止用户输入Select注解中手写${}高危${}是字符串替换不经过预编译XML中${}高危同上只有#{}才走预编译orderBy参数直接排序中危即使不用last也要做白名单校验in条件传逗号拼接字符串中危推荐传集合而不是拼字符串这几种用法在业务代码里太常见了尤其是排序字段前端一传就拼。不要觉得“没人会构造恶意请求”安全扫描工具每天都在探测迟早会被扫到。4.2 正确的条件构造姿势对比示例拿排序举例错误写法很常见String orderBy request.getParameter(orderBy); wrapper.last(order by orderBy);正确写法是前端只传排序字段名和排序方向后端做白名单映射String orderField request.getParameter(orderField); String orderDir request.getParameter(orderDir); MapString, String allowMap new HashMap(); allowMap.put(createTime, create_time); allowMap.put(id, id); allowMap.put(userName, username); String column allowMap.get(orderField); if (StringUtils.isBlank(column)) { column create_time; } wrapper.last(order by column (asc.equalsIgnoreCase(orderDir) ? asc : desc));再比如动态条件拼装错误写法// 错误示范status传入 1 or 11 wrapper.apply(status status);正确写法wrapper.eq(StringUtils.isNotBlank(status), status, status);eq、in、like这些方法会走参数绑定传什么值都只当值处理不会破坏SQL结构。能用这些方法的地方坚决不要用apply和last。4.3 用自定义拦截器做兜底防线代码规范总有漏网之鱼为了保险我习惯在MyBatis层面加一道自定义拦截器对所有即将执行的SQL做一次危险特征检查。思路是拦截Executor的query和update方法拿到SQL后匹配黑名单关键字命中就打日志并抛异常。Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class SqlInjectionGuardInterceptor implements Interceptor { private static final ListString DANGEROUS_KEYWORDS Arrays.asList( union select, sleep(, benchmark(, information_schema, --, /*, */, ; ); Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; BoundSql boundSql ms.getBoundSql(invocation.getArgs()[1]); String sql boundSql.getSql().toLowerCase(); for (String keyword : DANGEROUS_KEYWORDS) { if (sql.contains(keyword)) { // 记录攻击日志方便溯源 log.error(blocked dangerous sql, keyword{}, sql{}, keyword, sql); throw new RuntimeException(sql blocked by guard interceptor); } } return invocation.proceed(); } }注意这个拦截器要排除掉MyBatis-Plus自己生成SQL里正常包含--注释的兼容情况如果误报高就改成只拦截“用户可控SQL片段”中的特征或者只记录不阻断先观察一段时间的命中情况再逐步加严。不要一上来就全局阻断容易把正常业务打断。4.4 顺手把全表更新/删除的坑也堵上排查过程中还发现一个相关度很高的问题SQL注入一旦成功往往伴随全表数据被更新或删除。MyBatis-Plus提供了一个现成的拦截器BlockAttackInnerInterceptor专门阻止无条件的全表更新和删除操作。Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); return interceptor; }加上之后如果业务代码里出现没有where条件的update或deleteMP会直接抛异常。这个能力和SQL注入防范是互补的注入攻击即使绕过前面手段想一次性拖走全表数据也会被这道闸拦住。5. 上线验证与长期监控修完不等于一劳永逸5.1 复测方法构造边界用例验证升级和代码整改做完之后要有一轮针对性的验证。我的建议是在测试环境准备几个边界用例逐一跑通第一个用例正常排序逻辑。传orderByid确认业务正常运行返回结果有序。第二个用例注入探测。传orderByid%20and%2012和orderByid%20and%2011对比页面响应。如果响应完全一致说明SQL没有被拼接进去或者被拦截器拦住了。如果响应出现差异说明还有注入点需要继续排查。第三个用例子查询探测。传orderByid,(select%201%20from%20dual)如果数据库报错或者页面出现异常说明拼接路径没有完全闭合。第四个用例多租户场景验证。用一个租户A的token去查数据重点检查SQL日志里tenant_id是否永远等于租户A的ID尤其是那种带union或子查询的SQL确认租户条件没有被绕过。注意这些探测用例最好放在独立测试环境而且只用无害的探测语句不要在联调环境或者生产环境玩。安全验证的目的是发现问题不是制造故障。5.2 日志监控哪些SQL特征需要警觉修完之后长期的安全运营更重要。建议把下面这些SQL特征接入告警一旦日志里频繁出现就要及时排查SQL文本里出现union select且不是业务里手写的白名单SQL。SQL里出现information_schema攻击者要拖库结构信息时必然碰它。SQL里出现sleep(、benchmark(大概率是时间盲注在探测。SQL里出现连续注释符/**/通常是为了绕过关键字过滤。数据库错误日志里短时间出现大量语法错误且集中在同一个接口。如果是通过日志平台做的告警可以先用关键字匹配稳定之后再用正则表达式收敛误报。刚开始宁多勿少记住“告警之后确认是误报”远比“漏报一次被脱库”成本低。5.3 升级后分页失效的排查实录文章开头提到热词里有“MybatisPlus分页失效”这里展开讲一下因为很多团队修完这个CVE之后都栽在这一步。我遇到的情况是项目从3.4.2升到3.5.4之后列表页的分页不生效了每页返回的数据越来越多。排查过程大概分三步第一步看SQL日志。发现分页插件生成的count SQL正常但实际查询SQL里没有LIMIT关键字。这就说明分页拦截器没有生效。第二步检查拦截器配置。发现项目里同时存在两个MybatisPlusInterceptor的Bean一个是新加的一个是老项目里自己写的配置类注册的。两个拦截器互相覆盖导致分页插件没被加载。删除掉多余Bean之后恢复。第三步如果配置没问题就要检查JSqlParser相关依赖。3.5.9版本如果没引入mybatis-plus-jsqlparser分页插件底层解析会直接抛异常但有些项目异常被吞了表现出来就是“查全表”。另外还有一个隐蔽问题如果项目里自定义了countSql升级之后解析失败分页组件会回退到“查全表”这个最隐蔽因为日志里看不到明显报错。处理方式是给PaginationInnerInterceptor设置optimizeCountSqlfalse或者把你自定义的count SQL改成兼容新版JSqlParser的写法。写在最后的个人建议接触过不少被SQL注入问题搞到深夜上线的项目坦白说每次漏洞出来最累的不是修版本而是排查那些历史代码里“当初图省事”的写法。CVE-2023-25330是一个典型的“框架能力边界”问题MyBatis-Plus确实做了大量防注入设计但只要有人用了它留的自由口子再厚的防护也有被打穿的可能。所以我建议团队在平时做code review时就把apply、last、${}这些用法当成“高危操作”来审视前端传什么字段、后端有没有白名单这类问题越早发现成本越低。修完这个CVE后我们团队还专门做了一次全员安全编码培训把这次排查中发现的真实案例作为反面教材大家印象都很深。后续再遇到类似的SQL注入公告处理起来就快多了。
返回列表