WebRTC 与 ICE:候选地址、会话协商和 IP 泄漏检测
打开一个“WebRTC 泄漏检测”网站,它几秒钟就能列出你的内网地址和公网地址。这件事背后不是网站在“扫描”你的电脑,而是浏览器在为一次可能的点对点连接做准备:收集候选地址,再通过 JavaScript 交给网页。这篇从 WebRTC 的组成讲起,依次讲清 ICE 候选地址、连接建立流程、Offer 和本地描述,最后落到泄漏检测的原理,以及开了代理 TUN 模式后该怎么判断。原对话里说错或说得不全的地方都单独标了出来。
- 说出 Host、srflx、prflx、relay 四种候选地址分别从哪里来
- 讲清从收集候选、交换、连通性检查到选定路径的完整流程
- 解释 createOffer 和 setLocalDescription 各做什么,为什么检测网站不用真的建立连接
- 看懂泄漏检测的结果,分清“地址暴露”和“流量绕过代理”
WebRTC 是什么
WebRTC(Web Real-Time Communication)让浏览器(或其他应用)之间可以尽量直接地传输音视频和任意数据,常见于网页视频会议、语音通话、浏览器间传文件、云游戏和远程桌面。
它不是一个协议,而是一组协议和 API 的组合:
| 组成 | 负责什么 |
|---|---|
| 信令(Signaling) | 双方交换“我想怎么连、我在哪里”的信息。WebRTC 不规定信令怎么做,由应用自己用 HTTP、WebSocket 等实现 |
| SDP | 描述会话的文本格式:有哪些媒体、用什么编码、ICE 和加密参数 |
| ICE | 收集可能的通信地址,逐一测试,选出能用的那条路径 |
| STUN | 让设备得知自己在 NAT 外面“看起来”是什么地址和端口;也被 ICE 用来做连通性检查 |
| TURN | 直连走不通时,提供一个中继服务器转发数据 |
| DTLS / SRTP / SCTP | 加密和传输:音视频走 SRTP(密钥由 DTLS 协商),数据通道走 SCTP over DTLS |
WebRTC 的“点对点”指的是数据尽量直连,不是“完全不需要服务器”:交换连接信息总要靠信令服务器,直连失败时还要靠 TURN 中继。
两个人想当面交接一个包裹,但都住在有门卫的小区里(NAT)。
- 先打电话约好时间和交接方式:这是信令和 SDP。
- 各自去问门卫“外面的人看我是从哪个门出来的”:这是 STUN。
- 把能想到的碰头地点都列出来,挨个试哪儿能碰上:这是 ICE。
- 实在碰不上,就寄放到双方都能去的快递柜:这是 TURN。
- “WebRTC 让两个设备能够在不经过传统服务器转发数据的情况下直接通信”。只说了理想情况:信令必须经过服务器;直连不成功时数据要经 TURN 转发。
- 把流程写成“信令 → STUN → ICE → P2P → 最后才用 TURN”。实际上配置了 TURN 时,中继候选和其他候选是同时收集的,不是直连失败之后才去申请;TURN 只是在其他路径都不通时才被选中(RFC 8445 第 2.1 节)。
ICE 候选地址
ICE 候选地址(ICE candidate)不是单纯的 IP,而是一条“你可以试着从这里找到我”的线索。一台设备通常会收集到好几个。
候选地址里有什么
网页拿到的每个候选地址都是一行文本,格式来自 SDP 的 a=candidate 行:
candidate:842163049 1 udp 1677729535 203.0.113.10 62000 typ srflx raddr 0.0.0.0 rport 9
└基础标识┘ │ │ └─优先级─┘ └──地址──┘ └端口┘ └─类型─┘ └──对应的本地地址──┘
组件 协议typ 后面就是类型;raddr/rport 是它所对应的本机地址。浏览器启用 mDNS 隐藏时,这里会填成 0.0.0.0 和 9,免得从旁边漏出内网 IP。
候选地址的优先级按 RFC 8445 的公式计算:
- 类型偏好:0 到 126,推荐值 host 126、prflx 110、srflx 100、relay 0。
- 本地偏好:0 到 65535,同一类型里区分多块网卡或 IPv4/IPv6。
- 组件 ID:RTP 为 1,RTCP 为 2;现在多数连接复用同一端口,基本都是 1。
一个 host 候选,类型偏好取推荐值 126,本地偏好取最大值 65535,组件 ID 为 1。它的 priority 是多少?
答:126 × 16777216 + 65535 × 256 + 255 = 2,130,706,431。这个数在浏览器里很常见,看到它基本就是本地偏好拉满的 host 候选。
四种候选类型
| 类型 | 从哪里来 | 典型内容 |
|---|---|---|
| host 本机候选 | 本机网卡上的地址,不需要任何服务器 | 192.168.1.10,或被 mDNS 替换成 xxxx.local |
| srflx 服务器反射候选 | 向 STUN 服务器(或 TURN 服务器)发请求,对方告诉你“我看到你来自哪里” | NAT 映射后的公网地址和端口,如 203.0.113.10:62000 |
| prflx 对端反射候选 | 连通性检查时,从对方回过来的 STUN 消息里才发现的新地址 | 收集阶段没发现、检查时冒出来的映射地址 |
| relay 中继候选 | 向 TURN 服务器申请(Allocate)到的转发地址 | TURN 服务器上的 IP 和端口 |
srflx 不是你的电脑真正拥有的地址,而是 NAT 设备为这次通信临时开的映射;relay 则不只是一个地址,TURN 服务器会真的替你转发数据。
- 只讲了 host、srflx、relay 三种。ICE 标准(RFC 8445)和浏览器 API(MDN
RTCIceCandidate.type)都定义了四种,漏了prflx:它在连通性检查阶段才产生,泄漏检测页面上一般看不到,但读chrome://webrtc-internals时会遇到。 - “srflx 通过 STUN 获取”只说了一种来源。TURN 服务器在 Allocate 响应里也会带上你的反射地址(
XOR-MAPPED-ADDRESS),所以只配了 TURN 时同样会得到 srflx(RFC 8656 第 7.3 节、RFC 8445 第 2.1 节)。
从收集到连通:ICE 的完整流程
收集候选 ≠ 建立连接。 收集只是列出“可能的地址”;要配对、检查、提名之后,才知道哪条路真的能走。
交换候选与 Trickle ICE
双方各自收集好候选后,要通过信令把候选发给对方(写在 SDP 里,或单独一条条发)。信令服务器只负责转交这些信息,一般不参与之后的数据传输。
传统做法是等全部收集完再一次性发送。Trickle ICE(Trickle ICE)则是收集到一个就发一个,双方能更早开始检查,连接建立更快。浏览器的 icecandidate 事件就是按这种方式一条条吐出候选的;最后会再触发一次 candidate 为 null 的事件,表示收集结束。
候选对、连通性检查和提名
- 配对:把自己的每个候选和对方的每个候选组合成候选对(candidate pair),按两边优先级排序。
- 连通性检查:在每个候选对上发 STUN Binding 请求,收到对应的响应,说明这条路径双向可达。检查过程中如果发现了新的映射地址,就生成 prflx 候选。
- 提名:ICE 给双方分配角色,控制方(controlling agent)从检查通过的候选对里选一条,再发一个带“提名”标记的 STUN 请求告诉对方(RFC 8445 第 2.3 节)。
- 传输:之后的音视频和数据都走被提名的那一对。如果选中的是 host 或 srflx 组合,TURN 就完全不参与。
| 概念 | 回答的问题 |
|---|---|
| Candidate | 我可能在哪里被联系到? |
| Candidate Pair | 我们试试用哪两个地址通信? |
| Connectivity Check | 这条路通不通? |
| Nominated Pair | 最终走哪条路? |
Mac 和 Windows 不在同一个局域网,各自都收集到了 host、srflx、relay 三种候选。如果两边的 NAT 都允许打洞,最终最可能选中哪种组合?TURN 服务器还会转发数据吗?
想好了,看答案
最可能选中 srflx ↔ srflx(双方都用 NAT 映射后的公网地址直连)。host ↔ host 因为不在同一局域网而检查失败;relay 优先级最低,只有前面都不通时才会被选中。既然选中的是直连路径,TURN 不转发任何数据。
Offer 与本地描述
检测网站的脚本里总有 createOffer() 和 setLocalDescription() 两步。它们本身不负责“获取 IP”,而是 WebRTC 会话协商的起点。
createOffer:写一份会话提议
Offer(SDP offer)只是一份文本,生成它不会向任何人发送数据。只建了一个数据通道时,Offer 大致长这样:
v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:EsAw
a=ice-pwd:bP+XJMM09aR8AiX1jdukzR6Y
a=fingerprint:sha-256 0F:74:31:25:...
a=setup:actpass
a=mid:0
a=sctp-port:5000| 字段 | 含义 |
|---|---|
m=application … UDP/DTLS/SCTP webrtc-datachannel | 声明一个数据通道,跑在 DTLS 之上的 SCTP 上(RFC 8841) |
a=ice-ufrag / a=ice-pwd | ICE 连通性检查用的用户名片段和密码 |
a=fingerprint | DTLS 证书指纹,用来确认对方身份 |
a=setup:actpass | DTLS 角色协商:发起方表示“主动被动都可以” |
a=sctp-port | SCTP 使用的端口号 |
setLocalDescription:把提议正式生效
createOffer() 只是写好了草稿;setLocalDescription(offer) 才把它设为这条连接的本地描述,更新协商状态。W3C 规范注明,这一步可能触发 ICE 候选收集(依据 JSEP,RFC 9429 第 5.9 节),于是浏览器开始收集候选并逐个触发 icecandidate 事件。
正常的双端通信还差一半:对方要回一份 Answer,发起方用 setRemoteDescription() 设为远端描述,ICE 检查才有对象可测。
createOffer() 是写好一封合作邀请函;setLocalDescription() 是盖章确认“我就按这份来”,盖完章秘书(浏览器)就开始去准备各种联系方式(候选地址)。对方回函(Answer)之前,合作其实还没开始。
- “真正让浏览器进入 ICE 收集的就是
setLocalDescription()”。大多数情况如此,但不绝对:如果配置了iceCandidatePoolSize,浏览器可以在调用它之前就预先收集一批候选(W3C 规范RTCConfiguration)。 - 原对话有一段示例把
pc.onicecandidate = …写在await pc.setLocalDescription(offer)之后。规范的写法是先注册监听再开始协商,避免漏掉早到的事件。 - “Offer 不等于网络连接,也不等于已经向对方发送了数据”,这一点是对的。
- 必须先
createDataChannel()(或加一条音视频轨道):没有任何媒体或数据通道时,Offer 里没有m=段,也就不会收集候选。
| 概念 | 解决的问题 |
|---|---|
| Offer / Answer | 双方想建立什么样的会话? |
| Local / Remote Description | 本端和对端正式采用的会话描述 |
| ICE Candidate | 可以通过哪些地址找到我? |
Offer / Answer 负责会话协商,ICE 负责找路。两套机制配合,才是一次完整的 WebRTC 连接。
STUN 到底参与了哪些环节
原对话里你问:“STUN 只在获取 srflx 时有作用,host 和 TURN 都与 STUN 无关,对吧?”原回答说“基本正确”。更准确的说法是:收集 host 候选确实不需要 STUN 服务器,但 STUN 这套消息格式贯穿了 ICE 的好几个环节。
| 环节 | 用到的 STUN 消息 | 和哪类候选有关 |
|---|---|---|
| 收集 host | 不需要 | host |
| 收集 srflx | 向 STUN 服务器发 Binding 请求 | srflx |
| 收集 relay | TURN 的 Allocate、CreatePermission、ChannelBind 都是 STUN 格式的消息;Allocate 响应还会顺带返回 srflx | relay、srflx |
| 连通性检查与提名 | 在每个候选对上发 STUN Binding 请求 | 所有类型,包括 host 和 prflx |
STUN 服务器只在收集 srflx 时用到;但 STUN 协议是 ICE 的“通用语言”,连通性检查、TURN 申请都在用它。
泄漏检测网站做了什么
一段脚本就能读出候选地址
检测网站不需要扫描你的网卡,也不需要向你的路由器发请求。它做的只是让浏览器自己准备一次连接:
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }] // 指定一个 STUN 服务器
});
pc.onicecandidate = (e) => {
if (e.candidate) console.log(e.candidate.candidate); // 每收集到一个就打印
else console.log("收集结束");
};
pc.createDataChannel("probe"); // 让 Offer 里有 m= 段
await pc.setLocalDescription(await pc.createOffer()); // 触发候选收集
它不需要把 Offer 发给任何人、不需要对方回 Answer、也不需要真的建立数据通道。只要本地收集到候选,网页前端就已经读到了。
网页普通请求的来源 IP(HTTP 出口) ── 网站后台直接看到
WebRTC 候选里的 srflx / host / IPv6 ── 前端脚本读到后上报
│
两者比对:候选里有没有出现 HTTP 出口以外的公网地址?
│
有 → 报告“可能泄漏”; 没有 → 至少这一次没发现两个 IP 不一致不一定是泄漏,比如 WebRTC 走了另一个同样属于代理的出口;也要结合地址归属判断。
谁能看到什么
| 角色 | 能看到什么 |
|---|---|
| 网页 JavaScript | 浏览器通过 icecandidate 事件交出来的全部候选 |
| STUN 服务器 | STUN 请求到达时的来源 IP 和端口 |
| 网站后台 | 普通 HTTP 请求的来源 IP,以及脚本主动上报的候选 |
- “STUN 服务器得到的公网 IP 不会自动传给网站后台”。只有当 STUN 服务器是第三方的(比如上面例子里的 Google)才成立。STUN 服务器的地址是网站自己填的:如果填的是网站自己运营的 STUN 服务器,那么来源 IP 一到就被网站看到了,根本不需要脚本上报。
- “是浏览器主动把收集到的候选通过 JavaScript API 提供给网页”,这一点是对的,也是整个检测的关键。
开了 TUN + Global 还会泄漏吗
浏览器默认只用默认路由那块网卡
原对话举例说,Windows 有以太网、Wi-Fi、Clash TUN 三块网卡时,“浏览器可能收集这些网络接口对应的候选地址”。这不是浏览器的默认行为。RFC 8828 定义了四种地址处理模式:
| 模式 | 收集哪些地址 |
|---|---|
| 模式 1:枚举全部 | 所有网卡的地址都用 |
| 模式 2:默认路由 + 对应的本地地址 | 按系统路由表选出默认路由那块网卡,只给出这块网卡的私有地址作为 host 候选 |
| 模式 3:只用默认路由 | 同模式 2,但不给私有地址,只有经 STUN/TURN 得到的地址 |
| 模式 4:强制走代理 | 同模式 3,且浏览器配置了代理时,WebRTC 流量也必须走这个代理;代理不支持 UDP 就改用 TCP |
标准要求:没有得到用户同意时不得使用模式 1,应使用模式 2;摄像头、麦克风授权可以算作一种同意。在此之上,浏览器还会用 mDNS 候选(mDNS ICE candidate)隐藏私有地址。
所以开了 TUN 且接管了系统默认路由之后,按模式 2,浏览器选中的“默认路由网卡”就是 TUN 虚拟网卡本身。
- 以为普通网页能拿到你所有网卡的地址。按 RFC 8828,那需要你先授权(比如允许了摄像头),否则只用默认路由。
- 以为 mDNS 能藏住一切。它只替换私有地址;公网 IPv6 这类本来就公开的地址不一定会被隐藏,所以测试时 IPv6 要单独看。
- 各浏览器的具体默认策略和开启版本没有逐一对照厂商文档核实,以你自己的测试结果为准。
STUN 请求经过 VPS 的三种结果
假设环境如下(地址都是示例):
| 项目 | 地址 |
|---|---|
| Windows 内网 IP | 192.168.1.100 |
| TUN 虚拟网卡 | 198.18.0.1 |
| 家庭公网 IP | 203.0.113.10 |
| VPS 出口 IP | 198.51.100.50 |
| STUN 请求的去向 | STUN 服务器看到 | srflx 候选 | 结论 |
|---|---|---|---|
| 被 TUN 接管,由 VPS 转发出去 | 198.51.100.50 | VPS 地址 | 公网地址没有经 srflx 暴露 |
| 绕过了 TUN,直接从家里路由器出去 | 203.0.113.10 | 家庭公网 IP | 泄漏 |
| 进了 TUN,但代理链路不支持 UDP | 收不到 | 没有 srflx | 不代表安全,还要看 host 和 IPv6 |
“STUN 经过 VPS 就不会泄漏”有三个前提:
- 是 VPS 替你发出去的:请求从 VPS 出口到达 STUN 服务器,而不是只在本机绕了一圈又从原网卡出去。
- UDP 全程有人接:STUN 通常走 UDP,代理客户端、代理协议和服务端都要支持 UDP 转发;不支持时结果是失败,而不是自动变成 VPS 地址。
- 其他候选也没漏:srflx 已经是 VPS 地址,host 候选、IPv6 地址、TURN 候选仍要单独看。
- “开了 Global 就等于所有流量都走 VPS”。Global 是代理规则层面的选择,UDP 能不能被 TUN 接管、代理协议支不支持 UDP、系统有没有 IPv6 旁路,都会影响结果。
地址暴露 ≠ 流量泄漏
| 你想检查的 | 实际在问 |
|---|---|
| 候选地址暴露 | 网页能不能知道我的真实 IP? |
| STUN 流量 | STUN 服务器看到的是家庭 IP 还是 VPS IP? |
| 后续 P2P 流量 | 真正连上之后,UDP 数据有没有绕过代理? |
ICE 会根据双方候选的配对和检查结果选路,所以 STUN 请求经过 VPS,不代表之后的连通性检查和数据都经过 VPS。反过来,host 候选暴露了 192.168.1.100,也不代表互联网上的网站能连进你的内网。
开了 TUN + Global,检测网站显示:HTTP 出口 198.51.100.50(VPS),host 候选 3f2a….local,srflx 198.51.100.50。能不能下结论说“WebRTC 完全不泄漏”?
想好了,看答案
不能。 这次只说明:内网 IPv4 被 mDNS 藏住了,srflx 也是 VPS 地址,家庭公网 IPv4 没有通过候选暴露。还没覆盖的有:IPv6 地址、真正建立 P2P 连接后的数据走向(检测网站通常根本不建立连接),以及换一个 STUN 服务器或浏览器后的情况。
动手测一遍
- 关掉代理,打开 BrowserLeaks WebRTC 或 IPLeak.net,记下真实的公网 IPv4、IPv6 和 WebRTC 候选,作为基线。
- 开启 TUN + Global,刷新同一页面,记下普通 HTTP 检测到的出口 IP。
- 逐条看候选:
typ host(是不是.local)、typ srflx(是 VPS 还是家庭 IP)、有没有出现 IPv6 地址。 - 想看原始过程:在浏览器开发者工具的 Console 里粘贴上一节那段脚本,亲眼看候选一条条出来;把
iceServers改成空数组再跑一次,就只剩 host 候选。
| HTTP 出口 | srflx | 说明 |
|---|---|---|
| VPS | VPS | STUN 出口符合预期 |
| VPS | 家庭公网 IP | STUN 流量绕过了代理,需要排查 |
| VPS | 没有 | STUN 被阻断、没配置或没收集成功,不等于安全 |
| VPS | VPS,但实际连接直连 | 候选没暴露,后续通信路径仍可能泄漏 |
本篇小结
- WebRTC 的点对点是“数据尽量直连”,信令总要服务器,直连失败还要 TURN 中继。
- 候选地址有四种:host(本机网卡)、srflx(STUN/TURN 看到的 NAT 映射)、prflx(检查时发现)、relay(TURN 分配);优先级 host 最高、relay 最低。
- ICE 流程:收集 → 交换(可以 Trickle)→ 配对 → STUN 连通性检查 → 控制方提名 → 传输。
createOffer()写会话提议,setLocalDescription()让它生效并通常触发收集;检测网站只走到这一步就够了。- STUN 服务器只管 srflx,但 STUN 协议贯穿连通性检查和 TURN。
- 浏览器默认只用默认路由那块网卡并用 mDNS 藏私有地址;开 TUN 后重点看 srflx 是不是 VPS、IPv6 有没有漏,并分清“地址暴露”和“流量泄漏”。
拓展
以下内容来自 ChatGPT 结合我之前的提问(Tailscale、UDP 打洞)做的联想,不属于本篇正文,留作后续学习线索。
WebRTC 和 Tailscale 的对照。 两者都要解决“两台在 NAT 后面的设备怎么直连”,思路很像,但定位不同:WebRTC 是一套给应用用的实时通信能力,Tailscale 是把设备组成同一个私有网络的组网产品。
| 对比 | WebRTC | Tailscale |
|---|---|---|
| 定位 | 实时音视频和数据通信 | 设备组网(虚拟局域网) |
| 找路 | ICE + STUN | 自己的 NAT 穿透机制(DISCO 消息) |
| 直连失败时 | TURN 中继 | 先找同网内的 Peer Relay,再退到 DERP 中继 |
| 加密 | DTLS、SRTP | WireGuard(DERP 只转发已加密的包,看不到内容) |
Tailscale 一侧依据官方文档 DERP servers 核实;原对话把 Tailscale 的穿透写成“DERP、STUN、WireGuard 等机制”,文档里 DERP 同时负责协商连接和兜底中继,具体是否使用标准 STUN 未在本次查阅的文档中核实。
下一步可以学:NAT 类型与 UDP 打洞。 现在已经知道 ICE 怎么收集、配对、检查地址;接下来要弄清为什么有些 NAT 环境能打洞成功,有些却只能走 TURN 中继。这和端点无关映射(Endpoint-Independent Mapping)等 NAT 行为有关,可以从 RFC 4787 入手。