You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(GHSA-v836-6xw4-9cx3): cap ArrayBuffer/TypedArray/WebAssembly.Memory under bufferAllocLimit
Root cause: the bufferAllocLimit cap wrapped only the Buffer.* family.
ArrayBuffer, SharedArrayBuffer, every TypedArray constructor, and
WebAssembly.Memory allocate host backing-store memory through the same
synchronous, timeout-immune V8 path (ArrayBuffer::NewBackingStore ->
ArrayBufferAllocator::Allocate). A ~200-byte sandbox payload such as
`new ArrayBuffer(1<<30)` amplified into gigabytes of host RSS in one
uninterruptible allocation, defeating the cap an embedder had configured.
Fix: lib/setup-sandbox.js wraps each sandbox-realm allocation constructor in
a construct-trapping Proxy that runs checkBufferAllocLimit on the
ToIndex-coerced byte count before the native allocation. The size is read
once and the canonical primitive forwarded, so valueOf/accessor TOCTOU
cannot present a small value at check time and a large one at allocation
time. Each prototype.constructor back-reference is pinned to the wrapper so
the uncapped intrinsic cannot be recovered by a constructor walk such as
`new Uint8Array(0).buffer.constructor`. The Proxy forwards prototype,
[Symbol.species], and [[Prototype]], keeping instanceof, species-derived
construction, and subclassing intact.
Gated on a finite limit: with the default bufferAllocLimit of Infinity the
native intrinsics are untouched, matching GHSA-6785's opt-in semantics.
Node 8 compatibility: the WebAssembly.Memory construct trap rebuilt the
descriptor with `maximum` and `shared` always present. Node 8's V8 does not
treat an explicit `maximum: undefined` as an absent key -- it coerces it to
0 and rejects the descriptor for having `maximum` below `initial`, breaking
every capped `new WebAssembly.Memory({initial: N})` on that runtime. Both
keys are now forwarded only when the caller supplied them, still read
exactly once so the TOCTOU canonicalization is preserved.
Known residual, documented in the tests: a non-iterable array-like whose
`length` is a toggling accessor can still over-allocate, because V8 reads
that length itself and pinning the read would break the legitimate
`new Uint8Array(buffer, offset, length)` view path. The common {length: N}
data-property form is capped.
Conflict resolution: this branch and GHSA-gmc2-2x9w-cgh9 both add allocation
caps to lib/setup-sandbox.js at the same location, with an empty common
ancestor. The two guard disjoint surfaces -- gmc2 the host Buffer factories,
this one the sandbox-realm intrinsic constructors -- and both consume the
pre-existing checkBufferAllocLimit helper, so both blocks are kept in full.
Tests: test/ghsa/GHSA-v836-6xw4-9cx3/repro.js. Two gating corrections found
by the Node 8-26 sweep: the resizable-ArrayBuffer TOCTOU case is now gated
on HAS_RESIZABLE_AB (`.resize()` is Node 20+, so older runtimes observed a
TypeError instead of the cap's RangeError), and three positive-path
assertions now compare a host-realm copy of the returned array. The latter
is the documented bridge behaviour where sandbox arrays also surface their
index properties; it reproduces with and without a finite cap, so it is not
related to this fix.
docs/ATTACKS.md: new Category 36. No version bump.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,6 +6,7 @@
6
6
7
7
-**GHSA-gmc2-2x9w-cgh9** — `bufferAllocLimit` (GHSA-6785-pvv7-mvg7) bypass via `Buffer.concat(list, totalLength)` and `Buffer.from(arrayLike)`, whose host implementations reach the same C++ allocator without traversing the sandbox-side `allocUnsafe` wrapper — one `Buffer.concat([...], 50*1024*1024)` call drove host RSS by 50 MB despite the cap. Fix in `lib/setup-sandbox.js` adds sandbox-side wrappers for `Buffer.concat`, `Buffer.from`, and `Buffer.copyBytesFrom` (Node 22+), plus a fail-closed enumeration of `host.Buffer`'s own keys that throws on any unclassified function-valued key, so future Node releases adding new Buffer allocators surface as errors rather than silent bypasses. See ATTACKS.md Category 23 (extended) and `test/ghsa/GHSA-gmc2-2x9w-cgh9/`.
8
8
-**GHSA-m5w8-4gq2-6f8x** — sibling of GHSA-9g8x: NodeVM `builtin: ['*']` still surfaced `os` and `dns`, the two remaining process-wide builtins. Beyond host-identity and network-topology reads, both carry *write* APIs reachable in one line of sandbox code — `dns.setServers()` hijacks the host's process-wide DNS resolver and `os.setPriority()` renices the host process. Fix in `lib/builtin.js` extends `DANGEROUS_BUILTINS` with `os` and `dns`, reusing the existing two-layer enforcement; `isDangerousBuiltin` covers `node:os`, `node:dns`, and `dns/promises` automatically, and `mock`/`override` escape hatches are preserved. See ATTACKS.md Category 35 (extended) and `test/ghsa/GHSA-m5w8-4gq2-6f8x/`.
9
+
-**GHSA-v836-6xw4-9cx3** — `bufferAllocLimit` bypass via `ArrayBuffer` / `SharedArrayBuffer` / TypedArray / `WebAssembly.Memory` (host memory-exhaustion DoS). The GHSA-6785 cap only wrapped the `Buffer.*` family; these intrinsics allocate host backing-store memory through the same synchronous, timeout-immune V8 path uncapped. When a finite `bufferAllocLimit` is configured, `lib/setup-sandbox.js` now wraps each allocation constructor with a `construct`-trapping proxy that enforces the cap on the ToIndex-coerced byte count (single-read canonicalization defeats `valueOf`/accessor TOCTOU), and pins each `prototype.constructor` so the uncapped intrinsic cannot be recovered via a constructor walk. Default `bufferAllocLimit: Infinity` leaves the native intrinsics untouched (non-breaking). See ATTACKS.md Category 36 and `test/ghsa/GHSA-v836-6xw4-9cx3/`.
Copy file name to clipboardExpand all lines: docs/ATTACKS.md
+56-1Lines changed: 56 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1797,7 +1797,7 @@ new VM({ bufferAllocLimit: 32*1024*1024 }).run(
1797
1797
1798
1798
### Considered Attack Surfaces
1799
1799
1800
-
- **`newUint8Array(N)`, `newArrayBuffer(N)`, `newSharedArrayBuffer(N)` and other typed-array constructors**: same primitive class — synchronous native allocation by attacker-controlled size. **Not capped by this fix.** A determined attacker can substitute `newUint8Array(100*1024*1024)` for `Buffer.alloc(100*1024*1024)` and reproduce the DoS. Closing this fully requires wrapping each TypedArray constructor (and `ArrayBuffer` / `SharedArrayBuffer`) — significantly more invasive (Proxy wrappers, `instanceof` preservation, `prototype.constructor` pinning to prevent constructor-walk recovery). Tracked for follow-up.
1800
+
- **`newUint8Array(N)`, `newArrayBuffer(N)`, `newSharedArrayBuffer(N)` and other typed-array constructors**: same primitive class — synchronous native allocation by attacker-controlled size. **Now capped** — see [Category 36](#attack-category-36-buffallocLimit-bypass-via-arraybuffer--typedarray--webassemblymemory) (GHSA-v836-6xw4-9cx3), which wraps every `ArrayBuffer` / `SharedArrayBuffer` / TypedArray / `WebAssembly.Memory` constructor with the same `bufferAllocLimit` cap when a finite limit is configured.
1801
1801
- **`String.prototype.repeat(N)`**: produces a sandbox-realm string of size `len *N` bytes, similar primitive. Not capped here.
1802
1802
- **Repeated allocations under the cap** (e.g., 32 × `Buffer.alloc(32 MiB)`): an aggregate per-run budget would close this but would require tracking allocation totals across the bridge. Out of scope for the canonical advisory.
1803
1803
- **WebAssembly `memory.grow`**: governed by wasm `maximum` declaration at instantiation; not currently wrapped.
@@ -3031,6 +3031,61 @@ The fix restores **[Defense Invariant #13](#defense-invariants)** at a different
**Supersedes**: completes the "tracked for follow-up" residual of [Category 23: Unbounded `Buffer.alloc(N)` — Host Heap DoS](#attack-category-23-unbounded-bufferallocn--host-heap-dos).
3037
+
3038
+
### Description
3039
+
3040
+
The `bufferAllocLimit` cap introduced for Category 23 (GHSA-6785-pvv7-mvg7) only wrapped the `Buffer.*` family. `ArrayBuffer`, `SharedArrayBuffer`, and every TypedArray constructor (`Uint8Array`, `Float64Array`, …) allocate host backing-store memory through the **same** synchronous, timeout-immune V8 C++ path (`ArrayBuffer::NewBackingStore` → `ArrayBufferAllocator::Allocate` → `calloc`). `WebAssembly.Memory` is the same primitive in 64 KiB pages. None were subject to the cap, so an operator who set `bufferAllocLimit` believing they had DoS protection was fully bypassable: `new ArrayBuffer(1<<30)` allocates 1 GB in one uninterruptible call. CVSS reported as High (DoS). CWE-770.
Same root cause as Category 23: `timeout` only fires between bytecodes and cannot preempt a single native allocation. The Category 23 fix was *specific* (Buffer family) rather than *structural* (all sandbox-reachable backing-store allocators), leaving sibling intrinsics open.
3061
+
3062
+
### Mitigation
3063
+
3064
+
When a **finite** `bufferAllocLimit` is configured, `setup-sandbox.js` (`installAllocationCaps`) replaces each sandbox-realm allocation constructor with a `construct`-trapping `Proxy` that runs `checkBufferAllocLimit` on the requested byte count **before** the native allocation. Covered: `ArrayBuffer`, `SharedArrayBuffer`, all twelve TypedArray constructors (feature-gated for `Float16Array` / `BigInt64Array`), and `WebAssembly.Memory` (`initial` at construction + cumulative `grow()`). Two robustness properties, both found necessary during red-team (`/hacker`):
3065
+
3066
+
- **Coercion-faithful (ToIndex parity)**: the natives size their allocation via ToIndex (ToNumber first), so the cap measures the **coerced** magnitude (`coerceAllocMagnitude`). A length supplied as a string (`"1073741824"`), an object with `valueOf` / `Symbol.toPrimitive`, or an array-like `{length: N}` is measured, not waved through. Resizable buffers are capped on `max(length, maxByteLength)`.
3067
+
- **TOCTOU-safe (single-read canonicalization)**: every object-valued size input is read **exactly once**, and the construct trap hands the native constructor the already-coerced **primitive**, so a toggling accessor (`{get maxByteLength(){ return t++ ? BIG : 8 }}`) cannot read small at check-time and large at allocation-time. Pinning `maxByteLength` this way also closes the otherwise-uncapped `.resize()` / `.grow()` follow-up.
3068
+
3069
+
The original uncapped intrinsic cannot be recovered via a constructor walk: each `prototype.constructor` back-reference is pinned to the wrapping proxy, so `new Uint8Array(0).buffer.constructor`, `ArrayBuffer.prototype.constructor`, and species-derived construction all route through the cap. The proxy forwards `prototype`, `[Symbol.species]`, and `[[Prototype]]`, so `instanceof`, `slice`/`map`/`subarray`, and subclassing keep working.
3070
+
3071
+
Default `bufferAllocLimit: Infinity` leaves the native intrinsics **completely untouched** — zero behavioural or identity change for embedders who have not opted in (matches Category 23's non-breaking, opt-in semantics). This is a sandbox-side DoS mitigation only: the proxies wrap sandbox-realm intrinsics, expose no host object, and introduce no escape surface (verified — `new Uint8Array(0).constructor.constructor === Function` resolves to the sandbox realm).
3072
+
3073
+
### Detection Rules
3074
+
3075
+
- **`new ArrayBuffer(N)` / `new SharedArrayBuffer(N)`** with attacker-controlled N, including string / `valueOf` / `Symbol.toPrimitive` / `{maxByteLength}` forms.
- **`new WebAssembly.Memory({initial: N})`** and **`memory.grow(N)`**.
3078
+
3079
+
### Known Residual
3080
+
3081
+
A non-iterable **array-like whose `length` is a toggling accessor** (`new Uint8Array({get length(){ return t++ ? BIG : 0 }})`) can still over-allocate: V8 reads an array-like's `length` itself, and pinning that read would require Proxy-wrapping the source — which would break the legitimate `new Uint8Array(buffer, offset, length)` view path (a correctness regression). The common data-property `{length: N}` amplifier **is** capped. The identical gap exists in the shipped `Buffer.from({length: N})` cap (Category 23). Accepted and asserted in `test/ghsa/GHSA-v836-6xw4-9cx3/repro.js` so any future change is visible. `String.prototype.repeat(N)` and aggregate per-run budgets remain out of scope, as in Category 23.
0 commit comments