SQL 注入是老话题,但真正把「参数化查询」落到一个老项目里,问题往往不在知不知道,而在于代码里有多少地方在拼接 SQL、有多少个地方用了 ORM 却又开了口子。这篇文章给出一条从盘点到收口的完整路径,重点讲那些「看起来已经安全、其实没有」的写法。
一、先搞清楚危险面有多大
不要一上来就改代码,先盘一次量。一个运行了三五年的 PHP 站点,SQL 通常散落在三种地方:框架的模型层、手写的 DAO 类、以及最要命的散落在业务控制器和后台脚本里的一次性查询。
盘点可以按关键词粗筛,命中后再人工判断:
grep -rn "query(" --include=*.php . | grep -E "\$_(GET|POST|REQUEST|COOKIE)"
grep -rn "mysql_query\|mysqli_query" --include=*.php . | wc -l
grep -rn "->exec(" --include=*.php . | grep "\."第一条找出「直接把超全局变量塞进 query」的,这是最高危的一类;第二条统计老 API 的使用量,决定要不要整体换成 PDO;第三条找 exec 拼接。盘点结果通常能给出一个很直观的判断:如果一次性 SQL 超过几百处,就不该指望靠人工逐个改完,而要先建好一套封装,再按调用量从高到低替换。
二、参数化到底解决了什么
参数化查询的原理是把 SQL 语句结构和数据分两次发给数据库:语句先解析、生成执行计划,占位符随后绑定。这样一来,用户输入永远只会被当作值,不会被当作语法。这和「转义」有本质区别——转义是在字符串层面做修补,只要有一个上下文没转到就破防;参数化是在协议层面分离,不存在「转义漏掉」的形态。
了解这一点之后,一个常见的误区就清楚了:表名、列名、ORDER BY 后面的字段名不能用占位符。因为这些位置需要的是标识符,而不是值。很多教程在这里含糊带过,导致开发者写出来的代码要么报错,要么改用字符串拼接后重新引入注入。
三、PDO 预处理的正确写法
先说连接配置,这一步没做好,后面的参数化可能全是假的:
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=blog;charset=utf8mb4',
$user, $pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);关键在 ATTR_EMULATE_PREPARES => false。PDO 默认由客户端模拟预处理,本质上是 PDO 自己拼接好再发给 MySQL,虽然它拼得比较安全,但仍然不是服务端真正的预处理语句,在某些边界字符集场景下存在被绕过的历史问题。关掉模拟之后,语句和参数真正分离传输,安全性才到位,同时也能让 MySQL 更好地复用执行计划。
第二个必做项是把错误模式设为异常。SILENT 模式下 SQL 出错会静默返回 false,如果业务代码没检查返回值,就会出现「查询失败了但页面看起来正常」的诡异现象,排查成本极高。
四、IN 子句与批量查询
IN 是最常被绕开参数化的地方,因为占位符个数不固定。正确做法是先构造等量的问号:
$ids = array_map('intval', $ids);
$ph = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT id, title FROM posts WHERE id IN ($ph)";
$stmt = $pdo->prepare($sql);
$stmt->execute($ids);如果 $ids 可能为空数组,要提前短路返回,否则 IN () 是语法错误。批量插入同理,用 array_fill 生成占位符组,再一次性 execute 拍平后的数组。
另一个常见问题是 LIMIT 和 OFFSET。在 EMULATE_PREPARES = false 下,把整数绑定到 LIMIT 位置会报 1064 语法错误,因为服务端预处理不接受这两个位置用问号。处置方式有两种:一是强制转成整数后拼接((int)$limit,整数不存在注入面),二是用 bindValue($n, (int)$limit, PDO::PARAM_INT)。老版本 MySQL 上后者不一定可用,稳妥起见用 (int) 拼接,这是少数允许拼接的位置。
五、LIKE 与标识符的处理
模糊查询的写法是「参数化值 + 通配符拼在值里」:
$kw = $pdo->quote('%' . $keyword . '%'); # 或者下面这种
$stmt = $pdo->prepare("SELECT * FROM posts WHERE title LIKE ?");
$stmt->execute(['%' . $keyword . '%']);要注意 % 和 _ 在 LIKE 里是通配符,用户输入里的这两个字符不转义的话,会把「精确匹配」变成「宽泛匹配」,可能被用来枚举数据。虽然不构成注入,但属于逻辑漏洞,可以用 addcslashes($kw, '%_\\') 处理。
排序字段这类标识符必须走白名单:
$allow = ['created', 'views', 'title'];
$order = in_array($_GET['sort'] ?? '', $allow, true) ? $_GET['sort'] : 'created';
$dir = ($_GET['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';
$sql = "SELECT id, title FROM posts ORDER BY `$order` $dir LIMIT 20";白名单是唯一可靠的标识符防护手段。用正则限制字符集(只允许字母下划线)只能算第二道防线,因为它拦不住「选了别人的列名」这种问题。
六、框架层:别在 ORM 里开口子
用了 ORM 的项目容易产生虚假的安全感。实际上 ThinkPHP、Laravel、Yii 都提供了 whereRaw、orderByRaw、->query() 这类逃生舱,一旦参数从请求里直接进去,风险与手写 SQL 完全相同。
// 危险
Db::query("SELECT * FROM posts WHERE title = '{$title}'");
// 安全
Db::query("SELECT * FROM posts WHERE title = ?", [$title]);审计时优先搜这些 Raw 方法,而不是逐个模型看业务逻辑。还有一类隐蔽形态:把 order 参数直接传给 ORM 的 order() 方法。部分框架实现里 order('id desc') 是拼字符串的,只要业务传入的是用户可控值,同样可注入。所有排序参数统一过白名单。
七、如何验证改对了
改完不能靠「看起来对」验收。最低成本的三步:
- 用 sqlmap 对改动过的接口跑一轮,重点看有没有重现的注入点;但要注意别对自己的生产库跑,用测试环境。
- 在测试库打开通用查询日志,重放一遍典型操作,检查是否有带用户输入字面值的完整 SQL 出现在日志里。
- 代码层面再 grep 一遍,确认
query(后面跟着的字符串里没有$变量直接插入。
SET GLOBAL general_log = 'ON';
SET GLOBAL log_output = 'TABLE';
SELECT argument FROM mysql.general_log WHERE argument LIKE '%SELECT%' ORDER BY event_time DESC LIMIT 20;
SET GLOBAL general_log = 'OFF';通用日志开销不小,验完立刻关掉。查出来的语句里如果能看到用户原文,说明那条路径还是拼接的。
八、遗留系统的分批收口
老项目不可能一次改完,要有节奏。建议分三步走:先给所有 SQL 调用套一层统一的 db_query($sql, $params) 封装,把 params 传空的项目标记出来;再按接口调用量排序,优先改支付、登录、导出这些高价值路径;最后收尾那些调用量极低的遗留脚本。
还需要一条兜底:在封装层加一个「参数为空且 SQL 含引号包裹的变量」的日志提醒。这样即使漏改了某处,也能在线上流量里被自动发现,而不是等出事才知道。任何改造项目,最怕的不是改得慢,而是改完不知道还有多少没改。
九、几个常见误判
最后列出排查时容易被当成「没事」的几种形态:一是用了 addslashes 就以为安全,宽字节字符集下它可能被绕过;二是数字型参数用 intval 就放心,但字符串比较场景下仍然有问题;三是把输入放进 htmlspecialchars 以为能防 SQL 注入,那是防 XSS 的,和数据库层毫无关系。
判断标准始终只有一条:用户输入有没有以「值」的方式绑定进语句。满足了就是安全的,没满足就是靠运气。
十、存储过程与动态 SQL 的边界
有一部分老站点的业务逻辑写在存储过程里,PHP 侧只负责调用。这时候参数化要在两个层面都做:调用侧用 PDO 的 Call 并绑定参数,存储过程内部如果要用 PREPARE 构造动态 SQL,也必须用 USING 绑定值,而不是把参数拼进语句串。
SET @sql = CONCAT('SELECT id, title FROM posts WHERE status = 1 ORDER BY ', @col, ' LIMIT 20');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;上面这段仍然有一处风险:@col 如果是外部传入的,就是标识符注入。存储过程里同样只能用白名单,例如先判断 @col IN ('created','views'),不满足就赋一个默认值。动态 SQL 的存在不构成问题,问题永远是「哪一段是不可控的字符串被拼进了语句结构」。
另外要注意权限。如果业务账号拥有 CREATE ROUTINE 或者可以直接调用任意存储过程,那么即使前端全部参数化,攻击者仍可能通过过程名做文章。生产库的应用账号只给业务必需的四类权限,其余一律收回,这是参数化之外的第二层保障。