MyBatis中防止SQL注入的核心原则非常简单:永远优先使用#{},绝对避免使用${}拼接用户输入。#{}是预编译参数绑定,会将用户输入当作纯字符串参数处理,数据库驱动会自动转义特殊字符;而${}是字符串直接替换,会把用户输入原封不动地拼进SQL语句,攻击者可以通过构造恶意输入篡改SQL逻辑,直接导致数据泄露甚至数据库被删。这不是建议,这是铁律。下面我会把这个问题从原理到实践、从常见误区到最佳实践,一次性讲透。

一、#{}和${}的本质区别到底是什么

很多开发者只知道"#{}安全、${}不安全",但并不真正理解背后的机制。MyBatis在处理这两种写法时,生成的SQL和执行方式完全不同。

当你写#{username}时,MyBatis会生成类似这样的SQL:

SELECT * FROM user WHERE username = ?

这里的问号是预编译占位符(PreparedStatement),用户输入的值会通过JDBC的setString方法绑定上去,数据库驱动会对特殊字符进行转义,比如单引号、分号、注释符等全部被当作普通字符处理。攻击者输入的任何内容都不可能改变SQL的结构。

而当你写${username}时,MyBatis会直接做字符串拼接:

SELECT * FROM user WHERE username = '用户输入的内容'

如果用户输入的是:' OR '1'='1,那最终SQL就变成了:

SELECT * FROM user WHERE username = '' OR '1'='1'

这条语句会返回所有用户数据,SQL注入就这么轻松地发生了。更危险的是,攻击者还可以用UNION SELECT、DROP TABLE等语句进行更深度的破坏。

二、SQL注入攻击的真实危害有多大

SQL注入不是理论上的威胁,而是OWASP Top 10中长期排名靠前的安全漏洞。实际危害包括:

第一,数据泄露。攻击者可以读取数据库中的所有表、所有字段,包括用户密码、身份证号、银行卡信息等敏感数据。第二,数据篡改。攻击者可以修改、删除数据,甚至插入恶意数据。第三,权限提升。通过数据库的xp_cmdshell等功能,攻击者有可能直接执行操作系统命令,拿下整个服务器。第四,业务瘫痪。DROP TABLE、DELETE不加WHERE条件,可以直接让业务停摆。

这些不是夸张,而是每年都在发生的真实事件。一个${}的疏忽,代价可能是整个公司的数据安全。

三、哪些场景下开发者会误用${}

说实话,大部分人不是不知道${}危险,而是在某些场景下"不得不用"或者"习惯用了"。常见的误用场景有以下几种:

场景一:动态表名或列名。比如你需要根据用户选择动态查询不同的表,写成了${tableName}。场景二:动态排序字段。ORDER BY ${orderColumn},因为#{}会把字段名加上引号导致语法错误。场景三:IN查询的动态参数。有些人用${}拼接IN后面的值列表。场景四:LIKE模糊查询。有人写成${keyword}来实现前后通配符。场景五:老代码遗留。项目早期没注意,后来一直没改。

这些场景确实有使用${}的"理由",但每一个都有安全的替代方案,下面会详细讲。

四、动态表名和列名的安全替代方案

这是最常见的${}使用场景,也是最容易出问题的地方。MyBatis提供了一个专门的解决方案:使用${}但配合白名单校验。

具体做法是,在Java代码中维护一个允许的表名或列名列表,用户输入进来后先校验是否在白名单中,通过了再拼接。示例如下:

public List<Map> queryByTable(String tableName, String column, String value) {
    // 白名单校验
    List<String> allowedTables = Arrays.asList("user", "order", "product");
    if (!allowedTables.contains(tableName)) {
        throw new IllegalArgumentException("非法表名");
    }
    List<String> allowedColumns = Arrays.asList("username", "email", "phone");
    if (!allowedColumns.contains(column)) {
        throw new IllegalArgumentException("非法列名");
    }
    return sqlSession.selectList("dynamicQuery", tableName, column, value);
}

对应的Mapper XML:

<select id="dynamicQuery" resultType="map">
    SELECT * FROM ${tableName} WHERE ${column} = #{value}
</select>

注意看,表名和列名用了${},但值用了#{}。关键在于白名单机制确保了表名和列名不可能被攻击者控制。没有白名单的${}就是裸奔。

五、动态排序的安全写法

ORDER BY后面不能用#{},因为#{orderColumn}会生成ORDER BY 'username',加了引号就不是字段名了。正确做法同样是白名单:

public List<User> getUsers(String orderBy) {
    String allowed = "username,email,create_time";
    if (!allowed.contains(orderBy)) {
        orderBy = "create_time"; // 默认排序
    }
    return userMapper.selectByOrder(orderBy);
}
<select id="selectByOrder" resultType="User">
    SELECT * FROM user ORDER BY ${orderBy}
</select>

这里同样是${}配合白名单。如果你的排序字段很多,可以用枚举类来管理,比字符串拼接更规范。

六、LIKE模糊查询的正确姿势

很多人写LIKE查询时喜欢这样:

<select id="searchUser" resultType="User">
    SELECT * FROM user WHERE username LIKE '%${keyword}%'
</select>

这是典型的SQL注入入口。正确写法有两种:

方法一,在Java代码中拼接通配符,传给#{}:

<select id="searchUser" resultType="User">
    SELECT * FROM user WHERE username LIKE #{keyword}
</select>
// Java代码
String keyword = "%" + userInput + "%";
userMapper.searchUser(keyword);

方法二,使用MyBatis的bind标签:

<select id="searchUser" resultType="User">
    <bind name="pattern" value="'%' + keyword + '%'" />
    SELECT * FROM user WHERE username LIKE #{pattern}
</select>

两种方法都避免了${},同时实现了前后模糊查询。

七、IN查询的动态参数处理

IN查询是另一个重灾区。有人写成:

<select id="selectByIds" resultType="User">
    SELECT * FROM user WHERE id IN (${ids})
</select>

如果ids是"1,2,3"还好,但如果是用户可控的,就危险了。正确做法是用foreach遍历集合:

<select id="selectByIds" resultType="User">
    SELECT * FROM user WHERE id IN
    <foreach collection="idList" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

Java端传入List<Integer>即可,每个id都会被#{}安全绑定。

八、MyBatis-Plus等框架的额外注意事项

如果你用的是MyBatis-Plus,它提供了Wrapper条件构造器,大部分场景可以避免手写SQL。但要注意,Wrapper中的某些方法如果使用不当,底层仍然可能产生拼接。比如自定义SQL片段时,一定要检查是否有${}的使用。另外,MyBatis-Plus的@TableName注解虽然方便,但如果表名来自用户输入,同样需要校验。

还有一点,很多团队用注解方式写SQL,比如@Select("SELECT * FROM user WHERE name = ${name}"),这种写法更隐蔽,代码审查时更容易漏掉,风险更高。强烈建议复杂SQL还是放到XML中,方便审查和维护。

九、代码审查和自动化检测手段

光靠开发者自觉是不够的,必须建立机制。第一,代码审查时把${}作为重点检查项,每一处${}都要确认是否有白名单保护。第二,使用静态代码分析工具,比如SonarQube、FindBugs、Alibaba Java Coding Guidelines插件,它们能自动检测出MyBatis中的${}使用并报警。第三,在CI/CD流程中加入安全扫描,把SQL注入检测纳入自动化流程。第四,定期做渗透测试,用SQLMap等工具主动扫描自己的系统。

十、总结:一张表记住所有要点

最后给大家一个清晰的对照表,方便记忆:

场景:等值查询、条件过滤——用#{},绝对安全。场景:动态表名/列名——用${}但必须白名单校验。场景:动态排序——用${}但必须白名单校验。场景:LIKE查询——用#{}在Java层拼接通配符。场景:IN查询——用foreach+#{}遍历集合。场景:任何用户可控的输入——永远不要用${}直接拼接。

SQL注入的防御不是某一个技术点的问题,而是一种安全意识。MyBatis给了你#{}这个简单有效的武器,用好它,你的应用就能挡住绝大多数SQL注入攻击。剩下的${}场景,用白名单兜底,双保险才是正道。别心存侥幸,安全问题上没有"应该没事"这四个字。