Skip to content

safe-bash: redirect/tee file output retains promise reactions per chunk → fatal OOM from a 21-byte script #613

Description

@kamilio

Found in safe-bash deep pentest (v3, successor to docs/issues/safe-bash-security-audit-v2.md). Verified with executed PoCs against current src/ on Node 22.

Severity: Critical — threat (d): fatal, uncatchable isolate OOM from a 21-byte guest script

Summary

destination.write in filesystem-output.ts does await Promise.race([accepted, task!]) per chunk, where task is the long-lived writer-task promise that stays pending for the whole exec. Every chunk write attaches a new race reaction to it, retained (~300 B each) until the task settles at exec end. A redirect of ~2–2.8 MB output (≈15% of the 16 MB maxOutputBytes budget) fatally OOMs a 128 MB isolate (Cloudflare Workers proxy). The identical data to stdout or a pipe hits maxOutputBytes cleanly at 33 MB heap.

Location

  • packages/safe-bash/src/contracts/filesystem-output.ts:61await Promise.race([accepted, task!]) inside the write loop (:56-64)
  • Consumers sharing the path: shell redirects src/shell/runtime.ts:2246, tee src/commands/streams.ts:333, curl -o src/commands/network/output.ts:37,53

PoC (executed)

cd packages/safe-bash
node --max-old-space-size=128 --import tsx /tmp/poc.ts
// guest script is 21 bytes:
await shell.exec("seq 1 400000 > /big");

Measured:

  • seq 1 400000 > /big (2.8 MB redirected output), --max-old-space-size=128FATAL ERROR: JavaScript heap out of memory (uncatchable, process death). 600k/1M/2M/3M all fatal; 300k (2 MB) survives → fatal threshold ≈ 2–2.8 MB of redirect output.
  • Control: seq 1 3000000 | wc -c (same data to a pipe) → clean ShellLimitError: maxOutputBytes at 33 MB heap.
  • Default heap: seq 1 2000000 > /big → exit 0, 13.4 s, peak heap 988 MB for a 14.9 MB file (66× amplification); seq 1 3000000 → limit fires with file at 16,777,215 B, peak 1055 MB.
  • Mechanism microbench: 300k × Promise.race([Promise.resolve(), pendingTask]) → live heap 9 MB → 98 MB (+89 MB, ~300 B/iter), released the moment the task settles — exact match.
  • In-exec live heap during seq 1 300000 > /big with forced GC sampling: 44 → 62 → 82 → 98 MB, then 25 MB after exec.
  • tee amplifies multiplicatively: seq 1 20000 | tee /t1 … /t200 | wc -c → fatal at 128 MB; 2000 targets × 2k lines → fatal at 128 MB; default heap → 15.8 s, peak heap 984 MB / RSS 1189 MB before maxOutputBytes fired.
  • Chunk granularity drives it: per-line producers (seq, awk) = one race per line; a single-chunk 588 KB write is clean (26 MB live).

Suggested fix

Don't race the long-lived task per write. Track writer failure once (settled-flag / one-shot failed promise created before the loop, or check existing failure / operation.signal.aborted state), or attach a single rejection handler to task that aborts acknowledge. Apply the same to the per-64 KB-slice loop.

Status: NEW

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions