Thinking Outside the Sandbox: Decoding and Defeating Node.js Permissions
Thinking Outside the Sandbox #
Decoding and Defeating the Node.js Permission Model #
Node.js has been steadily moving toward a stronger sandboxing and permission model. The goal is clear: limit what code can do, especially third-party dependencies, even after they’re loaded into your process. In theory, this should dramatically reduce the blast radius of supply-chain attacks. In practice… it’s complicated. This post walks through how the Node.js permission model works, where it breaks down, and how real-world exploits have bypassed both module-based and process-based protections.
Node.js Permissions: The Big Picture #
Node.js has been experimenting with two different “permission” concepts that are easy to conflate:
- Module-based permissions (Policies): restrict what modules a given script (and its dependency graph) can load.
- Process-based permissions: restrict what the process can access (filesystem, child processes, etc.).
This post is a write-up version of a talk given at KernalCon: Thinking outside the Sandbox and walks through how each model works, where it broke, and what changed to fix it.
Module-Based Permissions #
The Goal #
Module-based permissions aim to control what modules a dependency can require().
Examples:
- “I don’t expect my app to ever spawn a child process.”
- “Fastify needs access to
http, but no one else should.”
This is enforced via a policy.json file passed to Node at startup:
node --experimental-policy=policy.json main.js
Module-based permissions (Policies) #
Policies are intended to answer: “Fastify needs node:http, but no one else should.” The usual shape is a policy.json manifest that enumerates which resources may depend on which specifiers.
A minimal policy setup #
main.js (allowed to load node:child_process):
const child_process = require("node:child_process");
console.log(child_process.execSync("ls -la").toString());
require("./child.js");
child.js (not allowed unless it declares the dependency in policy):
const child_process = require("node:child_process");
console.log(child_process.execSync("ls -la").toString());
policy.json (only ./main.js is granted node:child_process):
{
"resources": {
"./main.js": {
"integrity": true,
"dependencies": {
"node:child_process": true,
"./child.js": true
}
}
},
"scopes": {
"./": { "integrity": true }
}
}
Running child.js or letting it require("node:child_process") without listing the dependency typically fails with a manifest error.
Bypass #1: module.constructor._load (CVE-2023-32002) #
One early bypass was to avoid the “policy-aware” require path and use internal loader APIs directly:
// CVE-2023-32002 (example)
const child_process = module.constructor._load("node:child_process");
console.log(child_process.execSync("ls -la").toString());
This worked because policy enforcement was wired into normal compilation/loading paths, and some internal entrypoints weren’t being consistently guarded.
Fix (high level): harden/guard internal module loader surfaces so that untrusted code can’t reach privileged loaders via module.constructor the way it used to.
Bypass #2: “Module Moocher” via require.cache #
Even if your module can’t load something under policy, another already-loaded module might have a require function that can. Node caches loaded modules in require.cache, and policy is about who is doing the requiring.
Proof-of-concept shape:
// We could loop to find the module with the permissions we want.
const main_module_ref = Object.values(require.cache)[0];
const child_process = main_module_ref.require("node:child_process");
console.log(child_process.execSync("ls -la").toString());
If you can get a reference to a “privileged” module’s require, you can “mooch” its access.
Fix (high level): make it harder/impossible for untrusted code to obtain or reuse privileged loader entrypoints, and tighten how policy scope follows the requester.
Process-based permissions (--experimental-permission) #
Process permissions aim to answer: “I don’t want or expect my application to spawn a child process ever.” This model is about restricting runtime access to system resources (fs, child processes, etc.) for the entire Node process.
Bypass #1: WASI file access #
The core idea: process permissions were framed around access “through fs”, but WASI provides a separate path to file IO via WebAssembly + preopened directories.
The PoC pattern is:
- Enable process permissions and restrict fs read.
- Use
wasiwithpreopensto map a virtual directory to a real host path. - Run a WASI-compiled module that reads a file via POSIX-like syscalls.
Conceptual snippet:
const { WASI } = require("wasi");
const wasi = new WASI({
version: "preview1",
preopens: { "/": "/" },
args: ["/etc/passwd"]
});
// instantiate and start a WASI wasm module that fopen()/reads argv[0]
Fix (high level): ensure the permission model applies consistently to all filesystem access paths, not just the fs module surface.
Bypass #2: “Inspector Gadget” (debugger-assisted permission escalation) #
Another class of bypass came from using the inspector/debugger machinery to tamper with internal state and reach restricted APIs indirectly.
The slide deck demonstrates the idea by:
- Connecting to the inspector (
node:inspector/promises), - Using the debugger protocol to locate internal source,
- Setting a conditional breakpoint to flip a guard variable (e.g., forcing an
isInternalpath), - Creating a
Workerwitheval: trueand permissiveexecArgv.
High-level pseudocode shape:
const { Session } = require("node:inspector/promises");
const session = new Session();
session.connect();
// enable Debugger + Runtime, locate node:internal/worker source,
// set a conditional breakpoint to force an internal-only path,
// then construct Worker with eval + permissive execArgv.
Fix (high level): restrict inspector access under the permission model so it can’t be used as an escalation vector (i.e., “Access to this API has been restricted”).
Takeaways #
- Security boundaries must cover alternative access paths: guarding
fsdoesn’t help if another built-in API reaches the same OS primitives. - Internal APIs are part of the attack surface: if untrusted code can reach “internal-only” entrypoints, policy/permission checks need to follow.
- Debugger/inspector is extremely powerful: it should be treated as privileged and gated accordingly.