跳转到帖子

背景

最近在做一轮内部系统安全复盘时,遇到一个比较典型的问题:某业务后台的接口没有明显报错,也没有高危组件漏洞,但通过日志可以看到少量异常访问命中了用户信息查询接口。最后确认问题不是传统意义上的“未登录访问”,而是接口鉴权边界不清导致的水平越权。

这类问题在 Web 安全里很常见,尤其是前后端分离、微服务拆分后,很多接口默认依赖前端控制入口,后端只做了登录态校验,却没有做资源归属校验。本文整理一下排查思路、审计重点和修复建议,偏长期可复用的方法,不涉及真实目标攻击操作。

问题场景

业务形态大致如下:用户登录后可以查看自己的订单、资料、工单等信息,前端通过用户 ID、订单 ID 或工单 ID 调用后端 API。接口示例类似:

GET /api/order/detail?id=10086
Authorization: Bearer eyJxxx...

表面上接口已经要求登录,并且 token 校验正常。但如果后端只判断“当前请求是否已登录”,没有判断“当前用户是否拥有该订单”,就会出现越权风险。

常见表现包括:

  • 修改 URL 或请求体中的 id 参数后,返回了其他用户资源。
  • 列表接口做了用户过滤,但详情接口没有过滤。
  • 查询接口有权限判断,导出接口、下载接口遗漏。
  • 管理端和用户端复用了同一服务方法,权限条件不一致。
  • 网关做了统一认证,但业务服务没有做对象级授权。

技术分析

越权问题的核心不是“有没有登录”,而是“当前主体是否有权访问当前对象”。可以把权限判断拆成三层:

  • 认证:请求者是谁,例如 session、JWT、SSO 登录态。
  • 角色权限:请求者是否拥有该功能入口,例如普通用户、客服、管理员。
  • 对象权限:请求者是否拥有该具体数据对象,例如订单、文件、工单、项目。

很多系统前两层做得还可以,第三层容易遗漏。原因通常是开发时默认认为接口参数来自前端可信来源,或者认为 ID 不可猜测就足够安全。实际上,ID 是否连续不是根本问题,即便是 UUID,只要某个 ID 泄露或被间接引用,缺少对象级授权仍然会造成数据暴露。

审计切入点

代码审计时,我一般从路由、控制器、服务层、数据访问层四个位置看。重点不是逐行找“漏洞关键词”,而是找权限条件是否在数据查询链路中持续存在。

1. 路由和注解

先看接口是否声明了认证和角色要求。例如 Spring 项目中常见注解:

@PreAuthorize("hasRole('USER')")
@GetMapping("/order/detail")
public OrderDetail detail(@RequestParam Long id) {
    return orderService.detail(id);
}

这段代码只能说明当前用户具备 USER 角色,并不能说明该用户可以访问 id 对应的订单。只靠角色注解是不够的。

2. 控制器是否传入当前用户上下文

如果控制器只把外部传入的资源 ID 传给服务层,没有传当前用户信息,后续服务层通常很难做对象级校验:

// 风险写法
public OrderDetail detail(Long id) {
    return orderMapper.selectById(id);
}

更稳妥的方式是从服务端可信上下文中获取当前用户 ID,并参与查询或校验:

// 推荐思路
public OrderDetail detail(Long id) {
    Long currentUserId = SecurityContext.getCurrentUserId();
    return orderMapper.selectByIdAndUserId(id, currentUserId);
}

3. 数据查询条件是否绑定归属

越权修复不建议只在查询后做简单判断,优先让权限条件进入查询条件,减少误用和竞态问题。例如:

SELECT id, order_no, amount, status
FROM orders
WHERE id = #{id}
  AND user_id = #{currentUserId}
  AND deleted = 0

如果是多租户系统,还要注意 tenant_id、project_id、org_id 等边界字段。常见修复是只加了 user_id,却漏了 tenant_id,导致同一用户在多个组织之间仍可能越界访问。

4. 复用方法的权限语义

服务方法被用户端、管理端、定时任务复用时,要特别小心。比如:

Order getOrderById(Long id)

这个方法本身没有表达权限语义,很容易被误用。建议区分不同场景:

  • getUserOrderDetail(orderId, currentUserId):用户侧,只能查自己的。
  • getAdminOrderDetail(orderId, adminContext):管理侧,需要角色和数据范围。
  • getOrderInternal(orderId):内部任务使用,限制调用范围。

权限语义写清楚,比依赖调用方“记得校验”更可靠。

关键排查步骤

下面是一套偏防守和审计视角的流程,适合在授权范围内做内部验证。

步骤一:梳理接口资产

优先整理包含资源 ID 的接口,尤其是详情、下载、导出、修改、删除类接口。可以从网关日志、OpenAPI 文档、路由配置中提取。

# 示例:从 nginx access log 中粗略提取 API 路径
awk '{print $7}' access.log | cut -d '?' -f 1 | sort | uniq -c | sort -nr | head -50

重点关注这些参数名:

  • id、userId、orderId、fileId、projectId
  • tenantId、orgId、deptId、teamId
  • owner、creator、account、mobile

步骤二:建立权限矩阵

不要只看接口能不能访问,要建立“主体-功能-对象”的矩阵。例如:

主体功能对象范围预期结果
普通用户 A查看订单详情A 的订单允许
普通用户 A查看订单详情B 的订单拒绝
客服查看订单详情负责区域订单允许
客服查看订单详情非负责区域订单拒绝

这一步可以帮助开发和测试对齐预期,避免“管理员能看全部”这类过于粗糙的判断。

步骤三:检查异常状态码和返回体

权限拒绝时建议统一返回 403 或业务层无权限错误,不要返回真实对象信息。对于不存在和无权限是否区分,要结合业务场景决定。面向公网的敏感资源接口,可以考虑统一返回模糊结果,减少资源枚举信息泄露。

{
  "code": "FORBIDDEN",
  "message": "no permission to access this resource"
}

步骤四:覆盖非主路径接口

越权常出现在“非主路径”上,例如:

  • 附件预览、文件下载、缩略图接口。
  • 批量导出、报表查询、异步任务结果下载。
  • 移动端、小程序、旧版 H5 接口。
  • GraphQL 或聚合查询接口。
  • 后台调试接口、灰度接口。

这些接口往往不是业务主流程,却能拿到同样敏感的数据。

常见修复方式

1. 在服务端统一获取当前身份

不要信任客户端传来的 userId、role、tenantId。客户端可以传展示参数,但权限相关字段必须从服务端会话、token 解析结果或可信网关透传中获取,并且要校验签名和来源。

Long currentUserId = AuthContext.userId();
Long currentTenantId = AuthContext.tenantId();

2. 权限条件下沉到查询层

对于普通用户资源访问,推荐使用带归属条件的查询:

Order order = orderRepository.findOne(
    where("id", orderId)
    .and("user_id", currentUserId)
    .and("tenant_id", currentTenantId)
);

这样即使上层调用者遗漏了一次判断,也不容易直接返回越权对象。

3. 管理端引入数据范围

管理端不是天然拥有全部数据。客服、运营、区域管理员通常都有数据范围限制。可以把数据范围抽象成策略:

DataScope scope = dataScopeService.resolve(currentAdmin);
query.and("tenant_id", scope.tenantId());
query.andIn("region_id", scope.allowedRegions());

如果管理端使用 RBAC,只解决了“能不能打开功能”,还需要结合 ABAC 或数据范围模型解决“能看哪些数据”。

4. 文件资源单独授权

文件下载接口很容易被遗漏。不要只依赖文件名随机、URL 难猜。推荐文件表记录业务归属,下载时按 file_id 查询并校验 owner、tenant、业务状态。

SELECT f.id, f.storage_key
FROM files f
JOIN orders o ON f.biz_id = o.id
WHERE f.id = #{fileId}
  AND f.biz_type = 'ORDER'
  AND o.user_id = #{currentUserId}

如果使用对象存储临时链接,也应该在生成临时链接前完成权限校验,并设置合理过期时间。

日志与监控建议

越权问题在修复后还需要观察是否存在历史异常访问。建议至少记录以下字段:

  • 当前用户 ID、租户 ID、角色。
  • 访问资源类型和资源 ID。
  • 授权结果:允许或拒绝。
  • 拒绝原因:未登录、角色不足、对象不归属、数据范围不足。
  • 请求来源 IP、User-Agent、traceId。

可以针对短时间大量 403、连续访问不同资源 ID、跨租户访问尝试等行为做告警。注意日志中不要直接记录完整身份证号、手机号、token 等敏感信息。

代码审计检查清单

  • 所有详情、修改、删除、下载接口是否做了对象级授权。
  • 是否存在只校验登录态、不校验资源归属的接口。
  • 客户端传入的 userId、tenantId 是否被后端直接信任。
  • 列表接口和详情接口权限条件是否一致。
  • 导出、批量、异步任务结果接口是否复用了相同权限规则。
  • 管理端是否具备数据范围控制,而不是只做角色判断。
  • 文件、图片、附件接口是否校验业务归属。
  • 旧接口、移动端接口、灰度接口是否纳入权限测试。
  • ORM 查询是否存在绕过全局租户过滤器的原生 SQL。
  • 权限拒绝是否有统一错误处理和审计日志。

注意点

越权漏洞的修复不要只靠前端隐藏按钮,也不要只靠 ID 随机化。真正可靠的做法是服务端基于可信身份做对象级授权,并让权限条件进入查询链路。
  • 不要把 JWT 中的 role、tenant 信息长期视为实时权限,权限变更后要考虑 token 失效或版本号机制。
  • 多租户系统要优先校验 tenant 边界,避免跨租户数据泄露。
  • 缓存层要包含权限维度,避免用户 A 的缓存结果被用户 B 命中。
  • 批量接口要逐项校验,不要只校验第一个 ID。
  • 异步任务创建和结果下载都要校验权限,不能只在创建时校验。

总结

API 越权不是复杂漏洞,但在真实系统里很容易反复出现。它通常不是某一行代码的问题,而是权限模型、接口设计、服务复用和数据查询习惯共同造成的。

我的经验是,治理这类问题要抓三点:第一,明确主体、功能、对象三层权限;第二,服务端统一从可信上下文取身份,不信任客户端权限参数;第三,把对象归属和数据范围条件下沉到查询层。只要这三点落实,大多数水平越权和跨租户访问问题都能明显减少。

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

0篇意见

推荐意见

没有意见。

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