Skip to content

Commit c950166

Browse files
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>
1 parent 3ffb315 commit c950166

4 files changed

Lines changed: 748 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- **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/`.
88
- **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/`.
910

1011
## [3.11.5]
1112

docs/ATTACKS.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1797,7 +1797,7 @@ new VM({ bufferAllocLimit: 32*1024*1024 }).run(
17971797
17981798
### Considered Attack Surfaces
17991799
1800-
- **`new Uint8Array(N)`, `new ArrayBuffer(N)`, `new SharedArrayBuffer(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 `new Uint8Array(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+
- **`new Uint8Array(N)`, `new ArrayBuffer(N)`, `new SharedArrayBuffer(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.
18011801
- **`String.prototype.repeat(N)`**: produces a sandbox-realm string of size `len * N` bytes, similar primitive. Not capped here.
18021802
- **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.
18031803
- **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
30313031
30323032
---
30333033
3034+
## Attack Category 36: `bufferAllocLimit` Bypass via ArrayBuffer / TypedArray / WebAssembly.Memory
3035+
3036+
**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.
3041+
3042+
### Attack Flow
3043+
3044+
1. Operator configures `new VM({ bufferAllocLimit: 10 * 1024 * 1024 })`.
3045+
2. `Buffer.alloc(20 MB)` is correctly blocked.
3046+
3. Sandbox substitutes `new ArrayBuffer(1024*1024*1024)` (or `new Uint8Array(...)`, `new SharedArrayBuffer(...)`, `new WebAssembly.Memory({initial: N})`) — none routed through `checkBufferAllocLimit` → host RSS jumps by the full size → OOM in memory-constrained environments.
3047+
3048+
### Canonical Example
3049+
3050+
```javascript
3051+
// (advisory GHSA-v836-6xw4-9cx3)
3052+
const vm = new VM({ bufferAllocLimit: 10 * 1024 * 1024 });
3053+
vm.run('new ArrayBuffer(1024 * 1024 * 1024)'); // pre-fix: 1 GB allocated
3054+
vm.run('new Uint8Array(1024 * 1024 * 1024)'); // pre-fix: 1 GB allocated
3055+
vm.run('new WebAssembly.Memory({ initial: 16384 })'); // pre-fix: 1 GB allocated
3056+
```
3057+
3058+
### Why It Works
3059+
3060+
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.
3076+
- **`new <TypedArray>(N)`** numeric length or **`new <TypedArray>({length: N})`** array-like amplifier.
3077+
- **`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.
3082+
3083+
### Tests
3084+
3085+
`test/ghsa/GHSA-v836-6xw4-9cx3/repro.js` — 40 cases: per-constructor caps, constructor-walk recovery, resizable/growable, WebAssembly.Memory, coercion variants (string / `valueOf` / `Symbol.toPrimitive` / array-like), TOCTOU canonicalization, the documented residual, NodeVM forwarding, and non-breaking default behaviour.
3086+
3087+
---
3088+
30343089
## Considered Attack Surfaces
30353090
30363091
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.

0 commit comments

Comments
 (0)