真正防住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 配置:即使开了预处理,如果
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()是补救,不是替代) - Node.js 的
mysql2提供connection.escapeId(),仅用于兜底场景;生产环境优先走白名单,比如提前定义允许的分表后缀列表['log_202606', 'log_202607']
字符集不统一会让所有防护失效
即使开了预处理、关了模拟,如果连接字符集和 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() 方法混入用户输入即失效
无论 Laravel Eloquent 的 whereRaw()、selectRaw(),还是 TypeORM 的 query(),只要把用户输入拼进字符串里,预处理就形同虚设。
- 错误示例:
->whereRaw("status = '{$_GET['status']}'")—— 单引号包裹 + 直接拼接,等于开门揖盗 - 正确做法:只对固定结构用
raw(),变量部分仍走参数化,例如->whereRaw("status = ?", [$status]) - 更稳妥的是完全避开
raw(),改用框架原生查询构建器,比如->where('status', $status)
最容易被忽略的点是:攻击者不需要触发报错就能完成注入。一旦 escapeId() 或白名单漏判一个字段,或 utf8mb4 连接在某条查询里被覆盖,整套防护就断链。生产环境里,每个 SQL 执行路径都要确认这四件事:预处理开关、标识符来源、字符集上下文、ORM raw 使用边界。


















