一、引言
在现代企业应用开发中,灵活的数据查询能力已成为后端服务的重要组成部分。无论是后台管理系统、数据分析平台还是多维报表系统,动态SQL往往是业务需求驱动下的必然产物。所谓动态SQL,是指SQL语句的部分结构(如查询条件、排序、表名、字段等)可以根据运行时参数动态变化,而不是在代码中完全写死。
然而,动态SQL的灵活性也带来了巨大的安全隐患。最著名的就是SQL注入攻击(SQL Injection)。在实际开发中,很多团队在实现动态SQL时,往往忽视了安全细节,导致系统被攻击者利用,造成数据泄露、篡改甚至严重的业务损失。
本文将结合MyBatis框架,深入剖析动态SQL开发中的SQL注入风险,讲解MyBatis的SQL解析机制,并提供一套可落地的动态SQL安全防护方案,帮助开发者在保证灵活性的同时,最大程度保障系统安全。
二、SQL注入原理与案例
2.1 什么是SQL注入?
SQL注入是一种常见的安全漏洞,攻击者通过在用户输入中注入恶意SQL代码,使得后端数据库执行非预期的SQL语句,进而获取、篡改、删除敏感数据,甚至控制数据库服务器。
本质: 用户输入被当作SQL结构的一部分直接拼接进SQL语句,而不是作为参数绑定,导致SQL语法、逻辑被攻击者操控。
2.2 典型SQL注入案例
假设有如下登录功能(错误写法):
String sql = "SELECT * FROM user WHERE username = '" + userInput + "' AND password = '" + passInput + "'";
攻击者输入:
userInput为admin' --passInput随便填
组合后SQL变为:
SELECT * FROM user WHERE username = 'admin' --' AND password = 'xxx'
--为SQL注释,后面部分被忽略,实际执行:
SELECT * FROM user WHERE username = 'admin'
攻击者无需密码即可登录。
2.3 动态SQL下的注入加剧
动态SQL中,开发者经常需要拼接表名、字段、排序、条件等结构。如果这些结构由用户直接控制,注入风险更大。例如:
String sql = "SELECT * FROM " + table + " WHERE " + condition;
若table、condition均可控,攻击者可构造任意SQL,后果极其严重。
三、MyBatis动态SQL机制剖析
MyBatis是目前Java领域最流行的ORM/数据访问框架之一。它通过Mapper XML文件支持丰富的动态SQL能力,如<if>、<where>、${}、#{}等标签或占位符。
3.1 MyBatis中的${}与#{}
${}:字符串替换(原样插入)。适合插入表名、字段名、动态SQL片段等。有SQL注入风险,必须严格后端白名单校验。#{}:参数绑定(JDBC的?占位符)。适合插入用户输入的参数值。自动防注入。
示例XML:
<select id="selectUser" parametertype="map" resulttype="User">
SELECT * FROM ${tableName}
WHERE username = #{username}
</select>
${tableName}会被原样替换为传入的字符串#{username}会被替换成?,并安全绑定参数
3.2 MyBatis SQL解析流程
- XML解析阶段:先将
${}变量替换为实际值,生成完整SQL字符串。 - SQL预编译阶段:再将
#{}变量替换为?占位符,并记录参数映射。 - JDBC执行阶段:MyBatis从参数对象中取值,安全绑定到
?,传递给JDBC执行。
相关源码入口:
${}解析:TextSqlNode、GenericTokenParser#{}解析:SqlSourceBuilder、ParameterMappingTokenHandler
3.3 MyBatis为何能防注入?
- 只要用户输入通过
#{}参数绑定,MyBatis和JDBC会自动对参数值做转义,避免注入风险。 - 只有在用
${}拼接用户输入(如SQL片段、表名、字段名)时,才有注入隐患。
四、动态SQL防注入的最佳实践
4.1 原则
- 所有用户输入必须用
#{}参数绑定,绝不能直接拼接到SQL字符串。 - 动态拼接的SQL片段(表名、字段名、排序字段等)必须由后端白名单校验。
4.2 动态SQL安全设计模式
4.2.1 查询对象封装
用Java对象封装动态查询结构,参数和值分离。
public class DynamicQuery {
private String tableName; // 白名单校验
private String condition; // SQL片段,含#{params.xxx}
private Map<String, Object> params = new LinkedHashMap<>();
private String sortField; // 白名单校验
private String sortOrder; // ASC/DESC,白名单
private Integer pageNum;
private Integer pageSize;
// ...getter/setter
}
4.2.2 SQL组装工具
public class DynamicSqlUtil {
public static DynamicQuery buildQuery(String tableName, Map<String, Object> condMap, Map<String, list<Object>> inMap)
{
// 1. 校验tableName、字段名、排序字段是否在白名单
// 2. 用#{params.xxx}组装动态条件
// 3. params映射实际参数值
// 4. 返回DynamicQuery对象
}
}
4.2.3 Mapper与XML
Mapper接口:
List<Map<String, Object>> queryList(DynamicQuery query);
XML示例:
<select id="queryList" parametertype="DynamicQuery" resulttype="map">
SELECT * FROM ${tableName}
${condition}
ORDER BY ${sortField} ${sortOrder}
LIMIT #{offset}, #{pageSize}
</select>
注意: ${tableName}、${sortField}、${sortOrder}必须在Java代码中做白名单校验,#{}部分全部为参数绑定。
4.2.4 调用示例
DynamicQuery query = DynamicSqlUtil.buildQuery("user_table", cond, inMap);
List<Map<String, Object>> result = dynamicMapper.
queryList(query);
五、核心细节与代码安全点
5.1 白名单校验实现
private static final Set<String> TABLE_WHITE_LIST = Set.of("user_table", "order_table");
private static final Set<String> FIELD_WHITE_LIST = Set.of("id", "user_name", "status", "created_at");
public static boolean checkWhiteList(String input, Set<String> whiteList) {
return whiteList.contains(input);
}
5.2 动态条件拼装安全样例
public static DynamicQuery buildQuery(String tableName, Map<String, Object> condMap, Map<String, List<Object>> inMap, String sortField,
String sortOrder, Integer pageNum, Integer pageSize) {
if (!checkWhiteList(tableName, TABLE_WHITE_LIST)) {
throw new IllegalArgumentException("非法表名");
}
if (sortField != null && !checkWhiteList(sortField, FIELD_WHITE_LIST)) {
throw new IllegalArgumentException("非法排序字段");
}
if (sortOrder != null && !Set.of("ASC", "DESC").contains(sortOrder.toUpperCase())) {
throw new IllegalArgumentException("非法排序方式");
}
StringBuilder condition = new StringBuilder("1 = 1");
Map<String, Object> params = new LinkedHashMap<>();
int idx = 0;
for (Map.Entry<String, Object> entry : condMap.entrySet()) {
String field = entry.getKey();
if (!checkWhiteList(field, FIELD_WHITE_LIST)) {
continue;
}
String paramName = "param" + idx++;
condition.append(" AND ").append(field).append(" = #{params.").append(paramName).append("}");
params.put(paramName, entry.getValue());
}
for (Map.Entry<String, list<Object>> entry : inMap.entrySet()) {
String field = entry.getKey();
if (!checkWhiteList(field, FIELD_WHITE_LIST)) {
continue;
}
condition.append(" AND ").append(field).append(" IN (");
List<Object> values = entry.getValue();
for (int i = 0; i < values.size(); i++) {
String paramName = "param" + idx++;
if (i > 0) {
condition.append(", ");
}
condition.append("#{params.").append(paramName).append("}");
params.put(paramName, values.get(i));
}
condition.append(")");
}
DynamicQuery query = new DynamicQuery();
query.setTableName(tableName);
query.setCondition(condition.toString());
query.setParams(params);
query.setSortField(sortField);
query.setSortOrder(sortOrder);
query.setPageNum(pageNum);
query.setPageSize(pageSize);
return query;
}
5.3 分页参数安全处理
- offset = (pageNum - 1) * pageSize
- pageNum、pageSize需合法性校验(如大于0等)
六、常见误区与反例
6.1 反例:直接拼接用户输入
<select id="badQuery" parametertype="map" resulttype="map">
SELECT * FROM user_table WHERE ${userCondition}
</select>
风险: 用户可构造任意SQL片段,完全绕过业务控制。
6.2 反例:用${}插值参数
<select id="badQuery2" parametertype="map" resulttype="map">
SELECT * FROM user_table WHERE name = '${userName}'
</select>
风险: ${}不会转义参数值,直接插入,极易注入。
6.3 反例:未做白名单校验
<select id="badQuery3" parametertype="map" resulttype="map">
SELECT * FROM ${tableName} WHERE id = #{id}
</select>
风险: tableName未校验,攻击者可查询任意表。
七、复杂动态条件的安全实现思路
对于更复杂的多条件、嵌套AND/OR、NOT、LIKE、IN、BETWEEN等,建议采用:
- 前端用结构化对象描述查询条件(如树形结构)
- 后端递归解析结构,组装SQL片段和参数Map,所有值通过
#{}绑定 - 字段名、操作符等结构性内容全部白名单校验
可借鉴JPA Criteria API、QueryDSL等思想,将条件表达能力和安全性分离。
八、SQL注入防御的全局建议
- 参数绑定永远优先:所有用户输入均用
#{}绑定,切勿直接拼接。 - 结构性内容白名单:表名、字段名、排序字段、操作符等全部后端限定。
- 定期安全审计:代码review、自动化扫描、渗透测试三管齐下。
- 持续安全教育:团队技术分享、案例学习、最佳实践沉淀。
九、总结
动态SQL是现代后端开发中不可或缺的利器,为业务系统带来了强大的灵活性。但一旦安全把控不严,SQL注入带来的后果往往是灾难性的。MyBatis等ORM工具为我们提供了参数绑定的护城河,但开发者必须深刻理解其原理,合理区分 ${}与#{}的使用场景,并在动态SQL结构拼接时,严格执行白名单校验。
安全和灵活并不矛盾,关键在于设计模式、编码规范和团队意识。希望本文能帮助大家在日常开发中,既能享受动态SQL的高效,也能守住系统安全的底线。
十、参考资料
- MyBatis 官方文档
- OWASP SQL Injection
- 《深入浅出MyBatis技术原理与实战》
- 阿里巴巴Java开发手册(SQL防注入部分)