-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"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.\n\nThe 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.\"\n\nThe first sentence describes one CVE. The second sentence describes a class.\n\n## V8 reads the constructor in C++. The Proxy traps don't fire.\n\nvm2'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.\n\n`Array.prototype.map` reaches a value the bridge cannot wrap. The patch comment is explicit about why:\n\n```\nV8's ArraySpeciesCreate (used by Array#map/filter/slice/concat/splice and\nTypedArray equivalents) reads `this.constructor[Symbol.species]` DIRECTLY\non the raw host object, completely bypassing our proxy traps.\n```\n\n`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.\n\nThe bridge was the entire isolation primitive. The bridge does not see this read.\n\n## The exploit primitive is one species substitution\n\nThe primitive is the headline PoC in the upstream advisory thread. Reduced to its core, from `test/ghsa/GHSA-grj5-jjm8-h35p/repro.js`:\n\n```javascript\nconst r = hostArrayFactory(); // host-realm Array\nr.push(1, 2);\nfunction x() { return r; } // species function returning self\nx[Symbol.species] = x;\nr.constructor = x; // install on the host array\nconst mapped = r.map(function (v) { return 'm' + v; });\n// V8 calls new x(len), which returns r itself.\n// V8 stores 'm1' and 'm2' directly into r via CreateDataPropertyOrThrow.\n// mapped === r. The sandbox now holds a host-realm array with\n// mapped values written by V8, with no bridge mediation in between.\n```\n\n`hostArrayFactory()` is a host-realm function exposed by the test harness. In the field, the same primitive is reached by walking the prototype chain:\n\n```javascript\nconst g = ({}).__lookupGetter__;\nconst a = Buffer.apply;\nconst p = a.apply(g, [Buffer, ['__proto__']]);\nconst op = p.call(p.call(p.call(p.call(Buffer.of()))));\nconst ho = op.constructor; // host-realm Object\n```\n\nFrom `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:\n\n```javascript\nconst leaked = a.apply(a, e);\n// `leaked` is host Function('return process'), called.\n// process.mainModule.require('child_process').execSync('...') follows.\n```\n\nEvery 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.\n\n## The patch hides the constructor instead of watching it\n\nThe fix is two layers, both in `lib/bridge.js`. Both lie to V8.\n\nThe first lie is in the proxy `get` trap, for any sandbox-side read of `r.constructor` on a host-array-backed proxy:\n\n```javascript\nconst thisArrayCtor = Array; // captured at module load\n\n// in the proxy.get trap, key === 'constructor'\nlet isArr = false;\ntry { isArr = thisArrayIsArray(target); } catch (e) {}\nif (isArr) {\n return thisArrayCtor;\n}\n```\n\n`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.\"\n\nThe second lie is in the apply and construct traps, for the C++ read that the proxy cannot intercept:\n\n```javascript\nfunction neutralizeArraySpeciesOn(arr) {\n if (arr === null || typeof arr !== 'object') return null;\n if (!thisArrayIsArray(arr)) return null;\n\n const originalDesc = otherSafeGetOwnPropertyDescriptor(arr, 'constructor');\n\n if (originalDesc && originalDesc.configurable === false) {\n if (!(originalDesc.value === undefined && originalDesc.writable === false)) {\n throw new VMError('Unsafe array constructor cannot be neutralized');\n }\n return null;\n }\n\n const defined = otherReflectDefineProperty(arr, 'constructor', {\n __proto__: null,\n value: undefined,\n writable: true,\n enumerable: false,\n configurable: true,\n });\n if (!defined) {\n throw new VMError('Unsafe array state; cannot neutralize species');\n }\n\n return { __proto__: null, arr, originalDesc, marker: SPECIES_NEUTRALIZED };\n}\n```\n\nIn the apply trap:\n\n```javascript\nlet savedSpecies = null;\ntry {\n context = otherFromThis(context);\n args = otherFromThisArguments(args);\n savedSpecies = neutralizeArraySpeciesBatch(context, args);\n ret = otherReflectApply(object, context, args);\n} catch (e) {\n throw thisFromOtherForThrow(e);\n} finally {\n restoreArraySpeciesBatch(savedSpecies);\n}\n```\n\nBefore 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.\n\nThe `finally` block restores the original descriptor. The lie is bracketed by truth.\n\nThe patch's own commentary on the design choice:\n\n```\nINVARIANT: When the bridge invokes a host function, no host-realm array\nused as `this` (context) or as an argument may have an attacker-controlled\n`constructor` property visible to V8's internal ArraySpeciesCreate during\nthe call.\n```\n\nThe bridge cannot watch what V8 reads. The patch makes the host arrays unwatchable for the duration of every host call.\n\n## The defense category already had a name\n\nThe patch comment header, in full:\n\n```\nSECURITY (GHSA-grj5-jjm8-h35p): Array species self-return escape defense.\n...\nThis neutralize-on-entry/restore-on-exit pattern is analogous to\nresetPromiseSpecies in setup-sandbox.js, which defends the same\nV8-internal-bypass class for Promises.\n```\n\n`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.\n\nTwo 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.\n\nThe 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.\n\nThe 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.\n\n## The first version of this patch broke legitimate code\n\nThe patch supersedes an earlier attempt that lived briefly. The new code carries a comment about it:\n\n```javascript\n// (Removed PR #563's neutralizeArraySpecies / neutralizeArraySpeciesArgs\n// helpers, superseded by neutralizeArraySpeciesOn / neutralizeArraySpeciesBatch\n// + restoreArraySpeciesOn / restoreArraySpeciesBatch defined below, which\n// add restore-on-exit so host arrays' constructor isn't permanently mutated.\n// The PR's set/defineProperty trap interception of constructor writes is\n// preserved as a complementary defense layer.)\n```\n\nPR #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:\n\n```\nThis batch+restore design supersedes the no-restore neutralize from #563.\nThat variant permanently mutated host arrays' `constructor` to undefined,\nwhich breaks legitimate downstream reads of `arr.constructor`.\n```\n\nA 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.\n\nThe 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.\n\n## The advisory thread documented seven PoCs\n\n`test/ghsa/GHSA-grj5-jjm8-h35p/descriptor-chain-history.js` opens with a coverage matrix:\n\n```\nThe advisory thread documents seven escape attempts iterating through\ndescriptor-based bypasses of the `__lookupGetter__` + `Buffer.apply`\nconstructor leak. PoC #6 (the Symbol.species self-return chain) is the\nheadline case covered in repro.js. PoCs #1-5 are closed by intermediate\nfixes already on public main; this file ensures those defenses do not\nregress.\n```\n\nPoCs #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.\n\nPoC #7 came in from a different advisory entirely. The file `test/ghsa/GHSA-grj5-jjm8-h35p/regression-55hx.js` documents the migration:\n\n```\nGHSA-55hx publishes for variant 1's disclosure (fix `a6cd917`); variant 3\ngraduates to GHSA-grj5. Tests live here because grj5 is the load-bearing\nfix.\n```\n\nGHSA-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.\n\nTwo 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.\n\n## The CVE record names instances. The defense library names classes.\n\nThe 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](/posts/marimo-terminal-ws-only-websocket-without-the-check) 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.\n\nThe 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.\n\nvm2 is also a clean instance of [the detector is the target](/patterns/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.\n\nWe covered [vm2's other CVE this week through the same lens](/posts/vm2-cve-2026-24120-readme-already-admits): 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.\n\nvm2 is an [unpatchable primitive](/patterns/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.\n\nPoC: [HORKimhab/CVE-2026-24118](https://github.com/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`](https://github.com/patriksimek/vm2/commit/f9b700b1c7d9ef2df416666cb24e0b659140cc74) in vm2 v3.11.0.","closing_line":"The commit title names a CVE. The comment header names a class with two members. vm2 wrote both.","hook_md":"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.\n\nThe 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.\"\n\nThe first sentence describes one CVE. The second sentence describes a class.","post_id":199,"slug":"vm2-cve-2026-24118-resetpromisespecies-has-a-sibling","title":"CVE-2026-24118: vm2's Patch Names Its Sibling. resetPromiseSpecies Was 2023.","type":"initial","unreadable_sentence":"vm2's bridge is a Proxy. ArraySpeciesCreate reads the constructor in C++. The patch is the bridge admitting it cannot watch what V8 reads, and lying about what V8 sees, on every host call, on every host array."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCanTHJQAKCRDeZjl4jgkQ JtsOAQDDTrl6xDF3akBi+EUGAjdVVr+1cCA/vY8ocyXhJOOZ4wD8C52aGpjg2MN1 z9xqEt58p1Jq1+GM24AyrGt7yS61yQU= =zqtg -----END PGP SIGNATURE-----