一、核心结论速览
#{}实现预编译防注入的本质:MyBatis 将 #{} 解析为 JDBCPreparedStatement的?占位符,参数通过setXxx()方法绑定,最终由数据库内核完成 SQL 结构解析、参数转义与执行计划缓存,从语法层面彻底隔离参数与 SQL 结构。${} 存在注入风险的本质:MyBatis 直接将 ${} 替换为参数原始字符串(无任何转义),等价于 JDBC 的
Statement字符串拼接模式,参数会篡改 SQL 结构,无预编译保护。预编译核心归属:预编译是数据库内核的原生能力,
PreparedStatement仅为 JDBC 驱动对该能力的封装(而非实现),JDBC 仅提供调用数据库预编译能力的 API 桥梁。
二、底层原理拆解(从数据库到 MyBatis)
2.1 数据库内核的预编译机制(核心基石)
预编译是数据库内核级功能,分为“预编译(Prepare)”和“执行(Execute)”两个阶段,实现 SQL 结构与参数的彻底分离:
阶段 1:预编译(Prepare)—— 解析 SQL 结构,与参数无关
当数据库收到原生预编译命令(如 MySQL 的 PREPARE stmt1 FROM 'SELECT * FROM user WHERE name = ?')时,内核会完成 3 件事:
语法解析:校验 SQL 语法合法性(关键字、表名、字段名是否存在),但完全忽略 ? 占位符,明确其为“参数位置”,不属于 SQL 结构的一部分;
语义分析:绑定表/字段元数据(如确认 user 表、name 字段是否存在);
生成执行计划:优化器根据表结构、索引生成最优执行计划,将“预编译语句 + 执行计划”缓存到数据库端,并分配唯一标识(如 stmt1)。
关键:此阶段无任何参数参与,数据库仅处理“SQL 结构”,从语法层面锁定“结构范围”和“参数位置”,这是 SQL 结构隔离的根本实现。
阶段 2:执行(Execute)—— 绑定参数,复用执行计划
当数据库收到执行命令(如 MySQL 的 SET @name = 'xxx'; EXECUTE stmt1 USING @name)时,内核会完成 2 件事:
参数绑定:将参数值填充到 ? 占位符,但由于预编译阶段已明确 ? 是“值占位符”,无论参数内容是什么,都只会被当作“字符串值”处理,不会解析为 SQL 逻辑;
执行:直接复用预编译阶段缓存的执行计划,无需重新解析 SQL 结构,既安全又高效。
2.2 JDBC 对预编译的封装(桥梁作用)
JDBC 的 PreparedStatement 并非自己实现预编译,而是充当“翻译官”,将 Java 代码调用转换为数据库能识别的预编译命令/协议:
核心调用链路(以 MySQL 为例)
// 1. Java 代码层面(开发者视角)String sql = "SELECT * FROM user WHERE name = ?";PreparedStatement ps = conn.prepareStatement(sql);ps.setString(1, "张三' OR '1'='1"); // 传入恶意参数ps.executeQuery();// 2. MySQL 驱动底层转换(伪代码,开发者无感知)// 第一步:向数据库发送预编译命令sendToDB("PREPARE mysql_stmt_123 FROM 'SELECT * FROM user WHERE name = ?'");// 第二步:驱动自动转义参数(双重保险)String escapedValue = escape("张三' OR '1'='1"); // 转义为 "张三\' OR \'1\'=\'1"sendToDB("SET @param1 = '" + escapedValue + "'");// 第三步:执行预编译语句sendToDB("EXECUTE mysql_stmt_123 USING @param1");// 第四步:释放预编译资源(可选)sendToDB("DEALLOCATE PREPARE mysql_stmt_123");
数据库暴露预编译能力的两种方式
原生 SQL 命令(底层 API):所有关系型数据库均支持,如 MySQL 的
PREPARE/EXECUTE/DEALLOCATE PREPARE、Oracle 的PREPARE STATEMENT/EXECUTE;二进制通信协议(高效方式):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} 为例:
MyBatis 解析 SQL 时,将 #{name} 替换为 JDBC 的 ? 占位符,生成基础 SQL:
SELECT * FROM user WHERE name = ?;创建
PreparedStatement对象,将前端传入的 name 参数通过ps.setString(1, name)绑定到占位符;调用
ps.executeQuery()执行,完全复用 JDBC 预编译和参数转义能力。
(2)${参数名} 解析流程(字符串直接拼接)
以 SQL 模板 SELECT * FROM ${tableName} WHERE name = ${name} 为例:
MyBatis 解析 SQL 时,直接将 ${tableName}、${name} 替换为参数的原始字符串(无任何转义);
拼接成完整 SQL 后,底层用
Statement执行(或伪装成 PreparedStatement 但无预编译);风险示例:传入
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 能用 ${} + 手动转义替代 #{} 吗?
不建议,风险极高!原因有三:
手动转义无法全覆盖:不同数据库、字符集的转义规则不同(如 MySQL 转义 \,Oracle 不转义;还有 \n、\t、Unicode 编码 \u0027 等),手动函数极易遗漏场景,攻击者可找到漏洞;
无法解决逻辑注入:手动转义仅能处理“字符层面的注入”,无法应对“逻辑层面的注入”。例如:传入 id = “1 OR 1=1”,拼接后 SQL 为
SELECT * FROM user WHERE id = 1 OR 1=1,无特殊字符可转义,但 SQL 逻辑已被篡改;维护成本极高:需为不同参数类型(字符串、数字、日期)、不同数据库编写适配代码,数据库版本升级、字符集变更时,转义规则可能变化,代码需同步修改。
3.3 攻击者能绕过 PreparedStatement 的预编译吗?
几乎不可能!预编译是数据库内核级的安全防护,是业界公认的防 SQL 注入最优方案:
数据库原生支持:预编译是数据库内核的基础功能,而非应用层的“小技巧”,攻击者无法通过构造参数绕过数据库的语法解析规则;
参数与 SQL 完全隔离:数据库在预编译阶段已明确区分“结构部分”和“参数部分”,参数永远不会被当作结构解析——哪怕传入
; DROP TABLE user,也只会被当作“字符串值”处理。
唯一例外:若用 ${} 拼接表名/字段名且未做白名单校验,属于“结构层面的注入”,这是误用 ${} 的问题,而非 PreparedStatement 的缺陷。
3.4 配置化 SQL(模板存在数据库)用 #{} 仍安全吗?
安全!MyBatis 的预编译逻辑仅关注 SQL 模板中的 #{} 占位符,与模板来源(XML/数据库/配置文件)无关:
从数据库读取 SQL 模板:
SELECT * FROM user WHERE name = #{name};MyBatis 解析时,依然将 #{name} 替换为 ?,生成
SELECT * FROM user WHERE name = ?;前端参数通过
PreparedStatement.setXxx()绑定,与 XML 中定义的 SQL 安全性完全一致。
四、实战最佳实践(可配置报表系统场景)
参数值一律用 #{}:所有前端传递的动态值(name、age、时间范围等),无论 SQL 模板存储在哪里,均使用 #{参数名},依赖 PreparedStatement 的预编译能力,无需手动转义;
静态标识符用 ${} + 严格校验:表名、字段名等 SQL 结构类配置项,因数据库不支持
SELECT * FROM ? WHERE name = ?这种表名占位,只能用 ${} 拼接,但必须做两层校验:
- 白名单校验:仅允许拼接预设的合法表名/字段名(如 TABLE_WHITELIST = {"user", "order", "product"});- 语法校验:过滤特殊字符(;、DROP、ALTER、空格等),避免注入恶意结构。
- 绝对禁止:用 ${} 处理任何参数值,哪怕觉得“手动转义很简单”——PreparedStatement 的防护经过海量实践验证,远比手动代码可靠。
五、核心总结
- 预编译核心:数据库内核在预编译阶段区分“SQL 结构”和“参数位置”,参数仅作为“值”处理,这是防注入的根本,任何应用层转义都无法替代;
2.#{} 安全本质:对接 PreparedStatement,调用数据库内核预编译能力,彻底隔离参数与 SQL 结构;
- 安全准则:参数值用 #{},静态标识符用 ${} + 白名单校验,这是兼顾配置化需求与安全性的唯一最优解。
遵循此规则,即便攻击者构造再巧妙的参数,也无法突破数据库内核级的预编译防护——这也是 MyBatis 官方强调“优先用 #{},仅在必要时用 ${} 并做校验”的根本原因。
