不能靠“再次转义”防御二次SQL注入,因其在入库后读出拼SQL前失效,且字符集切换、关键字不处理、动态语法不覆盖等问题导致其不可靠;必须用白名单校验或强类型约束。

不能靠“再次转义”防御二次SQL注入——这方法在绝大多数场景下失效,且容易给人虚假安全感。
为什么 mysqli_real_escape_string() 或 addslashes() 对二次注入基本无效
二次注入的触发点不在“入库时”,而在“读出后拼SQL前”。此时连接字符集可能已变(比如从 utf8mb4 切到 latin1),mysqli_real_escape_string() 依赖当前连接的字符集做转义,逃逸结果不可控;更关键的是,它只处理单引号、反斜杠等有限字符,对 UNION、SELECT、ORDER BY 等关键字完全无感。
常见错误写法:
php $user_name = $row['name']; // 从 DB 读出,含 admin' OR '1'='1 $safe_name = mysqli_real_escape_string($conn, $user_name); $query = "SELECT * FROM logs WHERE user = '$safe_name'"; // 仍会执行恶意逻辑
- 即使
$safe_name被转义成admin\' OR \'1\'=\'1,在某些上下文中(如数字上下文、正则匹配、或配合其他漏洞)仍可绕过 - 该函数不处理动态表名、字段名、
ORDER BY子句——这些恰恰是二次注入高发区 - 一旦数据库连接复用或字符集切换,转义结果可能被解析为原始恶意字符串
真正有效的校验必须发生在 SQL 构造前,且基于白名单或强类型
校验不是为了“消毒”,而是为了确认这个值是否符合它本该具有的语义。比如用户名字段,它就不该包含单引号、分号、空格或 SQL 关键字。
- 如果是 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、宽字节等方式绕过。
参数化查询无法覆盖的场景,必须单独拦截
PDO 预处理只能保护 ? 和 :named 占位符位置的值,对拼接进 SQL 模板的任意部分(表名、列名、ORDER BY、GROUP BY)完全不设防。
危险示例:
php
$table = $row['preferred_table']; // 来自 DB,值为 'users; DROP TABLE users--'
$sql = "SELECT * FROM {$table} WHERE id = ?"; // 即使 id 用了 ?,$table 仍直接执行
$stmt = $pdo->prepare($sql); // prepare 会失败或静默截断,但风险已埋下
- 所有动态拼接进 SQL 模板的变量,无论来源是 POST、GET 还是数据库,都必须走白名单校验
-
whereRaw()、orderByRaw()、selectRaw()(Laravel)、queryBuilder().addSelectRaw()(Doctrine)等 ORM 方法同理,传入变量需前置校验 - 避免封装“通用查询函数”接受任意字段名或表名——这类抽象看似方便,实则是二次注入温床
最易被忽略的一点:校验逻辑不能散落在各处业务代码里。一个 getUserNameById() 函数返回的值,如果后续可能被拼进 SQL,那它就应该返回已校验后的安全字符串,而不是裸数据。把“可信上下文”内聚到数据获取层,比在每个 prepare() 前补 if 更可靠。

















