跳转到帖子

背景

越权漏洞在业务系统里很常见,尤其是后台管理、工单、订单、租户数据这类场景。它不像 SQL 注入、命令执行那样容易通过单点 payload 触发,更多依赖业务关系和权限模型理解。实际审计中,越权问题经常隐藏在看起来“功能正常”的接口里:接口能返回数据,参数也符合预期,但缺少对当前登录用户和目标资源之间关系的校验。

这篇文章整理一次 Java Web 项目中常见的越权审计方法,重点放在可复用的排查思路上,不涉及真实目标,也不提供任何未授权攻击引导。适合用于企业内部系统自查、代码评审和安全测试流程建设。

问题场景

假设系统中存在工单模块,用户可以查看自己提交的工单,管理员可以查看全部工单。常见接口形式如下:

GET /api/ticket/detail?id=10086

如果后端只根据 id 查询工单详情,而没有校验当前登录用户是否有权限访问该工单,就会出现水平越权。如果普通用户可以通过调整参数查看其他用户工单,问题就比较明显。

类似问题也可能出现在以下接口中:

  • 订单详情:根据 orderId 查询订单,但未校验订单归属。
  • 文件下载:根据 fileId 下载附件,但未校验附件所属业务对象。
  • 用户资料:根据 userId 查询资料,但未限制可访问范围。
  • 组织租户:根据 tenantId、orgId 查询数据,但只校验登录状态。
  • 状态变更:根据 id 修改审批、删除、关闭、转派等敏感状态。

技术分析

越权漏洞的核心不是“参数可改”,而是“资源访问控制缺失”。一个接口是否安全,不能只看是否登录、是否有角色,还要看当前主体和目标资源之间是否满足访问关系。

在代码审计时,我通常把权限校验拆成三层:

  • 认证:确认请求来自已登录用户,例如 token、session 是否有效。
  • 功能权限:确认用户是否具备访问某个功能菜单或接口的权限。
  • 数据权限:确认用户是否能访问当前这条具体数据。

很多系统只做了前两层,忽略第三层。比如普通用户拥有“查看工单详情”的功能权限,但这不代表他可以查看任意工单详情。

典型脆弱代码

下面是一类比较常见的写法:

public TicketDetailVO getDetail(Long id) {
    Ticket ticket = ticketMapper.selectById(id);
    if (ticket == null) {
        throw new BizException("工单不存在");
    }
    return convert(ticket);
}

这段代码的问题在于:只校验了资源是否存在,没有校验当前用户和资源之间的关系。如果接口前面有登录拦截器,开发人员容易误以为“已登录即可访问”就是安全的。

稍微复杂一些的系统可能会有注解权限:

@PreAuthorize("hasAuthority('ticket:detail')")
@GetMapping("/detail")
public TicketDetailVO detail(@RequestParam Long id) {
    return ticketService.getDetail(id);
}

这只能证明用户有调用详情接口的权限,仍然不能证明他有权限查看 id 对应的工单。

建议的修复方式

更稳妥的做法是把数据权限校验放到服务层,避免只依赖控制器层的注解。因为同一个 service 方法可能被多个入口调用,权限逻辑放得太靠外,容易遗漏。

public TicketDetailVO getDetail(Long id) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    boolean isAdmin = SecurityContext.hasRole("ADMIN");

    Ticket ticket = ticketMapper.selectById(id);
    if (ticket == null) {
        throw new BizException("工单不存在");
    }

    if (!isAdmin && !currentUserId.equals(ticket.getCreatorId())) {
        throw new AccessDeniedException("无权访问该工单");
    }

    return convert(ticket);
}

如果业务存在部门、项目组、租户等复杂关系,建议不要在各处散落 if 判断,而是抽象出统一的数据权限服务:

public TicketDetailVO getDetail(Long id) {
    Ticket ticket = ticketMapper.selectById(id);
    if (ticket == null) {
        throw new BizException("工单不存在");
    }

    permissionService.checkTicketReadable(ticket);
    return convert(ticket);
}
public void checkTicketReadable(Ticket ticket) {
    UserPrincipal user = SecurityContext.getCurrentUser();

    if (user.hasRole("ADMIN")) {
        return;
    }

    if (Objects.equals(ticket.getCreatorId(), user.getUserId())) {
        return;
    }

    if (departmentService.isManagerOf(user.getUserId(), ticket.getDepartmentId())) {
        return;
    }

    throw new AccessDeniedException("无权访问该资源");
}

这样做的好处是权限规则集中,后续业务变化时更容易维护,也方便写单元测试。

审计关键步骤

在真实项目里,越权审计建议按接口类型和数据流来做,不要只靠页面点击。下面是比较实用的检查顺序。

1. 梳理敏感资源对象

先找系统里的核心业务对象,例如:

  • 用户、员工、客户、供应商
  • 订单、合同、发票、支付记录
  • 工单、审批单、流程实例
  • 文件、附件、导入导出任务
  • 租户、组织、部门、项目空间

这些对象通常都有归属关系,越权风险也更高。审计时重点关注以 id、code、uuid、bizId、fileId、userId、tenantId 为入参的接口。

2. 搜索直接对象引用

在 Java 项目中,可以重点搜索以下模式:

selectById(
getById(
findById(
queryById(
@RequestParam Long id
@PathVariable Long id
@RequestParam String fileId
@RequestParam Long userId

如果项目使用 MyBatis,也可以看 XML 中是否存在按主键直接查询的语句:

select * from ticket where id = #{id}
select * from file_info where file_id = #{fileId}

发现这类接口后,不要立刻下结论,需要继续看调用链中是否有数据权限校验。例如是否调用了 permissionService、dataScopeService、tenantFilter 等逻辑。

3. 区分查询类和操作类接口

查询类越权主要导致数据泄露,操作类越权影响通常更严重。以下操作要重点看:

  • 修改状态:审批、关闭、取消、启用、禁用
  • 删除数据:逻辑删除、批量删除
  • 转移归属:转派、分配、变更负责人
  • 导出下载:批量导出、附件下载
  • 配置变更:角色授权、组织调整、系统参数

例如下面这种批量删除尤其容易遗漏逐条校验:

public void deleteTickets(List<Long> ids) {
    ticketMapper.deleteBatchIds(ids);
}

更稳妥的写法是先查询可操作范围,再做校验:

public void deleteTickets(List<Long> ids) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    List<Ticket> tickets = ticketMapper.selectBatchIds(ids);

    for (Ticket ticket : tickets) {
        permissionService.checkTicketWritable(ticket, currentUserId);
    }

    ticketMapper.deleteBatchIds(ids);
}

4. 检查列表接口和详情接口权限是否一致

很多系统列表接口做了数据范围过滤,但详情接口没有做校验。这是非常常见的漏洞来源。

例如列表查询中带了 creator_id 条件:

select * from ticket
where creator_id = #{currentUserId}
order by create_time desc

但详情查询却是:

select * from ticket
where id = #{id}

这会导致用户在列表里只能看到自己的数据,但只要知道或猜到其他资源 id,就可能访问详情。正确做法是详情也要带上数据权限条件,或者在 service 层统一校验。

select * from ticket
where id = #{id}
and creator_id = #{currentUserId}

如果管理员、部门主管等角色有更复杂的数据范围,不建议把 SQL 拼得过于分散,可以统一封装数据范围条件。

5. 关注文件下载接口

附件下载是越权高发点。很多项目会把文件表设计成独立模块,下载接口只校验 fileId 是否存在:

public void download(String fileId, HttpServletResponse response) {
    FileInfo file = fileMapper.selectByFileId(fileId);
    if (file == null) {
        throw new BizException("文件不存在");
    }
    fileStorage.writeToResponse(file.getStoragePath(), response);
}

问题在于文件本身往往挂载在工单、合同、订单等业务对象下,单独校验文件存在是不够的。建议文件表保留业务类型和业务 id,并在下载前校验业务对象权限:

public void download(String fileId, HttpServletResponse response) {
    FileInfo file = fileMapper.selectByFileId(fileId);
    if (file == null) {
        throw new BizException("文件不存在");
    }

    permissionService.checkBusinessReadable(file.getBizType(), file.getBizId());
    fileStorage.writeToResponse(file.getStoragePath(), response);
}

6. 多租户系统要特别看 tenantId

多租户系统里,tenantId 不应该完全信任前端传参。常见错误是接口允许用户传 tenantId,然后后端直接按该字段查询:

select * from customer
where tenant_id = #{tenantId}
and id = #{id}

如果 tenantId 来自请求参数,而不是登录上下文,就存在跨租户访问风险。更推荐从认证上下文中取租户信息:

Long tenantId = SecurityContext.getTenantId();

并在数据访问层自动注入租户条件,减少开发人员遗漏。

测试验证建议

在授权的测试环境中,可以准备两个低权限账号 A 和 B,分别创建不同资源,然后验证接口是否存在交叉访问。建议记录以下信息:

  • A 创建的资源 id,B 创建的资源 id。
  • A 是否能访问或操作 B 的资源。
  • 接口返回是 403、404,还是正常数据。
  • 列表接口和详情接口结果是否一致。
  • 批量接口中混入无权限资源时如何处理。

从安全角度看,无权限访问通常建议返回 403。如果不希望暴露资源是否存在,也可以统一返回 404,但团队内部要保持一致,避免不同接口表现不一致造成信息泄露。

自动化辅助思路

越权问题很难完全靠工具自动发现,但可以通过静态规则提高排查效率。例如用 Semgrep 做初步扫描,找出直接按 id 查询且附近没有权限校验的代码片段。

rules:
  - id: java-direct-get-by-id
    languages:
      - java
    message: Direct getById usage, please confirm data permission check.
    severity: WARNING
    patterns:
      - pattern-either:
          - pattern: $X.getById($ID)
          - pattern: $X.selectById($ID)

这类规则会有误报,但适合作为代码评审前的提示。实际判断仍然需要结合业务上下文和调用链。

常见误区

  • 只要接口需要登录就安全:登录只能证明身份,不能证明资源归属。
  • 有菜单权限就不会越权:菜单权限解决的是功能访问,不等于数据权限。
  • 前端隐藏按钮即可:前端控制只能改善体验,不能作为安全边界。
  • id 是 UUID 就没问题:不可预测可以降低枚举风险,但不能替代权限校验。
  • 列表已过滤,详情不用管:详情、下载、导出、修改接口都要独立校验。

代码评审检查表

检查项关注点
资源查询是否只按 id 查询,是否校验当前用户与资源关系
批量操作是否逐条校验权限,是否存在部分成功策略
文件下载是否校验文件所属业务对象权限
租户隔离tenantId 是否来自登录上下文,是否可被前端覆盖
角色判断是否把角色权限和数据权限混为一谈
异常返回403、404 策略是否统一,是否泄露资源存在性

总结

越权审计的重点不是找某个特殊参数,而是确认每个敏感资源在查询、下载、修改、删除时都经过了明确的数据权限判断。比较可靠的做法是:认证放入口,功能权限放接口,数据权限放服务层或数据访问层,并形成统一封装。

对长期维护的业务系统来说,越权问题最好不要依赖个人经验临时补丁,而应沉淀成权限服务、代码规范、审计规则和测试用例。这样后续新增模块时,安全边界才不会随着业务迭代被慢慢打穿。

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

0篇意见

推荐意见

没有意见。

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