背景
后台管理系统里,越权问题一直不算“高深”,但在真实代码审计和测试中出现频率很高。尤其是基于 Spring Boot、MyBatis、JWT 或 Session 的业务系统,很多接口只做了“是否登录”和“是否具备菜单权限”的校验,却没有在数据层面限制资源归属,最后形成水平越权。
这篇文章整理一个偏实战的排查思路,重点放在代码审计、验证方法和修复方式上。示例均为本地简化代码,不涉及真实目标,也不提供未授权攻击引导。
典型场景
假设有一个工单系统,普通用户只能查看和修改自己创建的工单。接口大概如下:
GET /api/ticket/detail?id=1001
POST /api/ticket/update
Content-Type: application/json
{
"id": 1001,
"title": "修改后的标题",
"content": "补充说明"
}从表面看,接口需要登录后访问;从权限菜单看,普通用户也确实有“工单查看”和“工单编辑”的功能权限。问题在于,如果后端只根据传入的 id 查询或更新数据,而没有校验这条工单是否属于当前用户,就可能产生水平越权。
问题本质
水平越权的关键不是“有没有登录”,也不是“有没有访问这个接口的菜单权限”,而是当前主体是否有权操作某个具体资源。
可以简单拆成三层:
- 认证:用户是谁,例如 userId=10。
- 功能授权:用户能不能访问某个接口,例如是否允许调用工单详情接口。
- 数据授权:用户能不能访问 id=1001 这条具体记录。
很多系统完成了前两层,但第三层缺失。后台系统尤其常见,因为开发时容易默认“能进后台的人都是可信的”。
代码审计切入点
审计这类问题时,我一般不会从页面点起,而是先从 Controller 和 Mapper 入手。重点关注以下几类接口:
- 根据 id 查询详情:detail、info、getById、selectOne。
- 根据 id 修改状态:updateStatus、approve、reject、enable、disable。
- 批量操作:deleteBatch、export、auditBatch。
- 下载类接口:download、preview、attachment。
- 数据导出:exportExcel、downloadCsv。
一个存在风险的简化代码如下:
@GetMapping("/detail")
public Ticket detail(@RequestParam Long id) {
return ticketService.getById(id);
}对应的 Service 只是透传:
public Ticket getById(Long id) {
return ticketMapper.selectById(id);
}如果 Ticket 表里存在 owner_id、create_by、tenant_id、dept_id 等字段,而查询条件没有带上这些字段,就需要重点关注。
安全验证方式
在授权测试环境里,可以准备两个普通账号 A 和 B,各自创建一条工单。验证时只需要确认“账号 A 是否能读取或修改账号 B 的资源”。为了避免对业务造成影响,建议使用测试数据,并优先验证只读接口。
例如先登录账号 A,读取 A 自己的工单:
curl -i \
-H "Authorization: Bearer <token_of_user_a>" \
" A 的身份,请求账号 B 的工单 id:curl -i \
-H "Authorization: Bearer <token_of_user_a>" \
" HTTP 状态码本身,而是响应内容是否返回了不属于当前用户的数据。如果返回了完整数据,基本可以确认存在数据级授权缺失。注意:验证时不要使用生产环境真实用户数据,更不要进行批量枚举。漏洞确认的目标是证明授权缺陷存在,不是扩大影响面。
常见错误修复方式
不少修复会停留在 Controller 层,比如判断当前用户是否登录:
@GetMapping("/detail")
public Ticket detail(@RequestParam Long id) {
Long userId = SecurityUtils.getCurrentUserId();
if (userId == null) {
throw new UnauthorizedException();
}
return ticketService.getById(id);
}
这种修复没有解决问题,因为它仍然没有确认 id 对应的资源是否属于当前用户。
还有一种常见问题是只在前端隐藏按钮。前端控制只能改善用户体验,不能作为安全边界。只要接口存在,后端就必须做授权。
推荐修复思路
比较稳妥的方式是在数据查询或更新时绑定当前用户上下文。以“普通用户只能访问自己的工单”为例,详情查询可以改成:
@GetMapping("/detail")
public Ticket detail(@RequestParam Long id) {
Long userId = SecurityUtils.getCurrentUserId();
return ticketService.getByIdAndOwner(id, userId);
}
public Ticket getByIdAndOwner(Long id, Long ownerId) {
Ticket ticket = ticketMapper.selectByIdAndOwner(id, ownerId);
if (ticket == null) {
throw new NotFoundException("ticket not found");
}
return ticket;
}
SQL 层显式加入 owner_id 条件:
SELECT id, title, content, owner_id, status, create_time
FROM ticket
WHERE id = #{id}
AND owner_id = #{ownerId}
LIMIT 1;
更新操作也应如此,不建议先 select 再无条件 update。更好的做法是更新语句直接带上数据归属条件:
UPDATE ticket
SET title = #{title},
content = #{content},
update_time = NOW()
WHERE id = #{id}
AND owner_id = #{ownerId};
随后检查影响行数:
int rows = ticketMapper.updateByIdAndOwner(param, currentUserId);
if (rows == 0) {
throw new ForbiddenException("no permission to update this ticket");
}
这样即使请求参数被篡改,也不会修改到不属于当前用户的数据。
管理员与多租户场景
真实系统通常不只是“本人数据”这么简单,还可能涉及管理员、部门负责人、多租户、数据范围等规则。此时不要把逻辑散落在每个 Controller 里,建议抽象成统一的数据权限条件。
一个常见的数据范围设计如下:
角色类型 可访问范围 普通用户 本人创建或本人负责的数据 部门负责人 本部门及下级部门数据 租户管理员 当前 tenant_id 下的数据 系统管理员 按需开放,仍建议保留审计日志
对应到查询条件,通常会变成 owner_id、dept_id、tenant_id 的组合。以多租户系统为例,tenant_id 应该是强制条件,不能依赖前端传入:
SELECT id, title, status
FROM ticket
WHERE id = #{id}
AND tenant_id = #{currentTenantId};
如果业务允许跨租户管理,也应通过后端角色和明确授权判断获得 tenantId 列表,而不是信任请求参数里的 tenantId。
批量接口的额外风险
批量删除、批量审核、批量导出更容易出问题,因为开发者往往直接使用 id 列表:
DELETE FROM ticket
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
这类 SQL 应补充数据范围条件:
DELETE FROM ticket
WHERE owner_id = #{ownerId}
AND id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
同时建议校验影响行数。如果用户提交了 10 个 id,但实际只删除了 3 条,说明其中可能包含无权操作的数据,应记录安全审计日志,必要时返回统一错误。
下载与预览接口
附件下载接口也很容易忽略数据授权。常见错误是根据 fileId 查出文件路径后直接输出:
@GetMapping("/attachment/download")
public void download(@RequestParam Long fileId, HttpServletResponse response) {
Attachment file = attachmentMapper.selectById(fileId);
fileService.writeToResponse(file.getPath(), response);
}
更合理的做法是先确认附件所属业务对象,再校验当前用户是否有权访问该业务对象:
Attachment file = attachmentMapper.selectById(fileId);
Ticket ticket = ticketMapper.selectByIdAndOwner(file.getBizId(), currentUserId);
if (ticket == null) {
throw new ForbiddenException("no permission to download this attachment");
}
如果附件表本身有 tenant_id 或 owner_id,也可以在附件查询时直接带上条件。关键点是不要把 fileId 当成授权凭据。
审计日志与告警
越权问题修复后,建议补充日志。日志不需要记录敏感内容,但至少应包含主体、资源、动作和结果:
userId=10 action=ticket.update resourceId=2001 result=deny reason=owner_mismatch ip=127.0.0.1
对于短时间内大量访问不存在或无权限资源的行为,可以做限速或告警。这类信号在事件复盘中很有价值,能帮助判断是否发生过资源枚举。
测试用例建议
修复后不要只测正常流程,建议至少补充以下用例:
- 用户 A 访问用户 A 的资源,应成功。
- 用户 A 访问用户 B 的资源,应失败。
- 用户 A 修改用户 B 的资源,应失败且数据不变。
- 批量操作中混入无权 id,应失败或只处理有权数据,并记录日志。
- 租户 A 访问租户 B 的资源,应失败。
- 管理员访问数据时,应符合预期的数据范围策略。
如果项目有集成测试,可以将这些场景固化下来。越权问题很容易在后续迭代中回归,自动化测试比人工回归更可靠。
注意点
- 不要信任前端传入的 userId、tenantId、deptId,这些上下文应来自登录态或服务端可信链路。
- 不要只依赖菜单权限,菜单权限解决的是功能入口,不解决数据归属。
- 不要用“查不到”直接暴露资源存在性,很多场景下 404 比 403 更合适。
- 批量接口、导出接口、附件接口要单独审计,不能只看详情和编辑。
- 管理员能力也应有边界,至少要有日志和必要的二次确认。
总结
水平越权的修复核心是把“当前用户上下文”和“资源归属条件”绑定起来,并尽量在数据访问层落实。只做登录校验、只看菜单权限、只靠前端隐藏按钮,都不能解决根因。
在代码审计时,可以优先检索 getById、updateById、deleteByIds、download、export 等关键接口,再反查 SQL 是否带有 owner_id、tenant_id、dept_id 等数据范围条件。这个方法不复杂,但对后台系统的安全质量提升很明显。

代码审计、调用链与关键函数定位示意
推荐意见