跳转到帖子

背景

越权问题在 Web 系统里一直很常见,尤其是业务接口越来越细、前后端分离之后,很多权限判断会被分散到 Controller、Service、网关、注解或数据层过滤里。表面上看接口返回正常、鉴权也存在,但只要某个对象级权限校验缺失,就可能出现用户访问他人订单、工单、文件、组织数据的情况。

这类问题的特点是:不一定依赖复杂漏洞利用,更多来自业务逻辑疏漏。做代码审计时,如果只看是否登录、是否有角色,很容易漏掉“当前用户是否有权操作当前资源”这个核心问题。

典型场景

下面以一个常见的后台业务系统为例:用户登录后可以查看自己的项目详情,接口大致如下:

GET /api/project/detail?id=10086

服务端代码中可能已经做了登录校验,例如通过 Token 获取当前用户身份:

@GetMapping("/detail")
public ProjectDTO detail(@RequestParam Long id) {
    LoginUser user = SecurityContext.getCurrentUser();
    return projectService.getDetail(id);
}

这里的问题在于,虽然接口确认了“请求者是谁”,但没有确认“请求者是否拥有 id=10086 这个项目的访问权限”。如果 projectService.getDetail(id) 内部也只是按主键查询,就容易形成水平越权。

技术分析

越权审计建议从三个层次看:认证、授权、数据约束。认证解决“你是谁”,授权解决“你能做什么”,数据约束解决“你能操作哪些对象”。实际代码里最容易缺的是第三层。

1. 只校验角色,不校验资源归属

常见代码如下:

@PreAuthorize("hasRole('USER')")
@GetMapping("/order/detail")
public OrderDTO orderDetail(@RequestParam Long orderId) {
    return orderService.detail(orderId);
}

这里的角色校验只能说明当前用户是普通用户,但不能说明该订单属于当前用户。修复时至少应在查询条件中加入用户约束:

public OrderDTO detail(Long orderId, Long userId) {
    Order order = orderMapper.selectOne(
        new QueryWrapper<Order>()
            .eq("id", orderId)
            .eq("user_id", userId)
            .eq("deleted", 0)
    );
    if (order == null) {
        throw new BizException("资源不存在或无权限访问");
    }
    return convert(order);
}

注意错误提示建议统一成“资源不存在或无权限访问”,不要明确告诉调用方资源是否存在,避免额外的信息泄露。

2. 列表接口有过滤,详情接口缺过滤

这是比较常见的审计发现。开发在列表接口中按 user_id、tenant_id、dept_id 做了过滤,但详情、编辑、删除接口为了方便直接按 id 操作。

// 列表:有权限过滤
SELECT * FROM ticket WHERE creator_id = ? ORDER BY id DESC;

// 详情:缺少权限过滤
SELECT * FROM ticket WHERE id = ?;

审计时不要只看列表页是否正常,应重点检查同一业务对象的所有接口是否都带了相同的数据范围约束,例如 detail、update、delete、export、download、statusChange、assign 等。

3. 前端隐藏按钮不等于后端授权

一些系统会根据权限菜单控制前端按钮展示,例如没有删除权限就不显示删除按钮。但如果后端接口只依赖前端控制,就属于典型的权限边界放错位置。

后端必须独立完成权限判断,前端控制只能用于改善体验,不能作为安全措施。

4. 多租户系统中的 tenant_id 校验

多租户系统里,越权不一定发生在用户之间,也可能发生在租户之间。典型风险是业务表有 tenant_id,但查询时没有带租户条件:

SELECT * FROM asset WHERE id = ?;

更稳妥的写法是:

SELECT * FROM asset WHERE id = ? AND tenant_id = ?;

如果项目使用 MyBatis Plus、Hibernate Filter 或自定义拦截器统一注入 tenant_id,也要检查以下几点:

  • 是否存在跳过租户过滤的注解或白名单。
  • 管理端、定时任务、导出任务是否复用了同一数据访问方法。
  • 原生 SQL、XML SQL 是否绕开了统一拦截器。
  • 联表查询时主表和子表是否都受 tenant_id 约束。

审计关键步骤

第一步:梳理敏感资源对象

先找系统里的核心资源对象,例如订单、项目、工单、合同、文件、客户、资产、消息、报表等。一般来说,只要资源对象带有 user_id、owner_id、creator_id、dept_id、tenant_id、org_id,就要重点关注越权风险。

可以通过数据库字段快速定位:

-- 常见用于定位资源归属字段的思路
SELECT table_name, column_name
FROM information_schema.columns
WHERE column_name IN ('user_id','owner_id','creator_id','tenant_id','dept_id','org_id');

第二步:按接口操作类型检查

对每类资源,建议按操作类型建立检查清单:

操作类型常见风险点审计关注点
详情查询按 id 直接查询是否带 user_id、tenant_id、dept_id 等约束
更新操作先查后改时只校验存在性修改前是否确认资源归属
删除操作逻辑删除直接按 id删除条件是否包含权限范围
导出下载导出接口复用管理员查询是否继承当前用户数据权限
批量操作只校验部分 id是否对所有 id 做权限校验

第三步:关注批量接口

批量接口经常被忽略,例如:

POST /api/file/delete
{"ids":[101,102,103]}

危险写法:

public void deleteFiles(List<Long> ids) {
    fileMapper.deleteBatchIds(ids);
}

更合理的方式是先按当前用户权限范围查询可操作资源,再比较数量或直接按权限条件删除:

public void deleteFiles(List<Long> ids, Long userId) {
    int count = fileMapper.countByIdsAndOwner(ids, userId);
    if (count != ids.size()) {
        throw new BizException("部分资源不存在或无权限操作");
    }
    fileMapper.deleteByIdsAndOwner(ids, userId);
}

SQL 层也应保持约束:

UPDATE file
SET deleted = 1
WHERE id IN (?, ?, ?)
  AND owner_id = ?
  AND deleted = 0;

第四步:检查间接对象访问

有些接口不是直接传资源 id,而是传 fileKey、uuid、taskNo、shareCode、downloadToken 等间接标识。审计时不要因为参数不是自增 id 就降低警惕。

例如文件下载接口:

GET /api/file/download?fileKey=2024/10/a8f3c1.pdf

如果服务端只根据 fileKey 去对象存储取文件,而没有查询文件记录并确认当前用户权限,同样存在风险。建议流程是:

  • 先根据 fileKey 查询文件元数据。
  • 校验 tenant_id、owner_id、业务关联对象权限。
  • 确认文件状态未删除、未过期。
  • 再生成短期有效的下载地址或返回文件流。

第五步:检查“管理员接口”复用问题

很多越权问题来自接口复用。例如普通用户接口和管理员接口调用同一个 Service 方法,但 Service 内部没有权限上下文,默认返回完整数据。Controller 看起来分开了,实际底层查询没有差异。

// 普通用户入口
public List<Report> userReports() {
    return reportService.queryReports(null);
}

// 管理员入口
public List<Report> adminReports(Long tenantId) {
    return reportService.queryReports(tenantId);
}

建议 Service 层方法明确传入权限上下文,不要用 null 表示“不过滤”,否则很容易被误用。

public List<Report> queryReports(QueryScope scope) {
    return reportMapper.query(scope);
}

修复建议

1. 权限校验尽量靠近数据访问层

Controller 层适合做参数校验和粗粒度权限判断,但对象级权限最好落实到 Service 或 DAO 查询条件中。原因很简单:同一个 Service 可能被多个入口调用,权限校验越靠近数据访问,越不容易遗漏。

2. 建立统一的数据权限模型

不同系统可以根据业务选择不同模型:

  • 个人数据:owner_id = current_user_id。
  • 部门数据:dept_id in current_user_dept_scope。
  • 租户数据:tenant_id = current_tenant_id。
  • 项目成员数据:通过 project_member 表确认 membership。
  • 共享数据:通过授权表确认 resource_id 与 user_id、role_id 的关系。

不要在每个接口里临时写一套判断,长期维护成本会很高,也容易出现口径不一致。

3. 对外部可见 ID 做好边界控制

使用 UUID、雪花 ID 不能代替权限校验。不可预测 ID 可以降低枚举风险,但不能证明访问者有权限。真正的安全边界仍然是服务端授权判断。

4. 增加审计日志

对于读取敏感数据、导出、下载、删除、授权变更等操作,建议记录审计日志。日志至少包含:

  • 操作者 user_id、tenant_id、角色信息。
  • 操作对象类型和对象 id。
  • 操作结果和失败原因。
  • 请求来源 IP、User-Agent、requestId。
  • 时间戳。

日志不要记录明文密码、完整 Token、身份证号等敏感内容。

测试与验证方式

在授权范围内做安全测试时,可以准备两个普通测试账号 A 和 B,各自创建一些资源。验证重点不是复杂 payload,而是确认 A 是否能访问或操作 B 的资源。

# 示例:使用测试环境账号 A 请求账号 B 的资源 id
curl -H "Authorization: Bearer <token_of_user_A>" \
  " 403,或返回统一的“资源不存在或无权限访问”。如果返回了资源详情,就需要回到代码里检查该接口的数据范围约束。

批量接口也要验证混合 id 的情况:

{
  "ids": [
    "resource_id_of_user_A",
    "resource_id_of_user_B"
  ]
}

安全实现不应只处理其中有权限的部分而静默忽略,也不应把无权限资源的详细信息返回给调用方。具体策略可以按业务选择全部失败或只处理有权限部分,但行为必须明确、可审计。

注意点

  • 不要把前端菜单权限当作后端权限控制。
  • 不要只在列表接口做过滤,详情、修改、删除、导出同样要做。
  • 不要用“ID 不好猜”替代对象级授权。
  • 不要在 Service 中用 null、空数组表达“查询全部”,容易被普通入口误用。
  • 多租户系统要重点检查原生 SQL、报表 SQL、异步任务和导出接口。
  • 批量操作要校验所有资源的权限,避免部分越权。
  • 文件下载、预览、导出链接要校验业务归属和有效期。

总结

越权问题本质上不是“有没有登录”的问题,而是“当前身份是否有权访问当前对象”。代码审计时,可以围绕资源对象、接口操作类型、数据归属字段、Service 复用路径来展开。

比较可靠的修复思路是:认证只解决身份,角色只解决粗粒度能力,真正的对象级权限必须落到服务端的数据查询和操作条件里。把这个原则落实到详情、更新、删除、导出、下载、批量操作等接口,才能明显降低越权风险。

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

0篇意见

推荐意见

没有意见。

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

SiteMap HACKHAT © 2026 All rights reserved.