PHP 连接 MySQL 的正确姿势:PDO 预处理为什么能防 SQL 注入
PDO 预处理之所以能防 SQL 注入,根本原因只有一个:它把「SQL 语句的结构」和「用户传入的数据」彻底分开了——先用占位符把语句模板发给数据库编译好,参数再通过独立通道单独传输,数据从头到尾不参与 SQL 解析,所以无论用户输入什么,都只能被当作「值」,永远变不成「代码」。
拼接字符串的写法,为什么一定会被注入
结论:只要用户输入参与拼接 SQL 字符串,注入就是时间问题,字符转义救不了你。
看这段最常见的错误写法:
$sql = "SELECT * FROM users WHERE email = '$email' AND status = 1";
$row = $pdo->query($sql)->fetch();
攻击者把 `$email` 填成 `' OR 1=1 -- `,拼接后语句变成 `WHERE email = '' OR 1=1 -- ' AND status = 1`,`--` 后面全部被注释掉,登录校验直接绕过。这类字符串在 MySQL 里称为「被解析成 SQL 语法的一部分」,危险就在这。
有人会说「我用 `mysqli_real_escape_string` 转义过」。转义能挡住大部分场景,但它依赖当前连接的字符集是否正确(历史上宽字节注入就出在这),而且一旦忘了转义某处、或者转义后又做了一次 `urldecode`,防线就破了。安全逻辑不该建立在「每次都记得转义」上。
PDO 的正确姿势:四条连接参数 + 占位符
结论:连接时关掉模拟预处理、开启异常模式,查询时一律用占位符传值,这是 PHP 访问 MySQL 的推荐基线。
$dsn = 'mysql:host=127.0.0.1;dbname=forum;charset=utf8mb4';
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 出错抛异常,别静默
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 关闭模拟预处理,走 MySQL 原生预处理
PDO::ATTR_STRINGIFY_FETCHES => false,
]);
`ATTR_EMULATE_PREPARES` 这一条尤其关键:PDO_MySQL 默认是开启模拟预处理的,此时 PDO 自己在前端做转义和字符串拼接,等于又回到了「靠转义吃饭」的老路。设为 `false` 后,语句模板发给 MySQL 编译,参数经二进制协议单独传输,注入面被彻底切断。`charset=utf8mb4` 写在 DSN 里也比事后 `SET NAMES` 更可靠。
查询写法:
$stmt = $pdo->prepare('SELECT id, username FROM users WHERE email = ? AND status = ?');
$stmt->execute([$email, 1]);
$user = $stmt->fetch();
命名占位符同样可用:`WHERE email = :email`,执行时传 `[':email' => $email]`,可读性更好,适合参数多的场景。顺便说一句,PHP 8.0 起错误模式默认已经是异常,但显式写出来更稳妥,老版本默认是静默失败,调试时会很痛苦。
占位符只能给「值」,这些位置必须走白名单
结论:占位符不能用在表名、列名、`ORDER BY`、`LIMIT` 等 SQL 结构位置上,这些地方只能用白名单映射。
这是最容易被误解的一点:`ORDER BY ?` 或 `FROM ?` 根本不会按你期望工作,因为它们是标识符/关键字,不是值。正确做法是白名单:
$allow = ['created_at', 'replies', 'views'];
$order = in_array($order, $allow, true) ? $order : 'created_at';
$sort = $sort === 'asc' ? 'ASC' : 'DESC';
$limit = max(1, min(50, (int)$limit)); // 强转成整数,不可能是任意字符串
$sql = "SELECT id, title FROM posts ORDER BY {$order} {$sort} LIMIT {$limit}";
`IN` 列表则用占位符数量动态生成,元素先强转整数:
$ids = array_map('intval', $ids);
$in = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT id, title FROM posts WHERE id IN ($in)");
$stmt->execute($ids);
三个高频踩坑
结论:同名命名占位符、LIKE 通配符、二次注入,是预处理用对了也会翻车的三个点。
第一,同名命名占位符在关闭模拟预处理后会报错。`WHERE a = :kw OR b = :kw` 在原生预处理下会抛 `HY093`,需要写成两个不同名字(`:kw1`、`:kw2`)。开启模拟时能跑,关掉后报错,很多人升级配置后才发现。
第二,`LIKE` 的通配符要处理。用户搜索词里带 `%` 或 `_` 会被当通配符,虽然不构成注入,但会拖慢查询、返回脏结果,建议对 `%`、`_`、`\` 做转义后再包上 `%...%`。
第三,二次注入不在预处理的保护范围内。数据用预处理安全存进库,之后又被取出直接拼进另一条 SQL,照样中招。防御靠的是「所有 SQL 一律走预处理」这个纪律,而不是某几处写了预处理。
预处理管不到的边界
结论:预处理只管「数据不被当代码执行」,XSS 输出转义、数据库账号最小权限、二次注入,都需要另外一层防线。
SQL 注入和 XSS 是两码事:PDO 帮你在入库时不炸数据库,但把用户昵称原样 `echo` 到页面照样是 XSS,输出必须做 HTML 转义。另外建议给应用配一个最小权限数据库账号,只授 `SELECT/INSERT/UPDATE/DELETE`,不给 `DROP`、`FILE`、`INTO OUTFILE`——这样即使某处漏了,攻击者也拿不到拖库或写文件的能力。
环境层面不用担心:以 Clara BBS 这类社区系统要求的 PHP 7.4–8.5 + MySQL 5.7+ 为例,PDO 及 pdo_mysql 扩展属于 PHP 自带且默认启用的组件,不需要额外安装,直接在代码里 `new PDO(...)` 就能用。
一句话收尾:把 `ATTR_EMULATE_PREPARES` 关掉、连接时 `charset=utf8mb4`、值一律走占位符、结构位置一律走白名单,这四条做到,SQL 注入这个坑基本就填平了;剩下的 XSS、越权、二次注入,是另外的账要另算。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





