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

每篇 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_idtenant_id,或那个带外承载同一含义的 X-Account-Id header。这就是大多数 hunter 从不做的那一步,因为教程训练出了错误的条件反射。

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

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

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": "[email protected]" }

同步的 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_idowner_idaccount_idroleis_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,授权就是有效的——那正是你想要的非对称性。如果两者都返回带相同 body200,非对称性就没了,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。就像赢下竞态条件一样,漏洞一直都在;方法才是把它浮现出来的东西。

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

常见问题

IDOR 和 BOLA 有什么区别?
它们是同一个缺陷的两个名字。IDOR(Insecure Direct Object Reference,不安全的直接对象引用)是经典的 Web 应用叫法;BOLA(Broken Object Level Authorization,对象级授权失效)则是 OWASP API Security Top 10 里面向 API 时代的名字,位列 API1。两者都指应用返回或修改了某个对象,却没有检查通过认证的请求者是否有权访问该特定对象。BOLA 是更精确的表述,因为它点明了缺的是什么:一个对象级检查,而不只是一个身份认证检查。
UUID 是抵御 IDOR 的可靠防御吗?
不是。UUID 提高了盲枚举的成本,但对授权毫无帮助——如果对象级检查缺失,一个已知的 UUID 照样会返回对象。而且 UUID 很少是秘密:它们会在邮件、Referer header、201 Created 的 Location header,以及其他 API response 内部的交叉引用中泄露,所以你是去收集它们,而不是暴力破解。UUIDv1 比随机值更糟,因为它编码了一个时间戳外加生成主机的 MAC 地址,使相邻值部分可预测。URL 里的 GUID 不是 access control。
为什么扫描器无法独自确认 IDOR 或 BOLA?
确认这个漏洞需要第二个合法但不同、持有不同对象的用户,这样你才能证明某个 200 是未经授权的,而不只是成功的。扫描器能标记出每一个引用对象的接口,但它没有已知的 victim 账号可供 diff,也没法把一个已授权的 200 和一个失效的 200 区分开。这正是为什么可靠的方法是两个保存的身份加一次 response diff——那是扫描器在结构上就做不出的判断。
免费试用 Crusader →