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))) 这种表达式,
白名单校验直接绕过,造成延迟注入。
因此,面试官想听你能否把“字段名”和“排序方向”两个变量都做成“纯枚举”,并给出可落地的代码级防御方案,而不是背一段“用预处理”就结束。

知识点

  1. ORDER BY 的语法特性:
    • 后面只能接“列名/别名/表达式+ASC|DESC”,不能接“UNION”或“WHERE”
    • 表达式可以返回数字,数字会被当作“列序号”再解析,造成二次注入
  2. 白名单绕过手法:
    • 字段名后接空格、反引号、注释符:?sort=id`%23
    • 利用 MySQL 隐式类型转换:?sort=!(select*from(select+sleep(2))a)
    • 利用别名子查询:?sort=(select+1+from+(select+if(1=1,1,0))x)
  3. 防御原则:
    • “字段名”必须是硬编码数组 key,用户只传索引,不传字符串
    • “排序方向”只能传 0|1,代码里映射成 ASC|DESC
    • 任何二次拼接都要经过 PDO::quote 或 strtr 做符号转义,但 ORDER BY 字段名本身不能用预处理占位
  4. 高并发场景下的性能权衡:
    • 白名单数组放 OPcache 共享内存,避免每次请求 array_map
    • 若字段过多,可放 Redis Set,用 SISMEMBER 做 O(1) 判断
  5. 合规要求:
    • 《网络安全法》第 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 做实时封禁,满足等保对“审计与追踪”的要求

拓展思考

  1. 复杂排序场景:
    电商大促需要“综合得分 = 销量0.6 + 库存0.2 + 评价*0.2”,可以把表达式在代码里算成别名,再把这个别名作为白名单的一项,而不是让用户直接传表达式。
  2. 多字段排序:
    用户可能要求“价格升序+销量降序”。此时可把多字段排序做成“排序模板”,模板 ID 同样走白名单,例如 101 => ['price ASC', 'sales DESC'],避免用户自行拼接逗号。
  3. 前端缓存一致性:
    字段名隐藏后,前端只能记住索引,若后台调整 SORT_MAP 顺序,老索引会错位。解决方式是:
    • 把 SORT_MAP 做成版本号 + Redis Hash,发版时递增版本,老索引在代码里做兼容映射
    • 或者对外暴露“语义化索引”如 price_asc、price_desc,内部再映射到真实列,兼顾 SEO 与缓存
  4. 自动化测试:
    在 PHPUnit 里用 @dataProvider 遍历 0x00~0x7F 特殊字符,断言所有非法输入均返回 400,保证后续重构不会回退。
  5. 与云 WAF 联动:
    阿里云/腾讯云 WAF 默认规则对 ORDER BY 注入覆盖不全,可在自定义规则里加入“order.*by.*sleep(|order.*by.*benchmark(|order.*by.*if(*select”等正则,再与上述白名单方案形成纵深防御。