一、先纠一个时差:你搜到的那些教程,大部分写于问题最严重的窗口
搜「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 还是被关联」这个现象,很多时候不是参数没改干净,而是有一层从来没被换过。排查的时候也应该先看这一层------它是唯一一个在浏览器全局设置里改不动、必须在网络层确认的东西。