DNS rebinding, explained
DNS rebinding turns a browser tab or a URL validator into a client on your private network. Why the same-origin policy misses it, and the two fixes that hold.
- security
- vibe coding
- engineering
DNS rebinding is a time-of-check to time-of-use bug hiding inside a system almost nobody thinks of as security-relevant: name resolution. You check a hostname, the check passes, and by the time you connect, the name means something else. The attacker did not bypass your validation. They waited for it to expire.
It shows up in two very different places, which is why it is confusing to read about. In the browser it is a same-origin policy bypass that lets a random web page talk to devices on your home or office network. On the server it is the race that defeats naive SSRF protection. Same mechanism, two victims.
The mechanism
The attacker controls a domain, so they control its DNS answers, including the TTL.
- A victim loads
https://attacker.example/. The name resolves to the attacker’s real public server, TTL set to zero or one second. - The page serves JavaScript that waits a moment and then requests
https://attacker.example/probe, its own origin. - The TTL has expired, so the browser resolves the name again. This time the
authoritative server answers
192.168.1.1, or127.0.0.1, or169.254.169.254. - The request goes to that address. The browser considers it same-origin, because the origin is the tuple of scheme, host name, and port, and the name never changed.
- The response comes back readable to the attacker’s JavaScript, which posts it to a server they control.
The same-origin policy did its job exactly as specified. The specification is written in terms of names, and the attacker changed the address underneath the name.
Why it matters more than it used to
Every device on a local network that runs an HTTP server and skips authorization “because it is only reachable from the LAN” is reachable from any web page a user on that LAN visits. That is the assumption rebinding deletes.
Brannon Dorsey’s 2018 research, Attacking Private Networks from the Internet with DNS Rebinding, demonstrated it against consumer hardware that was in millions of homes at the time, including Google Home, Roku, and Sonos devices, all of which exposed unauthenticated local HTTP control APIs. The vendors patched. The pattern did not go away, because the pattern is an architectural habit rather than a specific bug.
For anyone building AI-assisted apps, the modern versions of that habit are closer to home:
- A dev server bound to
0.0.0.0with no origin check, serving your source. - A local model runtime or vector database listening on a well-known port with no auth, because it is “localhost only.”
- An MCP server or agent tool endpoint on a fixed local port, exposing tools that read files or run commands.
- An admin panel on a private IP behind a VPN, with authorization delegated entirely to the network boundary.
Each of those is a public API to any browser tab open on the same machine, once an attacker owns a domain and a nameserver.
Testing it is not hard, which is part of the point.
rbndr.us, Tavis Ormandy’s public rebinding
service, encodes two IP addresses into a hostname and alternates between them,
so 7f000001.c0a80001.rbndr.us flips between 127.0.0.1 and 192.168.0.1 with
no infrastructure of your own. NCC Group’s
Singularity of Origin is the
full-featured version for authorized testing.
Defense one: stop trusting the network position
For anything that listens on a local or private address, the fix is not at the DNS layer. It is to stop treating “the request arrived on the loopback interface” as authentication.
Validate the Host header. A rebinding request arrives carrying the
attacker’s hostname, because that is the name the browser was told to use. Your
own clients never do that. This is the cheapest and most reliable check, and it
is a whole-class fix:
const ALLOWED_HOSTS = new Set([
"localhost:5173",
"127.0.0.1:5173",
"[::1]:5173",
]);
app.use((req, res, next) => {
if (!ALLOWED_HOSTS.has(req.headers.host ?? "")) {
return res.status(403).send("bad host");
}
next();
});
Note that this is an allowlist of exact host values including the port, not a
suffix match. A endsWith("localhost") check is defeated by
evil-localhost and by localhost.attacker.example.
Require a credential, even locally. A random token generated at startup and passed by your own client is enough. Rebinding gives an attacker the ability to make requests, not the ability to read a token out of a process they cannot see.
Require a non-simple request. An endpoint that only accepts
Content-Type: application/json plus a custom header forces a CORS preflight,
and the preflight is sent to the rebound address with the attacker’s origin,
where your (correct) CORS policy refuses it. This is a real barrier, but treat
it as depth rather than the primary control, since a GET that returns
sensitive data has no preflight to protect it. The related failure mode is its
own post: CORS misconfiguration.
Browsers are closing the gap from their side too. Chrome’s Private Network Access adds a preflight to requests that go from a public origin to a private or loopback address, which targets exactly this attack. It is a meaningful layer and it is not yours to rely on: rollout has been slow, it does not cover every browser your users run, and a device that ships in a factory today will still be running its current firmware in five years.
Defense two: pin the address, server side
The server-side version of rebinding is the reason a URL validator cannot be
written as a string check. You resolve webhook.customer.example, see a public
address, approve it, and then hand the hostname to your HTTP client, which
resolves it again and gets 10.0.0.5.
The fix is to make the check and the connection use the same value:
const answers = await dns.lookup(url.hostname, { all: true });
// Reject if ANY answer is non-public: a name can return one of each.
for (const { address } of answers) {
if (!isPublic(address)) throw new Error("non-public address");
}
// Connect to this address. Not to url.hostname. This is the whole fix.
return { address: answers[0].address, host: url.hostname, port };
Our own outbound webhook dispatcher does this in
src/services/webhook_dispatch.rs, because “POST to a URL the customer typed”
is SSRF as a product requirement. It pins the validated address, disables
redirect following (a redirect is a new destination and would need the whole
check again), and re-validates on every delivery attempt, since DNS at
attempt three is not DNS at attempt one. The full ordering is in
the SSRF post.
A tempting alternative is to cache the resolution for longer than the TTL asks, which is what browsers call DNS pinning. It narrows the window rather than closing it, because an attacker who can make your process drop the connection can usually make it re-resolve. Pinning the address for the specific request you are about to make is a different thing, and it is the one that works.
How to tell if you have it
Three questions, in order of how often the answer is bad:
- Does any service you run answer requests carrying a
Hostheader you have never issued? Trycurl -H 'Host: evil.example' http://localhost:PORT/against every local port you have open. A normal response means yes. - Does any internal service treat the source address as authorization? If removing the firewall would expose data, the firewall was the authorization.
- Does any code path resolve a hostname for validation and then pass the hostname, rather than the address, to a client? That is the server-side race, and it is invisible in a code review that only reads the validator.
Where it fits
Rebinding is worth understanding beyond its own remediation, because it is the clearest example of a category error that shows up everywhere in access control: validating an identifier instead of the thing the identifier points at. It is the same shape as checking a filename and then opening a symlink, or checking a token’s claims and then loading a record by a different key, or the time-of-check races that show up in balance and quota logic.
Check the thing you are about to use. Then use the thing you checked.