Prflxion - a WebRTC ip leak

Prflxion - a WebRTC ip leak

Peer5’s engineering team has identified a security vulnerability we’ve labeled Prflxion, a WebRTC security vulnerability that leaks a user’s local IP address when using Chrome or Edge. This data could be used as a valuable identifier for accurately fingerprinting and targeting users behind network address translation (NAT) by third parties, such as advertising companies or anyone dedicated to mapping internal networks for interested parties. This vulnerability is exploitable on Chromium-based browsers (Chrome, Edge) on all common operating systems.

On June 10, 2021, Peer5 reported the vulnerability to the Chromium development team and by June 15, 2021 a patch was released.

Introduction

WebRTC is a web standard that adds real-time and peer-to-peer communication capabilities to browsers. Peer5 leverages WebRTC to directly connect viewers of a video to one another in order to more efficiently distribute http video traffic.

WebRTC and mDNS

WebRTC technology is part of the browser’s Javascript Document Object Module (DOM) apparatus. It adds the ability to create a direct connection between two browsers and leverage that connection to send data, such as video, between those browsers. In order to achieve such connectivity the browser has to expose additional network capabilities and information to the application layer. WebRTC connections are created after both of the participating peers exchange information about their network interfaces - their local (direct) IP as well as their public IP for NAT traversal.

Even before WebRTC support was integrated into all major browsers, companies (such as the New York Times) figured out how to take advantage of the new standard to gather local IPs for fingerprinting. Even though the New York Times was revealed to be collecting users’ IP addresses all the way back in 2015, It was only closed in 2018, and was widely used in the wild. This vulnerability required a mechanism change in the way WebRTC connects peers. After much discussion, the method chosen to resolve the issue was to obfuscate the local IP address by replacing it with a randomly generated multicast DNS (mDNS) hostname (Chromium, Firefox).

mDNS is a hostname resolution protocol for small networks lacking a DNS name server. It is used today mostly for Internet of Things (IOT) connectivity within local area networks (LANs), like printer discovery, for example.

mDNS packets are designed to be rejected by router forwarding  (TTL 255). So the protocol can resolve names only within a single LAN segment. Because mDNS doesn’t hop routers, it is not effective in large corporate networks.

WebRTC Signaling and Candidates

When a peer connection is created, each peer enumerates and tries to bind all available network interfaces and encode their data (such as IP address, port, etc.) into a data structure known as a candidate. For WebRTC to create a peer-to-peer connection it needs to share the network information between the two peers. Implementing this signaling server is left for the service vendor.

After the candidates are shared amongst the peers, the WebRTC stack is responsible for finding the best way to connect the different browsers. This is done by pairing local candidates (candidates created locally) and remote candidates (candidates received from a remote peer via a signaling server).

Candidates are, in essence, representations of UDP sockets. There are three main types of UDP candidates:

  • Host - a candidate that represents the local IP address of the peer.
  • Server reflexive (srflx) - a candidate that represents the public IP address of the peer (which is created by a STUN server to coordinate a connection between two peers that are both behind a NAT).
  • Peer reflexive (prflx) - a candidate that represents the address of the first peer, as the other peer “sees” it, in case this address is incompatible with the two cases mentioned above. The most common use case is when one peer has a symmetric NAT. In a symmetric NAT the router will create a different port/address mapping for each destination address, so the other peer and the STUN server will see different source ports/addresses in the packets that they receive. As a result, a new candidate will be created in order to represent how the other peer sees the first peer’s address.

Javascript represents candidates as strings containing information such as address, port, protocol and candidate type. This string can be manipulated in the Web client or the signaling server.

Curiosity Killed a Cheshire Cat

From now on, for clarity’s sake, we will use an example of WebRTC connecting two hypothetical peers, one named Alice and the other Bob.

While researching the behavior of WebRTC when connecting two IPv6-enabled peers on different machines, Peer5’s research team discovered an anomaly.

When manually editing Alice’s candidates, replacing the mDNS hostname with Alice’s own local IPv4 address, a strange prflx candidate appeared in Bob’s list of available candidates.

This is an IPv6 address embedding Bob’s IPv4 address. This behavior has repeated itself consistently.  By knowing only one peer address (Alice) we could find the IP address of all the peers connected to it (Bob and anyone else running our JS).

Out of curiosity we set out to find out if we could explain why this anomaly happens and if it could be used to extract the IPv4 address of Bob’s computer without any prior knowledge of Alice’s IPv4 address.

To sum up, the research produced an exploit that obtains the local IP address using javascript without any prior knowledge. It is done by sending an IPv4 STUN packet to an IPv6-defined socket, which then triggers a 4in6 IP translation. This creates a new candidate that skips the mDNS obfuscation step.

Now we dive into more technical analysis.

A Detailed Profile of Prflx

Candidate creation is mostly done at the beginning of the connection process. During this phase, each of the peers enumerates its own network interfaces (more on this later), but some candidates aren’t created until later on. One such candidate is created if the peer finds that its current local host-type candidate is actually a prflx candidate, as occurs in the case of a symmetric NAT.

Like the srflx candidates, prflx candidates are created using the STUN protocol. But unlike srflx candidate creation, in the prflx case the peers themselves are acting as both the STUN client and the STUN server (resolving the STUN requests). Here’s a simple (and very pathological) example:

  • Assume we have a router (gateway) that connects two subnets, subnet A with an IP range of 192.168.10.1/25 (192.168.10.1 - 192.168.10.127) and subnet B with an IP range of 192.168.20.128/25 (192.168.20.128 - 192.168.20.255). This router has IP 192.168.10.1 in subnet A and 192.168.20.128 in subnet B.
  • Alice’s IP is 192.168.10.100/25 (in subnet A) and Bob’s IP is 192.168.20.200/25 (in subnet B), respectively. Alice’s gateway is 192.168.10.1 and Bob’s gateway is 192.168.20.128.
  • Alice and Bob have only one network interface (no VPN, IPv6, or any other NIC).
  • mDNS obfuscation of WebRTC is disabled.
  • A signaling server exists and is reachable by both Alice and Bob (so candidate passing will be transparent in this example).
  • No STUN servers are available or defined.
  • The router is a symmetric NAT, i.e, it maps each IP in subnet A to a respective address in subnet B and vice versa. So when Alice sends a packet to Bob, Bob will receive it with the source address 192.168.20.100, and Alice will receive the response from the source IP, 192.168.10.200.


We’ll now examine the STUN messaging process that initiates when Alice and Bob connect via WebRTC. In our example this will result in creation of a prflx candidate.

Alice and Bob try to connect using WebRTC:

  • Since Alice has only one network interface, she will create only one host-type candidate (local IP address), with an IP of 192.168.10.100. This will be set as Alice’s local candidate.
  • For the same reason, Bob will create only one host-type candidate using his local IP address, 192.168.20.200. This will be set as Bob’s local candidate.
  • After exchanging primary candidates, Alice and Bob will try to create a connection using the candidates. That connection is done with the STUN protocol. Even though no servers were defined, Alice and Bob will use the remote candidates as server adresses. The initiator of the connection acts as the STUN client and the remote peer as the STUN server.
  • A STUN bind request is sent from Alice to Bob. it reaches Bob with a source address of 192.168.20.100 (see above).
  • A STUN bind response is sent from Bob to Alice. This response contains, as part of the STUN protocol, the source IP address of the bind request in a field called XOR_MAPPED_ADDRESS. In our example, this field will contain Alice’s IP address 192.168.20.100.
  • When Alice receives the STUN bind response, she will see her IP address as it’s viewed by Bob in the XOR_MAPPED_ADDRESS attribute. This address is compared to all the local candidates she created and if it’s different from all of them, a new prflx candidate will be created. Since Alice’s local IP address is 192.168.10.100 and the XOR_MAPPED_ADDRESS contains 192.168.20.100 she’ll create a prflx candidate for herself.

The Maybe Candidate

Now that we have a basic understanding of prflx candidates and their creation, let’s get back to the strange prflx candidate anomaly we mentioned earlier.

We have seen that prflx candidates are created after the initial candidate exchange, so a good starting point to search for an explanation of the strange candidate would be to look for it in a nice, self-explanatory function that is called 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);

}


This function is called whenever a STUN_BINDING_RESPONSE is received on a STUN connection. The function gets the XOR_MAPPED_ADDRESS sent as a parameter by the STUN server (which in our example is Alice’s IP address as seen by Bob).

If the address is found within the current candidates, the connection's local candidate is changed to the newfound address. Otherwise, it creates a new local candidate (one that has not been generated or sent by the STUN) and assumes it to be a prflx candidate. As we can see in the code, the port type of this candidate will be PRFLX_PORT_TYPE.

WebRTC will not sanitize this candidate because of a bug in the SanitizeCandidate function. This function is used in the sanitization process of candidates when using the getStats API. This API is used by the application layer (javascript) to receive WebRTC’s performance statistics and connection information.

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);

}



In the case of Alice’s prflx candidate created by MaybeUpdateLocalCandidate, the use_hostname_address is set to false (Since c.type is set to PRFLX_PORT_TYPE), which will cause ToSanitizedCopy to return the candidate without sanitation.

The Exploit

At first glance there seems to be no way to exploit this, i.e., to create an unsanitized candidate with the local IPv4 address. The local IPv4 of Alice is always one of the host candidates created before communication starts. So, creating a prflx candidate with this address is impossible. Technically, we have to pass the following loop in 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;

   }

 }



On the one hand, in order to pass this loop and create an unsanitized candidate, we need the IP in XOR_MAPPED_ADDRESS to be different from any IP address currently present in the WebRTC stack. On the other hand, in order to discover the IPv4, the value in XOR_MAPPED_ADDRESS would have to “be” the local IPv4 address. This seems logically impossible given our contradictory assumption. But if we replace the last assumption with the assumption that the XOR_MAPPED_ADDRESS contains an address that encodes Alice’s IPv4 address, we can achieve an exploit.

We need to find a representation of the local IPv4 in a way that is, on the one hand, different from the candidate addresses created by the interface enumeration, and on the other hand, is an IP address that reaches Alice’s network stack (in order for the packet to reach its destination).

IPv6 can aid us in this task.

4in6 Encapsulation

As can be seen here, there and everywhere, when a client tries to connect using IPv4 to a dual-stack enabled server that listens to an IPv6 (AF_INET6) defined socket on the wildcard address in6addr_any (::), a padding of the IPv4 address to an IPv6 address takes place in the operating system’s kernel. This padded IPv6 address is returned to userland from the getaddrinfo() API (or recvfrom in our case) as the remote host.

For example, assume the following:

  • A client with IPv4 stack with an IP of 192.168.1.24. It has no IPv6 stack enabled.
  • A server with an IPv4 address of 192.168.1.25 and an IPv6 address of 2002:a00:3::1006 that is listening on an AF_INET6, UDP (SOCK_DGRAM) socket, bound to the address :: and port 1338.

When the client connects to 192.168.1.25:1338, the Server's Kernel pads the IPv4 of the client because the server is expecting an IPv6 source IP when recvfrom is called. The server will see the client as ::ffff:192.168.1.24.

We see that this address is on the one hand different from the client’s ip address, but encodes it in IPv6. So it is a kind of address that we would like to pass from Bob to Alice as the XOR_MAPPED_ADDRESS. This behavior can be avoided using setsockopt with the IPV6_V6ONLY flag.

Local Alice and Alice~

To leak the IP address of a computer, we can create a local peer connection on the same computer, using two local RTCPeerConnection instances (we shall name them Alice and Alice~). Our goal is to initiate a STUN_BIND_REQUEST that will create a STUN_BIND_RESPONSE that contains the local IPv4 4in6 encapsulated in the XOR_MAPPED_ADDRESS.

Host Candidates Come to (May)be

When an RTCPeerConnection object is instantiated, it first enumerates the network interfaces to create the host candidates. The host candidates are created in the function Port::AddAddress which calls Port::MaybeObfuscateAddress which creates a different, random mDNS name for each candidate.

MaybeExploitMdnsCandidate

To simplify the example, we will assume the local computer has one NIC with IPv4 and IPv6 interfaces defined.

  • When Alice creates her RTCPeerConnection, we would expect creation of two mDNS candidates - one for the local IPv4 address and another for the IPv6 address (each with its own respective UDP port). The two candidates would have two different mDNS names. In the exploit we ignore the candidates of Alice~.
  • After Alice’s two candidates are ready to be sent to the signaling server, the exploit replaces the mDNS hostname of the IPv6 candidate with the mDNS hostname of  the IPv4 candidate (exactly as the name 4-in-6 implies). It is easily done since the candidate is actually a string (the mdns name is marked in yellow, the port in pink)

candidate:2695439946 1 udp 2113934591 dba007e7-b6cc-4986-a89d-dacd61effe8a.local 62234 typ host generation 0 ufrag Xg+N network-cost 999



  • The exploit sends Alice’s malicious candidate locally to Alice’.

Note that the malicious candidate has the port of the IPv6-defined socket but the mDNS name of the IPv4 address.

When Alice~ will receive the malicious candidate she will resolve the mDNS name contained in the candidates, i.e. the mDNS name of Alice’s IPv4 address.

Now Alice~ will try to connect to Alice using IPv4. Since the port contained in the malicious candidate is bound to an IPv6-defined socket, the 4in6 backwards compatibility mechanism will kick in. As a result, in Alice~’s response to Alice the XOR_MAPPED_ADDRESS will contain the 4in6-encapsulated local IPv4.

As we have seen, this results in adding an unsanitized candidate to the WebRTC stats. Now the local IPv4(in6) address is available via the RTCPeerConnection.getStats API.

A code example can be found here

Some Closing Thoughts

Since the COVID pandemic, video streaming has become a vital tool as businesses, governments and educational institutions have moved to remote working and meeting models.

But even before 2020, WebRTC was already rapidly becoming one of the most prominent technologies for live streaming, underpinning Google Meet, Amazon Chime, Discord, Facebook Messenger and many others, as well as the internal communications of innumerable corporations, representing billions if not trillions in yearly revenue. As time goes on we will no doubt see the standard’s security measures become the target of more and more advanced attacks.

We believe that the most resilient model in preventing these types of exploits is that of open-source software, which allows the entire community to contribute to and fortify a given protocol or product.

Peer5, recently acquired by Microsoft, will do its best to continue to support WebRTC and make sure that it remains the leading, most viable, and most secure alternative to closed-source solutions.

We will continue to develop new and innovative ways of utilizing WebRTC to allow the massive bandwidth generated by video communications to be distributed efficiently and easily despite limited or outdated physical network infrastructure or bandwidth limitations.

Working with Chromium

Peer5 disclosed the vulnerability to the Chromium team via an exploit report and a proof of concept on June 10th, 2021. The Chromium team responded quickly and have been very courteous throughout the process. By June 15th, 2021, five days later, a patch had already been deployed. Chromium security team have not allocated a CVE for the bug.

The bug monorail can be found here