背景
越权漏洞在业务系统里很常见,尤其是后台管理、工单、订单、租户数据这类场景。它不像 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 策略是否统一,是否泄露资源存在性 |
总结
越权审计的重点不是找某个特殊参数,而是确认每个敏感资源在查询、下载、修改、删除时都经过了明确的数据权限判断。比较可靠的做法是:认证放入口,功能权限放接口,数据权限放服务层或数据访问层,并形成统一封装。
对长期维护的业务系统来说,越权问题最好不要依赖个人经验临时补丁,而应沉淀成权限服务、代码规范、审计规则和测试用例。这样后续新增模块时,安全边界才不会随着业务迭代被慢慢打穿。
推荐意见