动态SQL开发中的SQL注入风险与防护实践 —— 以MyBatis为例

软件架构 创建于:2025-05-30

一、引言

在现代企业应用开发中,灵活的数据查询能力已成为后端服务的重要组成部分。无论是后台管理系统、数据分析平台还是多维报表系统,动态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 + "'";

攻击者输入:

  • userInputadmin' --
  • 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;

tablecondition均可控,攻击者可构造任意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解析流程

  1. XML解析阶段:先将${}变量替换为实际值,生成完整SQL字符串。
  2. SQL预编译阶段:再将#{}变量替换为?占位符,并记录参数映射。
  3. JDBC执行阶段:MyBatis从参数对象中取值,安全绑定到?,传递给JDBC执行。

相关源码入口:

  • ${}解析:TextSqlNodeGenericTokenParser
  • #{}解析:SqlSourceBuilderParameterMappingTokenHandler

3.3 MyBatis为何能防注入?

  • 只要用户输入通过#{}参数绑定,MyBatis和JDBC会自动对参数值做转义,避免注入风险。
  • 只有在用${}拼接用户输入(如SQL片段、表名、字段名)时,才有注入隐患。

四、动态SQL防注入的最佳实践

4.1 原则

  1. 所有用户输入必须用#{}参数绑定,绝不能直接拼接到SQL字符串。
  2. 动态拼接的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注入防御的全局建议

  1. 参数绑定永远优先:所有用户输入均用#{}绑定,切勿直接拼接。
  2. 结构性内容白名单:表名、字段名、排序字段、操作符等全部后端限定。
  3. 定期安全审计:代码review、自动化扫描、渗透测试三管齐下。
  4. 持续安全教育:团队技术分享、案例学习、最佳实践沉淀。

九、总结

动态SQL是现代后端开发中不可或缺的利器,为业务系统带来了强大的灵活性。但一旦安全把控不严,SQL注入带来的后果往往是灾难性的。MyBatis等ORM工具为我们提供了参数绑定的护城河,但开发者必须深刻理解其原理,合理区分 ${}#{}的使用场景,并在动态SQL结构拼接时,严格执行白名单校验。

安全和灵活并不矛盾,关键在于设计模式、编码规范和团队意识。希望本文能帮助大家在日常开发中,既能享受动态SQL的高效,也能守住系统安全的底线。

十、参考资料

原文地址:https://my.oschina.net/kaisesai/blog/18475275

免责声明:本文来源于互联网,版权归合法拥有者所有,如有侵权请公众号联系管理员

* 本站提供的一些文章、资料是供学习研究之用,如用于商业用途,请购买正版。

kaisesai