真正防住sql注入必须用服务端预处理并禁用模拟模式(pdo::attr_emulate_prepares=>false),表名字段名等动态结构须白名单校验,连接字符集需统一为utf8mb4,orm的raw方法混入用户输入即失效。

真正防住 SQL 注入,靠的不是层层过滤或“看起来安全”的转义函数,而是让数据库服务端亲自处理参数绑定——必须用预处理语句,并确保它没被 PHP 或驱动悄悄绕过。
PDO 预处理必须关掉模拟模式(PDO::ATTR_EMULATE_PREPARES => false)
很多项目写了 $pdo->prepare() 和 $stmt->execute(),却仍被注入,原因就是 PDO::ATTR_EMULATE_PREPARES 默认为 true。此时 PDO 在 PHP 层自己拼 SQL + 转义,MySQL 根本没收到预处理指令。
- 初始化时必须显式关闭:
$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); - 验证是否生效:执行
$pdo->prepare("SELECT ?"),若抛出SQLSTATE[HY000]: General error,说明服务端预处理已启用;若成功返回对象,大概率还在模拟 - MySQL 5.7+ 默认支持服务端预处理,但若
my.cnf中设了max_prepared_stmt_count = 0,会静默退化为模拟模式,需检查
动态标识符(表名、列名、ORDER BY 字段)不能参数化,只能白名单校验
? 和 :name 只能绑定值,不能绑定字段名、表名或关键字。试图写 ORDER BY ? 会报错,或被驱动忽略——这不是 bug,是设计使然。
- 字段名必须走白名单:
in_array($sort_field, ['created_at', 'status', 'score'], true) - 表名同理,禁止从请求中直接取
$_GET['table']拼接,哪怕加了escapeId()也不行(escapeId()是补救,不是替代) -
LIMIT ?, ?中第一个参数(偏移量)MySQL 5.7+ 支持,但低版本不兼容,稳妥做法仍是(int)$offset+ 范围检查:if ($offset 10000) die('invalid offset');
字符集不统一会让所有防护失效
即使开了预处理、关了模拟,如果连接字符集和 MySQL 服务端不一致,宽字节注入(如 gbk 下的 %df%27)仍可绕过。
- PHP 连接 DSN 必须带
;charset=utf8mb4(PDO)或调用mysqli_set_charset($conn, 'utf8mb4')(MySQLi) - 检查服务端配置:
SHOW VARIABLES LIKE 'character_set%',确保character_set_client、character_set_connection、character_set_results全部为utf8mb4 - 对应表和字段的 collation 也得是
utf8mb4_unicode_ci,否则索引可能失效,且 emoji 存储异常
最易被忽略的一点:ORM 的 raw() 方法、Query Builder 的 whereRaw()、selectRaw() 等接口,一旦混入用户输入,就等于主动退出参数化保护区——这里没有自动转义,也没有服务端预处理,只有裸 SQL 执行权。











