背景
越权漏洞在 Web 安全里并不新鲜,但在实际代码审计和业务测试中,它仍然是最容易被遗漏的一类问题。原因也很现实:权限判断往往散落在 Controller、Service、DAO,甚至前端路由里;业务迭代频繁后,原本清晰的权限模型会被临时需求逐渐冲散。
这篇文章整理一次典型的接口越权问题复盘,重点放在如何定位、如何验证、如何修复,以及后续代码审计时可以沉淀哪些检查方法。内容不涉及真实目标系统,也不提供攻击他人系统的操作引导,适合作为内部安全评审或代码审计 checklist 参考。
问题场景
某业务系统中有一个资源管理功能,用户可以查看、编辑自己所属组织下的资源。接口设计大致如下:
GET /api/resource/detail?id=1001
POST /api/resource/update
前端页面会根据当前登录用户展示资源列表,用户点击某条记录后,前端把资源 id 带到详情接口。表面看起来没有问题,因为普通用户在页面上只能看到自己有权限的资源。
但问题出在后端接口只根据传入的 id 查询资源详情,没有校验该资源是否属于当前用户可访问范围。攻击者如果知道或猜到其他资源 id,就可能读取或修改非授权资源。
越权问题的核心不是“用户能不能看到按钮”,而是“后端在每一次访问资源时,是否重新验证主体、动作、资源三者之间的授权关系”。
技术分析
以常见的 Java Web 分层为例,问题代码可能类似下面这样:
// Controller
@GetMapping("/detail")
public ResourceDTO detail(@RequestParam Long id) {
return resourceService.getDetail(id);
}
// Service
public ResourceDTO getDetail(Long id) {
Resource resource = resourceMapper.selectById(id);
return convert(resource);
}这段代码的问题不是 SQL 注入,也不是认证缺失,而是认证后的授权缺失。用户已经登录,但系统没有确认该用户是否有权访问 id 对应的资源。
在很多系统里,权限校验通常分为两层:
- 功能权限:当前用户是否可以访问某个菜单或接口,例如是否具备 resource:view 权限。
- 数据权限:当前用户是否可以访问某条具体数据,例如资源是否属于其组织、项目或租户。
实际漏洞多出现在第二层。功能权限配置正确,接口也要求登录,但数据权限没有落到查询条件或业务判断里。
关键验证思路
在合法授权的测试环境中,可以按以下思路验证此类问题。这里强调,验证应限定在自有系统、授权测试环境或漏洞靶场中。
1. 确认资源标识是否可控
首先观察请求参数、路径参数或请求体中是否存在资源标识,例如:
id=1001
resourceId=1001
projectId=20
orderNo=202401010001如果这些标识由客户端传入,就需要进一步确认后端是否以当前用户身份进行二次授权判断。
2. 使用两个低权限账号交叉验证
准备两个属于不同组织或不同项目的普通账号,分别创建或查看各自资源。然后在授权测试环境中,将 A 账号请求中的资源 id 替换为 B 账号资源 id,观察响应是否返回敏感数据或允许修改。
需要注意的是,判断越权不能只看 HTTP 状态码,还要看业务返回码、响应体字段、数据库状态变化以及审计日志。
3. 区分读越权和写越权
读接口常见问题是详情、导出、下载、预览接口缺少数据权限。写接口常见问题是更新、删除、审批、绑定关系接口只校验了对象存在性,没有校验对象归属。
例如下面这种写接口就比较危险:
public void update(ResourceUpdateRequest request) {
Resource resource = resourceMapper.selectById(request.getId());
if (resource == null) {
throw new BizException("资源不存在");
}
resource.setName(request.getName());
resourceMapper.updateById(resource);
}它只判断资源是否存在,却没有判断当前用户是否能操作该资源。
修复设计
修复越权问题,不建议只在某个 Controller 上临时加 if 判断。更稳妥的做法是建立统一的数据权限校验模式,至少要覆盖查询、更新、删除、导出等入口。
1. 在查询条件中绑定用户可访问范围
对于列表和详情查询,可以把用户的数据范围加入 SQL 条件。示例:
public ResourceDTO getDetail(Long id, LoginUser user) {
Resource resource = resourceMapper.selectByIdAndOrgIds(
id,
user.getAllowedOrgIds()
);
if (resource == null) {
throw new BizException("资源不存在或无权限访问");
}
return convert(resource);
}对应 SQL 可以类似:
SELECT *
FROM resource
WHERE id = #{id}
AND org_id IN
<foreach collection="orgIds" item="orgId" open="(" separator="," close=")">
#{orgId}
</foreach>这里有一个细节:错误提示建议统一为“资源不存在或无权限访问”,避免通过差异化错误信息帮助枚举资源是否存在。
2. 写操作前做归属校验
更新、删除、审批类接口不要只根据 id 直接执行。可以先查询当前用户可操作范围内的对象,再进行修改:
public void update(ResourceUpdateRequest request, LoginUser user) {
Resource resource = resourceMapper.selectByIdAndOrgIds(
request.getId(),
user.getAllowedOrgIds()
);
if (resource == null) {
throw new BizException("资源不存在或无权限访问");
}
resource.setName(request.getName());
resourceMapper.updateById(resource);
}如果是高并发或状态敏感的操作,也可以把权限条件合并到 update 语句中,避免查询后对象状态变化带来的竞态问题:
UPDATE resource
SET name = #{name}, update_time = NOW()
WHERE id = #{id}
AND org_id IN (...)
AND status = 'DRAFT';执行后检查影响行数,影响行为 0 时按无权限或状态不允许处理。
3. 避免只依赖前端控制
前端隐藏按钮、路由拦截、禁用输入框只能改善用户体验,不能作为安全边界。所有关键授权必须在服务端完成。尤其是管理后台、移动端接口、小程序接口,经常存在多个前端共用同一套后端接口的情况,不能假设前端一定按预期传参。
代码审计关注点
审计越权问题时,可以围绕“资源标识进入点”和“权限判断落点”做静态排查。
1. 搜索高风险接口命名
可以先从常见方法名入手:
detail
info
getById
update
delete
remove
export
download
approve
bind
unbind
assign这些接口通常直接操作具体资源,若只看到 selectById、updateById、deleteById,就要重点确认是否有额外的数据权限判断。
2. 检查 Mapper 层是否包含租户或组织条件
多租户系统里,所有业务表一般都应该带 tenant_id、org_id、project_id 等隔离字段。审计时可以抽样检查核心表的查询语句是否包含这些约束。
| 检查对象 | 重点关注 |
|---|---|
| 详情查询 | 是否只按 id 查询 |
| 列表查询 | 是否带租户、组织、项目范围 |
| 更新删除 | WHERE 条件是否只包含主键 |
| 导出下载 | 是否复用列表权限条件 |
| 审批流 | 是否校验当前处理人或角色 |
3. 关注间接对象引用
有些接口表面上没有 resourceId,但会通过其他字段间接关联资源,例如 orderNo、fileKey、taskId、messageId。只要这个字段能定位到具体业务对象,就需要做授权校验。
文件下载接口尤其常见:数据库中保存 file_id 和业务对象关系,但下载接口只根据 file_id 取文件,导致用户可以下载不属于自己的附件。修复时应校验文件所属业务对象是否在当前用户权限范围内。
测试与回归建议
越权漏洞修复后,建议补充自动化回归用例,避免后续迭代再次引入。最基本的用例应覆盖:
- 同一用户访问自己的资源,预期成功。
- 不同组织用户访问彼此资源,预期失败。
- 无权限用户执行更新、删除、导出,预期失败。
- 管理员或授权角色访问授权范围内资源,预期成功。
- 不存在资源和无权限资源返回信息不泄露差异。
如果项目使用 Spring Security 或类似框架,可以把功能权限放在注解或网关层,把数据权限放在 Service 或 Mapper 层。两者不要混为一谈。
@PreAuthorize("hasAuthority('resource:view')")
@GetMapping("/detail")
public ResourceDTO detail(@RequestParam Long id) {
LoginUser user = LoginContext.getUser();
return resourceService.getDetail(id, user);
}这里注解只解决“能不能访问详情功能”,Service 中仍然要解决“能不能访问这条数据”。
注意点
- 不要把用户传入的 orgId、tenantId 当作可信数据,应从登录态或服务端上下文获取。
- 不要用自增 id 是否难猜来代替权限校验,复杂 id 也不是授权机制。
- 错误提示应避免暴露资源存在性,日志中可以记录更详细原因,但不要直接返回给客户端。
- 批量操作要逐条或按权限范围过滤,不能只校验第一条数据。
- 导出、下载、打印、分享链接等边缘接口要纳入同一套数据权限模型。
总结
越权漏洞的修复重点不在于某个接口加一行判断,而在于建立稳定的授权边界。简单说,就是每次访问具体资源时,都要确认当前主体是否有权对该资源执行该动作。
从代码审计角度看,优先关注按 id 查询、按 id 更新、文件下载、导出、审批流、绑定关系等接口,通常能较快发现问题。修复时建议把用户可访问范围下沉到 Service 或 Mapper 层,并通过测试用例固化下来。这样即使业务继续迭代,权限模型也不容易被局部改动破坏。
推荐意见