SSRF Security · June 2026 · 16 min read
MCP Server SSRF Advanced Patterns: DNS Rebinding Chains, Cloud Metadata Exploitation, IPv6 Bypass, and TOCTOU in URL Validation
Basic SSRF prevention — blocking 127.0.0.1 and 10.0.0.0/8 in a URL allowlist — stopped being sufficient years ago. Sophisticated attackers have four techniques that bypass allowlist-only defenses: DNS rebinding to swap a public IP for a private one after validation passes, IPv6 notation variants that encode private addresses in unexpected forms, cloud metadata service exploitation via path confusion on link-local addresses, and the TOCTOU (time-of-check time-of-use) race between DNS resolution at validation time and DNS re-resolution at fetch time. This post covers each attack in depth and closes them permanently.
Why MCP servers have a uniquely wide SSRF surface
An MCP server's core value proposition — executing tool calls that reach external systems — is precisely the capability that SSRF exploits. When a fetchPage tool takes a URL from an LLM prompt, the attacker's payload delivery mechanism is not a form field or an API request: it is the LLM itself, manipulated by attacker-controlled content in earlier tool results.
The four SSRF attack classes that defeat naive defenses:
DNS rebinding
Attacker-controlled domain resolves to a public IP on the first DNS query (passing your allowlist check), then re-resolves to 169.254.169.254 on the fetch. TTL = 0 makes the browser/Node DNS cache flush between validate and fetch.
Cloud metadata exploitation
AWS IMDS v1 at 169.254.169.254 returns IAM role credentials without any authentication. A single SSRF to /latest/meta-data/iam/security-credentials/ yields a rotating AWS key with the instance's permissions.
IPv6 notation bypass
http://[::1]/, http://[::ffff:127.0.0.1]/, http://[0:0:0:0:0:ffff:7f00:1]/, and http://[::ffff:7f00:1]/ all route to localhost. Regex-only allowlists that match IPv4 octets miss every IPv6 encoding.
TOCTOU race
Node's dns.resolve() at validation time returns the real IP; by the time fetch() calls the OS resolver, the DNS TTL has expired and a new attacker-controlled record returns 10.0.0.1. The window is milliseconds but reliably exploitable with scripted DNS.
Pattern 1: DNS rebinding chains
DNS rebinding is the most underestimated SSRF vector because it defeats the most common "fix": resolving the URL's hostname before fetching and checking the resolved IP against a blocklist. Here's why that approach still fails.
The basic rebinding setup:
- Attacker registers
rebind.evil.com, configures their nameserver to return1.2.3.4(a legitimate public IP) for the first query with TTL=0. - Your MCP server calls
dns.resolve('rebind.evil.com')as part of URL validation — receives1.2.3.4, passes the private IP check, allows the URL. - Node's DNS cache respects TTL=0 — the record is not cached. When
fetch('http://rebind.evil.com/')runs, the OS resolver queries the nameserver again. - The nameserver returns
169.254.169.254for the second query. The fetch reaches AWS IMDS.
The cache is the vulnerability. If you resolve once and then hand the hostname to fetch(), Node will re-resolve. The only safe pattern is resolving once and then connecting to the IP address, never re-querying the hostname. This requires overriding the DNS lookup behavior in your HTTP client.
Closing DNS rebinding: resolve once, connect by IP, pin TTL
import dns from 'node:dns/promises';
import net from 'node:net';
const BLOCKED_CIDRS = [
// RFC 1918 private
{ cidr: '10.0.0.0/8', base: 0x0a000000, mask: 0xff000000 },
{ cidr: '172.16.0.0/12', base: 0xac100000, mask: 0xfff00000 },
{ cidr: '192.168.0.0/16', base: 0xc0a80000, mask: 0xffff0000 },
// Link-local / APIPA
{ cidr: '169.254.0.0/16', base: 0xa9fe0000, mask: 0xffff0000 },
// Loopback
{ cidr: '127.0.0.0/8', base: 0x7f000000, mask: 0xff000000 },
// Multicast
{ cidr: '224.0.0.0/4', base: 0xe0000000, mask: 0xf0000000 },
// Broadcast
{ cidr: '255.255.255.255/32', base: 0xffffffff, mask: 0xffffffff },
];
function ipv4ToInt(ip) {
return ip.split('.').reduce((acc, octet) => (acc << 8) | parseInt(octet, 10), 0) >>> 0;
}
function isBlockedIPv4(ip) {
const n = ipv4ToInt(ip);
return BLOCKED_CIDRS.some(({ base, mask }) => (n & mask) === base);
}
function isBlockedIPv6(ip) {
// Normalize IPv4-mapped IPv6 addresses: ::ffff:127.0.0.1 → 127.0.0.1
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (mapped) return isBlockedIPv4(mapped[1]);
// Block loopback and link-local IPv6
const normalized = ip.toLowerCase();
return (
normalized === '::1' ||
normalized.startsWith('fe80:') || // link-local
normalized.startsWith('fc00:') || // unique local
normalized.startsWith('fd') // unique local
);
}
async function safeResolveUrl(urlString) {
let parsed;
try {
parsed = new URL(urlString);
} catch {
throw new Error('SSRF_INVALID_URL');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('SSRF_DISALLOWED_PROTOCOL');
}
const hostname = parsed.hostname;
// Reject bare IP addresses that are in blocked ranges immediately
// (before DNS — no DNS query needed for literal IPs)
if (net.isIPv4(hostname)) {
if (isBlockedIPv4(hostname)) throw new Error('SSRF_BLOCKED_IP');
return { resolvedIp: hostname, port: parsed.port || (parsed.protocol === 'https:' ? '443' : '80') };
}
if (net.isIPv6(hostname)) {
const bare = hostname.replace(/^\[|\]$/g, '');
if (isBlockedIPv6(bare)) throw new Error('SSRF_BLOCKED_IP');
return { resolvedIp: hostname, port: parsed.port || (parsed.protocol === 'https:' ? '443' : '80') };
}
// Resolve DNS — use the FIRST address returned, check every address
const addresses = await dns.resolve(hostname);
for (const addr of addresses) {
if (net.isIPv4(addr) && isBlockedIPv4(addr)) throw new Error('SSRF_BLOCKED_IP');
if (net.isIPv6(addr) && isBlockedIPv6(addr)) throw new Error('SSRF_BLOCKED_IP');
}
// Return the first safe address for caller to use directly
return {
resolvedIp: addresses[0],
port: parsed.port || (parsed.protocol === 'https:' ? '443' : '80'),
hostname, // kept for SNI / Host header
};
}
// Connect by IP, set Host header manually to prevent re-resolution
async function safeFetch(urlString, options = {}) {
const { resolvedIp, port, hostname } = await safeResolveUrl(urlString);
const parsed = new URL(urlString);
// Replace hostname with the resolved IP in the fetch URL
// This prevents Node's fetch() from calling DNS again
const fetchUrl = `${parsed.protocol}//${resolvedIp}:${port}${parsed.pathname}${parsed.search}`;
return fetch(fetchUrl, {
...options,
headers: {
...options.headers,
Host: hostname || resolvedIp, // Preserve original Host for virtual hosting
},
signal: AbortSignal.timeout(10_000),
});
}
The key defense: after resolving the hostname to an IP, construct the fetch URL with the literal IP address — not the hostname. Node's fetch() never calls the OS resolver again for a literal IP, so the rebinding window is closed. The original hostname is sent in the Host header for TLS SNI and virtual hosting, but DNS is only consulted once.
TTL pinning: Even with the IP-pinning approach above, consider enforcing a minimum TTL floor of 60 seconds if you cache resolved IPs for reuse across requests. Attackers can configure nameservers to return TTL=0 specifically to defeat client-side caches, so always clamp cached TTLs to Math.max(ttl, 60) before storing.
Pattern 2: Cloud metadata service exploitation
Every major cloud provider runs an Instance Metadata Service (IMDS) at a link-local address that returns credentials, instance identity, and configuration — with no authentication by default on the v1 API.
| Provider | IMDS address | Credential path | v2 protection |
|---|---|---|---|
| AWS | 169.254.169.254 |
/latest/meta-data/iam/security-credentials/ |
IMDSv2 requires PUT token first (can be forced) |
| GCP | 169.254.169.254, metadata.google.internal |
/computeMetadata/v1/instance/service-accounts/default/token |
Requires Metadata-Flavor: Google header |
| Azure | 169.254.169.254 |
/metadata/identity/oauth2/token?api-version=2018-02-01 |
Requires Metadata: true header |
| DigitalOcean | 169.254.169.254 |
/metadata/v1/ |
None — unauthenticated v1 only |
AWS IMDSv1 is the most exploitable because it requires no special headers:
# AWS IMDSv1 — no auth header required # Step 1: list available IAM roles curl http://169.254.169.254/latest/meta-data/iam/security-credentials/ # Step 2: retrieve credentials for a role (e.g. "my-ec2-role") curl http://169.254.169.254/latest/meta-data/iam/security-credentials/my-ec2-role # Returns: AccessKeyId, SecretAccessKey, Token, Expiration # AWS IMDSv2 — requires PUT token first (but a server with PUT SSRF = full bypass) TOKEN=$(curl -X PUT http://169.254.169.254/latest/api/token \ -H "X-aws-ec2-metadata-token-ttl-seconds: 21600") curl -H "X-aws-ec2-metadata-token: $TOKEN" \ http://169.254.169.254/latest/meta-data/iam/security-credentials/
IMDSv2 does not eliminate SSRF risk if your MCP server can be forced to send PUT requests. A fetchPage tool that allows arbitrary HTTP methods can be chained: first a PUT to obtain the IMDSv2 token, then a GET with the token to retrieve credentials. Enforce GET-only for fetch tools, and block 169.254.0.0/16 at the network layer (VPC security groups / iptables) in addition to application-level checks.
The metadata.google.internal hostname is also a problem — it's a GCP-specific internal DNS name that resolves to 169.254.169.254 inside GCP VMs. Your IP-based SSRF check won't catch a fetch to http://metadata.google.internal/ until after DNS resolution. Mitigation: resolve all hostnames before checking, as shown in safeResolveUrl above — metadata.google.internal resolves to 169.254.169.254, which is in the blocked 169.254.0.0/16 range.
Network-layer defense (required alongside application checks)
# Block IMDS access from the MCP server process at the iptables level # This is defense-in-depth: even if application SSRF checks are bypassed, # the kernel drops packets destined for link-local # Block outbound to link-local (IMDS on all major clouds) iptables -A OUTPUT -d 169.254.0.0/16 -j DROP ip6tables -A OUTPUT -d fe80::/10 -j DROP # If your MCP server runs in a Docker container: # Use --network=none and explicit bridge rules, or # Use Docker's built-in IMDS blocking (metadata-proxy=false in daemon.json) # AWS-specific: enforce IMDSv2 at the instance level (revokes v1 API) aws ec2 modify-instance-metadata-options \ --instance-id i-XXXX \ --http-tokens required \ --http-put-response-hop-limit 1
Pattern 3: IPv6 notation bypass
Regex-based URL allowlists that check for RFC 1918 IPv4 ranges (10\.x\.x\.x, 192\.168\.x\.x, 172\.(1[6-9]|2[0-9]|3[01])\.x\.x) and loopback (127\.x\.x\.x) miss every IPv6 encoding of the same addresses.
All of the following URLs route to localhost or link-local on a dual-stack host:
# Standard IPv6 loopback http://[::1]/admin # IPv4-mapped IPv6 (the :: prefix maps to IPv4) http://[::ffff:127.0.0.1]/admin http://[::ffff:7f00:1]/admin # hex form: 0x7f=127, 0x00=0, 0x00=0, 0x01=1 http://[0:0:0:0:0:ffff:127.0.0.1]/admin # Link-local IMDS — same attack as IPv4 http://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/ http://[::ffff:a9fe:a9fe]/latest/meta-data/iam/security-credentials/ # Private RFC 1918 in IPv6 mapped form http://[::ffff:10.0.0.1]/internal-api http://[::ffff:192.168.1.1]/admin # Unique local addresses (fc00::/7) http://[fc00::1]/ http://[fd12:3456:789a::1]/
The safe detection approach is not regex on the URL string but rather parsing the address through a proper IP library after extracting it from the URL:
import { isIPv4, isIPv6 } from 'node:net';
function normalizeAndCheckIP(rawAddress) {
// Strip brackets from IPv6 literals in URLs
const addr = rawAddress.replace(/^\[|\]$/g, '');
if (isIPv4(addr)) {
return isBlockedIPv4(addr);
}
if (isIPv6(addr)) {
return isBlockedIPv6(addr);
}
// Not a bare IP — must be a hostname, needs DNS resolution
return false; // caller must resolve before checking
}
// In isBlockedIPv6: handle all IPv4-in-IPv6 representations
function isBlockedIPv6(addr) {
const lower = addr.toLowerCase();
// IPv4-mapped: ::ffff:a.b.c.d
const mappedDotted = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mappedDotted) return isBlockedIPv4(mappedDotted[1]);
// IPv4-mapped: ::ffff:hhhh:hhhh (hex form)
const mappedHex = lower.match(/^(?:0*:)*:?ffff:([0-9a-f]+):([0-9a-f]+)$/);
if (mappedHex) {
const a = parseInt(mappedHex[1], 16);
const b = parseInt(mappedHex[2], 16);
const ipv4 = `${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`;
return isBlockedIPv4(ipv4);
}
// Loopback
if (lower === '::1') return true;
// Link-local (fe80::/10)
if (/^fe[89ab][0-9a-f]:/.test(lower)) return true;
// Unique local (fc00::/7: fc or fd prefix)
if (/^f[cd]/.test(lower)) return true;
return false;
}
Use a library, not hand-rolled regex. IPv6 has 8 groups, each up to 4 hex digits, with :: zero-expansion, uppercase/lowercase variants, and mixed IPv4-in-IPv6 notations. A hand-rolled checker will miss at least one variant. The Node.js built-in net.isIPv4() and net.isIPv6() handle normalization; after confirming it's an IP, use the numeric comparison functions above rather than string matching.
Pattern 4: TOCTOU in URL validation
TOCTOU (time-of-check time-of-use) is a race condition that appears in the gap between validating a URL and using it. Even a correct IP-checking implementation can be defeated if the resolved IP is not locked to the subsequent connection.
The standard vulnerable pattern looks like this:
// VULNERABLE: resolve at check time, re-resolve at fetch time
async function fetchUrlVulnerable(urlString) {
const { hostname } = new URL(urlString);
// Check at T=0: dns.resolve() returns 1.2.3.4 (a public IP)
const [firstAddress] = await dns.resolve(hostname);
if (isBlockedIPv4(firstAddress)) throw new Error('SSRF blocked');
// Some processing time passes... TTL=0 DNS cache flushes...
// Fetch at T=N: fetch() calls OS resolver again.
// If DNS TTL=0, nameserver returns 169.254.169.254 on this query.
// The blocked IP check already passed — this fetch goes through.
return fetch(urlString); // ← fetches from IMDS, not 1.2.3.4
}
The window is smaller than it sounds but larger than it needs to be. The time between dns.resolve() and fetch() includes at minimum:
- Any awaited async operations in between (database lookups, logging, rate-limit checks)
- The JavaScript event loop turn itself
- Node's internal fetch setup and TLS handshake initiation
With a TTL=0 DNS record, an attacker can script their nameserver to return the real IP for the first N queries and switch to the private IP for query N+1. By controlling the server-side query timing, they can reliably hit the window.
Closing TOCTOU: connect to the resolved IP, not the hostname
The fundamental fix is the same as for DNS rebinding: after resolving, connect to the IP address directly, never re-resolve the hostname. The implementation from Pattern 1 (safeFetch) already closes this gap by constructing the fetch URL with the literal resolved IP. But there is a subtlety with Node's fetch() and HTTPS: the Host header and TLS SNI must still use the original hostname for virtual hosting and certificate verification.
// Node 18+ fetch with manual DNS lookup override
// This is the most robust approach — overrides Node's internal resolver
// so that fetch() itself never calls DNS for this request.
import { createConnection } from 'node:tls';
import http from 'node:https';
async function safeFetchWithDnsLock(urlString, options = {}) {
const { resolvedIp, port, hostname } = await safeResolveUrl(urlString);
const parsed = new URL(urlString);
return new Promise((resolve, reject) => {
const req = http.request({
hostname: resolvedIp, // connect to the IP — no re-resolution
port: parseInt(port) || 443,
path: `${parsed.pathname}${parsed.search}`,
method: options.method || 'GET',
headers: {
...options.headers,
Host: parsed.hostname, // correct Host header for virtual hosting
},
servername: parsed.hostname, // SNI: use original hostname for TLS cert check
timeout: 10_000,
}, (res) => {
let body = '';
let size = 0;
res.on('data', chunk => {
size += chunk.length;
if (size > 4 * 1024 * 1024) {
req.destroy();
reject(new Error('SSRF_RESPONSE_TOO_LARGE'));
} else {
body += chunk;
}
});
res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('SSRF_TIMEOUT')); });
req.end();
});
}
Undici and got have lookup overrides. If you use undici (Node's built-in fetch implementation) or got, they expose a lookup option that overrides the DNS resolution function for the entire request lifecycle. Pass a custom lookup that calls safeResolveUrl() and caches the result for subsequent lookups within the same request — this is cleaner than the manual http.request approach above and handles redirects safely.
Defense checklist for MCP server fetch tools
- Resolve the URL hostname once via
dns.resolve()before fetching; check every returned address against blocked CIDRs, not just the first - Connect to the resolved IP directly (pass IP to
hostnameoption or construct the fetch URL with the literal IP); setHostheader to original hostname for virtual hosting - Block: RFC 1918 private (10/8, 172.16/12, 192.168/16), link-local (169.254/16, fe80::/10), loopback (127/8, ::1), unique local (fc00::/7), multicast (224/4), and broadcast (255.255.255.255)
- Handle IPv4-mapped IPv6 addresses (
::ffff:127.0.0.1,::ffff:7f00:1) in addition to bare IPv6 loopback (::1) - Enforce a minimum DNS cache TTL floor of 60s even if the upstream record says TTL=0
- Enforce HTTP and HTTPS protocols only; reject
file://,ftp://,gopher://,data:// - Set a response size cap (4 MB) and a connection timeout (10s) to prevent hang-based DoS
- Block outbound to link-local at iptables / VPC security group level as defense-in-depth (catches bypasses your application logic misses)
- On AWS: enforce IMDSv2 with
--http-tokens requiredand--http-put-response-hop-limit 1at the instance level - Reject all HTTP methods except GET for fetch tools; a PUT-capable fetch tool can obtain IMDSv2 tokens
- Follow redirects only after re-validating the redirect destination URL through the same SSRF check
Redirect chaining: the SSRF vector most implementations forget
When your safeFetch follows a redirect, the initial URL passes validation but the redirect destination may not. The attacker's server at https://attacker.com/redirect responds with 302 Location: http://169.254.169.254/latest/meta-data/. If your fetch client follows redirects automatically and doesn't re-run SSRF checks on the redirect destination, IMDS is reached via the redirect.
async function safeFetchWithRedirects(urlString, options = {}, maxRedirects = 3) {
let currentUrl = urlString;
let redirectCount = 0;
while (redirectCount <= maxRedirects) {
const result = await safeFetchWithDnsLock(currentUrl, {
...options,
redirect: 'manual', // Never follow redirects automatically
});
if ([301, 302, 303, 307, 308].includes(result.status)) {
const location = result.headers.location;
if (!location) throw new Error('SSRF_REDIRECT_NO_LOCATION');
if (redirectCount >= maxRedirects) throw new Error('SSRF_TOO_MANY_REDIRECTS');
// Re-validate the redirect destination through safeResolveUrl()
// This re-checks the new URL's IP against all SSRF rules
await safeResolveUrl(location); // throws if blocked
currentUrl = location;
redirectCount++;
} else {
return result;
}
}
}
// Note: after the safeResolveUrl() check for the redirect destination,
// the actual fetch of that destination still uses safeFetchWithDnsLock()
// on the next iteration — so it connects by IP, not re-resolved hostname.
SkillAudit findings for SSRF in MCP servers
169.254.169.254 or 127.0.0.1dns.resolve() returns multiple addresses, later addresses may include private IPsSummary
SSRF in MCP server fetch tools has a much wider attack surface than a simple private-IP blocklist addresses. The four advanced patterns — DNS rebinding, cloud metadata service exploitation, IPv6 notation bypass, and TOCTOU between validation and fetch — each represent a distinct bypass category that requires a specific countermeasure.
The single most impactful fix is also the simplest conceptually: connect to the IP address you verified, not the hostname you checked. Pass the resolved IP to your HTTP client directly, keep the original hostname only for the Host header and TLS SNI. This closes DNS rebinding and TOCTOU simultaneously. Add IPv6-aware IP checks (including IPv4-in-IPv6 forms), enforce network-layer blocks for IMDS addresses, and re-validate all redirect destinations — and SSRF moves from your CRITICAL findings list to a hardening footnote.
SkillAudit checks all four of these SSRF patterns automatically. See a sample audit report or run a free audit on your MCP server.