隐私与IP泄露
WebRTC 默认会枚举本机所有网卡 IP 并通过 SDP 暴露给对端,这是它最经典的隐私问题。
2015 年研究者公开演示后,"WebRTC 泄露真实 IP" 几乎成了 VPN 评测的固定项。
2026 年的主流浏览器已经默认用 mDNS 候选 把 host 候选脱敏,但仍有大量边缘场景能拿到真实 IP,
包括 srflx 候选、relay 候选的 server-reflexive 地址、RTCStatsReport 字段、
未禁用的 IPv6 候选,以及 getDisplayMedia 暴露的其他应用窗口。
本文面向需要把 WebRTC 部署到生产环境、且需要对用户隐私负责的工程团队。 讨论范围限定在浏览器端(Chromium、Firefox、Safari),原生 SDK 的等价行为另文。
1. 历史背景
1.1 为什么会泄露
ICE(RFC 8445)的目标是 NAT 穿透,所以默认行为是尽可能多地收集候选地址:
- host 候选:本机所有网卡 IP(包括 VPN 虚拟网卡、内网网卡、IPv6 临时地址)。
- srflx 候选:通过 STUN 探测拿到的公网映射地址。
- prflx 候选:对端在连通性检查中观测到的地址。
- relay 候选:TURN 分配的中继地址。
收集到的候选会通过 SDP a=candidate 行交换给对端。只要页面能拿到 RTCPeerConnection,
就能在 icecandidate 事件里读到所有候选,无需对方真的接受连接。
1.2 经典攻击场景
- VPN 用户身份关联:用户挂 VPN 访问页面,VPN 隧道之外的本地网卡 IP(如
192.168.x.x或运营商分配的 IPv6) 还是会作为 host 候选出现,攻击者拿到后能反查实际地理位置。 - 内网拓扑探测:广告 / 追踪脚本通过 host 候选拿到企业内网段,配合 SSRF 进一步攻击。
- 跨站点指纹:内网 IP + 设备列表 + 渲染指纹,足以做精度极高的用户识别。
1.3 失败时的样子
- 用户开了 VPN,但页面
RTCPeerConnection.onicecandidate仍打印出192.168.1.x、10.x.x.x、 甚至运营商真实公网 IP。 - 浏览器 DevTools 看到的 SDP 里含明文 IPv4 / IPv6,没有
*.local字样。 chrome://webrtc-internals候选表里candidateType=host行有真实 IP。
2. ICE 候选收集流程
下面这张图标注了哪些候选会暴露 IP,以及暴露给谁。
要点:
- 页面 JS 能直接读所有候选,所以 mDNS 只能挡住 SDP 上的暴露,挡不住宿主页面自己作恶。
- srflx 永远会暴露公网出口 IP。这是 ICE 工作的前提,无法去掉,除非走 relay-only。
- relay-only 把暴露面收缩到 TURN 服务器,但 TURN 自己看得到所有真实 IP。
3. mDNS 候选机制
3.1 是什么
mDNS(RFC 6762)让局域网内主机用 .local 域名互相解析。
WebRTC 把 host 候选的 IP 替换成一个随机生成的 UUID .local 主机名,
相关草案见 draft-ietf-mmusic-mdns-ice-candidates
(最终编号 RFC 9472 系列),定义了如何在 ICE 中安全使用 mDNS 名字。
原始(旧版)host 候选:
a=candidate:1 1 udp 2122260223 192.168.1.42 51234 typ host generation 0
mDNS 化后:
a=candidate:1 1 udp 2122260223 8f7e4c9a-3b21-4f0e-9d3a-1c4b5e6a7d8f.local 51234 typ host generation 0
对端浏览器拿到 SDP 后,通过本网段 mDNS 查询解析 .local 名字,只在 LAN 内能解析到。
跨网段的对端只能用 srflx / relay 候选,host 候选自动失效但不会暴露真实 IP。
3.2 浏览器默认行为(2026)
| 浏览器 | host 候选默认 | 控制开关 |
|---|---|---|
| Chrome / Edge | mDNS(强制) | chrome://flags/#enable-webrtc-hide-local-ips-with-mdns(已默认开) |
| Firefox | mDNS(默认开) | media.peerconnection.ice.obfuscate_host_addresses |
| Safari | mDNS(默认开) | 无用户开关 |
| WebView(iOS/Android) | 跟随系统 | 部分定制 ROM 可能关闭 |
3.3 适用场景
- P2P 浏览器到浏览器:mDNS 候选就够用,LAN 内 P2P 不需要真实 IP。
- WAN 通信:mDNS 失败也无所谓,会回落到 srflx / relay。
3.4 不适用场景
- 服务端必须解析候选 IP:SFU / MCU 是普通服务器,不在用户局域网内,
无法通过 mDNS 解析
.local。只能等浏览器自己拿到 srflx / relay 候选并连接服务端。 - 抓包排障:Wireshark 看到 SDP 里都是 UUID,要靠 STUN binding 包反查真 IP。
- CDN 内的 SFU 区分用户网络:拿不到 host IP,做 ISP 路由优化时会丢失信息,改用 srflx。
3.5 失败时的样子
- 服务端日志报
Failed to resolve candidate: xxxxx.local,连接超时。 chrome://webrtc-internals里selected candidate pair为空,ICE 状态停在checking。- 客户端
iceConnectionState从checking直接到failed。
4. mDNS 对 SFU / 服务器场景的影响
4.1 必须接受的事实
SFU / TURN 服务端永远拿不到对方的 host 候选 IP。 所有 ICE 配对要么在浏览器内完成(浏览器主动用 srflx / relay 连服务端), 要么靠服务端发布的公网 IP 作为远端候选让浏览器主动连过来。
典型 SFU 架构因此必须满足两个前提:
- SFU 自己有公网 IP或部署在能被 STUN 探测到的位置,下发 srflx-friendly 的候选。
- 业务允许 srflx 候选作为最终连接路径(即用户的 NAT 类型不是对称 NAT,否则要 relay)。
4.2 工程影响
- 不要在 SFU 端写"必须收到 host 候选才认为正常"的检查逻辑——浏览器永远不会发明文 host。
- 排障时区分"客户端没收集到候选" vs "服务端没解析到候选",两者日志位置不同。
- 客户端代码不要对 SDP 里出现
.local字样感到惊讶,不要把它当作错误过滤掉。
pc.onicecandidate = (event) => {
if (!event.candidate) return;
// 反模式:见到 .local 就丢弃
// if (event.candidate.candidate.includes('.local')) return;
// 正确:原样转发给信令
signaling.send({ type: 'candidate', candidate: event.candidate.toJSON() });
};
5. enumerateDevices 设备指纹
5.1 字段及其敏感度
navigator.mediaDevices.enumerateDevices() 返回 MediaDeviceInfo 数组,
四个字段的隐私属性如下表:
| 字段 | 无权限时 | 有权限后 | 跨源/跨会话稳定性 |
|---|---|---|---|
kind | audioinput / audiooutput / videoinput | 同左 | 仅反映设备类型,敏感度低 |
deviceId | 在同源内固定的不透明字符串 | 同左 | 同源内长期稳定,跨源不同 |
groupId | 空字符串 | 真实分组 ID | 同源内稳定,可关联同一物理设备 |
label | 空字符串 | 设备标签(MacBook Pro 摄像头) | 取决于设备命名 |
5.2 deviceId 是稳定指纹
很多团队以为 "没拿到 label 就没指纹",这是错的。deviceId 即使在用户未授权时也会返回,
并且在同源 + 同浏览器配置下长期稳定。攻击者可以在用户不知情的情况下:
- 调用
enumerateDevices()。 - 收集
(kind, deviceId)元组,hash 后作为指纹。 - 即使清 cookie,只要不重置浏览器存储,指纹保持不变。
跨源时浏览器会重新生成 deviceId,所以不能跨站追踪,但单站内能追踪用户在长周期内的同一性。
5.3 适用与不适用
- 适用:业务需要让用户在多个设备里选麦克风 / 摄像头,保存上次选择,用
deviceId作为 key 是合理的。 - 不适用:把
deviceId上报到分析 / 风控系统当作用户 ID。这等价于做用户追踪, 在 GDPR / PIPL 下是个人信息处理,需要合法基础。
5.4 失败时的样子
- 用户清浏览器存储后
deviceId变化,业务的 "记住选择" 功能失效。 - 同一台设备在不同浏览器(Chrome / Firefox)里
deviceId不同,导致跨浏览器同步出错。 - Safari 隐私浏览模式下
deviceId每次会话都重置。
async function listDevices() {
// 未授权前
const before = await navigator.mediaDevices.enumerateDevices();
console.log(before);
// [
// { kind: 'videoinput', deviceId: 'a3f2...', groupId: '', label: '' },
// { kind: 'audioinput', deviceId: '7c91...', groupId: '', label: '' },
// ]
// 申请一次摄像头权限
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((t) => t.stop());
// 授权后
const after = await navigator.mediaDevices.enumerateDevices();
console.log(after);
// [
// { kind: 'videoinput', deviceId: 'a3f2...', groupId: 'g1', label: 'FaceTime HD Camera' },
// ...
// ]
}
6. 最小化暴露策略
核心原则:默认只收集业务必需的候选。下表是不同 iceTransportPolicy 设置下的暴露面:
| 候选类型 | 暴露的 IP | 暴露给谁 | mDNS 后 | iceTransportPolicy: 'relay' 后 |
|---|---|---|---|---|
| host | 本机所有网卡(含 VPN 内网) | 对端 + 信令服务 + 页面脚本 | hostname .local(仅 LAN 可解析) | 不收集 |
| srflx | NAT 外侧公网 IP(VPN 出口) | 对端 + 信令 + 页面 | 不变 | 不收集 |
| prflx | 同 srflx | 对端 + 页面 | 不变 | 不收集 |
| relay | TURN 服务器 IP | 对端 + 信令 + 页面 | 不变 | 只收集这一类 |
强制 relay 模式:
const pc = new RTCPeerConnection({
iceServers: [
{
urls: ['turn:turn.example.com:3478', 'turns:turn.example.com:5349'],
username: shortLivedUsername,
credential: shortLivedCredential,
},
],
iceTransportPolicy: 'relay', // 关键:只用 TURN,不收集 host / srflx
});
pc.onicecandidate = (event) => {
if (!event.candidate) return;
// 强制 relay 后只会看到 typ relay 候选
console.assert(event.candidate.candidate.includes('typ relay'));
};
6.1 什么时候适用
- 用户开 VPN 且业务必须保护其真实 IP(如新闻举报、维权场景)。
- 企业内部会议系统不希望员工内网拓扑暴露。
- 跨国通话希望统一从 TURN 出口控制路由。
6.2 什么时候不适用
- 大量 P2P 场景,强制 relay 会显著增加带宽成本(TURN 中继所有媒体)。
- 低延迟场景,TURN 多一跳会增加 30–80ms RTT。
6.3 失败时的样子
- TURN 服务器抗不住流量,连接超时 / 卡顿。
- 用户网络无法访问 TURN UDP/TCP 端口,连接彻底失败(没有 host / srflx fallback)。
7. VPN 用户保护
如果业务面向对隐私敏感的 VPN 用户,仅靠 mDNS 不够,下面是组合拳:
function buildPrivacyFirstPC(turnServers) {
return new RTCPeerConnection({
iceServers: turnServers,
iceTransportPolicy: 'relay', // 关闭 host / srflx
iceCandidatePoolSize: 0, // 不预收集候选
bundlePolicy: 'max-bundle', // 减少候选数量
});
}
// 进一步:在 SDP 层过滤 IPv6(IPv6 临时地址会绕过部分 VPN)
function stripIPv6Candidates(sdp) {
return sdp
.split('\r\n')
.filter((line) => {
if (!line.startsWith('a=candidate:')) return true;
// candidate 行的第 5 个字段是连接地址
const parts = line.split(' ');
const address = parts[4] ?? '';
return !address.includes(':'); // 含冒号即 IPv6
})
.join('\r\n');
}
const offer = await pc.createOffer();
offer.sdp = stripIPv6Candidates(offer.sdp);
await pc.setLocalDescription(offer);
7.1 注意
iceTransportPolicy: 'relay'不能在setLocalDescription之后改,必须建 PC 时就指定。- 在 SDP 层过滤候选要同时处理本地和远端 SDP,否则一方过滤另一方没过滤会导致候选不匹配。
- 提示用户:"为保护您的 IP,本次通话将通过中继服务器传输,可能增加少量延迟"。
- 配合
Referrer-Policy: no-referrer和COOP: same-origin减少其他维度的泄露。
8. getUserMedia 权限模型
8.1 调用前置条件
| 条件 | 说明 | 例外 |
|---|---|---|
| Secure Context | 必须 https:// 或 http://localhost | file:// 在部分浏览器有限支持 |
| 用户手势 | iOS Safari 必须由用户点击触发 | 已授权的同源页面 |
| Permissions Policy | iframe 必须显式 allow | 同源 iframe 自动继承 |
| 用户同意 | 弹出权限提示框 | 企业策略可预授权 |
<!-- 父页面 -->
<iframe
src="https://meet.example.com/room/abc"
allow="camera 'src'; microphone 'src'; display-capture 'src'">
</iframe>
Permissions-Policy: camera=(self "https://meet.example.com"), microphone=(self), display-capture=(self)
8.2 权限粒度
- Chrome 116+ 支持一次性授权("此次允许"),刷新页面会重新询问。
- Firefox 一直支持"仅本次"。
- Safari 默认每个会话询问一次,不持久化。
8.3 失败时的样子
getUserMedia抛NotAllowedError:用户拒绝或被 Permissions-Policy 拦截。- 抛
NotFoundError:没有匹配 constraints 的设备。 - 抛
SecurityError:非安全上下文。
9. getDisplayMedia 的隐私风险
getDisplayMedia() 用于屏幕共享,隐私风险高于 getUserMedia:
- 用户可以选择共享 整个屏幕、应用窗口、浏览器标签。
- 共享整个屏幕时,任何弹出通知(钉钉、Slack、邮件)都会被采集。
- 共享应用窗口时,可能误选含密码管理器、私密聊天的窗口。
- 共享浏览器标签时,标签内 cookie / 登录态可能被对端录屏拿到。
async function safeShareScreen() {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'window', // 默认引导用户选窗口而不是整屏
},
audio: false,
// 提示用户排除当前标签,避免选到镜面递归
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include', // 允许中途切换共享源
systemAudio: 'exclude',
});
const track = stream.getVideoTracks()[0];
const settings = track.getSettings();
console.log('displaySurface:', settings.displaySurface);
// 'monitor' | 'window' | 'browser'
// 提示用户"你正在共享整个屏幕",给关闭按钮
if (settings.displaySurface === 'monitor') {
showFullScreenSharingWarning();
}
} catch (err) {
if (err.name === 'NotAllowedError') {
// 用户取消或权限被拒
}
}
}
9.1 工程建议
- 默认
displaySurface: 'window',引导用户选应用窗口而不是整屏。 - 共享期间在页面显眼位置展示"正在共享 X"+ 停止按钮。
- 服务端记录共享起止时间(合规留痕),但不要记录帧内容,除非业务允许且用户同意。
- 检测
displaySurface === 'monitor'时再次提示风险。
10. RTCStatsReport 中的隐私字段
pc.getStats() 返回的统计里有几个字段会暴露 IP:
| stats type | 字段 | 暴露内容 |
|---|---|---|
local-candidate | address, port | 本机候选 IP(mDNS 化后为 .local) |
remote-candidate | address, port | 对端候选 IP(不会 mDNS 化) |
candidate-pair | localCandidateId, remoteCandidateId | 关联具体候选 |
transport | selectedCandidatePairId | 当前使用的候选对 |
async function dumpStats(pc) {
const stats = await pc.getStats();
for (const report of stats.values()) {
if (report.type === 'remote-candidate') {
console.log('Remote IP exposed:', report.address, report.port, report.candidateType);
// 对端的 srflx 候选 = 对端公网 IP
}
}
}
10.1 风险点
- 把
getStats全量上报到自家分析系统时,等同于采集所有对端的 IP。 - 第三方分析 SDK 可能调用
getStats,把 IP 偷偷上报到广告系统。 - 排障日志上传到云端时,要脱敏
address字段。
10.2 脱敏示例
function redactStatsForLogging(stats) {
const redacted = [];
for (const r of stats.values()) {
const copy = { ...r };
if (copy.address) copy.address = hash(copy.address); // 单向 hash
if (copy.ip) copy.ip = hash(copy.ip);
redacted.push(copy);
}
return redacted;
}
11. 浏览器端 IP 泄露检测代码
下面这段代码可以放到隐私测试页 / 内部巡检页,主动探测当前浏览器配置下会暴露哪些 IP:
/**
* 探测当前浏览器在 WebRTC 默认配置下会暴露的 IP。
* 返回所有被收集到的非 mDNS 候选地址。
*/
async function detectIPLeak({
stunServers = ['stun:stun.l.google.com:19302'],
timeoutMs = 3000,
} = {}) {
const pc = new RTCPeerConnection({ iceServers: stunServers.map((u) => ({ urls: u })) });
const leaked = new Set();
const allCandidates = [];
// 必须创建一个 data channel 才会触发 ICE 收集
pc.createDataChannel('leak-probe');
pc.onicecandidate = (event) => {
if (!event.candidate) return;
const cand = event.candidate.candidate;
allCandidates.push(cand);
// candidate 字段:candidate:foundation component proto priority address port typ type ...
const parts = cand.split(' ');
const address = parts[4];
const type = parts[7];
// 排除 mDNS 名字
if (address.endsWith('.local')) return;
// 排除回环
if (address === '127.0.0.1' || address === '::1') return;
leaked.add(`${type}:${address}`);
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 等待候选收集完毕(icegatheringstate=complete)或超时
await new Promise((resolve) => {
const t = setTimeout(resolve, timeoutMs);
pc.onicegatheringstatechange = () => {
if (pc.iceGatheringState === 'complete') {
clearTimeout(t);
resolve();
}
};
});
pc.close();
return { leaked: [...leaked], allCandidates };
}
// 使用
const { leaked, allCandidates } = await detectIPLeak();
console.log('泄露的 IP(type:address):', leaked);
console.log('收集到的所有候选:', allCandidates);
// 期望输出:
// 泄露的 IP: ['srflx:203.0.113.42'] // 只有公网映射地址
// 不应该出现 host:192.168.x.x 这类
11.1 判断标准
- 只有 srflx:正常,符合 2026 年默认安全姿态。
- 出现 host 明文 IP:浏览器未启用 mDNS,需要排查浏览器版本 / 用户配置。
- 出现 IPv6 临时地址:可能绕过 IPv4 VPN,按场景决定是否过滤。
- 完全没候选:浏览器可能整体禁用了 WebRTC(用户装了反追踪扩展)。
12. 反模式
| 反模式 | 后果 | 正确做法 |
|---|---|---|
关闭 mDNS(chrome://flags 或 about:config) | 所有 host 候选明文暴露给页面脚本 | 保持默认开启 |
客户端代码过滤掉所有含 .local 的候选 | ICE 配对失败,连接率下降 | 原样转发,让对端浏览器解析 |
隐私场景仍用默认 iceTransportPolicy: 'all' | VPN 出口和内网 IP 暴露 | 显式 'relay' |
把 deviceId 当用户 ID 上报到分析系统 | 等同于持久追踪,违反 GDPR/PIPL | 同源内做"记住选择" key 即可,不出系统 |
在用户未授权前调用 enumerateDevices 收集指纹 | 隐私侵犯,且大多数 ToS 禁止 | 仅在用户主动进入设备设置时调用 |
全量上传 getStats 到云端 | 对端 IP 被批量采集 | 上报前脱敏 address / ip 字段 |
| 屏幕共享默认整屏 | 通知、私密窗口被对端看到 | 默认 displaySurface: 'window' + 显眼提示 |
信令走 ws:// | SDP 候选(含 IP)可被中间人嗅探 | 强制 wss:// |
iframe 不限制 Permissions-Policy | 子域 / 第三方脚本能偷启摄像头 | camera=(self ...) 白名单 |
在前端打印 / alert 完整 SDP 调试 | 第三方扩展 / 截图泄露候选 IP | 调试日志脱敏,生产关闭 |
把 RTCPeerConnection 暴露在 window 上 | 浏览器扩展 / XSS 能读所有候选 | 模块作用域内持有 |
| TURN 凭据复用,让用户都能强制 relay 跑流量 | TURN 被刷流量,被攻击者用作匿名代理 | 短期凭据 + 用户配额(见 TURN 章) |
13. 自查清单
- 浏览器默认 mDNS 处于开启状态(已验证 SDP 中 host 候选为
.local)。 - 隐私敏感场景显式设置
iceTransportPolicy: 'relay'。 - 检测脚本能确认默认配置下只暴露 srflx,不暴露 host 明文。
-
getStats上传前对address/ip做脱敏。 -
getDisplayMedia默认displaySurface: 'window',有共享提示 UI。 -
enumerateDevices不在权限申请前用于指纹收集。 - iframe 用
Permissions-Policy限制摄像头 / 麦克风 / 屏幕共享来源。 - 信令通道强制
wss://,SDP 不进明文日志。 - 用户能在 UI 看到当前是否走 relay、当前共享对象、当前授权设备。
14. 权威资料
- W3C WebRTC 1.0: Privacy Considerations(隐私章节): https://www.w3.org/TR/webrtc/#privacy-and-security
- W3C Media Capture and Streams: Privacy and security considerations: https://www.w3.org/TR/mediacapture-streams/#privacy-and-security-considerations
- RFC 8828 WebRTC IP Address Handling Requirements: https://www.rfc-editor.org/rfc/rfc8828
- RFC 8827 WebRTC Security Considerations: https://www.rfc-editor.org/rfc/rfc8827
- RFC 6762 Multicast DNS: https://www.rfc-editor.org/rfc/rfc6762
draft-ietf-mmusic-mdns-ice-candidates(mDNS-based ICE candidates): https://datatracker.ietf.org/doc/draft-ietf-mmusic-mdns-ice-candidates/- Chrome mDNS ICE candidates 设计文档: https://chromestatus.com/feature/6121446151172096
- Chromium 隐私沙箱 / WebRTC 隐私: https://www.chromium.org/Home/chromium-security/privacy/
- Mozilla Preventing WebRTC leaks: https://support.mozilla.org/en-US/kb/how-do-i-disable-webrtc
- EFF Cover Your Tracks(前 Panopticlick): https://coveryourtracks.eff.org/
- W3C Permissions Policy: https://www.w3.org/TR/permissions-policy/
- 核对日期:2026-06-22