文章总结: 本文剖析了MySQLJDBC预编译机制与MyBatisPlus的SQL注入风险,指出orderBy等方法因协议限制无法预编译列名,导致漏洞。文章分析了QueryWrapper及apply等高危Sink点,提供了基于Lambda表达式、白名单映射和占位符机制的防御方案。建议开发者避免字符串拼接,利用编译期校验和白名单机制从源头阻断注入链路。 综合评分: 97 文章分类: 代码审计,漏洞分析,WEB安全,安全开发
【JAVA安全研究】 MySQL “伪预编译” 机制深度剖析与 MyBatisPlus 进阶 SQL 注入审计防御体系
原创
Shelter1234 Shelter1234
安全研究员
2026年1月31日 15:27 浙江
在 Java 代码审计中,MyBatisPlus (MP) 是出镜率极高的 ORM 框架。许多开发者和初级审计人员往往陷入一个误区:“只要用了 MP 的使用了 QueryWrapper 就是安全的”、“只要没有 ${} 就不会存在 SQL 注入风险”。
然而现实是残酷的。在 BootPlus 等开源系统的审计案例中,我们发现高危 SQL 注入往往潜伏在那些被认为“自动处理好”的方法中。
本文将剥开 JDBC 的外衣,揭示 MySQL 预编译的底层真相,剖析 QueryWrapper.orderBy 漏洞的必然性,并提供一份超越 ${} 的进阶审计清单。
第一部分:MySQL JDBC 的“谎言”——什么是真正的预编译
我们常说 PreparedStatement 能防注入,是因为它使用了预编译。但在默认配置下,Java 的 MySQL 驱动(Connector/J)并没有真正调用数据库的预编译接口。
1. 默认模式:客户端模拟 (Client-Side Emulation)
当 JDBC URL 未显式配置 useServerPrepStmts=true 时,驱动程序处于客户端模拟模式。
-
机制:JDBC 驱动在 JVM 内存中将 SQL 中的
?替换为参数值,并进行转义(如将'转为\')。 -
实质:发送给 MySQL 服务器的,依然是一条拼接好的、完整的纯文本 SQL。MySQL 服务器根本不知道这条语句原本是“预编译”的。
-
工作流:
- Java 代码:
SELECT * FROM user WHERE id = ?(参数:1001) - JDBC 驱动层:在 JVM 内存中,将
?替换为参数值,并进行转义处理(如将'转为\')。 - 发送报文:向 MySQL 服务器发送一条纯文本 SQL ——
COM_QUERY: SELECT * FROM user WHERE id = 1001。 - MySQL Server:接收到 SQL,进行语法解析、优化、执行。
真相:在默认模式下,MySQL 服务器根本不知道这条语句是“预编译”的。所谓的“防注入”,完全依赖于 JDBC 驱动程序在本地进行的字符转义。
2. 真实模式:服务端真预编译 (Server-Side Prepared Statements)
真正的预编译遵循严格的四步生命周期:Prepare -> Set -> Execute -> Deallocate。这需要数据库内核的支持。
让我们在 MySQL 命令行中还原这个过程:
-- Step 1: 准备 (Prepare)
-- 发送带 ? 占位符的骨架,MySQL 解析语法树并生成执行计划句柄
PREPARE stmt_query FROM'SELECT * FROM sys_user WHERE user_id = ?';
-- Step 2: 绑定变量 (Set)
SET@u_id=1;
-- Step 3: 执行 (Execute)
-- 传入句柄和参数,执行查询
EXECUTE stmt_query USING@u_id;
-- Step 4: 释放 (Deallocate)
DEALLOCATEPREPARE stmt_query;
执行结果
- 工作流:
- Prepare 阶段:驱动发送
COM_STMT_PREPARE: SELECT * FROM user WHERE id = ?。MySQL 解析并返回Statement_ID。 - Execute 阶段:驱动发送
COM_STMT_EXECUTE,包含Statement_ID和参数1001(二进制流)。
3.虚假的预编译:ORDER BY 排序
SQL 标准铁律:占位符 ? 只能替代数据值 (Literal Value),绝不能替代标识符 (Identifier,如表名、列名)。
如果我们强行尝试对 ORDER BY 使用预编译,会发生什么?
-- 场景:试图用参数决定排序字段
PREPARE stmt_sort FROM'SELECT * FROM sys_user ORDER BY ?';
SET@col='username';-- 我们想按 username 排序
EXECUTE stmt_sort USING@col;
执行效果:
结果:SQL 不会报错,但排序完全失效。
原因:MySQL 将 USING @col 传入的 'username' 解析为字符串常量。上述语句在执行层等价于:SELECT * FROM t_test_sort ORDER BY 'username'。order by后使用了单引号,这在 SQL 中通常表示字符串常量,因此其实质是按名为 'username' 的字符串进行排序,这样会导致语法错误或不产生预期的排序效果。因为每一行的排序依据都是字符串 'username'(常量),权重相同,所以排序条件被忽略。
第二部分:为什么“列名”绝不可预编译?
理解这一点的核心在于:无论是真预编译还是假预编译,它们都依赖于 SQL 语法的占位符规则。
1.MySql的标准规定: 标识符不可被预编译编译
SQL 语法规定:占位符 ? 只能替代“值” (Value),绝不能替代“标识符” (Identifier,如表名、列名)。
这一规则并非凭空制定,而是由 MySQL 处理 SQL 语句的核心流程 —— 执行计划的生成逻辑所决定的。
数据库视角的“预编译”全过程
当 MySQL 服务端接收到一条预编译指令(如 COM_STMT_PREPARE)时,它需要完成以下核心步骤:
- 语法解析 (Parsing):将 SQL 语句解析成抽象语法树 (AST)。
- 语义分析 (Semantic Analysis):检查表是否存在、列是否存在、用户是否有权限。
- 优化与执行计划生成 (Optimization):这是最关键的一步。数据库优化器需要根据
WHERE条件和ORDER BY的字段,决定是全表扫描还是走索引。
这里的矛盾在于:
- 值的占位:
WHERE age > ?。无论?是 18 还是 80,数据库都知道它要比较的是age列,执行计划大概率是走age的索引。 - 列名的占位:
ORDER BY ?。如果在生成执行计划时,数据库不知道?代表哪个列,它就无法确定是否利用索引排序,也无法知道该列的数据类型。
因此,SQL 标准规定:预编译占位符只能代表数据值(Literal Value),不能代表标识符(Identifier,如表名、列名、数据库名)。
2.MyBatisPlus (MP) 的无奈妥协
正因为 JDBC 和 MySQL 协议层面物理阻断了“列名预编译”这条路,MyBatisPlus (MP) 作为上层框架,在这个问题上没有任何魔法可以施展。
为了实现开发者想要的“动态排序”功能(即根据前端传参决定排序列),也就是要想实现与Mysql的同等功能,例如对t 表下的 user_id 列进行排序,而不是找对一个名为 t.user_id 的列进行排序,ORDER BY LENGTH(name)按名字长度排序,而非按length(name) 的列进行排序,ORDER BY create_time DESC, id ASC 进行复合排序而非找一个create_time DESC, id ASC的列。
因此MP 不得不做出设计上的妥协:
- 放弃
#{}(预编译):因为用了#{}就会变成'id'(带引号的字符串),导致排序失效。 - 拥抱
${}(拼接):MP 必须将用户传入的列名,以原始字符串的形式拼接到 SQL 语句中,使其成为合法的 SQL 标识符。
MyBatisPlus 在Order BY问题上充当了一个“诚实的搬运工”——它把从 Controller 层传来的 String,原封不动地搬到了 SQL 语句中。正是这种“因为协议限制而被迫进行的拼接”,为 SQL 注入打开了方便之门。
第三部分:MyBatisPlus 代码审计点:危险的 Sink —— QueryWrapper.orderBy
在 BootPlus 等系统的审计中,orderBy 是最高危的 Sink 点。让我们从代码流向(Data Flow)看看它是如何崩溃的。
1. 漏洞代码复盘
// Controller 接收参数 sort = "id, (select sleep(5))"
sysUserLoginLogService.getPage(..., sort, ...);
// Service 层
publicPage<T>getPage(..., Stringsort, BooleanisAsc) {
QueryWrapper<T>wrapper=newQueryWrapper();
if (StringUtils.isNoneBlank(sort)) {
// 关键点:MethodNameUtil 工具类并不是安全过滤器
wrapper.orderBy(true, isAsc, MethodNameUtil.camel2underStr(sort));
}
returnthis.page(page, wrapper);
}
2. 核心成因分析
MyBatisPlus 的 QueryWrapper.orderBy 方法签名如下: orderBy(boolean condition, boolean isAsc, R... columns)
这里的 columns 参数,MP 最终会将其直接拼接到 ORDER BY 关键字后面。
- 输入:
id, (select sleep(5)) - 中间处理:
camel2underStr仅处理大写字母,对全小写或特殊字符的 Payload 无效。 - 最终 SQL:
... ORDER BY id, (select sleep(5)) ASC ...
3.漏洞复现
第四部分:落地修复 orderBy 的安全写法
要修复 BootPlus 中的SQL注入问题,必须要把污染链路给断掉,例如使用名单映射机制避免orderBy 为污染数据。还有以一种更加优雅的写法,使用 MyBatisPlus (MP) 的 Lambda 语法 (::) 获取列名。它不仅彻底杜绝了 SQL 注入(因为即没有字符串拼接,也没有污染链数据),还利用编译器保证了字段名的正确性(如果字段改名,代码会报错,而不是运行时才崩溃)。
以下是基于 Lambda 引用 (Method Reference) 的两种修复实现方式。
方案核心原理
MP 的 LambdaWrapper 接受的参数不是字符串(String),而是 SFunction<T, ?>(函数式接口)。
- 不安全:
orderBy(sort)-> sort是变量,数据可能为脏数据,字符串直接拼 SQL造成sql注入。 - 安全:
orderBy(Entity::getCreateTime)-> MP 内部解析该方法对应的@TableField数据库列名,完全不涉及用户输入的字符串拼接。
实现方式一:Switch-Case 映射(最直观、简单)
如果你允许排序的字段不多(例如只有 3-5 个),使用 switch 或 if-else 是最清晰的。
publicPage<SysUserLoginLogEntity>getPage(..., Stringsort, BooleanisAsc) {
// 1. 创建 LambdaQueryWrapper (或者使用 wrapper.lambda())
LambdaQueryWrapper<SysUserLoginLogEntity>lambdaWrapper=newLambdaQueryWrapper<>();
// 2. 基础查询条件
lambdaWrapper.eq(SysUserLoginLogEntity::getUserId, getAdminId());
// 3. 【核心修复】根据前端字符串,手动映射到 Lambda 方法引用
// 这里不仅做了白名单,还转换成了安全的 Lambda 对象
if (StringUtils.isNoneBlank(sort) &&isAsc!=null) {
switch (sort) {
case"id":
lambdaWrapper.orderBy(true, isAsc, SysUserLoginLogEntity::getId);
break;
case"createTime":
lambdaWrapper.orderBy(true, isAsc, SysUserLoginLogEntity::getCreateTime);
break;
case"loginIp":
lambdaWrapper.orderBy(true, isAsc, SysUserLoginLogEntity::getLoginIp);
break;
default:
// 默认排序:防止攻击者传入乱七八糟的字符,默认按时间倒序
lambdaWrapper.orderByDesc(SysUserLoginLogEntity::getCreateTime);
break;
}
} else {
// 无参数时的默认排序
lambdaWrapper.orderByDesc(SysUserLoginLogEntity::getCreateTime);
}
returnthis.page(page, lambdaWrapper);
}
实现方式二:SFunction 策略 Map(高级、可扩展)
如果有大量字段需要支持排序,写 switch 太臃肿。我们可以利用 Java 的 SFunction 接口定义一个映射表。
importcom.baomidou.mybatisplus.core.toolkit.support.SFunction;
// ... 其他导入
// 1. 定义映射表:Key=前端参数, Value=MP的字段引用
privatestaticfinalMap<String, SFunction<SysUserLoginLogEntity, ?>>SORT_LAMBDA_MAP=newHashMap<>();
static {
SORT_LAMBDA_MAP.put("id", SysUserLoginLogEntity::getId);
SORT_LAMBDA_MAP.put("createTime", SysUserLoginLogEntity::getCreateTime);
SORT_LAMBDA_MAP.put("loginIp", SysUserLoginLogEntity::getLoginIp);
SORT_LAMBDA_MAP.put("userName", SysUserLoginLogEntity::getUserName);
}
publicPage<SysUserLoginLogEntity>getPage(..., Stringsort, BooleanisAsc) {
// 使用 wrapper.lambda() 切换到 Lambda 模式
QueryWrapper<SysUserLoginLogEntity>wrapper=newQueryWrapper<>();
wrapper.lambda().eq(SysUserLoginLogEntity::getUserId, getAdminId());
if (StringUtils.isNoneBlank(sort) &&isAsc!=null) {
// 【核心修复】从 Map 中获取安全的函数引用
SFunction<SysUserLoginLogEntity, ?>sortField=SORT_LAMBDA_MAP.get(sort);
if (sortField!=null) {
// 只有 Map 中存在的 Key 才能被执行,天然白名单 + 预编译安全
wrapper.lambda().orderBy(true, isAsc, sortField);
} else {
// 非法字段,走默认排序
wrapper.lambda().orderByDesc(SysUserLoginLogEntity::getCreateTime);
}
} else {
wrapper.lambda().orderByDesc(SysUserLoginLogEntity::getCreateTime);
}
returnthis.page(page, wrapper);
}
第五部分:超越 ${} —— MyBatisPlus 进阶审计清单
资深审计人员绝不会只盯着 XML 文件里的 ${}。在 Service 层代码中,MP 暴露了大量允许拼接 SQL 的方法(Sink)。
请将以下方法加入你的代码审计 CheckList:
1. .apply() —— 动态拼接之王
这是最容易被滥用的方法,允许拼接任意 SQL 到 WHERE 子句。
- ❌ 危险写法:
// 直接拼接变量
wrapper.apply("date_format(create_time, '%Y') = "+year);
- ✅ 安全写法(利用 MP 的参数占位机制):
// 使用 {0} 占位符,MP 会将其处理为预编译参数
wrapper.apply("date_format(create_time, '%Y') = {0}", year);
2. .last() —— 无视规则的追加
该方法会将传入的字符串直接拼接到最终 SQL 的末尾(通常用于拼接 LIMIT 限制)。它不提供参数预编译(占位符)功能,因此必须杜绝直接拼接前端传入的字符串。
-
❌ 危险写法(极高风险):直接拼接前端传来的字符串,如果攻击者传入 “1; DROP TABLE sys_user; –“,数据库就会被清空。
codeJava
// 假设 limitStr 是前端传来的参数,例如 "10"
// 危险:没有任何转义或预编译
wrapper.last("LIMIT "+limitStr);
-
✅ 安全写法(硬编码或严格校验):由于 .last() 不支持占位符,安全的做法是只写死常量,或者确保变量是经过强类型转换(如转为 int)后的安全数值,绝不直接使用 String 类型变量。
codeJava
// 场景 1:只用于限制取一条(最常见用法)
wrapper.last("LIMIT 1");
// 场景 2:如果必须使用变量,先手动转换为 Integer,杜绝注入可能
intsafeLimit=Integer.parseInt(requestLimit);
wrapper.last("LIMIT "+safeLimit);
3. .having() & .groupBy()
-
功能:.groupBy 用于分组,.having 用于对分组后的数据进行过滤(通常配合聚合函数使用)。
-
风险:.having() 的机制与 .apply() 完全一致,直接拼接字符串极易导致 SQL 注入。
-
❌ 危险写法(直接拼接):
codeJava
// 假设 score 是前端传来的参数 "10 OR 1=1"
wrapper.groupBy("type")
.having("sum(score) > " + requestScore);
-
✅ 安全写法(使用占位符):MyBatis-Plus 的 .having() 支持 {0} 占位符机制,会自动处理参数预编译。
codeJava
// 安全:MP 会将 {0} 替换为 ?,并将 requestScore 作为参数传入
wrapper.groupBy("type")
.having("sum(score) > {0}", requestScore);
参考您之前的格式,为您补充这些方法的 安全写法 与 危险写法 对比。
3. .having() & .groupBy()
-
功能:.groupBy 用于分组,.having 用于对分组后的数据进行过滤(通常配合聚合函数使用)。
-
风险:.having() 的机制与 .apply() 完全一致,直接拼接字符串极易导致 SQL 注入。
-
❌ 危险写法(直接拼接):
codeJava
// 假设 score 是前端传来的参数 "10 OR 1=1"
wrapper.groupBy("type")
.having("sum(score) > "+requestScore);
-
✅ 安全写法(使用占位符):MyBatis-Plus 的 .having() 支持 {0} 占位符机制,会自动处理参数预编译。
codeJava
// 安全:MP 会将 {0} 替换为 ?,并将 requestScore 作为参数传入
wrapper.groupBy("type")
.having("sum(score) > {0}", requestScore);
4. .exists() / .notExists()
-
功能:拼接 EXISTS (sql) 子句,用于判断子查询是否有结果。
-
风险:这两个方法的参数是一段完整的 SQL 字符串。如果在这段 SQL 中拼接变量,等同于裸写 JDBC 的拼接注入。
-
❌ 危险写法(字符串拼接):
codeJava
// 假设 name 参数为 "' OR 1=1 --"
wrapper.exists("select 1 from sys_user where name = '"+name+"'");
-
✅ 安全写法(使用占位符):这两个方法同样继承了 AbstractWrapper 的能力,支持占位符传参。
codeJava
// 安全:使用 {0} 占位符
wrapper.exists("select 1 from sys_user where name = {0}", name);
5. .select() (动态指定列)
-
功能:指定查询结果返回哪些字段(列)。
-
风险:虽然 .select() 通常用于指定列名,但如果开发者为了“灵活”,直接将前端传来的字符串放入 .select(),攻击者可以利用逗号分隔符注入子查询或报错函数。
-
❌ 危险写法(直接信任前端字段):
codeJava
// 假设前端传来的 columns 是 "id, (SELECT password FROM sys_user LIMIT 1) as pwd"
Stringcolumns=request.getParameter("fields");
wrapper.select(columns);
-
✅ 安全写法(白名单或 Lambda):
方法 A:优先使用 Lambda(推荐)只能选择实体类中存在的字段,编译期保证安全。
codeJava
wrapper.select(SysUser::getId, SysUser::getName);
方法 B:使用 Predicate 过滤(动态场景)如果必须动态,使用 MP 提供的 Predicate 进行过滤,只允许特定字段。
codeJava
// 只允许查询 id 和 name,其他字段(如 password)会被忽略
wrapper.select(SysUser.class, fieldInfo-> {
StringcolName=fieldInfo.getColumn();
return"id".equals(colName) ||"name".equals(colName);
});
总结与防御策略
审计 CheckList
在审计基于 MyBatisPlus 的项目时,请按优先级检查以下模式:
- 全局搜索
${:检查 XML 文件。 - 全局搜索
orderBy:追踪排序字段的来源(Source -> Sink)。 - 全局搜索
.apply,.last,.having:检查是否存在字符串拼接。
开发标准
- 拒绝拼接:能用
eq,like等 Wrapper 方法解决的,绝不手写 SQL 片段。 - 白名单机制:对于
ORDER BY和动态列名,必须使用白名单映射(Map 结构)或 Enum 校验。不要试图用正则去清洗 SQL,那是徒劳的。 - 正确使用占位符:在使用
.apply()等方法时,务必使用{0}, {1}占位符机制。
第六部分: 结语
在 Java 安全领域,SQL 注入的攻防对抗从未止步,而 MyBatisPlus 这类高频使用的 ORM 框架,往往让开发者陷入 “封装即安全” 的认知误区。本文从 MySQL JDBC 预编译的底层协议出发,撕开了 “客户端模拟预编译” 的伪装,也厘清了 SQL 语法规则下 “标识符无法预编译” 的核心矛盾 —— 这正是 MyBatisPlus orderBy 等方法存在注入风险的底层根源,也解释了为何单纯规避 ${} 无法杜绝注入。
从审计视角来看,MyBatisPlus 的安全风险远不止 XML 中的 ${} 拼接:apply()、last()、having()、exists() 等方法的字符串拼接逻辑,select() 动态列名的滥用,orderBy() 对用户输入的无过滤传递,都是高频的注入 “Sink 点”。而这些风险的本质,是开发者对框架底层实现的忽视,以及对 “动态拼接” 的过度依赖。
防御层面,本文给出的方案并非 “正则过滤” 这类治标之策,而是从源头阻断污染链路:通过 Lambda 语法将动态列名 / 排序字段固化为编译期可校验的实体方法引用,通过白名单映射(Switch-Case / 策略 Map)限定合法输入范围,通过框架原生的 {0} 占位符机制替代裸字符串拼接 —— 这些方案的核心,是让 “动态逻辑” 回归可控的开发规范,而非交给不可信的用户输入。
安全从来不是 “堆砌技巧”,而是 “理解本质”。无论是代码审计还是安全开发,穿透框架的语法糖,回归 JDBC 协议、SQL 语法规则等底层逻辑,才能真正识别隐藏的注入陷阱;将 “白名单校验”“拒绝裸拼接”“编译期校验” 融入开发全流程,才能在提升开发效率的同时,构建起抵御 SQL 注入的坚固防线。对于安全从业者而言,唯有跳出 “找漏洞 – 修漏洞” 的闭环,理解风险背后的技术原理,才能为业务系统构建真正可持续的安全能力。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:安全研究员 Shelter1234 Shelter1234《【JAVA安全研究】 MySQL “伪预编译” 机制深度剖析与 MyBatisPlus 进阶 SQL 注入审计防御体系》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论