预编译为什么能防注入:先把”编译”这件事想清楚
先修知识
第一次读本篇之前,把下面这些词过一遍。不用背,看到后面正文里出现时能想起来”哦是这个意思”就行。
| 术语 | 一句话白话解释 |
|---|---|
| SQL 注入 | 攻击者把自己写的 SQL 片段混进正常查询里,让数据库执行了不该执行的命令。 |
| SQL 模板 | 一条写好了骨架、留了空位(?)的 SQL,像一张”填空题卷子”。 |
占位符(?) | 模板里的空格,告诉数据库”这里等会儿要填一个值”。 |
| 预编译(Prepare) | 先把 SQL 模板发给数据库”审题定稿”,之后再填值的过程。 |
| 参数绑定(Bind) | 把变量的值填进占位符的那个动作。 |
| 语法树 | 数据库解析 SQL 后在内存里生成的”句子结构图”,一旦生成,SQL 的骨架就定死了。 |
| 执行计划 | 数据库想好的”怎么查最快”的方案(用哪个索引、先查哪张表)。 |
| 标识符 | 表名、列名、ASC/DESC 这类”结构部件”,和”值”是两个世界的东西。 |
| 宽字节注入 | 利用 GBK 等多字节编码”两个字节拼一个汉字”的特性,把转义用的反斜杠吃掉,让单引号逃逸出来的注入手法。 |
| 转义 | 在危险字符前加 \(比如 ' 变成 \'),告诉解析器”这是普通字符不是语法”。 |
| PDO / MySQLi | PHP 连数据库的两种主流扩展,本篇会对比它们的预编译行为。 |
| JDBC | Java 连数据库的标准接口,PreparedStatement 是它的预编译 API。 |
| 数据流分析 | 审计的基本功:盯着一个变量,从”用户输入进来”一路追到”危险函数执行”,看中间有没有被洗干净。 |
| payload | 攻击者实际提交的那串恶意输入。 |
| 二次注入 | 恶意数据先被安全地存进数据库,之后被取出来拼接进新 SQL 时才引爆的注入。 |
| RASP | 插桩在程序内部的运行时防护,能在 SQL 执行点看到最终语句,比 WAF 更贴近真相。 |
| 白名单 | ”只许选这几个”的校验方式,和黑名单(“不许出现这几个”)相对,安全得多。 |
SQL 在数据库里的生命周期
是什么
你发给数据库的每一条 SQL,都不是”拿来就执行”的。数据库像一个严谨的会计,收到一张单子要走完一套流程才动手:
SQL 文本
→ ① 词法/语法解析(Lexer/Parser,生成语法树)
→ ② 语义分析(校验表/列/权限)
→ ③ 查询优化(选择索引、join 顺序,生成执行计划)
→ ④ 执行(按执行计划取数据)
拿生活类比:这就像你递给银行柜员一张业务单。柜员先读单子(① 认出每个字是什么成分)、再核对(② 这个账户存在吗、你有权限吗)、然后想怎么办最快(③ 先查流水还是先冻结)、最后才动手(④)。
SQL 注入能成功,问题全部出在第 ① 步。 攻击者的输入(比如 ' OR 1=1--)混在 SQL 文本里一起被送进解析器,解析器认不出”这几个字符是数据、那几个字符是语法”,把整个输入当成了句子的一部分来解读——语法树的形状被改写了。本来是 WHERE id = <一个值>,被改写成了 WHERE id = 1 OR <恒真条件>,条件作废,全表泄露。
预编译的两阶段:把”审题”和”填值”拆开
预编译(Prepared Statement)的思路特别朴素:先让数据库把题目审完定稿,再往里填答案。填答案的时候,审题环节早就结束了。
第一阶段:PREPARE —— 把含 ? 占位符的 SQL 模板发给服务端,完成 ①②③,语法树定型
第二阶段:EXECUTE —— 只把参数值(二进制/字符串原样)发给服务端,填入已定型的树
类比:预编译就像先印好一张表格模板,再往上贴照片。表格的格子结构(语法树)在印刷时已经定型了,你贴上去的照片哪怕长得像一行字,也永远只是一张照片——没人会拿它去改表格结构。
关键结论:参数到达服务端时,语法解析早已结束。参数永远不再进入解析器,无论里面是 ' OR 1=1-- 还是 DROP TABLE,都只是一个”值”。 这就是预编译防注入的根本原因——不是”过滤了危险字符”,而是危险字符失去了被当作语法解析的机会。这句话面试必考,务必理解透而不是背下来。
数据库层的类比:PREPARE / EXECUTE 语法
MySQL 自己就把这套机制暴露成了 SQL 命令,应用层的预编译只是它的协议化封装。你可以开一个叫 mysql 的客户端亲手验证:
-- 服务端先编译模板,语法树定型(这一步完成后,"WHERE id = ?"的结构已经定死)
PREPARE stmt FROM 'SELECT * FROM user WHERE id = ?';
-- 参数只作为"值"传入,不再参与解析
SET @id = '1 OR 1=1';
-- 执行:数据库只会去查 id 等于字符串 '1 OR 1=1' 的行(查不到),而不是执行 OR 1=1
EXECUTE stmt USING @id;
-- 用完释放
DEALLOCATE PREPARE stmt;在 MySQL 二进制协议层面,这两阶段对应两个独立的网络命令:
| 协议命令 | 阶段 | 携带内容 |
|---|---|---|
COM_STMT_PREPARE | 编译 | SQL 模板文本(含 ?) |
COM_STMT_EXECUTE | 执行 | statement id + 参数值(按类型编码) |
参数走的是 COM_STMT_EXECUTE 的独立数据段,与 SQL 文本物理上都不在同一个报文里。这是”数据与代码分离”最彻底的形态——连网线上跑的数据包都是分开的,注入根本没有发生的物理条件。
「预编译」与「参数绑定」概念辨析
面试高频坑,这俩词经常被混用,但它们不是一回事:
| 概念 | 本质 | 回答的问题 |
|---|---|---|
| 预编译(Prepare) | 一种机制:SQL 模板先编译、缓存执行计划,后续复用 | SQL 在哪一步定型? |
| 参数绑定(Bind) | 一个动作:把变量按类型填入已编译模板的占位符 | 值怎么传进去? |
用生活例子区分:预编译是”先把蛋糕模具放进烤箱定形”,参数绑定是”往定好形的模具里倒面糊”。防注入靠的是”面糊倒进去之前模具已经定形”这个时序组合——单独说”我用了预编译”或”我做了绑定”都不完整。
几个容易绕进去的点:
- 预编译的设计初衷其实是性能(同一个模板执行一百次,不用重复解析优化一百次),防注入是它的安全副产品。
- 反例:存储过程里
PREPARE stmt FROM @sql,而@sql本身是字符串拼接出来的——用了 PREPARE 语法没错,但编译的是已经污染的文本,照样注入。机制用对了,时机错了,等于没用。 - 审计时的判定口诀:别信 API 的名字,信时序。
prepare()三个字出现在代码里不代表安全,SQL 字符串在进prepare()之前必须已经是 100% 开发者写死的。
客户端模拟预处理 vs 服务端真预编译
这是整个预编译审计的核心分水岭,也是初学者最容易被骗过去的地方。同样写着 prepare(),底层可能是两条完全不同的路。
是什么
- 服务端真预编译:数据库服务端真的执行 PREPARE/EXECUTE 两阶段,参数不参与解析。
- 客户端模拟预处理(Emulated Prepares):驱动(数据库客户端库)在本地把参数转义后拼回 SQL 文本,拼成一条完整的、普通的 SQL,一次发给服务端。服务端根本不知道有”预编译”这回事。
类比:真预编译是”银行柜员先审单再收钱”;模拟预处理是”门口保安帮你把脏钱擦了擦、夹进单子里,一起递给柜员”——柜员收到的还是一整张单子,照样要逐字读。保安擦得干不干净,直接决定安不安全。
对比表
| 服务端真预编译 | 客户端模拟预处理(Emulated Prepares) | |
|---|---|---|
| 编译位置 | 数据库服务端 | 驱动/客户端本地 |
| 协议 | COM_STMT_PREPARE + COM_STMT_EXECUTE 两次交互 | 客户端把参数转义后拼进 SQL 文本,一次 COM_QUERY 发走 |
| 参数是否参与解析 | 否(解析先于绑定) | 是(拼完后整条文本仍要过解析器) |
| 残余风险 | 无(对绑定位置而言) | 转义依赖字符集一致性 → 宽字节注入等绕过 |
| 性能 | 模板可缓存执行计划 | 每次完整解析 |
模拟预处理的安全等式
模拟预处理 ≈ “驱动帮你做的
mysql_real_escape_string+ 拼接”。它的防注入强度完全等价于转义方案,继承转义的所有缺陷(字符集不一致即破)。审计时看到预编译 API 不能自动放行,必须确认它是真预编译。
宽字节注入:模拟预处理的经典死法
为什么会出现这个漏洞:开发者启用了 PDO/MySQL 驱动,看到代码里有 prepare() 就以为安全了,但驱动默认走模拟预处理,靠转义防注入;同时数据库连接编码设成了 GBK。开发者不知道”转义”和”多字节编码”碰在一起会出事。
攻击原理:连接编码是 GBK 时,模拟预处理把攻击者输入的 ' 转义为 \'(字节 0x5c 0x27)。攻击者不提交 ',而是提交 %df':
攻击者输入: %df '
原始字节: 0xdf 0x27
转义之后: 0xdf 0x5c 0x27 ← 驱动在 ' 前插入了 0x5c(\)
GBK 解码: (0xdf 0x5c 看作一个汉字"運"的组成部分) 0x27
最终效果: 一个汉字 + 裸露的单引号 → 引号逃逸成功,注入成立
%df%5c 在 GBK 里是一个合法汉字的前两字节,转义加进去的反斜杠被”吃掉”变成了汉字的一部分,单引号裸露出来。
真预编译下参数根本不经过这条文本路径(参数走独立协议数据段),此攻击面整个不存在。这也是为什么安全社区一致推荐”用真预编译而不是转义”。
PHP 与 Java 的预编译差异
PHP:PDO 的 ATTR_EMULATE_PREPARES 开关
为什么会出现问题
PHP 的 PDO 扩展为了兼容不支持预编译的老数据库/老驱动,默认在部分驱动上开启模拟预处理。开发者写了 $pdo->prepare() 就以为高枕无忧,实际上底层在客户端拼接转义。这是”API 名字骗了人”的典型。
代码对照
// ⚠️ 危险写法:没有显式配置,模拟预处理是否开启取决于驱动默认值
$pdo = new PDO('mysql:host=...;dbname=...', $u, $p);
// 此时 prepare() 很可能走客户端模拟——转义+拼接,有宽字节风险
// ✅ 安全写法:显式关闭模拟,走服务端真预编译;字符集写进 DSN
$pdo = new PDO('mysql:host=...;dbname=...;charset=utf8mb4', $u, $p, [
PDO::ATTR_EMULATE_PREPARES => false, // 关键开关:关掉模拟,强制真预编译
]);
$stmt = $pdo->prepare('SELECT * FROM user WHERE id = ?');
$stmt->execute([$id]); // 参数走独立通道,不参与解析审计时怎么找
- 全仓库搜
new PDO(,逐个看构造参数:- 第四个参数(options 数组)里有没有
PDO::ATTR_EMULATE_PREPARES => false?没有就按模拟预处理处理,继续查连接字符集是不是 GBK 等多字节编码——是的话宽字节注入面成立。 - DSN 字符串里有没有
charset=utf8mb4?没有的话,模拟模式下客户端转义用的字符集不受控,风险加倍。
- 第四个参数(options 数组)里有没有
- 模拟模式下还有个经典连锁坑:
LIMIT ?, ?绑定参数会被驱动加上引号变成LIMIT '0', '10'导致语法错误,开发者排查后往往”图省事”改回字符串拼接——审计时看到LIMIT附近有拼接,要追溯这个动机,大概率是从绑定退化来的。 charset=utf8mb4必须写进 DSN,而不是连上之后再SET NAMES utf8mb4——后者转义函数对字符集的认知和服务端不一致,又是宽字节的温床。
PHP:MySQLi 的 prepare
// ✅ MySQLi 的 prepare 默认就是服务端真预编译(没有模拟这个开关)
$stmt = $mysqli->prepare('SELECT * FROM user WHERE id = ?');
$stmt->bind_param('i', $id); // 第一个参数是类型声明:i=整数 d=浮点 s=字符串 b=二进制
$stmt->execute();记忆点:MySQLi 没有”模拟预处理”概念,prepare() 即真预编译——这是它与 PDO 的关键差异。审计口诀:“用了 PDO 不一定是真预编译,用了 MySQLi 的 prepare 一定是”。
Java:PreparedStatement 与 useServerPrepStmts
标准用法
String sql = "SELECT * FROM user WHERE id = ?"; // 模板写死,只有一个占位符
PreparedStatement ps = conn.prepareStatement(sql); // ① 预编译:模板送服务端定型
ps.setInt(1, userId); // ② 绑定:值填进第 1 个占位符
ps.executeQuery(); // ③ 执行为什么会出现问题
JDBC 规范层面 PreparedStatement 就是参数绑定接口,但驱动实现可以耍赖:MySQL 官方驱动 Connector/J 默认 useServerPrepStmts=false,也就是默认客户端模拟——驱动在本地转义拼接后发 COM_QUERY。开发者写了规范的 PreparedStatement 代码,底层却在做拼接,和 PHP PDO 的坑一模一样。
修复
// ✅ JDBC URL 显式开启服务端预编译
String url = "jdbc:mysql://host/db?useServerPrepStmts=true&cachePrepStmts=true";cachePrepStmts=true 顺带开启服务端预编译语句缓存,是性能配套项,一般成对出现。
审计时怎么找
- 找到数据源配置(
application.yml、application.properties、Java 配置类里的jdbcUrl),搜jdbc:mysql://:- URL 里没有
useServerPrepStmts=true→ 按模拟预处理对待。
- URL 里没有
- 全仓库搜
prepareStatement(,对每个调用点向上追溯 SQL 字符串的构造——模板里出现+拼接、String.format、StringBuilder.append拼进变量,就是”先污染后编译”。
审计对照表
| 技术栈 | 真预编译的达成条件 |
|---|---|
| PDO (PHP) | 显式设置 ATTR_EMULATE_PREPARES => false(老版本驱动默认模拟;PHP 8.1 起 mysqlnd 下默认行为改善,但审计一律以显式配置为准) |
| MySQLi (PHP) | prepare() 即真预编译,无需额外配置 |
| JDBC + MySQL | useServerPrepStmts=true(默认 false,是模拟) |
| JDBC + PostgreSQL | 默认参数即与 SQL 文本分离(扩展查询协议独立发送参数,安全);语句复用达到 prepareThreshold(默认 5 次)后才转为命名服务端预编译 |
"预编译之前 SQL 已被拼接"——最隐蔽的形态
模板字符串本身含用户输入,预编译 API 只是走了过场:
// ❌ prepareStatement 调了,但 SQL 在传入前就拼好了 String sql = "SELECT * FROM user WHERE name = '" + name + "'"; // 污染在这里已完成 PreparedStatement ps = conn.prepareStatement(sql); // 编译的是已污染文本,等于没防数据流分析时不能看到
prepareStatement就停,必须向上追溯 SQL 字符串的构造来源——模板里的每一个字符都必须是开发者写死的,用户输入只能出现在setXxx()里。
预编译防不住的场景(逐个击破)
预编译只保护绑定位置(值的位置)。一句话记住边界:? 占位符只能站在”值”的语法位上。凡是值以外的需求,占位符机制覆盖不到,注入照样成立。下面六种是审计常客。
1. 动态表名 / 列名 / ORDER BY——标识符不能绑定
为什么会出现
业务要做”用户自选排序字段""按条件查不同的表”,表名、列名、排序方向是标识符/关键字,语法树定型时就必须确定,不能用 ?:
// ❌ 这不是合法的绑定——标识符位不能用 ?
// "SELECT * FROM ? ORDER BY ?" ← 语法错误,驱动/服务端都不接受
// 开发者发现 ? 不管用,"图省事"退回拼接:
String sql = "SELECT * FROM product ORDER BY " + sort; // ❌ sort 可控 → 注入修复:白名单映射
把用户输入当”选择键”而不是”SQL 片段”——用户只能选菜单上的编号,后厨做什么菜是写死的:
// ✅ 用户传 key,服务端映射到写死的列名
Map<String, String> ALLOWED_SORT = Map.of(
"time", "create_time DESC", // key 是用户能传的,value 是开发者写死的
"hot", "view_count DESC",
"price", "price ASC"
);
String orderBy = ALLOWED_SORT.getOrDefault(sort, "id ASC"); // 不在白名单就用默认值
String sql = "SELECT * FROM product ORDER BY " + orderBy; // 拼接的是白名单常量,安全审计时怎么找
搜 ORDER BY、GROUP BY、DESC、ASC 附近有没有 + 拼接;搜 MyBatis XML 里的 ${}(${} 是纯文本替换,常用于排序字段,高危)。看到就问一句:这个拼进来的变量有没有过白名单?
2. LIKE 通配符 % 和 _——参数值内的”二级语法”
为什么会出现
绑定能挡住 SQL 语法,挡不住 LIKE 子句自己的通配符语义。%(任意长度任意字符)和 _(单个任意字符)是 LIKE 的”方言”,不归 SQL 解析器管:
// ⚠️ 不是经典注入,但 % 和 _ 进了参数会改变匹配语义
ps.setString(1, "%" + keyword + "%");
// 攻击者传 keyword = "%",查询退化成"匹配所有行",可遍历拖库、拖垮性能修复:绑定参数 + 转义参数内的 LIKE 元字符
注意转义发生在参数值上,不是 SQL 上:
// ✅ 先转义 LIKE 通配符,再绑定,SQL 中声明 ESCAPE 指定转义符
String kw = keyword.replace("\\", "\\\\") // 先转义反斜杠自身(顺序不能反!)
.replace("%", "\\%")
.replace("_", "\\_");
PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM article WHERE title LIKE ? ESCAPE '\\'");
ps.setString(1, "%" + kw + "%");审计时怎么找
搜 LIKE ?、LIKE CONCAT、"%" + 这类模式,看参数进 setXxx() 之前有没有过通配符转义函数。
3. IN (?) 列表——占位符数量是编译期固定的
为什么会出现
业务要”查 id 在这一批里的所有用户”,但语法树定型时 IN 里有几个 ? 就是几个,绑定阶段无法变长。开发者不知道这个限制,直接把列表拼进去:
// ❌ 直接拼进 IN 列表
String sql = "SELECT * FROM user WHERE id IN (" + ids + ")";修复:按参数个数动态生成占位符
// ✅ 动态生成 N 个 ?——动态的是"开发者算出来的占位符个数",值仍全部走绑定
String placeholders = String.join(", ", Collections.nCopies(idList.size(), "?"));
String sql = "SELECT * FROM user WHERE id IN (" + placeholders + ")";
PreparedStatement ps = conn.prepareStatement(sql);
for (int i = 0; i < idList.size(); i++) {
ps.setLong(i + 1, idList.get(i)); // 每个值照常绑定
}要点:动态拼接的只能是占位符本身(个数由服务端代码决定),用户数据仍然全部走绑定。 这是”拼接”与”参数化”共存且安全的唯一合法形态。
审计时怎么找
搜 IN ( 后面紧跟 + 或 String.join 且 join 的是用户数据(不是 ? 占位符)的写法。注意区分:join 一堆 "?" 是安全的,join 一堆 id 是注入。
4. 二次注入——绑定只挡得住”这一次”
为什么会出现
开发者对”来自数据库的数据”有一种天然信任:注册时参数化入库,恶意字符串(比如用户名 admin'--)原样落库,这一步完全安全;但后续某段代码把这个用户名从库里取出来拼接进新 SQL——开发者心想”这是我自己库里的数据,还能有害?“污染复活。
类比:把一颗没拆的炸弹妥善锁进仓库(安全),过两天另一个同事从仓库领出来直接接上了引爆线。
数据流示例
① 注册接口:INSERT INTO user(name) VALUES (?) ← 参数化,'admin\'--' 原样落库,安全
② 个人中心:String sql = "SELECT * FROM log WHERE owner = '" + currentUser.getName() + "'";
← 从库里取出的 name 被直接拼接,注入在这里引爆
审计时怎么找
修复原则不变:出库再用的数据一律按不可信输入处理,使用点全部参数化。审计时对所有”先查库取字段、再拼进新 SQL”的模式保持警惕——追踪拼接变量的来源如果是数据库字段,同样算注入点。
5. 存储过程内部的动态 SQL
-- ❌ 应用层预编译调存储过程没问题,问题在存储过程内部拼接
CREATE PROCEDURE search(IN p_name VARCHAR(64))
BEGIN
-- p_name 是绑定进来的"值",但下一行把它拼成了 SQL 文本
SET @sql = CONCAT('SELECT * FROM user WHERE name = ''', p_name, '''');
PREPARE stmt FROM @sql; -- 编译的是已拼接文本,注入在这里生效
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END应用侧用 CallableStatement 规规矩矩绑定参数调这个过程,每一层都”用了预编译”,注入依然成立——因为编译发生的位置之下还有一层拼接。审计存储过程要看到数据库层去,别停在应用代码。
6. 汇总表
| 场景 | 为什么预编译失效 | 修复手段 |
|---|---|---|
| 动态表名/列名/ORDER BY | 标识符不在绑定机制覆盖范围 | 白名单映射 |
LIKE % _ | 通配符是 LIKE 子语法,不归 SQL 解析器管 | 参数值转义 + ESCAPE |
IN (...) | 占位符数量编译期固定 | 动态生成 N 个 ?,值仍绑定 |
| 二次注入 | 污染源来自库内”可信”数据 | 使用点全部参数化 |
| 存储过程内部拼接 | 拼接下沉到 DB 层,预编译在其上游 | 存储过程内同样参数化 |
| SQL 先拼后 prepare | 污染在编译之前已完成 | 模板必须写死,输入只能进 setXxx() |
预编译改造成本高时的落地策略
老系统成千上万处拼接,全量改参数化不现实。工程上的收敛路径,按优先级:
- 统一 DAO 收口:禁止业务代码裸用
createStatement/mysql_query,所有 SQL 走一个封装层。审计和改造都从”全网搜”变成”查一个入口”,后续在收口层强制参数化 API(根本不暴露拼接入口——想拼接都没有函数可调)。 - ORM 绑定 API 优先:能走 MyBatis
#{}/ JPA / QueryDSL 的一律走框架绑定通道,框架收口天然等价于 DAO 收口;同时立规禁用${}、*Raw系列 API(或加 Code Review 硬性卡点)。 - 标识符场景统一白名单组件:排序、动态表名这类无法参数化的需求,做一个公共白名单映射工具类,业务侧只许传 key,全公司一个实现,审一处等于审全部。
- RASP / WAF 兜底:改造期内用 RASP(hook 在 SQL 执行点,能看到最终 SQL 与调用栈,比 WAF 准)或 WAF 拦通用 payload,定位为应急缓解而非修复——可被绕过,报告里不能写”已修复”,只能写”已缓解”。
- 优先级排序:按”参数是否直接来自 HTTP 输入 × 是否有回显 × 库权限高低”排改造顺序,数字型拼接点(无引号包裹、利用成本最低)最先改。
案例:自以为用了预编译,仍被注入
下面这个案例把前面所有知识点串一遍。先自己读一遍代码,试着找问题,再看分析。
// UserController.java —— 开发者自述:"全部用了 PreparedStatement,不可能注入"
@GetMapping("/users")
public List<User> list(@RequestParam String sort, // sort:排序字段,来自 URL 参数
@RequestParam String kw, // kw:搜索关键词,来自 URL 参数
@RequestParam List<Long> deptIds) // deptIds:部门 id 列表
throws SQLException {
// ① 排序字段和 IN 列表"灵活"地拼进了 SQL 模板
String sql = "SELECT id, name, email FROM user WHERE name LIKE ? "
+ "AND dept_id IN (" + String.join(",", deptIds.stream()
.map(String::valueOf).toArray(String[]::new)) + ") "
+ "ORDER BY " + sort;
PreparedStatement ps = conn.prepareStatement(sql); // ② "我用了预编译!"
ps.setString(1, "%" + kw + "%"); // ③ 参数也绑定了!
ResultSet rs = ps.executeQuery();
return mapRows(rs);
}逐行数据流分析(三个注入面):
面一:ORDER BY " + sort——真注入(报错/布尔/延时均可)
① 用户输入:GET /users?sort=<攻击者控制>,sort 从 URL 参数原样进来;
② 处理过程:没有任何校验/白名单,直接字符串拼接进 SQL;
③ 危险函数:conn.prepareStatement(sql)——拼接发生在 prepare 之前,编译的就是含恶意片段的文本;
④ 防线为何失效:预编译机制根本没有机会生效——它编译的那一刻,恶意片段已经是 SQL 文本的一部分了。这是”预编译之前 SQL 已被拼接”的教科书形态。
payload 拆解:sort=id,(select+sleep(5))
id,—— 先把前面的ORDER BY语法补全闭合,让语句合法;(select sleep(5))—— 注入一个子查询,让数据库沉睡 5 秒(+在 URL 里是空格的编码);- 效果:页面响应慢 5 秒 → 延时注入确认存在,之后可以用
sleep+ 条件判断逐位盲注数据。
另一个布尔盲注 payload:sort=if(1=1,id,(select+1+from+dual))——if(条件, 真时的值, 假时的值),条件真就按 id 排序,假就触发错误,靠页面差异回显猜数据。
面二:IN (" + deptIds + ")"——真注入(绕过类型表象)
① 用户输入:?deptIds=1&deptIds=2...;
② 处理过程:deptIds 声明为 List<Long>,框架只校验”能否转成 Long 列表”——攻击者传 deptIds=1,2,(select...) 这类会 400;然而一旦框架绑定宽松(或下次有人把类型改成 String[]),拼接进 IN 列表的内容直接进入语法树;
③ 危险函数:同上,prepareStatement(sql);
④ 防线为何失效:即使本例类型严格、当下打不进去,这个写法本身是定时炸弹——拼接 + 用户可控 = 漏洞只差一次重构。正确写法是前文第 3 节的动态生成 ?,?,? 占位符。
面三:ps.setString(1, "%" + kw + "%")——LIKE 通配符未转义
① 用户输入:kw 原样进参数;
② 处理过程:只包了 %,没转义参数值内部的 %、_;
③ 到达:LIKE 子句;
④ 影响:kw 里的 %、_ 会改变匹配语义,配合遍历可拖全表(不是语法注入,但属于预编译管不到的语义层问题)。修法见前文第 2 节。
审计结论
“用了预编译 API” ≠ “预编译在保护你”。判定标准是三条同时成立:
- SQL 模板 100% 由开发者写死(拼接内容只能是白名单常量或
?占位符本身); - 所有用户输入只出现在
setXxx()/bindValue()里; - 底层是真预编译(PDO 关模拟 / MySQL 开
useServerPrepStmts/ MySQLi prepare)。
本案例三条全破防,注入成立。这就是审计预编译类代码的完整 checklist,背下来直接能用。
上手实操:30 分钟排查一个项目的预编译问题
拿到一个陌生项目,按这个顺序搜,基本不会漏:
第一步:确定技术栈和驱动配置(5 分钟)
# PHP 项目:找 PDO 连接点,看有没有关模拟、DSN 有没有 charset
grep -rn "new PDO(" --include="*.php" .
grep -rn "ATTR_EMULATE_PREPARES" --include="*.php" .
# Java 项目:找 JDBC URL,看 useServerPrepStmts
grep -rn "jdbc:mysql://" --include="*.yml" --include="*.properties" --include="*.java" .判定:PHP 没找到 ATTR_EMULATE_PREPARES => false、Java 没找到 useServerPrepStmts=true,就在报告里记一笔”模拟预处理,存在宽字节等转义系绕过面”,然后继续往下找真正的拼接点。
第二步:找”先拼后 prepare”(10 分钟,主力环节)
# 找出所有 prepare 调用点
grep -rn "prepareStatement(" --include="*.java" .
grep -rn "->prepare(" --include="*.php" .对每个调用点做一件事:盯住传进去的 SQL 字符串变量,向上回溯它的构造。看到这个变量身上有 +、.=、String.format、StringBuilder.append(变量)、sprintf,且拼进来的东西能追到 HTTP 参数——漏洞成立。IDE 里用”Find Usages / 跳转到定义”一路点上去即可,这步没有捷径,就是体力活,也是审计的核心产出。
第三步:扫标识符拼接和 IN 列表(10 分钟)
# 排序/表名拼接重灾区
grep -rn "ORDER BY" --include="*.java" --include="*.php" . | grep -E "\+|\.|concat"
# IN 列表拼接
grep -rn "IN (" --include="*.java" --include="*.php" . | grep -E "\+|join|implode"
# MyBatis 的 ${} 纯文本替换(等价于拼接)
grep -rn '\${' --include="*.xml" .每个命中点问同一个问题:这个变量过白名单了吗? 没有白名单 + 来源可控 = 漏洞。
第四步:收尾确认(5 分钟)
- LIKE 场景搜
LIKE ?和"%" +,看参数有没有转义%、_; - 全局搜存储过程调用(
CallableStatement、CALL),有的话去数据库层看过程内部有没有CONCAT+PREPARE; - 把发现按”参数直达 HTTP × 有无回显 × 库权限”排序,写进报告。
新手最常犯的错:第一步看到 prepare( 就下结论”该项目使用参数化查询,无 SQL 注入”。整篇笔记讲的就是为什么不能这么下结论——prepare 只是入场券,时序、真假、覆盖面三条都要查。
面试题眼速答(题库四.24-29)
| 题眼 | 一句话答案 | 展开两三句 |
|---|---|---|
| 预编译为什么能防 SQL 注入? | 参数到达服务端时语法解析已结束,永远只是值不是语法。 | SQL 模板先在服务端完成词法语法解析、生成执行计划,语法树定型后才绑定参数;参数走独立协议数据段,不再经过解析器。不是”过滤了危险字符”,而是危险字符失去了被当语法解析的机会。 |
| 预编译和参数绑定是一回事吗? | 不是,预编译是机制,参数绑定是动作,防注入靠二者按正确时序组合。 | 预编译回答”SQL 在哪一步定型”,绑定回答”值怎么传进去”;必须绑定发生在编译之后才安全。预编译的初衷是性能(执行计划复用),防注入是副产品。 |
| 客户端模拟预处理和服务端预编译区别? | 模拟是驱动本地转义拼接后一次发走,参数仍参与解析;真预编译是 PREPARE/EXECUTE 两次协议交互,参数不参与解析。 | 模拟预处理安全强度等价于 mysql_real_escape_string + 拼接,继承转义全部缺陷(宽字节注入);真预编译参数走 COM_STMT_EXECUTE 独立数据段,与 SQL 文本不在同一个报文。 |
| PDO 怎么保证真预编译?宽字节风险在哪? | 显式 PDO::ATTR_EMULATE_PREPARES => false,DSN 里写 charset=utf8mb4。 | 模拟模式下转义发生在客户端,GBK 连接时攻击者提交 %df',转义出的 0x5c 被 %df 吃成汉字、单引号逃逸;真预编译参数不过文本路径,此面消失。MySQLi 的 prepare() 默认即真预编译。 |
| Java PreparedStatement 一定安全吗? | 不一定,两个坑:驱动默认模拟、SQL 先拼后 prepare。 | MySQL Connector/J 默认 useServerPrepStmts=false 是客户端模拟,需在 JDBC URL 显式开启;且若 SQL 字符串在 prepareStatement() 之前已被拼接污染,预编译只是走过场——必须向上追溯 SQL 构造来源,模板必须写死。 |
| 哪些场景预编译防不住?怎么修? | 六类:动态标识符、LIKE 通配符、IN 列表、二次注入、存储过程内部拼接、先拼后 prepare。 | ① 动态表名/列名/ORDER BY(标识符不能绑定)→白名单映射;② LIKE 的 %_→参数值转义加 ESCAPE;③ IN 列表→按个数动态生成 ?,值仍全绑定;④ 二次注入→出库数据按不可信处理;⑤ 存储过程内拼接→过程内同样参数化;⑥ 先拼后 prepare→模板写死,输入只进 setXxx()。 |
| 存量代码改造成本高怎么落地? | 收口 + 框架绑定 + 白名单组件 + 兜底缓解 + 按风险排优先级。 | 统一 DAO/ORM 收口让 SQL 只有一个入口,不暴露拼接通道;标识符需求做公共白名单组件;改造期用 RASP/WAF 兜底但只算应急缓解;按”输入可达 × 有无回显 × 库权限”排序,数字型拼接点最先改。 |