换了 IP 还是被关联:WebRTC 泄露本地 IP 的三种触发路径

一、先纠一个时差:你搜到的那些教程,大部分写于问题最严重的窗口

搜「WebRTC 泄露本地 IP」,你会得到非常多的结果,而且说法高度一致:创建一个 RTCPeerConnection,页面就能读到你的内网 IP 192.168.x.x,所以必须关掉 WebRTC 或者装防护扩展。

这套说法在 2015 到 2019 年间是准确的。今天它过期了。

时间线是这样的:2019 年 8 月,Chrome 76 起默认开启了一项改动------ICE 候选里的内网 IP 不再原样交给页面,而是替换成一个随机生成的 .local 主机名(mDNS 混淆)。Google 在官方 PSA 里给出的替换样例长这样:

复制代码
改之前: candidate:1 1 udp 2122262783 192.168.1.42 54596 typ host
改之后: candidate:1 1 udp 2122262783 1f4712db-ea17-4bcf-a596-105139dfd8bf.local 54596 typ host

随后 Safari、Edge、Opera、Brave、Firefox 陆续跟上,今天主流浏览器基本都默认如此。

但这份官方 PSA 里同时写明了两条例外,而这两条例外,才是 2026 年真正还在生效的东西。

绝大多数教程停在了 2019 年之前,所以它们教的是一个已经被堵住的口子,而真正开着的那两个,没人讲。

二、ICE 候选到底在交出什么

浏览器要跟另一台机器直接通话(视频、语音、P2P 传文件),得先搞清楚"自己有哪些地址可以被连到"。这个自我盘点的过程叫 ICE 候选收集 ,盘出来的东西通过 onicecandidate 事件原样交给页面 JavaScript------没有弹窗、不需要你同意摄像头、甚至不需要真的发起通话。

四类候选:

类型 是什么 从哪来 隐私意味
host 本机网卡地址 直接读本机网络接口 内网拓扑 + 一块稳定的内网标识
srflx STUN 反射出的公网地址 向外部 STUN 服务器发包,服务器把你"从外面看起来"的地址回传 真正的公网出口地址,可能不是你以为的那个
prflx 连通性检查中临时发现的地址 从没在候选列表里的来源发来检查包 罕见,历史上有过利用案例
relay TURN 中继服务器地址 中继服务的地址 是服务器的地址,不是你的,风险低

关键点在于:默认受众读到的"泄露"教程只讲了 host 这一行。 而 srflx 那一行的地址,才是今天最常被漏掉的一项------因为它依赖的不是浏览器设置,而是你的网络隧道怎么走。

三、三条到今天还在生效的触发路径

路径一:媒体权限让 mDNS 当场失效

这是 Google 官方 PSA 里白纸黑字写明的第一条例外:

该特性对除以下情况外的所有站点生效------已取得 getUserMedia 权限的站点,这类站点被假定拥有更高程度的用户信任。

也就是说:如果一个站点拿到了你的摄像头或麦克风权限,它在 mDNS 保护之外 。同一个浏览器、同一个网络,在没授权的页面上读到的是 .local 假名,在已授权的页面上读到的是 192.168.1.105 本尊。

这件事的实际后果比听起来大。想想哪些页面会拿到媒体权限:做过视频客服身份核验的、参加过浏览器内语音/视频会议的、用过浏览器端面试系统的。这类页面看到的内网地址,跟其他页面看到的不一样。

再往前推一步。mDNS 是一种"返回一个被转换过的值"的防御,而不是"什么都不返回"。凡是这种设计的防线,都存在被反演的可能。2021 年就有一例:Peer5 的研究者发现,可以通过让 IPv4 流量走一个绑定为 IPv6 的 socket,触发内核把 IPv4 地址编码成 ::ffff:192.168.x.x 的形式,而当时的候选净化逻辑没有处理这种情况,于是真实内网 IP 又被读了出来。这个漏洞几天内就被修掉了。

我把这段写出来不是为了讲攻防,是为了说明一件事:这一层的保护是有条件的、可被特例绕开的,它不是一个可以默认信赖的开关。 这和我在第 1 篇里说 Canvas 的道理是一致的。

路径二:STUN 反射根本没走你的隧道(这条才是大头)

这是 2026 年最值得关注的一条,也是「换了 IP 还是被关联」最常见的真实成因。

区别在协议层:

你的工具形态 HTTP/HTTPS 流量 STUN(UDP)流量 结果
全设备 VPN 客户端(原生 App) 走隧道 走隧道 STUN 只能看到隧道出口
浏览器扩展式 VPN 走代理 不走 STUN 发出去的是真实出口 IP
SOCKS / HTTP 代理 走代理 通常不走 同上
系统代理配置 走代理 通常不走 同上

原因很单纯:代理接管的是 TCP,而 STUN 用的是 UDP。 一个只配了 HTTP 代理或浏览器扩展的环境,网页请求老老实实走了代理,但 STUN 那一个 UDP 包是直接对着本机默认网卡出去的。页面读到的 srflx 候选,因此是你真实的运营商公网地址。

于是出现了那个典型困境:你在页面上查IP,查到的是代理 IP;但平台在候选列表里看到的,是你真实的那一个。 你换的 IP 只换了 HTTP 那一层。

IPv6 是同类问题的变体------不少隧道只接管 IPv4,IPv6 候选照样从真实出口出去。

路径三:mDNS 在特定网络环境下降级,以及残留字段

mDNS 依赖链路层组播,也就是说它假设"同一网段内的机器能互相听见"。在以下几类环境里它会失效或表现不一致:

  • 企业网络、酒店/公共网络里组播被上游过滤
  • 容器、虚拟机、云桌面里的虚拟网卡拓扑
  • 多网卡环境(有线 + 无线 + VPN 适配器同时存在)

另外还有个容易忽略的细节:srflx 候选里有个 raddr 字段 ,记录的是该候选对应的本地地址。正常情况下它被抹平为 0.0.0.0:

复制代码
candidate:3129071897 1 udp 1677729535 43.163.83.151 1209 typ srflx raddr 0.0.0.0 rport 0

但如果你看到的不是 0.0.0.0 而是一个真实内网地址,那说明内网地址是被这条候选顺手带出来的。这也是为什么只看"是不是 .local"不够------要看整条候选字符串。

还有一个版本差异:Chromium 较新的版本在关闭 mDNS 特性时,不再回退到明文 IP 模式,而是干脆跳过本地接口枚举,host 候选直接消失。不同版本的行为不一致,所以与其背版本号,不如实际跑一遍看结果。

四、动手查:一段代码就够了

打开任意网页,按 F12,把下面这段代码整段粘进控制台,回车,等约 3 秒。

它会做三件事:列出全部 ICE 候选并逐条标注含义、读取本站点当前的媒体设备权限状态、给出结论。

javascript 复制代码
// WebRTC 暴露面自查脚本
// 用法:F12 打开控制台 -> 粘贴 -> 回车 -> 等 3 秒看结果
// 作用:列出当前浏览器在 ICE 候选收集过程中,向页面交出的全部地址,并标注每一项的含义
(function () {
  'use strict';

  if (typeof RTCPeerConnection === 'undefined') {
    console.log('%c[结论] 本浏览器没有 RTCPeerConnection,WebRTC 不可用。', 'color:#c0392b');
    console.log('注意:WebRTC 缺失本身也是一个可被识别的特征,未必等于"更安全"。');
    return;
  }

  var ICE_SERVERS = [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'stun:stun1.l.google.com:19302' }
  ];

  var rows = [];
  var seen = {};
  var finished = false;

  // ---------- 地址归类 ----------
  function isIPv6(s) { return s.indexOf(':') !== -1; }
  function isLoopback(s) { return s === '127.0.0.1' || s === '::1'; }
  function isMdns(s) { return /\.local$/i.test(s); }
  function isV4Mapped(s) { return /^::ffff:/i.test(s); }

  function isPrivateIPv4(s) {
    if (isIPv6(s)) return false;
    var p = s.split('.');
    if (p.length !== 4) return false;
    var a = parseInt(p[0], 10);
    var b = parseInt(p[1], 10);
    if (isNaN(a) || isNaN(b)) return false;
    if (a === 10) return true;                          // 10.0.0.0/8
    if (a === 192 && b === 168) return true;            // 192.168.0.0/16
    if (a === 172 && b >= 16 && b <= 31) return true;   // 172.16.0.0/12
    if (a === 169 && b === 254) return true;            // 链路本地 169.254.0.0/16
    return false;
  }

  function isPrivateIPv6(s) {
    if (!isIPv6(s)) return false;
    var low = s.toLowerCase();
    if (low.indexOf('fe80') === 0) return true;       // 链路本地 fe80::/10
    if (low.indexOf('fc') === 0 || low.indexOf('fd') === 0) return true; // 唯一本地 fc00::/7
    return false;
  }

  function classify(addr) {
    if (isMdns(addr)) return 'mDNS 假名(内网 IP 已被替换)';
    if (isV4Mapped(addr)) return 'IPv4 映射地址(内含内网 IPv4)';
    if (isLoopback(addr)) return '本机回环';
    if (isPrivateIPv4(addr)) return '内网 IPv4';
    if (isPrivateIPv6(addr)) return '内网 IPv6';
    if (isIPv6(addr)) return '公网 IPv6';
    return '公网 IPv4';
  }

  function typeMeaning(t) {
    if (t === 'host') return '本机网卡地址';
    if (t === 'srflx') return 'STUN 反射出的公网地址';
    if (t === 'prflx') return '连通性检查中发现的对端地址';
    if (t === 'relay') return 'TURN 中继服务器地址';
    return t;
  }

  // ---------- 候选字符串解析 ----------
  // 典型格式:
  // candidate:842163049 1 udp 1677729535 203.0.113.45 54321 typ srflx raddr 0.0.0.0 rport 0 generation 0
  function parse(line) {
    var parts = line.split(' ');
    var typIdx = parts.indexOf('typ');
    if (typIdx < 0 || typIdx + 1 >= parts.length) return null;
    var addr = parts[4] || '';
    if (!addr) return null;
    var rIdx = parts.indexOf('raddr');
    return {
      '类型': parts[typIdx + 1],
      '类型说明': typeMeaning(parts[typIdx + 1]),
      '地址': addr,
      '端口': parts[5] || '',
      '协议': String(parts[2] || '').toLowerCase(),
      '归类': classify(addr),
      'raddr': rIdx >= 0 ? parts[rIdx + 1] : '---'
    };
  }

  // ---------- 媒体权限状态(决定 host 候选会不会被打回真实 IP) ----------
  function queryPermission(name) {
    if (!navigator.permissions || !navigator.permissions.query) {
      return Promise.resolve('此浏览器不支持查询');
    }
    return navigator.permissions.query({ name: name })
      .then(function (r) { return r.state; })
      .catch(function () { return '此项目不被支持'; });
  }

  // ---------- 报告 ----------
  function report() {
    if (finished) return;
    finished = true;

    console.log('');
    console.log('%c===== 一、ICE 候选逐条 =====', 'font-weight:bold;font-size:13px');
    if (rows.length === 0) {
      console.log('未收集到任何候选。可能原因:本机无可用网卡、UDP 被网络策略阻断、或浏览器禁用了 WebRTC。');
    } else if (console.table) {
      console.table(rows);
    } else {
      rows.forEach(function (r) { console.log(JSON.stringify(r)); });
    }

    console.log('%c===== 二、媒体设备权限状态 =====', 'font-weight:bold;font-size:13px');
    Promise.all([
      queryPermission('camera'),
      queryPermission('microphone')
    ]).then(function (st) {
      console.log('摄像头权限:' + st[0] + '    麦克风权限:' + st[1]);
      if (st[0] === 'granted' || st[1] === 'granted') {
        console.log('%c注意:本站点已取得媒体权限,Chromium 系浏览器会停止对内网 IP 做 mDNS 替换,host 候选可能直接是真实内网地址。',
          'color:#e67e22');
      } else {
        console.log('当前站点未取得媒体权限,host 候选应被替换为 .local 假名(若仍看到内网 IP,说明浏览器版本较旧或被策略改动过)。');
      }
      summary();
    });
  }

  function summary() {
    var has = function (kw) {
      return rows.some(function (r) { return r['归类'].indexOf(kw) >= 0; });
    };
    var hasType = function (t) {
      return rows.some(function (r) { return r['类型'] === t; });
    };
    // relay 是中继服务器自己的地址,不属于本机暴露面,不参与内网泄漏判定
    var privateLeak = rows.filter(function (r) {
      return (r['类型'] === 'host' || r['类型'] === 'srflx') &&
             (r['归类'].indexOf('内网 IPv4') >= 0 || r['归类'].indexOf('内网 IPv6') >= 0);
    });
    var raddrLeak = rows.filter(function (r) {
      return r['raddr'] && r['raddr'] !== '---' && r['raddr'] !== '0.0.0.0' && r['raddr'] !== '::';
    });

    console.log('');
    console.log('%c===== 三、结论 =====', 'font-weight:bold;font-size:13px');

    if (privateLeak.length > 0) {
      console.log('%c× 候选里出现了未被替换的内网地址(' + privateLeak.length + ' 项)。', 'color:#c0392b');
      privateLeak.forEach(function (r) {
        console.log('   ' + r['类型'] + ' | ' + r['地址'] + ' | ' + r['归类']);
      });
    } else if (has('mDNS')) {
      console.log('%c√ 内网地址已被替换为 mDNS 假名,本地 IP 这一层没有直接暴露。', 'color:#27ae60');
    } else {
      console.log('· 没有收集到 host 候选(浏览器可能已限制本地接口枚举)。');
    }

    if (hasType('srflx')) {
      console.log('%c! 存在 srflx 候选,也就是 STUN 反射出来的公网地址 ------ 请逐条核对它是不是你以为的那个出口 IP。', 'color:#e67e22');
      console.log('  核对方法:在不开代理时打开 ip.sb 之类页面记下真实 IP,开代理后再看这里的 srflx 是否等于代理出口 IP。');
      console.log('  两者一致 => 隧道把 UDP 也接走了;出现真实运营商 IP => UDP 没进隧道,这一层是绕过代理出去的。');
    }

    if (raddrLeak.length > 0) {
      console.log('%c× 有 srflx 候选的 raddr 字段带着非零地址(' + raddrLeak[0]['raddr'] + '),内网地址被顺带留在了候选里。', 'color:#c0392b');
    }

    if (has('IPv4 映射地址')) {
      console.log('%c× 出现 ::ffff: 开头的 IPv4 映射地址,其中编码了内网 IPv4。', 'color:#c0392b');
    }

    console.log('');
    console.log('提示:同一个浏览器里,不同站点的结果可能不同(媒体权限按站点授予)。');
    console.log('提示:请在你实际跑业务的那个浏览器配置里测,而不是用一个干净的新配置测。');
  }

  // ---------- 开始收集 ----------
  var pc;
  try {
    pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
  } catch (e) {
    console.log('RTCPeerConnection 创建失败:' + e.message);
    return;
  }

  pc.onicecandidate = function (e) {
    if (e && e.candidate && e.candidate.candidate) {
      var line = e.candidate.candidate;
      if (seen[line]) return;
      seen[line] = true;
      var row = parse(line);
      if (row) rows.push(row);
    }
    if (!e || !e.candidate) {
      report();           // 候选收集完成(null candidate)
      try { pc.close(); } catch (err) {}
    }
  };

  try {
    pc.createDataChannel('env-probe');
  } catch (e) {
    // 某些环境不支持数据通道,改用音频 transceiver
    try { pc.addTransceiver && pc.addTransceiver('audio'); } catch (e2) {}
  }

  pc.createOffer()
    .then(function (offer) { return pc.setLocalDescription(offer); })
    .catch(function (err) { console.log('createOffer/setLocalDescription 失败:' + err.message); });

  // 兜底:部分环境不会触发 null candidate
  setTimeout(report, 3000);
})();

这段代码在干什么

① 为什么要有 createDataChannel / addTransceiver

单纯 new 一个 RTCPeerConnection 不会触发候选收集。必须至少建立一个数据通道或媒体轨道,浏览器才会真正去盘点自己的网络地址------这也是为什么那种「三行代码的探测脚本」能起作用:它调用的是完全正常的 API 流程,没有任何越权动作,页面拿到的东西是从 onicecandidate 事件里正常回调出来的。

② classify() 在判断什么

正则 /\.local$/i 命中就是 mDNS 假名;::ffff: 开头说明是 IPv4 被编码进了 IPv6(内含真实内网地址);RFC1918 三段(10./172.16-31./192.168.)加 169.254 链路本地,算内网 IPv4;fe80、fc/fd 开头算内网 IPv6。

③ 为什么 relay 不参与泄漏判定

relay 的地址是 TURN 中继服务器自己的地址,它可能是个内网地址,但那不是你的。代码里单独把它排除掉了,否则跑出来会误报。

④ 为什么要读 permissions.query({name:'camera'})

因为路径一的存在。权限状态直接决定了你的 host 候选会不会被打回真实 IP------同一段代码在不同站点跑出不同结果,原因就在这里。Firefox 不支持查询这两个权限项,代码里做了 catch,会显示"此项目不被支持",属正常现象。

⑤ setTimeout(report, 3000) 是兜底

少数环境不会抛出表示收集完成的空候选事件,靠超时保底输出。

输出怎么读

看到什么 说明什么
host 全是 xxxx.local 内网 IP 这一层已被替换,这条路径没暴露
host 直接是 192.168.x.x 未替换。先核对本站点有没有媒体权限,再看浏览器版本与被改动的策略
srflx 的地址 = 你的代理出口 IP 隧道把 UDP 也接走了,这一层是干净的
srflx 的地址 = 你的运营商真实 IP UDP 没进隧道,这就是"换了 IP 还是被认出来"的机制层原因
raddr 不是 0.0.0.0 内网地址被 srflx 顺带出来了
出现 ::ffff: IPv4 被编码进 IPv6,其中含内网地址
一条候选都没有 网络策略拦了 UDP,或浏览器限制了该 API

srflx 那一行要怎么核对:不开代理时开一个查 IP 的页面记下真实地址,开代理后跑这段代码,看 srflx 是不是等于代理出口 IP。两者一致才算这一层干净。

五、各浏览器现在的处置选项

浏览器 原生控制在哪 说明与代价
Chrome / Edge 无原生开关。仅 chrome://flags/#enable-webrtc-hide-local-ips-with-mdns(默认已启用);企业可用策略 WebRtcIPHandling 要靠扩展或策略。不少防护扩展已停更,装之前看最后更新日期和申请的权限
Firefox about:config 最可控:media.peerconnection.enabled=false 全关(代价是浏览器内视频会议全挂);也可只设 ice.obfuscate_host_addresses / ice.no_host / ice.default_address_only。各版本条目名有出入,以 about:config 里搜 ice.no_host 实际出现的为准
Safari 默认不暴露 host 候选,是主流里姿态最保守的 开发菜单 → 实验特性里有 mDNS 相关开关;没有完整的原生关闭项
Brave 设置 → Shields → WebRTC IP Handling Policy Chromium 内核里唯一把这个策略做成 UI 的。最严一档是 Disable Non-Proxied UDP
Tor Browser WebRTC 默认全关 最干净,但见下面第 1 条误解

顺带说一句:这几条都是"能不能被调用",没有一条能解决路径二。 路径二在你的网络出口那一层,浏览器里改不动。

六、三个还在流传的误解

误解一:把 WebRTC 关掉就干净了。

不一定。RTCPeerConnection 构造失败、完全没有候选,这件事本身就是一个可被读取的信号------真正的普通浏览器很少处于这个状态。相比"WebRTC 不存在","WebRTC 存在但不额外给出东西"反而更接近常态。

误解二:看到 .local 就说明没事了。

mDNS 的适用范围从头到尾只有本地地址那一半。公网那一半(srflx)一次都没有被它管过。 这也是很多人测完觉得安全、但实际情况没变的原因。

误解三:换了 IP 就看不出来了。

如果 UDP 不进隧道,你换掉的是 HTTP 那一层的 IP,STUN 那一层压根没换过。这时候问题不在浏览器、不在指纹参数,在网络出口的拓扑上。

七、为什么这件事在多店铺场景里格外重要

把上面的机制放回实际操作里,结论是这样的:

同一台机器上多个店铺环境,如果它们共用同一条代理,而这条代理只接管 TCP------那么每个环境的 srflx 候选里冒出来的,都会是同一个真实公网 IP。

这时候无论你把浏览器参数改得多干净,平台在候选列表里看到的那个地址始终没变过。因为变的是可被"替换"的那些项,而没有被换掉的那一项,压根不在浏览器里。

所以「换了 IP 还是被关联」这个现象,很多时候不是参数没改干净,而是有一层从来没被换过。排查的时候也应该先看这一层------它是唯一一个在浏览器全局设置里改不动、必须在网络层确认的东西。

相关推荐
ZhangJun952 小时前
三次握手、四次挥手的具体细节和流程详解,附加思考题
tcp/ip·计算机网络·云计算
被摘下的星星2 小时前
HTTP 状态码 和 网络端口号
网络·网络协议·http
zxanz14 小时前
https 的基本原理是什么样的?
网络协议·http·https
不会就选b17 小时前
Linux之应用层协议HTTP(二)
网络·网络协议·http
hasty17 小时前
只监听本机为何仍需鉴权?Cline Hub 的 WebSocket 信任边界解析
网络·websocket·网络协议
2501_9378609421 小时前
下篇:网络层、数据链路层与应用层:IP、NAT、ARP、DNS 一网打尽
网络·网络协议·tcp/ip
ITxiaobing20231 天前
IP 定位服务怎么选:第三方认可、实测精度和合规边界不是一回事
网络·网络协议·tcp/ip
VidDown1 天前
从视频画面里提取文字:OCR、文字检测与结构化输出
python·网络协议·ocr·音视频·视频编解码·视频
阿钱真强道1 天前
12 嵌入式操作系统 | UDP 服务器编程
服务器·网络协议·udp