跳转到帖子

背景

水平越权是 Web 系统里非常常见、也很容易被低估的一类问题。它通常不依赖复杂利用链,而是出现在业务接口对“资源归属关系”的校验缺失上。实际排查中,很多系统已经做了登录态校验、角色校验,甚至网关层也有鉴权,但只要具体业务对象没有绑定当前用户或租户,就可能出现用户 A 访问用户 B 数据的情况。

这篇文章整理的是一次接口越权排查过程中沉淀下来的方法。内容偏代码审计和修复思路,不涉及真实目标攻击,也不会给出针对第三方系统的利用流程。适合用于内部系统自查、上线前安全评审和漏洞复盘。

问题场景

一个典型的业务接口如下:用户登录后,可以查看自己的订单详情。前端请求大致是:

GET /api/order/detail?id=10086

后端接口做了登录校验,确认请求里存在有效 token,然后根据订单 ID 查询订单详情并返回。

表面看流程没有问题,但安全问题往往出在这里:接口只校验了“你是否登录”,没有校验“这个订单是否属于你”。如果订单 ID 是可枚举、可猜测或者来自其他接口泄露,就可能造成水平越权。

代码层面的典型缺陷

以下是比较常见的缺陷写法,示例仅用于说明问题:

public OrderDetail getOrderDetail(Long orderId) {
    User currentUser = SecurityContext.getCurrentUser();
    if (currentUser == null) {
        throw new UnauthorizedException();
    }

    Order order = orderMapper.selectById(orderId);
    if (order == null) {
        throw new NotFoundException();
    }

    return convertToDetail(order);
}

这段代码做了登录检查,也处理了订单不存在的情况,但没有把 orderId 和 currentUser 关联起来。问题不在认证,而在授权。

更安全的做法是查询时直接带上资源归属条件:

public OrderDetail getOrderDetail(Long orderId) {
    User currentUser = SecurityContext.getCurrentUser();
    if (currentUser == null) {
        throw new UnauthorizedException();
    }

    Order order = orderMapper.selectByIdAndUserId(orderId, currentUser.getId());
    if (order == null) {
        throw new NotFoundException();
    }

    return convertToDetail(order);
}

对应 SQL 应该类似:

SELECT id, user_id, status, amount, created_at
FROM orders
WHERE id = #{orderId}
  AND user_id = #{userId}
LIMIT 1;

这里有个细节:建议在查询层就加入 user_id、tenant_id 这类归属条件,而不是查出来以后再用 Java 代码判断。这样可以减少后续开发误用通用查询方法的概率,也能避免日志、缓存、异常处理环节提前接触到不该访问的数据。

审计时优先看的接口类型

水平越权不是只出现在详情查询接口,实际审计时可以重点看以下几类:

  • 详情接口:例如订单详情、工单详情、合同详情、用户资料详情。

  • 列表接口:分页查询时是否按当前用户、组织、租户过滤。

  • 下载接口:发票、报表、附件、合同 PDF 等文件下载是否校验归属。

  • 修改接口:修改地址、取消订单、更新工单、编辑备注等是否校验资源所有权。

  • 异步任务接口:导出任务、批处理任务、消息记录是否只校验任务 ID。

  • 管理后台接口:是否只依赖菜单权限,没有细分数据范围权限。

经验上,下载类和异步任务类接口更容易漏。因为它们经常由不同模块维护,代码路径也可能绕过常规 Controller 逻辑,例如直接通过文件 ID、任务 ID 获取结果。

从请求参数反推风险点

做代码审计时,可以先从参数命名入手。只要接口中出现下面这些参数,就需要检查是否做了归属校验:

  • id、orderId、userId、fileId、taskId

  • accountId、customerId、companyId、tenantId

  • projectId、groupId、departmentId

  • reportId、invoiceId、ticketId

如果这些参数来自客户端,就不能默认可信。尤其是 userId、tenantId 这类参数,不建议由前端传入后直接参与权限判断。更稳妥的做法是从服务端会话、token claims 或权限上下文中取当前身份信息。

关键审计步骤

下面是一套比较实用的审计流程,适合在代码量较大的业务系统中快速定位问题。

1. 建立资源归属模型

先梳理系统里的核心资源,以及它们和用户、组织、租户之间的关系。例如:

资源归属字段常见风险接口
订单user_id / tenant_id详情、取消、退款、导出
附件owner_id / biz_id预览、下载、删除
工单creator_id / assignee_id / tenant_id详情、回复、关闭
报表任务created_by / tenant_id查询进度、下载结果

这一步很重要。没有资源归属模型,审计容易停留在“看起来有鉴权”的层面,漏掉业务数据范围。

2. 搜索通用查询方法

在代码中搜索类似方法名:

selectById
findById
getById
queryDetail
getDetail
download
preview
export
removeById
updateById

如果这些方法被 Controller 或 Service 直接调用,需要重点确认调用链上是否补充了用户、组织或租户过滤条件。

例如下面这种写法要谨慎:

Order order = orderService.getById(orderId);

如果 getById 是全局通用方法,通常不会包含当前用户上下文。业务接口直接使用它,很容易形成越权点。

3. 检查权限注解的实际语义

不少项目会使用类似注解:

@PreAuthorize("hasAuthority('order:view')")
@GetMapping("/detail")
public OrderDetail detail(Long id) {
    return orderService.detail(id);
}

这类注解解决的是“有没有查看订单的功能权限”,不等于“能不能看这条订单”。功能权限和数据权限是两层逻辑,不能互相替代。

比较稳妥的实现是:注解负责入口级权限,Service 或 Repository 层负责资源归属校验。

4. 关注多租户过滤是否全链路生效

多租户系统里,越权经常表现为租户隔离失败。常见问题包括:

  • 部分 SQL 漏加 tenant_id 条件。

  • 后台任务、导出任务没有租户上下文。

  • 缓存 key 没有带租户标识。

  • 管理员接口和普通用户接口复用 Service,数据范围不一致。

  • 手写 SQL 没有经过租户拦截器。

缓存问题尤其容易忽略。例如:

String cacheKey = "order:detail:" + orderId;

如果不同租户下存在相同业务 ID,或者缓存结果没有经过权限隔离,就可能出现跨租户数据污染。建议至少把租户和用户范围纳入 key 设计:

String cacheKey = "tenant:" + tenantId + ":order:detail:" + orderId;

修复建议

修复水平越权不要只在 Controller 里加一个 if 判断,更推荐形成一致的工程约束。

1. 查询条件绑定当前身份

详情、修改、删除、下载等操作,都应在数据查询阶段绑定当前用户或租户。

UPDATE orders
SET status = #{status}
WHERE id = #{orderId}
  AND user_id = #{userId};

修改接口可以根据影响行数判断是否允许操作:

int affected = orderMapper.cancel(orderId, currentUser.getId());
if (affected == 0) {
    throw new NotFoundException();
}

这里返回 NotFound 通常比返回 Forbidden 更稳妥,避免暴露资源是否存在。当然内部审计日志要记录真实原因,便于排查。

2. 避免信任客户端传入的身份字段

下面这种做法风险较高:

GET /api/order/list?userId=12345

除非这是管理后台的数据查询接口,并且有明确的数据范围权限,否则普通用户接口不应该允许前端指定 userId。更推荐:

User currentUser = SecurityContext.getCurrentUser();
List<Order> orders = orderMapper.selectByUserId(currentUser.getId());

3. 文件下载要校验业务归属

文件下载接口不要只根据文件 ID 或文件路径返回内容。建议文件表保存业务归属字段,并在下载前校验:

SELECT f.id, f.storage_key, f.owner_id
FROM files f
WHERE f.id = #{fileId}
  AND f.owner_id = #{currentUserId}
LIMIT 1;

如果文件归属于订单、工单等业务对象,也可以通过业务对象进行二次校验,而不是单独信任文件记录。

4. 管理后台要区分功能权限和数据权限

管理后台常见情况是:用户有“订单查询”菜单权限,但只能看自己部门、自己负责客户或指定租户的数据。此时需要建立数据范围模型,例如:

  • 本人数据

  • 本部门数据

  • 本部门及子部门数据

  • 指定组织数据

  • 全局数据

不要把“能访问菜单”直接等同于“能访问全部数据”。

测试与验证思路

在合法授权的内部测试环境中,可以准备两个普通账号,分别创建不同资源,然后验证以下点:

  • 账号 A 是否能访问账号 B 的详情资源。

  • 账号 A 是否能修改、删除、取消账号 B 的资源。

  • 账号 A 是否能下载账号 B 的附件或导出结果。

  • 列表接口是否只返回当前账号授权范围内的数据。

  • 缓存命中后是否仍然保持租户和用户隔离。

测试时重点不是“猜 ID”,而是验证服务端是否真正绑定当前身份与资源归属。即使业务 ID 使用 UUID,也不能替代权限校验。不可猜测 ID 只能降低枚举风险,不能作为授权机制。

日志与告警

越权尝试在日志上通常表现为:当前用户访问不存在或不属于自己的资源。建议记录以下字段:

  • 当前用户 ID、租户 ID、角色信息。

  • 请求接口、资源类型、资源 ID。

  • 权限判断结果和失败原因。

  • 请求来源 IP、User-Agent、Trace ID。

注意不要在日志中记录敏感明文数据,例如完整 token、身份证号、银行卡号等。对于频繁访问无权限资源的行为,可以做内部告警,但告警策略要结合业务误操作情况,避免噪声过大。

容易踩的坑

  • 只在前端隐藏按钮:前端控制只能改善体验,不能作为权限边界。

  • 只做角色校验:角色说明能不能使用功能,不说明能不能访问某条数据。

  • 过度依赖 ID 随机性:UUID、雪花 ID、加密 ID 都不能代替服务端授权。

  • Service 复用不区分场景:普通用户接口和后台接口复用查询方法时,要显式传入数据范围。

  • 异常信息过细:对外返回“资源不存在”通常比“无权访问该资源”更稳妥。

  • 缓存绕过校验:缓存数据返回前也应确保 key 和权限范围设计正确。

总结

水平越权的本质不是缺少登录校验,而是缺少资源级授权。审计时要沿着“当前用户是谁、要访问什么资源、资源属于谁、是否在授权范围内”这条线看代码。

比较可靠的治理方式,是把资源归属校验下沉到 Service 或数据访问层,并形成统一约束:客户端传入的资源 ID 只能用于定位候选资源,最终是否可访问必须由服务端结合当前身份、租户、组织和数据范围判断。

这类问题修复成本通常不高,但覆盖面容易被低估。建议在新增接口评审、代码审计、自动化测试用例中都加入资源归属检查,长期看比事后逐个补漏洞更省成本。

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

0篇意见

推荐意见

没有意见。

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