IDOR दूसरे identifier में छिपा होता है: दो-अकाउंट Shadow Replay से BOLA खोजना
हर 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।
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": "[email protected]" }
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 जीतने में, 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 करें।