{"repro_id":"REPRO-2026-00270","version":6,"title":"9router before 0.4.44 allows unauthenticated remote OS command execution via the /api/tunnel/tailscale-install endpoint by injecting shell commands in the sudoPassword field when sudo does not prompt for a password.","repro_type":"security","status":"published","severity":"critical","cvss_score":9.2,"description":"An unauthenticated POST to `/api/tunnel/tailscale-install` in 9router (<0.4.44) can trigger OS command injection. The endpoint is not covered by the dashboard middleware matcher, so no authorization checks are applied. The `sudoPassword` field is written to stdin of a `sudo -S sh` child process; if sudo does not prompt for a password (running as root, NOPASSWD configured, or a recent sudo timestamp), the value is interpreted by `sh` as shell input, enabling arbitrary command execution.","root_cause":"# RCA Report — CVE-2026-59800 / 9router Unauthenticated OS Command Injection\n\n## Summary\n\n9router versions before 0.4.44 expose unauthenticated command execution through `POST /api/tunnel/tailscale-install`. The endpoint is not covered by the vulnerable dashboard authorization middleware matcher, so a remote client can invoke it without credentials. The handler passes the request-body `sudoPassword` field to stdin of `sudo -S sh` before writing the intended Tailscale install script. When sudo does not consume stdin for a password prompt (root process, NOPASSWD sudo, or cached sudo timestamp), `sh` reads the attacker-controlled `sudoPassword` bytes as shell commands. This run demonstrates the command boundary through the real 9router HTTP service and records marker files created by the attacker-supplied command, plus a fixed-version negative control.\n\n## Impact\n\n- **Package / component:** npm package `9router`; Next.js standalone server route `/api/tunnel/tailscale-install`; Linux Tailscale install helper.\n- **Affected versions:** `< 0.4.44`; reproduced against `9router@0.4.39`.\n- **Fixed version used for negative control:** `9router@0.4.45` (patched behavior: unauthenticated request receives HTTP 401 and the install helper no longer feeds password bytes as shell script input).\n- **Risk level and consequences:** Critical. Under the documented no-prompt sudo precondition, an unauthenticated HTTP request can execute attacker-selected shell commands as the 9router process user. In root/container deployments this is root command execution.\n\n## Impact Parity\n\n- **Disclosed / claimed maximum impact:** unauthenticated remote OS command execution (`code_execution`).\n- **Reproduced impact from this run:** attacker-controlled commands were executed through the real unauthenticated `POST /api/tunnel/tailscale-install` endpoint on the vulnerable build. The reproduction script ran the actual 9router npm package and actual Next.js server, passed `/api/health`, sent JSON containing `sudoPassword`, reached the vulnerable `spawn(\"sudo\", [\"-S\", \"sh\"])` path, and wrote marker artifacts containing `PWNED_BY_9ROUTER_*` plus `id` output. Two vulnerable attempts succeeded. Two fixed attempts returned HTTP 401 and produced no marker.\n- **Parity:** full for the demonstrated command-execution boundary under the no-prompt sudo precondition. The current sandbox has no Docker daemon and no real root/NOPASSWD sudo, so the script used a PATH-local no-prompt sudo precondition shim for the successful marker run; if Docker or real root/NOPASSWD is available, the same script first attempts those stronger production-like modes.\n- **Not demonstrated:** actual `uid=0` output in this sandbox, because the sandbox user is unprivileged (`vscode`, uid 1000), Docker daemon access is denied, and real sudo requires a password. The marker nevertheless proves attacker-controlled command execution through the vulnerable product route when sudo does not consume stdin.\n\n## Root Cause\n\nTwo defects combine:\n\n1. **Authorization matcher omission.** In `9router@0.4.39`, the middleware matcher is an explicit allow-list:\n\n   ```text\n   matcher:[\"/\",\"/dashboard/:path*\",\"/api/shutdown\",\"/api/settings/:path*\",\"/api/keys\",\"/api/keys/:path*\",\"/api/providers/client\",\"/api/provider-nodes/validate\",\"/api/cli-tools/:path*\",\"/api/mcp/:path*\"]\n   ```\n\n   `/api/tunnel/tailscale-install` is absent, so the dashboard guard is not invoked and the route accepts unauthenticated requests.\n\n2. **Shell stdin injection.** The vulnerable install helper spawns `sudo -S sh`, writes `sudoPassword` to stdin first, then writes the legitimate install script. If sudo does not prompt, stdin remains available to `sh`; therefore a value such as `echo PWNED > /path/marker; id >> /path/marker; exit 0` is interpreted as the first shell command.\n\nThe fixed build uses a catch-all middleware matcher that protects `/api/tunnel/*`, validates the sudo password, writes the install script to a temporary file, and invokes `sh <tempfile>` so password bytes are no longer interpreted as shell script input.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh`.\n2. The script:\n   - fetches `9router@0.4.39` and `9router@0.4.45`;\n   - starts the real 9router server with `node cli.js --log --skip-update`;\n   - waits for `/api/health`;\n   - sends an unauthenticated `POST /api/tunnel/tailscale-install` request with JSON body containing an attacker command in `sudoPassword`;\n   - records HTTP status, SSE response body, server log, sudo/no-prompt log, and marker files;\n   - repeats two vulnerable attempts and two fixed attempts.\n3. Expected evidence:\n   - Vulnerable attempts: HTTP 200, response includes `Running install script...`, sudo/no-prompt log shows invocation as `sudo -S sh`, and marker files contain `PWNED_BY_9ROUTER_*` plus `uid=...` output.\n   - Fixed attempts: HTTP 401 `{\"error\":\"Unauthorized\"}` and no marker files.\n\n## Evidence\n\nThe script was run twice consecutively and succeeded both times. Key artifacts from the final run:\n\n| Artifact | Evidence |\n|---|---|\n| `bundle/logs/reproduction_steps.log` | Complete successful transcript; final result confirmed. |\n| `bundle/repro/runtime_manifest.json` | `service_started=true`, `healthcheck_passed=true`, `target_path_reached=true`; lists all proof artifacts. |\n| `bundle/artifacts/vuln_1_http_code.txt`, `bundle/artifacts/vuln_2_http_code.txt` | `200` for vulnerable unauthenticated POSTs. |\n| `bundle/logs/vuln_1_response.txt`, `bundle/logs/vuln_2_response.txt` | SSE progress includes `Running install script...`, proving route handler reached the install/spawn path. |\n| `bundle/logs/vuln_1_sudo_shim.log`, `bundle/logs/vuln_2_sudo_shim.log` | No-prompt sudo precondition invoked as `argv=-S sh` and did not consume stdin. |\n| `bundle/artifacts/vuln_1_marker.txt`, `bundle/artifacts/vuln_2_marker.txt` | Attacker command markers, e.g. `PWNED_BY_9ROUTER_vuln_1` and `uid=1000(vscode) ...`. |\n| `bundle/artifacts/fixed_1_http_code.txt`, `bundle/artifacts/fixed_2_http_code.txt` | `401` for fixed unauthenticated POSTs. |\n| `bundle/logs/fixed_1_response.txt`, `bundle/logs/fixed_2_response.txt` | `{\"error\":\"Unauthorized\"}`. |\n| `bundle/artifacts/fixed_1_marker.txt`, `bundle/artifacts/fixed_2_marker.txt` | `[marker absent]`, proving the fixed build did not execute the command. |\n| `bundle/artifacts/vuln_middleware_matcher.txt`, `bundle/artifacts/fixed_middleware_matcher.txt` | Vulnerable matcher omission vs fixed catch-all matcher. |\n| `bundle/artifacts/vuln_install_code.txt`, `bundle/artifacts/fixed_install_code.txt` | Vulnerable stdin-to-`sh` implementation vs fixed temp-file/validation implementation. |\n\nKey excerpt from `bundle/logs/reproduction_steps.log`:\n\n```text\n[vuln_1] HTTP 200; marker: PWNED_BY_9ROUTER_vuln_1 uid=1000(vscode) gid=1000(vscode) groups=1000(vscode)\n[vuln_2] HTTP 200; marker: PWNED_BY_9ROUTER_vuln_2 uid=1000(vscode) gid=1000(vscode) groups=1000(vscode)\n[fixed_1] HTTP 401; marker: [marker absent]\n[fixed_2] HTTP 401; marker: [marker absent]\nRESULT: CONFIRMED - attacker-controlled command executed through unauthenticated POST /api/tunnel/tailscale-install; fixed build blocked the same request.\n```\n\n## Recommendations / Next Steps\n\n- Upgrade 9router to `>= 0.4.44` (or the latest patched release).\n- Protect routes with a deny-by-default or catch-all authorization middleware matcher so newly added API endpoints cannot bypass authentication by omission.\n- Never feed attacker-influenced data into a shell that reads its script from stdin. Pass scripts by immutable file path or avoid shell interpreters entirely.\n- Validate privileged-operation inputs, including rejecting newlines/metacharacters in password fields.\n- Add integration tests that enumerate `/api/*` routes and assert unauthenticated requests receive 401/403 unless deliberately public.\n\n## Additional Notes\n\n- The reproduction script is idempotent and was verified twice consecutively.\n- The script attempts Docker-root and direct root/NOPASSWD modes before the controlled no-prompt sudo mode. Those stronger modes were unavailable in this sandbox (`docker` socket permission denied; no passwordless real sudo), but remain implemented for environments that can run the ticket's root/NOPASSWD precondition directly.\n- The successful run still uses the real product HTTP boundary and real vulnerable route; only the sudo precondition was supplied by a local no-prompt executable so the constrained sandbox could demonstrate the final command-execution marker.\n","cve_id":"CVE-2026-59800","cwe_id":"CWE-78","source_url":"https://nvd.nist.gov/vuln/detail/CVE-2026-59800","package":{"name":"9router (npm)","ecosystem":"npm","affected_versions":"< 0.4.44 (advisory states <= v0.4.39)","fixed_version":"0.4.44 (GitHub advisory page); OSV lists 0.4.45 as fixed range"},"reproduced_at":"2026-07-08T04:50:04.999945+00:00","duration_secs":2753.0,"tool_calls":382,"handoffs":4,"total_cost_usd":12.033510369999997,"agent_costs":{"hypothesis_generator":0.0150512,"judge":0.09306260000000002,"repro":8.365082709999996,"support":0.05146686,"vuln_variant":3.5088469999999994},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0150512},"judge":{"gpt-5.4-mini":0.09306260000000002},"repro":{"accounts/fireworks/routers/glm-5p2-fast":3.2641487099999997,"gpt-5.5":5.100934000000001},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.05146686},"vuln_variant":{"gpt-5.5":3.5088469999999994}},"quality":{"confidence":"medium","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-08T04:50:47.643751+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":21179,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":8522,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":11981,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":9241,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":27506,"category":"other"},{"path":"bundle/artifact_promotion_report.json","filename":"artifact_promotion_report.json","size":37865,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":906,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":798,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1342,"category":"other"},{"path":"bundle/logs/demo_response.txt","filename":"demo_response.txt","size":252,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":2016,"category":"log"},{"path":"bundle/logs/vuln_1_response.txt","filename":"vuln_1_response.txt","size":293,"category":"other"},{"path":"bundle/logs/fixed_1_response.txt","filename":"fixed_1_response.txt","size":24,"category":"other"},{"path":"bundle/logs/vuln_1_server.log","filename":"vuln_1_server.log","size":1890,"category":"log"},{"path":"bundle/logs/vuln_1_sudo_shim.log","filename":"vuln_1_sudo_shim.log","size":389,"category":"log"},{"path":"bundle/logs/vuln_2_response.txt","filename":"vuln_2_response.txt","size":293,"category":"other"},{"path":"bundle/logs/vuln_2_sudo_shim.log","filename":"vuln_2_sudo_shim.log","size":389,"category":"log"},{"path":"bundle/logs/fixed_1_server.log","filename":"fixed_1_server.log","size":1539,"category":"log"},{"path":"bundle/logs/fixed_2_response.txt","filename":"fixed_2_response.txt","size":24,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":2289,"category":"log"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":3215,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3818,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6950,"category":"documentation"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":2490,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c0_parent_install_baseline_http_code.txt","filename":"fixed_c0_parent_install_baseline_http_code.txt","size":4,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c0_parent_install_baseline_marker.txt","filename":"fixed_c0_parent_install_baseline_marker.txt","size":16,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c0_parent_install_baseline_response.txt","filename":"fixed_c0_parent_install_baseline_response.txt","size":42,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c1_install_trailing_slash_http_code.txt","filename":"fixed_c1_install_trailing_slash_http_code.txt","size":4,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c1_install_trailing_slash_marker.txt","filename":"fixed_c1_install_trailing_slash_marker.txt","size":16,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c1_install_trailing_slash_response.txt","filename":"fixed_c1_install_trailing_slash_response.txt","size":29,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c2_start_daemon_http_code.txt","filename":"fixed_c2_start_daemon_http_code.txt","size":4,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c2_start_daemon_marker.txt","filename":"fixed_c2_start_daemon_marker.txt","size":16,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c2_start_daemon_response.txt","filename":"fixed_c2_start_daemon_response.txt","size":24,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c3_cli_tools_mitm_http_code.txt","filename":"fixed_c3_cli_tools_mitm_http_code.txt","size":4,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c3_cli_tools_mitm_marker.txt","filename":"fixed_c3_cli_tools_mitm_marker.txt","size":16,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_c3_cli_tools_mitm_response.txt","filename":"fixed_c3_cli_tools_mitm_response.txt","size":42,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_server.log","filename":"fixed_server.log","size":1161,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":146,"category":"other"},{"path":"bundle/logs/vuln_variant/latest_version.txt","filename":"latest_version.txt","size":26,"category":"other"},{"path":"bundle/logs/vuln_variant/route_files.log","filename":"route_files.log","size":3710,"category":"log"},{"path":"bundle/logs/vuln_variant/shell_sink_scan.log","filename":"shell_sink_scan.log","size":31150,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_c0_parent_install_baseline_http_code.txt","filename":"vuln_c0_parent_install_baseline_http_code.txt","size":4,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c0_parent_install_baseline_marker.txt","filename":"vuln_c0_parent_install_baseline_marker.txt","size":100,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c0_parent_install_baseline_response.txt","filename":"vuln_c0_parent_install_baseline_response.txt","size":293,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c1_install_trailing_slash_http_code.txt","filename":"vuln_c1_install_trailing_slash_http_code.txt","size":4,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c1_install_trailing_slash_marker.txt","filename":"vuln_c1_install_trailing_slash_marker.txt","size":16,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c1_install_trailing_slash_response.txt","filename":"vuln_c1_install_trailing_slash_response.txt","size":29,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c2_start_daemon_http_code.txt","filename":"vuln_c2_start_daemon_http_code.txt","size":4,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c2_start_daemon_marker.txt","filename":"vuln_c2_start_daemon_marker.txt","size":16,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c2_start_daemon_response.txt","filename":"vuln_c2_start_daemon_response.txt","size":16,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c3_cli_tools_mitm_http_code.txt","filename":"vuln_c3_cli_tools_mitm_http_code.txt","size":4,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c3_cli_tools_mitm_marker.txt","filename":"vuln_c3_cli_tools_mitm_marker.txt","size":16,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_c3_cli_tools_mitm_response.txt","filename":"vuln_c3_cli_tools_mitm_response.txt","size":24,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_server.log","filename":"vuln_server.log","size":1658,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_sudo_shim.log","filename":"vuln_sudo_shim.log","size":1781,"category":"log"}]}