Skip to content
Masking
step 1/5

Reading — step 1 of 5

Read

~2 min readFrame Format

Masking

Every client-to-server frame MUST be masked; every server-to-client frame MUST NOT be. This asymmetry looks arbitrary until you know the attack it kills.

The 2010 cache-poisoning attack

Researchers demonstrated the problem against draft WebSockets: a browser runs attacker-controlled JavaScript, and that JS opens a WebSocket through a transparent proxy that doesn't understand the Upgrade handshake. The proxy thinks it's still relaying plain HTTP bytes. Now the attacker sends a WebSocket "message" whose bytes are chosen to look like:

GET http://victim.com/app.js HTTP/1.1
Host: victim.com
...

The confused proxy parses that as a new HTTP request, forwards it, and caches the attacker-supplied "response" under victim.com/app.js. Every user behind that proxy now gets the attacker's script when they load the victim site. The vulnerability was real enough that browsers disabled WebSockets until the protocol added masking.

How masking works

The client picks a fresh random 4-byte masking key per frame, puts it in the frame header (right after the length field), and XORs every payload byte with it, cycling through the key:

python

Because the key is random per frame, the attacker's JavaScript can no longer control what bytes appear on the wire — the "fake HTTP request" turns to noise before any proxy sees it. That's the entire point: anti protocol-confusion, not encryption. The key rides in the same frame in plaintext; anyone reading the stream can unmask it trivially. Confidentiality is TLS's job (wss://).

Two properties fall out of XOR:

  • Self-inverse: masking and unmasking are the same operation. mask(mask(x)) == x. One function serves both directions.
  • Identity with a zero key: a key of 00000000 leaves bytes unchanged — legal, if pointless.

Why aren't server frames masked? The threat model is attacker-controlled code inside a browser generating traffic. Servers don't run hostile page scripts, so there is no equivalent vector — and skipping the XOR saves work. The rule is enforced both ways: a server receiving an unmasked client frame must fail the connection (close code 1002, protocol error), and a client receiving a masked server frame must do the same.

Walking one masked frame

Hello masked with key 37fa213d:

H=48^37=7f  e=65^fa=9f  l=6c^21=4d  l=6c^3d=51  o=6f^37=58
wire bytes: 81 85 37fa213d 7f9f4d5158
                ^ mask bit set, len=5

Note byte 4 (o) wraps back to mask[0] — the index is i % 4 over the whole payload, not per chunk.

Your exercise

Implement xor_mask and the MASK/UNMASK dispatch. What the grader catches: output must be lowercase hex (bytes.hex() is already lowercase — 7f9f4d5158, not 7F9F4D5158). The empty-payload line MASK 01020304 must print an empty line, not crash with an IndexError — the starter's parts check hands you b"", keep it working. And don't special-case anything: MASK 00000000 deadbeef expects exactly deadbeef, because XOR with zero is the identity.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…