A WebSocket security review toolkit. It opens its own authenticated socket and covers one protocol surface end to end for a single review: understand a capture and draft the target profile, test the handshake, test authorization across identities, sweep frame fields, replay state-changing frames.
flowchart LR
cap["WebSocket capture"] --> an["Analyzer<br/>drafts a profile"]
an --> prof["Profile:<br/>handshake, auth,<br/>message map"]
prof --> conn["Authenticated<br/>socket"]
conn --> hs["Handshake matrix"]
conn --> diff["Two-account diff"]
conn --> sweep["Field sweep"]
conn --> replay["Replay"]
conn --> bridge["HTTP-to-WS bridge"]
bridge --> ext["sqlmap / ffuf"]
hs --> v{{"Deterministic verdict:<br/>close code or reply diff"}}
diff --> v
sweep --> v
replay --> v
ext --> v
Live SQL injection carried through a WebSocket frame against OWASP DVWS: sqlmap drives the frame field through the bridge while wsprobe owns the handshake and token refresh. DVWS is a separate MIT project you build from its own repo.
This second run is against the shipped synthetic fixture, and reproduces from a
clean clone with bash scripts/demo.sh.
wsprobe goes deep on one protocol. A WebSocket connection begins as an ordinary
HTTP GET and, at 101 Switching Protocols, stops being HTTP and becomes a raw
two-way socket for the life of the app. The one checkpoint HTTP walks on every
request is walked exactly once here, at the upgrade, so the interesting defects
live after it: a frame that reads as another user because the server never
re-derived the principal, a control frame accepted with no per-message check, a
state-changing frame replayed because it carries no nonce. HTTP tooling reasons
about a unit (the request) that no longer exists after the upgrade. wsprobe owns
the handshake, speaks the app's message language, holds connection state, and
drives its own sockets.
This tool is for testing systems you own or are explicitly authorized to test. It ships no target and no default host. There is nothing to point it at until you write a profile for a system you are permitted to test.
uv venv --python 3.12
uv pip install -e '.[dev]'
# 1. Draft a profile from a capture (a proxy export, or wsprobe's own capture)
wsprobe analyze capture.ndjson --emit-profile target.yaml
# then edit target.yaml: the handshake URL, token location, message map, probes
# 2. Test the handshake with no session
wsprobe matrix target.yaml
# 3. Send the same frame as two identities and diff the replies (BOLA/IDOR)
wsprobe diff target.yaml --frame '{"type":"getRecord","id":1002}' \
--token-a a.tok --token-b b.tok
# 4. Reach a frame field with an HTTP injection tool
wsprobe bridge target.yaml --frame '{"q":"§FUZZ§"}' # then point sqlmap at 127.0.0.1:8081
A profile is the single piece of target-specific knowledge the engine needs: a
declarative, validated document describing the handshake, how a token is
acquired and kept fresh, the message vocabulary, and the keepalive pattern to
suppress. Profiles are YAML, validated by a pydantic model. The model's JSON
schema is published at profile.schema.json:
wsprobe schema
A profile can be hand-written or drafted from a capture (wsprobe analyze --emit-profile), then refined. One profile drives every capability, so the
same tooling that discovers the message language also drives the authorization
diff and the replay.
The refresh policy is load-bearing. Many targets mint a single-use token per
connection, so re-authenticating mid-run revokes the token a live socket is
holding. per-dial mints one token per new dial, reuse harvests one and
holds it, ttl caches until an age limit.
One profile adapts the generic engine to a specific target:
- Framing (
handshake.framing):json,text(raw strings), orsocketio(Socket.IO/Engine.IO v4, where the connect handshake and ping/pong are handled for you). Length-prefixed and binary are stubs for a later phase. - Correlation (
messages.correlation):echo(the server echoes a correlation id),ordered(the next reply is the answer, which fits most real servers), orack(Socket.IO acknowledgements). - Token location (
auth.token_location):query,header,subprotocol,cookie, orlogin-frame(with an optionallogin_frametemplate carrying a§token§placeholder). - Probes (
probes): the target-specific frames the matrix needs (the unauthenticated control frame, the identity probe, and the URL identity parameter), so nothing target-shaped is baked into the engine.
| Verb | What it does |
|---|---|
wsprobe schema |
Export the profile JSON schema as a build artifact. |
wsprobe validate |
Load and validate a profile. |
wsprobe matrix |
Handshake security matrix: unauthenticated upgrade, expired and foreign token, CSWSH/Origin variants, no-auth control frames, cross-user handshake binding. |
wsprobe repl |
Interactive authenticated client: one frame per line, correlated reply, recorded to a capture. |
wsprobe analyze |
Ingest captures, drop heartbeats, inventory message types, correlate request and reply, and emit a draft profile. Opens no socket. |
wsprobe diff |
Two-account authorization diff: the same frame from two identities, replies compared. |
wsprobe sweep |
Field sweep: one field over a list of values on one identity, correlated replies. |
wsprobe replay |
Re-drive a captured outbound sequence on a fresh authenticated socket, optional field mutation. |
wsprobe bridge |
Loopback HTTP-to-WebSocket bridge: an HTTP tool (injection tester, fuzzer) drives one frame field via a §FUZZ§ placeholder while wsprobe owns the handshake and token refresh. Binds 127.0.0.1 only. |
The handshake matrix and the two-account diff report the observed behavior of the server. The tool never prints "confirmed". That word is reserved for the operator, after live reproduction. An observation of an insecure shape (an upgrade accepted with no credentials, two identities receiving the same private reply) is a lead to reproduce, not a finding the tool has closed.
The observation verbs default to a human table. Pass --json to matrix,
diff, sweep, or analyze for a stable, documented structure a script or the
Burp companion panel can parse instead. Each payload carries a schema tag of
the form wsprobe.<command>/v1 and a command field, so a reader dispatches on
schema and trusts the keys under it. The reading vocabulary is the same one the
tables use. The single
source of the shapes is src/wsprobe/jsonout.py.
wsprobe matrix profile.yaml --token-file valid.tok --json
{
"schema": "wsprobe.matrix/v1",
"command": "matrix",
"profile": "drafted-target",
"channel": "default",
"observations": [
{"check": "unauth-upgrade", "observed": "upgraded-without-auth",
"reading": "insecure-shape", "detail": {"upgraded": true, "error": null}}
]
}diff emits frame, identity_a/identity_b, reading, and reply_a/reply_b;
sweep emits field, distinct_replies, and rows; analyze emits the frame
inventory and correlations. See src/wsprobe/jsonout.py for the full field
list of each.
matrix and diff also take --sarif <path> to write the insecure-shape leads
as SARIF 2.1.0, so a run drops into any code-scanning viewer. Results are
level: warning and phrased as leads to reproduce, never verdicts; a sample is
at docs/sample.sarif.json.
wsprobe matrix profile.yaml --token-file valid.tok --sarif out.sarif.json
For operators who work in Burp, burp/ ships a Montoya extension and two
Bambdas that sit alongside the CLI:
- The extension drafts a wsprobe profile from live WebSocket traffic, and can run
wsprobe against a target by shelling out to the CLI with
--jsonand showing the observations in a Burp tab, so no testing logic is reimplemented in Kotlin. HideHeartbeatFramesfilters keepalive noise out of the WebSocket history.FreshTokenFromHandshakecopies a fresh token from the handshake for reuse.
One login can serve both Burp and the CLI. Build the extension with the bundled
Gradle wrapper (./gradlew build); details are in burp/README.md.
lab/docker-compose.yml stands up the shipped vulnerable fixture and the clean
control, plus OWASP Juice Shop, so every capability has something to run against
locally. OWASP DVWS is a separate MIT project you build from its own repo. Full
run-throughs with commands and output live in docs/validation.md.
Every capability runs from a first-class Python API; the CLI verbs are thin
wrappers over it. The core objects are the profile loader and an async
connection manager whose request returns a correlated reply:
import asyncio
from wsprobe import ConnectionManager, load_profile, two_account_diff
async def main():
profile = load_profile("target.yaml")
a = ConnectionManager(profile, token="<token-A>", identity="A")
b = ConnectionManager(profile, token="<token-B>", identity="B")
result = await two_account_diff(a, b, {"type": "read_note", "owner": "B"})
print(result.reading, result.reply_a)
asyncio.run(main())Run pre-commit install once per clone. On every commit this then runs,
against staged files:
- betterleaks: secrets scan (redacted output), blocks the commit on a hit.
- opengrep: SAST over Python, against a pinned rule pack vendored at
.opengrep/rules(no registry fetch at commit time), blocks the commit on a finding. - the standard pre-commit-hooks set: end-of-file-fixer, trailing-whitespace, check-merge-conflict, detect-private-key.
Both scanners run as already-installed binaries (brew install betterleaks;
opengrep via its install script) rather than something pre-commit builds for
you. Run everything on demand with pre-commit run --all-files.
MIT. See LICENSE.
websockets (BSD-3-Clause), pydantic (MIT), typer (MIT), PyYAML (MIT),
pytest (MIT), invoked as libraries. Methodology is distilled from public
sources on WebSocket security testing.

