背景
最近在一个授权测试项目里复现了一处典型的 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=ascGET /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 片段建立检查清单:排序、分组、分页、动态表名、导出字段。复现时保持低影响,修复时使用白名单映射,并补充测试和扫描规则。这样不只是修掉一个漏洞,也能降低同类问题在后续迭代中反复出现的概率。
推荐意见