跳转到帖子

背景

最近在一个授权测试项目里复现了一处典型的 Web 业务漏洞。漏洞本身并不复杂,但它比较适合作为代码审计和漏洞复现的案例:入口看起来是普通查询接口,后端使用了 ORM,但在局部代码里为了拼接动态条件退回了字符串拼接,最终造成可控参数进入 SQL 片段。

这类问题在真实项目里很常见:主框架是安全的,基础库也比较规范,但业务开发为了快速实现筛选、排序、导出等功能,在某些边角逻辑里绕过了统一封装。本文整理复现思路、定位方式和修复建议,重点放在授权环境下的分析流程,不涉及未授权攻击或破坏性操作。

问题场景

目标系统提供了一个列表查询接口,用于后台运营人员查看订单记录。接口支持按状态、时间、关键字查询,同时支持前端传入排序字段和排序方向。

GET /api/order/list?status=paid&keyword=test&sortField=create_time&sortOrder=desc

从功能设计上看,查询参数大致分为两类:

  • 值类型参数:如 status、keyword、startTime、endTime,通常进入 where 条件。
  • 结构类型参数:如 sortField、sortOrder,通常进入 order by 片段。

很多团队会重视 where 条件里的参数绑定,但容易忽视 order by、group by、limit 等结构片段。这些位置通常不能直接使用普通占位符绑定字段名,如果没有白名单校验,就会形成注入风险。

技术分析

审计时先从路由入口定位控制器方法。简化后的代码如下:

@GetMapping("/list")
public PageResult<OrderVO> list(OrderQuery query) {
    return orderService.queryList(query);
}

继续跟进 service 层,可以看到普通筛选条件使用了参数化封装:

public PageResult<OrderVO> queryList(OrderQuery query) {
    QueryWrapper<Order> wrapper = new QueryWrapper<>();

    if (StringUtils.hasText(query.getStatus())) {
        wrapper.eq("status", query.getStatus());
    }
    if (StringUtils.hasText(query.getKeyword())) {
        wrapper.like("order_no", query.getKeyword());
    }

    if (StringUtils.hasText(query.getSortField())) {
        wrapper.last("order by " + query.getSortField() + " " + query.getSortOrder());
    }

    return orderMapper.selectPage(query.toPage(), wrapper);
}

问题点在 wrapper.last。类似方法通常会把传入内容直接拼到 SQL 末尾,不再做参数化处理。sortField 和 sortOrder 来自 HTTP 请求,如果没有白名单限制,就相当于把用户输入接入了 SQL 结构。

从风险角度看,这类漏洞不一定都能直接读取敏感数据,取决于数据库类型、驱动配置、SQL 执行方式和权限。但它至少会造成查询逻辑被篡改、异常回显、时间延迟、稳定性影响等问题。对于后台系统,还可能被组合利用来扩大影响面。

复现思路

授权环境下复现时,建议遵循“低影响、可证明、可回滚”的原则。这里不使用破坏性语句,也不尝试越权读取敏感数据,只验证参数是否进入 SQL 结构以及后端是否按输入执行。

第一步,确认正常请求:

GET /api/order/list?status=paid&sortField=create_time&sortOrder=desc

观察响应状态、排序结果和服务端日志,记录一组基线。

第二步,使用无害表达式验证排序字段是否可控。例如在测试库中选择一个不会改变数据的表达式作为排序项,观察 SQL 日志或结果变化:

GET /api/order/list?status=paid&sortField=id&sortOrder=asc
GET /api/order/list?status=paid&sortField=id&sortOrder=desc

如果 asc 和 desc 能稳定改变顺序,说明排序方向参数生效。随后检查 sortField 是否仅允许预期字段。如果传入不存在字段会导致数据库错误,并且错误栈能在日志中看到拼接后的 SQL,基本可以确认缺少字段白名单。

GET /api/order/list?status=paid&sortField=not_exists_column&sortOrder=desc

第三步,结合服务端 SQL 日志确认拼接位置。测试环境可以临时开启 ORM SQL 输出,不建议在生产环境开启详细 SQL 日志。

logging:
  level:
    com.example.order.mapper: debug

如果日志中出现类似内容:

select * from t_order where status = ? order by not_exists_column desc

即可证明 sortField 被直接拼入 order by 子句。这里已经足够支撑漏洞结论,不需要进一步构造高风险载荷。

关键定位方法

这类问题在代码审计中可以通过关键字快速收敛。Java 项目里常见关注点包括:

  • MyBatis XML 中的 ${},尤其是 order by、group by、tableName、columnName。
  • QueryWrapper.last、apply、inSql、exists 等会拼接 SQL 片段的方法。
  • 手写 StringBuilder 拼 SQL,特别是拼接排序字段、导出字段、动态表名。
  • Controller 直接接收 Map、JSONObject,然后透传到 DAO 层。

可以先用 ripgrep 做一次全局扫描:

rg "\$\{|\.last\(|\.apply\(|StringBuilder|order by|group by|limit" ./src/main

如果项目使用 MyBatis XML,重点看这些模式:

ORDER BY ${sortField} ${sortOrder}
GROUP BY ${groupField}
LIMIT ${offset}, ${pageSize}

其中 ${} 是文本替换,#{} 才是预编译参数绑定。字段名、表名这类结构无法简单用 #{} 替代,正确做法通常是白名单映射。

修复建议

修复核心是:用户只能选择业务允许的排序字段和排序方向,不能直接控制 SQL 片段。

一个比较稳妥的做法是建立字段映射表:

private static final Map<String, String> SORT_FIELD_MAP = Map.of(
    "createTime", "create_time",
    "payTime", "pay_time",
    "amount", "amount",
    "id", "id"
);

public PageResult<OrderVO> queryList(OrderQuery query) {
    QueryWrapper<Order> wrapper = new QueryWrapper<>();

    if (StringUtils.hasText(query.getStatus())) {
        wrapper.eq("status", query.getStatus());
    }

    String sortColumn = SORT_FIELD_MAP.getOrDefault(query.getSortField(), "create_time");
    boolean asc = "asc".equalsIgnoreCase(query.getSortOrder());

    wrapper.orderBy(true, asc, sortColumn);
    return orderMapper.selectPage(query.toPage(), wrapper);
}

注意这里不是简单判断 sortField 是否匹配正则。正则只能限制字符形态,不能保证字段属于业务允许范围。白名单映射还能避免前端字段名和数据库字段名强耦合。

如果是 MyBatis XML,可以使用 choose 做白名单分支:

ORDER BY
<choose>
  <when test="sortField == 'createTime'">create_time</when>
  <when test="sortField == 'payTime'">pay_time</when>
  <when test="sortField == 'amount'">amount</when>
  <otherwise>create_time</otherwise>
</choose>
<choose>
  <when test="sortOrder == 'asc'">ASC</when>
  <otherwise>DESC</otherwise>
</choose>

分页参数也要限制范围,避免极大 pageSize 造成慢查询:

int pageSize = Math.min(Math.max(query.getPageSize(), 1), 100);

验证修复

修复后建议补充三类测试:

  • 正常字段:createTime、amount 等应能正常排序。
  • 非法字段:不存在字段、带特殊字符的字段应回退到默认排序或返回参数错误。
  • 非法方向:除 asc 外统一按 desc 或返回参数错误,行为要稳定。

可以加入单元测试或接口测试,避免后续重构再次引入同类问题:

@Test
void shouldFallbackWhenSortFieldInvalid() {
    OrderQuery query = new OrderQuery();
    query.setSortField("not_exists_column");
    query.setSortOrder("desc");

    PageResult<OrderVO> result = orderService.queryList(query);

    assertNotNull(result);
}

如果团队有代码扫描流程,也可以把危险 API 纳入规则。例如发现 wrapper.last 接收外部参数时给出告警,MyBatis XML 中出现 ${sortField} 时要求人工确认。

注意点

  • 不要把“使用 ORM”等同于“没有 SQL 注入”。ORM 只能降低风险,不能替代输入边界控制。
  • order by、group by、动态表名、动态列名是审计重点,因为这些位置经常被文本拼接。
  • 复现阶段尽量使用低影响方式证明问题,不要在非隔离环境执行破坏性语句。
  • 错误信息不要直接返回前端,生产环境应统一异常响应,详细 SQL 只保留在受控日志中。
  • 权限也要最小化。即使存在注入点,数据库账号也不应拥有超出业务需要的权限。

总结

这次案例的根因不是框架缺陷,而是业务代码在处理动态排序时缺少白名单。类似问题往往隐藏在“看起来只是列表查询”的接口里,危害容易被低估。

实际审计时,可以围绕结构型 SQL 片段建立检查清单:排序、分组、分页、动态表名、导出字段。复现时保持低影响,修复时使用白名单映射,并补充测试和扫描规则。这样不只是修掉一个漏洞,也能降低同类问题在后续迭代中反复出现的概率。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…