The most common question I get after writing about SSRF allowlists is some version of: "if I parse the URL, pull out the hostname, and check it against an allowlist before fetching, that's bulletproof, right?" The answer is no, and the reason is DNS rebinding. The attacker controls the hostname-to-IP mapping. Your validation happens at time T1, your fetch happens at time T2, and the answer to "what IP is this hostname?" can change between the two. The hostname stays allowlisted; the IP underneath it does not.
DNS rebinding is a TOCTOU (time-of-check-to-time-of-use) attack against the DNS resolver itself. The application checks the hostname, the resolver answers honestly at that instant, the application then asks the resolver again (or the HTTP client does, transparently), and a different answer comes back. Both answers are valid responses from the attacker's authoritative DNS for the same name; the attacker has just decided to flip the record between the two queries. There is no parser bug to fix and no allowlist entry to tighten. The bug is the assumption that DNS is stable across that window. This article is the variant deep-dive that the SSRF main article only summarises. I cover the network mechanics, why "good" allowlists fall to it, the modern tooling (Singularity et al.), realistic targets, the defences that actually work, and a worked safe-fetch pattern.
What is DNS rebinding?
DNS rebinding is the technique where an attacker who controls the authoritative DNS for a domain serves two different IP addresses for the same hostname, in sequence, in order to defeat a same-origin or same-host check. The classic shape is two DNS responses with a tiny TTL (often 0), where:
- The first response points the hostname at an IP the target trusts (a public IP the attacker controls, or an IP that passes whatever allowlist the application enforces).
- The second response, served seconds later, points the same hostname at an internal address:
127.0.0.1,169.254.169.254,10.0.0.5, anything reachable from the server but not from the public internet.
The application validates at step 1, fetches at step 2, and the fetch lands on the internal target. From the application's perspective every line of code did the right thing. The hostname passed the allowlist, the URL parsed cleanly, the HTTP client made one request. The attacker just rewired what the hostname meant in between.
The technique predates SSRF as a named category. The Stanford paper from 2007 framed it as a browser problem (bypass the same-origin policy by rebinding a hostname from the attacker's IP to the victim's internal IP). The server-side version is exactly the same trick aimed at a different validator: instead of a browser's same-origin check, it is the SSRF allowlist.
Why this works against "good" allowlists
The "secure" SSRF pattern that every blog post recommends looks roughly like this:
def safe_fetch(url):
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("bad scheme")
if parsed.hostname not in ALLOWLIST:
raise ValueError("host not allowed")
return requests.get(url)Read it once and it looks fine. Scheme is restricted, host is allowlisted, only then is the fetch made. The bug is the gap between parsed.hostname not in ALLOWLIST and requests.get(url). The first line never touched DNS. The second line does the DNS resolution itself, inside requests, and it asks the resolver fresh. If the attacker's name is on the allowlist and the record has TTL 0, the resolver's second answer is whatever the attacker wants it to be.
A few specific reasons this code path is fragile:
- The allowlist holds a hostname, not an IP. Hostnames are mutable. The IP the hostname pointed to a second ago is not the IP it points to now.
- The HTTP client re-resolves at connection time. Python
requests, Nodefetch, Gonet/http, libcurl, the JVM HTTP clients - all of them open a socket by hostname when given a URL with a hostname. They do not memoise the resolution the application did at validation time. - TTL
0is legal. DNS specifies TTL as the maximum cache lifetime (RFC 1035 §3.2.1), with0meaning "do not cache, ask again next time". Recursive resolvers honour it (with some practical caveats covered below). - The race window is generous. "Validate then fetch" is rarely sub-millisecond; even a few hundred milliseconds is plenty for a fresh DNS query to fire and resolve.
Some recursive resolvers enforce a minimum TTL (Unbound's cache-min-ttl, common defaults of 30 seconds or so on some ISP resolvers) which slows the rebind but does not prevent it. Production rebinding tools just keep retrying until the resolver coughs up the second record. The attack budget is "wait a minute then try again", which is fine.
Singularity and the modern attack tooling
In 2017, pulling off DNS rebinding meant standing up your own authoritative DNS, writing the zone file by hand, and timing the rebind manually. Today, NCC Group's Singularity of Origin (and several open-source competitors) automate the entire pipeline. You point it at a target, it spins up the authoritative DNS, hosts the rebinding web payload, manages the TTL flip, and runs the attack against the chosen internal IP. The barrier to launching DNS rebinding has dropped from "I need an ops weekend" to "I need a VPS and ten minutes".
The practical takeaway for anyone defending an application that fetches user-supplied URLs: assume rebinding is in the threat model. Anyone deciding to attack your SSRF surface in 2026 has a turnkey tool that will try this without breaking sweat. It is not exotic anymore.
Realistic targets
Rebinding is interesting because the attacker runs the DNS, but the resolved IP can be anything the server can reach. That is a large surface:
- Internal services on RFC1918 networks.
10.x.x.x,172.16.0.0/12,192.168.x.x. Internal admin panels, monitoring dashboards (Grafana, Prometheus), CI servers, internal HTTP APIs that authenticate via source-IP-allowlist or not at all. - Cloud metadata.
169.254.169.254for AWS / Azure / GCP. IMDSv2 mitigates the standard SSRF primitive (see the main SSRF article), but any workload still on IMDSv1, or any application that lets the attacker control headers in addition to the URL, is reachable from a rebound name as easily as from a hardcoded IP. - Localhost services.
127.0.0.1admin endpoints. The classic targets are unauthenticated control APIs that bind to loopback on the assumption that anything on loopback is trusted: language-runtime debuggers (pprof, Node inspector, JVM JMX), local Redis / memcached / etcd, dev-only management consoles left running in prod by mistake. - IoT and LAN devices (browser-side rebinding). Rebinding has a parallel life as a browser-side attack against home routers, Chromecasts, Philips Hue bridges, and smart-home hubs. The victim loads
attacker.comin a browser; the script triggers fetches toattacker.comafter the rebind; the rebind points at192.168.1.1or some hub's admin port; the browser, still thinking it is talking toattacker.com, sends authenticated fetches against the LAN device. Server-side SSRF and browser-side rebinding share the same DNS trick.
Wherever the application server has more network reach than the internet does, the rebind has a target.
Modern defences
The defences are the same family as the rest of the SSRF stack, but the emphasis is different. Anything that relies on validating a hostname is wrong; only things that validate the resolved IP, and pin that IP through the fetch, hold up.
Resolve once, validate the IP, connect to the IP literal. This is the canonical defence. Do the DNS resolution yourself, in the application, before the fetch. Validate the resulting IP against your deny-list (RFC1918, link-local, loopback, broadcast, IPv6 multicast, internal VPC ranges). Then open the HTTP connection to that IP directly, passing the original hostname as the Host header so TLS SNI and HTTP routing still work. The HTTP client never re-resolves, because you handed it an IP.
Block private ranges at the IP level, not the hostname level. Treat the deny-list as a property of the resolved address, not of the input string. A name that resolves to a public IP at validation time and a private IP at fetch time still has a private IP at fetch time, and the IP-level block catches it.
Pin the resolved address for the lifetime of the request. Most HTTP clients support either an explicit Resolver, a lookup callback, or a Dialer.Control hook that lets you supply a pre-resolved IP. Go's net.Dialer, Node's http.request with a custom lookup, Python urllib3 with a custom HTTPConnection that overrides socket.create_connection, libcurl's CURLOPT_RESOLVE. All of these let the application say "for this hostname, use this IP, do not ask DNS again". That closes the rebinding window.
Cache the validated resolution briefly, in the application. If the application resolves a hostname once and caches the validated IP for the duration of the operation (or a few minutes for a long-running fetcher), even a re-resolving HTTP client will not get a chance to be lied to. This is a coarser fix than IP pinning but it works when the HTTP client cannot be configured.
Enforce a minimum TTL on the resolver. A defence-in-depth measure: configure the local stub resolver (Unbound, dnsmasq, systemd-resolved) with a cache-min-ttl of, say, 60 seconds. The attacker's TTL 0 is overridden upward, and the rebind has to wait at least that long. Combined with one of the validation-side fixes above, it raises the cost meaningfully.
A worked safe-fetch pattern
Here is the validation-and-fetch pattern in Python, doing it right. Resolve once, validate the IP, then connect to the IP directly with the original hostname in the Host header.
import ipaddress
import socket
import urllib.parse
import urllib3
PRIVATE_NETS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
def is_internal(ip):
addr = ipaddress.ip_address(ip)
return any(addr in net for net in PRIVATE_NETS)
def safe_fetch(url, timeout=5):
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("scheme not allowed")
if parsed.username or parsed.password:
raise ValueError("userinfo not allowed")
host = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "https" else 80)
# Resolve ONCE. This IP is the one we will connect to.
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
ip = infos[0][4][0]
if is_internal(ip):
raise ValueError(f"resolved IP {ip} is internal")
# Connect to the IP literal, send the original Host header.
pool = urllib3.HTTPSConnectionPool(
ip,
port=port,
server_hostname=host, # TLS SNI
assert_hostname=host, # certificate name validation
)
return pool.request("GET", parsed.path or "/",
headers={"Host": host},
timeout=timeout)The shape is the load-bearing part, not the exact library calls. Resolve once. Validate the IP. Open the socket to that IP. Pass the hostname through only where the protocol genuinely needs it (TLS SNI, HTTP Host header). The HTTP client never gets to re-resolve, so there is no second DNS query to lie to.
Real-world incidents
DNS rebinding is not a theoretical class. A few representative incidents:
- Geth (Ethereum client) JSON-RPC rebinding (2018). Geth's JSON-RPC was reachable on
127.0.0.1:8545by default. A user browsing a malicious page got their wallet drained because the page rebinding-attackedlocalhost:8545and calledeth_sendTransactionagainst the local node. Multiple wallets reported losses; Geth tightened the default bind and host-header validation in response. - Plex Media Server (2018). Plex's local web admin interface, bound to a loopback or LAN address, was reachable via a rebinding attack from a browser. The advisory and patches went out the same year.
- Blizzard Battle.net Update Agent (2018, "Tavis Ormandy DNS rebinding"). Project Zero researcher Tavis Ormandy demonstrated DNS rebinding against the Blizzard updater's local HTTP service. Any visit to a malicious page could trigger updater control on the victim's machine. Blizzard shipped a host-header check as the fix.
- A series of smart-home and router disclosures (2019 onward). Researchers have repeatedly shown rebinding against home-router admin pages, Sonos, Chromecast, and various smart-home hubs. The pattern is identical in every case: the device exposes an unauthenticated HTTP control surface on the LAN, the rebind targets it from a browser, and the manufacturer eventually ships a host-header allowlist as the mitigation.
The thread connecting all of these is unauthenticated services binding to loopback or LAN on the assumption that "if you're on this interface you're trusted". Rebinding breaks that assumption. Either authenticate (always), or validate the Host header against an explicit allowlist (the LAN-side mitigation), or never expose state-changing endpoints on an interface where a rebound browser can reach them.
Where to go next
- The server-side request forgery deep dive is the parent article. Rebinding is one variant; the others (blind, cloud-metadata, wrapper schemes) live there.
- Blind SSRF covers the response-suppressed variant of the same primitive, which composes with rebinding: the attacker maps internal networks even without seeing response bodies.
- Cloud-metadata SSRF is the specific high-impact target where rebinding most often pays off, against any workload still allowing IMDSv1.
- Back up to the web application security vulnerabilities taxonomy for the full map.
Sources
Authoritative references this article was fact-checked against.
- Wikipedia, DNS rebindingen.wikipedia.org
- NCC Group, Singularity DNS rebinding toolgithub.com
- Stanford, Protecting browsers from DNS rebinding attacks (2007)crypto.stanford.edu





