背景
水平越权是 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、taskIdaccountId、customerId、companyId、tenantIdprojectId、groupId、departmentIdreportId、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 只能用于定位候选资源,最终是否可访问必须由服务端结合当前身份、租户、组织和数据范围判断。
这类问题修复成本通常不高,但覆盖面容易被低估。建议在新增接口评审、代码审计、自动化测试用例中都加入资源归属检查,长期看比事后逐个补漏洞更省成本。
推荐意见