很多人以为在MyBatis里用了#{}就算搞定了SQL注入防御,实际上这只是一个开始。真正的问题往往藏在那些看似安全的写法里:模糊查询的拼接、动态排序字段、IN条件的处理、甚至是某些场景下对$()的滥用。这些地方一旦处理不当,预编译机制就会被绕过,攻击者依然可以构造恶意参数完成注入。
预编译的底层逻辑不是简单的占位符替换要理解MyBatis的防注入机制,必须先搞清楚JDBC层面PreparedStatement到底做了什么。预编译语句的执行分为两步:第一步是把SQL骨架发送给数据库进行编译和解析,第二步才是把参数值传递进去执行。参数值无论包含什么特殊字符,都不会改变已经编译好的SQL逻辑结构。MyBatis的#{}本质上就是调用PreparedStatement的参数绑定方法,把用户输入当作纯数据处理。而${}是直接进行字符串替换,替换发生在SQL语句被编译之前,这就意味着恶意内容可以改变SQL的语法结构。
模糊查询里的陷阱:LIKE条件不能直接拼模糊查询是SQL注入的重灾区。很多开发人员会写出这样的代码:
SELECT * FROM user WHERE name LIKE '%${keyword}%'
这种写法直接把用户输入拼接到SQL里,攻击者输入一个单引号就能闭合前面的引号并插入自己的SQL片段。正确的做法是把百分号和参数一起通过#{}传递:
SELECT * FROM user WHERE name LIKE CONCAT('%', #{keyword}, '%')
或者在使用MySQL时也可以写成:
SELECT * FROM user WHERE name LIKE "%"#{keyword}"%"
这两种写法都保证了keyword的值是通过预编译参数绑定的,不会参与SQL语法构建。需要注意的是,不同数据库的字符串拼接函数不同,Oracle用||,SQL Server用+,要根据实际数据库选择合适的方式。
动态排序和动态表名:不得不用的${}该怎么处理ORDER BY和GROUP BY后面的字段名、表名这些标识符,JDBC的PreparedStatement不支持用占位符绑定。这是SQL标准层面的限制,不是MyBatis能解决的。所以很多人在这个地方直接用了${}:
SELECT * FROM user ORDER BY ${orderColumn} ${orderDirection}
这种写法极其危险,因为orderColumn和orderDirection完全来自用户输入的话,攻击者可以注入任意SQL。正确的处理方式是做白名单校验。在Java代码里定义一个允许的字段名集合和排序方向集合:
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "username", "create_time");
private static final Set<String> ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");
public List<User> getUsersByOrder(String orderColumn, String orderDirection) {
if (orderColumn == null || !ALLOWED_COLUMNS.contains(orderColumn)) {
throw new IllegalArgumentException("Invalid column: " + orderColumn);
}
if (orderDirection == null || !ALLOWED_DIRECTIONS.contains(orderDirection.toUpperCase())) {
throw new IllegalArgumentException("Invalid direction: " + orderDirection);
}
return mapper.getUsersByOrder(orderColumn, orderDirection.toUpperCase());
}
校验通过后再传入Mapper,Mapper里用${}引用这些已经经过严格校验的值。白名单校验必须做在服务端,不能依赖前端校验,因为前端校验可以被轻松绕过。
IN条件的参数绑定:foreach标签的正确姿势IN查询是另一个容易出问题的地方。MyBatis的foreach标签本质上也是在动态拼接SQL,如果使用不当同样会引入注入风险。常见的错误写法是把foreach的collection直接拼接成字符串:
SELECT * FROM user WHERE id IN (${ids})
这里ids如果是一个拼接好的字符串"1,2,3",看似没问题,但如果用户能控制这个字符串的内容,注入就发生了。正确的做法是用foreach遍历集合,每个元素都用#{}绑定:
SELECT * FROM user WHERE id IN
<foreach collection="idList" item="id" open="(" separator="," close=")">
#{id}
</foreach>
这样MyBatis会为每个id生成一个预编译参数占位符,最终的SQL类似WHERE id IN (?, ?, ?),每个参数值都安全绑定。foreach标签内部使用#{}时,MyBatis走的是参数绑定路径,不会进行字符串替换。
MyBatis动态SQL里的隐式注入风险动态SQL标签如if、choose、when、otherwise本身不会引入注入风险,因为它们只控制SQL片段的拼接逻辑,不处理参数值。但问题在于这些标签内部如果混用了${},风险就来了。比如下面这段代码:
<if test="userName != null and userName != ''">
AND user_name LIKE '%${userName}%'
</if>
if标签的判断逻辑是安全的,但内部用了${}来拼接userName,注入风险依然存在。改成#{}配合CONCAT就能消除这个风险:
<if test="userName != null and userName != ''">
AND user_name LIKE CONCAT('%', #{userName}, '%')
</if>
另外,test属性里的OGNL表达式是在MyBatis内部解析的,不会传递到数据库层,所以test里的变量引用不会造成SQL注入。但要注意test表达式本身可能存在的OGNL注入问题,不过那属于另一个安全层面,影响范围通常局限于MyBatis配置层面。
注解方式下的预编译使用使用MyBatis注解开发时,同样要遵循预编译原则。@Select、@Insert等注解里的SQL,用#{}的地方走预编译,用${}的地方走字符串替换。很多人写注解SQL时图方便,对表名、列名直接用${},这跟XML配置里的风险完全一样:
@Select("SELECT * FROM ${tableName} WHERE id = #{id}")
User selectById(@Param("tableName") String tableName, @Param("id") Long id);
tableName如果来自用户输入,必须做白名单校验。注解方式下没有foreach标签,处理IN条件时可以用MyBatis提供的@SelectProvider配合SQL构建器,或者用Java代码在Mapper接口的default方法里构建好SQL再调用。
批量操作中的预编译注意事项批量插入或更新时,MyBatis的foreach配合#{}依然能保证安全:
INSERT INTO user (name, email) VALUES
<foreach collection="userList" item="user" separator=",">
(#{user.name}, #{user.email})
</foreach>
但要注意,不同数据库对预编译参数数量的上限不同。MySQL默认的max_prepared_stmt_count是16382,Oracle也有类似限制。如果批量操作的数据量很大,foreach生成的参数占位符数量可能超出限制。这种情况下可以考虑分批处理,或者使用MyBatis的BatchExecutor模式,它会在JDBC层面利用addBatch机制,每条记录仍然走预编译,但不会一次性生成海量占位符。
存储过程和函数调用中的参数处理调用存储过程时,MyBatis的参数传递同样遵循#{}和${}的规则:
{call update_user_status(#{userId}, #{status})}
这种写法是安全的。但如果存储过程内部使用了动态SQL拼接传入的参数,那注入风险就转移到了数据库层面。这个问题不是MyBatis能控制的,需要在数据库端确保存储过程内部也使用参数化查询。调用函数同理,传入的参数值通过#{}绑定,但函数内部的SQL安全性需要DBA和数据库开发人员保证。
框架层面的额外防护措施除了在MyBatis层面正确使用预编译,还可以在框架层面增加防护。可以自定义MyBatis的拦截器,对所有执行的SQL进行审计,检测是否存在${}拼接的敏感模式。也可以配置SQL防火墙类的插件,在JDBC驱动层面拦截可疑的SQL模式。不过这些是辅助手段,不能替代正确使用预编译。另外,数据库权限最小化原则也很重要,应用连接的数据库账号只授予必要的SELECT、INSERT、UPDATE权限,不授予DDL权限,这样即使出现注入漏洞,攻击者能造成的破坏也有限。
说到底,MyBatis的防注入核心就一条:凡是用户输入的数据,一律用#{}绑定;凡是必须用${}的标识符类内容,一律在服务端做严格的白名单校验。把这个原则贯彻到每一行SQL里,预编译语句才能真正发挥它的防护作用。模糊查询、动态排序、IN条件、批量操作这些常见场景,只要按照上面说的方式处理,就不会给SQL注入留下可乘之机。
