背景
越权漏洞在企业系统里一直很常见,尤其是后台管理、SaaS 多租户、工单系统、内容平台这类业务。它不像 SQL 注入或命令执行那样显眼,但实际影响往往不低:普通用户可以查看他人数据、低权限账号可以修改高权限资源,严重时会造成业务数据泄露或权限体系失效。
这篇文章整理一次比较典型的后台接口越权审计思路。重点放在如何发现、验证和修复这类问题,不涉及真实目标攻击,也不提供非法利用流程。文中示例均为简化后的本地代码和接口模型,适合用于内部安全评审或开发自查。
问题场景
假设某系统存在一个项目管理功能,用户登录后可以查看自己所在项目的详情。接口大致如下:
GET /api/project/detail?id=1001
Authorization: Bearer <token>从业务设计看,用户只能访问自己所属组织或自己参与的项目。但在实际审计中经常会看到一种问题:接口只校验了“是否登录”,没有校验“当前用户是否有权访问该资源”。
典型错误代码类似这样:
// 伪代码:存在风险
@GetMapping(\"/api/project/detail\")
public ProjectVO detail(@RequestParam Long id) {
User currentUser = SecurityContext.getCurrentUser();
if (currentUser == null) {
throw new UnauthorizedException();
}
Project project = projectService.getById(id);
return ProjectConverter.toVO(project);
}这段代码看起来有登录校验,但没有把 project.id 和 currentUser 建立权限关系。只要知道或猜到其他项目 ID,就可能读取非授权数据。
技术分析
越权问题一般可以分为两类:
- 水平越权:同级用户访问了其他用户或其他租户的数据。例如用户 A 查看用户 B 的订单、项目、工单。
- 垂直越权:低权限用户调用了高权限接口。例如普通用户访问管理员接口、审核接口、配置接口。
这类问题的核心并不在鉴权组件是否存在,而在权限判断是否覆盖到了具体对象。很多系统使用了统一登录中间件,但资源级授权仍然依赖业务代码实现,稍有遗漏就会出问题。
审计时可以优先关注以下接口类型:
- 详情查询接口:
/detail、/info、/get - 列表导出接口:
/export、/download - 状态变更接口:
/updateStatus、/approve、/close - 附件接口:
/file/get、/attachment/download - 后台管理接口:用户管理、角色管理、配置管理、字典管理
尤其是使用自增 ID、雪花 ID、短字符串编号的资源,如果接口没有服务端权限校验,仅依赖前端隐藏按钮或菜单权限,风险会比较明显。
代码审计切入点
实际审计时,不建议只从接口表面判断。比较稳妥的做法是沿着 Controller、Service、Mapper 三层看权限条件是否贯穿到底。
1. Controller 层是否只做了登录校验
如果接口仅使用类似 @LoginRequired、@PreAuthorize(\"isAuthenticated()\") 的注解,需要继续确认是否有资源级判断。
// 风险点:只校验登录,不校验资源归属
@PreAuthorize(\"isAuthenticated()\")
@GetMapping(\"/order/detail\")
public OrderDTO detail(@RequestParam Long orderId) {
return orderService.detail(orderId);
}这种代码不是一定有漏洞,但需要重点追踪 orderService.detail 内部是否存在基于当前用户、组织、租户、角色的过滤。
2. Service 层是否校验资源归属
相对安全的写法通常会显式传入当前用户上下文,并检查资源归属。
public OrderDTO detail(Long orderId, CurrentUser user) {
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new NotFoundException();
}
if (!order.getTenantId().equals(user.getTenantId())) {
throw new ForbiddenException();
}
if (!order.getOwnerId().equals(user.getUserId()) && !user.hasRole(\"ORDER_ADMIN\")) {
throw new ForbiddenException();
}
return convert(order);
}这里需要注意两点:第一,租户边界通常应该优先校验;第二,角色权限不能替代资源归属判断,除非业务上明确允许该角色跨资源访问。
3. Mapper 层是否缺少权限过滤条件
很多越权问题最终会落在 SQL 条件缺失上。例如列表接口只按查询参数过滤,没有附加当前租户或当前用户条件。
-- 风险 SQL:缺少 tenant_id / owner_id 限制
SELECT id, title, status, owner_id
FROM ticket
WHERE status = #{status}
ORDER BY id DESC;更稳妥的做法是把权限边界下沉到查询条件里,避免先查出来再在内存中过滤。
-- 示例:附加租户和用户范围限制
SELECT id, title, status, owner_id
FROM ticket
WHERE tenant_id = #{tenantId}
AND status = #{status}
AND (
owner_id = #{userId}
OR #{isTicketAdmin} = true
)
ORDER BY id DESC;对于多租户系统,tenant_id 条件建议作为基础约束,不要依赖前端传入,也不要从请求参数直接读取,应从服务端登录态或可信上下文中获取。
验证思路
在授权测试中,建议准备至少两个普通账号和一个高权限账号。测试时不要使用生产数据,可以在测试环境构造最小数据集。
| 账号 | 角色 | 数据范围 |
|---|---|---|
| user_a | 普通用户 | 项目 A、工单 A |
| user_b | 普通用户 | 项目 B、工单 B |
| admin_1 | 管理员 | 组织内全部项目 |
验证步骤可以这样设计:
- 使用 user_a 登录,正常访问项目 A,记录请求参数和响应字段。
- 将资源 ID 替换为项目 B 的 ID,观察是否返回数据。
- 对查询、修改、删除、导出、附件下载分别验证,不能只测详情接口。
- 检查响应是否存在“数据为空”和“无权限”的差异,避免通过枚举判断资源是否存在。
- 验证后台接口是否只依赖菜单权限,直接请求接口时是否仍有服务端校验。
如果需要用命令复现内部测试,可以使用本地测试环境的 token 和资源 ID,重点是确认服务端返回逻辑。
curl -i ' \\
-H 'Authorization: Bearer USER_A_TEST_TOKEN'如果 user_a 不属于项目 1002,合理结果应为 403,或者统一返回不暴露资源存在性的错误信息。
HTTP/1.1 403 Forbidden
Content-Type: application/json
{\"code\":\"FORBIDDEN\",\"message\":\"permission denied\"}常见修复方式
修复越权问题不能只在前端隐藏入口,也不建议只在某个 Controller 临时加判断。比较可靠的方式是建立统一的资源授权模型。
1. 明确资源归属字段
每类敏感资源都应明确归属关系,例如:
tenant_id:租户或组织边界owner_id:资源创建者或拥有者project_id:项目边界department_id:部门边界visibility:公开、内部、私有等可见范围
如果数据库表本身没有这些字段,后续做权限判断会非常困难,往往只能依赖间接关联,容易漏掉边界条件。
2. 封装权限检查方法
可以在 Service 层封装资源权限检查,避免每个接口重复写散落逻辑。
public void checkProjectReadable(Long projectId, CurrentUser user) {
Project project = projectMapper.selectById(projectId);
if (project == null) {
throw new NotFoundException();
}
if (!project.getTenantId().equals(user.getTenantId())) {
throw new ForbiddenException();
}
boolean member = projectMemberMapper.exists(projectId, user.getUserId());
boolean admin = user.hasRole(\"PROJECT_ADMIN\");
if (!member && !admin) {
throw new ForbiddenException();
}
}更进一步,可以把资源读取和权限检查合并为一个方法,避免调用方先无权限读取资源再处理。
public Project getReadableProject(Long projectId, CurrentUser user) {
Project project = projectMapper.selectById(projectId);
if (project == null) {
throw new NotFoundException();
}
if (!project.getTenantId().equals(user.getTenantId())) {
throw new ForbiddenException();
}
if (!canReadProject(project, user)) {
throw new ForbiddenException();
}
return project;
}3. 列表接口必须限制数据范围
列表接口容易被忽略,尤其是带分页、搜索和导出的场景。建议把当前用户的数据范围作为后端强制条件,不允许客户端传入覆盖。
public Page<TicketDTO> list(TicketQuery query, CurrentUser user) {
query.setTenantId(user.getTenantId());
if (!user.hasRole(\"TICKET_ADMIN\")) {
query.setOwnerId(user.getUserId());
}
return ticketMapper.page(query);
}如果是复杂组织架构,可以先计算用户可访问的部门、项目、角色范围,再拼接到查询条件中。注意范围为空时应返回空结果,而不是移除条件。
4. 状态变更接口需要二次校验
不少系统只在详情页做权限判断,修改接口却直接按 ID 更新。例如:
// 风险点:任意已登录用户可能修改其他资源状态
UPDATE ticket
SET status = #{status}
WHERE id = #{id};更稳妥的做法是在更新条件里加入权限边界,或者先查询并校验后再更新。
UPDATE ticket
SET status = #{status}
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND owner_id = #{userId};对于审批、关闭、分配等敏感操作,还应校验当前状态是否允许流转,避免出现越权之外的业务逻辑问题。
5. 附件下载不要只校验文件 ID
附件接口是越权高发点。常见错误是附件表只有 file_id,下载时只判断文件是否存在,没有回溯业务资源权限。
// 风险点:知道 fileId 即可下载
FileMeta file = fileMapper.selectById(fileId);
return fileStorage.download(file.getPath());修复时需要让附件和业务对象建立关联,例如 biz_type、biz_id,下载前根据业务对象做权限校验。
FileMeta file = fileMapper.selectById(fileId);
if (file == null) {
throw new NotFoundException();
}
if (\"TICKET\".equals(file.getBizType())) {
ticketAuthService.checkReadable(file.getBizId(), currentUser);
}
return fileStorage.download(file.getPath());注意点
- 不要信任前端权限:菜单、按钮、路由控制只能提升体验,不能作为安全边界。
- 不要信任请求参数中的租户 ID:租户、组织、用户身份应来自服务端登录态或可信网关注入。
- 避免只修单个接口:同一资源的详情、列表、导出、修改、删除、附件都要一起检查。
- 错误信息要克制:跨租户访问时可以统一返回 404 或 403,避免泄露资源是否存在。
- 审计日志要记录关键操作:包括操作者、资源 ID、操作类型、结果、来源 IP、请求 ID,便于后续追踪。
- 测试用例要固化:越权问题修复后建议加入接口自动化测试,避免后续迭代回归。
自动化测试示例
对于稳定接口,可以用集成测试固化权限边界。下面是一个简化示例:
@Test
void userShouldNotReadOtherProject() throws Exception {
String tokenA = loginAs(\"user_a\");
mockMvc.perform(get(\"/api/project/detail\")
.param(\"id\", \"1002\")
.header(\"Authorization\", \"Bearer \" + tokenA))
.andExpect(status().isForbidden());
}测试重点不是覆盖所有业务字段,而是覆盖“不同用户、不同资源、不同角色”的边界。对多租户系统来说,跨租户用例必须单独加入。
总结
越权漏洞本质上是资源级授权缺失。登录态、角色权限、菜单权限只能解决一部分问题,真正可靠的做法是把当前用户与目标资源建立明确关系,并在服务端强制校验。
实际审计中,可以按“接口入口是否鉴权、业务资源是否归属、SQL 是否带范围、附件是否回溯校验、状态变更是否二次校验”这条线排查。修复时尽量沉淀统一权限组件和测试用例,不要依赖零散判断。这样不仅能修掉单点漏洞,也能降低后续迭代再次出现同类问题的概率。
"}
推荐意见