Why a WebSocket client masks its frames

Every frame from a browser is masked with a random key. This simple XOR stops web pages from poisoning proxy caches.

On this page

What masking is

WebSocket data travels in units called frames. When a client sends a frame, it first mixes the payload, the data part of the frame, with a random 4-byte masking key. This step is called masking. The server removes the mask when the frame arrives. Frames from the server to the client are never masked.

The masking key travels inside the same frame, so the mask hides nothing. It exists because of an attack on web proxies. A proxy is a server that passes web traffic between users and websites. Researchers found the attack while the standard was still being written. This post explains that attack and tests a small C# mask function against the example in the standard.

For the full layout of a frame, read the companion post, How a WebSocket frame is built and sent.

The rule in the standard

The WebSocket protocol is defined in RFC 6455 (opens in a new tab). It is an IETF standard, and IETF stands for Internet Engineering Task Force, the group that develops internet standards. Each frame header has a MASK bit that says whether the payload is masked. Section 5.2 (opens in a new tab) states the rule for this bit in one sentence:

All frames sent from client to server have this bit set to 1.

RFC 6455, section 5.2

Section 5.1 (opens in a new tab) adds the other half: a server must never mask a frame. Each side checks the other and closes the connection when the rule is broken. Before it closes, it may send a close frame, the frame that ends a connection. That frame can carry status code 1002, which means "protocol error". The rule also applies over TLS, the encryption that HTTPS uses. A wss:// address means WebSocket over TLS.

DirectionMASK bitMasking keyIf a frame breaks the rule
Client to server14 bytes, new for every frameThe server closes the connection.
Server to client0noneThe client closes the connection.
Table 1 The masking rules in RFC 6455, sections 5.1 and 5.3.

How the mask works

The MASK bit is the highest bit of the second header byte. When it is 1, a 4-byte masking key comes right after the length field. The payload follows the key.

Masking uses XOR, short for "exclusive or". XOR compares two bits. The result is 1 when the bits are different and 0 when they are the same. If you XOR a byte with the same key byte twice, you get the first byte back. So one function can both mask and unmask.

Section 5.3 (opens in a new tab) gives the formula. Byte i of the payload is XORed with byte j of the key, where j = i MOD 4. In other words, the 4 key bytes repeat along the payload. The payload length does not change.

Section 5.7 of the RFC (opens in a new tab) lists a masked frame that carries the text "Hello". The program below unmasks it. Then it masks "Hello" again and compares the result with the bytes in the RFC. Run it with dotnet run mask.cs on the .NET 10 SDK.

C#
using System.Text;

// The masked "Hello" frame from RFC 6455, section 5.7.
byte[] frame = [0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58];

bool masked = (frame[1] & 0x80) != 0; // the MASK bit
int length = frame[1] & 0x7f;         // the 7 bit payload length

// This short parser works for lengths up to 125. The values 126 and 127
// mean that an extended length comes first, before the key.
byte[] key = frame[2..6];             // the 4 byte masking key
byte[] payload = frame[6..(6 + length)];

Console.WriteLine($"MASK bit: {masked}, length: {length}");
Console.WriteLine($"Key:      {Hex(key)}");
Console.WriteLine($"On wire:  {Hex(payload)}");

Mask(payload, key); // the same XOR removes the mask
Console.WriteLine($"Unmasked: {Encoding.UTF8.GetString(payload)}");

// Mask "Hello" with the same key. We must get the bytes from the RFC.
byte[] hello = Encoding.UTF8.GetBytes("Hello");
Mask(hello, key);
Console.WriteLine($"Same as the RFC: {hello.AsSpan().SequenceEqual(frame.AsSpan(6))}");

// The mask hides nothing: 4 known bytes of text give back the key.
byte[] known = Encoding.UTF8.GetBytes("Hell");
Mask(known, frame.AsSpan(6, 4));
Console.WriteLine($"Key from known text: {Hex(known)}");

// Byte i of the data is XORed with byte i MOD 4 of the key.
// The same call masks and unmasks.
static void Mask(Span<byte> data, ReadOnlySpan<byte> key)
{
    if (key.Length != 4)
    {
        throw new ArgumentException("A masking key is 4 bytes.", nameof(key));
    }

    for (int i = 0; i < data.Length; i++)
    {
        data[i] ^= key[i % 4];
    }
}

static string Hex(byte[] bytes) => string.Join(" ", bytes.Select(b => b.ToString("x2")));

It prints these lines:

Output
MASK bit: True, length: 5
Key:      37 fa 21 3d
On wire:  7f 9f 4d 51 58
Unmasked: Hello
Same as the RFC: True
Key from known text: 37 fa 21 3d

Note the last line. Four known bytes of text give you the whole key.

The attack that masking stops

A network can put a transparent proxy between its users and the web. It sits in the network path, and the browser does not know it is there. Some of these proxies also keep a cache. They save a copy of a response and give it to the next user who asks for the same address.

A WebSocket connection opens with an HTTP request that carries an Upgrade header. This header asks the server to switch to another protocol. That request and the server's answer are called the opening handshake. A proxy that does not understand Upgrade still thinks the connection speaks HTTP. So it may read the next bytes from the browser as a new HTTP request.

In 2011, Huang, Chen, Barth, Rescorla and Jackson showed how to abuse this. Their paper is called Talking to Yourself for Fun and Profit (opens in a new tab). The steps below follow the paper and section 10.3 of the RFC (opens in a new tab):

  1. A user behind a transparent proxy with a cache opens a page on the attacker's site.
  2. A script on that page opens a WebSocket connection to the attacker's own server.
  3. The script sends bytes that look like an HTTP request for a script file. Its Host header, the line that names the site, points to another site, for example cdn.example.
  4. The proxy sends the request to the attacker's server, because the connection was opened to it. That server answers with harmful code and asks the proxy to keep it for a long time.
  5. The proxy stores the answer as the file from cdn.example, the name in the Host header. Every user behind the proxy who loads that file now gets the attacker's code. This is called cache poisoning.

This works only on a certain kind of proxy. It sends each request to the address the connection was opened to, yet stores each answer under the name in the Host header. A proxy that sent requests by the Host header would ask the real cdn.example instead.

The authors tested this on real networks through an online ad. They used a test handshake built like the WebSocket Upgrade handshake of that time. In 8 of the 47,338 ad views that completed the handshake, a proxy cache was poisoned. That share is small, but each poisoned cache serves every user behind its proxy.

In November 2010, the authors reported the problem to the IETF working group that was writing the standard. Firefox and Opera turned WebSocket off for a while because of it.

How a random key stops it

The attack also needs control over the exact bytes that go on the wire, that is, the bytes the network carries. Masking takes that control away from the script. The browser picks the key, and the script cannot know it in advance. After the XOR, the bytes on the wire look random. The proxy never sees a line such as GET /app.js HTTP/1.1, so it has nothing to cache. Figure 1 shows the difference.

A fake request with and without a maskTwo rows, each with a page script in a browser, a transparent proxy and a server. In the first row there is no mask. The script sends GET /app.js HTTP/1.1 and Host: cdn.example. The proxy sees a request and caches the reply, and the server sends bad code. In the second row the same bytes are masked with the key 37 fa 21 3d, so they leave the browser as 70 bf 75 1d 18 9b 51 4d and so on. The proxy sees no request and passes the bytes on, and the server unmasks the data.without a maskpage scriptin a browserGET /app.js HTTP/1.1Host: cdn.exampleproxysees a requestcaches the replyserversends bad codewith a mask, key 37 fa 21 3dpage scriptin a browser70 bf 75 1d 18 9b 51 4d19 90 52 1d 7f ae 75 6d ...proxysees no requestpasses bytes onserverunmasks the data
Figure 1 The same bytes from a page script, without and with a mask. In both rows the page and the server belong to the attacker. The second row shows only the first 16 masked bytes.

A frame header is not valid HTTP. That alone would not stop the attack. The RFC notes that nobody can test every broken proxy. Some proxies may skip the bytes they do not understand and then act on the payload.

The mask only helps if the script cannot guess the key or learn it in time. Sections 5.3 and 10.3 add two rules for that:

  • Each frame gets a fresh key from a strong source of random numbers. RFC 4086 (opens in a new tab) describes such sources. A script that could guess the next key could mask its data in advance. The client's own mask would then turn it back into a clean HTTP request.
  • The payload must not change after the client starts to send the frame. Four known bytes reveal the key. If a client allowed such a change, a script could start a long frame with zeros and let its server learn the key. Then the script could change the part that is not sent yet. New data must go in a new frame, with a new key.

Only the client masks, because the attack needs a fake request, and requests go from the client to the server. The RFC notes that data from the server can look like a response. Such a response does no harm unless the client can also fake the request.

The RFC also names a limit. A broken proxy can still be poisoned by a client or server that does not mask. Masking protects it from scripts in web pages, because the browser masks everything those scripts send.

Masking is not encryption

Masking gives you no privacy. Its key sits in the frame header, right before the data. Anyone who can read the frame can remove the mask.

In the paper, the authors first proposed full encryption with AES-128 in counter mode. AES is a standard cipher, which means a method that scrambles data with a secret key. Counter mode is one way to run it over data of any length, and AES-GCM and why a nonce must never repeat shows how it works. Each frame would get a new random value. This encryption had one purpose: to make the bytes look random to proxies. In the end, the working group chose a simpler XOR with a 32-bit key. With small frames, the paper's speed tests showed AES slowing down a lot, while XOR masking kept an acceptable speed.

One weak point remains, and the paper names it. Because the 4-byte key repeats, a script can still control how some bytes on the wire relate to each other. Its authors knew of no way to use this in an attack.

To keep data private, use a wss:// address. It runs WebSocket over TLS, which encrypts the whole connection. Inside TLS, the client still masks every frame.

What .NET does for you

In most code you never write the mask yourself. In .NET, ClientWebSocket masks the frames it sends. So does a WebSocket that you create with WebSocket.CreateFromStream (opens in a new tab) and isServer: false. The program below sends "Hello" twice into a MemoryStream, a stream that keeps its bytes in memory. Then it prints the raw bytes.

C#
using System.Net.WebSockets;
using System.Text;

// A client WebSocket that writes into memory, so we can read its bytes.
var wire = new MemoryStream();
using WebSocket client = WebSocket.CreateFromStream(
    wire, isServer: false, subProtocol: null, keepAliveInterval: TimeSpan.Zero);

// Send the same text twice.
byte[] hello = Encoding.UTF8.GetBytes("Hello");
await client.SendAsync(hello, WebSocketMessageType.Text, true, CancellationToken.None);
await client.SendAsync(hello, WebSocketMessageType.Text, true, CancellationToken.None);

// Each "Hello" frame is 11 bytes: a 2 byte header, the 4 byte key, then 5 bytes of text.
byte[] bytes = wire.ToArray();
byte[][] frames = [bytes[0..11], bytes[11..22]];

foreach (byte[] frame in frames)
{
    byte[] key = frame[2..6];
    byte[] payload = frame[6..];
    Console.WriteLine($"Frame: {Hex(frame)}");

    Mask(payload, key);
    Console.WriteLine($"  key {Hex(key)}, text {Encoding.UTF8.GetString(payload)}");
}

Console.WriteLine($"Total bytes written: {bytes.Length}");
Console.WriteLine($"Different keys: {!frames[0][2..6].SequenceEqual(frames[1][2..6])}");

static void Mask(Span<byte> data, ReadOnlySpan<byte> key)
{
    if (key.Length != 4)
    {
        throw new ArgumentException("A masking key is 4 bytes.", nameof(key));
    }

    for (int i = 0; i < data.Length; i++)
    {
        data[i] ^= key[i % 4];
    }
}

static string Hex(byte[] bytes) => string.Join(" ", bytes.Select(b => b.ToString("x2")));

One run printed the lines below. The keys change on every run.

Output
Frame: 81 85 0a de b1 5a 42 bb dd 36 65
  key 0a de b1 5a, text Hello
Frame: 81 85 be 33 66 6c f6 56 0a 00 d1
  key be 33 66 6c, text Hello
Total bytes written: 22
Different keys: True

Both frames carry the same text, yet the bytes differ, because each frame has its own key. In the second byte, 85, the MASK bit is set and the length is 5.

On the receiving side, .NET checks the rule as well. Give the unmasked "Hello" frame, 81 05 48 65 6c 6c 6f, to a WebSocket created with isServer: true. On .NET 10, ReceiveAsync throws a WebSocketException with the message "The WebSocket client sent an unmasked frame." Its state becomes Aborted, and it writes back 88 02 03 ea, a close frame with status code 1002. A client that reads a masked frame fails with "The WebSocket server sent a masked frame."

If you write your own WebSocket code

  • On the client, set the MASK bit on every frame. Control frames such as ping and close need it too.
  • Take a new key for each frame from a cryptographic random number generator. In .NET, use RandomNumberGenerator.Fill (opens in a new tab).
  • Mask your own copy of the payload, so the application cannot change it while the frame is being sent.
  • On the server, never mask. Close the connection when a frame arrives without a mask.
  • Use wss:// whenever the data must stay private.

Read more

More posts