跳到主要内容

隐私与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.x10.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 / EdgemDNS(强制)chrome://flags/#enable-webrtc-hide-local-ips-with-mdns(已默认开)
FirefoxmDNS(默认开)media.peerconnection.ice.obfuscate_host_addresses
SafarimDNS(默认开)无用户开关
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-internalsselected candidate pair 为空,ICE 状态停在 checking
  • 客户端 iceConnectionStatechecking 直接到 failed

4. mDNS 对 SFU / 服务器场景的影响

4.1 必须接受的事实

SFU / TURN 服务端永远拿不到对方的 host 候选 IP。 所有 ICE 配对要么在浏览器内完成(浏览器主动用 srflx / relay 连服务端), 要么靠服务端发布的公网 IP 作为远端候选让浏览器主动连过来。

典型 SFU 架构因此必须满足两个前提

  1. SFU 自己有公网 IP或部署在能被 STUN 探测到的位置,下发 srflx-friendly 的候选。
  2. 业务允许 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 数组, 四个字段的隐私属性如下表:

字段无权限时有权限后跨源/跨会话稳定性
kindaudioinput / audiooutput / videoinput同左仅反映设备类型,敏感度低
deviceId在同源内固定的不透明字符串同左同源内长期稳定,跨源不同
groupId空字符串真实分组 ID同源内稳定,可关联同一物理设备
label空字符串设备标签(MacBook Pro 摄像头取决于设备命名

5.2 deviceId 是稳定指纹

很多团队以为 "没拿到 label 就没指纹",这是错的deviceId 即使在用户未授权时也会返回, 并且在同源 + 同浏览器配置下长期稳定。攻击者可以在用户不知情的情况下:

  1. 调用 enumerateDevices()
  2. 收集 (kind, deviceId) 元组,hash 后作为指纹。
  3. 即使清 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 可解析)不收集
srflxNAT 外侧公网 IP(VPN 出口)对端 + 信令 + 页面不变不收集
prflx同 srflx对端 + 页面不变不收集
relayTURN 服务器 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-referrerCOOP: same-origin 减少其他维度的泄露。

8. getUserMedia 权限模型

8.1 调用前置条件

条件说明例外
Secure Context必须 https://http://localhostfile:// 在部分浏览器有限支持
用户手势iOS Safari 必须由用户点击触发已授权的同源页面
Permissions Policyiframe 必须显式 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 失败时的样子

  • getUserMediaNotAllowedError:用户拒绝或被 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-candidateaddress, port本机候选 IP(mDNS 化后为 .local
remote-candidateaddress, port对端候选 IP(不会 mDNS 化
candidate-pairlocalCandidateId, remoteCandidateId关联具体候选
transportselectedCandidatePairId当前使用的候选对
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. 权威资料