đź§ WebSocket Exploitation
What is it?
- Concept: A WebSocket connection starts as a plain HTTP request carrying
Upgrade/Connection/Sec-WebSocket-*headers, then — after a101 Switching Protocolsresponse — 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
- The handshake is a plain HTTP
GETwithConnection: Upgrade,Upgrade: websocket,Sec-WebSocket-Key: <base64 of 16 random bytes>, andSec-WebSocket-Version: 13. A101 Switching Protocolsresponse with a matchingSec-WebSocket-Acceptconfirms the upgrade succeeded — anything else (403,404, a normal HTML page) means the request never actually upgraded. - 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:
0x1text,0x2binary,0x8close,0x9ping,0xApong. - 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.
- 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 maskedTwo 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 apong(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 of101 Switching Protocolswhen probing a suspected WS endpoint withcurl/requestsdoesn’t necessarily mean the route doesn’t exist — plain HTTP clients never send theUpgradeheaders, 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://vshttp://) 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 -kis 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.
Related Usage
| File | Created |
|---|---|
| HTB Machine - Cohort | Monday, August 10th 2026, 12:30:58 pm |
References: