← Back to Attack Research

A sudoPassword field that runs your command: unauth RCE in the 9router npm package

The 9router npm package left one install route out of its dashboard auth middleware, then piped a request field straight into a root shell. Before 0.4.44, an unauthenticated POST could turn a JSON sudoPassword value into a command running as root. Here is the two-bug stack behind CVE-2026-59800, how to find it in your dependency tree, and what closes it.

Two ordinary mistakes stacked into a critical. One route, POST /api/tunnel/tailscale-install, is missing from the dashboard middleware matcher in src/proxy.js, so the guard that protects every other dashboard route never runs for it, and the route answers unauthenticated. The same handler takes the JSON sudoPassword field and pipes it, alongside install-script content, into a child process spawned as sudo -S sh. Neither bug is exotic. Together they are CVE-2026-59800, CVSS 9.8, unauthenticated remote code execution in every 9router before 0.4.44. This is CWE-78 (OS command injection) meeting CWE-862 (missing authorization) in a single request.

9router (the npm package decolua/9router) is a small routing and tunnel utility with a web dashboard. Like many self-hosted tools of its shape, it ships a management UI behind a guard and a set of helper endpoints that perform privileged setup on the host. One of those helpers installs a tunnel agent. The guard was meant to sit in front of all of it. It did not sit in front of that one route, and that route runs a shell as root. This post walks the mechanism, the detection you can do without firing a payload, and the fix. Verifiable security.

Two bugs, one request

The dashboard middleware in src/proxy.js decides which requests must pass the dashboard guard by matching their path against a list. Every dashboard route on that list gets the authentication check before its handler runs. POST /api/tunnel/tailscale-install is not on the list. Because the matcher never matches it, the guard is never invoked for it, and the handler is reachable with no credentials at all. On its own, a missing authorization check on an install helper is a hardening problem, the kind of thing you would flag and schedule. It turns critical because of what the handler does after it runs.

To perform the install steps with elevated privilege, the handler spawns a child process as sudo -S sh and writes the request's sudoPassword field to that process on standard input. The -S flag tells sudo to read the password from stdin instead of the terminal, so on paper this is the normal way to feed a password to sudo non-interactively. The design assumes sudo will always prompt for, and consume, that first line of input. That assumption is the whole vulnerability.

When sudo does not prompt

sudo does not always ask for a password. If the process is already root, if the invoking account has a NOPASSWD rule, or if a recent sudo call left a warm authentication timestamp, sudo runs the command immediately without reading anything from stdin. Now the bytes that were meant for the password prompt are still queued in the pipe, and sh, the shell sudo just launched, reads them as its next line of input. A line of input to a shell is a command. Whatever an unauthenticated caller placed in sudoPassword is now executed by the shell.

The privilege of that execution depends on who the shell is. In a container built from a Node base image with no USER directive, the process is root. A global npm install run as root is root. In both of those very common deployment shapes, sudo has nothing to elevate to and never prompts, so the field lands in a root shell and runs as root. The two mistakes compound cleanly: the missing matcher entry gets an anonymous request to the handler, and the shell-fed field turns that request into command execution.

POST /api/tunnel/tailscale-install (no credentials) | v src/proxy.js matcher -- route NOT in list --> guard skipped [CWE-862] | v handler: spawn( sudo -S sh ) write( body.sudoPassword ) --> stdin | sudo already root / NOPASSWD / warm timestamp | v sudo does not prompt --> sh reads the "password" as its next command [CWE-78] | v command runs as root

One request, two defects: a route left off the auth matcher, and untrusted input handed to a shell. Conceptual, not a weaponized payload.

How to recognize your exposure

You can assess this without reproducing the exploit, because the risk is decided by three readable facts, none of which require sending a malicious body.

  1. Is 9router in your dependency tree, below 0.4.44? This is the highest-signal fact. Search lockfiles, generated SBOMs, and installed node_modules for the package and its version. Every release before 0.4.44 carries the bug; 0.4.44 is the fixed line.
  2. Does the Node process run as root? The blast radius is the identity of the shell. Check the container image for a USER directive, check whether the package was installed and launched globally as root, and check any service unit for a non-root run-as account. A root process is the worst case and, unfortunately, the default one.
  3. Is the install route reachable before authentication from where it is deployed? The dangerous surface is any path that answers the install route without a credential. If the service is internet-facing, that surface is the internet; if it is internal, it is every host that can reach it.

None of those three checks sends the sudoPassword field to anything. They are inventory, version, and position, and together they tell you whether a given deployment is exposed before anyone tries the request.

The field was named for a password prompt that, on a root container, never appears. The prompt that never fires is the door that never closes.

This is not an isolated slip in the package. A sibling advisory, CVE-2026-46339, describes unauthenticated RCE in the same project via unprotected MCP custom-plugin routes: again, routes that the auth layer did not cover, reachable by anyone, reaching code that should have been privileged. The pattern is a matcher that enumerates protected paths rather than denying by default, so any route the author forgets to add becomes a public one. When the code behind a forgotten route can run commands, the omission is remote code execution.

What to do about it

Close 9router command injection in your estate

How Celvex catches this

Find. Prove. Fix. Verify.

Find

A read-only sweep greps your dependency trees and SBOMs for 9router below 0.4.44 and checks whether the Node process runs as root, flagging the exposed pairing of a vulnerable version with a privileged runtime, no payload sent.

Prove

In an authorized deployment, an unauthenticated request to the install route carrying a benign marker command confirms both the auth gap and the injection. We record it as an Ed25519-signed Proof Capsule of the package version and route-reachability evidence, reproducible offline.

Fix

The capsule's remediation block names the steps: upgrade to 0.4.44 or later, run the service under a non-root USER, and front it with real authentication so a forgotten route is never a public one.

Verify

A fresh sweep confirms the route now requires auth and answers 401 or a redirect, the field no longer reaches a shell, and the container runs non-root. The finding closes and the verified-fix event is recorded for the audit trail.

The lesson generalizes past this one package. An allowlist matcher that names the routes it protects is only as safe as the author's memory; a route added later, or a helper written in a hurry, silently escapes the guard. Deny by default, and make the privileged code paths run as the least privilege they can. Do both, and a forgotten line in a matcher stops being a root shell for strangers.

Verifiable security. Find it. Prove it. Fix it. Verify the fix held. That is what we ship.

Sources

Is 9router below 0.4.44 running as root anywhere in your estate?

Free Exposure Check, no signup required. We grep your dependency trees for the vulnerable version, check whether the process runs as root, confirm whether the install route answers pre-auth, and ship a signed Proof Capsule for the highest-confidence finding.

Run a Free Scan →