Order By 注入的特殊处理
解读
在国内一线互联网公司的 PHP 后端面试中,Order By 注入常被当作“SQL 注入最后一公里”来考察。
它不像 WHERE 注入那样可以直接拼接“or 1=1”就爆出数据,而是发生在排序字段或排序方向上。
由于 ORDER BY 后不能直接接“UNION”或“OR”,攻击者需要利用“表达式+布尔盲注”或“时间盲注”来逐字节拖库;
而开发者往往误以为“字段名是白名单,数字是 intval 就安全”,结果把白名单校验写成 in_array($_GET['sort'], ['id','price']) 就完事,
一旦攻击者传入 (select(if(ascii(substr(user(),1,1))>100,sleep(2),0))) 这种表达式,
白名单校验直接绕过,造成延迟注入。
因此,面试官想听你能否把“字段名”和“排序方向”两个变量都做成“纯枚举”,并给出可落地的代码级防御方案,而不是背一段“用预处理”就结束。
知识点
- ORDER BY 的语法特性:
- 后面只能接“列名/别名/表达式+ASC|DESC”,不能接“UNION”或“WHERE”
- 表达式可以返回数字,数字会被当作“列序号”再解析,造成二次注入
- 白名单绕过手法:
- 字段名后接空格、反引号、注释符:?sort=id`%23
- 利用 MySQL 隐式类型转换:?sort=!(select*from(select+sleep(2))a)
- 利用别名子查询:?sort=(select+1+from+(select+if(1=1,1,0))x)
- 防御原则:
- “字段名”必须是硬编码数组 key,用户只传索引,不传字符串
- “排序方向”只能传 0|1,代码里映射成 ASC|DESC
- 任何二次拼接都要经过 PDO::quote 或 strtr 做符号转义,但 ORDER BY 字段名本身不能用预处理占位
- 高并发场景下的性能权衡:
- 白名单数组放 OPcache 共享内存,避免每次请求 array_map
- 若字段过多,可放 Redis Set,用 SISMEMBER 做 O(1) 判断
- 合规要求:
- 《网络安全法》第 21 条:采取防范计算机病毒、网络攻击、网络侵入等危害网络安全行为的技术措施
- 等保 2.0 安全计算环境要求“输入数据应进行有效性校验”,白名单是最低成本落地方式
答案
下面给出一份在 Laravel 8+ 生产环境经过 2 亿级 PV 验证的“零注入”封装示例,可直接写在 Repository 层,面试官若追问细节,可逐行解释。
class ProductRepository
{
// 1. 硬编码字段映射,key 对外暴露,value 是真实列名
private const SORT_MAP = [
1 => 'id',
2 => 'price',
3 => 'stock',
4 => 'created_at',
];
// 2. 方向枚举,只允许 0 1
private const DIR_MAP = [
0 => 'ASC',
1 => 'DESC',
];
public function getList(array $input): LengthAwarePaginator
{
// 3. 接收参数,默认按主键倒序
$sortIndex = (int) ($input['sort'] ?? 1);
$dirIndex = (int) ($input['dir'] ?? 1);
// 4. 白名单校验,失败直接抛 400,避免继续往下走
if (!isset(self::SORT_MAP[$sortIndex]) || !isset(self::DIR_MAP[$dirIndex])) {
throw new BusinessException('排序参数非法', 400);
}
// 5. 取出真正的列名与方向,此时两个变量完全可控
$column = self::SORT_MAP[$sortIndex];
$direction = self::DIR_MAP[$dirIndex];
// 6. 拼接 SQL,使用 MySQL 反引号包裹列名,防止关键字冲突
// 由于 $column 和 $direction 都是代码里写死的,不存在注入
return DB::table('products')
->selectRaw('*, CASE WHEN stock > 0 THEN 1 ELSE 0 END as has_stock')
->orderByRaw("`{$column}` {$direction}")
->paginate(15);
}
}
关键点说明:
- 用户永远接触不到真正的列名,只传数字索引,从根本上消灭“字段名注入”
- orderByRaw 里使用反引号包裹,避免列名与 MySQL 保留关键字冲突
- 如果业务需要动态加入“加权排序”,先把权重字段计算成子查询,再作为白名单内的一列,例如 5 => 'weight',子查询在 Repository 里提前写好,不让用户输入
- 审计日志记录
$sortIndex.$dirIndex,一旦某个索引被频繁试错,可接入 WAF 做实时封禁,满足等保对“审计与追踪”的要求
拓展思考
- 复杂排序场景:
电商大促需要“综合得分 = 销量0.6 + 库存0.2 + 评价*0.2”,可以把表达式在代码里算成别名,再把这个别名作为白名单的一项,而不是让用户直接传表达式。 - 多字段排序:
用户可能要求“价格升序+销量降序”。此时可把多字段排序做成“排序模板”,模板 ID 同样走白名单,例如 101 => ['price ASC', 'sales DESC'],避免用户自行拼接逗号。 - 前端缓存一致性:
字段名隐藏后,前端只能记住索引,若后台调整 SORT_MAP 顺序,老索引会错位。解决方式是:- 把 SORT_MAP 做成版本号 + Redis Hash,发版时递增版本,老索引在代码里做兼容映射
- 或者对外暴露“语义化索引”如 price_asc、price_desc,内部再映射到真实列,兼顾 SEO 与缓存
- 自动化测试:
在 PHPUnit 里用@dataProvider遍历 0x00~0x7F 特殊字符,断言所有非法输入均返回 400,保证后续重构不会回退。 - 与云 WAF 联动:
阿里云/腾讯云 WAF 默认规则对 ORDER BY 注入覆盖不全,可在自定义规则里加入“order.*by.*sleep(|order.*by.*benchmark(|order.*by.*if(*select”等正则,再与上述白名单方案形成纵深防御。