跳转到帖子

一次常见 JSON 接口越权的复现与代码审计思路

精选回复

发布于

背景

最近在审计业务系统时,比较常见的一类问题还是 JSON 接口层面的越权。它不一定有明显的报错,也不依赖复杂利用链,通常是后端只校验了“是否登录”,但没有校验“当前用户是否有权限访问该资源”。

这类问题在后台管理、工单系统、订单系统、用户中心里都很常见。前端页面看起来按钮和菜单都做了控制,但接口如果只依赖前端传参,比如 userId、tenantId、orderId,就容易出现水平越权或垂直越权。

技术分析

以一个典型接口为例:

GET /api/order/detail?orderId=10086 HTTP/1.1
Host: example.local
Cookie: SESSION=xxxx

正常情况下,用户 A 只能查看自己的订单。如果后端实现如下,就存在明显风险:

@GetMapping("/api/order/detail")
public OrderDetailVO detail(Long orderId) {
    return orderService.getDetail(orderId);
}

这里的问题是:接口只根据 orderId 查询订单详情,没有把当前登录用户纳入权限判断。只要攻击者能枚举或获取其他订单 ID,就可能直接读取他人的订单信息。

更隐蔽的情况是 Service 层做了查询,但查询条件仍然不完整:

public OrderDetailVO getDetail(Long orderId) {
    Order order = orderMapper.selectById(orderId);
    if (order == null) {
        throw new BizException("订单不存在");
    }
    return convert(order);
}

这种写法在功能上是正常的,但安全边界缺失。正确做法应该是将当前用户身份、租户信息、角色权限等纳入查询或校验逻辑。

复现思路

在授权测试或内部安全测试场景下,可以按下面步骤排查。

  1. 准备两个低权限账号,例如用户 A 和用户 B。
  2. 分别登录两个账号,抓取各自正常访问资源的请求。
  3. 记录资源标识,例如 orderId、userId、ticketId、fileId。
  4. 使用用户 A 的 Cookie 或 Token,请求用户 B 的资源 ID。
  5. 观察响应是否返回了用户 B 的真实数据。

例如用户 A 的订单详情请求:

GET /api/order/detail?orderId=10001 HTTP/1.1
Host: example.local
Cookie: SESSION=userA_session

用户 B 的订单详情请求:

GET /api/order/detail?orderId=10002 HTTP/1.1
Host: example.local
Cookie: SESSION=userB_session

测试时使用用户 A 的身份访问用户 B 的订单:

GET /api/order/detail?orderId=10002 HTTP/1.1
Host: example.local
Cookie: SESSION=userA_session

如果返回 200,并且响应体包含用户 B 的订单信息,就可以判断存在水平越权。

代码示例:存在问题的实现

下面是一个比较典型的 Spring Boot 示例,问题点在于完全信任前端传入的 orderId。

@RestController
@RequestMapping("/api/order")
public class OrderController {

    @Autowired
    private OrderService orderService;

    @GetMapping("/detail")
    public Result<OrderDetailVO> detail(@RequestParam Long orderId) {
        OrderDetailVO detail = orderService.getDetail(orderId);
        return Result.ok(detail);
    }
}

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    public OrderDetailVO getDetail(Long orderId) {
        Order order = orderMapper.selectById(orderId);
        if (order == null) {
            throw new BizException("订单不存在");
        }
        return OrderConverter.toVO(order);
    }
}

从审计角度看,看到这种接口需要重点确认几个问题:

  • 当前登录用户是谁,是否在后端可信获取。
  • 查询条件是否包含用户 ID、租户 ID、组织 ID 等边界字段。
  • 管理员和普通用户是否共用同一个接口。
  • 是否存在前端隐藏字段控制权限的情况。

修复方式一:查询时绑定当前用户

对于普通用户只能访问自己资源的接口,推荐在查询阶段直接带上用户边界条件。

@GetMapping("/detail")
public Result<OrderDetailVO> detail(@RequestParam Long orderId) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    OrderDetailVO detail = orderService.getDetail(orderId, currentUserId);
    return Result.ok(detail);
}

public OrderDetailVO getDetail(Long orderId, Long currentUserId) {
    Order order = orderMapper.selectOne(
        Wrappers.<Order>lambdaQuery()
            .eq(Order::getId, orderId)
            .eq(Order::getUserId, currentUserId)
            .eq(Order::getDeleted, 0)
    );

    if (order == null) {
        throw new BizException("订单不存在或无权限访问");
    }

    return OrderConverter.toVO(order);
}

这里有一个细节:错误信息不建议区分“订单不存在”和“无权限访问”。如果返回信息过于明确,可能帮助攻击者判断资源 ID 是否存在。

修复方式二:权限校验前置

如果业务权限较复杂,例如管理员、客服、财务、商户角色都可以查看不同范围的订单,可以将权限判断抽象成独立方法。

public OrderDetailVO getDetail(Long orderId) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    Long currentTenantId = SecurityContext.getCurrentTenantId();
    Set<String> roles = SecurityContext.getCurrentUserRoles();

    Order order = orderMapper.selectById(orderId);
    if (order == null || order.getDeleted() == 1) {
        throw new BizException("订单不存在或无权限访问");
    }

    if (!canAccessOrder(order, currentUserId, currentTenantId, roles)) {
        throw new BizException("订单不存在或无权限访问");
    }

    return OrderConverter.toVO(order);
}

private boolean canAccessOrder(Order order,
                               Long currentUserId,
                               Long currentTenantId,
                               Set<String> roles) {
    if (!Objects.equals(order.getTenantId(), currentTenantId)) {
        return false;
    }

    if (Objects.equals(order.getUserId(), currentUserId)) {
        return true;
    }

    if (roles.contains("ORDER_ADMIN")) {
        return true;
    }

    return false;
}

这种方式适合权限规则比较多的系统。但要注意,不能只在 Controller 层做判断,Service 层也应该有完整保护,避免其他内部接口复用 Service 方法时绕过校验。

审计时的重点接口

实际代码审计中,可以优先搜索这些参数名和接口模式:

userId
uid
memberId
accountId
orderId
projectId
tenantId
orgId
deptId
fileId
ticketId
recordId
/admin/*/detail
/api/*/update
/api/*/delete
/api/*/export

尤其要关注以下几类操作:

  • 详情读取:是否能查看他人订单、资料、工单、合同。
  • 修改操作:是否能修改他人手机号、地址、备注、状态。
  • 删除操作:是否能删除不属于自己的资源。
  • 导出操作:是否能批量导出其他租户或部门数据。
  • 文件下载:是否只根据 fileId 下载文件。

接口测试中的几个细节

只改 URL 参数有时不够,还需要检查 JSON Body、Header 和路径参数。

POST /api/user/update HTTP/1.1
Host: example.local
Content-Type: application/json
Cookie: SESSION=userA_session

{
  "userId": 20002,
  "mobile": "13800000000",
  "email": "[email protected]"
}

有些系统会在 Header 中传租户或组织信息:

GET /api/project/list HTTP/1.1
Host: example.local
Cookie: SESSION=userA_session
X-Tenant-Id: 3
X-Org-Id: 8

如果后端直接信任这些 Header,也可能导致跨租户越权。租户 ID、组织 ID 应该从服务端会话、Token 中可信解析,不能以客户端传入值作为最终依据。

注意事项

  • 测试越权时应使用授权环境和测试账号,避免访问真实用户敏感数据。
  • 不要只依赖前端按钮隐藏、路由守卫或菜单权限。
  • 不要相信客户端传入的 userId、tenantId、role。
  • 分页列表接口也要做权限控制,否则可能通过列表直接枚举资源 ID。
  • 批量接口要逐条校验权限,例如批量删除、批量导出。
  • 日志中应记录越权访问尝试,但避免打印完整身份证号、手机号、Token 等敏感信息。

总结

JSON 接口越权本质上不是框架漏洞,而是业务权限边界没有落到后端代码里。审计时不要只看是否有登录校验,更要看资源查询和修改是否绑定了当前用户、租户、组织或角色。

比较稳妥的原则是:资源归属关系必须由服务端确认,客户端传来的任何身份和权限字段都只能作为普通输入,不能作为授权依据。对于高风险接口,建议在 Service 层统一沉淀权限判断,避免 Controller 分散校验导致遗漏。

网络流量与边界分析示意图
网络请求、日志与边界流量分析示意
补充一个审计时比较容易漏掉的点:这类 JSON 接口越权不一定只看 `userId / accountId` 这种显式字段,很多时候会藏在嵌套对象或数组里,比如批量查询、批量修改时的 `ids`、`items[].ownerId`、`filter.userId`。 复现时建议把请求体里的所有“资源归属字段”都做一轮替换,不要只改 URL 参数。例如:
{
  "page": 1,
  "size": 20,
  "filter": {
    "userId": 10002,
    "status": "normal"
  },
  "ids": [2001, 2002, 2003]
}
重点看服务端是否只校验了登录态,而没有把资源和当前用户绑定校验。代码审计可以直接搜这些模式:
grep -R "getParameter(\"userId\"" -n .
grep -R "request.get.*userId" -n .
grep -R "JSON.parseObject" -n .
grep -R "ownerId\|userId\|accountId\|tenantId" -n src/main/java
如果是 Spring 项目,我一般会重点看 Controller 到 Service 之间有没有把前端传入的用户标识直接透传到 DAO,例如:
@PostMapping("/order/list")
public List<Order> list(@RequestBody OrderQuery query) {
    return orderService.list(query);
}
这种写法风险点在于 `query.userId` 可能完全来自客户端。更稳的方式是服务端从登录上下文取当前用户,再覆盖或忽略前端字段:
@PostMapping("/order/list")
public List<Order> list(@RequestBody OrderQuery query) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    query.setUserId(currentUserId);
    return orderService.list(query);
}
DAO 层也建议兜底加条件,不要只依赖 Controller:
SELECT *
FROM orders
WHERE id = #{orderId}
  AND user_id = #{currentUserId}
对于管理端接口还要额外区分“身份认证”和“数据权限”。有些代码只判断了是否是管理员,但没有判断管理员所属租户、部门或数据范围,最后变成水平越权。可以重点检查 SQL 里有没有类似 `tenant_id`、`org_id`、`dept_id` 的约束条件。 测试时可以准备两个普通账号 A/B: 1. A 创建或查询一条资源,记录资源 ID; 2. B 登录后复用同一个接口,把请求体中的 ID 或 userId 换成 A 的; 3. 对比响应码、响应体字段、数据条数; 4. 如果接口返回空数据,也要看是否存在时间差、错误信息差异,避免误判。 修复验证不要只测单条查询,批量接口、导出接口、详情接口、修改接口都要一起覆盖。很多系统详情页做了鉴权,但导出接口直接按前端传的查询条件出数据,这类 JSON 接口里很常见。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。