//nefariousplan

CVE-2026-24118: vm2's Patch Names Its Sibling. resetPromiseSpecies Was 2023.

patterns

cve

proof of concept

vm2 closed CVE-2026-24118 on April 24, 2026, with one commit that adds 306 lines to lib/bridge.js. The commit's title in the git log is "block Array species self-return sandbox escape." The commit's title in its own comment header is something else.

The header says: "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."

The first sentence describes one CVE. The second sentence describes a class.

vm2 closed CVE-2026-24118 on April 24, 2026, with one commit that adds 306 lines to lib/bridge.js. The commit's title in the git log is "block Array species self-return sandbox escape." The commit's title in its own comment header is something else.

The header says: "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."

The first sentence describes one CVE. The second sentence describes a class.

V8 reads the constructor in C++. The Proxy traps don't fire.

vm2's isolation primitive is a Proxy. Sandbox code that touches a host-realm value goes through a bridge whose get, set, apply, construct, and defineProperty traps mediate every read and every write. Every published vm2 escape is, at root, a way to reach a value the bridge did not wrap.

Array.prototype.map reaches a value the bridge cannot wrap. The patch comment is explicit about 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.

ArraySpeciesCreate is the abstract operation defined in ES2024 §10.4.2.3 that built-in array methods invoke to construct their result array. Its first step is Get(O, "constructor"). V8's implementation of that step is C++ inside the engine, not JavaScript. The Proxy's get trap fires when JavaScript code reads a property. C++ code reading the same property does not call the trap.

The bridge was the entire isolation primitive. The bridge does not see this read.

The exploit primitive is one species substitution

The primitive is the headline PoC in the upstream advisory thread. Reduced to its core, from test/ghsa/GHSA-grj5-jjm8-h35p/repro.js:

const r = hostArrayFactory();          // host-realm Array
r.push(1, 2);
function x() { return r; }              // species function returning self
x[Symbol.species] = x;
r.constructor = x;                      // install on the host array
const mapped = r.map(function (v) { return 'm' + v; });
// V8 calls new x(len), which returns r itself.
// V8 stores 'm1' and 'm2' directly into r via CreateDataPropertyOrThrow.
// mapped === r. The sandbox now holds a host-realm array with
// mapped values written by V8, with no bridge mediation in between.

hostArrayFactory() is a host-realm function exposed by the test harness. In the field, the same primitive is reached by walking the prototype chain:

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-realm Object

From ho, sandbox code mints host arrays and chains the species substitution into a host Function constructor. The full PoC #6 chain in repro.js walks through cwu (a "call via unrelated") helper that uses repeated r.map(f) calls to rewrite host descriptor entries until Function.prototype.apply is reachable as a sandbox-callable host reference. The terminal call is the standard:

const leaked = a.apply(a, e);
// `leaked` is host Function('return process'), called.
// process.mainModule.require('child_process').execSync('...') follows.

Every step from r.map(f) onward writes raw host references into a sandbox-visible array, because V8's species channel routed the result back into the array the sandbox already controls. The bridge's job was to wrap host references on their way into the sandbox. The bridge did not see this assignment.

The patch hides the constructor instead of watching it

The fix is two layers, both in lib/bridge.js. Both lie to V8.

The first lie is in the proxy get trap, for any sandbox-side read of r.constructor on a host-array-backed proxy:

const thisArrayCtor = Array;            // captured at module load

// in the proxy.get trap, key === 'constructor'
let isArr = false;
try { isArr = thisArrayIsArray(target); } catch (e) {}
if (isArr) {
    return thisArrayCtor;
}

thisArrayCtor is the host-realm Array constructor reference, captured at module load time before any sandbox code executes. Any sandbox-side read of r.constructor returns it, regardless of what was installed on the array. The patch comment names the requirement: "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 sandbox's perspective."

The second lie is in the apply and construct traps, for the C++ read that the proxy cannot intercept:

function neutralizeArraySpeciesOn(arr) {
    if (arr === null || typeof arr !== 'object') return null;
    if (!thisArrayIsArray(arr)) return null;

    const originalDesc = otherSafeGetOwnPropertyDescriptor(arr, 'constructor');

    if (originalDesc && originalDesc.configurable === false) {
        if (!(originalDesc.value === undefined && originalDesc.writable === false)) {
            throw new VMError('Unsafe array constructor cannot be neutralized');
        }
        return null;
    }

    const defined = otherReflectDefineProperty(arr, 'constructor', {
        __proto__: null,
        value: undefined,
        writable: true,
        enumerable: false,
        configurable: true,
    });
    if (!defined) {
        throw new VMError('Unsafe array state; cannot neutralize species');
    }

    return { __proto__: null, arr, originalDesc, marker: SPECIES_NEUTRALIZED };
}

In the apply trap:

let savedSpecies = null;
try {
    context = otherFromThis(context);
    args = otherFromThisArguments(args);
    savedSpecies = neutralizeArraySpeciesBatch(context, args);
    ret = otherReflectApply(object, context, args);
} catch (e) {
    throw thisFromOtherForThrow(e);
} finally {
    restoreArraySpeciesBatch(savedSpecies);
}

Before the bridge invokes a host function, it walks context and every top-level argument. For every host-realm Array.isArray-true value, it installs constructor = undefined as an own data property. ES2024 §10.4.2.3 step 3 states that ArraySpeciesCreate treats constructor === undefined as "use the default %Array% constructor." V8 reads the undefined, builds a fresh sandbox-irrelevant plain array, and the sandbox's r is unreached.

The finally block restores the original descriptor. The lie is bracketed by truth.

The patch's own commentary on the design choice:

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.

The bridge cannot watch what V8 reads. The patch makes the host arrays unwatchable for the duration of every host call.

The defense category already had a name

The patch comment header, in full:

SECURITY (GHSA-grj5-jjm8-h35p): Array species self-return escape defense.
...
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.

resetPromiseSpecies is in lib/setup-sandbox.js. It shipped in vm2 commit 26168e6 on September 6, 2023, as the fix for CVE-2023-37466. Its job is the same shape as the new function. Promise.prototype.then calls SpeciesConstructor(this, %Promise%) internally to construct the derived promise, and that operation reads this.constructor[Symbol.species] from the raw object in V8's C++ Promise machinery. resetPromiseSpecies rewrites the host promise's constructor slot to defeat that read. Same primitive, different built-in.

Two named defenses for the same class, shipped 31 months apart. resetPromiseSpecies was the first member of a category that did not exist in the codebase yet. The patch for CVE-2026-24118 names the category by citing it.

The category's name in the patch's words is "the V8-internal-bypass class." Defenses in the category share a shape: a host-realm built-in invokes a species lookup in C++; the bridge cannot intercept the C++ read; the bridge instead overwrites the property the C++ code is about to read, and restores it afterward. The defense is per-built-in. Promise had its defense in 2023. Array got its defense in 2026. TypedArray's slice, subarray, filter, and map invoke TypedArraySpeciesCreate with the same structural lookup. Array.prototype.flat and flatMap go through ArraySpeciesCreate and are listed in the patch comment alongside map/filter/slice/concat/splice. The patch's neutralizeArraySpeciesBatch covers them, because they all read the same property the patch is now hiding.

The defense library will get a third named entry whenever V8 ships another species-using built-in whose C++ implementation reads through to a raw object. The vocabulary is the inventory of places where the Proxy gave up.

The first version of this patch broke legitimate code

The patch supersedes an earlier attempt that lived briefly. The new code carries a comment about it:

// (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.)

PR #563 had set constructor = undefined on every host array entering the bridge and never restored it. The patch comment on the apply trap's new try/finally shape:

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`.

A previous fix attempt for the same class shipped a simpler version of the same lie: lie before the call, do not put it back. That lie was simpler. It also broke any host code that read arr.constructor after a host call had touched the array, because the value the host code read was now the lie.

The current patch keeps the lie and adds the bracketing. Before every host call, lie. Run the call. After every host call, restore the truth. The defense is a try/finally around every sandbox-to-host invocation.

The advisory thread documented seven PoCs

test/ghsa/GHSA-grj5-jjm8-h35p/descriptor-chain-history.js opens with a coverage matrix:

The advisory thread documents seven escape attempts iterating through
descriptor-based bypasses of the `__lookupGetter__` + `Buffer.apply`
constructor leak. PoC #6 (the Symbol.species self-return chain) is the
headline case covered in repro.js. PoCs #1-5 are closed by intermediate
fixes already on public main; this file ensures those defenses do not
regress.

PoCs #1 through #5 are descriptor-chain bypasses. Each iteration was a different way to walk from ({}).__lookupGetter__ through Buffer.apply to the host Object constructor and pull a host Function reference back. Each iteration was closed by an intermediate fix on main before this CVE was filed. The five intermediate fixes do not have CVEs. They are the fixes that produced the substrate the seventh PoC was reported against.

PoC #7 came in from a different advisory entirely. The file test/ghsa/GHSA-grj5-jjm8-h35p/regression-55hx.js documents the migration:

GHSA-55hx publishes for variant 1's disclosure (fix `a6cd917`); variant 3
graduates to GHSA-grj5. Tests live here because grj5 is the load-bearing
fix.

GHSA-55hx-c926-fr95 was a SuppressedError sub-error sandbox escape. Its variant 3, Array.fromAsync wrapping a using+eval block, threw a host-realm Promise rejection that bypassed the SuppressedError instrumented catch path. The 55hx fix (a6cd917) does not close variant 3. The grj5 fix's first layer, the cached thisArrayCtor returned from the proxy.get trap, does. Forcing host-array .constructor to return the sandbox Array re-routes Array.fromAsync's prototype walk through sandbox Promise, where handleException does fire.

Two CVEs share a root cause. The patch for one closes the other as a side effect. The advisory thread's seven PoCs were not seven bugs. They were seven addresses for one primitive.

The CVE record names instances. The defense library names classes.

The CVE record for CVE-2026-24118 is one paragraph: vm2 prior to 3.11.0 suffers from a sandbox breakout vulnerability, sandbox code can escape and run arbitrary commands on the host, and the issue is patched in 3.11.0. That description is a design-debt-driver instance label. The same template fits CVE-2023-37466 ("vm2 was vulnerable to sandbox escape via Promise.prototype.then"). It fits CVE-2026-24120 ("the fix for CVE-2023-37466 is insufficient"). It fits the next one, whichever it is.

The defense library names a class. lib/setup-sandbox.js has resetPromiseSpecies in it, with a comment that explains why the function exists. lib/bridge.js now has neutralizeArraySpeciesOn, neutralizeArraySpeciesBatch, restoreArraySpeciesOn, and restoreArraySpeciesBatch, with a comment that names the category they belong to and cites the sibling. The vocabulary is vm2's position on what its bridge cannot do.

vm2 is also a clean instance of the detector is the target. The product's only input shape is attacker-influenced code, by definition; CVE-2026-24118's PoC is a few lines of sandbox JavaScript doing exactly what sandbox JavaScript is documented to do. Every property of the tool that makes it useful, executes caller code, exposes host built-ins, mediates through a Proxy, is the surface this CVE exploits.

We covered vm2's other CVE this week through the same lens: the README's official position is "new bypasses will likely be discovered in the future." This CVE's patch comment is the source-code expression of the same position. The README admits in prose. The patch admits in defense-pattern naming. Both are vm2's words.

vm2 is an unpatchable primitive that has now formalized its defense vocabulary in code, because every fix produces a new entry in that vocabulary, because the primitive has more entry points than the bridge has trap sites.

PoC: HORKimhab/CVE-2026-24118 (the public PoC demonstrates an older Symbol-error-name async escape against vm2 3.10.1; the actual CVE-2026-24118 mechanism is the Array species self-return chain documented in the advisory and patched in f9b700b). Patch: f9b700b in vm2 v3.11.0.

The commit title names a CVE. The comment header names a class with two members. vm2 wrote both.