pdo预处理不能防住所有sql注入,因默认模拟预处理会拼接参数,且参数绑定仅适用于值,不适用于表名、列名、order by等结构化部分,须白名单校验。

为什么PDO预处理不能直接防住所有SQL注入
很多人以为只要用了 PDO::prepare() 就万事大吉,其实不然。PDO默认开启的模拟预处理(PDO::ATTR_EMULATE_PREPARES = true)会让参数拼接回SQL字符串再交给MySQL执行——这在某些边界场景下仍可能被绕过,比如含恶意编码的表名、列名或ORDER BY字段。
真正起作用的是数据库原生预处理,它把SQL结构和参数完全分离,服务端根本不解析参数内容。必须显式关闭模拟:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
- MySQL 5.7+ 和 MariaDB 10.2+ 才稳定支持原生预处理的全部语法(如 LIMIT ?)
- 若连接时指定
charset=utf8mb4但未在DSN中声明,可能导致字符集降级,让宽字节注入有机可乘 -
PDO::ATTR_EMULATE_PREPARES => false在调试阶段容易报错(如“Invalid parameter number”),别急着切回true,先检查占位符是否匹配
哪些地方根本不能用参数绑定,必须手动过滤
参数绑定只适用于**值(value)**,对表名、列名、排序字段、UNION子句等结构化部分完全无效。例如下面这段代码无论怎么绑定都危险:
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY $unsafe_column");
这类场景必须白名单校验或转义:
- 列名/表名:用硬编码白名单或
in_array($input, ['id', 'name', 'created_at'], true) - ORDER BY 方向:只允许
in_array($dir, ['ASC', 'DESC'], true),且全大写校验(避免 'aSc' 绕过) - 动态表名:若真需多表查询,用配置数组映射,而非拼接用户输入
- 不要用
addslashes()或mysql_real_escape_string()处理结构部分——它们不是为这个设计的,且已废弃
如何验证你的PDO配置真的生效了
光看代码不顶用,得从MySQL服务端确认是否走了原生预处理。最直接的方式是开启MySQL通用日志(仅开发环境):
SET GLOBAL general_log = 'ON';
然后执行一条带参数绑定的查询,去日志里找记录。如果看到类似:
Prepare SELECT * FROM users WHERE id = ?
说明是原生预处理;如果看到:
Execute SELECT * FROM users WHERE id = '1\' OR \'1\'=\'1'
那就还是模拟模式在干活。
- 生产环境禁用 general_log,可用
SHOW VARIABLES LIKE 'have_prepared_statement'确认服务端支持 - 用
$pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES)动态检查当前连接状态 - 注意:PHP-FPM进程复用连接时,配置可能被上一个请求意外改写,建议每次初始化PDO时显式传入选项
错误处理不当会暴露敏感信息,反而帮攻击者
开启 PDO::ERRMODE_EXCEPTION 是对的,但没捕获就直接抛出异常,页面可能打印出完整SQL、表结构甚至服务器路径。真实攻击者会故意触发错误来探路。
- 永远不要在生产环境显示
PDOException::getMessage()—— 它包含SQL原文 - 记录日志时可保留
$e->getTraceAsString(),但响应给前端的只能是泛化提示,比如“请求失败,请稍后重试” - 对登录、密码重置等关键接口,统一返回相同错误信息(如“用户名或密码错误”),避免泄露账号是否存在
- 若用ORM(如Doctrine),注意其底层是否也遵守了
ATTR_EMULATE_PREPARES => false,有些版本默认仍开模拟
实际中最容易被忽略的,是动态SQL结构部分的校验逻辑——它不在PDO职责范围内,但恰恰是高危区。别让一个没加白名单的 ORDER BY 毁掉整套参数绑定防线。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











