vm2 Sandbox Escape via Promise Species Manipulation: Quick Look at CVE-2026-24120 with PoC Analysis

A brief summary of CVE-2026-24120, a critical CVSS 9.8 sandbox escape in the vm2 Node.js package that bypasses a prior fix for CVE-2023-37466. Includes proof of concept analysis and affected version details.

CVE Analysis

7 min read

ZeroPath CVE Analysis
ZeroPath CVE Analysis

2026-05-04

vm2 Sandbox Escape via Promise Species Manipulation: Quick Look at CVE-2026-24120 with PoC Analysis
Experimental AI-Generated Content

This CVE analysis is an experimental publication that is completely AI-generated. The content may contain errors or inaccuracies and is subject to change as more information becomes available. We are continuously refining our process.

If you have feedback, questions, or notice any errors, please reach out to us.

[email protected]

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:

  1. Neutralize the defense. The attacker overwrites Object.defineProperty with an empty arrow function (()=>{}), rendering resetPromiseSpecies inert. The sandbox can no longer restore the Promise species to a safe value.

  2. Trigger a host-realm TypeError. An async function creates an Error object and sets its name property to a Symbol(). When V8's internal FormatStackTrace attempts to coerce this Symbol to a string (via .stack access), it produces a TypeError that originates in the host realm, not the sandbox realm.

  3. Replace the Promise constructor's species. The rejected Promise returned by the async function has its constructor property replaced with an object containing a custom Symbol.species pointing to a FakePromise class controlled by the attacker.

  4. Hijack Promise chaining. When .then() is called on the manipulated Promise, the JavaScript runtime uses the attacker-controlled species to construct the chained Promise. The FakePromise executor's reject callback receives the unsanitized host-realm TypeError.

  5. Escape to the host process. The host-realm error object's constructor chain is traversed via err.constructor.constructor('return process')(), yielding the host process object. 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:

  1. Object.defineProperty is replaced with a no-op, preventing resetPromiseSpecies from resetting the Symbol.species property on the Promise prototype.

  2. The async function fn() creates an Error with a Symbol() as its name. Accessing .stack forces V8's FormatStackTrace to attempt string coercion of the Symbol, which throws a host-realm TypeError.

  3. The variable p holds the rejected Promise from the async function. Its constructor is overwritten with an object whose Symbol.species points to the attacker's FakePromise class.

  4. Calling p.then() triggers Promise chaining. The runtime uses the attacker-controlled species, instantiating FakePromise. The executor's reject callback receives the unsanitized host-realm TypeError.

  5. err.constructor.constructor('return process')() traverses the constructor chain from the host-realm error to the host Function constructor, evaluates 'return process' to obtain the host process object, and then calls child_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 RangeStatusRecommended Action
Versions 3.10.3 and belowVulnerableUpgrade immediately to 3.10.5
Version 3.10.4Not listed as patchedUpgrade to 3.10.5
Version 3.10.5PatchedVerify 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

Detect & fix
what others miss

Security magnifying glass visualization