个人随笔
目录
MyBatis #{} 预编译防注入核心原理笔记
2026-01-14 10:55:18

一、核心结论速览

  1. #{} 实现预编译防注入的本质:MyBatis 将 #{} 解析为 JDBC PreparedStatement? 占位符,参数通过setXxx() 方法绑定,最终由数据库内核完成 SQL 结构解析、参数转义与执行计划缓存,从语法层面彻底隔离参数与 SQL 结构。

  2. ${} 存在注入风险的本质:MyBatis 直接将 ${} 替换为参数原始字符串(无任何转义),等价于 JDBC 的 Statement 字符串拼接模式,参数会篡改 SQL 结构,无预编译保护。

  3. 预编译核心归属:预编译是数据库内核的原生能力,PreparedStatement 仅为 JDBC 驱动对该能力的封装(而非实现),JDBC 仅提供调用数据库预编译能力的 API 桥梁。

二、底层原理拆解(从数据库到 MyBatis)

2.1 数据库内核的预编译机制(核心基石)

预编译是数据库内核级功能,分为“预编译(Prepare)”和“执行(Execute)”两个阶段,实现 SQL 结构与参数的彻底分离:

阶段 1:预编译(Prepare)—— 解析 SQL 结构,与参数无关

当数据库收到原生预编译命令(如 MySQL 的 PREPARE stmt1 FROM 'SELECT * FROM user WHERE name = ?')时,内核会完成 3 件事:

  1. 语法解析:校验 SQL 语法合法性(关键字、表名、字段名是否存在),但完全忽略 ? 占位符,明确其为“参数位置”,不属于 SQL 结构的一部分;

  2. 语义分析:绑定表/字段元数据(如确认 user 表、name 字段是否存在);

  3. 生成执行计划:优化器根据表结构、索引生成最优执行计划,将“预编译语句 + 执行计划”缓存到数据库端,并分配唯一标识(如 stmt1)。

关键:此阶段无任何参数参与,数据库仅处理“SQL 结构”,从语法层面锁定“结构范围”和“参数位置”,这是 SQL 结构隔离的根本实现。

阶段 2:执行(Execute)—— 绑定参数,复用执行计划

当数据库收到执行命令(如 MySQL 的 SET @name = 'xxx'; EXECUTE stmt1 USING @name)时,内核会完成 2 件事:

  1. 参数绑定:将参数值填充到 ? 占位符,但由于预编译阶段已明确 ? 是“值占位符”,无论参数内容是什么,都只会被当作“字符串值”处理,不会解析为 SQL 逻辑;

  2. 执行:直接复用预编译阶段缓存的执行计划,无需重新解析 SQL 结构,既安全又高效。

2.2 JDBC 对预编译的封装(桥梁作用)

JDBC 的 PreparedStatement 并非自己实现预编译,而是充当“翻译官”,将 Java 代码调用转换为数据库能识别的预编译命令/协议:

核心调用链路(以 MySQL 为例)

  1. // 1. Java 代码层面(开发者视角)
  2. String sql = "SELECT * FROM user WHERE name = ?";
  3. PreparedStatement ps = conn.prepareStatement(sql);
  4. ps.setString(1, "张三' OR '1'='1"); // 传入恶意参数
  5. ps.executeQuery();
  6. // 2. MySQL 驱动底层转换(伪代码,开发者无感知)
  7. // 第一步:向数据库发送预编译命令
  8. sendToDB("PREPARE mysql_stmt_123 FROM 'SELECT * FROM user WHERE name = ?'");
  9. // 第二步:驱动自动转义参数(双重保险)
  10. String escapedValue = escape("张三' OR '1'='1"); // 转义为 "张三\' OR \'1\'=\'1"
  11. sendToDB("SET @param1 = '" + escapedValue + "'");
  12. // 第三步:执行预编译语句
  13. sendToDB("EXECUTE mysql_stmt_123 USING @param1");
  14. // 第四步:释放预编译资源(可选)
  15. sendToDB("DEALLOCATE PREPARE mysql_stmt_123");

数据库暴露预编译能力的两种方式

  1. 原生 SQL 命令(底层 API):所有关系型数据库均支持,如 MySQL 的 PREPARE/EXECUTE/DEALLOCATE PREPARE、Oracle 的 PREPARE STATEMENT/EXECUTE

  2. 二进制通信协议(高效方式):JDBC 驱动优先使用,如 MySQL 的 COM_STMT_PREPARE(预编译)、COM_STMT_EXECUTE(执行),避免直接拼接 SQL 命令,效率更高。

2.3 JDBC 两种执行方式对比(预编译 vs 字符串拼接)

方式 核心实现 执行逻辑 注入风险 代码示例
Statement 字符串拼接 SQL 完整拼接后直接执行,数据库无参数转义 极高 String name = “张三’ OR ‘1’=’1’”;String sql = “SELECT FROM user WHERE name = ‘“ + name + “‘“;Statement stmt =conn.createStatement();stmt.executeQuery(sql); #最终 SQL:SELECT FROM user WHERE name = ‘张三’ OR ‘1’=’1’(注入成功)
PreparedStatement ? 占位符 + 预编译 1. 预编译解析 SQL 结构并缓存;2. 驱动转义参数;3. 复用执行计划执行 String name = “张三’ OR ‘1’=’1’”;String sql = “SELECT * FROM user WHERE name = ?”;PreparedStatement ps =conn.prepareStatement(sql);ps.setString(1, name); // 参数转义为 “张三\’ OR \’1\’=\’1”ps.executeQuery(); // 条件不成立,注入失败

2.4 MyBatis 对 #{} 和 ${} 的解析逻辑(JDBC 封装层)

MyBatis 本质是 JDBC 的封装,对两种占位符的处理直接对应 JDBC 的两种执行方式:

(1)#{参数名} 解析流程(绑定 PreparedStatement)

以 SQL 模板 SELECT * FROM user WHERE name = #{name} 为例:

  1. MyBatis 解析 SQL 时,将 #{name} 替换为 JDBC 的 ? 占位符,生成基础 SQL:SELECT * FROM user WHERE name = ?

  2. 创建 PreparedStatement 对象,将前端传入的 name 参数通过 ps.setString(1, name) 绑定到占位符;

  3. 调用ps.executeQuery() 执行,完全复用 JDBC 预编译和参数转义能力。

(2)${参数名} 解析流程(字符串直接拼接)

以 SQL 模板 SELECT * FROM ${tableName} WHERE name = ${name} 为例:

  1. MyBatis 解析 SQL 时,直接将 ${tableName}、${name} 替换为参数的原始字符串(无任何转义);

  2. 拼接成完整 SQL 后,底层用 Statement 执行(或伪装成 PreparedStatement 但无预编译);

  3. 风险示例:传入 tableName = "user; DROP TABLE order",最终 SQL 变为SELECT * FROM user; DROP TABLE order WHERE name = xxx,导致表被删除。

(3)#{} 与 ${} 核心特性对比

特性 #{} 占位符 ${} 占位符
JDBC 底层实现 PreparedStatement(? 占位符) Statement(字符串拼接)
参数处理 数据库驱动自动转义特殊字符 无转义,直接拼接原始字符串
预编译支持 支持(SQL 结构预编译,参数动态绑定) 不支持(完整拼接后才解析 SQL 结构)
SQL 注入风险 极高
适用场景 传递参数值(name、age、id、时间范围等) 拼接静态标识符(表名、字段名,需严格校验)

三、关键问题深度解答

3.1 预编译 ≠ 单纯字符转义?

正确!PreparedStatement 的防注入是“双层防护”,字符转义仅为补充,核心是 SQL 结构隔离:

防护层级 具体作用
第一层(核心):SQL 结构隔离 预编译阶段已明确 ? 是参数值,无论参数是什么(如 OR 1=1、; DROP TABLE),都只会被当作“字符串值”处理,永远不会被解析为 SQL 逻辑的一部分。
第二层(补充):字符转义 数据库驱动对参数中的特殊字符(’、;、\ 等)做转义(如 ‘ 转成 \’),避免参数中的引号“打断”SQL 语法,属于“双重保险”。

结论:即便驱动不做转义,只要参数通过 setXxx() 绑定到 ? 占位符,数据库也会将其当作值处理;单纯的字符转义(如手动替换 ‘ 为 ‘’)无法替代结构隔离。

3.2 能用 ${} + 手动转义替代 #{} 吗?

不建议,风险极高!原因有三:

  1. 手动转义无法全覆盖:不同数据库、字符集的转义规则不同(如 MySQL 转义 \,Oracle 不转义;还有 \n、\t、Unicode 编码 \u0027 等),手动函数极易遗漏场景,攻击者可找到漏洞;

  2. 无法解决逻辑注入:手动转义仅能处理“字符层面的注入”,无法应对“逻辑层面的注入”。例如:传入 id = “1 OR 1=1”,拼接后 SQL 为 SELECT * FROM user WHERE id = 1 OR 1=1,无特殊字符可转义,但 SQL 逻辑已被篡改;

  3. 维护成本极高:需为不同参数类型(字符串、数字、日期)、不同数据库编写适配代码,数据库版本升级、字符集变更时,转义规则可能变化,代码需同步修改。

3.3 攻击者能绕过 PreparedStatement 的预编译吗?

几乎不可能!预编译是数据库内核级的安全防护,是业界公认的防 SQL 注入最优方案:

  1. 数据库原生支持:预编译是数据库内核的基础功能,而非应用层的“小技巧”,攻击者无法通过构造参数绕过数据库的语法解析规则;

  2. 参数与 SQL 完全隔离:数据库在预编译阶段已明确区分“结构部分”和“参数部分”,参数永远不会被当作结构解析——哪怕传入 ; DROP TABLE user,也只会被当作“字符串值”处理。

唯一例外:若用 ${} 拼接表名/字段名且未做白名单校验,属于“结构层面的注入”,这是误用 ${} 的问题,而非 PreparedStatement 的缺陷。

3.4 配置化 SQL(模板存在数据库)用 #{} 仍安全吗?

安全!MyBatis 的预编译逻辑仅关注 SQL 模板中的 #{} 占位符,与模板来源(XML/数据库/配置文件)无关:

  1. 从数据库读取 SQL 模板:SELECT * FROM user WHERE name = #{name}

  2. MyBatis 解析时,依然将 #{name} 替换为 ?,生成 SELECT * FROM user WHERE name = ?

  3. 前端参数通过 PreparedStatement.setXxx() 绑定,与 XML 中定义的 SQL 安全性完全一致。

四、实战最佳实践(可配置报表系统场景)

  1. 参数值一律用 #{}:所有前端传递的动态值(name、age、时间范围等),无论 SQL 模板存储在哪里,均使用 #{参数名},依赖 PreparedStatement 的预编译能力,无需手动转义;

  2. 静态标识符用 ${} + 严格校验:表名、字段名等 SQL 结构类配置项,因数据库不支持 SELECT * FROM ? WHERE name = ? 这种表名占位,只能用 ${} 拼接,但必须做两层校验:

  1. - 白名单校验:仅允许拼接预设的合法表名/字段名(如 TABLE_WHITELIST = {"user", "order", "product"});
  2. - 语法校验:过滤特殊字符(;、DROPALTER、空格等),避免注入恶意结构。
  1. 绝对禁止:用 ${} 处理任何参数值,哪怕觉得“手动转义很简单”——PreparedStatement 的防护经过海量实践验证,远比手动代码可靠。

五、核心总结

  1. 预编译核心:数据库内核在预编译阶段区分“SQL 结构”和“参数位置”,参数仅作为“值”处理,这是防注入的根本,任何应用层转义都无法替代;

2.#{} 安全本质:对接 PreparedStatement,调用数据库内核预编译能力,彻底隔离参数与 SQL 结构;

  1. 安全准则:参数值用 #{},静态标识符用 ${} + 白名单校验,这是兼顾配置化需求与安全性的唯一最优解。

遵循此规则,即便攻击者构造再巧妙的参数,也无法突破数据库内核级的预编译防护——这也是 MyBatis 官方强调“优先用 #{},仅在必要时用 ${} 并做校验”的根本原因。

 6

啊!这个可能是世界上最丑的留言输入框功能~


当然,也是最丑的留言列表

有疑问发邮件到 : suibibk@qq.com 侵权立删
Copyright : 个人随笔   备案号 : 粤ICP备18099399号-2