đź§  WebSocket Exploitation

What is it?

  • Concept: A WebSocket connection starts as a plain HTTP request carrying Upgrade/Connection/Sec-WebSocket-* headers, then — after a 101 Switching Protocols response — switches to a framed binary protocol (RFC 6455) for the rest of the connection’s life. Standard HTTP tooling (curl, requests, most “fetch a URL” server-side utilities) has no concept of this: it can hit the exact same endpoint but will never send the upgrade headers or speak the frame protocol afterward, so a WS-only route stays effectively unreachable through generic HTTP client automation.
  • Impact: WebSocket routes are an easy place for a missing authentication check to slip through — especially when a sibling HTTP/WS route on the same service correctly checks auth and this one doesn’t (see HTB Machine - Cohort, CVE-2026-39987). Building a client by hand is also frequently the only way to actually reach a WS endpoint at all, when the target hostname doesn’t resolve via normal DNS (private CTF lab domains) or a high-level library’s automatic Host/SNI handling gets in the way of testing a specific hostname against a specific IP.

How it works

  1. The handshake is a plain HTTP GET with Connection: Upgrade, Upgrade: websocket, Sec-WebSocket-Key: <base64 of 16 random bytes>, and Sec-WebSocket-Version: 13. A 101 Switching Protocols response with a matching Sec-WebSocket-Accept confirms the upgrade succeeded — anything else (403, 404, a normal HTML page) means the request never actually upgraded.
  2. After the upgrade, both sides speak framed messages: a short header (FIN bit, opcode, MASK bit, payload length) followed by the payload. The opcodes that matter: 0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong.
  3. Client-to-server frames must be masked — the header carries a random 4-byte mask key, and the payload is XORed against it. Servers can reject unmasked client frames outright.
  4. A generic server-side “fetch this URL” primitive (e.g. an SSRF sink that just issues a plain outbound GET) fundamentally cannot drive this handshake, no matter how the URL is crafted — it has no persistent connection to hold open and no frame-protocol implementation. This is a structural limitation, not a filter to bypass.

Exploitation

Prerequisites

  • Network-level (TCP) reachability to the WS endpoint — a one-shot HTTP fetch primitive (SSRF, webhook, etc.) is not sufficient to complete or use a WS session, only to prove one exists

Attack Vectors

Minimal handshake by hand. Useful whenever a library’s own DNS/SNI/Host handling gets in the way of testing a specific IP + hostname pair cleanly — e.g. a private lab domain with no real DNS record, where you need to connect to a known IP while presenting an arbitrary hostname as both the TLS SNI and the Host header:

import socket, ssl, base64, os
 
def ws_handshake(ip, host, path, port=443):
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    key = base64.b64encode(os.urandom(16)).decode()
    req = (f"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: Upgrade\r\n"
           f"Upgrade: websocket\r\nSec-WebSocket-Key: {key}\r\n"
           f"Sec-WebSocket-Version: 13\r\n\r\n").encode()
    sock = ctx.wrap_socket(socket.create_connection((ip, port)), server_hostname=host)
    sock.sendall(req)
    return sock.recv(4096)  # look for "HTTP/1.1 101 Switching Protocols"

This sidesteps two library-level headaches at once: DNS resolution (connects straight to ip) and TLS SNI/Host mismatches some high-level libraries introduce when you try to override the connection target separately from the URL’s hostname — e.g. Python’s websockets library conflating a host= override with what Host header actually gets sent, producing a silent redirect the library then chokes on trying to follow as a ws:// URI.

Sending/receiving frames by hand once connected.

import struct, os
 
def send_ws_frame(sock, payload: bytes, opcode=0x1):  # 0x1=text, 0x2=binary
    mask = os.urandom(4)
    masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
    length = len(payload)
    first_byte = 0x80 | opcode  # FIN=1
    if length < 126:
        header = struct.pack("!BB", first_byte, 0x80 | length)
    elif length < 65536:
        header = struct.pack("!BBH", first_byte, 0x80 | 126, length)
    else:
        header = struct.pack("!BBQ", first_byte, 0x80 | 127, length)
    sock.sendall(header + mask + masked)  # client frames MUST be masked

Two easy-to-miss gotchas that silently break a hand-rolled client:

  • Opcode matters. Some server handlers explicitly call something like Starlette’s websocket.receive_text(), which rejects/ignores binary (0x2) frames. If input stops working (or the connection drops) with no obvious error, check you’re sending the opcode the server actually expects.
  • Ping/pong keepalive. Most WS servers periodically send a ping (0x9) frame and expect a pong (0xA) back within some timeout, closing the connection if none arrives. A client that only ever reads “data” frames and ignores pings will work fine at first and then mysteriously die after a fixed interval — always branch on the opcode and reply to pings.

Application-level control messages riding inside the session. Some services multiplex control messages as JSON text frames alongside raw data (e.g. a terminal-over-WebSocket service handling a PTY resize):

import json
def send_resize(sock, cols, rows):
    send_ws_frame(sock, json.dumps({"type": "resize", "cols": cols, "rows": rows}).encode())

Worth checking the target’s own source (if available) for this kind of side-channel protocol rather than assuming a WS session is “just” raw bytes in both directions — the resize handling above only exists because the backend’s source was readable (open-source project) and defined it explicitly. (from HTB Machine - Cohort)

Notes

  • A 403/404/generic HTML response instead of 101 Switching Protocols when probing a suspected WS endpoint with curl/requests doesn’t necessarily mean the route doesn’t exist — plain HTTP clients never send the Upgrade headers, and frameworks (Starlette/FastAPI in particular) commonly reject any non-upgrade request to a @app.websocket(...)-decorated route outright.
  • SSRF/generic-fetch primitives cannot drive a WebSocket handshake, full stop — don’t burn time on scheme tricks (ws:// vs http://) against a fetcher that only ever issues plain GETs. The limitation is structural (no persistent connection, no frame-protocol support), not a filter to bypass.
  • websocat -k is the fast, interactive equivalent of everything above. Reach for a hand-rolled client only when scripted control is needed, when DNS/SNI weirdness has to be worked around, or when the target has its own control-frame protocol (resize, etc.) that a generic CLI tool doesn’t expose.

Mitigation

  • WebSocket routes need the same authentication/authorization checks as their HTTP siblings. A missing auth-validation call on one WS route while a sibling route correctly has it (as in CVE-2026-39987) is an easy, easy-to-miss regression — test every WS route explicitly rather than assuming framework-level middleware covers them all uniformly.
  • Rate-limit/monitor unauthenticated WebSocket upgrade attempts the same way login endpoints are monitored — a raw hand-rolled client is indistinguishable from a legitimate one at the TCP/TLS layer.
FileCreated
HTB Machine - CohortMonday, August 10th 2026, 12:30:58 pm

References: