所有用户输入参与SQL构建(含JOIN的ON子句、WHERE条件、UNION各子句)必须参数化,动态表名/字段名须用白名单校验,不可字符串拼接。

多表 JOIN 中的 WHERE 条件必须全部参数化
只要有一个关联字段用字符串拼接,整个查询就可能被绕过。比如 JOIN orders ON users.id = orders.user_id WHERE users.status = 'active' AND orders.status = ? —— 看似只对 orders.status 做了参数化,但 users.status 是硬编码字符串,不构成注入点;真正危险的是像 AND users.role = '{$_GET['role']}' 这种写法。
关键判断标准:所有来自用户输入、URL 参数、表单字段、Cookie 或 API Body 的值,只要参与 SQL 构建(哪怕只是 ON 子句或 WHERE 中的任意一个条件),就必须走参数绑定。
- 常见错误现象:
SELECT * FROM users u JOIN logs l ON u.id = l.user_id WHERE u.name = ? AND l.type IN ('login', 'logout')——l.type是固定枚举,安全;但如果改成l.type = ?且传入值未校验类型,仍可能被用于逻辑绕过(虽非典型注入,但属边界失控) - 使用场景:管理员后台按用户名 + 订单状态 + 时间范围筛选数据,三个维度都来自前端请求
- 参数差异:
PDO支持命名参数(:status)和位置参数(?),但命名参数在多表 JOIN 查询中更易追踪绑定关系;MyBatis 的#{}默认安全,${}绝对禁止用于任何用户输入
ON 子句里的关联字段不能靠“信任内表”来豁免参数化
有人觉得“users.id 是主键,不可能被污染,所以 JOIN orders ON users.id = ? 里这个 ? 可以省略、直接拼成 ... ON users.id = {$_GET['uid']}”,这是错的。一旦 $_GET['uid'] 是用户可控输入,它就具备注入能力——哪怕它本该是数字,攻击者仍可传入 1 OR 1=1 或 1 UNION SELECT ... 来扭曲 JOIN 行为。
数据库不会因为字段出现在 ON 就跳过语法解析;它只认最终拼出的完整 SQL 字符串。
- 容易踩的坑:ORM 如 Django 的
filter()在跨表查询时默认参数化,但若手动写extra(tables=['orders'], where=["users.id = orders.user_id AND orders.status = '%s'"]),其中的%s是字符串格式化,不是参数绑定 - 性能影响:预编译语句对多表 JOIN 同样适用,首次执行稍慢(编译模板),后续复用更快;不要因“多几个 ? 就变慢”而退回到拼接
- 验证建议:在测试环境把任意一个绑定参数改为
' OR 1=1 --,观察是否返回全量数据或报错——如果会,说明该位置没真正参数化
动态表名或字段名无法参数化,必须白名单硬控制
SELECT * FROM ? 或 ORDER BY ? 是非法的,所有数据库驱动都不支持对表名、列名、关键字做参数绑定。这时候不能靠“过滤掉单引号”应付,必须用严格白名单。
例如支持按不同维度排序的接口:/api/users?sort=created_at&order=desc,sort 和 order 都不可信,必须显式限定:
if (!in_array($sort, ['id', 'username', 'created_at', 'status'])) {
throw new InvalidArgumentException('Invalid sort field');
}
if (!in_array($order, ['ASC', 'DESC'])) {
throw new InvalidArgumentException('Invalid order direction');
}- 常见错误现象:用正则替换掉所有非字母数字字符,结果留下
__或空格,导致ORDER BY username--成功注释掉后续条件 - 使用场景:BI 报表导出接口允许选择多个关联表(
users/customers/members)并指定连接字段,这些都需映射到预定义配置数组,而非直通请求参数 - 兼容性注意:MySQL 8.0+ 支持
PREPARE动态执行含变量表名的语句,但必须配合sp_executesql类机制(SQL Server)或EXECUTE IMMEDIATE(PostgreSQL),且仍需先校验变量内容——这属于高危操作,日常业务应避免
UNION 查询中的每个子句都要独立参数化
多表关联有时会退化为 UNION 拼合不同来源的数据,比如合并用户、客户、会员三张结构相似的表。此时每个 SELECT 子句里的 WHERE 条件都必须单独参数化,不能只绑最外层的 ?。
示例危险写法:(SELECT id,name FROM users WHERE type='user' AND status=?) UNION (SELECT id,name FROM customers WHERE type='cust' AND status='{$_GET['c_status']}') —— 后半部分直接拼接,前半部分看似安全,整体已失守。
- 容易踩的坑:以为 “UNION 左右两边结构一致,只要左边参数化了,右边也自动安全”,实际上数据库是分别解析每个子句的
- 参数差异:PDO 对
UNION查询只支持一次execute()绑定,所以所有?必须在同一数组中提供;若用命名参数,确保每个子句里的:status不冲突(最好加前缀如:user_status,:cust_status) - 调试技巧:开启数据库 query log(如 MySQL 的
general_log),看实际执行的 SQL 是否含原始用户输入字符,而不是只检查代码里有没有?
参数化不是“加几个问号”的机械动作,而是对每一份外部输入的明确归属声明——哪怕它只出现在 ON 里、只用于 UNION 的第二个分支、或只决定要不要加 LIMIT,只要它来自不可信源头,就必须被数据库引擎当作纯数据处理,而非 SQL 语法的一部分。

















