MyBatis动态SQL安全实践:${}与#{}的深度解析与SQL注入防御

📅 2026/7/29 4:49:08 👁️ 阅读次数 📝 编程学习
MyBatis动态SQL安全实践:${}与#{}的深度解析与SQL注入防御

1. 项目概述:从一道课后习题到企业级安全实践

最近在辅导团队新人学习Java EE,特别是MyBatis框架时,总会遇到第三章关于动态SQL的课后习题。这些习题看似基础,但往往藏着许多新手乃至有一定经验的开发者都容易忽略的“坑”。尤其是在当前企业开发环境中,安全扫描工具(如奇安信等)的普及,让一个曾经被简单带过的知识点——${}#{}的区别——变成了可能引发线上事故的“高危漏洞”。这道课后习题,远不止是教会你如何拼接SQL字符串,它更像是一把钥匙,打开了理解MyBatis执行原理、SQL注入防御以及编写健壮数据访问层的大门。如果你正在学习MyBatis,或者在工作中被安全报告提示了SQL注入风险却不知如何彻底解决,那么这次围绕动态SQL的深度拆解,正是为你准备的。

我们将从一个典型的课后习题场景出发,逐步深入到${}的诱惑与陷阱、#{}的魔法原理,并最终给出在企业级开发中安全、高效使用动态SQL的完整方案和避坑指南。这不仅仅是一次语法学习,更是一次安全编码思维的建立。

2. 动态SQL核心:${}#{}的终极辨析

几乎所有MyBatis的入门教程都会提到:动态SQL中参数传递要用#{},因为它能防止SQL注入。而${}是字符串拼接,有风险。但为什么?什么时候非用${}不可?安全扫描报的漏洞到底怎么修?这一章,我们抛开教条,从根上弄明白。

2.1#{}:预编译的守护者

#{}的工作原理,是MyBatis安全性的基石。它不是简单的字符串替换。当你写下SELECT * FROM user WHERE id = #{userId}时,MyBatis会创建一个PreparedStatement对象。

底层过程解析:

  1. SQL解析与预编译:JDBC驱动会将这句SQL发送给数据库,数据库会对其进行解析和编译,生成一个执行计划。此时,#{userId}被视为一个占位符(?),而不是具体的值。
  2. 参数传递:当真正执行时,MyBatis再将具体的参数值(例如123)通过setXXX方法(如setInt)安全地设置到这个占位符上。
  3. 关键优势:因为值是在预编译后才传入的,所以无论这个值是什么内容(哪怕是包含‘ OR ‘1’=‘1这样的恶意字符串),数据库都只会把它当作一个普通的参数值来处理,而不会将其作为SQL语法的一部分进行解析。这就从根本上杜绝了SQL注入。

注意#{}不仅用于WHERE条件,它适用于任何需要参数值的地方,如INSERT VALUES(#{name})UPDATE SET column=#{value},甚至在IN子句中(需结合动态SQL标签处理列表)。

2.2${}:直接的字符串替换器

#{}相反,${}的行为简单而“危险”。它是在SQL语句被预编译之前,就进行直接的字符串替换。

底层过程解析:

  1. 字符串拼接:MyBatis在处理SELECT * FROM user WHERE name = ‘${userName}’时,会直接从传入的参数对象中取出userName属性的值,假设为“Alice”
  2. 替换生成最终SQL:MyBatis会直接进行字符串拼接,生成SELECT * FROM user WHERE name = ‘Alice’
  3. 提交执行:这条完整的、拼接好的SQL语句被发送到数据库执行。

风险即刻显现:如果userName来自不可信的用户输入,且值为‘ OR ‘1’=‘1’ --,那么拼接后的SQL将变成:

SELECT * FROM user WHERE name = ‘’ OR ‘1’=‘1’ --‘

--是SQL注释符,这会导致条件永远为真,从而泄露所有用户数据。这就是经典的SQL注入。

2.3 为何有时不得不使用${}

既然${}这么危险,为什么MyBatis还要保留它?因为它处理的是“SQL语法部分”,而非“参数值”。这是理解其应用场景的关键。

必须使用${}的典型场景:

  1. 动态表名或列名:SQL的语法部分(如表名、列名、ORDER BY字段)无法使用预编译占位符。

    <!-- 根据类型查询不同的统计表 --> SELECT * FROM ${tableName} WHERE year = #{year}

    这里的${tableName}可能是“stats_2023”“stats_2024”重要前提:这个tableName值必须是内部逻辑可控的(如从枚举或配置中读取),绝不能来自用户前端输入。

  2. 动态排序(ORDER BY):同样,排序字段和方向是语法。

    ORDER BY ${sortField} ${sortOrder}

    安全实践:必须对sortFieldsortOrder进行严格的白名单校验。例如,只允许“create_time”“amount”等有限的几个字段,sortOrder只允许“ASC”“DESC”

  3. 拼接SQL片段:在一些极其复杂的动态查询中,可能需要根据条件拼接完整的WHEREJOIN片段。这需要极高的警惕性,通常意味着你的数据模型或查询设计可能需要反思。

实操心得:在我的经验中,99%的${}使用场景都可以通过优化设计来避免。例如,动态表名可以通过分库分表中间件或数据库视图来屏蔽;动态排序可以通过在业务层映射枚举,确保传入的只能是白名单值。每次你想用${}时,先问自己:这个值是否100%来自系统内部、绝对可信?如果不是,请寻找替代方案。

3. 课后习题实战:安全漏洞场景还原与修复

现在,让我们回到常见的课后习题场景,看看那些“不经意”的写法如何埋下隐患,以及如何修复。

3.1 漏洞场景:模糊查询与排序的陷阱

习题示例:实现一个用户搜索功能,可以根据用户名模糊搜索,并支持按不同字段排序。

危险写法(常见于初学者)

<select id="searchUsers" resultType="User"> SELECT * FROM user WHERE 1=1 <if test="username != null and username != ‘’"> AND name LIKE ‘%${username}%’ </if> ORDER BY ${orderBy} DESC </select>

漏洞分析

  1. LIKE ‘%${username}%’:直接拼接用户输入的username,存在SQL注入风险。攻击者可以输入“%‘ OR ‘1’=‘1’ --”进行攻击。
  2. ORDER BY ${orderBy}orderBy参数直接拼接,攻击者可输入诸如“1; DROP TABLE user; --”之类的值,导致灾难性后果。即使不注入,传入不存在的列名也会导致SQL错误。

3.2 安全修复方案:使用#{}与OGNL函数

修复后安全写法

<select id="searchUsers" resultType="User"> SELECT * FROM user WHERE 1=1 <if test="username != null and username != ‘’"> AND name LIKE CONCAT(‘%’, #{username}, ‘%’) </if> ORDER BY <choose> <when test=“orderBy == ‘createTime’”>create_time</when> <when test=“orderBy == ‘username’”>username</when> <otherwise>id</otherwise> </choose> DESC </select>

修复解析

  1. 模糊查询:使用CONCAT函数,将通配符‘%’与安全的参数#{username}在数据库层面进行拼接。这样,username的值始终作为参数传入,无法破坏SQL结构。不同数据库的CONCAT语法可能略有差异(如MySQL是CONCAT,Oracle是||),需注意兼容性。
  2. 动态排序:放弃危险的${},改用<choose>标签进行白名单映射。orderBy参数现在只是一个业务逻辑标识(如“createTime”),而非直接的列名。MyBatis根据这个标识,选择预定义的安全的列名字符串进行拼接。这彻底杜绝了注入和无效列名的风险。

3.3 使用<bind>标签提升可读性与兼容性

对于复杂的拼接,特别是涉及数据库方言时,<bind>标签是利器。它可以在动态SQL内部创建一个变量,这个变量可以在后续使用。

改进的模糊查询写法

<select id="searchUsers" resultType="User"> <bind name=“usernamePattern” value=“‘%’ + username + ‘%’” /> SELECT * FROM user WHERE 1=1 <if test="username != null and username != ‘’"> AND name LIKE #{usernamePattern} </if> </select>

优势

  • usernamePattern是一个在OGNL表达式中计算出的字符串,但最终#{usernamePattern}仍以预编译参数的形式传入SQL。它既实现了字符串拼接的逻辑,又保证了安全性。
  • 将拼接逻辑前置,使主要的SQL语句更加清晰。
  • 方便处理更复杂的拼接逻辑,且与数据库方言解耦(拼接发生在MyBatis层,而非SQL层)。

4. 企业级动态SQL最佳实践与安全规范

在真实的企业开发中,尤其是面临严格安全扫描(如奇安信)的环境,仅知道如何修复单个点是不够的,需要建立一套规范和最佳实践。

4.1 代码层面:强制安全编程规范

  1. 静态代码扫描集成:在项目的CI/CD流水线中集成SonarQube、Checkstyle或Alibaba Java Coding Guidelines等工具,并配置规则,直接禁止在XML映射文件中使用${}(或仅允许在特定模式如“${tableName}”tableName符合特定正则时使用)。让机器在代码合并前就发现问题。

  2. Mapper接口参数设计

    • 对于排序、分组等参数,定义明确的枚举类型作为接口参数,而不是传递字符串。
    public enum SortField { CREATE_TIME(“create_time”), USER_NAME(“user_name”); private final String columnName; // getter... } List<User> searchUsers(@Param(“username”) String username, @Param(“sortBy”) SortField sortBy);
    • 在Service层,将前端的字符串参数转换为枚举,转换失败则使用默认值。这样,Mapper XML中接收到的就是安全的枚举,可以直接用于<choose>标签的判断。
  3. 集中式SQL片段管理:对于必须使用${}的动态表名等场景,将其抽象到独立的<sql>片段中,并在片段顶部添加清晰的警告注释,说明该片段的安全前提和责任人。

    <!-- 警告:此片段使用${},tableNameParam必须为内部可控值,禁止传入用户输入 --> <sql id=“dynamicTable”> FROM ${tableNameParam} </sql>

4.2 MyBatis配置与插件:增强防御

  1. 配置defaultScriptingLanguageRAW网上有些文章建议通过配置来限制动态SQL。实际上,MyBatis的核心防御在于理解并正确使用#{}。更有效的方法是使用插件。
  2. 自定义拦截器插件:可以编写一个MyBatis的Interceptor插件,对BoundSql的SQL语句进行解析,在运行时检测是否包含非法的${}使用模式(例如,检测到${后面跟的不是预定义的安全关键字),并记录警告或抛出异常。这是一个更高级的防御层。

4.3 应对安全扫描报告

当奇安信等安全扫描工具报告你的MyBatis XML文件存在SQL注入漏洞时,通常是指出了使用${}的位置。排查和修复流程如下:

  1. 定位:根据报告提供的文件路径和行号,快速定位到具体的Mapper XML文件及SQL语句。
  2. 分析:判断该${}的使用场景。
    • 场景A:用于传递值(如WHERE name = ‘${value}’:这是高风险,必须修复。改为#{value},并检查上下文(如LIKE)是否需要配合CONCAT<bind>
    • 场景B:用于动态表名/列名/排序(如ORDER BY ${field}:这是中风险。修复方案是改为白名单机制,使用<choose>/<when>或从枚举映射。
  3. 修复与测试
    • 实施上述修复方案。
    • 编写或补充单元测试:这是关键一步。不仅要测试正常功能,还要编写安全测试用例,尝试传入各种边缘和恶意参数(如包含单引号、分号、SQL关键字的字符串),确保SQL执行正确且不会报语法错误或产生非预期结果。可以使用JUnit + 内存数据库(如H2)快速完成。
    • 在测试环境中重新部署,触发安全扫描,确认漏洞已关闭。

5. 深度进阶:<script>Provider与复杂动态SQL

对于极其复杂的动态SQL,在XML中使用大量的<if><choose>标签会导致可读性急剧下降。MyBatis提供了另外两种强大的方式。

5.1 使用<script>标签编写内联动态SQL

在注解@Select@Update等中,可以直接编写动态SQL。

@Select(“<script>” + “SELECT * FROM user ” + “WHERE 1=1 ” + “<if test=‘username != null’>” + “ AND name LIKE CONCAT(‘%’, #{username}, ‘%’)” + “</if>” + “<if test=‘statusList != null and statusList.size() > 0’>” + “ AND status IN ” + “ <foreach collection=‘statusList’ item=‘item’ open=‘(’ separator=‘,’ close=‘)’>” + “ #{item}” + “ </foreach>” + “</if>” + “</script>”) List<User> findUsersByCriteria(@Param(“username”) String username, @Param(“statusList”) List<Integer> statusList);

适用场景:SQL逻辑相对简单,且开发者希望DAO层接口和SQL定义在一起,避免在XML文件中跳转。缺点:复杂的SQL会使得注解字符串非常长,影响代码美观和编辑体验。

5.2 使用SQL Provider类实现极致灵活

对于高度动态、条件组合极其复杂的查询,@SelectProvider@UpdateProvider等注解是终极武器。

public class UserSqlProvider { public String searchUsers(Map<String, Object> params) { String username = (String) params.get(“username”); List<Integer> statusList = (List<Integer>) params.get(“statusList”); StringBuilder sql = new StringBuilder(“SELECT * FROM user WHERE 1=1”); if (username != null && !username.isEmpty()) { sql.append(“ AND name LIKE CONCAT(‘%’, #{username}, ‘%’)”); // 注意,这里参数名仍需与Mapper接口匹配 } if (statusList != null && !statusList.isEmpty()) { sql.append(“ AND status IN (“); for (int i = 0; i < statusList.size(); i++) { sql.append(“#{statusList[“).append(i).append(“] }”); if (i != statusList.size() - 1) { sql.append(“,”); } } sql.append(“)”); } sql.append(“ ORDER BY create_time DESC”); return sql.toString(); } } // 在Mapper接口中使用 @SelectProvider(type = UserSqlProvider.class, method = “searchUsers”) List<User> searchUsersByProvider(@Param(“username”) String username, @Param(“statusList”) List<Integer> statusList);

优势

  • 完全的Java代码控制:你可以使用所有Java语言特性(循环、条件、字符串操作、工具方法)来构建SQL,逻辑表达能力远超XML标签。
  • 易于调试:可以在Provider方法中打日志,输出最终拼接的SQL字符串,调试非常方便。
  • 类型安全:虽然参数是Map,但你可以通过方法签名和内部强转来获得一定的类型安全。

关键注意事项与安全实践

  • 警惕手动拼接:在Provider中,你是在用Java代码拼接SQL字符串。对于部分,必须坚持使用#{paramName}的占位符语法(如上例中的#{username}#{statusList[i]}),让MyBatis后续进行预编译处理。绝对不要在Provider里用字符串“+”操作符拼接用户输入的值。
  • 对于动态表名/列名:如果必须在Provider中处理,应使用白名单机制。例如,从一个预定义的枚举或配置Map中根据传入的key获取真正的表名,然后再用字符串拼接进SQL。永远不要将未经校验的用户输入直接拼接到表名、列名的位置
  • 代码可读性:复杂的Provider方法可能会变得难以维护,请务必添加充足注释,并将不同条件的构建逻辑抽取为私有方法。

6. 常见问题排查与性能考量

6.1 动态SQL使用中的典型“坑”

  1. <if>标签判断整数0失效<if test=“status != null”>,如果status是int类型且值为0,这个判断会为false,因为MyBatis的OGNL表达式将0视为false。对于基本数据类型,应使用其包装类(Integer),或者使用更精确的判断<if test=“status != null and status != 0”>

  2. <foreach>遍历Map<foreach collection=“map” item=“value” index=“key”>,注意item是值,index是键,顺序和遍历List时不同。

  3. 分页插件兼容性:在使用PageHelper等分页插件时,复杂的动态SQL(特别是带有<foreach>或嵌套查询)可能会影响分页计数查询(count语句)的生成准确性。务必在集成后,对各种动态查询条件组合进行分页测试,验证总条数和当前页数据是否正确。

  4. ${}导致的模糊查询索引失效:即使安全地使用${}进行动态列选择,也可能带来性能问题。例如,WHERE ${dynamicColumn} = #{value},如果dynamicColumn变化频繁,数据库可能难以针对此查询建立有效的索引命中策略。

6.2 性能优化建议

  1. 避免过度动态化:不要为了“灵活”而将每个条件都做成动态的。如果某个条件在业务上几乎总是存在,可以将其作为必传参数,减少动态标签的解析开销和SQL的复杂度。

  2. 优先使用<where>标签<where>标签会自动处理WHERE关键字以及去除首个条件前的AND/OR,比手动写WHERE 1=1更符合SQL标准,一些数据库优化器可能对1=1这种恒真条件有更好的处理,但<where>是更优雅和安全的做法。

  3. 批量操作使用<foreach>的批处理模式:对于INSERT INTO ... VALUES (...), (...), ...这种语句,<foreach>拼接大量值会导致SQL语句极长。考虑在<foreach>内使用<bind>预编译所有参数,或者直接使用MyBatis的ExecutorType.BATCH模式配合循环单条插入,在SqlSession层面进行批处理提交,性能更高且能有效控制SQL语句长度。

  4. 监控与日志:开启MyBatis的SQL日志(配置log4j.logger.org.apache.ibatis=DEBUG或使用p6spy等工具),观察最终执行的SQL语句。检查动态SQL生成的语句是否最优,是否存在不必要的子查询或条件。特别是在Provider模式下,打印出构建的SQL字符串进行审查是性能调优的第一步。

动态SQL是MyBatis的灵魂特性,它赋予了DAO层巨大的灵活性。然而,能力越大,责任越大。${}#{}之间的选择,本质上是在灵活性与安全性之间做权衡。通过建立严格的安全规范(值用#{},语法用白名单)、善用<bind>和Provider等高级特性,并辅以完善的测试和安全扫描,我们完全可以编写出既灵活高效又坚如磐石的数据访问代码。记住,每一次SQL拼接,都值得你停下来思考一下:这样做,安全吗?