接手过不少遗留系统,最让我头皮发麻的不是混乱的业务逻辑,而是在MyBatis的XML映射文件里看到满屏的${}符号。很多开发者为了图方便,直接把参数拼接到SQL语句里,这在生产环境里无异于给数据库留了一扇没有锁的后门。SQL注入攻击至今仍稳居OWASP十大安全风险榜单,不是因为它技术多高深,而是因为开发者的疏忽太普遍。MyBatis作为Java生态里最流行的持久层框架之一,它本身提供了足够强大的防注入机制,问题在于很多人用错了方式,或者根本没意识到某些写法会带来灾难性后果。

分清#{}和${}的本质区别,这是第一道防线

MyBatis处理参数的两种占位符,底层行为完全不同。#{}走的是预编译通道,MyBatis会把这个占位符替换成问号,然后通过JDBC的PreparedStatement把参数值传递进去。数据库收到SQL时,参数和SQL结构已经彻底分离,恶意输入无论怎么构造都无法改变SQL的语义。而${}做的是纯粹的字符串替换,MyBatis在解析XML时直接把参数值拼接到SQL文本里,然后才把完整的SQL发送给数据库执行。这意味着如果参数值里包含恶意的SQL片段,它就会成为执行指令的一部分。

举个例子,假设登录验证的XML里这样写:

SELECT * FROM users WHERE username = '${username}' AND password = '${password}'

攻击者在用户名输入框里填上 admin' -- ,最终执行的SQL就会变成:

SELECT * FROM users WHERE username = 'admin' --' AND password = 'whatever'

连字符把后面的密码验证条件全部注释掉了,攻击者不需要知道密码就能以admin身份登录。同样的位置如果改用#{},输入的内容只会被当作普通字符串处理,单引号会被转义,SQL结构纹丝不动。记住一个铁律:凡是需要传递参数值的地方,一律用#{}。只有一种情况可以用${},那就是必须动态指定表名、字段名或者排序方式这类SQL结构元素,而且这些值绝不能来自前端用户输入,必须从后端白名单里取值。

动态表名和排序字段的处理,白名单校验是唯一安全方案

实际业务中确实存在需要动态切换表名或者动态排序的场景,比如多租户系统根据租户ID路由到不同分表,或者列表页允许用户点击列头切换排序字段。这些场景下#{}无法使用,因为预编译占位符只能替代值,不能替代表名和字段名。很多开发者在这里妥协,直接把前端传过来的字段名用${}拼接进去,这等于把SQL控制权交给了用户。

正确的做法是在服务层建立严格的白名单映射。假设前端传过来一个排序字段参数sortField,后端绝不能直接把它扔进XML。应该在Service或者Controller层做校验:

private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("id", "username", "create_time", "update_time");
private static final Set<String> ALLOWED_SORT_DIRECTIONS = Set.of("ASC", "DESC");

public List<User> getUsersByOrder(String sortField, String sortDirection) {
    if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) {
        throw new IllegalArgumentException("非法的排序字段");
    }
    if (sortDirection == null || !ALLOWED_SORT_DIRECTIONS.contains(sortDirection.toUpperCase())) {
        throw new IllegalArgumentException("非法的排序方向");
    }
    return mapper.getUsersByOrder(sortField, sortDirection.toUpperCase());
}

经过白名单过滤后,传入XML的参数值已经确保是安全的枚举值,这时候再用${}拼接就不会有注入风险。表名动态切换同理,把所有合法的表名枚举出来,任何不在白名单内的输入直接拒绝。这种防御方式简单粗暴但极其有效,不依赖任何框架特性,纯粹从业务逻辑层面堵死了注入路径。

模糊查询的坑,LIKE语句的注入风险

搜索功能里的模糊查询是SQL注入的重灾区。很多开发者在XML里这样写:

SELECT * FROM products WHERE name LIKE '%${keyword}%'

这种写法不仅存在注入风险,而且完全没必要。MyBatis的#{}完全支持LIKE语句,只需要把百分号放在参数值里拼接好再传进来:

SELECT * FROM products WHERE name LIKE #{keyword}

在Java代码里处理参数:

String keyword = "%" + userInput + "%";
List<Product> products = mapper.searchProducts(keyword);

这样参数值走的是预编译通道,安全性和功能性兼得。如果用的是MySQL,还可以直接用CONCAT函数在SQL里拼接百分号,但参数本身仍然用#{}传递:

SELECT * FROM products WHERE name LIKE CONCAT('%', #{keyword}, '%')

两种方式都安全,选择哪种取决于你习惯把拼接逻辑放在Java代码还是SQL里,但核心原则不变:参数值本身必须通过#{}传递。

IN语句的参数处理,foreach标签的正确用法

处理多条件查询时,IN语句是高频场景。如果写成这样就是自掘坟墓:

SELECT * FROM orders WHERE status IN (${statusList})

MyBatis提供了foreach标签来安全处理集合参数,它会为集合里的每个元素生成一个预编译占位符:

SELECT * FROM orders WHERE status IN
<foreach collection="statusList" item="status" open="(" separator="," close=")">
    #{status}
</foreach>

假设传入的statusList包含三个元素,最终生成的SQL是SELECT * FROM orders WHERE status IN (?, ?, ?),每个参数都独立走预编译。这里要注意的是,如果集合为空,生成的SQL会变成IN (),这在大多数数据库里会报语法错误。所以在调用前务必判断集合是否为空,或者在XML里用if标签包裹整个IN条件。

MyBatis拦截器,构建运行时安全审计层

即使代码规范要求严格,大型项目里难免有开发者不小心用了${}或者写出了危险的SQL模式。可以在MyBatis的拦截器层面加一道运行时检测,对所有执行的SQL进行安全审计。实现Interceptor接口,拦截Executor的update和query方法:

@Intercepts({
    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class SqlInjectionInterceptor implements Interceptor {
    
    private static final Pattern DANGEROUS_PATTERN = Pattern.compile(
        "('|%27|%2527).*?(--|%2D%2D|%23|#|OR|UNION|DROP|DELETE|TRUNCATE|INSERT|UPDATE)", 
        Pattern.CASE_INSENSITIVE
    );
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object parameter = invocation.getArgs()[1];
        if (parameter != null) {
            String paramStr = parameter.toString().toLowerCase();
            if (paramStr.contains("'") || paramStr.contains("--") || 
                paramStr.contains("union") || paramStr.contains("drop")) {
                // 记录告警日志,包含用户信息、IP、时间戳、完整参数
                log.warn("检测到疑似SQL注入攻击,参数: {}", paramStr);
                throw new RuntimeException("参数包含非法字符");
            }
        }
        return invocation.proceed();
    }
}

这种拦截器不能作为唯一的防御手段,因为攻击者可以绕过简单的正则匹配,但它能提供一层额外的纵深防御。更重要的是,拦截器里可以记录详细的审计日志,包括时间、用户标识、IP地址、完整的SQL和参数,这些日志对于事后追溯攻击行为价值巨大。生产环境里建议把拦截器的行为配置成可切换的模式,开发测试阶段拦截并告警,生产环境初期先只记录日志不阻断,观察误报率后再决定是否开启阻断。

存储过程和动态SQL的隐患

有些项目习惯把复杂业务逻辑封装在数据库存储过程里,然后在MyBatis里调用。存储过程内部如果使用了动态SQL拼接,同样存在注入风险,而且这种风险更难被发现,因为它藏在数据库层,代码审查时容易被忽略。调用存储过程时,MyBatis的参数传递方式决定了安全性:

<select id="callProcedure" statementType="CALLABLE">
    {call sp_query_user(#{userId, mode=IN})}
</select>

只要参数用#{}传递,输入值就会被安全地绑定到存储过程的参数上。但问题出在存储过程内部,如果它拿到参数后又用EXECUTE或者拼接字符串的方式执行动态SQL,那MyBatis层面的安全措施就失效了。解决方案是审查所有存储过程的代码,确保它们内部也使用参数化查询。如果无法修改存储过程,就在调用前对参数做严格的格式校验,比如userId只允许数字,就强制用正则验证。

ORM框架不是银弹,代码规范和安全意识才是根本

MyBatis和所有ORM框架一样,它提供的安全机制只有在正确使用的前提下才有效。我见过太多项目,技术选型时花大量时间对比框架性能,上线后却被一个简单的SQL注入打穿。防注入不是某个框架的责任,而是贯穿开发全流程的安全意识问题。代码审查时,看到${}就应该像看到红色警报一样警觉,除非旁边有注释明确说明为什么必须用它以及安全措施是什么。

另外,数据库权限最小化原则同样重要。应用程序连接数据库的账号,只授予SELECT、INSERT、UPDATE、DELETE这些必要权限,绝不授予DROP、ALTER、TRUNCATE等DDL权限。即使攻击者绕过了应用层的防护,权限限制也能把损害控制在数据操作层面,避免整库被删除。生产环境的数据库账号和开发测试环境严格隔离,杜绝开发账号的权限泄露到生产环境。

参数校验的层次也要清晰。前端校验只是提升用户体验,真正的安全校验必须在后端完成。任何来自HTTP请求的参数,无论GET还是POST,无论URL参数还是请求体,在进入MyBatis之前都应该经过类型校验、长度限制、格式验证和业务规则检查。建议在Controller层用Validation框架做第一层过滤,Service层做业务白名单校验,最后才把清洗过的参数交给Mapper层。三层防线各司其职,单点失效不至于全线崩溃。

SQL注入攻击的手法在不断演进,从简单的单引号闭合到二次注入、盲注、带外注入,防御思路的核心却始终没变:永远不要信任用户输入,永远把数据和指令分离。MyBatis的#{}给了我们一个简单可靠的分离手段,用对它,就能挡住绝大多数注入攻击。