公司动态
MyBatis动态SQL安全实战:从#{}与${}区别到SQL注入漏洞修复
1. 从一道课后习题到真实的安全漏洞最近在辅导团队新人学习Java EE特别是MyBatis框架时总会让他们做一道经典的课后习题“请简述MyBatis中#{}和${}的区别并说明动态SQL中如何使用。”这道题几乎成了面试和笔试的“八股文”答案也烂熟于心#{}是预编译安全${}是字符串拼接有SQL注入风险动态SQL里要慎用。然而就在上周我们一个已经上线运行了半年的服务突然被公司的安全团队用的正是奇安信的天镜扫描器扫出了一个高危的SQL注入漏洞。定位到代码一看问题恰恰出在一个“以为很安全”的动态SQL片段里一个不经意的${}用法成了罪魁祸首。这让我意识到书本上的“知道”和实战中的“做到”中间隔着一道巨大的鸿沟。这道课后习题远不是背下区别那么简单它直接关联着系统的命门——安全。所以今天我们不只谈区别更想结合这次真实的踩坑经历把动态SQL里那些关于${}的“灰色地带”和“安全红线”彻底讲透。你会看到在哪些场景下你可能会“被迫”或“无意中”用到${}以及如何在这种“高危操作”下依然能构建出铜墙铁壁。2.#{}与${}不仅仅是“安全”与“不安全”的二分法几乎所有教程都会告诉你#{}是占位符MyBatis会将其替换为?然后使用PreparedStatement进行预编译能有效防止SQL注入。而${}是字符串替换MyBatis会将其替换为变量的字面值直接拼接到SQL语句中存在注入风险。这个结论没错但它太绝对了容易让人产生一个误区用了${}就等于有漏洞。实际上风险不取决于你用没用${}而取决于这个${}所替换的内容是否用户可控。2.1 深入原理预编译如何筑起防线为了理解为什么#{}安全我们需要稍微深入一点。当MyBatis处理SELECT * FROM user WHERE id #{userId}时SQL解析与编译数据库驱动会先将SELECT * FROM user WHERE id ?这个模板SQL发送给数据库。数据库会对其进行语法分析、优化并生成一个执行计划。这个“”就是一个等待输入的参数位。参数传递之后驱动再将真实的参数值如userId5单独传递给数据库。关键点无论参数值是什么哪怕是userId “5 or 11”数据库也只会把它当作一个整体的字符串值去和id字段比较。它不会将“or 11”解析为新的SQL操作符因为SQL的结构在第一步就已经固定了。而${}则完全不同。SELECT * FROM user WHERE id ${userId}如果userId是字符串“5 or 11”最终生成的SQL会是SELECT * FROM user WHERE id 5 or 11。这条语句到达数据库时数据库会完整地解析id 5 or 11or 11成了新的查询条件导致查询出所有数据。2.2${}的合法使用场景当你需要“SQL片段”时既然${}这么危险为什么MyBatis还要保留它因为它解决的是#{}无法解决的问题动态拼接SQL语句的组成部分而非参数值。场景一动态表名或列名想象一个数据分表的场景用户数据按年份分表user_2023,user_2024。你想根据年份动态查询不同的表。select idselectByYear resultTypeUser SELECT * FROM user_${year} WHERE status #{status} /select这里${year}替换的是表名的一部分它是一个标识符而不是一个值。#{status}才是查询条件的值。你无法写成FROM user_#{year}因为数据库不允许FROM user_?这种语法。场景二排序字段动态化用户前端点击表头需要按不同字段排序。select idselectUsers resultTypeUser SELECT * FROM user ORDER BY ${orderBy} ${orderType} /select同样ORDER BY ? DESC是非法SQL。排序的列名和顺序ASC/DESC必须是SQL语句的一部分。关键认知转变在这些场景中${}注入的内容不应来自用户直接的、未经验证的输入。上面的${year}应该在你的业务逻辑中严格限定比如从当前日期计算或从一个预定义的枚举中获取${orderBy}应该在前端或后端对传入的字段名进行白名单校验比如只允许“name”, “create_time”等几个字段。3. 动态SQL的“安全区”与“风险区”实战拆解MyBatis的动态SQL标签if,choose,where,set,foreach极大地简化了复杂查询的编写。大部分时候我们在这些标签里混用#{}和${}但风险就藏在混用的细节里。3.1 安全区在动态标签内使用#{}构建条件这是最标准、最安全的用法。动态SQL标签负责逻辑判断#{}负责安全地传入值。select idfindUsers resultTypeUser SELECT * FROM user where if testname ! null and name ! ‘’ AND name #{name} /if if testemail ! null AND email #{email} /if if teststatusList ! null and statusList.size 0 AND status IN foreach collectionstatusList itemstatus open( separator, close) #{status} /foreach /if /where /select在这个例子中即便name来自用户输入因为使用了#{}所以是安全的。foreach标签内遍历集合每个项也用#{}包裹确保了IN查询的安全性。3.2 风险区在动态标签内混用${}这里就是坑开始的地方。我们来看一个我踩过的真实案例的简化版。需求一个后台管理系统需要支持根据管理员选择的“字段”和“关键词”进行动态模糊搜索。字段可选“用户名(name)”或“邮箱(email)”。最初的错误实现select iddynamicSearch resultTypeUser SELECT * FROM user where if testfield ! null and keyword ! null AND ${field} LIKE CONCAT(‘%’, #{keyword}, ‘%’) /if /where /select看起来好像没问题field用了${}因为它是列名keyword用了#{}因为它是值。但问题在于field这个参数是从前端下拉框传过来的。攻击者完全可以绕过前端直接构造HTTP请求将field参数设置为field “name’ OR ‘1’‘1’ -- ” keyword “anything”拼接后的SQL会变成SELECT * FROM user WHERE name’ OR ‘1’‘1’ -- LIKE CONCAT(‘%’, ‘anything’, ‘%’)--是SQL注释符后面的内容被注释掉。最终执行的查询是WHERE name’ OR ‘1’‘1’由于语法错误或者恒真条件可能导致信息泄露或异常。奇安信扫描器正是捕捉到了这种模式它发现你的SQL语句中存在来自请求参数的、未经验证的字符串直接拼接${}即使它当前被用在列名位置扫描器也会保守地判定为潜在注入点报出高危漏洞。从安全角度看这种报法是合理的。4. 漏洞修复从“可用”到“安全”的代码重构收到漏洞报告后修复过程不是简单地把${}换成#{}那会语法错误而是需要建立一套防御机制。4.1 方案一白名单校验最推荐在参数传入MyBatis的XML之前在Java服务层进行严格的校验。public ListUser dynamicSearch(String field, String keyword) { // 定义允许查询的字段白名单 SetString allowedFields new HashSet(Arrays.asList(“name”, “email”)); if (!allowedFields.contains(field)) { // 抛出业务异常记录日志或者使用一个安全的默认值 throw new IllegalArgumentException(“非法的查询字段: “ field); // 或者 field “name”; // 使用默认值 } // 此时field一定是安全的 return userMapper.dynamicSearch(field, keyword); }这样无论前端传来什么到了MyBatis这一层${field}中的field只可能是“name”或“email”彻底杜绝了注入的可能。这是最小权限原则的体现。4.2 方案二使用choose标签枚举所有可能适用于场景少的情况如果动态字段的可能性很少可以直接在XML中用choose写死所有分支完全避免${}。select iddynamicSearch resultTypeUser SELECT * FROM user where choose when test“field ‘name’ and keyword ! null” AND name LIKE CONCAT(‘%’, #{keyword}, ‘%’) /when when test“field ‘email’ and keyword ! null” AND email LIKE CONCAT(‘%’, #{keyword}, ‘%’) /when otherwise !-- 可加11或不加条件视业务而定 -- /otherwise /choose /where /select这个方法将逻辑判断完全收拢在XML中无需在Java代码中校验字段但缺点是如果可选项很多XML会变得冗长。4.3 方案三对${}内容进行转义复杂且不推荐理论上可以对传入的field值进行严格的SQL标识符转义比如检查是否只包含字母、数字、下划线并去除反引号等。但MyBatis本身不提供这个功能需要自己实现且不同数据库的标识符规则略有不同容易留下死角。除非有非常特殊和受控的场景否则不如白名单方案直接可靠。我们最终采用了方案一白名单校验修复后代码清晰安全性也易于理解和审计。重新提交扫描后漏洞状态标记为“已修复”。5. 高级场景下的${}风险与防御除了上述明显的动态列名还有一些更隐蔽的场景。5.1foreach标签中的${}陷阱foreach通常用于IN查询我们一般这样安全地使用AND id IN foreach collection“idList” item“id” open“(” separator“,” close“)” #{id} /foreach但如果你需要动态决定IN查询的字段呢比如按id集合查或者按code集合查。AND ${inField} IN foreach collection“valueList” item“value” open“(” separator“,” close“)” #{value} /foreach看${inField}又出现了同样的必须对inField进行白名单校验“id”, “code”。5.2ORDER BY与动态排序的终极安全写法结合白名单一个安全的动态排序实现如下// Service层 public ListUser getUsers(String orderBy, String orderType) { MapString, String columnMap new HashMap(); columnMap.put(“name”, “name”); columnMap.put(“time”, “create_time”); // 校验并获取安全的数据库列名 String safeOrderBy columnMap.get(orderBy); if (safeOrderBy null) { safeOrderBy “create_time”; // 默认值 } // 校验排序方式 String safeOrderType “ASC”.equalsIgnoreCase(orderType) || “DESC”.equalsIgnoreCase(orderType) ? orderType.toUpperCase() : “DESC”; return userMapper.selectUsers(safeOrderBy, safeOrderType); }!-- Mapper XML -- select idselectUsers resultTypeUser SELECT * FROM user ORDER BY ${safeOrderBy} ${safeOrderType} /select经过Service层的过滤传到XML的${safeOrderBy}和${safeOrderType}已经是绝对安全的内部值了。5.3LIMIT子句的“历史遗留”问题在MySQL中LIMIT子句后的参数不允许使用预编译的占位符即LIMIT ?, ?在某些旧版本驱动或场景下不支持。这是一个历史遗留的“特例”。因此老代码中经常看到LIMIT ${offset}, ${pageSize}这风险极高offset和pageSize通常是数字但攻击者可以传入0; DROP TABLE user --之类的字符串。修复方案强制类型转换与范围校验在Java层确保offset和pageSize是正整数并设置合理上限如每页最多100条。使用MyBatis的RowBounds不推荐用于分页查询性能不佳。最佳实践使用PageHelper等成熟分页插件。这些插件在底层会安全地处理分页参数无需你手动拼接LIMIT。6. 安全开发习惯与代码审计要点经过这次事件我们在团队内推行了几条关于MyBatis动态SQL的硬性规定禁用搜索在IDE和代码仓库中全局搜索${。任何一处的出现都必须经过安全评审说明其必要性和已采取的安全措施如白名单。参数校验前置坚持在Service层或专门的校验器中对所有传入Mapper的参数进行校验特别是用于${}的参数必须进行白名单或强类型转换。代码审查重点在Code Review时动态SQL是必看项。重点关注if、foreach、choose标签内的表达式看是否有未经验证的参数直接用于字符串拼接${}或OGNL表达式注入风险test属性虽然一般安全但也要注意。依赖安全插件在Maven或Gradle中引入find-sec-bugs、SpotBugs等静态代码安全扫描插件并将其集成到CI/CD流程中自动检测潜在的${}误用问题。理解扫描器报告当收到奇安信、Fortify等安全扫描器的报告时不要急于标记“误报”。首先要彻底理解它报出的原因即使当前参数看似可控也要思考未来代码迭代、参数传递路径变化后是否可能失控。最安全的态度是除非能证明绝对安全否则一律视为不安全。那道关于#{}和${}区别的课后习题答案不应该止步于“一个安全一个不安全”。真正的答案是一套结合了白名单校验、最小权限原则、参数前置过滤和严格代码审查的完整防御体系。动态SQL是MyBatis的利器但${}就像是这把利器的锋刃用得好可以披荆斩棘用不好就会伤及自身。记住在安全问题上永远不要心存侥幸也永远不要相信任何未经验证的外部输入。