跳转到帖子

背景

越权问题在 Java Web 项目里很常见,但也很容易被低估。很多系统登录、菜单、角色配置都做得比较完整,接口层却只校验了“是否登录”,没有校验“是否有权访问当前资源”。这类问题在测试环境里不一定显眼,到了真实业务场景,往往会造成订单、工单、用户资料、审批记录等数据被横向访问。

这篇文章整理一次代码审计中比较典型的越权排查思路。重点不在某个漏洞利用技巧,而是如何从代码、接口和数据流三个角度判断权限边界是否可靠,适合做代码审计、自查和安全加固参考。

问题场景

项目是常见的 Spring Boot 后台系统,用户登录后可以查看自己的业务数据。权限模型大致分为三层:

  • 登录认证:判断用户是否已登录。
  • 菜单权限:控制前端页面和按钮是否显示。
  • 数据权限:控制用户只能访问自己有权限的数据。

审计时发现,部分接口依赖前端传入的 userId、deptId 或 recordId 查询数据,后端没有重新确认当前登录用户与目标资源之间的关系。典型代码形态如下:

@GetMapping("/record/detail")
public Result detail(Long recordId) {
    BusinessRecord record = recordService.getById(recordId);
    return Result.ok(record);
}

这段代码本身看起来没有复杂逻辑,但风险点很明确:只要 recordId 可枚举或可获取,接口就会直接返回记录详情。如果记录里包含客户信息、审批意见、附件地址等敏感字段,就会形成水平越权。

技术分析

越权问题通常不是单点缺陷,而是权限校验分散、模型边界不清导致的结果。审计时可以按以下几个维度梳理。

1. 认证不等于授权

很多项目使用拦截器或网关统一判断登录态,例如检查 token 是否有效:

public boolean preHandle(HttpServletRequest request,
                         HttpServletResponse response,
                         Object handler) {
    String token = request.getHeader("Authorization");
    LoginUser loginUser = tokenService.parse(token);
    if (loginUser == null) {
        response.setStatus(401);
        return false;
    }
    UserContext.set(loginUser);
    return true;
}

这只能说明请求来自一个已登录用户,不能说明该用户有权限访问目标数据。真正的授权需要结合当前用户、目标资源和操作类型判断。

2. 菜单权限不能替代接口权限

一些后台系统只在前端控制按钮展示,例如没有“详情”按钮就认为用户不能访问详情接口。这种设计不可靠,因为接口本身仍然可被直接请求。审计时需要重点检查:

  • Controller 方法是否有权限注解或显式校验。
  • Service 层是否根据当前用户追加数据范围条件。
  • 仅前端隐藏按钮但后端无校验的接口。
  • 导出、下载、详情、删除、审批等高风险操作。

3. ID 参数是越权高发点

常见高风险参数包括:

  • userId、accountId、memberId
  • orderId、recordId、caseId、ticketId
  • deptId、tenantId、orgId
  • fileId、attachmentId

如果接口直接信任这些参数,就需要进一步确认是否存在资源归属校验。例如下面这种写法就不够安全:

@PostMapping("/order/cancel")
public Result cancel(Long orderId) {
    orderService.cancel(orderId);
    return Result.ok();
}

更稳妥的方式是基于当前登录用户做条件更新,避免只按主键操作:

@PostMapping("/order/cancel")
public Result cancel(Long orderId) {
    Long currentUserId = UserContext.getUserId();
    boolean success = orderService.cancelOwnOrder(orderId, currentUserId);
    if (!success) {
        return Result.fail("无权操作或订单不存在");
    }
    return Result.ok();
}
public boolean cancelOwnOrder(Long orderId, Long userId) {
    int rows = orderMapper.cancelByIdAndUserId(orderId, userId);
    return rows == 1;
}

对应 SQL 可以写成:

UPDATE orders
SET status = 'CANCELED'
WHERE id = #{orderId}
  AND user_id = #{userId}
  AND status = 'WAIT_PAY'

这样即使传入了别人的 orderId,也不会影响不属于当前用户的数据。

关键排查步骤

步骤一:从路由和接口清单入手

优先整理 Controller 层接口,标记以下类型:

  • 详情查询:/detail、/info、/view
  • 列表查询:/list、/page、/search
  • 状态变更:/update、/delete、/cancel、/approve
  • 文件相关:/download、/preview、/export
  • 管理类接口:/admin、/manage、/config

可以用简单命令先定位接口入口:

grep -R "@GetMapping\|@PostMapping\|@RequestMapping\|@DeleteMapping\|@PutMapping" src/main/java

如果项目使用权限注解,也可以反向查找未加注解的接口:

grep -R "@PreAuthorize\|@RequiresPermissions" src/main/java

这一步不是为了机械判断“没注解就是漏洞”,而是先找出需要人工确认的数据访问入口。

步骤二:追踪当前用户是否参与查询条件

对于涉及业务数据的接口,重点看当前用户信息是否参与了查询或更新条件。安全性较差的常见写法:

public BusinessRecord getDetail(Long id) {
    return recordMapper.selectById(id);
}

更推荐的方式是把数据范围放进查询条件:

public BusinessRecord getDetail(Long id) {
    LoginUser user = UserContext.get();
    return recordMapper.selectDetailWithScope(id, user.getUserId(), user.getDeptIds());
}
SELECT *
FROM business_record
WHERE id = #{id}
  AND (
      owner_id = #{userId}
      OR dept_id IN
      
          #{deptId}
      
  )

如果系统有租户隔离,还应当检查 tenant_id 是否始终参与条件。多租户系统里漏掉 tenant_id,影响通常比普通水平越权更大。

步骤三:确认角色权限和数据权限是否分离

角色权限解决“能不能访问某个功能”,数据权限解决“能访问哪些数据”。两者不能混在一起。比如财务角色可以访问订单页面,但不代表可以访问所有部门订单。

比较清晰的授权判断可以拆成两层:

public OrderDetail getOrderDetail(Long orderId) {
    LoginUser user = UserContext.get();

    permissionService.checkFunctionPermission(user, "order:detail");

    OrderDetail detail = orderMapper.selectDetailByScope(
        orderId,
        user.getTenantId(),
        user.getUserId(),
        user.getDeptIds()
    );

    if (detail == null) {
        throw new AccessDeniedException("无权访问该订单");
    }
    return detail;
}

这样排查问题时也更清楚:是功能权限缺失,还是数据范围缺失。

步骤四:关注导出和下载接口

导出接口经常复用列表查询条件,但有些项目为了性能会重新写一套 SQL,导致列表有数据权限,导出没有数据权限。文件下载也是越权高发点,常见问题是只校验 fileId 是否存在,不校验文件所属业务对象是否属于当前用户。

不建议直接这样下载:

@GetMapping("/file/download")
public void download(Long fileId, HttpServletResponse response) {
    FileInfo file = fileService.getById(fileId);
    fileService.writeToResponse(file, response);
}

建议把文件和业务资源的权限关系一起校验:

@GetMapping("/file/download")
public void download(Long fileId, HttpServletResponse response) {
    LoginUser user = UserContext.get();
    FileInfo file = fileService.getAccessibleFile(fileId, user);
    if (file == null) {
        throw new AccessDeniedException("无权下载该文件");
    }
    fileService.writeToResponse(file, response);
}

步骤五:用最小化测试验证风险

在授权测试环境中,可以准备两个普通账号 A 和 B,各自创建一条业务数据。验证时不需要做破坏性操作,重点确认读取和状态变更是否被限制:

  • A 登录后访问 A 自己的数据,应正常返回。
  • A 登录后请求 B 的 recordId,应返回 403、404 或空结果。
  • A 尝试对 B 的数据执行更新或删除,应失败且数据不变。
  • 导出、下载、预览接口也要重复同样验证。

对于状态变更类接口,建议只在测试数据上操作,或者使用只读环境复现,避免影响真实业务。

常见修复建议

1. 不信任前端传入的身份字段

userId、tenantId、deptId 这类字段尽量从服务端登录上下文获取,不应由普通用户请求参数决定。前端传入的字段最多作为筛选条件,还需要和服务端数据范围求交集。

Long tenantId = UserContext.getTenantId();
Long userId = UserContext.getUserId();

2. 在 Service 层统一承载授权逻辑

Controller 层适合做参数接收和响应封装,不建议把复杂权限判断散落在各个 Controller 方法里。Service 层更接近业务语义,适合判断资源归属和操作权限。

3. 使用“按范围查询”替代“先查再判断”

先 selectById 再判断 ownerId,容易在后续改动中漏掉判断,也可能产生额外的数据暴露风险。更稳妥的做法是在 SQL 条件中直接加入 user_id、tenant_id、dept_id 等范围限制。

4. 对失败结果保持克制

对于无权访问的数据,可以返回 403,也可以返回 404,取决于业务设计。关键是不要在错误信息里暴露资源是否存在、归属人、内部状态等额外信息。

5. 补充审计日志

对于敏感数据访问和状态变更,建议记录必要日志,便于后续排查:

  • 当前用户 ID、租户 ID、部门 ID
  • 访问资源 ID 和资源类型
  • 操作类型
  • 授权结果
  • 请求来源 IP、User-Agent

日志不要记录完整身份证号、银行卡号、token 等敏感字段,必要时做脱敏。

代码审计检查清单

检查项关注点风险表现
详情接口是否只按 id 查询可查看他人数据
列表接口是否追加数据范围跨部门、跨租户查询
更新接口WHERE 条件是否包含归属限制修改他人数据
删除接口是否校验资源归属删除非本人资源
导出接口是否复用安全查询条件批量导出越权数据
文件接口fileId 是否绑定业务权限下载他人附件
多租户接口tenant_id 是否强制参与条件租户间数据泄露

注意点

  • 不要只依赖前端路由、按钮隐藏或菜单权限。
  • 不要把管理员逻辑和普通用户逻辑混在同一个接口里,除非权限判断非常清楚。
  • 批量接口要逐条校验权限,不能只校验列表中的第一个 id。
  • 缓存数据也要带上租户和用户维度,避免缓存键设计不当导致串数据。
  • 异步任务、消息消费、定时导出同样需要考虑数据范围,不只是 HTTP 接口。

总结

越权问题的核心不是某个注解有没有加,而是后端是否始终基于当前登录主体判断目标资源是否可访问。比较可靠的做法是:认证统一做,功能权限明确做,数据权限靠近业务查询做,敏感操作按资源归属做。

实际审计时,我通常先从 Controller 接口清单出发,筛出带 id、userId、tenantId、fileId 的接口,再追踪 Service 和 SQL 是否包含当前用户的数据范围。这个方法不复杂,但覆盖面比较稳定,尤其适合后台管理系统、多租户系统和工单审批类系统的自查。

安全加固的目标不是让代码看起来“有权限控制”,而是让每一次数据读取和状态变更都能回答一个问题:当前用户为什么有权访问这条资源?
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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