跳转到帖子

背景

最近整理历史项目代码审计记录时,发现一类问题出现频率很高:后台接口已经做了登录校验,也有菜单权限控制,但在具体数据访问层缺少对象级权限校验,最终形成越权访问。这个问题不算新,但在企业内部系统、SaaS 管理后台、低代码平台里很常见,影响也比较实际。

本文基于一个脱敏后的场景做复盘,重点放在审计思路、问题定位、修复方式和验证方法。文中不会涉及真实目标,也不会给出可用于未授权攻击的操作引导。

问题场景

系统是一个典型的前后端分离后台,后端使用 Java Spring Boot,权限模型大致如下:

  • 登录态通过 JWT 或 Session 维护;
  • 菜单和按钮权限通过 RBAC 控制;
  • 用户归属于不同租户或部门;
  • 业务对象包括项目、工单、客户记录等;
  • 接口层多数使用注解做权限校验。

表面看权限体系比较完整,但在审计时发现一个细节:部分详情接口、导出接口、状态修改接口只校验了“是否有功能权限”,没有校验“当前用户是否有权访问该条业务数据”。这就是典型的 IDOR / BOLA 类问题。

登录校验解决的是“你是谁”,菜单权限解决的是“你能不能使用这个功能”,对象级权限解决的是“这条数据是不是你的权限范围内”。三者不能互相替代。

技术分析

先看一个简化后的接口示例:

@GetMapping("/workorder/detail")
@PreAuthorize("hasAuthority('workorder:view')")
public WorkOrderDetailVO detail(@RequestParam Long id) {
    return workOrderService.getDetail(id);
}

对应的 Service 代码如下:

public WorkOrderDetailVO getDetail(Long id) {
    WorkOrder order = workOrderMapper.selectById(id);
    if (order == null) {
        throw new BizException("工单不存在");
    }
    return convert(order);
}

这段代码的问题不在于没有登录校验,也不在于没有功能权限。问题在于只要用户具备 workorder:view 权限,就可以查询任意 ID 对应的工单详情。如果不同部门、不同租户之间的数据共用同一张表,风险会更明显。

类似问题经常出现在以下接口中:

  • 详情接口:根据 id、uuid、code 查询单条记录;
  • 导出接口:根据查询条件导出当前数据范围;
  • 附件下载接口:根据文件 ID 下载业务附件;
  • 状态流转接口:审批、关闭、分配、删除等操作;
  • 批量接口:批量删除、批量更新、批量导入关联关系。

审计思路

这类问题适合从路由、权限注解、数据库字段三条线并行看。

1. 先梳理接口入口

可以优先筛选带有对象 ID 参数的接口,例如:

grep -R "@GetMapping\|@PostMapping\|@DeleteMapping\|@PutMapping" src/main/java | grep -E "detail|info|get|delete|update|export|download"

重点关注参数中出现以下字段的接口:

  • id、ids;
  • userId、tenantId、deptId;
  • projectId、orderId、customerId;
  • fileId、attachmentId。

如果接口接收的是 DTO,也要继续看 DTO 内部字段,很多越权点藏在对象参数里。

2. 区分功能权限和数据权限

看到 @PreAuthorize、@RequiresPermissions 这类注解时,不要直接认为权限已经完整。需要继续确认它校验的是什么。

@PreAuthorize("hasAuthority('customer:edit')")
@PostMapping("/customer/update")
public Boolean update(@RequestBody CustomerUpdateDTO dto) {
    return customerService.update(dto);
}

上面的注解只能说明用户有“编辑客户”的功能权限,不能说明用户有权编辑 dto.id 对应的客户。后续仍然要看 Service 或 Mapper 是否加入了数据范围约束。

3. 追踪数据范围字段

常见的数据隔离字段包括:

  • tenant_id:租户隔离;
  • dept_id:部门隔离;
  • owner_id:数据负责人;
  • creator_id:创建人;
  • project_id:项目空间隔离;
  • org_path:组织树范围隔离。

如果业务表里存在这些字段,但查询时只按主键查,就需要重点关注。例如:

SELECT * FROM work_order WHERE id = #{id}

更合理的查询通常需要带上当前登录用户的数据范围:

SELECT * FROM work_order
WHERE id = #{id}
  AND tenant_id = #{tenantId}
  AND dept_id IN
  <foreach collection="deptIds" item="deptId" open="(" separator="," close=")">
      #{deptId}
  </foreach>

这里不是说每个系统都必须按部门过滤,关键是权限边界要和业务模型一致。

关键修复方式

修复这类问题时,不建议只在 Controller 层临时判断。更稳妥的方式是把对象级权限沉到 Service 或 Repository 层,避免后续新增接口时再次遗漏。

1. 查询接口加入数据范围条件

原始代码:

public WorkOrderDetailVO getDetail(Long id) {
    WorkOrder order = workOrderMapper.selectById(id);
    if (order == null) {
        throw new BizException("工单不存在");
    }
    return convert(order);
}

修复后示例:

public WorkOrderDetailVO getDetail(Long id) {
    LoginUser user = SecurityContext.getLoginUser();

    WorkOrder order = workOrderMapper.selectByIdAndScope(
        id,
        user.getTenantId(),
        user.getDataScopeDeptIds()
    );

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

Mapper 示例:

SELECT * FROM work_order
WHERE id = #{id}
  AND tenant_id = #{tenantId}
  AND dept_id IN
  <foreach collection="deptIds" item="deptId" open="(" separator="," close=")">
      #{deptId}
  </foreach>
LIMIT 1

实际项目中,如果存在“本人数据”“本部门数据”“本部门及下级数据”“全部数据”等规则,建议抽象成统一的数据范围组件,而不是每个 Mapper 手写一套。

2. 更新和删除接口避免先查后改遗漏

很多项目更新时是这样写的:

public Boolean close(Long id) {
    WorkOrder order = workOrderMapper.selectById(id);
    if (order == null) {
        throw new BizException("工单不存在");
    }
    order.setStatus("CLOSED");
    return workOrderMapper.updateById(order) > 0;
}

即使前面查询加了权限判断,后面更新最好也带范围条件,减少并发或后续改动造成的风险:

public Boolean close(Long id) {
    LoginUser user = SecurityContext.getLoginUser();

    int rows = workOrderMapper.closeByIdAndScope(
        id,
        user.getTenantId(),
        user.getDataScopeDeptIds()
    );

    if (rows == 0) {
        throw new BizException("工单不存在或无权限操作");
    }
    return true;
}
UPDATE work_order
SET status = 'CLOSED', update_time = NOW()
WHERE id = #{id}
  AND tenant_id = #{tenantId}
  AND dept_id IN
  <foreach collection="deptIds" item="deptId" open="(" separator="," close=")">
      #{deptId}
  </foreach>

3. 附件下载要绑定业务对象权限

附件接口是越权高发点。很多系统的文件表只记录了文件路径、文件名、上传人,却没有和业务权限绑定。

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

这里即使文件 ID 难猜,也不能作为安全边界。更合理的方式是:

  • 文件表记录业务类型和业务 ID;
  • 下载前根据业务类型找到对应权限校验器;
  • 校验当前用户是否能访问该业务对象;
  • 通过后再读取文件内容。
public void download(Long fileId, HttpServletResponse response) {
    FileRecord file = fileMapper.selectById(fileId);
    if (file == null) {
        throw new BizException("文件不存在");
    }

    PermissionChecker checker = permissionCheckerFactory.get(file.getBizType());
    if (!checker.canView(SecurityContext.getLoginUser(), file.getBizId())) {
        throw new BizException("无权限下载该文件");
    }

    storageService.writeToResponse(file.getStorageKey(), response);
}

验证方法

修复完成后,建议不要只用管理员账号验证。至少准备两个普通账号,分别属于不同部门或不同租户,并构造几条彼此不可见的数据。

账号数据范围应能访问不应访问
user_a租户 A / 部门 AA 部门工单B 部门或其他租户工单
user_b租户 A / 部门 BB 部门工单A 部门或其他租户工单
user_c租户 B租户 B 数据租户 A 数据

推荐覆盖以下用例:

  • 详情接口:跨范围 ID 查询应失败;
  • 列表接口:结果集中不应出现越权数据;
  • 导出接口:导出内容应与列表范围一致;
  • 更新接口:跨范围 ID 更新应失败,且数据库无变化;
  • 删除接口:跨范围 ID 删除应失败;
  • 附件接口:不能下载无权业务对象下的附件;
  • 批量接口:混入无权 ID 时应整体失败或只处理有权数据,并有明确策略。

接口自动化测试可以加一组对象级权限用例,例如:

# 示例:仅用于本地测试环境验证权限边界
curl -H "Authorization: Bearer ${USER_A_TOKEN}" \
  " UUID 不可猜就安全:不可预测 ID 可以降低枚举概率,但不是权限控制。
  • 管理员测试通过就认为安全:越权问题通常出现在普通角色之间。
  • 列表做了过滤,详情没做过滤:这是最常见的遗漏之一。
  • 导出接口复用不完整查询:导出结果范围经常和列表不一致。
  • 代码审计检查清单

    实际审计时可以按下面清单快速过一遍:

    • 所有根据 ID 查询详情的接口,是否绑定当前用户数据范围;
    • 所有更新、删除、审批、分配类接口,SQL 条件里是否包含权限边界;
    • 批量操作是否逐条校验或使用范围 SQL 过滤;
    • 附件下载是否回溯到业务对象权限;
    • 导出接口是否复用列表权限条件;
    • 是否存在客户端可控的 tenantId、deptId、userId;
    • 是否存在后端直接信任请求参数中的组织或用户身份;
    • 异常信息是否泄露“存在但无权限”的敏感状态。

    尤其要注意客户端传入的 tenantId 或 deptId。这些字段通常只能从服务端登录上下文获取,不能相信前端提交。

    // 不推荐:直接使用客户端传入的 tenantId
    query.setTenantId(request.getTenantId());
    
    // 推荐:从登录上下文获取
    query.setTenantId(SecurityContext.getLoginUser().getTenantId());

    日志与监控建议

    越权修复后,也建议补充必要的安全日志。不是为了记录所有请求,而是记录异常访问模式,方便后续排查。

    • 记录跨权限访问失败事件,包括用户 ID、接口、业务 ID、时间;
    • 避免记录敏感明文内容,例如身份证号、完整手机号、文件路径;
    • 对短时间内大量访问不存在或无权限 ID 的行为做告警;
    • 区分业务错误和权限错误,便于后续统计。

    示例:

    securityLogger.warn(
        "object access denied, userId={}, resourceType={}, resourceId={}, ip={}",
        user.getId(),
        "WORK_ORDER",
        id,
        RequestUtils.getClientIp()
    );

    总结

    后台接口越权问题并不依赖复杂技巧,更多是权限模型落地不完整导致的。审计时不要停留在“是否登录”“是否有菜单权限”,还要继续确认对象级权限是否覆盖到查询、更新、删除、导出、下载等完整链路。

    比较稳妥的做法是:统一从服务端登录上下文获取身份和数据范围,在 Service 或数据访问层加入对象级权限约束,并用多账号、多数据范围的测试用例持续验证。这样即使后续业务接口增加,也能减少同类问题反复出现。

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

    0篇意见

    推荐意见

    没有意见。

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

    SiteMap HACKHAT © 2026 All rights reserved.