SQL 注入复习
我将结合 MyBatis 框架,从 SQL 注入的原理、多种高危场景(不仅限于 WHERE 条件)、防御策略与最佳实践 三个维度,为你系统、深入、实战化地讲解 SQL 注入问题。内容覆盖你特别强调的:字段名注入、表名注入、ORDER BY 注入、动态 SQL 风险等,并给出 可落地的 MyBatis 安全编码规范。
一、SQL 注入的本质原理(MyBatis 视角)
🔍 核心问题:
当用户输入被当作“SQL 代码”而非“数据”执行时,就发生了注入。
在 MyBatis 中,这种风险主要源于 错误地使用 ${} 而非 #{}。
-
#{property}→ 参数化查询(安全)
MyBatis 会将其转换为PreparedStatement的占位符?,由 JDBC 驱动自动转义。 -
${property}→ 字符串直接替换(危险!)
MyBatis 会原样拼接到 SQL 中,等同于手动字符串拼接,极易引发注入。
💡 记住:
#{}是盾,${}是刀——用得好是利器,用不好伤自己。
二、MyBatis 中 SQL 注入的典型场景与示例
场景 1:WHERE 条件注入(最常见)
❌ 危险写法(使用 ${}):
<!-- userMapper.xml -->
<select id="findByUsername" resultType="User">SELECT * FROM users WHERE username = '${username}'
</select>
// 调用
userMapper.findByUsername("admin' --");
// 实际执行 SQL:
// SELECT * FROM users WHERE username = 'admin' --'
// → 绕过密码验证,登录 admin 账户!
✅ 安全写法(使用 #{}):
<select id="findByUsername" resultType="User">SELECT * FROM users WHERE username = #{username}
</select>
→ MyBatis 生成:SELECT * FROM users WHERE username = ?,参数由 PreparedStatement 安全绑定。
场景 2:动态表名 / 字段名注入(高危!常被忽视)
❌ 危险写法:
<select id="selectFromTable" resultType="Map">SELECT ${columns} FROM ${tableName} WHERE status = #{status}
</select>
// 攻击调用
mapper.selectFromTable("id, (SELECT password FROM users LIMIT 1) AS pwd", "orders; DROP TABLE users--", 1
);
// 实际 SQL:
// SELECT id, (SELECT password FROM users LIMIT 1) AS pwd
// FROM orders; DROP TABLE users-- WHERE status = 1
// → 数据泄露 + 表被删除!
✅ 安全方案:
原则:绝不允许用户输入直接作为表名/字段名!
-
白名单校验(推荐):
public List<Map> safeSelect(String columns, String tableName, int status) {Set<String> allowedColumns = Set.of("id", "name", "email");Set<String> allowedTables = Set.of("users", "orders");if (!allowedColumns.containsAll(Arrays.asList(columns.split(","))) ||!allowedTables.contains(tableName)) {throw new IllegalArgumentException("Invalid column or table");}return mapper.selectFromTableInternal(columns, tableName, status); }XML 中仍可用
${},但确保输入已严格校验。 -
避免动态表名:
通过多 Mapper 或泛型设计规避,如UserMapper,OrderMapper分开。
场景 3:ORDER BY 注入(盲注高发区)
❌ 危险写法:
<select id="listUsers" resultType="User">SELECT * FROM users ORDER BY ${sortBy}
</select>
// 攻击输入:id ASC,(SELECT CASE WHEN (1=1) THEN 1 ELSE 1/0 END)
// 可用于布尔盲注探测数据库内容
✅ 安全方案:
- 白名单 + 枚举校验:
public enum SortField { ID("id"), NAME("name"), CREATE_TIME("create_time");private final String col;SortField(String col) { this.col = col; }public String getColumn() { return col; } }public List<User> listUsers(SortField field) {return mapper.listUsers(field.getColumn()); // 安全 } - 禁止用户传入原始字段名,改为传枚举值或预定义 code。
场景 4:LIMIT / OFFSET 注入(分页场景)
❌ 危险写法:
<select id="getPage" resultType="User">SELECT * FROM users LIMIT ${offset}, ${limit}
</select>
→ 攻击者可注入 UNION 查询窃取数据。
✅ 安全方案:
- 使用 MyBatis-Plus / PageHelper 等分页插件(内部安全处理);
- 若手写,对 offset/limit 做类型和范围校验:
if (offset < 0 || offset > 10000 || limit <= 0 || limit > 100) {throw new IllegalArgumentException("Invalid pagination params"); }⚠️ 注意:MySQL 支持
LIMIT ?,但 Oracle 不支持,跨数据库需谨慎。
场景 5:动态 WHERE 条件中的列名注入(MyBatis <if> 陷阱)
❌ 危险写法:
<select id="search" resultType="User">SELECT * FROM users<where><if test="field != null and value != null">${field} = #{value}</if></where>
</select>
→ 若 field = "1=1; DROP TABLE users--",则注入成功!
✅ 安全写法:
- field 必须白名单校验(不能来自前端);
- 或改用固定字段组合:
<choose><when test="searchByName">name = #{value}</when><when test="searchByEmail">email = #{value}</when> </choose>
三、MyBatis 安全开发黄金法则(面试加分项)
| 原则 | 说明 |
|---|---|
✅ 永远优先使用 #{} | 所有用户输入的“值”必须用 #{} |
⚠️ 慎用 ${} | 仅用于:静态配置(如 schema 名)、白名单校验后的标识符 |
| 🔒 动态标识符必须白名单校验 | 表名、字段名、排序字段等,禁止直通前端 |
| 🧪 开启 MyBatis 日志 | 开发环境打印实际 SQL,便于审计 log-impl: SLF4J |
| 🛡️ 结合全局防护 | WAF(如阿里云 WAF)、数据库权限最小化(应用账号无 DROP 权限) |
四、高级防御:审计 + 监控(生产级保障)
即使代码安全,也建议增加纵深防御:
- SQL 审计插件:记录所有 DML,便于追溯;
- 数据库防火墙:拦截含
UNION、DROP、SLEEP()等高危关键词的 SQL; - 权限隔离:应用数据库账号仅授予
SELECT/INSERT/UPDATE/DELETE,禁止 DDL 权限。
💡 示例:即使攻击者注入了
DROP TABLE,因账号无权限,操作会被拒绝。
五、总结:
“在 MyBatis 项目中,SQL 注入的核心防线是 正确区分
#{}和${}的使用场景。
- 所有用户输入的‘值’必须用
#{};- 任何动态‘标识符’(表名、字段名、排序)必须经过白名单校验;
- 禁止前端直接传字段名,改为传业务语义参数(如 sortType=NAME)。
此外,通过 SQL 审计插件 + 数据库最小权限 + WAF 构建三层防护,确保即使出现疏漏,也能兜底止损。
安全不是功能,而是架构的一部分。”
