//nefariousplan

CVE-2026-21636: --allow-net Permitted UDS Because UDS Is Not Net

pattern

cve

proof of concept

Node 25 ships an experimental permission model. Run a process with --permission and no --allow-net, and outbound network is denied. The denial is one line in TCPWrap::Connect. It was not in PipeWrap::Connect. From the perspective of the gated process, the Docker control socket, the snapd socket, the system D-Bus, and journald were not network. CVE-2026-21636.

The check is one macro at the handle-class layer

The Node.js permission model adds runtime gates around capabilities the operator wants denied. The C++ implementation pattern is consistent: each native handle class that reaches a privileged operation calls a check macro before dispatching to libuv. For network connections, the macro is ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS and the scope is permission::PermissionScope::kNet.

In v25.2.1, TCPWrap::Connect (src/tcp_wrap.cc) contains the line:

ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS(
    env, permission::PermissionScope::kNet,
    ip_address.ToStringView(), args);

The IPv4 connect path runs the check before calling req_wrap->Dispatch(uv_tcp_connect, ...). A process started with --permission and no --allow-net cannot use net.connect({ host, port }) against a TCP target. The denial is concrete and visible.

Two files over in the same directory, src/pipe_wrap.cc, PipeWrap::Connect looked like this in the same release:

void PipeWrap::Connect(const FunctionCallbackInfo<Value>& args) {
  Environment* env = Environment::GetCurrent(args);
  PipeWrap* wrap;
  ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This());
  CHECK(args[0]->IsObject());
  CHECK(args[1]->IsString());
  Local<Object> req_wrap_obj = args[0].As<Object>();
  node::Utf8Value name(env->isolate(), args[1]);
  ConnectWrap* req_wrap =
      new ConnectWrap(env, req_wrap_obj,
                      AsyncWrap::PROVIDER_PIPECONNECTWRAP);
  int err = req_wrap->Dispatch(
      uv_pipe_connect2, &wrap->handle_, *name,
      name.length(), 0, AfterConnect);
  // ...
}

No ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS. No reference to permission::PermissionScope. The name argument is the destination path. uv_pipe_connect2 opens a Unix Domain Socket. The dispatch happens unconditionally.

PipeWrap is the C++ wrap class for libuv pipe handles. It is the type that backs net.createConnection({ socketPath }), undici's Agent constructed with connect: { socketPath }, and any HTTP or TLS client that lets the dispatcher choose a UDS path. Three high-level APIs converge on the same C++ class. None of them traversed a permission check on the way through.

What the flag was actually gating

--allow-net is documented as the capability that unblocks network access when --permission is enabled. Operators reading the docs see the word "network" and reach for the obvious mental model: this process cannot speak TCP, cannot speak UDP, cannot speak any IPC over a socket.

The implementation is narrower than the documentation. --allow-net, as shipped in v25.2.1, was the absence of ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS on TCP-handle dispatch and nothing else. UDP did not have it either. PipeWrap did not have it. The flag named a capability. The runtime checked one of its surfaces.

The vendor's advisory hedges this by labeling network permissions "still in the experimental phase" at the time of disclosure. That hedge is the reason the CVE was scored Medium by the Node.js project and 10.0 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H) by NVD. The gap between those two numbers is the gap between "we promised this would prevent connections" and "we promised this might prevent connections, conditions apply."

--permission itself is not experimental. It stabilized in December 2024. Operators wiring up production hardening for an Express service trust the flag the way they trust filesystem capabilities. The docs encourage this. The CVE is what happens when one of the gates underneath the flag turns out to be a single line in a single C++ file that someone has to remember to add.

The PoC chain, and what it actually proves

The public PoC by Pauldechassey/CVE-2026-21636 runs the vulnerable process under a documented permission set:

node --permission --allow-fs-read=/ /app/server.mjs

server.mjs exposes one /language endpoint:

app.post('/language', async (req, res) => {
    const requested = req.body?.lang ?? 'fr';
    res.json(await import(requested + '/index.js'));
});

The handler does dynamic import() on caller-controlled input. Node's import() accepts data: URLs. The exploit posts data:text/javascript,<percent-encoded JS>// and gets arbitrary JavaScript execution inside the sandboxed process.

The author then chains:

  1. JS payload calls process.kill(<target-pid>, 'SIGUSR1'). Signal dispatch is not gated by --permission. A second Node process running unsandboxed receives SIGUSR1 and starts the V8 inspector on 127.0.0.1:9229.
  2. Second JS payload calls fetch('http://127.0.0.1:9229/json') and opens a WebSocket to the CDP endpoint.
  3. CDP Runtime.evaluate runs child_process.execSync('cat /app/secret.txt') inside the unsandboxed target. Arbitrary code execution outside the sandbox.

The lab's Dockerfile installs nodejs~=22. The repo's README admits one detail buried in that choice: the PoC reproduces, in the README's words, "the concept on v22 where the same bypass is observable." Node 22 has neither TCP nor UDS gating because the --permission model's net surface had not yet shipped on that line. The chain works because every connection bypasses, not because the v25 CVE primitive fires.

On v25.2.1, the fetch to http://127.0.0.1:9229/json is a TCP connection. It hits TCPWrap::Connect. It hits ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS. Without --allow-net, that connection is denied, and the PoC fails at step three.

The PoC is honest about what it demonstrates: it is a proof of the chain shape, not the v25 primitive. The v25 primitive lives one architectural layer down. Same JS-injection entry point, same SIGUSR1 stage if the target service exposes one, but the third stage routes through a Unix Domain Socket instead of 127.0.0.1. The v25 chain calls net.createConnection({ socketPath: '/var/run/docker.sock' }) and walks the Docker API, or net.createConnection({ socketPath: '/run/snapd.socket' }) and asks snapd to install a snap, or any other UDS-exposed control plane the host happens to mount into the sandboxed process's filesystem view.

What the operator actually exposed

A Node service started with --permission --allow-fs-read=/ and no --allow-net, running on a default Linux host or a typical container, has filesystem-level access to:

  • /var/run/docker.sock, when the container is run with -v /var/run/docker.sock:/var/run/docker.sock (the Docker-in-Docker pattern many CI runners and observability sidecars use). Reaching this UDS is reaching POST /containers/create followed by POST /containers/<id>/start, with a Privileged: true flag and a Binds entry that mounts host / into the new container. Container escape primitive, root on host.
  • /run/snapd.socket, on Ubuntu hosts where snapd is the package manager. The UDS speaks the snapd REST API. POST /v2/snaps with devmode accepts an arbitrary snap and installs it as root.
  • /run/dbus/system_bus_socket, the system D-Bus. From there, org.freedesktop.PolicyKit1, org.freedesktop.systemd1, org.freedesktop.NetworkManager. PolicyKit-mediated privilege escalation has been a CVE workshop for a decade.
  • /run/systemd/journal/socket, journald's structured-log ingest. Log injection at minimum, depending on downstream consumers.
  • /run/containerd/containerd.sock, on Kubernetes nodes. Same shape as the Docker socket, larger blast radius.

--permission documents itself as a sandbox. None of those UDS endpoints are network in the sense the documentation uses the word. All of them were reachable from inside the sandbox.

The fix is the missing macro

Node 25.3.0 changes one method:

 void PipeWrap::Connect(const FunctionCallbackInfo<Value>& args) {
   Environment* env = Environment::GetCurrent(args);
   PipeWrap* wrap;
   ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This());
   CHECK(args[0]->IsObject());
   CHECK(args[1]->IsString());
   Local<Object> req_wrap_obj = args[0].As<Object>();
   node::Utf8Value name(env->isolate(), args[1]);
+
+  ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS(
+      env, permission::PermissionScope::kNet,
+      name.ToStringView(), args);
+
   ConnectWrap* req_wrap =
       new ConnectWrap(env, req_wrap_obj,
                       AsyncWrap::PROVIDER_PIPECONNECTWRAP);
   int err = req_wrap->Dispatch(
       uv_pipe_connect2, &wrap->handle_, *name,
       name.length(), UV_PIPE_NO_TRUNCATE, AfterConnect);

Same macro that has been in TCPWrap::Connect since the network surface stabilized. Same scope: kNet. Same call shape. The change is the call site.

The fix lands at the right layer for the high-level APIs the advisory names. net.createConnection({ socketPath }), tls.connect({ socketPath }), and undici's UDS-dispatcher path all funnel through libuv's pipe handle, which is wrapped by PipeWrap. One line at PipeWrap::Connect covers all three. That is what the vendor caught when it framed the CVE as "via net, tls, or undici/fetch": the gap was not in three different surfaces, it was in the one C++ class all three reach.

PipeWrap::Bind in 25.3.0 still has no permission check. Binding a UDS server (listening on /tmp/x.sock for inbound connections) is not gated. Whether that becomes the next CVE depends on what an attacker can do with a sandboxed process that listens but cannot reach.

The model lives at the handle class

This is a design-debt-driver. The Node permission model checks at C++ wrap-class call sites. Each native operation that should be gated needs its own macro, in its own file, by the developer who shipped the operation. TCPWrap::Connect got the macro. PipeWrap::Connect did not, because adding a UDS connection codepath and adding a permission gate to that codepath are two separate edits that have to be remembered together.

The same shape played out across Composer's VCS drivers: the Hg driver was hardened against shell injection in 2021, the Perforce driver shipped the same primitive for another five years. Different language, different domain, identical mechanism. One fix per surface, no fix at the architecture.

The next handle class Node ships is the next opportunity for the same omission. QUIC has its own wrap class. HTTP/3 transport has its own wrap class. Whatever the next experimental network feature lands as will have its own wrap class. The model offers no compile-time signal, no test scaffolding that fails when a new wrap class appears without the macro, no design-level enforcement at the libuv boundary or below. The check is convention and code review.

This is the part of --permission the experimental label is doing real work. It lets the project ship surface-by-surface and call each gap a known limitation. CVE-2026-21636 is the gap that escaped the limitations doc and turned into a CVE.

PoC: Pauldechassey/CVE-2026-21636

TCPWrap::Connect got the macro. PipeWrap::Connect did not. The next wrap class is the next CVE.