跳转到帖子

背景

在实际业务安全评估里,接口越权是我见得比较多、也比较容易被低估的一类问题。它不像反序列化、RCE 那样“显眼”,但一旦出现在订单、工单、文件、审批、租户管理等模块,影响往往不小。

这篇文章不讨论如何攻击具体系统,而是整理一次典型接口越权排查的思路:如何从现象定位到代码,如何判断是水平越权还是垂直越权,如何把修复建议落到开发可执行的层面。适合用于内部安全测试、代码审计和缺陷复盘。

问题场景

一个常见场景是:系统中存在“查看详情”接口,前端页面通过资源 ID 查询数据。例如订单详情、项目详情、文件详情、客户详情等。

GET /api/order/detail?id=1024

正常情况下,用户只能查看自己名下或自己组织范围内的数据。但如果服务端只根据传入的 id 查询记录,没有校验当前登录用户与该资源之间的关系,就可能导致水平越权。

另一个常见场景是后台功能入口被前端隐藏,但接口本身没有做角色校验。例如普通用户无法在菜单里看到“用户管理”,但直接访问接口仍然能查询敏感数据,这类通常属于垂直越权。

技术分析

越权问题的核心不是“接口有没有登录态”,而是“登录态与资源、动作、角色之间是否建立了可信校验”。很多项目只做了认证,没有做好授权。

一般可以从三个维度拆解:

  • 认证 Authentication:当前请求是谁发起的,例如 userId、tenantId、session、JWT subject。
  • 授权 Authorization:这个用户是否允许执行当前动作,例如 read、update、delete、approve。
  • 资源归属 Ownership:目标资源是否属于该用户、部门、租户或授权范围。

很多越权漏洞出在第三点。代码里看起来已经判断了登录态,但实际查询资源时没有带上用户边界。

常见问题代码模式

以下是比较典型的风险写法。为了说明问题,示例做了简化。

// 风险示例:只根据订单 ID 查询,没有校验归属
@GetMapping("/order/detail")
public OrderDTO detail(@RequestParam Long id) {
    Order order = orderService.getById(id);
    return convert(order);
}

这种代码的问题是:只要请求者处于登录状态,并且知道或猜到一个存在的资源 ID,就可能读取不属于自己的数据。

相对安全的做法是,在查询时加入当前用户或租户边界,或者在取出资源后进行明确授权判断。

// 推荐示例:查询条件包含用户边界
@GetMapping("/order/detail")
public OrderDTO detail(@RequestParam Long id) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    Order order = orderService.getByIdAndUserId(id, currentUserId);
    if (order == null) {
        throw new BizException("资源不存在或无权限访问");
    }
    return convert(order);
}

如果是多租户系统,不能只校验 userId,还要校验 tenantId。否则可能出现跨租户访问。

// 多租户场景建议同时校验 tenantId
Long currentUserId = SecurityContext.getCurrentUserId();
Long currentTenantId = SecurityContext.getCurrentTenantId();
Order order = orderMapper.selectOne(
    Wrappers.<Order>lambdaQuery()
        .eq(Order::getId, id)
        .eq(Order::getUserId, currentUserId)
        .eq(Order::getTenantId, currentTenantId)
);

代码审计切入点

做代码审计时,不建议一上来盯着某一个接口反复看。更有效的方法是先找“资源型接口”,再向下追授权逻辑。

我通常会优先关注以下接口特征:

  • 参数中出现 id、orderId、userId、fileId、projectId、taskId、tenantId。
  • 接口语义包含 detail、info、query、list、download、update、delete、approve。
  • Controller 层直接调用 getById、selectById、findById。
  • Service 层查询条件只包含主键,没有当前用户、部门、租户条件。
  • 导出、下载、预览接口没有单独鉴权。
  • 批量操作接口只校验了数组格式,没有逐项校验资源权限。

可以通过静态搜索快速缩小范围。例如在 Java 项目中:

grep -R "selectById\|getById\|findById" src/main/java

grep -R "download\|export\|detail\|delete" src/main/java

如果项目使用 MyBatis,也可以关注 XML 或 Mapper 注解中的 SQL 条件,确认是否带有业务边界。

SELECT * FROM orders WHERE id = #{id}

上面这种 SQL 在资源详情接口里就需要警惕。更合理的写法通常是:

SELECT * FROM orders
WHERE id = #{id}
  AND user_id = #{currentUserId}
  AND tenant_id = #{currentTenantId}

关键排查步骤

一套比较稳的排查流程如下:

  • 确认接口功能:判断该接口操作的资源类型,以及资源是否具备归属关系。
  • 确认身份来源:查看当前用户 ID、角色、租户 ID 是从哪里取的,是否来自服务端可信上下文。
  • 追踪查询条件:从 Controller 跟到 Service、Mapper,确认 SQL 或 ORM 查询是否包含授权边界。
  • 检查角色判断:如果接口是管理功能,确认是否有后端角色校验,而不是只依赖前端菜单隐藏。
  • 关注批量操作:批量删除、批量导出、批量审批需要对每一个资源做权限判断。
  • 检查异常返回:无权限和不存在建议使用统一响应,避免暴露资源是否存在。

这里有一个经验点:不要只看 Controller 上有没有类似 @PreAuthorize 的注解。注解能解决一部分“能不能进接口”的问题,但未必能解决“能不能操作这条数据”的问题。

水平越权与垂直越权的区别

类型典型表现重点检查
水平越权同角色用户访问彼此资源资源归属、用户边界、租户边界
垂直越权低权限用户访问高权限功能角色校验、权限点、后端访问控制
跨租户越权一个租户访问另一个租户数据tenantId 来源、查询条件、缓存隔离

实际项目里这几类可能同时存在。比如一个“导出用户列表”的接口,普通用户能访问属于垂直越权;如果还能通过 tenantId 参数导出其他租户数据,就是跨租户越权。

容易忽略的细节

  • 不要信任前端传入的 userId:如果接口参数里允许传 userId,并用它作为查询条件,需要确认是否和当前登录用户匹配,或者该用户是否具备代查权限。
  • 下载接口要单独鉴权:文件 URL、预签名地址、附件 ID 都可能成为越权入口。
  • 缓存也要带边界:缓存 key 如果只使用资源 ID,在多租户或多用户场景下可能导致数据串读。
  • 审批流要校验当前节点:不是登录用户有审批角色就能审批所有单据,还要看是否是当前处理人或候选组。
  • 异步任务不要丢失身份上下文:导出、报表、批处理如果在后台执行,需要保存并校验发起人的权限范围。
  • 避免过度返回:列表接口即使做了权限过滤,也要控制字段脱敏,减少敏感数据暴露面。

修复建议

修复越权问题时,不建议只在单个接口里临时加 if 判断。更好的方式是结合业务模型做统一控制。

  • 资源查询统一封装权限条件,例如自动追加 tenantId、deptId、ownerId。
  • 关键操作使用服务端身份上下文,不直接信任客户端传入的用户身份字段。
  • 管理类接口使用后端权限注解或拦截器进行角色校验。
  • 资源级授权放在 Service 层,避免多个入口绕开 Controller 校验。
  • 对批量操作做逐项授权,不能只校验第一个 ID。
  • 为敏感接口补充安全回归测试,覆盖普通用户、管理员、跨租户等场景。

一个比较实用的 Service 层封装示例:

public Order getReadableOrder(Long orderId) {
    Long userId = SecurityContext.getCurrentUserId();
    Long tenantId = SecurityContext.getCurrentTenantId();

    Order order = orderMapper.selectOne(
        Wrappers.<Order>lambdaQuery()
            .eq(Order::getId, orderId)
            .eq(Order::getTenantId, tenantId)
    );

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

    if (!order.getUserId().equals(userId) && !PermissionUtil.canManageOrder(userId)) {
        throw new BizException("资源不存在或无权限访问");
    }

    return order;
}

这样做的好处是,后续详情、更新、取消、导出等多个入口都可以复用同一套资源读取逻辑,减少漏校验。

测试与验证

安全测试阶段可以设计几组低风险验证用例,重点验证授权边界是否生效:

  • 用户 A 创建资源,用户 B 尝试读取该资源详情,应返回无权限或不存在。
  • 普通用户访问管理接口,应被后端拒绝,而不是只靠前端隐藏菜单。
  • 租户 A 的用户传入租户 B 的资源 ID,应无法访问。
  • 批量接口中混入无权限资源 ID,应整体拒绝或仅处理有权限部分,并有明确策略。
  • 导出任务生成后,下载结果文件时仍应校验发起人身份。

对于自动化回归,可以在集成测试里固定准备两组用户和资源数据,避免每次靠手工验证。

@Test
void userShouldNotReadOthersOrder() {
    loginAs(userB);
    Result result = orderApi.detail(orderOfUserA.getId());
    assertEquals(403, result.getCode());
}

注意点

越权修复的难点通常不在代码写法,而在业务边界定义。谁可以看、谁可以改、部门管理员能管理哪些范围、租户管理员是否能跨部门,这些规则需要和业务方确认清楚。

另外,不建议把所有失败都简单返回 403。对外部接口来说,“资源不存在或无权限访问”更稳妥,可以减少资源枚举带来的信息泄露。不过内部管理系统是否需要明确提示,可以根据审计和运维需要权衡。

总结

接口越权是 Web 系统里很常见的问题,表面看是少了一个判断,实际反映的是授权模型没有落到资源层。排查时可以抓住三个关键词:身份、动作、资源。只要能确认“谁在对什么资源做什么动作”,大多数越权问题都能定位清楚。

从长期维护角度看,建议把资源级权限封装到 Service 或统一权限组件里,而不是依赖每个接口开发者手写判断。安全测试也应把详情、下载、导出、批量操作、多租户隔离作为固定检查项,这比事后单点补丁更可靠。

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

0篇意见

推荐意见

没有意见。

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