Skip to content

GHSA-27g9-p43v-cw3v CVE-2026-92944

vm2 sandbox escape on Node.js 26 through a stale PromiseThenLookupChain protector

Critical npm
Severity
9.3 / 10
Critical · CVSS 4.0
Exploit chance (EPSS)
0.84%
Chance of exploitation in the next 30 days. Higher than 57.90% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-693 Protection Mechanism Failure
CWE-913 Improper Control of Dynamically-Managed Code Resources
Fix
3.11.7
Upgrade vm2 to this version or later

Description

Reporter

  • Name or handle: [YMsora]
  • Report date: 2026-08-14

Summary

The latest published vm2 release, 3.11.5, and the current main branch are vulnerable to a sandbox escape when used on Node.js 26. An ordinary fulfilled Promise created by an async function can retain an attacker-controlled constructor[Symbol.species] across Promise.prototype.finally().

vm2 installs wrappers on the intrinsic Promise.prototype.then and catch methods. On Node.js 26 / V8 14.6, V8's SetPrototypeProperties path updates these existing data properties without invalidating the PromiseThenLookupChain protector. Promise.prototype.finally() subsequently trusts the stale protector and uses an internal InvokeThen fast path that calls the original native then, bypassing vm2's wrapper and its resetPromiseSpecies(this) hardening.

The attacker-controlled species constructor therefore supplies the resolve and reject functions used by a native Promise reaction. A calibrated stack overflow at that native reaction boundary yields a raw host-realm RangeError to the attacker-controlled reject function. Its constructor chain reaches the host Function constructor and consequently the host process object.

The attached proof is non-destructive: it reads only process.version. It does not execute commands, access files, or perform network requests.

Latest-version status

Verified on 2026-08-14:

  • npm latest: vm2@3.11.5
  • vm2@3.11.5 publication time: 2026-05-18T15:37:26.138Z
  • npm gitHead: 7a1f5100b96f48d34e0fe104ab37c0acc5944f92
  • npm tarball: https://registry.npmjs.org/vm2/-/vm2-3.11.5.tgz
  • npm integrity: sha512-RSrkBiwrj6FRU+QdqNs6KG0XdlvJCjpQ4GXiqmMbrhmwfu5k/XIMpAer0L8f6iuf0uJ3a4T1xJN126Q8yf0VIA==
  • GitHub default branch main HEAD: the same commit, also tagged v3.11.5
  • latest formal Node.js release: v26.7.0, V8 14.6.202.34

The npm tarball and current GitHub main contain the same relevant lib/setup-sandbox.js Git blob. There is no unpublished fix on main at the time of this report.

Permanent source reference:

https://github.com/patriksimek/vm2/blob/7a1f5100b96f48d34e0fe104ab37c0acc5944f92/lib/setup-sandbox.js#L345-L390

Affected configurations

vm2 versions tested on Node.js 26.7.0

vm2 version Result
3.10.2 through 3.10.5 host realm reached, 3/3 per release
3.11.0 host realm reached, 3/3
3.11.1 host realm reached, 3/3
3.11.2 host realm reached, 5/5
3.11.3 host realm reached, 3/3
3.11.4 host realm reached, 5/5
3.11.5 host realm reached, 5/5
current main (7a1f5100) host realm reached, 5/5

This report therefore confirms vm2 3.10.2 through 3.11.5 as affected when used with the vulnerable runtime. Earlier vm2 versions are not claimed: they may be independently vulnerable through older published issues, which would not establish this root cause.

Node.js versions tested with stock vm2 3.11.5

Node.js V8 Result
22.23.2 12.4.254.21 safe, 5/5
24.19.0 13.6.233.17 safe, 5/5
25.9.0 14.1.146.11 safe, 5/5
26.0.0 14.6.202.33 host realm reached, 5/5
every formal release from 26.1.0 through 26.7.0 14.6.202.34 host realm reached, 5/5 per release
v27.0.0-nightly202608131b2de5e052 14.6.202.34 host realm reached
v27.0.0-v8-canary20260812f3fe529a1e 15.3.55 safe

The vulnerable behavior was reproduced on Linux/musl, Linux/glibc, and Windows x64. It is not Alpine-specific.

Configuration requirements

The default configuration is affected:

const vm = new VM();

Default new VM() was verified 3/3 on Node.js 26.7.0 and vm2 3.11.5 with the operating system's default Node stack size.

The escape also remains reproducible with stronger settings:

const vm = new VM({
  allowAsync: true,
  eval: false,
  wasm: false,
  timeout: 5000
});

Therefore the exploit does not require:

  • WebAssembly or JSPI;
  • dynamic evaluation being enabled;
  • Buffer;
  • an exposed host object;
  • a custom Promise supplied by the embedder;
  • a specific operating system; or
  • any challenge-specific code.

The demonstrated producer uses an async function, so async support must be available. This is vm2's default.

The V8 PromiseThenLookupChain protector must still be intact when vm2 installs its wrappers. A fresh/default Node.js process satisfies this condition. An unrelated earlier mutation that correctly invalidates the protector can make this exact path safe, but that is not a documented vm2 mitigation and is not present in the default setup.

Reproduction

The attachment poc.js uses only the published npm package and prints a benign host-version marker.

mkdir vm2-node26-repro
cd vm2-node26-repro
npm init -y
npm install --ignore-scripts --no-audit --no-fund vm2@3.11.5
cp /path/to/poc.js .
node poc.js

Alternatively, with the official Node.js 26.7.0 container:

docker run --rm -v "$PWD:/poc:ro" -w /tmp node:26.7.0-bookworm-slim sh -lc '
  npm init -y >/dev/null 2>&1 &&
  npm install --ignore-scripts --no-audit --no-fund vm2@3.11.5 >/dev/null 2>&1 &&
  cp /poc/poc.js . &&
  node poc.js
'

Representative output:

{
  "node": "v26.7.0",
  "v8": "14.6.202.34-node.28",
  "vm2": "3.11.5",
  "config": "default",
  "result": "HOST",
  "hostVersion": "v26.7.0",
  "speciesCalls": 1,
  "localError": false,
  "localRangeError": false
}

The calibrated depth varies with the platform and process layout; this is expected. The PoC searches the boundary instead of relying on a fixed depth.

To verify that disabling eval and WebAssembly is not sufficient:

STRICT=1 node poc.js

Technical root cause

  1. vm2 replaces the intrinsic Promise.prototype.then and catch methods with wrappers that sanitize callbacks and execute resetPromiseSpecies(this).
  2. These two prototype writes are consecutive direct assignments in lib/setup-sandbox.js.
  3. V8 14.6 enables the proto_assign_seq_opt optimization, combining this assignment sequence into SetPrototypeProperties.
  4. In the Node.js 26 implementation, the existing-data-property branch calls Object::SetDataProperty(&it, value) without first calling it.UpdateProtector().
  5. The JavaScript property now contains vm2's wrapper, but the V8 PromiseThenLookupChain protector incorrectly remains valid.
  6. Promise.prototype.finally() performs an internal InvokeThen. Because the protector appears valid, V8 directly selects the native Promise then instead of performing the observable property lookup that would reach vm2's wrapper.
  7. The wrapper never executes, so the attacker's own constructor[Symbol.species] is not reset before the native then creates its result capability.
  8. A native Promise reaction calls the attacker-provided capability resolve function. At a calibrated stack boundary, V8 creates a host-realm RangeError and passes it to the attacker-provided capability reject function without vm2 conversion.
  9. error.constructor.constructor is consequently the host Function, even when the VM was configured with eval:false.

The root-cause control is strong:

node --no-proto-assign-seq-opt poc.js

On Node.js 26.7.0 this changes the result from HOST to SAFE (3/3). A separate semantic probe also changes from wrapperCalls=0 to wrapperCalls=1.

Relationship to previous advisories

This report intentionally discloses the overlap with prior work:

CVE-2026-22709 / GHSA-99p7-6v5w-7xg8

That issue concerned bypassing callback sanitization on intrinsic Promises returned by async functions. vm2's fix added/strengthened the globalPromise.prototype.then and catch wrappers. The current issue is different: on V8 14.6, finally() trusts a stale protector and bypasses those installed wrappers at the engine fast path.

CVE-2026-47208 / GHSA-76w7-j9cq-rx2j

That issue used a missing resetPromiseSpecies call in localPromise's rejection-swallowing tail. It is marked fixed in 3.11.4. The current entry point is the intrinsic async Promise plus finally()/InvokeThen; the PoC succeeds against 3.11.4 and 3.11.5.

CVE-2026-47210 / GHSA-6j2x-vhqr-qr7q

That issue required a JSPI-backed Promise and WebAssembly APIs. Its 3.11.4 fix removes the relevant JSPI surface. The current PoC uses an ordinary fulfilled async-function Promise, works with wasm:false, and succeeds against 3.11.5.

Public V8 fix

The underlying V8 protector bookkeeping bug is already public and fixed upstream:

This report does not claim discovery of a new V8 bug. It reports a previously undocumented, still-unpatched vm2 sandbox escape created by the interaction between that V8 bug and vm2's Promise hardening. The escape affects vm2's latest release and current main branch.

Impact

An attacker who can execute untrusted JavaScript inside a vm2 VM can cross the sandbox boundary and obtain the host Function constructor and process object. This permits arbitrary code execution with the privileges of the host Node.js process, including access to its filesystem, credentials, environment, network, and child-process facilities.

This is the exact security boundary vm2 is intended to enforce; no additional application mistake is required beyond executing attacker-controlled code in the sandbox.

Suggested remediation

  1. Install the intrinsic then and catch wrappers through an operation that reliably invalidates V8's Promise lookup-chain protector, such as an appropriate Reflect.defineProperty/Object.defineProperty path, instead of the optimizable consecutive direct-assignment sequence.
  2. Wrap the intrinsic Promise.prototype.finally entry point and execute resetPromiseSpecies(this) before delegating to the cached native finally implementation.
  3. Add a build/runtime regression test using an intrinsic Promise from (async () => 1)(): an attacker-controlled own species must not be constructed by p.finally().
  4. Until a vm2 release is available, document Node.js 26 as affected or fail closed on the vulnerable runtime range.
  5. Coordinate with Node.js to backport the existing V8 protector fixes to the Node.js 26 release line.

Verified controls show that wrapping finally before calling the cached native implementation blocks the tested direct variants, while the upstream V8 fix also restores correct wrapper invocation.

Credit is requested as: YMsora. https://github.com/YMs0ra

Attachments

vm2-node26-finally-private-report.zip

Affected versions

npm

PackageVulnerableFixed in
vm2
>= 3.10.2, < 3.11.7
3.11.7

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

vm2 npm → 3.11.7

npm
npm install vm2@3.11.7
yarn
yarn add vm2@3.11.7
pnpm
pnpm add vm2@3.11.7

Severity breakdown

CVSS 4.0 9.3

CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Attack vector
Network
Attack complexity
Low
Attack requirements
None
Privileges required
None
User interaction
None
Confidentiality (vulnerable system)
High
Integrity (vulnerable system)
High
Availability (vulnerable system)
High
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None
Exploit maturity
Not defined
Scope
X

CVSS 3.1 9.8

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

Weakness

CWE-693: Protection Mechanism Failure

The product does not use or incorrectly uses a protection mechanism that provides sufficient defense against directed attacks against the product.

CWE-913: Improper Control of Dynamically-Managed Code Resources

The product does not properly restrict reading from or writing to dynamically-managed code resources such as variables, objects, classes, attributes, functions, or executable instructions or statements.

How to prevent it
  • Implementation: For any externally-influenced input, check the input against an allowlist of acceptable values.
  • Implementation, Architecture and Design: Refactor the code so that it does not need to be dynamically managed.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-489w-w794-jq94
vm2: NodeVM zlib Buffers expose pooled host memory across the VM boundary
High Also affects vm2
GHSA-2v2p-6j97-cjg9
vm2: Host Promise rejection from an exposed constructor can terminate the vm2 host process
Medium Also affects vm2
GHSA-5h3f-q97h-ccvc
vm2: NodeVM custom resolution bypasses external path boundaries
Critical Also affects vm2
GHSA-pq68-rvw4-xp4r
vm2 contains a sandbox escape vulnerability
Critical Also affects vm2
GHSA-f8gf-w286-fmq2
vm2: `allowAsync: false` can be bypassed through Promise thenable assimilation in VM and NodeVM
High Also affects vm2
GHSA-6rh5-qq4q-97xh
vm2: NodeVM builtin denylist bypass via fs/promises despite -fs, allowing host filesystem writes
High Also affects vm2
GHSA-8686-vhfx-7r3j
vm2: NodeVM node:-prefixed negative builtin deny bypass exposes child_process
Critical Also affects vm2
GHSA-wjwh-qqvp-g4p4
vm2 sandbox escape via WebAssembly.compileStreaming Promise species bypass
Critical Also affects vm2
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor