Peer5工程师团队发现了一个WebRTC安全漏洞,我们将其归类为Prflxion。在用户使用Chrome或Edge时,该漏洞会泄露用户的本地IP地址。而该数据作为一个有价值的标识符,可能会被第三方(如广告公司或任何专门为感兴趣的人映射内部网络的人)用来准确识别、定位NAT(网络地址转换)背后的用户。该漏洞存在于所有通用操作系统上基于Chromium的浏览器(Chrome,Edge)。
2021年6月10日,Peer5向Chromium开发团队报告了该漏洞。2021年6月15日该漏洞的修复补丁发布。
简介
WebRTC是一项增加浏览器实时通信和端对端通信功能的网络标准。Peer5用WebRTC把不同视频的观众连接在一起,以便更有效分配http视频流量。
WebRTC和mDNS
WebRTC技术属于浏览器Javascript DOM(文档对象模型)装置的一部分。它增强了两个浏览器间建立直连的能力,并且利用连接在浏览器间发送视频等数据。为实现这种连接,浏览器必须向应用层展示附加网络功能和信息。WebRTC的连接建立在参与端交换各自网络接口的信息后。该信息包括他们的本地(直接)IP,以及用于NAT遍历的公共IP。
其实,早在WebRTC支持集成到所有主流浏览器之前,一些公司(如纽约时报)就已经在利用这个新标准收集本地IP,进行指纹识别。尽管早在2015年,《纽约时报》就被爆出收集用户的IP地址,但该动作在2018年才被禁止,且私下仍被广泛使用。要解决这个漏洞,就需要对WebRTC连接各端的方式进行系统改变。经过多次讨论,选择用随机生成的mDNS(多播DNS)主机名(Chromium,Firefox)混淆本地IP地址。
mDNS是一种主机名解析协议, 适用于不包含DNS名称服务器的小型网络。如今它主要用于LAN(局域网)的IOT(物联网)连接,比如printer discovery。
mDNS数据包默认被路由器转发(TTL 255)拒绝。因此mDNS协议只能在单个LAN段内解析名称。且mDNS无法跳过路由器,所以它并不适用于大型企业网络。
WebRTC信令和Candidates
当创建端对端连接时,每一端列举并尝试绑定所有可用的网络接口,再将其数据(如IP地址、端口等)编码到一个名为candidate的数据结构中。若WebRTC要创建端对端连接,它需要在两端之间分享网络信息。而服务供应商负责实现这个信令服务器。
在两端之间共享candidates后,WebRTC堆栈负责寻找连接不同浏览器的最佳方式。这需要配对本地创建的候选者和远程候选者(即通过信令服务器从远程一端收到的candidate)来实现。
本质上,candidate就是UDP套接字。有三种主要类型的UDP candidate。
- 主机candidate:对端本地IP地址的candidate。
- 服务器反向(srflx)candidate:对端公共IP地址的candidate(由STUN服务器创建,以协调在NAT后的两端间的连接)。
- Peer reflexive (prflx) :在其他端“看到”第一个对端地址时,作为第一个对端地址的candidate出现,以防这个地址与之前两种情况不兼容。最常见的用例出现在当一端有一个对称NAT时——在对称NAT中,路由器会给每个目标地址创建不同的端口/地址映射。所以另一端和STUN服务器会在他们收到的数据包中看到不同的源端口/地址。为解决这个问题,就要创建一个新的candidate地址,来代表其他端“看到的”第一个对端地址。
Javascript将候选人表示为包含地址、端口、协议和candidate类型等信息的字符串。这个字符串可以在web客户端,或信令服务器中进行操作。
安全漏洞
为通俗易懂,下文中我们会用WebRTC连接两端来举例说明。假设一端名为Alice,另一端为Bob。
在研究WebRTC在不同机器上连接两个支持IPv6对等端的行为时,Peer5的团队发现了一个异常现象:
在手动编辑Alice的candidate时,如果用Alice自己的本地IPv4地址替换mDNS主机名,会有一个陌生的prflx candidate出现在Bob的可用candidate列表中。
该candidate是一个嵌入Bob IPv4地址的IPv6地址。这种行为会一直重复。所以,只要知道一个对等端地址(Alice),我们就可以找到与之相连的所有对等端的IP地址(即Bob和其他运行我们JS的人)。
出于好奇,我们着手研究是否能解释这种异常现象发生的原因,以及是否能在事先不知道Alice IPv4地址的情况下,利用它来抓取Bob电脑的IPv4地址。
通过研究,团队发现了一个漏洞。尽管事先没有任何准备,该漏洞利用javascript就能获得本地IP地址。具体方法是:先发送一个IPv4 STUN数据包到一个IPv6定义的套接字,随即触发一个4in6 IP转换。这样就生成了一个新的candidate,跳过了mDNS混淆这一步。
接下来我们进行技术分析。
Prflx详述
candidate的创建主要是在连接过程的开始阶段完成的。在这个阶段,每一端都会列举自己的网络接口(这一点稍后会详细介绍)。但也有一些candidate是后来才创建的。在对称NAT情况下,如果对等端发现自己当前的本地主机类型candidate实际上是一个prflx candidate,其就会创建一个这样的candidate。
像srflx candidate一样,prflx candidate是使用STUN协议创建的。但与srflx candidate的创建不同,在prflx情况中,对等端本身既是STUN客户,又是STUN服务器(解决STUN请求)。举一个简单(又极端的)的例子:
- 假设我们用一个路由器(网关)连接两个子网,子网A的IP范围是192.168.10.1/25(192.168.10.1 – 192.168.10.127),子网B的IP范围是192.168.20.128/25(192.168.20.128 – 192.168.20.255)。这个路由器在子网A中的IP为192.168.10.1,在子网B中的IP为192.168.20.128。
- Alice端的IP是192.168.10.100/25(在子网A),Bob端的IP是192.168.20.200/25(在子网B)。Alice的网关是192.168.10.1,Bob的网关是192.168.20.128。
- Alice和Bob只有一个网络接口(没有VPN、IPv6或任何其他NIC)
- WebRTC的mDNS混淆功能被禁用。
- 有一个信令服务器。且Alice和Bob都可以到达这个服务器(所以在本例中,candidate传递是透明的)。
- 没有STUN服务器可用或被定义。
- 路由器是一个对称的NAT,也就是说它会把子网A的每个IP映射到子网B中相应的地址,反之亦然。因此,若Alice向Bob发送一个数据包,Bob会以源地址192.168.20.100的方式接收,Alice会从源IP192.168.10.200接收响应。
接下来,我们会检验Alice和Bob通过WebRTC连接时启动的STUN消息传递过程。在我们的研究中,该过程会导致prflx candidate的创建。
让Alice和Bob尝试用WebRTC进行连接:
- 由于Alice只有一个网络接口,所以使用它的IP192.168.10.100,只能创建一个主机类型的candidate(本地IP地址)。它会被设置为Alice的本地candidate。
- 同理可得,Bob也只创建一个主机类型的candidate,使用他的本地IP地址192.168.20.200。这将被设置为Bob的本地candidate。
- 在交换了主candidate之后,Alice和Bob尝试用candidate创建连接。该连接通过STUN协议完成。即使没有定义服务器,Alice和Bob也能使用远程candidate作为服务器地址。连接的发起者作为STUN客户端,而远程端作为STUN服务器。
- 一个STUN绑定请求会从Alice发送到Bob端,到达Bob的源地址是192.168.20.100(见上文)。
- 一个STUN绑定响应会从Bob发送到Alice端。作为STUN协议的一部分,该响应包含了绑定请求的源IP地址(来自名为XOR_MAPPED_ADDRESS的字段。在本例中,这个字段会包含Alice的IP地址192.168.20.100)。
- Alice收到STUN绑定响应时,会看到自己的IP地址,因为该地址被Bob在XOR_MAPPED_ADDRESS属性中看到了。该地址会和Alice创建的所有本地candidate地址进行比较,如果它与所有的candidate地址不同,一个新的prflx候选地址会被创建。由于Alice的本地IP地址是192.168.10.100,而XOR_MAPPED_ADDRESS包含192.168.20.100,所以她会给自己创建一个prflx candidate。
潜在candidate
鉴于现在我们基本了解了 prflx candidate和其创建,现在回到前面提到的陌生 prflx candidate异常情况。
我们知道,prflx candidate是在最初candidate交换后创建的,所以要解释这个陌生candidate,最好是用一个完美、明晰的函数中理清思路,这个函数叫做 Connection::MaybeUpdateLocalCandidate。
void Connection::MaybeUpdateLocalCandidate(ConnectionRequest* request, StunMessage* response) { // RFC 5245 // The agent checks the mapped address from the STUN response. If the // transport address does not match any of the local candidates that the // agent knows about, the mapped address represents a new candidate -- a // peer reflexive candidate. const StunAddressAttribute* addr = response->GetAddress(STUN_ATTR_XOR_MAPPED_ADDRESS); if (!addr) { RTC_LOG(LS_WARNING) << "Connection::OnConnectionRequestResponse - " "No MAPPED-ADDRESS or XOR-MAPPED-ADDRESS found in the " "stun response message"; return; } for (size_t i = 0; i < port_->Candidates().size(); ++i) { if (port_->Candidates()[i].address() == addr->GetAddress()) { if (local_candidate_index_ != i) { RTC_LOG(LS_INFO) << ToString() << ": Updating local candidate type to srflx."; local_candidate_index_ = i; // SignalStateChange to force a re-sort in P2PTransportChannel as this // Connection's local candidate has changed. SignalStateChange(this); } return; } } // RFC 5245 // Its priority is set equal to the value of the PRIORITY attribute // in the Binding request. const StunUInt32Attribute* priority_attr = request->msg()->GetUInt32(STUN_ATTR_PRIORITY); if (!priority_attr) { RTC_LOG(LS_WARNING) << "Connection::OnConnectionRequestResponse - " "No STUN_ATTR_PRIORITY found in the " "stun response message"; return; } const uint32_t priority = priority_attr->value(); std::string id = rtc::CreateRandomString(8); // Create a peer-reflexive candidate based on the local candidate. Candidate new_local_candidate(local_candidate()); new_local_candidate.set_id(id); new_local_candidate.set_type(PRFLX_PORT_TYPE); new_local_candidate.set_address(addr->GetAddress()); new_local_candidate.set_priority(priority); new_local_candidate.set_related_address(local_candidate().address()); new_local_candidate.set_foundation(Port::ComputeFoundation( PRFLX_PORT_TYPE, local_candidate().protocol(), local_candidate().relay_protocol(), local_candidate().address())); // Change the local candidate of this Connection to the new prflx candidate. RTC_LOG(LS_INFO) << ToString() << ": Updating local candidate type to prflx."; local_candidate_index_ = port_->AddPrflxCandidate(new_local_candidate); // SignalStateChange to force a re-sort in P2PTransportChannel as this // Connection's local candidate has changed. SignalStateChange(this); }
每当STUN连接上收到STUN绑定响应,该函数就被调用。该函数会获取由STUN服务器发送的,作为参数的XOR_MAPPED_ADDRESS(即上述例子中,Bob看到的Alice的IP地址)。
如果在当前的candidate地址中找到了该地址,连接的本地candidate地址会更改为新找到的地址。如果没找到,一个新的candidate地址(一个没有被STUN生成或发送的地址)会被创建,并假定它是一个prflx candidate地址。正如我们在上述代码中看到的,这个candidate的端口类型是PRFLX_PORT_TYPE。
由于SanitizeCandidate函数中的bug,WebRTC不会检验该candidate。在使用getStats API(应用层(javascript)使用API来接收WebRTC的性能数据和连接信息)时,该函数会用于candidate的检验过程。
Candidate PortAllocator::SanitizeCandidate(const Candidate& c) const { CheckRunOnValidThreadAndInitialized(); // For a local host candidate, we need to conceal its IP address candidate if // the mDNS obfuscation is enabled. bool use_hostname_address = c.type() == LOCAL_PORT_TYPE && MdnsObfuscationEnabled(); // If adapter enumeration is disabled or host candidates are disabled, // clear the raddr of STUN candidates to avoid local address leakage. bool filter_stun_related_address = ((flags() & PORTALLOCATOR_DISABLE_ADAPTER_ENUMERATION) && (flags() & PORTALLOCATOR_DISABLE_DEFAULT_LOCAL_CANDIDATE)) || !(candidate_filter_ & CF_HOST) || MdnsObfuscationEnabled(); // If the candidate filter doesn't allow reflexive addresses, empty TURN raddr // to avoid reflexive address leakage. bool filter_turn_related_address = !(candidate_filter_ & CF_REFLEXIVE); bool filter_related_address = ((c.type() == STUN_PORT_TYPE && filter_stun_related_address) || (c.type() == RELAY_PORT_TYPE && filter_turn_related_address)); return c.ToSanitizedCopy(use_hostname_address, filter_related_address); }
在MaybeUpdateLocalCandidate函数创建的prflxcandidate Alice中,use_hostname_address被设置为false(因为c.type被设置为PRFLX_PORT_TYPE),这会导致ToSanitizedCopy返回一个没有检验过的candidate。
漏洞
乍一看,我们似乎没有办法利用这一点,即用本地的IPv4地址创建一个未经检验的candidate。因为在通信开始前,Alice的本地IPv4总是被作为候选主机之一,已经创建出来了。所以用这个地址创建一个prflx candidate是不可能的。技术上讲,我们必须在MaybeUpdateLocalCandidate中通过以下循环。
for (size_t i = 0; i < port_->Candidates().size(); ++i) { if (port_->Candidates()[i].address() == addr->GetAddress()) { if (local_candidate_index_ != i) { RTC_LOG(LS_INFO) << ToString() << ": Updating local candidate type to srflx."; local_candidate_index_ = i; // SignalStateChange to force a re-sort in P2PTransportChannel as this // Connection's local candidate has changed. SignalStateChange(this); } return; } }
首先,为了通过这个循环并创建一个未检验的candidate, XOR_MAPPED_ADDRESS中的IP需要和WebRTC堆栈中当前存在的任何IP地址都不同。同时,为了发现IPv4,XOR_MAPPED_ADDRESS中的值必须 “是”本地IPv4地址。由于假设存在矛盾,这在逻辑上似乎是不可能的。但如果用XOR_MAPPED_ADDRESS包含一个编码Alice的IPv4地址,我们就可以用本地的IPv4地址创建一个未经检验的candidate。
我们需要找到一个本地IPv4的表达方式,一方面要与enumeration 接口创建的candidate地址不同,另一方面,该方式要是一个可以到达Alice网络堆栈的IP地址(以便数据包到达目的地)。
IPv6可以帮助我们完成这一任务。
4-in-6封装
从文章一、文章二、文章三可以看出,若客户端试图用IPv4连接到一个启用双协议栈的服务器,而该服务器监听的是通配符地址in6addr_any(::)上定义的IPv6(AF_INET6)套接字时,操作系统的内核会出现IPv4地址转换为IPv6的情况。被填充的IPv6地址会作为远程主机,从getaddrinfo()API(在我们的例子中是recvfrom)返回到用户区。
例如,我们假设:
- 一个使用IPv4协议栈的客户端,其IP为192.168.1.24,且没有启用IPv6堆栈。
- 一个IPv4地址为192.168.1.25,IPv6地址为2002:a00:3::1006的服务器,正在UDP(SOCK_DGRAM)套接字,AF_INET6上监听,绑定到地址::和端口1338。
当客户端连接到192.168.1.25:1338时,服务器内核会填充客户端的IPv4。因为当recvfrom被调用时,服务器预想的事一个IPv6的源IP。服务器会把客户端设为::fffff:192.168.1.24。
我们看到,这个地址与客户的ip地址不同,但却用IPv6编码了地址。所以它是我们希望从Bob传递给Alice的XOR_MAPPED_ADDRESS地址。使用带有IPV6_V6ONLY标志的setockopt可以避免这种行为。
本地Alice和Alice~
要泄露一台计算机的IP地址,我们要使用两个本地RTCPeerConnection实例(将它们命名为Alice和Alice~),在同一台计算机上创建一个本地端到端连接。目标是发起一个STUN_BIND_REQUEST,它会创建一个STUN_BIND_RESPONSE,这其中包含封装在XOR_MAPPED_ADDRESS的本地IPv4 4in6。
创建主机candidate
一个RTCPeerConnection对象被实例化时,首先它会枚举网络接口以创建主机candidate。主机candidate是在函数Port::AddAddress中创建的,该函数调用Port::MaybeObfuscateAddress,为每个主机candidate创建一个不同的、随机的mDNS名称。
MaybeExploitMdnsCandidate
为了简化这个例子,我们假设本地计算机有一个定义了IPv4和IPv6接口的网卡。
- 当Alice创建了RTCPeerConnection,我们假设能创建两个mDNS candidate:一个用于本地IPv4地址,另一个用于IPv6地址(每个都有自己的UDP端口)。这两个candidate会有不同的mDNS名称。在该漏洞中我们忽略Alice~的candidate。
- 在Alice的两个candidate准备好被发送到信令服务器后,该漏洞将IPv6candidate的mDNS主机名替换为IPv4candidate的mDNS主机名(正如名称4-in-6所暗示的)。这很容易做到,因为candidate实际上是一个字符串(如下图,mdns名称用黄色标记,端口用粉色标记)。
![](https://webrtc.org.cn/wp-content/uploads/2022/03/WechatIMG1.png)
- 该漏洞将Alice本地的恶意candidate发送到Alice~。
需要注意,恶意candidate有IPv6定义的套接字端口,但没有IPv4地址的mDNS名称。
当Alice~收到恶意candidate时,她会解析candidate中包含的mDNS名称,即Alice IPv4地址的mDNS名称。
然后,Alice~会尝试通过IPv4连接到Alice。由于恶意candidate中包含的端口被绑定到IPv6定义的套接字上,这就触发了4in6向后兼容机制。因此,在Alice~对Alice的响应中,XOR_MAPPED_ADDRESS会包含4in6封装的本地IPv4。
我们可以看到,这会导致WebRTC统计中加入了一个未被规范的candidate,本地IPv4(in6)地址可以通过RTCPeerConnection.getStats API获得。
代码示例请点击这里。
总结
疫情爆发以来,随着企业、政府和学校转向在线工作及会议模式,视频直播已经成为一个重要工具。
但即使在2020年,疫情爆发之前,WebRTC也迅速成长为最突出的直播技术之一,成为Google Meet、Amazon Chime、Discord、Facebook Messenger和其他许多技术,以及无数企业内部通信的基石。每年都创造出数十亿甚至上万亿的收入。随着时间推移,我们无疑会看到该标准的安全措施受到越来越多的高级攻击。
我们相信,解决这些漏洞最有效的方式是开源软件,它允许整个社区对一个特定的协议或产品进行贡献和强化。
最近被微软收购的Peer5会尽最大努力,继续支持WebRTC。确保它仍然是针对闭源解决方案,最前沿、最可行和最安全的替代选择。
我们也会继续开发利用WebRTC的新方法,在网络基础设施有限、过时或带宽有限的情况下,有效、便捷地分配视频通信产生的大量带宽。
与Chromium合作
Peer5在2021年6月10日通过一份漏洞报告和概念证明,向Chromium团队展示了该漏洞。Chromium团队迅速回应,并在整个过程中表现得非常有礼貌。2021年6月15日,即五天后,谷歌部署了补丁。但目前Chromium安全团队还没有为该漏洞分配一个CVE。
点击此处,查看该bug monorail。
文章地址:https://blog.peer5.com/prflxion-or-how-we-uncovered-a-webrtc-ip-leak/
原文作者:Amir Pirogovsky