Security controls bypass or absence In vm2
Description
vm2 sandbox escape on Node.js 26 through a stale PromiseThenLookupChain protector ## 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: 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 vm2@3.11.5 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 vm2@3.11.5 >/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
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Component | Affected version | Patched versions |
|---|---|---|---|
npm | 3.11.7 |
Aliases
References