GitHub Advisoryadvisory · npm

GHSA-27g9-p43v-cw3v vm2 sandbox escape on Node.js 26 through a stale PromiseThenLookupChain protector

CRITICAL Published 2026-10-01 Modified 2026-10-01 Official page ↗
Aliases
CVE-2026-92944
Ecosystem
npm
Packages
vm2
Severity
Critical
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Weaknesses
CWE-693, CWE-913
Reviewed
2026-10-01

Affected packages

• npm vm2: >= 3.10.2, < 3.11.7 → fixed in 3.11.7

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`: `[email protected]` • `[email protected]` 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: 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: ```js 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: ```js 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. ```sh mkdir vm2-node26-repro cd vm2-node26-repro npm init -y npm install --ignore-scripts --no-audit --no-fund [email protected] cp /path/to/poc.js . node poc.js ``` Alternatively, with the official Node.js 26.7.0 container: ```sh 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 [email protected] >/dev/null 2>&1 && cp /poc/poc.js . && node poc.js ' ``` Representative output: ```json { "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: ```sh 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: ```sh 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. …

Related

Alias · CVE (1)

  • CVE-2026-92944 vm2 versions 3.10.2 through 3.11.6 contain a sandbox escape vulnerability on Node.js 26 where Promise.prototype.finally() bypasses vm2's… vulnerability

Weakness · CWE (2)

  • CWE-693 Protection Mechanism Failure weakness
  • CWE-913 Improper Control of Dynamically-Managed Code Resources weakness

References