Skip to content

SSRF Filter Bypass: Beating Allow-Lists, Blocklists, and Parser Mismatches

Why SSRF filters fail in practice. Parser confusion, IP encoding, DNS rebinding, and redirect chains explained from the attacker's side, with the defensive fixes that actually hold.

Published on 7 min read

Almost every SSRF filter I've broken failed for the same reason: the code that decides whether a URL is safe and the code that actually fetches it are two different parsers, and they disagree. That gap is the whole game. Everything else, the IP encodings, the rebinding, the redirect tricks, is just a way to widen the disagreement until something slips through.

So instead of listing payloads, let me walk through the failure classes. Once you see why they fail, the payloads write themselves, and you stop guessing.

The two-parser problem

Picture a typical "fetch this URL" feature. Webhook tester, URL preview, PDF-from-URL, avatar-by-URL, import-from-URL. The code looks roughly like:

parsed = urlparse(user_url)
if parsed.hostname in BLOCKED_HOSTS:
    raise Forbidden()
resp = requests.get(user_url)   # <-- fetches the RAW string, not `parsed`

The validation runs on urlparse's view of the world. The fetch runs on requests' view, which internally re-parses the string. If you can write a URL those two read differently, you win. This is not theoretical. Python's urllib has had multiple documented parsing divergences from requests, and the same is true across Node's WHATWG URL versus http.request, Java's URL versus Apache HttpClient, and Go's net/url versus whatever client wraps it.

The cleanest demonstration is userinfo abuse:

http://allowed.example.com@169.254.169.254/

A validator that pulls hostname and checks an allow-list might extract allowed.example.com if it parses the userinfo wrong, or might extract 169.254.169.254 and reject it. The HTTP client reads allowed.example.com as the username, an empty password, and 169.254.169.254 as the host it connects to. Whether you win depends on which parser the validator used and whether it correctly handles the @. Test both @ and the encoded %40, because some validators decode before parsing and some after.

Blocklists are a losing position

If the target uses a deny-list, you have already won, they just don't know it yet. The address space of "things that mean 127.0.0.1" is enormous, and no blocklist enumerates it.

I covered the encodings in the payload cheat sheet, so here I'll just hammer the point that matters: these are not tricks, they are valid representations. 2130706433 is genuinely 127.0.0.1. The C function inet_aton will parse it, and so will most language runtimes that wrap the system resolver. A blocklist that greps for the dotted-quad string is checking for one spelling of a word that has thousands.

http://2130706433/
http://0x7f.1/
http://0177.0.0.1/
http://127.0.0.1.nip.io/
http://[::ffff:127.0.0.1]/

The only blocklist that works is one applied after resolution, to the resolved IP, checking it against the private and reserved ranges. And even that has a hole, which is the next section.

DNS rebinding: attacking the time gap

Resolve-then-check looks bulletproof. The server resolves your hostname, gets 93.184.216.34, confirms it's public, approves. Then it fetches. The catch is that resolution and fetch are two separate DNS lookups, and you control what comes back each time.

First lookup returns a public IP and passes validation. The record's TTL is zero or near-zero. By the time the HTTP client does its own lookup milliseconds later, your authoritative server answers with 127.0.0.1. Time-of-check to time-of-use, classic.

# rbndr.us style: alternates between two IPs on each query
http://7f000001.93b87622.rbndr.us/

In practice the reliability is spotty because of DNS caching. If the runtime caches the first result and reuses it for the connect, there's no second lookup to poison. Java is notorious here, it caches DNS aggressively by default (networkaddress.cache.ttl), which accidentally mitigates rebinding. Go and Python with a fresh socket per request are more exploitable. You usually need a few attempts to hit the window, and a Collaborator callback to confirm which IP the fetch actually used.

The robust defense is not to block rebinding directly but to resolve once, pin that exact IP, and connect to the pinned address rather than re-resolving the hostname. If your HTTP client doesn't support connecting to a fixed IP with the original Host header, you're stuck doing it at the socket layer.

Redirect-based bypass

This one is underused and very reliable. The validator checks your URL, sees a public host you control, approves it, fetches it. Your server returns:

HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/

If the fetcher follows redirects (most do, by default), it now requests the internal address, and crucially many implementations only validate the initial URL, not the redirect target. I've seen this beat otherwise-solid allow-lists more than once, because the team validated the user input rigorously and then handed the whole thing to a client with follow_redirects=True and forgot the redirect is also attacker-controlled.

Defense: disable redirect following, or re-run your SSRF validation on every hop including the final resolved IP. Setting max-redirects to zero is the simple version. If you need redirects for legitimate reasons, you have to validate each Location the same way you validated the input.

Allow-list bypasses

A proper allow-list (only fetch from api.partner.com) is much harder, and this is where most of my time goes on a mature target. The attacks:

Subdomain and suffix tricks if the match is sloppy. startsWith and unanchored regex are the gifts that keep giving:

http://api.partner.com.attacker.com/      # beats "starts with api.partner.com"
http://attacker.com/api.partner.com       # beats "contains api.partner.com"
http://api.partner.com@attacker.com/       # beats naive host extraction the other way

If api.partner.com itself has an open redirect or its own SSRF, you chain through it, the allow-list is satisfied and the partner host does the dirty work for you. Always check whether the allowed host has a redirect endpoint. It very often does.

And parser confusion again, with backslashes and tabs, because browsers and many clients normalize \ to / while validators treat it as a literal:

http://allowed.example.com\@attacker.com/
http://allowed.example.com\t@attacker.com/
https://attacker.com\@allowed.example.com/

The yield on these varies wildly by stack. Rather than hand-test forty variants, I declare the allowed host and the suspected filter behavior and let the generator produce the permutations that target that specific match logic. It will not tell you which one works, nothing can without hitting the target, but it gets you a tight candidate list instead of a shotgun.

Blind SSRF and exfiltration

Plenty of SSRF is blind: the request fires but you never see the response body. Don't write it off. You can still:

Use timing as an oracle. A connection to an open internal port returns fast, a filtered port hangs until timeout. Diff the response times and you're port scanning the internal network through the vulnerable host.

Reach services that act on a request without needing a readable response, the gopher-to-Redis cron-write being the canonical example. The server never shows you Redis's reply, but the cron job still gets written.

Exfiltrate through DNS when a service reflects data into a hostname you can observe via your authoritative nameserver. Slow, but it works when nothing else does.

The OOB callback is non-negotiable for blind work. interactsh or Collaborator, always running, so you catch the DNS lookup even when the HTTP request dies on arrival.

The fix that actually holds

Since this series is read by both sides: if you're defending, stop building blocklists. The only SSRF control I trust combines a few things. Resolve the hostname yourself, reject anything in private, loopback, link-local, or reserved ranges (and do it for every resolved A/AAAA record, not just the first). Pin the validated IP and connect to it directly so there's no second lookup to poison. Disable redirect following, or validate each hop. Drop schemes you don't need down to http and https only, which kills gopher, file, and dict in one move. And run the fetcher through an egress proxy or in a network namespace that physically cannot reach the metadata IP or your internal subnets, so a bypass at the application layer still hits a wall at the network layer. Defense in depth, because every single application-layer check above has a documented bypass.

Next: exploiting cloud metadata endpoints, where the link-local 169.254.169.254 address turns a blind fetch into stolen IAM credentials.

Related articles

A working SSRF payload list from real engagements: IP encoding tricks, allow-list bypasses, protocol smuggling, and the cloud metadata endpoints worth memorizing.
How SSRF turns into stolen cloud credentials through the 169.254.169.254 metadata endpoint. IMDSv1 vs IMDSv2, the GCP header requirement, Azure quirks, and why IMDSv2 still gets popped.