|
| 1 | +# GHSA-47x8-96vw-5wg6 — Structural fix |
| 2 | + |
| 3 | +## The invariant I closed |
| 4 | + |
| 5 | +> Every well-known host built-in **prototype** and **constructor** must |
| 6 | +> be pre-mapped to its sandbox-realm equivalent in the bridge's identity |
| 7 | +> cache, so any code path that would otherwise surface a host intrinsic |
| 8 | +> into the sandbox returns the sandbox-realm intrinsic instead. |
| 9 | +
|
| 10 | +This is the right chokepoint because: |
| 11 | + |
| 12 | +1. The bridge already maintains two paired weakmaps for identity: |
| 13 | + `mappingOtherToThis` (host → sandbox) and `mappingThisToOther` |
| 14 | + (sandbox → host). Every code path that converts a host value to a |
| 15 | + sandbox-side proxy consults `mappingOtherToThis` *before* any |
| 16 | + wrapping logic runs (the cache short-circuit at |
| 17 | + `lib/bridge.js:1600`). The same is true for `thisFromOtherForThrow` |
| 18 | + (line 1537) and `thisEnsureThis` (line 1499). |
| 19 | +2. Pre-populating those weakmaps at bridge init is a single, structural |
| 20 | + write — not a per-trap filter, not a per-call check. Once the cache |
| 21 | + is seeded, every lookup path benefits without any additional code. |
| 22 | +3. Adding the mappings symmetrically (host-side via `otherWeakMapSet` |
| 23 | + on `mappingThisToOther`) gives round-trip identity preservation, so |
| 24 | + sandbox values that flow to the host and back keep the same |
| 25 | + identity from the sandbox's view. |
| 26 | + |
| 27 | +The previous symbol-filter patch (commit `67bc511`) closed the canonical |
| 28 | +RCE payload (extraction of `nodejs.util.inspect.custom`), but the |
| 29 | +underlying primitive — `HObject !== sandbox Object`, i.e., a *handle* on |
| 30 | +a host built-in — survived. Any future vulnerability that converts |
| 31 | +"I have a host built-in handle" into "I can call a host method that |
| 32 | +bypasses bridge sanitisation" would re-enable the same escape class. |
| 33 | +The structural fix removes that primitive: the sandbox can no longer |
| 34 | +*hold* a wrapped host built-in. |
| 35 | + |
| 36 | +## Implementation |
| 37 | + |
| 38 | +`lib/bridge.js` — added `thisAddIdentityMapping(thisProto, otherProto)` |
| 39 | +right after the existing `thisAddProtoMapping` calls (line ~1700). |
| 40 | +The helper: |
| 41 | + |
| 42 | +1. Writes `[otherProto, thisProto]` to `mappingOtherToThis` (sandbox-side). |
| 43 | +2. Writes `[thisProto, otherProto]` to `mappingThisToOther` (host-side |
| 44 | + mirror, via `otherReflectApply(otherWeakMapSet, ...)`); failure here |
| 45 | + is best-effort because round-trip identity is a quality-of-life |
| 46 | + concern, not the security invariant. |
| 47 | +3. Reads the `.constructor` slot via `getOwnPropertyDescriptor` on both |
| 48 | + prototypes (so we never trigger a getter), guards against |
| 49 | + `isThisDangerousFunctionConstructor` and |
| 50 | + `isDangerousFunctionConstructor`, and writes the constructor |
| 51 | + identity mapping with the same dual-direction semantics. |
| 52 | + |
| 53 | +Called for: `Object`, `Array`, every entry in `globalsList` *except* |
| 54 | +`Function`, every entry in `errorsList` (`RangeError`, |
| 55 | +`ReferenceError`, `SyntaxError`, `TypeError`, `EvalError`, `URIError`, |
| 56 | +`SuppressedError`, `Error`). |
| 57 | + |
| 58 | +`Function` / `AsyncFunction` / `GeneratorFunction` / |
| 59 | +`AsyncGeneratorFunction` are deliberately skipped — see "Edge cases" |
| 60 | +below. |
| 61 | + |
| 62 | +Every changed line is annotated with `// SECURITY (GHSA-47x8): ...`. |
| 63 | + |
| 64 | +## Edge cases considered |
| 65 | + |
| 66 | +### Function-family prototypes are NOT cached |
| 67 | + |
| 68 | +If `host Function.prototype` were mapped to `sandbox Function.prototype`, |
| 69 | +then a sandbox proto-walk landing on the host `Function.prototype` |
| 70 | +would surface the sandbox prototype directly — and reading |
| 71 | +`.constructor` on a real sandbox prototype follows the prototype's own |
| 72 | +data slot, returning sandbox `Function`. Sandbox `Function` is callable |
| 73 | +(it creates sandbox-realm functions), which means the existing |
| 74 | +"`fp.constructor` returns `emptyFrozenObject`" defense |
| 75 | +(`isDangerousFunctionConstructor` in `thisFromOtherWithFactory`) would |
| 76 | +be silently bypassed for that path. |
| 77 | + |
| 78 | +By leaving the four Function-family prototypes un-cached, the proxy |
| 79 | +`get` trap continues to handle `fp.constructor` reads: it reads |
| 80 | +`host Function.prototype.constructor` = host Function, and |
| 81 | +`thisFromOtherWithFactory` returns `emptyFrozenObject` because |
| 82 | +`isDangerousFunctionConstructor(hostFunction)` is true. |
| 83 | + |
| 84 | +The structural-leak test "Function constructor block remains in force" |
| 85 | +exercises this exact invariant; the existing |
| 86 | +`getOwnPropertyDescriptor Function constructor bypass attack` regression |
| 87 | +in `test/vm.js:1279` exercises the descriptor variant. |
| 88 | + |
| 89 | +### Promise mapping subtlety |
| 90 | + |
| 91 | +`setup-sandbox.js` replaces sandbox `Promise` with |
| 92 | +`localPromise extends globalPromise` *after* `bridge.js` runs. |
| 93 | +At bridge-init time, `thisGlobalPrototypes.Promise` is the sandbox's |
| 94 | +**original** `globalPromise.prototype`, **not** `localPromise.prototype`. |
| 95 | + |
| 96 | +Consequence: a host `Promise` flowing into the sandbox now collapses to |
| 97 | +`globalPromise` (sandbox-original), not to `localPromise`. From the |
| 98 | +sandbox's perspective, `hostPromise instanceof Promise` is false (this |
| 99 | +matches the pre-fix behaviour — verified by stash + re-test). The |
| 100 | +sandbox's runtime `Promise` is `localPromise` whose `.prototype.__proto__` |
| 101 | +*is* `globalPromise.prototype`, so the proto-chain still terminates at |
| 102 | +the original sandbox intrinsic, and bridge sanitisation is intact. |
| 103 | +Identity-equality with `Promise.prototype` is the only thing that |
| 104 | +changes, and that was already broken before this fix. |
| 105 | + |
| 106 | +### Well-known symbols |
| 107 | + |
| 108 | +V8's well-known symbols (`Symbol.iterator`, `Symbol.species`, etc.) |
| 109 | +are realm-shared by design — they are the same value across all realms |
| 110 | +in the same V8 isolate. The structural fix doesn't touch them; the |
| 111 | +existing dangerous-symbol filter in `isDangerousCrossRealmSymbol` |
| 112 | +(`Symbol.for('nodejs.util.inspect.custom')`, |
| 113 | +`Symbol.for('nodejs.rejection')`) is unchanged. |
| 114 | + |
| 115 | +### Round-trip identity for sandbox values |
| 116 | + |
| 117 | +The dual-direction mapping (writing both `mappingOtherToThis` and |
| 118 | +`mappingThisToOther`) means a sandbox-realm intrinsic flowing to the |
| 119 | +host now resolves to the host equivalent. This is consistent with |
| 120 | +how the bridge has always treated `Object.prototype.bind`, |
| 121 | +`__lookupGetter__` etc. via `connect()` in `setup-sandbox.js` — the |
| 122 | +existing `__lookupGetter__ / __lookupSetter__ attack` regression |
| 123 | +(`test/vm.js:839`) still passes: |
| 124 | +`Buffer.from.__lookupGetter__("__proto__") === Object.prototype.__lookupGetter__.call(Buffer.from, "__proto__")` |
| 125 | +remains `true`. |
| 126 | + |
| 127 | +### Existing species-defense tests |
| 128 | + |
| 129 | +The historical PoC for GHSA-grj5-jjm8-h35p (Array species self-return) |
| 130 | +used `op.constructor.entries({})` to mint a host array. After the |
| 131 | +structural fix, `op.constructor === sandbox Object`, so that chain |
| 132 | +returns a sandbox array. The species defense is still required for |
| 133 | +genuinely-host arrays (e.g., those exposed via sandbox config or |
| 134 | +`Buffer.from`), so I: |
| 135 | + |
| 136 | +- updated `test/ghsa/GHSA-grj5-jjm8-h35p/repro.js` to mint a host array |
| 137 | + via `sandbox: { hostArrayFactory: () => [] }` (functions executing |
| 138 | + in the host frame still produce host-realm objects when called); |
| 139 | +- updated `test/vm.js`'s `neutralizeArraySpecies prevents species attack |
| 140 | + in apply trap` to use `Buffer.from([1,2,3]).slice(0)` (still a |
| 141 | + bridge-traversed host call where the apply trap fires). |
| 142 | + |
| 143 | +The canonical PoC test for GHSA-grj5 still exercises the full |
| 144 | +Function-extraction pipeline and remains blocked. The species |
| 145 | +defense itself is unchanged. |
| 146 | + |
| 147 | +## Variant attacks tried (all blocked) |
| 148 | + |
| 149 | +I ran a 12-variant red-team probe (in `/tmp/redteam2.js` and |
| 150 | +`/tmp/redteam3.js`) to verify the structural fix closes the entire |
| 151 | +class: |
| 152 | + |
| 153 | +| # | Variant | Outcome | |
| 154 | +|---|---------|---------| |
| 155 | +| 1 | `Buffer.apply.__proto__.__proto__` | `op === Object.prototype`, `op.constructor === Object` | |
| 156 | +| 2 | `Reflect.getPrototypeOf` chain | same | |
| 157 | +| 3 | `Object.getPrototypeOf` chain | same | |
| 158 | +| 4 | `Object.getOwnPropertyDescriptor(Object.prototype, '__proto__').get` walk | same | |
| 159 | +| 5 | sandbox-config-passed host array | `arr.constructor === sandbox Array` | |
| 160 | +| 6 | `Buffer.from(...)` proto-chain walk | terminates at sandbox `Object.prototype` | |
| 161 | +| 7 | `Object(42)` (Number wrapper) | sandbox `Number` identity | |
| 162 | +| 8 | host array `Symbol.iterator` | iterator API filtered (next is undefined) — no leak | |
| 163 | +| 9 | host TypeError caught in sandbox | sandbox `TypeError` identity, sandbox `instanceof` works | |
| 164 | +| 10 | host Promise via sandbox config | sandbox proto chain (`localPromise`/`globalPromise` subtlety, no leak) | |
| 165 | +| 11 | `Object.getPrototypeOf(Buffer.apply).constructor('return process')()` | `process is not defined` (sandbox Function is sandbox-safe) | |
| 166 | +| 12 | `getOwnPropertyDescriptor` on wrapped host `Function.prototype` | descriptor `.value` is undefined (filtered) | |
| 167 | +| w1 | `e.constructor.constructor('return process')()` | `process is not defined` | |
| 168 | +| w2 | `Buffer.from('hi')` proto walk to `.constructor.constructor` | sandbox Function (sandbox-safe) | |
| 169 | +| w11 | descriptor extraction on `fp` (uncached Function.prototype) | `.value` is undefined ✓ | |
| 170 | + |
| 171 | +The only paths that "succeed" are sandbox-realm Function constructor |
| 172 | +calls — which are inherently safe because sandbox `Function` creates |
| 173 | +sandbox-realm functions where `process` is undefined, and this has |
| 174 | +always been the sandbox's contract. |
| 175 | + |
| 176 | +## Second-order effects |
| 177 | + |
| 178 | +- **Performance**: The fix runs at bridge init time only. The cache |
| 179 | + lookup at `thisFromOtherWithFactory` line ~1600 was already there; |
| 180 | + pre-populating it with ~20 extra entries is negligible. No runtime |
| 181 | + hot-path changes. |
| 182 | +- **Identity preservation**: For sandbox code that depended on |
| 183 | + `wrappedHostObject !== Object` to detect "this came from the host", |
| 184 | + that test is now unreliable. I am not aware of any consumer code |
| 185 | + that relied on this; the documented contract is that the bridge |
| 186 | + hides the host realm. |
| 187 | +- **Round-trip identity**: A sandbox `Object` that flows to the host |
| 188 | + and back now keeps its sandbox `Object` identity (instead of |
| 189 | + becoming a fresh wrapped host Object proxy on the way back). This |
| 190 | + is strictly better — it eliminates a class of identity-confusion |
| 191 | + bugs. |
| 192 | +- **`hostPromise instanceof Promise`**: This was already false before |
| 193 | + the fix because of `localPromise extends globalPromise`. No change. |
| 194 | + |
| 195 | +## Files changed |
| 196 | + |
| 197 | +- `lib/bridge.js` — `thisAddIdentityMapping` helper + invocation loop. |
| 198 | +- `docs/ATTACKS.md` — Category 8 mitigation paragraph + defense table |
| 199 | + row for "Host built-in identity leak". |
| 200 | +- `test/ghsa/GHSA-grj5-jjm8-h35p/repro.js` — switch host-array source |
| 201 | + from `ho.entries({})` to `hostArrayFactory()` (sandbox config). |
| 202 | +- `test/vm.js` — switch `neutralizeArraySpecies` test to use |
| 203 | + `Buffer.from([1,2,3]).slice(0)`. |
| 204 | +- `test/ghsa/GHSA-47x8-96vw-5wg6/structural-leak.js` — cherry-picked |
| 205 | + from base branch (was the failing test that drove this fix). |
| 206 | + |
| 207 | +## Test results |
| 208 | + |
| 209 | +- `test/ghsa/GHSA-47x8-96vw-5wg6/structural-leak.js`: 7/7 pass. |
| 210 | +- `test/vm.js`, `test/nodevm.js`, `test/compilers.js` (npm test): |
| 211 | + 150 pass, 1 pending, 0 failing. |
| 212 | +- `test/ghsa/**/*.js`: 46/46 pass. |
0 commit comments