二次SQL注入无法靠再次转义防御,因mysqli_real_escape_string依赖字符集且不防护关键字、表名、ORDER BY等非值上下文;必须在数据读出后、拼入新SQL前,对动态部分做白名单或强类型校验。

二次SQL注入不是靠“再转义一次”就能拦住的,因为转义结果在读出时已失效,且根本不管关键字、表名、ORDER BY 这些高危位置。真正要防住,得在数据从数据库取出、即将拼进新 SQL 前那一瞬间,做白名单或强类型校验。
为什么 mysqli_real_escape_string() 在二次调用时完全失效
这个函数依赖当前 MySQL 连接的字符集做转义,而应用层读取数据时连接可能已切换(比如从 utf8mb4 切到 latin1),导致 admin\' OR \'1\'=\'1 被解析回原始恶意字符串;更关键的是,它只处理单引号、反斜杠、\0 等极少数字符,对 UNION、SELECT、ORDER BY 等关键字毫无反应。
- 常见错误:从 DB 读出
$name = $row['name'],接着调用mysqli_real_escape_string($conn, $name),再拼进"WHERE user = '$name'" - 即使转义成功,只要下游是数字上下文(如
id = $name)、正则匹配、或配合宽字节漏洞,照样可绕过 - 该函数对动态表名、字段名、
GROUP BY子句等零防护——这些正是二次注入最常爆发的位置
哪些地方必须在二次调用前做校验
所有从数据库读出、又参与 SQL 拼接的变量,都属于高危路径。典型场景包括:
- 后台日志模块读取
user_remark字段后,拼进INSERT INTO audit_log (remark) VALUES ('{$remark}') - 报表系统从配置表查出
table_name,用于构造SELECT * FROM {$table_name} - ORM 的
whereRaw("status = '{$dbValue}'")或orderByRaw("{$sortField} {$direction}") - SQL Server Agent 作业中引用
$(ESCAPE_NONE(SQLCMD)),而该变量值来自上游查询结果
怎么做才真正有效:白名单 + 强类型 + 位置紧贴
校验动作必须发生在 prepare() 或 mysqli_query() 调用前,且不能复用入库时的逻辑。重点不是“消毒”,而是确认语义合法性:
- ID 类字段:
is_int($id) || ctype_digit((string)$id)—— 拒绝任何含字母、符号、空格的输入 - 邮箱字段:
filter_var($email, FILTER_VALIDATE_EMAIL)—— 白名单式结构验证 - 用户名字段:
preg_match('/^[a-zA-Z0-9_\x{4e00}-\x{9fa5}]{2,20}$/u', $name)—— 明确限定字符集与长度 - 动态表名:
in_array($table, ['users', 'orders', 'products'], true)—— 绝对禁止外部输入决定表名 - 禁止用
str_replace(['"', "'"], '', $s)这类黑名单清洗——攻击者可用/**/、%00、Unicode 变体绕过
参数化查询为什么不能自动覆盖二次注入
PDO 预处理和 sp_executesql 只保护占位符位置(? 或 @p1)的值,不保护 SQL 模板里被拼进去的任意部分。比如:
SELECT * FROM {$table} WHERE id = ?这里 $table 是从数据库读出的,哪怕 id 用了 ?,$table 仍能触发注入。这种地方必须单独校验 $table 是否在允许列表里,而不是指望参数化“顺便”兜底。
最容易被忽略的是校验位置——不是写在注册入口,而是写在每次 SELECT 出来后、紧接着就要拼 SQL 的那几行代码里。漏掉一次,就等于留了一个隐蔽的后门。

















