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:

  1. Attacker registers rebind.evil.com, configures their nameserver to return 1.2.3.4 (a legitimate public IP) for the first query with TTL=0.
  2. Your MCP server calls dns.resolve('rebind.evil.com') as part of URL validation — receives 1.2.3.4, passes the private IP check, allows the URL.
  3. 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.
  4. The nameserver returns 169.254.169.254 for 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:

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

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

CRITICAL −24fetch() called with user-supplied URL with no SSRF validation — confirmed reachability of 169.254.169.254 or 127.0.0.1
CRITICAL −22DNS rebinding window open: hostname resolved at validation, hostname passed to fetch() for re-resolution (not locked to resolved IP)
CRITICAL −20IPv4-only blocklist: IPv6 forms of blocked addresses (::1, ::ffff:127.0.0.1, ::ffff:a9fe:a9fe) pass SSRF check
HIGH −18Redirect following without re-validating redirect destination URL — redirect chain can reach private IP from a public initial URL
HIGH −16No network-layer SSRF block — application-level checks are the only defense; iptables / security group not configured to block 169.254.0.0/16
HIGH −14Only first DNS resolution result checked — if dns.resolve() returns multiple addresses, later addresses may include private IPs
MEDIUM −10No response size cap — SSRF to a slow/large internal endpoint can be used for denial of service or data exfiltration via timing
MEDIUM −8No connection timeout — hanging SSRF request consumes Node.js event loop resources; 10s timeout is the minimum threshold

Summary

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.