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

唯一能真正防住 SQL 注入的,是让数据库服务端亲自解析并绑定参数——也就是必须用预处理语句,且禁用驱动层模拟模式。其他所有过滤、转义、WAF 都是补救,不是防线。
MySQL 预处理必须走服务端,不能靠驱动模拟
很多项目写了 prepare() 和 execute(),却仍被注入,问题就出在 PDO 默认开启 PDO::ATTR_EMULATE_PREPARES => true。这时 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,说明服务端预处理已启用;若返回PDOStatement对象,大概率还在模拟 - 检查 MySQL 配置:
SHOW VARIABLES LIKE 'max_prepared_stmt_count';,若为 0,服务端会静默退化为模拟模式
表名、列名、ORDER BY 字段不能用 ? 占位符
? 和 :name 只能绑定值,不能绑定标识符。写 SELECT * FROM ? 或 ORDER BY ? 会直接报错,或被驱动忽略——这不是 bug,是设计使然。
- 动态表名/列名必须走白名单校验:
in_array($table, ['users', 'orders', 'logs'], true) - 万不得已需拼接时,用
connection.escapeId()(Node.js mysql2)或PDO::quote()(PHP)包裹,但白名单永远比转义更可靠 -
LIMIT ?, ?中第一个参数(偏移量)在 MySQL 5.7+ 支持,低版本不兼容,稳妥做法仍是(int)$offset+ 范围检查
字符集不统一,所有防护都失效
即使开了预处理、关了模拟,如果连接字符集和 MySQL 服务端不一致,宽字节注入(如 gbk 下的 %df%27)仍可绕过。
- PHP DSN 必须带
;charset=utf8mb4,或调用mysqli_set_charset($conn, 'utf8mb4') - 检查服务端配置:
SHOW VARIABLES LIKE 'character_set%',确保character_set_client、character_set_connection、character_set_results全部为utf8mb4 - 对应表和字段的
COLLATION也得是utf8mb4_unicode_ci,否则索引可能失效,emoji 存储异常
ORM 的 raw() 方法是高危缺口
几乎所有 ORM(Django、Laravel、Sequelize、TypeORM)都提供 raw()、whereRaw()、selectRaw() 等接口。只要混入用户输入,参数化就彻底失效。
- 禁止写
whereRaw("status = '" + req.query.status + "'"),哪怕加了escape()也不行 - 若必须用 raw,只允许拼接白名单内的固定字符串,如
whereRaw("status IN ('active', 'pending')") - 检查项目里所有
raw调用点,逐个确认是否含用户输入——这是审计中最常被漏掉的一环
最易被忽略的其实是错误信息处理:MySQL 默认错误(如 Unknown column 'xxx' in 'where clause')会暴露表结构和字段名。攻击者靠这个就能反推查询模板,绕过你自以为安全的“过滤”。捕获 connection.query() 的 err 后,前端只能返回通用提示,完整错误只记日志。











