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_id、tenant_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_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。就像赢下竞态条件一样,漏洞一直都在;方法才是把它浮现出来的东西。
这正是 Crusader 的 Shadow Replay 为之而生的:只需定义一次 attacker 与 victim 身份,然后以两者身份 replay 任意 request 并读取差异——基于 HTTP/2,面向 web、mobile 与 AI 目标。把它指向你历史里每一个引用对象的 request,换掉那个次要标识符,盯住那个本不该相同却相同的 response。免费下载 Crusader。