DNS Rebinding Attack Mechanics for MCP Skills: The Complete Lifecycle
DNS rebinding is not a theoretical vulnerability. It is a practical, weaponised attack class that has been exploited against home routers, internal dashboards, and developer tools for over a decade — and the same mechanics apply directly to every localhost MCP skill server running on a non-standard port. The attack fools the browser into treating an attacker’s domain as same-origin with your skill server, then uses that granted identity to read responses from private endpoints. Most MCP skill developers have never heard of it, and the default Express, Fastify, or ws configurations leave skill servers fully exposed. This post walks through the complete attack lifecycle, explains why commonly recommended mitigations are insufficient on their own, and shows the exact code required to close each gap.
Why non-standard ports are uniquely exposed
Chrome and Firefox implement the Private Network Access (PNA) specification, which blocks public internet pages from making requests to private IP addresses — including 127.0.0.1 and ::1 — without an explicit opt-in CORS preflight. On paper, this sounds like it should protect localhost skill servers entirely. In practice it does not, and the reason comes down to port numbers and DNS.
The browser’s same-origin policy defines an origin as the triple of scheme, host, and port. Two URLs are same-origin only if all three components match exactly. https://localhost:3456 and https://localhost:8765 are different origins; they cannot read each other’s responses. This is the protection you rely on: your skill server at http://localhost:3456 is a distinct origin from every website on the internet, so cross-origin requests from attacker websites cannot read your skill’s responses.
But Private Network Access protection is applied based on the IP address the browser resolves at the time of the request. When the browser sends a request to https://attacker.example.com, it resolves the domain to an IP address. If that IP is a public IP (say, 203.0.113.42), the browser treats the request as public-to-public and does not apply PNA restrictions. The key insight: PNA fires on the IP address of the destination, not the domain name. DNS rebinding exploits this by changing what IP address a domain resolves to after the browser has already classified the connection as public.
Ports 80 and 443 get additional protection from browser heuristics and operating system socket restrictions — most systems require elevated privileges to bind to them, and browsers apply special handling for well-known ports. But ports above 1024, where virtually every MCP skill server listens (:3456, :8765, :5173, :7654), have no such heuristic protection. The browser will happily make requests to http://some-domain.com:3456, and once DNS rebinding has completed, some-domain.com resolves to 127.0.0.1 — your skill server. The scheme+host+port origin of http://attacker.example.com:3456 becomes same-origin with http://localhost:3456 in the browser’s view, because both resolve to the same socket.
The core identity confusion: after a successful DNS rebind, the browser believes it is talking to attacker.example.com:3456, which now resolves to 127.0.0.1. From the browser’s perspective, the origin http://attacker.example.com:3456 is same-origin with the server running at http://localhost:3456 because both resolve to 127.0.0.1:3456. The browser cannot distinguish them. Same-origin becomes same as localhost after the rebind.
DNS TTL manipulation: the complete attack timeline
DNS rebinding works by manipulating a domain’s DNS record to first resolve to the attacker’s legitimate server, then rapidly switch to resolving to the victim’s local IP address after the browser has loaded the attacker’s page. The attack exploits a race condition between the browser’s DNS cache expiry and the attacker’s ability to change the DNS record.
| Time | Event | DNS resolves to |
|---|---|---|
| T+0s | Victim visits http://rebind.attacker.example.com:3456/ via phishing link, ad, or embedded iframe |
203.0.113.42 (attacker’s real server) |
| T+0s | Browser resolves DNS, connects to attacker’s server, loads malicious JavaScript page; page stays open in tab | 203.0.113.42 |
| T+1s | Attacker’s authoritative DNS server returned TTL=1; browser DNS cache entry expires after 1 second (though browsers enforce a floor around 60s) | 203.0.113.42 (cached) |
| T+60s | Browser DNS cache expires; JavaScript running in the open tab triggers a new same-origin fetch to the same host | Cache expired, re-resolves now |
| T+60s | Attacker’s DNS server now returns 127.0.0.1 for the same hostname with TTL=1 |
127.0.0.1 (victim’s loopback) |
| T+60s | Browser makes fetch('/api/tokens') to rebind.attacker.example.com:3456 — which now hits 127.0.0.1:3456 — and reads the response as same-origin |
127.0.0.1 |
| T+61s | JavaScript sends the stolen response body to https://exfil.attacker.example.com/collect |
N/A |
The TTL value of 1 second is deliberately chosen by the attacker. Browsers enforce a minimum effective TTL floor of approximately 60 seconds for most DNS records, so the attacker must wait for that window to pass. The malicious JavaScript page remains open in the victim’s browser tab, executing a polling loop in the background, waiting for the rebind to succeed. The payload is typically a simple setInterval that attempts a fetch to the skill’s known endpoint paths every few seconds and checks whether the response body looks like skill data rather than the attacker’s own server response.
Modern DNS rebinding toolkits — Singularity of Origin, rbndr.us, and others — fully automate this process. The attacker registers a domain, runs their own authoritative DNS server configured to alternate A records between their real IP and 127.0.0.1, and hosts a small JavaScript payload. No manual timing is required. Total setup time for an experienced attacker is under an hour, and the resulting attack infrastructure is reusable against any victim who visits the attack URL while running a vulnerable skill server.
The browser security model failure
At the moment the rebounded request completes, the browser holds two facts simultaneously that appear contradictory: the page’s origin is http://rebind.attacker.example.com:3456, and the server it just received a response from is running at 127.0.0.1:3456. These are not the same thing from an operator’s perspective — but the browser has no mechanism to distinguish them. The browser resolved the hostname to an IP address, made a TCP connection to that IP, and received an HTTP response. It cannot verify that the IP address it connected to was the IP address it “intended” to connect to, because the browser’s security model is built on origins (scheme + host + port), not on IP addresses at request time.
The browser cannot distinguish “legitimate localhost request” from “rebounded attacker request” once DNS resolves to 127.0.0.1. Both requests arrive at the same socket on the same port. Both carry the same set of HTTP headers. Both receive the same response. The Sec-Fetch-Site header says same-origin for a rebounded request, because from the browser’s perspective it is same-origin — the page at rebind.attacker.example.com is making a same-origin fetch to rebind.attacker.example.com. Every browser-side security mechanism that relies on the browser’s origin model is defeated by DNS rebinding, because the rebind makes the attacker’s origin appear to be the skill server’s origin.
The only server-side signal that distinguishes a legitimate request from a rebounded one is the Host header. A legitimate request from a browser to http://localhost:3456/ sends Host: localhost:3456. A rebounded request from the attacker’s page sends Host: rebind.attacker.example.com:3456. There is no other reliable server-side discriminator. The Host header is the only point of defence available to the server.
Why binding to 127.0.0.1 reduces but does not prevent DNS rebinding
A common mitigation recommendation is to bind your skill server to 127.0.0.1 rather than 0.0.0.0. This is correct and important, but it is insufficient on its own, and the distinction matters for understanding your actual residual exposure.
Binding to 0.0.0.0 makes the skill server listen on all network interfaces: the loopback interface (127.0.0.1), any Ethernet or Wi-Fi interfaces (e.g. 192.168.1.105), and any virtual or container interfaces. This exposes the skill server to every device on the local network. A separate machine on the same Wi-Fi network — or any device that can route to the skill server’s IP — can directly access the skill without any browser involvement. Binding to 127.0.0.1 closes this vector by restricting the socket to loopback traffic only. This is the correct baseline configuration.
But DNS rebinding does not require a remote machine to connect directly. It uses the victim’s own browser as a proxy. The victim’s browser runs on the victim’s machine. The victim’s machine is on the loopback interface. When the rebounded request fires, the browser on the victim’s machine makes an outbound TCP connection to 127.0.0.1:3456. That connection originates from the loopback interface. The skill server, bound to 127.0.0.1, accepts it — because the connection is coming from loopback, which is exactly the traffic it was configured to accept. Binding to 127.0.0.1 blocks remote machines; it does not block the victim’s own browser. DNS rebinding still works against 127.0.0.1-bound skill servers.
Bind to 127.0.0.1, not 0.0.0.0 — this eliminates the LAN exposure vector and is a mandatory baseline. But do not mistake it for a DNS rebinding defence. A skill server bound to 127.0.0.1 with no Host header validation remains fully vulnerable to any DNS rebinding attack executed via the victim’s own browser.
Host header allowlist construction
The Host header allowlist is the primary server-side defence against DNS rebinding. The principle: the server inspects every incoming request’s Host header and rejects any request whose Host value is not on an explicit allowlist. A rebounded request arrives with Host: rebind.attacker.example.com:3456; this is not on the allowlist; the server returns 400 Bad Request before processing the request body or executing any handler logic.
Constructing the allowlist correctly requires precision. The complete set of allowed Host values for a skill server on port PORT is:
localhost— without port suffix, for clients that omit the port when it matches the scheme default or for HTTP/1.0 compatibilitylocalhost:PORT— with port, the canonical HTTP/1.1 form127.0.0.1— IPv4 loopback without port127.0.0.1:PORT— IPv4 loopback with port::1— IPv6 loopback without port (used by some clients on IPv6-enabled systems)[::1]:PORT— IPv6 loopback with port (RFC 3986 requires brackets around IPv6 addresses in Host headers)
Critical implementation rules: reject requests with a missing Host header (HTTP/1.1 requires Host; its absence is suspicious and some attack tooling omits it to avoid detection); compare the full Host value against the allowlist, not just the extracted hostname; and never echo the rejected Host value back in the response body, to prevent header injection and information leakage:
import express from 'express';
const PORT = 3456;
const ALLOWED_HOSTS = new Set([
'localhost',
`localhost:${PORT}`,
'127.0.0.1',
`127.0.0.1:${PORT}`,
'::1',
`[::1]:${PORT}`,
]);
function validateHost(
req: express.Request,
res: express.Response,
next: express.NextFunction
): void {
const host = req.headers['host'];
// HTTP/1.1 requires Host; reject missing header
if (!host) {
res.status(400).json({ error: 'Missing Host header' });
return;
}
// Normalise: lowercase only; do NOT strip port before Set lookup
// A naive hostname-only check like host.split(':')[0] === 'localhost'
// is bypassable with Host: localhost:3456.attacker.example.com
const normalised = host.toLowerCase().replace(/\.$/, '');
if (!ALLOWED_HOSTS.has(normalised)) {
// Never echo the invalid host value — prevents header injection
res.status(400).json({ error: 'Invalid Host header' });
return;
}
next();
}
const app = express();
// Apply BEFORE all routes — this is the primary DNS rebinding defence
app.use(validateHost);
app.get('/api/tokens', (req, res) => {
// Unreachable unless Host is localhost or 127.0.0.1 at correct port
res.json({ tokens: getTokens() });
});
app.listen(PORT, '127.0.0.1');
One common bypass attempt to guard against: an attacker-controlled Host like localhost:3456.attacker.example.com. If you split the Host header on : and compare only the first part, this yields localhost — a false match. The allowlist approach using the full Host string value avoids this because localhost:3456.attacker.example.com is not in the set. Always compare the complete, normalised Host header value against a Set of exact expected strings.
WebSocket upgrade: the CORS preflight gap
CORS preflight is the mechanism by which browsers send an OPTIONS request before cross-origin requests that may be dangerous, allowing the server to reject them before any state-mutating request is made. For regular HTTP requests, this is an additional layer of defence. For WebSocket connections, it does not apply at all. The browser sends a GET request with an Upgrade: websocket header directly, without an OPTIONS preflight. There is no CORS layer for WebSocket upgrades.
Instead, the browser includes an Origin header in the upgrade request, containing the origin of the page that initiated the connection. For a legitimate local connection it will be http://localhost:3456. For a DNS-rebinded connection it will be http://rebind.attacker.example.com:3456. The server must validate this Origin header and reject upgrade requests from unexpected origins. The ws Node.js library exposes this through its verifyClient option:
import { WebSocketServer } from 'ws';
const PORT = 3456;
const ALLOWED_WS_ORIGINS = new Set([
`http://localhost:${PORT}`,
`http://127.0.0.1:${PORT}`,
]);
const ALLOWED_HOSTS = new Set([
'localhost',
`localhost:${PORT}`,
'127.0.0.1',
`127.0.0.1:${PORT}`,
'::1',
`[::1]:${PORT}`,
]);
// UNSAFE: accepts WebSocket connections from any origin
// const wss = new WebSocketServer({ port: PORT });
// SAFE: validates both Origin and Host before completing the upgrade
const wss = new WebSocketServer({
port: PORT,
host: '127.0.0.1', // Bind to loopback only
verifyClient: (info, callback) => {
const origin = info.origin ?? '';
const host = (info.req.headers['host'] ?? '').toLowerCase();
const originOk = ALLOWED_WS_ORIGINS.has(origin);
const hostOk = ALLOWED_HOSTS.has(host);
if (!originOk || !hostOk) {
// callback(false, code, message) — MUST call callback to avoid hang
// Forgetting to call callback(false) is equivalent to callback(true)
callback(false, 403, 'Forbidden: invalid origin or host');
return;
}
callback(true);
},
});
wss.on('connection', (ws, req) => {
// Only reachable after verifyClient passes both checks
ws.on('message', (data) => handleMessage(ws, data));
});
The ws library footgun: the verifyClient callback must be called on every code path. If your function returns without calling callback, the WebSocket handshake hangs silently until it times out. If you write callback(true) unconditionally at the end as a default fallback, every connection is accepted regardless of the origin check above. Structure your verifyClient so the rejection path calls callback(false, ...) explicitly, and the acceptance path is reached only after all checks pass.
Prometheus /metrics as a high-value rebinding target
The /metrics endpoint served by Prometheus client libraries — prom-client in Node.js, prometheus-client in Python — is one of the highest-value targets for DNS rebinding in real-world MCP skill deployments. Developers add Prometheus instrumentation to track request latency, queue depths, error rates, and custom business metrics. The endpoint is intended to be scraped by a Prometheus server on the same private network and is never meant to be publicly readable.
The problem is what accumulates in metric labels. Prometheus labels are key-value string pairs attached to metric samples, and skill developers frequently embed context-rich strings in labels to aid debugging. In practice this results in metric endpoints that expose far more than intended:
# HELP mcp_db_query_duration_seconds Duration of database queries
# TYPE mcp_db_query_duration_seconds histogram
mcp_db_query_duration_seconds_bucket{
query="SELECT * FROM users WHERE email='alice@corp.example.com'",
status="ok",le="0.1"} 47
# HELP mcp_api_errors_total Total API errors
# TYPE mcp_api_errors_total counter
mcp_api_errors_total{
error="sk-ant-api03-REDACTED: rate limit exceeded",
provider="anthropic"} 3
mcp_api_errors_total{
error="password authentication failed for user skilldb at db.internal.corp.com",
host="db.internal.corp.com:5432"} 1
# HELP mcp_config_info Runtime configuration snapshot
# TYPE mcp_config_info gauge
mcp_config_info{
GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx",
SLACK_WEBHOOK="https://hooks.slack.com/services/T.../B.../xxxx",
DB_URL="postgresql://skilldb:hunter2@db.internal:5432/skillprod"} 1
The particularly dangerous aspect of metrics exposure is persistence. Prometheus label values are scraped and stored by the Prometheus server, then forwarded to long-term storage systems like Thanos or Grafana Cloud. A leaked API key or database password that appears in a label value persists in the metrics store indefinitely, surviving the key rotation that was meant to remediate the exposure. An attacker who performs a successful DNS rebinding attack on the /metrics endpoint once may have captured credentials that remain valid for months in the metrics backend even after the skill server is patched.
The fix is a dedicated requireLocalhost middleware applied to the metrics route:
import express from 'express';
import { register } from 'prom-client';
const app = express();
function requireLocalhost(
req: express.Request,
res: express.Response,
next: express.NextFunction
): void {
// Re-use the host allowlist; combine with IP check for defence-in-depth
const host = (req.headers['host'] ?? '').toLowerCase();
const clientIp = req.socket.remoteAddress;
const hostAllowed = ALLOWED_HOSTS.has(host);
const ipAllowed = clientIp === '127.0.0.1' || clientIp === '::1' || clientIp === '::ffff:127.0.0.1';
if (!hostAllowed || !ipAllowed) {
res.status(403).end();
return;
}
next();
}
// Metrics route is gated by BOTH the global Host validation middleware
// (applied to all routes via app.use(validateHost)) AND this route-specific
// extra check that also validates the remote IP address
app.get('/metrics', requireLocalhost, async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
Additionally, review what you put in label values. Database query strings, error messages from upstream APIs, and any value derived from process.env should never appear in metric labels. Use label cardinality appropriately: label the query template (e.g. SELECT * FROM users WHERE email = $1) rather than the parameterised value; label the error type (e.g. rate_limit) rather than the error message string.
CORS wildcard: when DNS rebinding is not even required
DNS rebinding is the attack path when same-origin policy blocks direct cross-origin reads. But when a skill server sets Access-Control-Allow-Origin: *, same-origin policy is not blocking cross-origin reads at all — the server is explicitly granting every origin permission to read its responses. In that configuration, DNS rebinding is not needed. The attacker’s page can make a direct cross-origin fetch to http://localhost:3456/api/tokens and read the response without any DNS manipulation.
This is the cors({ origin: '*' }) pattern, and it is extraordinarily common in MCP skill codebases because the express cors package with default or minimal configuration applies a wildcard origin. The skill developer added CORS to fix a browser error during development, used the permissive default, and shipped it to production without understanding the implication:
// WRONG: wildcard CORS grants every origin permission to read responses
// DNS rebinding not required — direct cross-origin fetch works immediately
import cors from 'cors';
app.use(cors()); // default: Access-Control-Allow-Origin: *
app.use(cors({ origin: '*' })); // explicit wildcard: same effect
// Attack from any page on the internet:
// fetch('http://localhost:3456/api/tokens', { mode: 'cors' })
// .then(r => r.json())
// .then(data => exfiltrate(data))
// This works immediately — no DNS rebinding needed
// CORRECT: no CORS headers for a localhost-only skill server
// (the browser's same-origin policy already prevents cross-origin reads
// from pages at public internet origins; CORS headers would only loosen this)
// Simply: do not add a cors() middleware to a localhost-only skill server
A localhost skill server that is only accessed by the local Claude Desktop application or by the MCP host process does not need CORS at all. CORS headers allow browser pages to read cross-origin responses. If no browser page at a non-localhost origin should ever read your skill’s responses, the correct CORS configuration is to omit CORS headers entirely. Adding a wildcard CORS header bypasses the same-origin policy protection that would otherwise require an attacker to perform DNS rebinding, turning a complex 60-second attack into a trivial one-line fetch call.
SkillAudit’s AST-based detection algorithm
SkillAudit detects DNS rebinding exposure through static analysis of the skill’s source code using an AST (Abstract Syntax Tree) visitor. The detection logic targets four specific patterns in the SA-SKL-DNS family, each corresponding to a code structure that creates or fails to close a DNS rebinding attack surface:
| Pattern | AST signal | Severity |
|---|---|---|
DNS-001: app.listen on port > 1024 without Host validation middleware |
CallExpression where callee is app.listen and argument[0] is a numeric literal > 1024; no upstream app.use(validateHost) or equivalent in middleware chain |
CRITICAL |
DNS-002: WebSocketServer instantiation without verifyClient |
NewExpression where callee resolves to WebSocketServer or Server from ws; options object literal lacks a verifyClient property, or verifyClient property is a function that always calls callback(true) |
CRITICAL |
DNS-003: app.get('/metrics') without requireLocalhost-style guard middleware |
CallExpression where callee is app.get and argument[0] is string literal '/metrics'; route handler chain does not include a middleware function that references req.headers['host'] or req.socket.remoteAddress |
HIGH |
DNS-004: cors({ origin: '*' }) or cors() without explicit allowlist |
CallExpression where callee resolves to cors from cors package; either called with no arguments or with an options object whose origin property is the string literal '*' |
HIGH |
The detection logic for DNS-001 walks the middleware registration chain from the bottom up, looking for a use() call registered before the listen() call whose handler function contains a reference to req.headers['host'] and a call to res.status(400) or res.json({ error: ... }) on the negative path. A middleware that reads the Host header but does not reject on mismatch (logging-only middleware, for example) does not satisfy the detection condition.
The DNS-002 check also inspects the body of verifyClient functions. A function that contains only callback(true) or that calls callback(true) unconditionally before the origin check fires is flagged as equivalent to no verifyClient. This catches the common copy-paste error of adding verifyClient to satisfy a checklist but implementing it incorrectly.
Layering all four defences
The complete DNS rebinding defence for an MCP skill requires all four controls working together. None of them is sufficient alone:
- Bind to
127.0.0.1, not0.0.0.0. Eliminates LAN exposure. Does not prevent DNS rebinding. - Host header allowlist middleware applied to all routes before any handler. Primary defence against DNS rebinding for HTTP endpoints.
- WebSocket
verifyClientwith Origin and Host validation. Required separately because CORS preflight does not apply to WebSocket upgrades. - No wildcard CORS. Omit CORS headers entirely for localhost-only servers. Wildcard CORS makes DNS rebinding unnecessary by directly granting cross-origin read permission.
Optional but recommended for endpoints with sensitive data: an additional req.socket.remoteAddress check verifying the connection originates from 127.0.0.1 or ::1. This is defence-in-depth — the Host header check is the primary guard — but the IP check adds a second independent layer that is harder to bypass. An attacker would need to forge both the Host header (prevented by the allowlist) and the remote IP address (prevented by the TCP stack), which is not practically achievable.
For detailed pattern documentation and remediation code for each SA-SKL-DNS vulnerability, see the SA-SKL-DNS security patterns page. DNS rebinding connects to the broader network egress topic covered in SA-SKL-NET — skills that make outbound network calls face related SSRF vulnerabilities in addition to the inbound rebinding exposure. For a complete map of all 15 MCP skill vulnerability families, see the Claude skill security attack surface reference.
Scan your Claude skill for DNS rebinding vulnerabilities
SkillAudit detects missing Host header validation, CORS wildcards, WebSocket Origin checks, and exposed internal endpoints across the full SA-SKL-DNS family in 60 seconds.
Get your free audit