Critical severity9.8GHSA Advisory· Published May 4, 2026· Updated May 8, 2026
CVE-2026-24118
CVE-2026-24118
Description
vm2 is an open source vm/sandbox for Node.js. Prior to version 3.11.0, VM2 suffers from a sandbox breakout vulnerability. This allows attackers to write code which can escape from the VM2 sandbox and execute arbitrary commands on the host system. This issue has been patched in version 3.11.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
vm2npm | < 3.11.0 | 3.11.0 |
Affected products
2- Range: <= 3.10.4
Patches
22b5f3e3a060dtest(GHSA-grj5-jjm8-h35p): add descriptor-chain history coverage (PoCs #1-5)
1 file changed · +137 −0
test/ghsa/GHSA-grj5-jjm8-h35p/descriptor-chain-history.js+137 −0 added@@ -0,0 +1,137 @@ +'use strict'; + +/** + * GHSA-grj5-jjm8-h35p — descriptor-chain history coverage (PoCs #1–5) + * + * The advisory thread documents seven escape attempts iterating through + * bypasses. PoC #6 (the `cwu` / Symbol.species + * self-return chain) is the headline case, closed by the fix on this branch + * and covered in `repro.js`. PoCs #1-5 are an earlier descriptor-based + * family that progressively bypassed each tightening of the + * `__lookupGetter__` + `Buffer.apply` constructor leak. + * + * They are CLOSED by intermediate fixes that have already landed on public + * `main` (e.g. `9084cd6`, `c17c27e`). This file ensures those defenses do + * not regress: each test exercises the exact PoC verbatim from the report + * with a host-side `hostMark` for ground truth. + * + * Coverage matrix (verified on Node 25.9.0): + * + * PoC #1 (Object.getOwnPropertyDescriptor → .value) blocked + * PoC #2 (ho.getOwnPropertyDescriptor → .value) blocked + * PoC #3 (ho.entries(cd).find('value') chain) blocked + * PoC #4 (getOwnPropertyDescriptors double indirection) blocked + * PoC #5 (find('constructor') variant) blocked + * + * RCE payload is replaced with `process.pid` extraction so the test is + * observation-only. + */ + +const assert = require('assert'); +const { VM } = require('../../../lib/main.js'); + +describe('GHSA-grj5-jjm8-h35p — descriptor chain history (PoCs #1–5)', function () { + + it('PoC #1 (Object.getOwnPropertyDescriptor): host process.pid not extractable', function () { + const hostMark = {pid: null, err: null}; + const vm = new VM({sandbox: {hostMark}}); + vm.run(` + try { + const g = ({}).__lookupGetter__; + const a = Buffer.apply; + const p = a.apply(g, [Buffer, ['__proto__']]); + hostMark.pid = Object.getOwnPropertyDescriptor(p.call(a),'constructor').value('return process.pid')(); + } catch (e) { hostMark.err = e.message; } + `); + assert.strictEqual(hostMark.pid, null, + 'host process.pid was extracted: hostMark.pid=' + hostMark.pid + ', host pid=' + process.pid); + }); + + it('PoC #2 (ho.getOwnPropertyDescriptor): host process.pid not extractable', function () { + const hostMark = {pid: null, err: null}; + const vm = new VM({sandbox: {hostMark}}); + vm.run(` + try { + const g = ({}).__lookupGetter__; + const a = Buffer.apply; + const p = a.apply(g, [Buffer, ['__proto__']]); + const fp = p.call(a); + const op = p.call(fp); + const ho = op.constructor; + hostMark.pid = ho.getOwnPropertyDescriptor(fp,'constructor').value('return process.pid')(); + } catch (e) { hostMark.err = e.message; } + `); + assert.strictEqual(hostMark.pid, null, + 'host process.pid was extracted: hostMark.pid=' + hostMark.pid + ', host pid=' + process.pid); + }); + + it('PoC #3 (ho.entries+a.apply chain): host process.pid not extractable', function () { + const hostMark = {pid: null, err: null}; + const vm = new VM({sandbox: {hostMark}}); + vm.run(` + try { + const g = ({}).__lookupGetter__; + const a = Buffer.apply; + const p = a.apply(g, [Buffer, ['__proto__']]); + const fp = p.call(a); + const op = p.call(fp); + const ho = op.constructor; + const cd = ho.getOwnPropertyDescriptor(fp,'constructor'); + const e = ho.entries(cd).find(v => v[0] === 'value'); + e.shift(); + e.push([undefined, ['return process.pid']]); + hostMark.pid = a.apply(a, e)(); + } catch (e) { hostMark.err = e.message; } + `); + assert.strictEqual(hostMark.pid, null, + 'host process.pid was extracted: hostMark.pid=' + hostMark.pid + ', host pid=' + process.pid); + }); + + it('PoC #4 (getOwnPropertyDescriptors double indirection): host process.pid not extractable', function () { + const hostMark = {pid: null, err: null}; + const vm = new VM({sandbox: {hostMark}}); + vm.run(` + try { + const g = ({}).__lookupGetter__; + const a = Buffer.apply; + const p = a.apply(g, [Buffer, ['__proto__']]); + const fp = p.call(a); + const op = p.call(fp); + const ho = op.constructor; + const cd = ho.getOwnPropertyDescriptor(ho.getOwnPropertyDescriptors(fp,'constructor'),'constructor'); + const ee = ho.entries(cd).find(v => v[0] === 'value'); + ee.shift(); + const e = ho.entries.apply(null, ee).find(v => v[0] === 'value'); + e.shift(); + e.push([undefined, ['return process.pid']]); + hostMark.pid = a.apply(a, e)(); + } catch (e) { hostMark.err = e.message; } + `); + assert.strictEqual(hostMark.pid, null, + 'host process.pid was extracted: hostMark.pid=' + hostMark.pid + ', host pid=' + process.pid); + }); + + it('PoC #5 (find(\'constructor\') variant): host process.pid not extractable', function () { + const hostMark = {pid: null, err: null}; + const vm = new VM({sandbox: {hostMark}}); + vm.run(` + try { + const g = ({}).__lookupGetter__; + const a = Buffer.apply; + const p = a.apply(g, [Buffer, ['__proto__']]); + const fp = p.call(a); + const op = p.call(fp); + const ho = op.constructor; + const cd = ho.getOwnPropertyDescriptors(fp,'constructor'); + const ee = ho.entries(cd).find(v => v[0] === 'constructor'); + ee.shift(); + const e = ho.entries.apply(null, ee).find(v => v[0] === 'value'); + e.shift(); + e.push([undefined, ['return process.pid']]); + hostMark.pid = a.apply(a, e)(); + } catch (e) { hostMark.err = e.message; } + `); + assert.strictEqual(hostMark.pid, null, + 'host process.pid was extracted: hostMark.pid=' + hostMark.pid + ', host pid=' + process.pid); + }); +});
f9b700b1c7d9fix(GHSA-grj5-jjm8-h35p): block Array species self-return sandbox escape
3 files changed · +498 −63
docs/ATTACKS.md+7 −4 modified@@ -1283,11 +1283,14 @@ The `Object.assign` bypass is particularly insidious: the sandbox proxy's `set` ### Mitigation -Three-layer defense in `bridge.js`: +Two-layer defense in `lib/bridge.js`: -1. **Proxy `set` trap**: Intercepts direct `constructor` writes and stores locally on proxy target. -2. **Proxy `defineProperty` trap**: Same interception for `Object.defineProperty` from sandbox code. -3. **Apply trap species neutralization**: Before and after every host function call, sets `constructor = undefined` as own property on host arrays (context and args). This shadows both own and prototype-inherited constructors. `ArraySpeciesCreate` treats `constructor = undefined` as "use default Array constructor". Non-configurable constructors detected by `Reflect.deleteProperty` returning false, blocked via VMError. Non-extensible arrays detected by `Reflect.defineProperty` returning false, blocked via VMError. `Array.isArray` works cross-realm to identify host arrays. +1. **Proxy `get` trap — cached `Array` ctor for host arrays**: When sandbox code reads `.constructor` on a host-array-backed proxy, the trap returns a module-load-time-captured `thisArrayCtor = Array` reference. This bypass of the normal property read neutralises any attacker-installed `constructor` (direct `r.constructor = x`, `Object.defineProperty`, `Object.assign`, prototype-chain injection via `Object.setPrototypeOf`) and is immune to prototype pollution of `Array.prototype.constructor`. Only defends sandbox-side reads; does not cover V8-internal reads issued from the host realm. +2. **Apply/construct trap neutralize-and-restore**: Before every `otherReflectApply(object, context, args)` and `otherReflectConstruct(object, args)` — i.e. every sandbox→host function invocation — the bridge walks `context` and each top-level argument. For every host array found (`Array.isArray` is cross-realm safe), it installs `constructor = undefined` as a data own property (shadowing both own and inherited constructors; the ES2024 spec explicitly maps `constructor === undefined` to `%Array%` in ArraySpeciesCreate). After the host call returns — in a `finally` — the prior descriptor is restored (or the shadow deleted if none existed). This covers V8-internal reads issued from the host realm during the call. + +Both layers reject un-neutralisable arrays with `VMError`: a pre-installed non-configurable `constructor` whose value is anything other than `undefined`, or a non-extensible array without an own `constructor` slot, cannot be safely shadowed or restored and is treated as an attack. + +The neutralize-on-entry/restore-on-exit pattern mirrors `resetPromiseSpecies` in `setup-sandbox.js`, which closes the equivalent V8-internal-bypass class for Promise. ### Detection Rules
lib/bridge.js+247 −59 modified@@ -145,6 +145,13 @@ const thisMapSet = ThisMap.prototype.set; const thisFunction = Function; const thisFunctionBind = thisFunction.prototype.bind; const thisArrayIsArray = Array.isArray; +// SECURITY (GHSA-grj5-jjm8-h35p): Cache the this-realm Array constructor at module +// load time, BEFORE any sandbox code runs. Used as the safe species-neutralized +// return value from proxy.get 'constructor' on host arrays. We cache this directly +// from `global.Array` so that prototype pollution attacks +// (e.g., `Array.prototype.constructor = attackerFn` via cross-realm proto injection) +// cannot redirect our defense to an attacker-controlled value. +const thisArrayCtor = Array; const thisErrorCaptureStackTrace = Error.captureStackTrace; const thisSymbolToString = Symbol.prototype.toString; @@ -364,58 +371,12 @@ function createBridge(otherInit, registerProxy) { if (!thisReflectPreventExtensions(target)) throw thisUnexpected(); } - /** - * Neutralize Array species on a host-realm value. - * - * V8's ArraySpeciesCreate algorithm reads `obj.constructor[Symbol.species]` on - * the raw host object, bypassing proxy traps. If an attacker sets constructor - * to a function that returns the same array (species self-return), map/filter/etc. - * store raw host values directly into that array, bypassing bridge sanitization. - * - * This function sets `constructor = undefined` as an own data property on any - * host array. With `constructor` undefined, ArraySpeciesCreate falls back to - * the default Array constructor, which is safe. - * - * Called before and after every host function call in the apply trap. - */ - function neutralizeArraySpecies(value) { - // Only process non-null objects (arrays are objects) - if (value === null || typeof value !== 'object') return; - try { - // Array.isArray works cross-realm — identifies host arrays correctly - if (!thisArrayIsArray(value)) return; - - // Set constructor = undefined as own data property. - // This shadows any inherited or attacker-set constructor. - const success = otherReflectDefineProperty(value, 'constructor', { - __proto__: null, - value: undefined, - writable: true, - configurable: true - }); - if (!success) { - // If defineProperty failed, the array may be non-extensible or - // constructor is non-configurable (attacker froze it). - // Either way, throw to prevent the call from proceeding. - throw new VMError('Cannot neutralize array species: constructor is non-configurable or array is non-extensible'); - } - } catch (e) { - if (e instanceof VMError) throw e; - // Swallow other errors (e.g., from Proxy traps on exotic objects) - } - } - - /** - * Neutralize Array species on all arguments and context before/after a host call. - */ - function neutralizeArraySpeciesArgs(context, args) { - neutralizeArraySpecies(context); - if (args) { - for (let i = 0; i < args.length; i++) { - neutralizeArraySpecies(args[i]); - } - } - } + // (Removed PR #563's `neutralizeArraySpecies` / `neutralizeArraySpeciesArgs` + // helpers — superseded by `neutralizeArraySpeciesOn` / `neutralizeArraySpeciesBatch` + // + `restoreArraySpeciesOn` / `restoreArraySpeciesBatch` defined below, which + // add restore-on-exit so host arrays' `constructor` isn't permanently mutated. + // The PR's set/defineProperty trap interception of `constructor` writes is + // preserved as a complementary defense layer.) function thisAddProtoMapping(proto, other, name) { // Note: proto@this(unsafe) other@other(unsafe) name@this(unsafe) throws@this(unsafe) @@ -602,6 +563,187 @@ function createBridge(otherInit, registerProxy) { } } + // SECURITY (GHSA-grj5-jjm8-h35p): Array species self-return escape defense. + // + // INVARIANT: When the bridge invokes a host function, no host-realm array used as + // `this` (context) or as an argument may have an attacker-controlled `constructor` + // property visible to V8's internal ArraySpeciesCreate during the call. + // + // WHY: V8's ArraySpeciesCreate (used by Array#map/filter/slice/concat/splice and + // TypedArray equivalents) reads `this.constructor[Symbol.species]` DIRECTLY on the + // raw host object, completely bypassing our proxy traps. If an attacker can install + // a sandbox function `x` with `x[Symbol.species] = x` as the host array's constructor, + // then call `r.map(f)` through the bridge, V8 will call `new x(len)` (returning `r` + // itself) and store mapped values directly into `r` via CreateDataPropertyOrThrow, + // bypassing the bridge entirely -- the attacker reads them back from `r`. + // + // The prior fixes (ebcfe94, 9084cd6) blocked the Function-constructor exfiltration + // chain that this primitive was originally composed with, but the primitive itself + // -- writing raw host values into a sandbox-visible slot -- remained a bridge bypass. + // This defense closes the class. + // + // Strategy: + // 1. Before every sandbox->host function invocation (apply/construct traps), walk + // `context` and each element of `args`. For every host array found, neutralize + // its species state by installing `constructor = undefined` as a data own + // property. An own-property `undefined` shadows any own or inherited ctor, and + // ArraySpeciesCreate treats undefined as "use the default %Array% constructor" + // (spec ES2024 23.1.3.1 step 3), producing a fresh plain array. + // 2. After the call completes (finally), restore the original state: if the array + // had an own `constructor` descriptor before, re-install it; otherwise delete + // our shadow. + // 3. If any host array is non-extensible or has a non-configurable `constructor` + // that isn't already `undefined`, reject the call with VMError rather than + // proceeding with an un-neutralizable species channel. + // + // This neutralize-on-entry/restore-on-exit pattern is analogous to resetPromiseSpecies + // in setup-sandbox.js, which defends the same V8-internal-bypass class for Promises. + + // SECURITY: Sentinel used to tag saved-state records. Using a unique module-local + // object ensures we never confuse attacker-installed state with our own. + const SPECIES_NEUTRALIZED = {__proto__: null}; + + // SECURITY: Neutralize the ArraySpeciesCreate channel on a single host array. + // Returns an opaque saved-state record to be passed to restoreArraySpeciesOn, + // or null if `arr` did not need neutralization (not an array / not host-realm). + // Throws VMError if the array's species state cannot be safely neutralized + // (non-configurable attacker-installed constructor, non-extensible with a + // constructor own property, etc.). + function neutralizeArraySpeciesOn(arr) { + // SECURITY: only host-realm raw arrays need neutralization. Sandbox arrays + // that cross back are already fresh objects produced by thisFromOtherArguments + // (which creates them with thisReflectDefineProperty in the local realm) -- + // V8 internal reads on those happen in the local realm too, and the + // local-realm Array.prototype.constructor is not attacker-controlled. + if (arr === null || typeof arr !== 'object') return null; + let isHostArray; + try { + // SECURITY: thisArrayIsArray works cross-realm -- Array.isArray returns + // true for any ECMAScript Array exotic object regardless of realm. + isHostArray = thisArrayIsArray(arr); + } catch (e) { + return null; + } + if (!isHostArray) return null; + + // SECURITY: capture original own descriptor (if any) before mutating. + let originalDesc; + try { + originalDesc = otherSafeGetOwnPropertyDescriptor(arr, 'constructor'); + } catch (e) { // @other(unsafe) + throw thisFromOtherForThrow(e); + } + + // SECURITY: if attacker pre-installed a non-configurable `constructor`, we + // cannot safely remove or shadow it for the duration of the call. Reject. + if (originalDesc && originalDesc.configurable === false) { + // An existing non-configurable `constructor === undefined` data property + // is the already-neutralized shape we want. Anything else is an attack. + if (!(originalDesc.value === undefined && originalDesc.writable === false)) { + throw new VMError('Unsafe array constructor cannot be neutralized'); + } + // Already safely neutralized; no-op. + return null; + } + + // SECURITY: if array is non-extensible and has no own `constructor` slot, we + // cannot install our shadow. The inherited Array.prototype.constructor value + // is benign (host %Array%), but an attacker may have shadowed it via the + // prototype chain (setPrototypeOf to an intermediate proto). Reject. + let isExt; + try { + isExt = otherReflectIsExtensible(arr); + } catch (e) { + throw thisFromOtherForThrow(e); + } + if (!isExt && !originalDesc) { + throw new VMError('Unsafe non-extensible array passed across bridge'); + } + + // SECURITY: install `constructor = undefined` as a data own property. This + // shadows any inherited or attacker-installed constructor; V8's + // ArraySpeciesCreate treats undefined as "use %Array%", producing a fresh + // plain array that is NOT the attacker's target. + let defined; + try { + defined = otherReflectDefineProperty(arr, 'constructor', { + __proto__: null, + value: undefined, + writable: true, + enumerable: false, + configurable: true + }); + } catch (e) { // @other(unsafe) + throw thisFromOtherForThrow(e); + } + if (!defined) { + // SECURITY: defineProperty returned false (e.g., frozen object). Reject. + throw new VMError('Unsafe array state; cannot neutralize species'); + } + + return { + __proto__: null, + arr: arr, + originalDesc: originalDesc, + marker: SPECIES_NEUTRALIZED + }; + } + + // SECURITY: Restore the original `constructor` state on a host array after the + // guarded host call completes. Called from a `finally` block so that errors in + // the host call do not leave the array in a neutralized state. + function restoreArraySpeciesOn(saved) { + if (!saved || saved.marker !== SPECIES_NEUTRALIZED) return; + const {arr, originalDesc} = saved; + try { + if (originalDesc) { + // SECURITY: put back exactly what was there before (including any + // non-writable/non-enumerable flags). originalDesc has __proto__ null + // already (via otherSafeGetOwnPropertyDescriptor). + otherReflectDefineProperty(arr, 'constructor', originalDesc); + } else { + // SECURITY: no prior own property -- remove our shadow so inherited + // constructor becomes visible again (preserves legitimate API semantics). + otherReflectDeleteProperty(arr, 'constructor'); + } + } catch (e) { + // SECURITY: swallow restore errors -- the array is in an inert (undefined + // constructor) state, which is strictly safer than leaving it unrestored + // if there were an exception. We intentionally do NOT re-throw because + // this runs in a finally that must not mask the primary error. + } + } + + // SECURITY: Walk `context` and every element of `args`, neutralize species on + // each host array found, return a flat list of saved-state records. Used by the + // apply/construct traps. The returned list must be passed to restoreArraySpeciesBatch + // in a `finally` block. + function neutralizeArraySpeciesBatch(context, args) { + const saved = []; + const c = neutralizeArraySpeciesOn(context); + if (c) saved[saved.length] = c; + if (args) { + // SECURITY: args is @other(safe-array) produced by otherFromThisArguments, + // length/index access is safe. We defensively use a cached length to + // avoid accidental getter invocation. + const len = args.length | 0; + for (let i = 0; i < len; i++) { + const s = neutralizeArraySpeciesOn(args[i]); + if (s) saved[saved.length] = s; + } + } + return saved; + } + + // SECURITY: Restore every host array in a saved list. Must not throw. + function restoreArraySpeciesBatch(savedList) { + if (!savedList) return; + const len = savedList.length | 0; + for (let i = 0; i < len; i++) { + restoreArraySpeciesOn(savedList[i]); + } + } + function thisDefaultGet(handler, object, key, desc) { // Note: object@other(unsafe) key@prim desc@other(safe) let ret; // @other(unsafe) @@ -656,6 +798,36 @@ function createBridge(otherInit, registerProxy) { const object = getHandlerObject(this); // @other(unsafe) switch (key) { case 'constructor': { + // SECURITY (GHSA-grj5-jjm8-h35p): If the underlying object is a + // host-realm array, ALWAYS return the sandbox-realm Array constructor + // (via the target's prototype). This neutralizes the species channel + // that V8's ArraySpeciesCreate reads via `this.constructor` during + // Array.prototype.{map,filter,slice,concat,splice,...}. Attackers + // who install a malicious `constructor` (directly, via defineProperty, + // via Object.assign, or via prototype injection) cannot leak it back + // into V8's species resolution, because this trap short-circuits to + // the safe sandbox Array. Sandbox-originated arrays are unaffected + // -- their target prototype is sandbox Array.prototype, and its + // .constructor is sandbox Array (the same return value). + // + // This covers the case where sandbox Array.prototype.map runs on + // a host-backed proxy and reads `r.constructor` via this proxy.get + // trap. The apply-trap-based neutralize (below) covers the case + // where a host Array.prototype.map runs in the host realm on the + // raw array directly (via otherReflectApply). + let isArr = false; + try { + isArr = thisArrayIsArray(target); + } catch (e) {} + if (isArr) { + // SECURITY: return the cached this-realm Array constructor. + // Do NOT read via `proto.constructor` -- that's vulnerable to + // prototype pollution (Array.prototype.constructor = attackerFn). + // thisArrayCtor is captured at module load time before any + // sandbox code can execute, so it is immutable from the + // attacker's perspective. + return thisArrayCtor; + } const desc = otherSafeGetOwnPropertyDescriptor(object, key); if (desc) { if (desc.value && isDangerousFunctionConstructor(desc.value)) return {}; @@ -739,19 +911,27 @@ function createBridge(otherInit, registerProxy) { // Note: target@this(unsafe) context@this(unsafe) args@this(safe-array) throws@this(unsafe) const object = getHandlerObject(this); // @other(unsafe) let ret; // @other(unsafe) + let savedSpecies = null; // SECURITY: GHSA-grj5-jjm8-h35p -- see neutralizeArraySpeciesBatch try { context = otherFromThis(context); args = otherFromThisArguments(args); - // Neutralize Array species before the host call. - // V8's ArraySpeciesCreate reads constructor[Symbol.species] on raw host - // arrays, bypassing proxy traps. Setting constructor = undefined forces - // the default Array constructor, which is safe. - neutralizeArraySpeciesArgs(context, args); + // SECURITY (GHSA-grj5-jjm8-h35p): Before invoking the host function, + // neutralize any attacker-installed `constructor`/Symbol.species channel + // on every host-realm array reachable as `context` or as a top-level + // argument. V8's ArraySpeciesCreate reads these properties on the raw + // object and will store raw host values into a sandbox-visible array, + // bypassing the bridge, unless we shadow them here. This batch+restore + // design supersedes the no-restore neutralize from #563 — that variant + // permanently mutated host arrays' `constructor` to undefined, which + // breaks legitimate downstream reads of `arr.constructor`. + savedSpecies = neutralizeArraySpeciesBatch(context, args); ret = otherReflectApply(object, context, args); - // Re-neutralize after the call in case the host function restored constructor. - neutralizeArraySpeciesArgs(context, args); } catch (e) { // @other(unsafe) throw thisFromOtherForThrow(e); + } finally { + // SECURITY: restore the pre-call species state even if the host call + // threw. `restoreArraySpeciesBatch` is guaranteed not to throw. + restoreArraySpeciesBatch(savedSpecies); } return thisFromOther(ret); } @@ -760,11 +940,19 @@ function createBridge(otherInit, registerProxy) { // Note: target@this(unsafe) args@this(safe-array) newTarget@this(unsafe) throws@this(unsafe) const object = getHandlerObject(this); // @other(unsafe) let ret; // @other(unsafe) + let savedSpecies = null; // SECURITY: GHSA-grj5-jjm8-h35p try { args = otherFromThisArguments(args); + // SECURITY (GHSA-grj5-jjm8-h35p): constructors can internally invoke + // ArraySpeciesCreate on argument arrays (e.g., Array(arr) copies, typed + // array constructors, Promise.all on an iterable), so neutralize args + // before the call as well. + savedSpecies = neutralizeArraySpeciesBatch(null, args); ret = otherReflectConstruct(object, args); } catch (e) { // @other(unsafe) throw thisFromOtherForThrow(e); + } finally { + restoreArraySpeciesBatch(savedSpecies); } return thisFromOtherWithFactory(getHandlerFactory(this), ret, thisFromOther(object)); }
test/ghsa/GHSA-grj5-jjm8-h35p/repro.js+244 −0 added@@ -0,0 +1,244 @@ +/** + * GHSA-grj5-jjm8-h35p — Array species self-return sandbox escape + * + * + * ## Vulnerability + * V8's `ArraySpeciesCreate` (invoked internally by `Array.prototype.{map,filter, + * slice,concat,splice,flat,flatMap}`) reads `this.constructor[Symbol.species]` + * directly on raw objects, bypassing the bridge proxy's trap handlers. An + * attacker walked the prototype chain with `({}).__lookupGetter__` + `Buffer.apply` + * to obtain host `Object`, used `ho.entries({})` to get a host-realm array `r`, + * installed a sandbox `constructor` whose `[Symbol.species]` returned `r` itself, + * then called `r.map(f)` — causing V8 to write raw host values directly into the + * sandbox-visible array and bypass bridge sanitisation entirely. Cross-reference: + * docs/ATTACKS.md Category 18 (Array Species Self-Return via Constructor + * Manipulation). + * + * ## Fix + * Two-layer defense in `lib/bridge.js`: (1) the proxy `get` trap for + * `constructor` on host arrays now returns a cached `Array` constructor captured + * at module load (prototype-pollution-proof), neutralising any sandbox-side read + * of the attacker-installed constructor; (2) the `apply`/`construct` traps + * neutralise every host array reachable as context or argument by shadowing + * `constructor = undefined` as a data own property before `otherReflectApply`, + * then restoring the prior descriptor in a `finally`. Non-configurable or + * non-extensible arrays with attacker state are rejected with `VMError`. + */ + +'use strict'; + +const assert = require('assert'); +const { VM } = require('../../../lib/main.js'); + +// SECURITY: this prelude is common across variant tests. It uses the +// `({}).__lookupGetter__` + `Buffer.apply` trick to walk the prototype chain until +// it obtains a reference to the HOST `Object` constructor. `ho.entries({})` then +// returns a host-realm array -- the primitive surface for the species attack. +const PRELUDE = ` + const g = ({}).__lookupGetter__; + const a = Buffer.apply; + const p = a.apply(g, [Buffer, ['__proto__']]); + const op = p.call(p.call(p.call(p.call(Buffer.of())))); + const ho = op.constructor; // host Object +`; + +function assertBlocked(label, code) { + const vm = new VM(); + let result; + let thrown = null; + try { + result = vm.run(PRELUDE + code); + } catch (e) { + thrown = e; + } + // Test code signals the outcome through its own return value: + // - 'BLOCKED' means the attack attempt threw inside the sandbox (caught by test code) + // - 'NO_ESCAPE' means the attack completed but did NOT escape (fix worked silently) + // Any other string means the exploit may have succeeded. + if (thrown) return; // a bridge-level throw also counts as blocked + assert.ok( + result === 'BLOCKED' || result === 'NO_ESCAPE', + `[${label}] expected BLOCKED or NO_ESCAPE, got: ${JSON.stringify(result)}`, + ); +} + +describe('GHSA-grj5-jjm8-h35p (array species self-return escape)', () => { + it('blocks the raw species primitive (r.constructor = x + r.map writes into r)', () => { + assertBlocked( + 'species-primitive', + ` + try { + const r = ho.entries({}); + r.push(1, 2); + function x() { return r; } + x[Symbol.species] = x; + r.constructor = x; + const mapped = r.map(function (v) { return 'm' + v; }); + // The defense is successful iff V8 does NOT return r as the mapped + // array (because r.constructor is neutralized to undefined during + // the map call, so ArraySpeciesCreate falls back to default %Array%). + if (mapped === r) 'ESCAPE-species-returned-self'; + else if (r[0] !== 1) 'ESCAPE-r-was-mutated'; + else 'NO_ESCAPE'; + } catch (e) { 'BLOCKED' } + `, + ); + }); + + it('blocks the canonical PoC chain (cwu helper + Function extraction)', () => { + assertBlocked( + 'canonical-poc', + ` + try { + function cwu(func, thiz, args) { + const r = ho.entries({}); + args.unshift(thiz); + const f = a.apply(a.bind, [func, args]); + r[0] = 0; + function x() { return r; } + x[Symbol.species] = x; + r.constructor = x; + r.map(f); + r.constructor = undefined; + return r; + } + const fp = cwu(p.call, p, [a]); + const cd = cwu(ho.getOwnPropertyDescriptors, undefined, fp); + const xx = cwu(ho.entries, undefined, cd); + xx[1] = [4]; + const ee = cwu(a, xx.at, xx); + ee[1] = [1]; + const y = cwu(a, ee.at, ee); + const ey = cwu(ho.entries, 0, y); + ey[1] = [0]; + const ea = cwu(a, ee.at, ey); + ea[1] = [1]; + const e = cwu(a, ee.at, ea); + e.push([undefined,['return process']]); + const leaked = a.apply(a, e); + // If we got here, the fix failed and attacker has Function ctor. + 'ESCAPE-canonical:' + typeof leaked; + } catch (e) { 'BLOCKED' } + `, + ); + }); + + it('blocks the Object.assign backdoor for installing constructor', () => { + assertBlocked( + 'object-assign', + ` + try { + const r = ho.entries({}); + r.push(1); + function x() { return r; } + x[Symbol.species] = x; + ho.assign(r, { constructor: x }); + const mapped = r.map(function (v) { return 'm' + v; }); + if (mapped === r) 'ESCAPE-assign-species-returned-self'; + else 'NO_ESCAPE'; + } catch (e) { 'BLOCKED' } + `, + ); + }); + + it('rejects non-configurable attacker-installed constructor', () => { + assertBlocked( + 'non-configurable', + ` + try { + const r = ho.entries({}); + r.push(1); + function x() { return r; } + x[Symbol.species] = x; + ho.defineProperty(r, 'constructor', { + value: x, writable: false, enumerable: false, configurable: false + }); + // After this, ANY host call that includes r as context/arg must be rejected + // because we cannot safely neutralize the species channel. + const mapped = r.map(function (v) { return 'm' + v; }); + if (mapped === r) 'ESCAPE-nonconfig-species'; + else 'NO_ESCAPE'; + } catch (e) { 'BLOCKED' } + `, + ); + }); + + it('blocks prototype-level constructor injection (intermediate proto)', () => { + assertBlocked( + 'proto-level', + ` + try { + const r = ho.entries({}); + r.push(1); + function x() { return r; } + x[Symbol.species] = x; + const fake = ho.create(Array.prototype); + fake.constructor = x; + ho.setPrototypeOf(r, fake); + const mapped = r.map(function (v) { return 'm' + v; }); + if (mapped === r) 'ESCAPE-proto-species'; + else 'NO_ESCAPE'; + } catch (e) { 'BLOCKED' } + `, + ); + }); + + it('rejects preventExtensions-d host arrays with attacker state', () => { + assertBlocked( + 'prevent-extensions', + ` + try { + const r = ho.entries({}); + r.push(1); + function x() { return r; } + x[Symbol.species] = x; + // Install constructor first (as configurable), then preventExtensions. + // After preventExtensions, existing own properties can be reconfigured + // (as long as they stay configurable), so the fix still neutralizes. + r.constructor = x; + ho.preventExtensions(r); + const mapped = r.map(function (v) { return 'm' + v; }); + if (mapped === r) 'ESCAPE-preventExt-species'; + else 'NO_ESCAPE'; + } catch (e) { 'BLOCKED' } + `, + ); + }); + + it('blocks species attacks through filter and slice', () => { + assertBlocked( + 'filter-slice', + ` + try { + const r = ho.entries({}); + r.push(1, 2); + function x() { return r; } + x[Symbol.species] = x; + r.constructor = x; + const filtered = r.filter(function () { return true; }); + const sliced = r.slice(0); + if (filtered === r || sliced === r) 'ESCAPE-filter-slice-species'; + else 'NO_ESCAPE'; + } catch (e) { 'BLOCKED' } + `, + ); + }); + + it('blocks species attack on concat result', () => { + assertBlocked( + 'concat', + ` + try { + const r = ho.entries({}); + r.push(1); + function x() { return r; } + x[Symbol.species] = x; + r.constructor = x; + const concated = r.concat([2]); + if (concated === r) 'ESCAPE-concat-species'; + else 'NO_ESCAPE'; + } catch (e) { 'BLOCKED' } + `, + ); + }); +});
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/patriksimek/vm2/commit/2b5f3e3a060d9088f5e1cdd585d683d491f990a3nvdPatchWEB
- github.com/patriksimek/vm2/commit/f9b700b1c7d9ef2df416666cb24e0b659140cc74nvdPatchWEB
- github.com/patriksimek/vm2/security/advisories/GHSA-grj5-jjm8-h35pnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-grj5-jjm8-h35pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-24118ghsaADVISORY
- github.com/patriksimek/vm2/releases/tag/v3.11.0nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.