vm2's Bridge Proxy set trap ignores receiver parameter, enabling host object property injection via prototype chain
Description
Summary
The BaseHandler.set trap in bridge.js (line 1231) ignores the receiver parameter and unconditionally writes to the host target object. Per the Proxy set trap specification, when receiver !== proxy (e.g., when a child object inherits from the proxy via Object.create), the property assignment should create an own property on the receiver, not on the proxy target. The current implementation always calls otherReflectSet(object, key, value) against the host target, causing all inherited property writes to leak through to the host object.
This bug provides an alternative attack vector for writing dangerous cross-realm Symbol keys (e.g., nodejs.util.promisify.custom) to host objects, bypassing any future per-trap isDangerousCrossRealmSymbol guard on the direct set path.
Vulnerable
Code
// bridge.js:1231-1260
set(target, key, value, receiver) {
validateHandlerTarget(this, target);
const object = getHandlerObject(this);
if (isProtectedHostObject(object)) throw new VMError(OPNA);
// ...
try {
value = otherFromThis(value);
return otherReflectSet(object, key, value) === true;
// BUG: 'receiver' is never used.
// Should check if receiver !== proxy and handle accordingly.
} catch (e) {
throw thisFromOtherForThrow(e);
}
}
Impact
Sandbox code can write arbitrary properties (including dangerous Symbol-keyed properties) to any host object it holds a reference to, by creating a prototype-inheriting child:
// Sandbox code
const child = Object.create(hostObj);
child.injectedProp = 'attacker-value';
// hostObj now has 'injectedProp' on the HOST side
Combined with the Symbol.for coverage gap, this enables semantic confusion attacks:
const kCustom = Symbol.for('nodejs.util.promisify.custom');
const child = Object.create(hostFunction);
child[kCustom] = function() {
return Promise.resolve('attacker-controlled');
};
// Host: util.promisify(hostFunction)() returns 'attacker-controlled'
Reproduction
const { VM } = require('vm2');
const util = require('util');
const vm = new VM();
const hostFn = function api(cb) { cb(null, 'ok'); };
vm.setGlobal('hostFn', hostFn);
vm.run(`
const kCustom = Symbol.for('nodejs.util.promisify.custom');
const child = Object.create(hostFn);
child[kCustom] = function() {
return Promise.resolve('EXPLOITED-VIA-RECEIVER-BUG');
};
`);
// Host side
const promisified = util.promisify(hostFn);
promisified('test').then(r => console.log(r));
// Output: EXPLOITED-VIA-RECEIVER-BUG
Suggested
Fix
set(target, key, value, receiver) {
validateHandlerTarget(this, target);
const object = getHandlerObject(this);
if (isProtectedHostObject(object)) throw new VMError(OPNA);
if (isDangerousCrossRealmSymbol(key)) throw new VMError(OPNA);
if (key === '__proto__' && !thisOtherHasOwnProperty(object, key)) {
return this.setPrototypeOf(target, value);
}
if (key === 'constructor' && thisArrayIsArray(object)) {
thisReflectSet(target, key, value);
return true;
}
try {
value = otherFromThis(value);
// When receiver is not the proxy itself, set on receiver (this-realm)
// instead of the host target to preserve prototype-chain semantics.
return otherReflectSet(object, key, value) === true;
} catch (e) {
throw thisFromOtherForThrow(e);
}
}
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
vm2's bridge Proxy set trap ignores the receiver parameter, allowing sandbox code to write arbitrary properties to any host object via prototype inheritance, bypassing safety guards.
Vulnerability
The BaseHandler.set trap in vm2's lib/bridge.js (line 1231) ignores the receiver parameter and unconditionally writes to the host target object via otherReflectSet(object, key, value). Per the ECMA-262 §9.5.9 [[Set]] specification, when receiver !== proxy the property assignment should create an own property on the receiver, not on the proxy target. This bug exists in all versions before 3.11.4 [1][2][3][4].
Exploitation
An attacker with sandbox code execution can create an object that inherits from any host object reference (e.g., const child = Object.create(hostObj)) and then set a property on that child. Because the trap ignores the receiver, the write lands on the host object. The attacker can also use Reflect.set(proxy, key, value, customReceiver) to trigger the same write-through behavior [1][3][4]. This provides a vector to write dangerous cross-realm Symbol keys, such as Symbol.for('nodejs.util.promisify.custom'), to host functions [3][4].
Impact
Successful exploitation allows sandbox code to write arbitrary properties (including dangerous Symbol-keyed properties) onto any host object held by the sandbox. Combined with other weaknesses, this enables semantic confusion attacks: for example, writing kCustom to a host function's prototype chain can cause util.promisify(hostFunction) to call attacker-controlled code, leading to remote code execution (RCE) in the host realm [1][2]. The CVSS score is 8.0 (High) [1][3].
Mitigation
The vulnerability is fixed in vm2 version 3.11.4, released on 2026-05-29 [2]. Users should upgrade to v3.11.4 or later. No workaround is available; this patch closes the set trap receiver bypass along with several related sandbox escape vectors [2].
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <= 3.11.3
Patches
126d0318b5e65fix(GHSA-c4cf-2hgv-2qv6): honour spec [[Set]] receiver in BaseHandler.set
4 files changed · +351 −2
CHANGELOG.md+3 −2 modified@@ -2,10 +2,11 @@ ## [3.11.4] -Four advisories closed. Patch release — no API changes. +Five advisories closed. Patch release — no API changes. ### Security fixes +- **GHSA-c4cf-2hgv-2qv6** — bridge escape via `BaseHandler.set` ignoring the ECMA-262 §9.5.9 `Receiver` argument; `Object.create(hostProxy).x = v` and `Reflect.set(hostProxy, k, v, sandboxObj)` wrote through to the host object instead of installing on the receiver, turning every embedder-exposed host object into a sandbox write channel. Receiver-gated install-on-receiver fix in `lib/bridge.js` mirroring `ReadOnlyHandler.set`. See ATTACKS.md Category 32 and `test/ghsa/GHSA-c4cf-2hgv-2qv6/`. - **GHSA-m5q2-4fm3-vfqp** — sandbox escape via unblocked cross-realm `Symbol.for` keys plus missing dangerous-symbol guards on the bridge's write traps. Two-layer structural fix: `lib/setup-sandbox.js` denies the entire `nodejs.` namespace at `Symbol.for` and aligns the read-side filters with the full 9-symbol cache, and `lib/bridge.js` extends `isDangerousCrossRealmSymbol` and applies it to the `set`/`defineProperty`/`deleteProperty` traps. See ATTACKS.md Category 8 / Category 20 (both extended) and `test/ghsa/GHSA-m5q2-4fm3-vfqp/`. - **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/`. - **GHSA-q3fm-4wcw-g57x** — Defense Invariant #11 hardening for `defaultSandboxPrepareStackTrace` (second variant of GHSA-9qj6-qjgg-37qq in a different file). The sandbox stack-trace formatter accumulated frames in a sandbox-realm array and `.join`-ed them, so a sandbox-installed setter on `Array.prototype[N]` (or `.join` override) observed bridge-internal state — no host reference reachable today, but one enrichment away from regressing into the GHSA-9qj6 RCE shape. Fixed in `lib/setup-sandbox.js` by folding frames through a primitive string accumulator (no `Array.prototype` slot reachable) and converting `makeCallSiteGetters` to `localReflectDefineProperty` for symmetry. See ATTACKS.md Category 28 Variant B and `test/ghsa/GHSA-q3fm-4wcw-g57x/`. @@ -14,7 +15,7 @@ Four advisories closed. Patch release — no API changes. ## [3.11.3] -Single advisory closed. Patch release — no API changes. +Patch release — no API changes. ### Security fix
docs/ATTACKS.md+102 −0 modified@@ -2535,6 +2535,107 @@ The fix preserves benign subclass behaviour: `class MyPromise extends Promise {} --- +## Attack Category 32: Bridge `set` Trap Ignores Spec `Receiver` — Inherited-Receiver Write-Through + +**Uses**: [Category 6: Proxy Trap Exploitation](#attack-category-6-proxy-trap-exploitation). + +### Description + +ECMA-262 §9.5.9 `[[Set]](P, V, Receiver)` for Proxy exotic objects supplies the *original recipient* of the assignment as the `Receiver` parameter to the trap. When sandbox code writes to an object that **inherits** from a bridge proxy (`Object.create(proxy).x = v`) or supplies a forged receiver (`Reflect.set(proxy, k, v, customReceiver)`), V8 invokes the trap with `Receiver` set to that recipient — *not* the proxy itself. The spec-mandated behaviour for the trap is to install the property on `Receiver`, mirroring how ordinary objects propagate `[[Set]]` up the prototype chain. + +`BaseHandler.set` in `lib/bridge.js` historically ignored the `Receiver` argument and unconditionally forwarded the write through to the wrapped host object via `otherReflectSet(object, key, value)`. Consequence: **every host-realm object exposed to the sandbox becomes a write channel through any inheriting receiver.** A single `Object.create(hostObj)` produces a sandbox-side object whose every property write lands on the host object, bypassing any future write-side hardening that assumes "writes only arrive via direct `proxy.x = v` through the canonical proxy receiver". The originally reported path used `kCustom = Symbol.for('nodejs.util.promisify.custom')` as the write key against `Object.create(hostFn)` to install a sandbox-controlled function under the host promisifier dispatch slot, so `util.promisify(hostFn)()` on the host side would dispatch to attacker code. The class is generic to any key; the symbol-key shape was the sharpest end (host control flow hand-off) but plain string keys are equally write-through. + +CVSS:3.1 ~8.0 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N — direct host data integrity violation; full RCE requires the host to consume the polluted slot). + +### Attack Flow + +1. Sandbox obtains a reference to any host-realm object the embedder exposed — either via `sandbox: { x }` or any function/value reachable through the bridge. +2. Sandbox constructs an inheriting object: `const child = Object.create(hostObj)` (or any equivalent — `Reflect.set(hostObj, k, v, sandboxObj)`, `Object.create(Object.create(hostObj))`, `Object.assign(Object.create(hostObj), src)`). +3. Sandbox writes a property: `child[key] = sandboxValue`. V8's `[[Set]]` walks `child` → `proxy(hostObj)` and invokes the trap with `Receiver = child`. +4. The pre-fix trap discarded `Receiver` and ran `otherReflectSet(hostObj, key, value)`. The property landed on `hostObj` on the **host realm**. +5. Host code subsequently reads `hostObj[key]` (or `Object.getOwnPropertySymbols(hostObj)`, or dispatches through it via a well-known protocol such as `util.promisify`). The attacker's value is read; if the host treats it as a callable, sandbox code runs in the host realm. + +### Canonical Example + +```javascript +// (advisory GHSA-c4cf-2hgv-2qv6) +const util = require('util'); +const { VM } = require('vm2'); + +const hostFn = function api(cb) { cb(null, 'real-data'); }; +const vm = new VM(); +vm.sandbox.hostFn = hostFn; + +vm.run(` + const kCustom = Symbol.for('nodejs.util.promisify.custom'); + const child = Object.create(hostFn); + child[kCustom] = function () { + return Promise.resolve('HIJACKED-VIA-RECEIVER-BUG'); + }; +`); + +// Host side: +util.promisify(hostFn)().then(console.log); // → "HIJACKED-VIA-RECEIVER-BUG" +``` + +Five variants share the same primitive — `receiver !== <canonical proxy for target>`: + +| # | Primitive | +|---|-----------| +| 1 | `Object.create(hostObj)[Symbol.for('nodejs.util.promisify.custom')] = fn` | +| 2 | `Object.create(hostObj).x = 'v'` (plain string key — no symbol involved) | +| 3 | `Reflect.set(hostObj, k, v, sandboxObj)` | +| 4 | `Object.create(Object.create(hostObj)).x = 'v'` (deep proto chain) | +| 5 | `Object.assign(Object.create(hostObj), { k: v })` | + +### Why It Works + +`BaseHandler.set` was written to "forward writes to the host target", a reasonable mental model when the only assumption is `proxy.x = v`. The spec, however, defines `[[Set]]` over the proxy as a single trap that subsumes *all* writes reaching the proxy through the prototype chain — including writes whose original recipient is some sandbox-side child. Two existing handlers got this right by accident: `ReadOnlyHandler.set` writes to `receiver` unconditionally (its policy is "host is read-only"), and `ProtectedHandler.set`'s function-value branch also installs on `receiver`. `BaseHandler.set` was the only trap that violated the spec — and because every non-intrinsic host object flows through `BaseHandler` (intrinsics go through `ProtectedHandler`'s OPNA short-circuit, read-only mocks go through `ReadOnlyHandler`), the bug applied to every embedder-exposed object the sandbox was ever handed. + +Interaction with [Category 8 / Category 20 / GHSA-m5q2-4fm3-vfqp](#attack-category-20-cross-realm-symbol-extraction-via-host-object-prototype-walk): the m5q2 fix expanded `Symbol.for` to deny the entire `nodejs.` namespace **and** added an `isDangerousCrossRealmSymbol(key)` rejection inside the `set` / `defineProperty` / `deleteProperty` traps themselves. That symmetric symbol guard fires before the receiver-mismatch check below, so the canonical symbol-key PoC (variant 1) is now structurally blocked at two independent layers even on a sandbox lacking this category's fix. The receiver bug remains a real, generic write-channel — variants 2–5 (plain string keys, forged `Reflect.set` receiver, deep proto chains, `Object.assign(child, src)`) reach the host write path without involving any cross-realm symbol — so this category's fix is required defense-in-depth on top of m5q2 rather than a duplicate of it. + +### Mitigation + +Restores [Defense Invariant 1](#defense-invariants) ("no host-realm object reaches sandbox code unwrapped") and adds a previously-implicit corollary: **a sandbox-originated write reaches a host-realm object only when the spec `[[Set]]` receiver equals the canonical bridge proxy for that object's target.** + +`lib/bridge.js`, `BaseHandler.set` (after the existing `__proto__` and `constructor`-on-array short-circuits, before the host-write forwarding): + +```javascript +const canonicalProxy = thisReflectApply(thisWeakMapGet, mappingOtherToThis, [object]); +if (receiver !== canonicalProxy) { + return thisReflectDefineProperty(receiver, key, { + __proto__: null, + value: value, + writable: true, + enumerable: true, + configurable: true, + }) === true; +} +``` + +The lookup reuses `mappingOtherToThis`, which `thisProxyOther` already populates with `[other, proxy]` (`!isHost`) or `[other, proxy2]` (`isHost`). In the host-loaded branch the *outer* `proxy2` is the sandbox-facing object; its empty handler forwards `[[Set]]` to `proxy` while preserving `Receiver`, so the BaseHandler trap fires with `Receiver === proxy2`. Both branches therefore yield a single canonical proxy per host target, and direct sandbox writes (`hostProxy.x = v`, `Reflect.set(hostProxy, k, v, hostProxy)`, `Object.assign(hostProxy, src)`) continue to take the legitimate `otherReflectSet` path. Non-canonical receivers — `Object.create(proxy)` children, explicit-receiver `Reflect.set` calls, sandbox-side `setPrototypeOf` constructions — install on the receiver itself via `Reflect.defineProperty`, exactly as the spec mandates. + +The fix is symmetric with `ReadOnlyHandler.set` (which uses the same install-on-receiver shape unconditionally) and with the function-value branch of `ProtectedHandler.set`. `ProtectedHandler.set` inherits the fix automatically through its `super.set` delegation for non-function values. + +### Detection Rules + +- **`Object.create(hostProxy)` followed by property assignment on the child** — every form: `child[k] = v`, `Reflect.set(child, k, v)`, `Object.defineProperty(child, k, desc)` (the last installs on `child` directly and does not route through the trap, so it is not relevant; the first two are). +- **`Reflect.set(hostProxy, k, v, receiver)` where `receiver !== hostProxy`** — explicit-receiver writes against any host object. +- **`Object.assign(Object.create(hostProxy), src)`** — bulk pollution via the receiver-mismatch primitive. +- **Code review pattern**: sandbox code that calls `Object.create` on any embedder-exposed object is suspicious; benign sandbox code virtually never needs to inherit from host objects. + +### Considered Attack Surfaces + +- **`Reflect.set(hostProxy, k, v, hostProxy)`** — receiver matches the canonical proxy, goes through the legitimate write path. Equivalent to `hostProxy[k] = v`. Existing `isProtectedHostObject` and `__proto__` short-circuits still apply. +- **`Reflect.set(hostProxy, k, v, undefined)` / `null`** — receiver does not strict-equal the canonical proxy → install-on-receiver branch fires → `Reflect.defineProperty(undefined, …)` throws a `TypeError`, the sandbox sees a `TypeError`, the host object is untouched. +- **Adversary-controlled Proxy receiver with a custom `defineProperty` trap** — vm2 does not expose the `Proxy` constructor to the sandbox in plain VM mode (`typeof Proxy === 'undefined'`), so this composition is not reachable today. If a future change exposes `Proxy`, the install-on-receiver branch must be re-audited. +- **`setPrototypeOf(child, hostProxy)` after `child` already has writes** — the next write on `child` walks the new prototype, fires the trap with `Receiver = child`, and installs on `child`. Host untouched. +- **`BaseHandler.defineProperty`** — `[[DefineOwnProperty]]` carries no `Receiver` per ECMA-262 §9.5.6; `Object.defineProperty(child, k, desc)` where `child` inherits from `hostProxy` installs on `child` directly without invoking the proxy's `defineProperty` trap. No analogous receiver bug exists. (`Object.defineProperty(hostProxy, k, desc)` *does* route to the trap, but that path already has the `isProtectedHostObject` short-circuit from GHSA-vwrp-x96c-mhwq.) +- **`BaseHandler.deleteProperty`** — `[[Delete]]` (§9.5.10) has no `Receiver`. `delete child.k` for `child` inheriting from proxy is a no-op on the proxy. +- **Compound with [Category 18: Array Species Self-Return](#attack-category-18-array-species-self-return-via-constructor-manipulation)** — `Object.create(hostArray).constructor = fn` now installs `constructor` on the sandbox child rather than (as before the existing array-constructor short-circuit) on the proxy target. The host array's raw `constructor` slot remains untouched in either case; `neutralizeArraySpecies` continues to defend the species path independently. + +--- + ## Considered Attack Surfaces These attack surfaces were analyzed and found to be safe or low-risk. They are documented here so future reviewers do not re-investigate them. @@ -2660,6 +2761,7 @@ The most dangerous attacks combine multiple categories. Each pattern references | Internal state probe via computed property access on `globalThis` (GHSA-2cm2-m3w5-gp2f) | Bootstrap script declares `let VM2_INTERNAL_STATE_…` at script-top so the binding lands in the context's `[[GlobalLexicalEnvironment]]`; transformer-emitted `${INTERNAL_STATE_NAME}.handleException(…)` resolves there as before, but `globalThis[k]`, `Reflect.get`, descriptor APIs, and own-property enumeration cannot reach it (the global object's own-key table no longer contains the entry). Supersedes the identifier-only mitigation of GHSA-wp5r-2gw5-m7q7 by closing the entire computed-key class structurally. | | Bridge-internal container via `Array.prototype[N]` setter (Category 28: GHSA-9qj6-qjgg-37qq Variant A + GHSA-q3fm-4wcw-g57x Variant B) | Variant A — `neutralizeArraySpeciesBatch` in `lib/bridge.js` 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. Variant B — `defaultSandboxPrepareStackTrace` in `lib/setup-sandbox.js` accumulates frames in a string via primitive concatenation rather than an array, removing every reachable `Array.prototype` slot (index setter, getter, and `.join`); `makeCallSiteGetters` installs entries via `localReflectDefineProperty` for symmetry | | 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. | +| Bridge `set` trap ignores spec `Receiver` (GHSA-c4cf-2hgv-2qv6) | `BaseHandler.set` gates host-write forwarding on `receiver === mappingOtherToThis.get(object)`; non-canonical receivers (inherited-receiver writes via `Object.create(proxy)`, forged-receiver `Reflect.set` calls, `Object.assign(child, src)` loops) install on `receiver` via `Reflect.defineProperty`, mirroring `ReadOnlyHandler.set` | ### Key Security Invariant: Promise Species Resolution Timing
lib/bridge.js+24 −0 modified@@ -1450,6 +1450,30 @@ function createBridge(otherInit, registerProxy) { thisReflectSet(target, key, value); return true; } + // SECURITY (GHSA-c4cf-2hgv-2qv6): honour ECMA-262 9.5.9 [[Set]] + // receiver semantics. When sandbox writes via an object that + // *inherits* from this proxy (e.g. `Object.create(proxy).x = v`) + // or supplies a forged receiver (`Reflect.set(proxy, k, v, child)`), + // V8 invokes this trap with `receiver` set to the child — not the + // proxy itself. The pre-fix trap ignored `receiver` and wrote + // through to the host object, letting attacker-controlled writes + // silently land on host-side properties the sandbox should never + // reach. Mirror ReadOnlyHandler.set's install-on-receiver shape, + // gated on receiver !== canonical proxy so the legitimate direct + // `proxy.x = v` write path (embedder contract) remains unchanged. + // `mappingOtherToThis` already stores the canonical sandbox-facing + // proxy per host object (populated in thisProxyOther for both the + // !isHost inner-proxy and isHost outer-proxy2 branches). + const canonicalProxy = thisReflectApply(thisWeakMapGet, mappingOtherToThis, [object]); + if (receiver !== canonicalProxy) { + return thisReflectDefineProperty(receiver, key, { + __proto__: null, + value: value, + writable: true, + enumerable: true, + configurable: true, + }) === true; + } try { value = otherFromThis(value); return otherReflectSet(object, key, value) === true;
test/ghsa/GHSA-c4cf-2hgv-2qv6/repro.js+222 −0 added@@ -0,0 +1,222 @@ +/** + * GHSA-c4cf-2hgv-2qv6 — Bridge `set` trap ignores `receiver`, leaking + * prototype-inheriting writes through to the wrapped host object. + * + * ## Vulnerability class + * + * Per ECMA-262 9.5.9 `[[Set]] (P, V, Receiver)`, when sandbox code writes + * to an object that *inherits* from a bridge proxy (rather than to the + * proxy itself), the proxy's `set` trap is invoked with `receiver` set to + * the original recipient — not to the proxy. The spec-correct behaviour + * is to install the property on `receiver` (or refuse), NOT on the proxy + * target. `BaseHandler.set` in `lib/bridge.js` historically ignored the + * `receiver` argument and unconditionally wrote through to the host + * object via `otherReflectSet(object, key, value)`. The consequence: any + * sandbox-side child built via `Object.create(hostObj)` becomes a write + * channel onto the host object, bypassing any write-side hardening + * keyed on the direct-assignment path. + * + * ## Canonical PoC (from the advisory) + * + * const kCustom = Symbol.for('nodejs.util.promisify.custom'); + * const child = Object.create(hostFn); + * child[kCustom] = function () { return Promise.resolve('HIJACKED'); }; + * // Host side: util.promisify(hostFn) now returns the sandbox's fn. + * + * No proxy trap on the host side intervenes because the write went via + * the proxy's `set` trap with `receiver === child`, and the trap blindly + * called `otherReflectSet(hostFn, kCustom, hijackedFn)`. + * + * ## Fix (lib/bridge.js) + * + * `BaseHandler.set` records the canonical sandbox-facing proxy at + * construction time and, when invoked, refuses to write through to the + * host target unless `receiver` is one of the bridge's own proxies for + * that target. For any other receiver — a sandbox-side `Object.create` + * child, an arbitrary `Reflect.set(..., custom)` receiver — the property + * is installed on `receiver` itself via `Reflect.defineProperty`, which + * matches both the ECMA-262 [[Set]] semantics and the existing + * `ReadOnlyHandler.set` shape. The host object is never mutated for + * non-proxy receivers. + * + * ## Invariant + * + * "Sandbox writes reach a host-realm object only through the canonical + * bridge proxy for that object." Restated: the bridge `set` trap MUST + * honour the spec `receiver`; child-of-proxy writes never cross the + * realm boundary. + */ + +'use strict'; + +const assert = require('assert'); +const util = require('util'); +const { VM } = require('../../../lib/main.js'); + +if (typeof it.cond !== 'function') { + it.cond = function (name, cond, fn) { + return cond ? it(name, fn) : it.skip(name, fn); + }; +} + +describe('GHSA-c4cf-2hgv-2qv6 (set trap receiver ignored — write-through via Object.create child)', function () { + // ---- PRIMARY POC ---------------------------------------------------------- + + it('blocks PoC: Object.create(hostFn) + child[Symbol.for(...)]=fn must NOT install on host', function () { + const hostFn = function api(cb) { + cb(null, 'ok'); + }; + const vm = new VM(); + vm.sandbox.hostFn = hostFn; + + try { + vm.run(` + const kCustom = Symbol.for('nodejs.util.promisify.custom'); + const child = Object.create(hostFn); + child[kCustom] = function () { + return Promise.resolve('HIJACKED-VIA-RECEIVER-BUG'); + }; + `); + } catch (_) { + /* either trap threw or symbol stripped — both acceptable */ + } + + // Host-side: no own property must have leaked onto hostFn, regardless of + // whether the key materialised as the dangerous host-realm symbol or as + // a sandbox-local symbol. + const ownSyms = Object.getOwnPropertySymbols(hostFn); + for (const s of ownSyms) { + const desc = String(s); + assert.notStrictEqual( + desc, + 'Symbol(nodejs.util.promisify.custom)', + 'host fn must not carry a sandbox-installed nodejs.util.promisify.custom symbol', + ); + } + + // And util.promisify must use Node's default behaviour, not the sandbox fn. + const promisified = util.promisify(hostFn); + return promisified().then(v => { + assert.strictEqual(v, 'ok', 'promisify must not be hijacked'); + }); + }); + + // ---- VARIANTS ------------------------------------------------------------- + + it('blocks variant: plain string-key write on Object.create child must NOT leak to host', function () { + const hostObj = { tag: 'host-original' }; + const vm = new VM(); + vm.sandbox.hostObj = hostObj; + + try { + vm.run(` + const child = Object.create(hostObj); + child.injectedString = 'sandbox-value'; + child.tag = 'overwritten-by-sandbox'; + `); + } catch (_) { + /* trap-refusal is also acceptable */ + } + + assert.strictEqual( + Object.prototype.hasOwnProperty.call(hostObj, 'injectedString'), + false, + 'host object must not gain an injected own property via Object.create-child write', + ); + assert.strictEqual(hostObj.tag, 'host-original', 'host object existing property must be unchanged'); + }); + + it('blocks variant: Reflect.set(hostObj, k, v, customReceiver) must NOT leak to host', function () { + const hostObj = function () {}; + const vm = new VM(); + vm.sandbox.hostObj = hostObj; + + try { + vm.run(` + const customReceiver = { __proto__: null }; + Reflect.set(hostObj, 'reflectInjected', 'sandbox-value', customReceiver); + Reflect.set(hostObj, Symbol.for('nodejs.util.promisify.custom'), + function () { return Promise.resolve('HIJACK-VIA-REFLECT'); }, + customReceiver); + `); + } catch (_) { + /* trap-refusal acceptable */ + } + + assert.strictEqual( + Object.prototype.hasOwnProperty.call(hostObj, 'reflectInjected'), + false, + 'Reflect.set with explicit receiver must not leak to host', + ); + const ownSyms = Object.getOwnPropertySymbols(hostObj); + for (const s of ownSyms) { + assert.notStrictEqual( + String(s), + 'Symbol(nodejs.util.promisify.custom)', + 'Reflect.set with explicit receiver must not install dangerous symbol on host', + ); + } + }); + + it('blocks variant: nested Object.create chain (grandchild → child → host) must NOT leak', function () { + const hostObj = {}; + const vm = new VM(); + vm.sandbox.hostObj = hostObj; + + try { + vm.run(` + const child = Object.create(hostObj); + const grand = Object.create(child); + grand.depthInjected = 'leaked'; + `); + } catch (_) { + /* acceptable */ + } + + assert.strictEqual( + Object.prototype.hasOwnProperty.call(hostObj, 'depthInjected'), + false, + 'deep proto-chain writes must not surface on the host object', + ); + }); + + it('blocks variant: Object.assign(child, src) where child inherits from host must NOT leak', function () { + const hostObj = {}; + const vm = new VM(); + vm.sandbox.hostObj = hostObj; + + try { + vm.run(` + const child = Object.create(hostObj); + Object.assign(child, { assignedKey: 'leaked' }); + `); + } catch (_) { + /* acceptable */ + } + + assert.strictEqual( + Object.prototype.hasOwnProperty.call(hostObj, 'assignedKey'), + false, + 'Object.assign onto Object.create-child must not leak to host', + ); + }); + + // ---- NEGATIVE CONTROL ----------------------------------------------------- + // Sanity: legitimate proxy writes still work — direct property assignment on + // a host object handed to the sandbox should still mutate the host object. + // (Without this, our fix has gone too far and broken the embedder contract.) + + it('negative-control: direct write proxy.x = v still mutates host object', function () { + const hostObj = { existing: 1 }; + const vm = new VM(); + vm.sandbox.hostObj = hostObj; + + vm.run(`hostObj.newField = 42;`); + + assert.strictEqual( + hostObj.newField, + 42, + 'direct sandbox-side writes to the proxy must still mutate the host object', + ); + }); +});
Vulnerability mechanics
Root cause
"`BaseHandler.set` in `lib/bridge.js` ignores the ECMA-262 §9.5.9 `receiver` parameter and unconditionally writes to the host target, so property assignments on objects inheriting from a bridge proxy land on the host object instead of the receiver."
Attack vector
An attacker in the sandbox obtains a reference to any host-realm object exposed by the embedder. They construct an inheriting child via `Object.create(hostObj)` and write a property on that child. Per ECMA-262 §9.5.9, V8 invokes the proxy's `set` trap with `receiver` set to the child, not the proxy itself [ref_id=1]. The pre-fix trap ignores `receiver` and writes through to the host object, allowing the attacker to plant arbitrary properties — including dangerous cross-realm Symbol keys like `Symbol.for('nodejs.util.promisify.custom')` — on the host object [ref_id=1]. Host code that subsequently reads or dispatches through that property (e.g., `util.promisify(hostFn)`) executes attacker-controlled logic in the host realm.
Affected code
The bug is in `BaseHandler.set` in `lib/bridge.js` (line 1231 in the vulnerable version). The trap ignores the `receiver` parameter and unconditionally writes to the host target via `otherReflectSet(object, key, value)`, violating ECMA-262 §9.5.9 [[Set]] semantics. The patch adds a receiver check that routes non-canonical receivers (e.g., `Object.create(proxy)` children) to `Reflect.defineProperty` on the receiver instead of the host object.
What the fix does
The patch in `lib/bridge.js` looks up the canonical sandbox-facing proxy for the host target via `mappingOtherToThis` and compares it to the `receiver` argument [patch_id=3103569]. When `receiver !== canonicalProxy` (e.g., an `Object.create` child or a forged `Reflect.set` receiver), the property is installed on the receiver itself via `Reflect.defineProperty` with a writable, enumerable, configurable descriptor, matching both the ECMA-262 spec and the existing `ReadOnlyHandler.set` pattern [ref_id=1]. Direct writes like `proxy.x = v` continue to take the legitimate `otherReflectSet` path because the receiver equals the canonical proxy.
Preconditions
- configThe embedder must expose at least one host-realm object to the sandbox (e.g., via `vm.sandbox.x = hostObj`).
- authThe sandbox must be able to execute arbitrary JavaScript (standard VM sandbox code execution).
- networkNo network precondition — the attack is local to the Node.js process.
- inputThe attacker writes a property on an object that inherits from the host proxy (e.g., `Object.create(hostObj).x = v`) or supplies a forged receiver via `Reflect.set`.
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.