Skip to content

Commit c33c2bb

Browse files
committed
fix(GHSA-9vg3-4rfj-wgcm): null-proto throw write-through via bridge from()
handleException and globalPromise.prototype.then onFulfilled wrapped caught/resolved values with bridge.from(). from() builds a sandbox-side proxy whose target the bridge treats as host-realm; when called on a sandbox-realm null-proto value, the proxy's set trap unwraps incoming sandbox proxies of host references back to raw host originals and stores them on the underlying sandbox object. The original sandbox reference then yields the raw host fn -> .constructor is host Function -> RCE. Three callsites in lib/setup-sandbox.js reverted to ensureThis() (the pre-b57ac2d behavior, sandbox-realm safe). The host-Promise rejection sanitizer composes from() outside handleException so the GHSA-mpf8 invariant -- host null-proto rejection values reach sandbox callbacks bridge-wrapped, not raw -- is preserved. Repro: test/ghsa/GHSA-9vg3-4rfj-wgcm/repro.js (7 tests, all pass after fix; 3 fail without it). ATTACKS.md gains Category 26.
1 parent 46cbbdd commit c33c2bb

4 files changed

Lines changed: 376 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Security fix
6+
7+
- **GHSA-9vg3-4rfj-wgcm** — Sandbox breakout via null-proto throw / `handleException`. The post-GHSA-mpf8 hardening switched `handleException` and `globalPromise.prototype.then` onFulfilled to wrap caught/resolved values with `bridge.from()` for "symmetry". `from()` builds a sandbox-side proxy whose target the bridge treats as host-realm; calling it on a sandbox-realm null-proto value (`{__proto__: null}` thrown or `Promise.resolve`-d by sandbox JS) produced a proxy whose `set` trap unwrapped sandbox proxies of host references (e.g. `Buffer.prototype.inspect`) back to their raw host originals and stored them on the underlying sandbox object — readable via the original sandbox reference and pivot to host `Function` constructor → RCE. Three callsites in `lib/setup-sandbox.js` reverted to `ensureThis()` semantics; the host-Promise rejection sanitizer composes `from()` outside `handleException` so the GHSA-mpf8 invariant (host null-proto rejection values must reach sandbox callbacks bridge-wrapped) is preserved. ATTACKS.md Category 26.
8+
39
## [3.11.1]
410

511
Single advisory closed plus prominent documentation of an existing escape hatch. Patch release — no API changes for valid configurations.

docs/ATTACKS.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1870,6 +1870,97 @@ The narrow fix closes the **specific** contradictory pair. The **broader** issue
18701870
18711871
---
18721872
1873+
## Attack Category 26: Sandbox-Realm Null-Proto via Bridge `from()` — Set-Trap Write-Through
1874+
1875+
**Uses**: [Category 1](#attack-category-1-constructor-chain-traversal) (host `Function` via `.constructor`), [Category 6](#attack-category-6-proxy-trap-exploitation) (bridge `set` trap as the actual leak vector).
1876+
1877+
**Supersedes**: defense-in-depth portion of GHSA-mpf8-4hx2-7cjg's fix that extended `from()` to `handleException` and `globalPromise.prototype.then` onFulfilled.
1878+
1879+
### Description
1880+
1881+
`bridge.from(other)` constructs a sandbox-side proxy whose internal target the bridge **treats as an other-realm (host) object**. The proxy's `set` trap therefore unwraps incoming sandbox bridge proxies (`otherFromThis(value)`) back to their raw host references and writes them directly onto the underlying target via `otherReflectSet(object, key, value)`.
1882+
1883+
When `from()` is called from a sandbox-side path with a **sandbox-realm null-proto value**, the proxy's underlying target IS the sandbox object. The write-through path then stores raw host references onto a sandbox-visible object, readable via the original sandbox reference (which bypasses the proxy entirely). Reading `.constructor` on a leaked host function yields host `Function`; `Function('return process')()` is RCE.
1884+
1885+
The post-GHSA-mpf8 hardening (commit `b57ac2d`, "setup-sandbox defense-in-depth (mpf8 symmetry)") added `from()` calls in three sandbox-side spots — `handleException` (transformer-instrumented JS catch path), `globalPromise.prototype.then` onFulfilled wrapper, and the `setHostPromiseSanitizers` install — for "symmetry" with the original GHSA-mpf8 fix. Two of those callsites receive sandbox-realm values and turn them into write-through proxies; this is the leak path GHSA-9vg3-4rfj-wgcm exploits.
1886+
1887+
CVSS:3.1 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). CWE-913 (Improper Control of Dynamically-Managed Code Resources).
1888+
1889+
### Attack Flow
1890+
1891+
1. Sandbox creates a null-proto carrier: `const o = {__proto__: null}`.
1892+
2. Sandbox throws it: `throw o`. Transformer-instrumented catch calls `e = handleException(e)`.
1893+
3. `handleException` was passing `e` through `from(e)`. With `e`'s prototype being null, `bridge.from()` had no proto-mapping to consult and built a sandbox-side proxy treating `o` as host-realm.
1894+
4. Sandbox writes a sandbox-side proxy of a host function onto the proxy: `e.f = Buffer.prototype.inspect`. The bridge `set` trap converts the sandbox value to host realm via `otherFromThis(value)`, yielding the **raw host `inspect`**, then stores it on the underlying target — which is the sandbox object `o`.
1895+
5. Sandbox reads via the original reference: `o.f` returns the raw host function (no proxy in the way; `o` IS sandbox-realm, so plain property access bypasses the bridge entirely).
1896+
6. `o.f.constructor` is host `Function``Function('return process')()` → host `process` → RCE.
1897+
1898+
The same bug exists on the `globalPromise.prototype.then` onFulfilled path: `Promise.resolve({__proto__:null}).then(e => { e.f = HostFn; ... })`.
1899+
1900+
### Canonical Examples
1901+
1902+
```javascript
1903+
// (advisory GHSA-9vg3-4rfj-wgcm)
1904+
const {VM} = require("vm2");
1905+
new VM().run(`
1906+
const o = {__proto__: null};
1907+
try {
1908+
throw o;
1909+
} catch (e) {
1910+
e.f = Buffer.prototype.inspect;
1911+
o.f.constructor("return process")()
1912+
.mainModule.require('child_process').execSync('touch pwned');
1913+
}
1914+
`);
1915+
```
1916+
1917+
```javascript
1918+
// Promise.then variant
1919+
new VM().run(`
1920+
(async () => {
1921+
const o = {__proto__: null};
1922+
return Promise.resolve(o).then(e => {
1923+
e.f = Buffer.prototype.inspect;
1924+
return o.f.constructor('return process')();
1925+
});
1926+
})()
1927+
`).then(p => console.log(p)); // host process leaked
1928+
```
1929+
1930+
### Why It Works
1931+
1932+
`bridge.from()` (= `thisFromOtherWithFactory(defaultFactory, other)`) is **defined for host-realm inputs**. Its internal logic walks the prototype chain looking for a `protoMappings` entry; if proto is null and no mapping found, it creates a default proxy via `thisProxyOther(factory, other, null, dangerous)`. The bridge has no realm-tagging on raw values, so it cannot distinguish "host null-proto object the sandbox should see wrapped" (the GHSA-mpf8 motivating case) from "sandbox null-proto object the sandbox already owns" (this GHSA's case).
1933+
1934+
The post-GHSA-mpf8 commit `b57ac2d` extended `from()` to two sandbox-side callsites — `handleException` and `globalPromise.prototype.then` onFulfilled — purely for "symmetry"; no exploit existed for the sandbox-side path at the time. Those callsites do not receive host-realm values in normal flow: host throws are pre-converted by the bridge `apply`-trap's `thisFromOtherForThrow`, and host-promise resolutions are intercepted at the bridge level via `wrapHostPromiseThenArgs`. The "symmetry" wrap therefore only ever fires on sandbox-realm values, where it creates the dangerous write-through proxy.
1935+
1936+
### Mitigation
1937+
1938+
Restores [Defense Invariant 2](#defense-invariants) ("All caught exceptions are sanitized") with the **right** sanitizer for each callsite's actual realm context, and Defense Invariant 1 by ensuring `from()` is not used to "wrap" sandbox-realm values into host-treating proxies.
1939+
1940+
`lib/setup-sandbox.js`:
1941+
1942+
- `handleException` (line ~876): `e = from(e)``e = ensureThis(e)`. `ensureThis` returns sandbox-realm values unchanged and walks the proto chain only for host-mapped values, so a sandbox null-proto value stays sandbox-realm. SuppressedError / AggregateError sub-error recursion still works because each sub-call routes through the same `ensureThis` and the sub-error proto chain reaches a known host Error prototype mapping for genuinely-host sub-errors.
1943+
- `globalPromise.prototype.then` onFulfilled wrap (line ~283): same change. The host-promise resolution path is unaffected because it goes through the bridge-level `wrapHostPromiseThenArgs` interception, which keeps using `from()` (correct — values there ARE host-realm).
1944+
- `bridge.setHostPromiseSanitizers` install (line ~959): the rejection sanitizer is now `e => handleException(from(e))` instead of `handleException`. The explicit outer `from(e)` preserves the GHSA-mpf8 invariant for genuinely-host null-proto rejection values (they reach sandbox callbacks bridge-wrapped, not raw); the inner `handleException` then performs SuppressedError / AggregateError recursive sanitization on the wrapped value.
1945+
1946+
The fix surface is three lines of code in `setup-sandbox.js`, no bridge changes.
1947+
1948+
### Detection Rules
1949+
1950+
- **`from(value)` calls in sandbox-side code paths** — `lib/setup-sandbox.js` and any future sandbox-side callsite. Whenever the value can be sandbox-realm by construction (transformer catch path, sandbox-Promise rejection, executor catch), the call must use `ensureThis` (sandbox-passthrough for unmapped values) instead of `from` (always-wrap).
1951+
- **`{__proto__: null}` followed by `throw` or `Promise.resolve(...)` in untrusted code review** — the canonical attack carrier. Innocuous on its own, but combined with property assignment in catch / `.then` it's a write-through probe.
1952+
- **`obj.constructor("return process")` or `obj.f.constructor("return ...")` patterns** — the post-leak escape primitive. Flag in code review even when wrapped in try/catch.
1953+
- **Reverts of the b57ac2d "symmetry" change** — any future commit re-introducing `from()` in `handleException` or sandbox-side `Promise.prototype.then` onFulfilled must re-prove the realm assumption holds for every callsite reachable on those paths.
1954+
1955+
### Considered Attack Surfaces
1956+
1957+
- **`localPromise` constructor catch wrapper** (line ~76): `reject(handleException(e))`. The executor runs in the sandbox; `e` is a sandbox-realm thrown value (host throws inside an executor that was passed through the bridge would already be wrapped at the bridge boundary). `handleException` now uses `ensureThis` internally, so this path is safe.
1958+
- **Sandbox-side `localPromise.prototype.then` onRejected wrap** (line ~1170): also routes through `handleException`. Same reasoning — sandbox-realm rejection value, `ensureThis` correctly passes through.
1959+
- **`readonly()` factory `from(mock)` call** (line ~1281): `mock` is a sandbox-supplied user value that the embedder asked to read-only-mock onto a host target. The wrap is intentional (the value crosses TO the host as the read-side data). Sandbox cannot exploit the resulting proxy because it doesn't have a sandbox-side reference to the underlying mock object identity.
1960+
- **Bridge-level `wrapHostPromiseThenArgs` / `wrapHostPromiseCatchArgs`**: still use `from()` directly, correct because at that layer the value is host-realm by construction (delivered from host Promise machinery).
1961+
1962+
---
1963+
18731964
## Considered Attack Surfaces
18741965
18751966
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.
@@ -1988,6 +2079,7 @@ The most dangerous attacks combine multiple categories. Each pattern references
19882079
| Host prepareStackTrace fallback | Safe default always set; setter resets to safe default instead of `undefined` |
19892080
| NodeVM `require.root` symlink bypass | `isPathAllowed` realpaths candidate before prefix check; `rootPaths` canonicalized at construction; deny-by-default if realpath throws |
19902081
| NodeVM `nesting: true` + `require: false` config trap | Constructor throws `VMError` at the contradictory option pair, citing GHSA-8hg8-63c5-gwmx and the README escape-hatch section |
2082+
| 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 |
19912083
19922084
### Key Security Invariant: Promise Species Resolution Timing
19932085

lib/setup-sandbox.js

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -273,12 +273,20 @@ globalPromise.prototype.then = function then(onFulfilled, onRejected) {
273273
if (typeof onFulfilled === 'function') {
274274
const origOnFulfilled = onFulfilled;
275275
onFulfilled = function onFulfilled(value) {
276-
// SECURITY (GHSA-mpf8-4hx2-7cjg): use `from` rather than `ensureThis`.
277-
// ensureThis returns the raw `other` when no proto-mapping is found,
278-
// so unmapped host objects (e.g., objects with `__proto__: null`)
279-
// crossed into the sandbox callback unwrapped. `from` always returns
280-
// a bridge proxy regardless of proto-mapping.
281-
value = from(value);
276+
// SECURITY (GHSA-9vg3-4rfj-wgcm): use `ensureThis`, NOT `from`.
277+
// Reverts the b57ac2d "GHSA-mpf8 symmetry" change. This wrapper
278+
// runs for every sandbox-realm Promise (including async-function-
279+
// returned globalPromise instances). The resolution value is
280+
// sandbox-realm by construction; host-realm values reach sandbox
281+
// callbacks through a separate path — the bridge's apply-trap
282+
// interception of host `Promise.prototype.then`, which sanitises
283+
// via `wrapHostPromiseThenArgs` (see bridge.js). Calling `from`
284+
// on a SANDBOX null-proto value built a bridge proxy whose `set`
285+
// trap unwraps incoming sandbox proxies of host (e.g. raw
286+
// Buffer.prototype.inspect) onto the underlying sandbox object;
287+
// reading the property back via the original sandbox reference
288+
// returned the raw host fn → host Function constructor → RCE.
289+
value = ensureThis(value);
282290
return apply(origOnFulfilled, this, [value]);
283291
};
284292
}
@@ -859,13 +867,23 @@ const localSuppressedErrorProto = typeof SuppressedError === 'function' ? Suppre
859867
const localAggregateErrorProto = typeof AggregateError === 'function' ? AggregateError.prototype : null;
860868

861869
function handleException(e, visited) {
862-
// SECURITY (post-GHSA-mpf8 hardening): use `from` (not `ensureThis`) so
863-
// unmapped null-proto host objects always get a bridge proxy. ensureThis
864-
// returned the raw `other` for null-proto values, leaving an asymmetry
865-
// vs the fulfillment path. `from` is idempotent on already-wrapped
866-
// proxies and pass-through on primitives, so the SuppressedError /
867-
// AggregateError prototype walk below still functions correctly.
868-
e = from(e);
870+
// SECURITY (GHSA-9vg3-4rfj-wgcm): use `ensureThis`, NOT `from`. Reverts
871+
// the b57ac2d "GHSA-mpf8 symmetry" hardening. The values reaching this
872+
// function from sandbox-side callsites — transformer-instrumented JS
873+
// catch (`catch(e){e=handleException(e);}`), the localPromise executor
874+
// catch wrapper, and the sandbox-side `Promise.prototype.then|catch`
875+
// onRejected wrappers — are sandbox-realm by construction (host-side
876+
// errors are pre-converted at the bridge boundary by
877+
// `thisFromOtherForThrow`). Wrapping a sandbox-realm null-proto value
878+
// with `from` builds a bridge proxy whose `set` trap unwraps incoming
879+
// sandbox proxies of host references (e.g., `Buffer.prototype.inspect`)
880+
// to their raw host originals and stores them on the underlying
881+
// sandbox object — readable directly via the sandbox reference and
882+
// trivially pivoted to host Function via `.constructor` → RCE. The
883+
// genuinely-host-realm path (host-Promise rejections through
884+
// `setHostPromiseSanitizers`) wraps with `from()` *before* calling
885+
// handleException; see the install site below.
886+
e = ensureThis(e);
869887
if (e === null || (typeof e !== 'object' && typeof e !== 'function')) return e;
870888
if (localSuppressedErrorProto === null && localAggregateErrorProto === null) return e;
871889
if (!visited) visited = new LocalWeakMap();
@@ -916,24 +934,29 @@ function handleException(e, visited) {
916934
return e;
917935
}
918936

919-
// SECURITY (GHSA-55hx): install handleException / ensureThis as the
920-
// sandbox-side sanitizers for host-realm Promise.prototype.then|catch|finally
921-
// invocations. Without this, when sandbox code calls .then/.catch on a host
922-
// Promise (returned e.g. by an embedder-exposed `async () => {}`), the host
923-
// Promise machinery (PromiseReactionJob) runs the sandbox callback against
924-
// the RAW host rejection value, bypassing the sandbox-side Promise.prototype
925-
// override at lines 199-228. The bridge apply-trap interception on those
926-
// methods now wraps callbacks through the same sanitizers, closing the
927-
// invariant: every sandbox callback bound to a Promise — host or sandbox
928-
// realm — receives its argument(s) routed through from() (fulfillment)
929-
// or handleException (rejection, which wraps via from() internally).
937+
// SECURITY (GHSA-55hx): install sanitizers for sandbox callbacks bound to
938+
// host-realm Promise.prototype.then|catch|finally. Without this, when sandbox
939+
// code calls .then/.catch on a host Promise (returned e.g. by an embedder-
940+
// exposed `async () => {}`), the host Promise machinery (PromiseReactionJob)
941+
// runs the sandbox callback against the RAW host fulfillment/rejection value,
942+
// bypassing the sandbox-side Promise.prototype override above. The bridge
943+
// apply-trap interception on those methods now wraps callbacks through these
944+
// sanitizers, closing the invariant: every sandbox callback bound to a host
945+
// Promise receives its argument(s) bridge-wrapped.
930946
//
931-
// SECURITY (post-GHSA-mpf8 hardening): use `from` (not `ensureThis`) for the
932-
// fulfillment sanitiser so unmapped null-proto host values are wrapped in a
933-
// bridge proxy. Mirrors the sandbox-side `globalPromise.prototype.then`
934-
// onFulfilled wrap above.
947+
// Both arguments wrap with `from()` because at this site the value is host-
948+
// realm by construction (delivered from host Promise machinery).
949+
//
950+
// SECURITY (GHSA-9vg3-4rfj-wgcm): the rejection sanitizer composes `from` ON
951+
// THE OUTSIDE of `handleException`. handleException itself now uses
952+
// `ensureThis` internally (sandbox-realm-safe) — see its body above for why.
953+
// We must still wrap host-realm rejection values to preserve the GHSA-mpf8
954+
// invariant (unmapped-proto host values reach sandbox callbacks bridge-
955+
// wrapped, not raw), so do the wrap explicitly here before calling
956+
// handleException, which then performs its SuppressedError / AggregateError
957+
// recursive sanitization on the wrapped value.
935958
if (typeof bridge.setHostPromiseSanitizers === 'function') {
936-
bridge.setHostPromiseSanitizers(handleException, from);
959+
bridge.setHostPromiseSanitizers(e => handleException(from(e)), from);
937960
}
938961

939962
const withProxy = localObjectFreeze({

0 commit comments

Comments
 (0)