跳转到帖子

背景

后台管理系统里,越权问题一直不算“高深”,但在真实代码审计和测试中出现频率很高。尤其是基于 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 等数据范围条件。这个方法不复杂,但对后台系统的安全质量提升很明显。

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

0篇意见

推荐意见

没有意见。

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

SiteMap HACKHAT © 2026 All rights reserved.