因为二次注入的恶意数据在入库时已通过过滤,但取出后拼接sql时绕过所有入口防护,waf和静态扫描无法覆盖跨请求、跨模块的数据流转链路。

二次SQL注入不是“过滤没做好”,而是过滤位置错了——恶意数据在入库时被安全处理,却在取出后直接拼进新SQL,导致所有入口层防护完全失效。
为什么 WAF 和静态扫描都抓不到二次注入
自动化工具只看单次请求的输入输出链路,而二次注入依赖跨请求、跨模块的数据流转。比如:注册接口用 PDO::prepare() 安全存入 admin\' OR \'1\'=\'1,三天后后台导出报表时从数据库查出该值,再拼进 "SELECT * FROM logs WHERE user = '$name'" —— 这个拼接动作发生在另一个文件、另一个函数、甚至另一个服务里。
常见漏报场景包括:
- 日志模块读取
user_remark字段后直接写进INSERT INTO audit_log (remark) - 配置表中存储的
table_name被用于构造"SELECT * FROM {$table}" - Redis 缓存的
nickname被原样塞进WHERE nickname LIKE '%$k%'
mysqli_real_escape_string() 在二次调用时为何形同虚设
这个函数只对单引号、反斜杠等极少数字符做转义,且严重依赖当前 MySQL 连接的字符集。一旦连接复用或字符集切换(如从 utf8mb4 切到 latin1),admin\' OR \'1\'=\'1 就会被还原成原始 payload。
更关键的是,它对以下高危位置零防护:
-
ORDER BY $sort_field中的字段名 -
GROUP BY $group_col中的列名 -
SELECT * FROM $table中的表名 - 数字上下文中的
id = $id(哪怕 $id 是字符串型)
必须在 SQL 构造前做白名单校验的三个典型位置
校验动作不能放在入库时,也不能靠“统一中间件”兜底,必须紧贴执行点,即 mysqli_query() 或 PDO::prepare() 调用前那一行。
- ID 类字段:
is_int($id) || ctype_digit((string)$id)—— 拒绝任何含字母、空格、符号的输入 - 用户名字段:
preg_match('/^[a-zA-Z0-9_\x{4e00}-\x{9fa5}]{2,20}$/u', $name)—— 明确限定字符集与长度 - 动态表名:
in_array($table, ['users', 'orders', 'products'], true)—— 绝对禁止外部输入决定结构
参数化查询无法覆盖的盲区必须手动拦截
PDO 和 mysqli 的预处理只能保护 ? 和 :named 占位符位置的值,对拼进 SQL 模板的任意部分完全不设防。例如:
SELECT * FROM {$table} WHERE id = ?
这里 $table 是从数据库读出的,哪怕 id 用了占位符,$table 仍可触发注入。这种地方必须单独校验,不能指望参数化“自动兜底”。
最常被忽略的一点是:ORM 的 whereRaw()、orderByRaw()、groupByRaw() 等方法,传入变量时和原生拼接无异,同样需要白名单或强类型约束。











