前言SQL 注入SQL Injection到今天仍然是后台系统最容易被拿下的漏洞原因不是它难防而是它太容易在不经意间被重新引入一个「排序字段」参数、一个「批量删除」的 ID 列表、一句为了偷懒拼出来的ORDER BY都足以让前面所有的防护失效。更麻烦的是症状。一个注入点往往在测试环境完全看不出来——参数是正常的1SQL 执行得飞快只有攻击者传进一段带引号的内容时行为才暴露出来。而在那之前代码看起来「用了参数绑定」。本文从「为什么拼接一定出事」讲起把 PDO 预处理真正安全和不安全的边界划清楚再解决那些参数绑定覆盖不到的位置表名、排序、LIMIT、IN、LIKE最后给出一个可以直接用的查询封装。示例需要 PHP 8.0 及以上并会标出 PHP 8.0 / 8.1 中与数据库错误处理相关的默认行为变化。一、为什么字符串拼接一定出事SQL 语句在数据库里要经历两个阶段解析parser 决定哪些是语法结构和执行。当用户输入被拼进语句字符串的那一刻它和 SQL 关键字就处在同一个词法层面了——数据库无法区分哪一个字符来自你的代码、哪一个来自攻击者。// ❌ 拼接用户输入进入了 SQL 的语法层面 $sql SELECT * FROM admin WHERE username {$_GET[u]};如果u传的是admin OR 11拼出来的语句变成SELECT * FROM admin WHERE username admin OR 11条件恒真整张表被读出来。如果传的是x; DROP TABLE admin; --在允许堆叠查询的连接上就能直接删表。预处理的本质是让数据永远停留在数据层面SQL 语句和参数分两次发送给数据库数据库先把语句结构解析并编译好参数再以独立的形式填进去。参数里就算含有引号和分号也只会被当成一个字符串的值不会变成语法结构。一句话记忆注入的根因不是「引号没转义」而是「数据和代码混在了同一个字符串里」。所有正确的防御手段都是在恢复这条边界。二、PDO 的正确打开方式先看连接配置这里有两处默认值必须显式改掉?php // db.php —— 需要 PHP 8.0 declare(strict_types1); function makePdo(): PDO { // 1) charset 写在 DSN 里确保连接建立时就指定字符集 $dsn mysql:host127.0.0.1;port3306;dbnameapp;charsetutf8mb4; $pdo new PDO($dsn, app_user, getenv(DB_PASSWORD) ?: , [ // PHP 8.0 起默认已经是异常模式这里显式声明避免老代码迁移时踩空 PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, // 2) 关闭仿真预处理让参数真正由数据库端绑定 PDO::ATTR_EMULATE_PREPARES false, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, // 让整数列返回 int而不是字符串减少类型判断上的意外 PDO::ATTR_STRINGIFY_FETCHES false, ]); return $pdo; }两个要点charset必须写在 DSN 里不要用连接后的SET NAMES。使用仿真预处理时如果连接字符集和客户端声明不一致历史上出现过宽字节如 GBK注入%bf%27这样的字节序列会让转义函数加上的反斜杠被「吃掉」。ATTR_EMULATE_PREPARES false让参数由 MySQL 服务端绑定。仿真模式下 PDO 自己在客户端做转义安全性依赖字符集设置的正确性关掉之后参数和语句在不同的网络包中传输从机制上杜绝了这类问题。绑定参数的标准写法?php // 使用命名占位符避免位置参数在参数多时对错位置 $stmt $pdo-prepare( SELECT id, username, role FROM admin WHERE username :username AND status :status ); $stmt-execute([ :username $_POST[username] ?? , :status 1, ]); $rows $stmt-fetchAll();不要用$pdo-quote()手工拼接也不要写... WHERE id . $pdo-quote($id)。quote()只在你想把值嵌进一段无法使用占位符的 SQL例如某些 DDL时才用得上正常查询一律走prepareexecute。三、参数绑定覆盖不到的地方这是注入真正的高发区有几类位置在 SQL 语法里就不允许出现占位符。位置能不能绑定参数正确做法WHERE的值能WHERE id :idINSERT的值能VALUES (:a, :b)表名、列名不能白名单校验后拼接ORDER BY的字段与方向不能白名单映射绝不直接拼用户值LIMIT/OFFSET视驱动而定强制转成整数(int)再拼或绑PDO::PARAM_INTIN (...)的元素个数不能按数组长度动态生成占位符LIKE的模式串能但语义要处理绑定值同时转义%和_GROUP BY/HAVING不能白名单ORDER BY是最典型的翻车现场。很多人以为ORDER BY :field是安全的实际不会报错、也不会按预期排序于是被改成字符串拼接// ❌ 用户在 sort 里传 id; DROP TABLE admin -- 就完了 $sql SELECT * FROM orders ORDER BY {$_GET[sort]} {$_GET[dir]};正确做法是把「允许的字段」和「允许的方向」都枚举出来?php // PHP 8.0 $sortable [id, amount, created_at, status]; // 白名单只允许这些列 $sort $_GET[sort] ?? id; if (!in_array($sort, $sortable, true)) { $sort id; // 非法值直接回落到默认列 } $dir strtolower((string) ($_GET[dir] ?? desc)); $dir $dir asc ? ASC : DESC; // 只可能取两个固定值 $limit max(1, min(100, (int) ($_GET[limit] ?? 20))); // 强制整数并夹紧区间 $sql SELECT id, amount, created_at, status FROM orders WHERE admin_id :admin_id ORDER BY {$sort} {$dir} LIMIT {$limit}; $stmt $pdo-prepare($sql); $stmt-execute([:admin_id $adminId]);这里的白名单是关键即使表结构里有个叫password的列也不在$sortable里用户就永远排不了它。IN子句要按数组长度生成占位符?php // PHP 8.0 $ids array_values(array_filter(array_map(intval, $ids), static fn (int $v): bool $v 0)); if ($ids []) { return []; // 空数组直接短路别拼出 IN () } $placeholders implode(,, array_fill(0, count($ids), ?)); $stmt $pdo-prepare(SELECT id, name FROM goods WHERE id IN ({$placeholders}) AND on_sale 1); $stmt-execute($ids); $rows $stmt-fetchAll();这段代码里拼接的$placeholders是由数组长度生成的问号串用户数据一个字符都没有进去所以是安全的。LIKE除了绑定还要处理通配符否则用户传%就能匹配全部记录?php // PHP 8.0 $keyword (string) ($_GET[q] ?? ); // 先转义 MySQL LIKE 的通配符再拼上自己的 % $escaped str_replace([\\, %, _], [\\\\, \\%, \\_], $keyword); $stmt $pdo-prepare( SELECT id, title FROM articles WHERE title LIKE :kw ESCAPE \\\\ LIMIT 50 ); $stmt-execute([:kw % . $escaped . %]);四、二次注入最隐蔽的一类二次注入second-order injection指的是第一次写入是安全的危险发生在第二次读取之后。数据规规矩矩地通过预处理存进了数据库然后在另一个地方被取出来拼接// 第一步安全写入用了预处理 $stmt $pdo-prepare(INSERT INTO admin (username) VALUES (:u)); $stmt-execute([:u admin-- ]); // 参数绑定安全落库 // 第二步另一个模块里取出后拼接❌ 注入在这里爆发 $row $pdo-query(SELECT username FROM admin WHERE id 1)-fetch(); $sql UPDATE admin SET password x WHERE username {$row[username]};数据库里存的用户名带着一个单引号拼接后就破坏了语句结构。这类漏洞用「输入过滤器」是防不住的因为第一次输入完全合法。唯一可靠的防线就是任何进入 SQL 的值都必须走参数绑定无论它来自用户还是来自自己的数据库。常见坑点以为用了预处理就万事大吉❌prepare()之后用execute()传参但 SQL 字符串本身还是拼的$pdo-prepare(SELECT * FROM t WHERE id {$_GET[id]})。 ✅ 占位符写在 SQL 里值只通过execute()或bindValue()传入。ORDER BY/GROUP BY直接拼❌... ORDER BY {$_GET[sort]}—— 绑定不了于是顺手拼上。 ✅ 用白名单数组做映射非法值回落到默认列。LIMIT用字符串绑定❌$stmt-bindValue(:limit, $_GET[limit])在关闭仿真预处理的连接上会直接报语法错误于是又改回拼接。 ✅ 先(int)强制转换并夹紧上下界再作为整数拼进 SQL 或绑PDO::PARAM_INT。addslashes()当防护手段❌$name addslashes($_POST[name]);然后拼进 SQL。 ✅addslashes()不是数据库感知的转义函数字符集一变就失效。一律改用参数绑定。mysqli_real_escape_string()没先设字符集❌ 连接后直接调用转义没有先$mysqli-set_charset(utf8mb4)连接字符集仍是默认值时宽字节注入依然可行。 ✅ 先设字符集再转义更推荐直接放弃转义路线改用预处理。过时的mysql_*函数❌ 老项目里还在用mysql_query()、mysql_real_escape_string()。 ✅ ext/mysql 在PHP 7.0中已经被移除代码根本跑不起来迁移到 PDO 或 mysqli并同时改用参数绑定。错误处理上的默认值变更被忽略❌ 升级 PHP 后原先「查询失败返回 false代码继续往下跑」的逻辑因为 PHP 8.0 起 PDO 默认抛异常而直接 500。 ✅ 要知道两处默认值变化PDO 的错误模式从PHP 8.0起默认为ERRMODE_EXCEPTIONmysqli 的错误报告模式从PHP 8.1起默认为抛异常。升级前先检查代码里对返回值false的判断。把异常详情直接回显给用户❌catch (PDOException $e) { echo $e-getMessage(); }—— 把 SQL 片段、表名、字段名全暴露出去等于免费给攻击者做侦察。 ✅ 记录到日志对外统一返回「系统繁忙」。总结位置安全做法常见错误WHERE/VALUES的值命名占位符 execute()字符串拼接表名、列名白名单校验直接拼用户输入ORDER BY白名单映射 方向二值化ORDER BY :field或直接拼LIMIT/OFFSET强制(int)并夹紧区间绑字符串导致语法错误后改回拼接IN (...)按数组长度生成占位符把数组拼成a,b字符串LIKE绑定 转义%_直接拼接导致通配符越权二次写入来自数据库的值同样绑定认为「自己库里的数据可信」连接字符集写在 DSN 的charset用SET NAMES后手工转义防住 SQL 注入只需要坚持一条纪律任何进入 SQL 语句的值都必须经过参数绑定或白名单校验没有任何例外。预处理的边界很清楚——它保护的是「值」保护不了「结构」把所有结构性拼接都换成白名单映射注入面就被彻底关掉了。