real_escape_string 在双引号里并不安全:PHP 8.6 的 mysqli_quote_string 连引号一起处理
PHP 8.6 新增了 mysqli::quote_string() 和过程式别名 mysqli_quote_string()。这个函数做两件事:按当前连接的字符集转义字符串,再根据连接的 SQL 模式选一种合适的引号样式,把值包裹起来,返回已经带引号的字面量。它修复的场景是:mysqli_real_escape_string() 的输出被塞进双引号字符串,而会话开了 NO_BACKSLASH_ESCAPES 时留下的注入面。
预处理语句仍然是推荐路径,quote_string() 只是把「不得不拼接字面量」时的默认做法从易错变正确。
转义函数为什么只在特定引用符下成立
mysqli_real_escape_string() 的语义是:考虑连接当前字符集,转义 SQL 语句中的特殊字符。它不添加外层引号,转义方式是把特殊字符前面加上反斜杠,' 变 \'," 变 \"。
这套做法成立的前提是「反斜杠是转义符」。一旦有人在会话里执行:
SET sql_mode='NO_BACKSLASH_ESCAPES';
反斜杠就退化成一个普通字符。此时 \' 在字符串里表示「反斜杠 + 引号」,引号会照常闭合字符串,转义等于没做。
两种引号的处境并不对等:
- 单引号包裹时,MySQL 还有第二种写法,把内部的
'双写成''。但real_escape_string()生成的是\'而不是'',所以它同样失效——只是理论上还有救。 - 双引号包裹时,没有任何替代写法可用(
""在字符串里不表示转义的双引号,在ANSI_QUOTES下双引号还直接变成标识符引用符)。\"一失效就是死路。
可复现的注入样例
$mysqli = new mysqli('127.0.0.1', 'app', 'secret', 'test');
$mysqli->set_charset('utf8mb4');
$mysqli->query("SET sql_mode='NO_BACKSLASH_ESCAPES'");
$name = 'a" OR 1=1 -- ';
$sql = 'SELECT * FROM users WHERE name = "' . $mysqli->real_escape_string($name) . '"';
echo $sql;
输出的语句是:
SELECT * FROM users WHERE name = "a\" OR 1=1 -- "
在 NO_BACKSLASH_ESCAPES 下,\" 里的反斜杠是字面量,引号在 a\ 之后就把字符串闭合了,剩下的 OR 1=1 -- 成为真正的 SQL。同一段代码在默认 sql_mode 下是安全的,这正是它难被发现的原因:安全性取决于运行时会话状态,而不取决于调用点的代码长得对不对。
quote_string 的用法:返回值已经带引号
$mysqli->query("SET sql_mode='NO_BACKSLASH_ESCAPES'");
$quoted = $mysqli->quote_string('a" OR 1=1 -- ');
$sql = 'SELECT * FROM users WHERE name = ' . $quoted;
// SELECT * FROM users WHERE name = 'a" OR 1=1 -- '
这里连接处于 NO_BACKSLASH_ESCAPES,函数选了单引号样式,内部的 ' 按 '' 规则处理;模式切回默认后,它会改用反斜杠转义并加引号。
需要特别注意:不要在外面再手动加引号。
// 错误:变成两层引号
$sql = 'SELECT * FROM users WHERE name = "' . $mysqli->quote_string($name) . '"';
quote_string() 已经返回 '...' 这样的完整字面量,再套一层双引号,得到的要么是语法错误,要么是一个被当成字符串拼接的空串 + 原串,值直接变了。
只适用于字面量拼接
quote_string() 产出的是字符串字面量,不能用来拼表名、列名、ORDER BY 字段这类标识符。标识符位置需要白名单或反引号转义:
$allowed = ['id', 'name', 'created_at'];
$col = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'id';
$sql = 'SELECT ' . $col . ' FROM users ORDER BY ' . $col;
把 'users' 放在表名的位置,SQL 会把字符串字面量当成表名,直接报语法错。
8.5 及以下的替代写法
addslashes() 不能当作替代:它不感知连接字符集,也不感知 sql_mode,在多字节字符集下存在已知的绕过方式。
需要手工拼接字面量时,只能自己读取会话的 sql_mode 并据此选引号样式:
function quote_literal(mysqli $db, string $s): string {
$row = $db->query('SELECT @@SESSION.sql_mode AS m')->fetch_assoc();
if (strpos($row['m'], 'NO_BACKSLASH_ESCAPES') !== false) {
return "'" . str_replace("'", "''", $s) . "'";
}
return "'" . $db->real_escape_string($s) . "'";
}
这段代码每次都会多一次查询。能改用预处理语句的地方,还是用预处理语句更省事。