一、问题的本质:字符串到底是什么时候变成代码的?

很多开发者知道"用预编译可以防 SQL 注入",但说不清为什么。本文将从数据库内核的视角,一步步拆解一条 SQL 语句从应用层发出到最终在存储引擎上执行的完整链路,揭示为什么字符串拼接会让"数据"变成"代码",而预编译从根本上切断了这个转换通道

1.1 一个最简单的注入点

先看一段经典的 Java 代码:

// 危险写法
String sql = "SELECT * FROM users WHERE id = " + userId;
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);

// 安全写法
String sql = "SELECT * FROM users WHERE id = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setInt(1, userId);
ResultSet rs = pstmt.executeQuery();

这两段代码最终都要向 MySQL 发送网络包。区别究竟在哪里?我们用 Wireshark 抓 MySQL 协议来看看。

二、MySQL 协议层面的真相

MySQL 客户端/服务器协议有两套完全不同的查询指令:

指令 触发方式 协议包内容
COM_QUERY (0x03) Statement / 字符串拼接 完整 SQL 文本一次性发送
COM_STMT_PREPARE (0x16) + COM_STMT_EXECUTE (0x17) PreparedStatement 先发送模板,再发送二进制参数

2.1 COM_QUERY 抓包

当你用字符串拼接时,Wireshark MySQL 协议解析器里看到的是:

0000 03 43 4f 4d 5f 51 55 45  52 59 00 53 45 4c 45 43   ..COM_QUERY.SEL
0010 54 20 2a 20 46 52 4f 4d  20 75 73 65 72 73 20 57   T * FROM users W
0020 48 45 52 45 20 69 64 20  3d 20 31 20 4f 52 20 31   HERE id = 1 OR 1
0030 3d 31                                                =1

数据库收到的是完整的、未区分数据和代码的文本。它必须自己做所有解析工作。

2.2 COM_STMT_PREPARE + COM_STMT_EXECUTE 抓包

而用 PreparedStatement 时,是两个独立的协议交互:

第一步:发送模板(COM_STMT_PREPARE)

0000 16 50 52 45 50 41 52 45  20 53 45 4c 45 43 54 20   ..PREPARE SELECT
0010 2a 20 46 52 4f 4d 20 75  73 65 72 73 20 57 48 45   * FROM users WHE
0020 52 45 20 69 64 20 3d 20  3f                          RE id = ?

服务器返回一个 statement id,比如 3。

第二步:发送参数(COM_STMT_EXECUTE)

0000 17 03 00 01 00 00 00 00  01 01 04 81               ...........

这里的二进制结构是:

  • 0x17 — COM_STMT_EXECUTE 标识
  • 0x0300 — statement id = 3(小端序)
  • 0x01 — flags
  • 0x00000000 — iteration count = 0
  • 0x01 — 参数个数 = 1
  • 0x01 — types 长度 = 1
  • 0x04 — 参数类型 = MYSQL_TYPE_LONG(32位整数)
  • 0x81 — unsigned flag
  • 然后参数值以二进制形式发送(不是 ASCII 文本)

注意:此时参数永远不会被当成 SQL 语法的一部分去解析。数据库知道"这里有一个整数",仅此而已。

三、SQL 执行的五个阶段

让我们深入 MySQL 8.0 InnoDB 内核,看一条 SQL 到底经历了什么。

3.1 词法分析(Lexical Analysis)

输入:SQL 文本(字符串拼接时是完整 SQL,预编译时是模板)

工作:按词法规则切割成 token 流

MySQL 的词法规则硬编码在 sql/lex.hsql/sql_lex.cc 里:

// MySQL 8.0 sql/lex.h 片段
enum {
  SQL_TOKEN_SELECT = 257,
  SQL_TOKEN_FROM = 258,
  SQL_TOKEN_WHERE = 259,
};

// sql/sql_lex.cc 中的关键字匹配
static const SYMBOL symbols[] = {
  {"SELECT", SQL_TOKEN_SELECT, 0},
  {"FROM",   SQL_TOKEN_FROM,   0},
  {"WHERE",  SQL_TOKEN_WHERE,  0},
  {"OR",     SQL_TOKEN_OR,     0},
  {"AND",    SQL_TOKEN_AND,    0},
  {"UNION",  SQL_TOKEN_UNION,  0},
};

对于注入 payload 1 OR 1=1-- ,词法分析器会识别出:

  • 数字 1
  • 关键字 OR
  • 数字 1
  • 运算符 =
  • 数字 1
  • 注释标记 --

这就是为什么预编译能防注入:参数值(比如 1 OR 1=1-- )根本不会进入词法分析器的 token 流。它们被标记为 PARAM_ITEM 节点,在语法树中是叶子节点,永远不可能变成 SQL_TOKEN_OR 这样的关键字。

3.2 语法解析(Parsing)

MySQL 使用 Bison(yacc 兼容)语法分析器,语法规则定义在 sql/sql_yacc.y

对于 SELECT * FROM users WHERE id = ?,语法树大致是:

SELECT_LEX
├── SELECT_ITEM_LIST
│   └── STAR (*)
├── FROM_CLAUSE
│   └── TABLE_REF (users)
└── WHERE_CLAUSE
    └── COND_ITEM
        ├── COLUMN_REF (id)
        └── PARAM_ITEM (占位符)   <-- 参数在这里

而字符串拼接的 SELECT * FROM users WHERE id = 1 OR 1=1-- 的语法树是:

SELECT_LEX
├── SELECT_ITEM_LIST
│   └── STAR (*)
├── FROM_CLAUSE
│   └── TABLE_REF (users)
└── WHERE_CLAUSE
    └── OR_EXPR                   <-- 注入的 OR 成为语法结构的一部分
        ├── EQ_EXPR
        │   ├── COLUMN_REF (id)
        │   └── NUMBER (1)
        └── EQ_EXPR
            ├── NUMBER (1)
            └── NUMBER (1)

关键差异:在预编译版本中,参数是语法树的叶节点(值);在拼接版本中,注入的 SQL 片段可以成为语法树的结构节点(改变语义)。

3.3 语义检查(Semantic Analysis)

语法正确不代表能执行。这阶段检查:

  • 表/列是否存在
  • 权限是否足够
  • 数据类型是否兼容

在预编译中,PARAM_ITEM 的类型在 PREPARE 阶段就确定了(由 pstmt.setInt(1, userId) 指定为 INT),所以类型兼容性检查一次性完成。

3.4 查询优化(Query Optimization)

MySQL 8.0 基于成本的优化器(Cost-Based Optimizer, CBO):

EXPLAIN SELECT * FROM users WHERE id = 1;

输出中:

  • type: ref — 走了索引
  • key: PRIMARY — 用了主键索引
  • rows: 1 — 预计扫描 1 行

SELECT * FROM users WHERE id = 1 OR 1=1

  • type: ALL — 全表扫描
  • rows: 10000 — 预计扫描全部行

3.5 代码生成与执行(Code Generation & Execution)

MySQL 最终生成一棵执行树(Execution Tree),每个节点是一个迭代器(Iterator)。

TableScan (users)
  └── Filter (id = param_value)
        └── Materialize (?)  <-- 参数在这里被实例化为具体值

注意参数实例化发生在执行阶段,而不是解析阶段。这意味着无论 param_value1 还是 1 OR 1=1-- ,它都只会是一个值,不会再被解析。

四、预编译的缓存机制

很多人不知道,MySQL 的预编译还有一层好处:编译结果缓存

4.1 服务端 PREPARE

PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?';
SET @pid = 1;
EXECUTE stmt USING @pid;
SET @pid = 2;
EXECUTE stmt USING @pid;
DEALLOCATE PREPARE stmt;

PREPARE 只做一次词法分析 + 语法解析 + 语义检查 + 优化。后续 EXECUTE 只做参数替换和代码生成,性能提升可达 30-50%。

4.2 客户端驱动的 Statement Pooling

JDBC 的 useServerPrepStmts=true&cachePrepStmts=true 可以在客户端缓存 prepared statement 对象,避免重复走 COM_STMT_PREPARE。

4.3 服务端 Statement Cache

MySQL 8.0 的 Prepared_statement_count 状态变量可以监控当前打开的预编译语句数量:

SHOW STATUS LIKE 'Prepared_stmt_count';
-- Variable_name: Prepared_stmt_count
-- Value: 7

五、SQLite 的特殊情况

SQLite 的 prepare 机制值得单独说,因为它比 MySQL 更激进:

// sqlite3_prepare_v2 的行为
int sqlite3_prepare_v2(
  sqlite3 *db,
  const char *zSql,       // SQL 文本
  int nByte,
  sqlite3_stmt **ppStmt,  // 输出:编译好的语句对象
  const char **pzTail
);

SQLite 的 sqlite3_stmt 对象是一个编译好的字节码程序sqlite3_bind_int(stmt, 1, value) 只是设置一个运行时参数,永远不会重新解析 SQL 文本。

你甚至可以用 SQLite 的 EXPLAIN 命令来看字节码:

EXPLAIN SELECT * FROM users WHERE id = ?;

输出:

0|Init|0|0|0| |0|0
1|Addr|1|0|0| |0|0
2|OpenRead|0|2|0| |0|0
3|IsNull|2|9|0| | | 
4|If|9|22|0| | | 
5|Read|0|9|0| | | 
6|IsNull|1|8|0| | | 
7|Eq|8|3|6| |1| 
8|Goto|0|12|0| | | 

六、PostgreSQL 的 PREPARE/EXECUTE

PostgreSQL 使用类似但略有不同的机制:

PREPARE user_by_id(int) AS
  SELECT id, name, email FROM users WHERE id = $1;

EXECUTE user_by_id(1);
EXECUTE user_by_id(2);

6.1 pg_prepared_statements 系统视图

SELECT name, statement, parameter_types FROM pg_prepared_statements;

6.2 PostgreSQL 的计划缓存陷阱

PostgreSQL 有一个著名的 "参数嗅探" 问题:预编译语句的查询计划在 PREPARE 时就生成了,如果后来的参数值差异很大(比如大部分值走索引,但某个值需要全表扫描),性能可能反而下降。

可以用 EXECUTE ... USING + DEALLOCATE 的组合来强制重新规划。

七、哪些"看似预编译"实际上不防注入

7.1 Python 的字符串格式化

# 错误!这不是预编译
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
cursor.execute("SELECT * FROM users WHERE id = %s" % user_id)
cursor.execute("SELECT * FROM users WHERE id = " + user_id)

7.2 Python 的正确写法

# 正确!参数化查询
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))

# SQLite3 的正确写法(注意是 ? 占位符)
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))

# psycopg2 (PostgreSQL) 的正确写法(也用 %s)
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))

7.3 MyBatis 的 $ vs

<!-- 危险:$ 直接字符串拼接 -->
<select id="getUser" resultType="User">
  SELECT * FROM users WHERE id = '${id}'
</select>

<!-- 安全:# 使用 PreparedStatement -->
<select id="getUser" resultType="User">
  SELECT * FROM users WHERE id = #{id}
</select>

7.4 什么时候 $ 是必需的

动态表名、列名、ORDER BY 字段无法用预编译(数据库协议不支持参数化这些位置):

<!-- 这是 $ 的合理用法,但需要在 Java 层白名单校验 columnName -->
<select id="getUser" resultType="User">
  SELECT * FROM users ORDER BY ${columnName}
</select>

八、如何在你的项目中确保使用预编译

8.1 Java Agent 检测工具

在 pom.xml 中加入依赖后,可以通过 Java Agent 运行时检测 SQL 拼接。

8.2 JDBC 日志

开启 JDBC 日志可以看到实际执行的 SQL:

# logback.xml
<logger name="java.sql.PreparedStatement" level="DEBUG"/>
<logger name="java.sql.Statement" level="DEBUG"/>

8.3 代码扫描规则

SonarQube 规则 java:S2077 会报告所有在 SQL 语句中使用字符串拼接的地方。

8.4 动态分析

OWASP ZAP 的 SQLi 扫描器可以自动探测注入点:

zap-cli active-scan --scanners sqli http://target-app/

九、真实世界案例

9.1 Sony 2011 年 PlayStation Network 被黑

攻击者通过一个 SQL 注入漏洞获取了 7700 万用户的姓名、生日、电子邮件和加密密码。Sony 最终支付了 1500 万美元的和解费。

根因:某 Java Servlet 使用了字符串拼接构建 SQL。

9.2 Equifax 2017 年数据泄露

1.47 亿美国人的社会安全号码、出生日期和地址被泄露。

根因:Apache Struts 框架的 OGNL 注入,最终通过 JDBC Statement 执行了注入的 SQL。

9.3 2023 年某大型电商平台泄露

某电商平台的商品搜索功能存在注入,攻击者通过 UNION SELECT 提取了所有商家的敏感数据。

根因:MyBatis XML 中使用了 ${keyword} 而不是 #{keyword}

十、总结

预编译防注入的核心原理:

  1. 协议层分离:参数通过专用二进制通道发送,与 SQL 模板完全隔离
  2. 语法树分离:参数在语法树中是叶节点(值),永远不会成为结构节点
  3. 词法分析隔离:参数值不会进入词法分析器,永远不会被识别为关键字

记住这个判断标准:如果用户输入的内容最终在网络包中以 ASCII 文本形式出现在 SQL 语句区域,那就是字符串拼接,就可能有注入风险。

┌─────────────────┐     COM_QUERY      ┌─────────────────┐
│  字符串拼接      │ ──────────────────▶ │   数据库解析     │
│  (完整SQL+数据)  │     文本混合        │   词法+语法      │
└─────────────────┘                     └─────────────────┘
                                              ▲
                                              │ 注入点!
                                              │ 数据可以变成代码

┌─────────────────┐  COM_STMT_PREPARE   ┌─────────────────┐
│  预编译模板      │ ─────────────────▶ │   数据库编译     │
│  SELECT * WHERE │                     │   (固定语法树)   │
│  id = ?         │                     └─────────────────┘
└─────────────────┘

┌─────────────────┐  COM_STMT_EXECUTE   ┌─────────────────┐
│  二进制参数      │ ─────────────────▶ │   执行 (无解析)  │
│  type=INT val=1 │                     └─────────────────┘
└─────────────────┘                    ▲
                                       │ 参数永远是值
                                       │ 不可能变成代码

这就是预编译的本质。理解了这个层级的区别,你就永远不会写出有注入漏洞的代码。