Skip to content

Commit 77ab500

Browse files
committed
fix(GHSA-6785-pvv7-mvg7): cap Buffer.alloc family with bufferAllocLimit option
Sandbox `Buffer.alloc(N)` (and allocUnsafe / allocUnsafeSlow / deprecated `Buffer(N)` / `new Buffer(N)`) executes as a single synchronous host C++ allocation. V8's `timeout` option cannot interrupt it. A single ~100-byte sandbox payload could drive a 100 MB+ host RSS jump and crash the host process via OOM in memory-constrained environments. New `bufferAllocLimit` option on VM constructor (default Infinity, opt-in): - lib/vm.js: validate option, plumb through `data` channel. - lib/setup-sandbox.js: `checkBufferAllocLimit(size)` helper. The four sandbox-side wrappers (alloc, allocUnsafe, allocUnsafeSlow, plus BufferHandler.apply/construct for the deprecated Buffer(N) form) call the helper before delegating. The new `alloc` wrapper is registered via `connect()`. The captured host allocator is invoked via raw `Reflect.apply` to avoid re-entering our own wrapper. Oversized requests throw RangeError synchronously with no host allocation. Documented residuals: `new Uint8Array(N)`, `new ArrayBuffer(N)`, `new SharedArrayBuffer(N)` and other typed-array constructors share the same primitive class; `String.prototype.repeat(N)` is a similar surface; repeated allocations under the cap (per-call, not aggregate budget).
1 parent 6bbfbb3 commit 77ab500

4 files changed

Lines changed: 280 additions & 62 deletions

File tree

docs/ATTACKS.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1584,6 +1584,62 @@ The fix preserves the native semantics for non-callable executors (`new Promise(
15841584
15851585
---
15861586
1587+
## Attack Category 24: Unbounded `Buffer.alloc(N)` — Host Heap DoS
1588+
1589+
### Description
1590+
1591+
`Buffer.alloc(N)`, `Buffer.allocUnsafe(N)`, `Buffer.allocUnsafeSlow(N)`, and the deprecated `Buffer(N)` / `new Buffer(N)` forms all execute as a single synchronous host C++ allocation. V8's `timeout` mechanism is an interrupt watchdog that runs *between bytecodes*, so it cannot preempt a single native allocation that is already in flight. An attacker controlling the size argument can therefore amplify a small (≤ 200-byte) sandbox payload into a hundreds-of-megabyte host RSS jump in a single call, bypassing the configured `timeout` entirely. In memory-constrained environments (Docker memory limits, Kubernetes pods, AWS Lambda) this exceeds the container memory budget and triggers `FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory`, killing the host process. CVSS reported as High (DoS).
1592+
1593+
### Attack Flow
1594+
1595+
1. Attacker submits a small request that runs sandbox code containing `Buffer.alloc(LARGE_N)` (or any of its variants above).
1596+
2. The sandbox-side `Buffer.alloc` is exposed by vm2 via the bridge; the call routes through `BaseHandler.apply` to host `Buffer.alloc`.
1597+
3. Host `Buffer.alloc(LARGE_N)` runs synchronously in C++; V8's timeout cannot interrupt it.
1598+
4. RSS jumps by `LARGE_N` bytes; if `LARGE_N` exceeds the container's available memory, the process OOMs.
1599+
1600+
### Canonical Example
1601+
1602+
```javascript
1603+
// (advisory GHSA-6785-pvv7-mvg7)
1604+
new VM({ timeout: 5000 }).run(`Buffer.alloc(1024*1024*100).length`);
1605+
// Returns 104857600. RSS jumps ~770 MB. timeout: 5000 has no effect — the
1606+
// allocation completes in one synchronous C++ call.
1607+
```
1608+
1609+
### Why It Works
1610+
1611+
vm2's primary DoS guard is the `timeout` option, which uses Node's `vm.runInContext` interrupt mechanism. That mechanism only fires between bytecodes, so any single host call that runs entirely in native code (allocation, regex matching with catastrophic backtracking, sync filesystem syscalls, etc.) bypasses it. The Buffer.alloc family is the most weaponizable example: small input, predictable amplification, deterministic crash on memory-constrained hosts.
1612+
1613+
### Mitigation
1614+
1615+
New `bufferAllocLimit` option on the `VM` (and inheriting `NodeVM`) constructor, default **32 MiB** (`32 * 1024 * 1024`). The option is plumbed from the host into `setup-sandbox.js` via the existing `data` channel and captured into a closure-scoped const so sandbox-side prototype pollution cannot mutate it. Every entry point to host Buffer allocation is wrapped:
1616+
1617+
- `Buffer.alloc(size, fill, encoding)` — sandbox-side wrapper checks size, then delegates to the cached host allocator via `Reflect.apply`. Registered with `connect()` so the bridge surfaces this wrapper as the canonical sandbox `Buffer.alloc`.
1618+
- `Buffer.allocUnsafe(size)` / `Buffer.allocUnsafeSlow(size)` — same pattern, defense-in-depth (also covered transitively because they delegate to the now-capped `Buffer.alloc`).
1619+
- Deprecated `Buffer(N)` / `new Buffer(N)``BufferHandler.apply` / `construct` traps already special-case numeric first arg; the cap is added there too.
1620+
1621+
Oversized requests throw `RangeError('Buffer allocation size N exceeds bufferAllocLimit M')` synchronously with no host allocation — RSS delta drops from hundreds of megabytes to ~2 MB (just the error object).
1622+
1623+
The default 32 MiB is generous for legitimate workloads (image processing, JSON parsing, CSV transformation typically stay under 16 MiB per buffer) but tiny compared to typical container memory budgets (256 MB - 1 GB). Callers can tighten with `bufferAllocLimit: smaller_number` or opt out with `bufferAllocLimit: Infinity`.
1624+
1625+
### Detection Rules
1626+
1627+
- **`Buffer.alloc(N)` / `Buffer.allocUnsafe(N)` / `Buffer.allocUnsafeSlow(N)`** with attacker-controlled N inside sandbox code.
1628+
- **`Buffer(N)` / `new Buffer(N)`** — deprecated forms still work and are equivalent.
1629+
- **`Buffer.from(largeString)`** — partially capped via byteLength on the source string, but still a residual surface (see below).
1630+
1631+
### Considered Attack Surfaces
1632+
1633+
- **`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.
1634+
- **`String.prototype.repeat(N)`**: produces a sandbox-realm string of size `len * N` bytes, similar primitive. Not capped here.
1635+
- **`Buffer.from(largeArray)` / `Buffer.from(iterable)`**: bounded by source array size which had to be allocated through some other path first; iteration runs in JS-land and is interruptible by `timeout`. Lower priority.
1636+
- **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.
1637+
- **WebAssembly `memory.grow`**: governed by wasm `maximum` declaration at instantiation; not currently wrapped.
1638+
1639+
The fix closes the canonical reported DoS (Buffer.alloc family) and provides the mechanism (`bufferAllocLimit` option, `checkBufferAllocLimit` helper) that future fixes for typed-array constructors and `String.repeat` can reuse.
1640+
1641+
---
1642+
15871643
## Considered Attack Surfaces
15881644
15891645
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.

lib/setup-sandbox.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,25 @@ const {
323323
newBufferHandler
324324
} = bridge;
325325

326-
const { allowAsync, GeneratorFunction, AsyncFunction, AsyncGeneratorFunction } = data;
326+
const { allowAsync, GeneratorFunction, AsyncFunction, AsyncGeneratorFunction, bufferAllocLimit } = data;
327+
328+
// SECURITY (GHSA-6785-pvv7-mvg7): Buffer.alloc / allocUnsafe / allocUnsafeSlow
329+
// (and the deprecated Buffer(N) / new Buffer(N) forms) execute as a single
330+
// synchronous host C++ allocation. V8's `timeout` cannot interrupt them, so
331+
// an attacker controlling the size argument can amplify a small payload into
332+
// hundreds of megabytes of host RSS, crashing the host process in
333+
// memory-constrained environments (Docker/K8s/Lambda). Cap every allocation
334+
// size before it reaches the host implementation. Cached in a const so a
335+
// sandbox-side prototype-pollution attempt cannot mutate it post-init.
336+
const localBufferAllocLimit = bufferAllocLimit;
337+
function checkBufferAllocLimit(size) {
338+
// Match host Buffer.alloc semantics: it expects a number. Non-numeric
339+
// values are passed through to host validation (it throws TypeError).
340+
// Only enforce the cap on numbers actually large enough to trip it.
341+
if (typeof size === 'number' && size > localBufferAllocLimit) {
342+
throw new RangeError('Buffer allocation size ' + size + ' exceeds bufferAllocLimit ' + localBufferAllocLimit);
343+
}
344+
}
327345

328346
const { get: localWeakMapGet, set: localWeakMapSet } = LocalWeakMap.prototype;
329347

@@ -390,13 +408,17 @@ class BufferHandler extends ReadOnlyHandler {
390408

391409
apply(target, thiz, args) {
392410
if (args.length > 0 && typeof args[0] === 'number') {
411+
// SECURITY (GHSA-6785-pvv7-mvg7): deprecated Buffer(N) form. Cap before delegating to host.
412+
checkBufferAllocLimit(args[0]);
393413
return LocalBuffer.alloc(args[0]);
394414
}
395415
return apply(LocalBuffer.from, LocalBuffer, args);
396416
}
397417

398418
construct(target, args, newTarget) {
399419
if (args.length > 0 && typeof args[0] === 'number') {
420+
// SECURITY (GHSA-6785-pvv7-mvg7): deprecated new Buffer(N) form. Cap before delegating.
421+
checkBufferAllocLimit(args[0]);
400422
return LocalBuffer.alloc(args[0]);
401423
}
402424
return apply(LocalBuffer.from, LocalBuffer, args);
@@ -421,13 +443,30 @@ if (
421443

422444
addProtoMapping(LocalBuffer.prototype, host.Buffer.prototype, 'Uint8Array');
423445

446+
// SECURITY (GHSA-6785-pvv7-mvg7): cap Buffer.alloc before delegating to host.
447+
// The captured `localBufferAllocOriginal` is the bridge proxy of host.Buffer.alloc;
448+
// `connect()` then registers our wrapper as the canonical sandbox-side alloc, so
449+
// future sandbox lookups of `Buffer.alloc` route through the cap.
450+
const localBufferAllocOriginal = LocalBuffer.alloc;
451+
function alloc(size, fill, encoding) {
452+
checkBufferAllocLimit(size);
453+
// Use raw Reflect.apply (`apply`) here — LocalBuffer is a frozen bridge proxy.
454+
return apply(localBufferAllocOriginal, LocalBuffer, arguments);
455+
}
456+
457+
connect(alloc, host.Buffer.alloc);
458+
424459
/**
425460
*
426461
* @param {*} size Size of new buffer
427462
* @this LocalBuffer
428463
* @return {LocalBuffer}
429464
*/
430465
function allocUnsafe(size) {
466+
// SECURITY (GHSA-6785-pvv7-mvg7): cap before delegating. LocalBuffer.alloc
467+
// is already capped via connect() above, but we check here too so a future
468+
// refactor cannot silently re-open this path.
469+
checkBufferAllocLimit(size);
431470
return LocalBuffer.alloc(size);
432471
}
433472

@@ -440,6 +479,8 @@ connect(allocUnsafe, host.Buffer.allocUnsafe);
440479
* @return {LocalBuffer}
441480
*/
442481
function allocUnsafeSlow(size) {
482+
// SECURITY (GHSA-6785-pvv7-mvg7): cap before delegating (see allocUnsafe).
483+
checkBufferAllocLimit(size);
443484
return LocalBuffer.alloc(size);
444485
}
445486

0 commit comments

Comments
 (0)