预编译为什么能防注入:先把”编译”这件事想清楚

先修知识

第一次读本篇之前,把下面这些词过一遍。不用背,看到后面正文里出现时能想起来”哦是这个意思”就行。

术语一句话白话解释
SQL 注入攻击者把自己写的 SQL 片段混进正常查询里,让数据库执行了不该执行的命令。
SQL 模板一条写好了骨架、留了空位(?)的 SQL,像一张”填空题卷子”。
占位符(?)模板里的空格,告诉数据库”这里等会儿要填一个值”。
预编译(Prepare)先把 SQL 模板发给数据库”审题定稿”,之后再填值的过程。
参数绑定(Bind)把变量的值填进占位符的那个动作。
语法树数据库解析 SQL 后在内存里生成的”句子结构图”,一旦生成,SQL 的骨架就定死了。
执行计划数据库想好的”怎么查最快”的方案(用哪个索引、先查哪张表)。
标识符表名、列名、ASC/DESC 这类”结构部件”,和”值”是两个世界的东西。
宽字节注入利用 GBK 等多字节编码”两个字节拼一个汉字”的特性,把转义用的反斜杠吃掉,让单引号逃逸出来的注入手法。
转义在危险字符前加 \(比如 ' 变成 \'),告诉解析器”这是普通字符不是语法”。
PDO / MySQLiPHP 连数据库的两种主流扩展,本篇会对比它们的预编译行为。
JDBCJava 连数据库的标准接口,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]);                      // 参数走独立通道,不参与解析

审计时怎么找

  1. 全仓库搜 new PDO(,逐个看构造参数:
    • 第四个参数(options 数组)里有没有 PDO::ATTR_EMULATE_PREPARES => false?没有就按模拟预处理处理,继续查连接字符集是不是 GBK 等多字节编码——是的话宽字节注入面成立。
    • DSN 字符串里有没有 charset=utf8mb4?没有的话,模拟模式下客户端转义用的字符集不受控,风险加倍。
  2. 模拟模式下还有个经典连锁坑:LIMIT ?, ? 绑定参数会被驱动加上引号变成 LIMIT '0', '10' 导致语法错误,开发者排查后往往”图省事”改回字符串拼接——审计时看到 LIMIT 附近有拼接,要追溯这个动机,大概率是从绑定退化来的。
  3. 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 顺带开启服务端预编译语句缓存,是性能配套项,一般成对出现。

审计时怎么找

  1. 找到数据源配置(application.yml、application.properties、Java 配置类里的 jdbcUrl),搜 jdbc:mysql://:
    • URL 里没有 useServerPrepStmts=true → 按模拟预处理对待。
  2. 全仓库搜 prepareStatement(,对每个调用点向上追溯 SQL 字符串的构造——模板里出现 + 拼接、String.format、StringBuilder.append 拼进变量,就是”先污染后编译”。

审计对照表

技术栈真预编译的达成条件
PDO (PHP)显式设置 ATTR_EMULATE_PREPARES => false(老版本驱动默认模拟;PHP 8.1 起 mysqlnd 下默认行为改善,但审计一律以显式配置为准)
MySQLi (PHP)prepare() 即真预编译,无需额外配置
JDBC + MySQLuseServerPrepStmts=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()

预编译改造成本高时的落地策略

老系统成千上万处拼接,全量改参数化不现实。工程上的收敛路径,按优先级:

  1. 统一 DAO 收口:禁止业务代码裸用 createStatement/mysql_query,所有 SQL 走一个封装层。审计和改造都从”全网搜”变成”查一个入口”,后续在收口层强制参数化 API(根本不暴露拼接入口——想拼接都没有函数可调)。
  2. ORM 绑定 API 优先:能走 MyBatis #{} / JPA / QueryDSL 的一律走框架绑定通道,框架收口天然等价于 DAO 收口;同时立规禁用 ${}、*Raw 系列 API(或加 Code Review 硬性卡点)。
  3. 标识符场景统一白名单组件:排序、动态表名这类无法参数化的需求,做一个公共白名单映射工具类,业务侧只许传 key,全公司一个实现,审一处等于审全部。
  4. RASP / WAF 兜底:改造期内用 RASP(hook 在 SQL 执行点,能看到最终 SQL 与调用栈,比 WAF 准)或 WAF 拦通用 payload,定位为应急缓解而非修复——可被绕过,报告里不能写”已修复”,只能写”已缓解”。
  5. 优先级排序:按”参数是否直接来自 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 兜底但只算应急缓解;按”输入可达 × 有无回显 × 库权限”排序,数字型拼接点最先改。