Skip to content

safe-bash: sed s///g / awk gsub replacement expansion → ~30× heap amplification, fatal OOM with all budgets green #617

Description

@kamilio

Found in safe-bash deep pentest (v3). Verified with executed PoCs against current src/ on Node 22.

Severity: High — threat (d): fatal, uncatchable isolate OOM from a ~50-byte sed/awk program and a 32–192 KB input file, with every configured budget green; plus ~2 s non-preemptible synchronous stretch per call

Summary

sed s///g and awk sub/gsub replacement expansion (& / \N backreferences) assembles the result with result += … per match — one V8 cons cell (~30 B live heap) per append. Budget.check counts logical characters only, so the 32 MiB maxBufferBytes authorizes ~1 GB of real heap; maxOutputBytes fires only at flush, after substitution; maxSteps is never charged for concat byte-work; and substitute() is fully synchronous (no checkpoint), so aborts land up to ~1.4 s late. Global substitution over dense small matches stays under every cap while killing a 128 MB isolate — cloudflareWorkerLimits (4 MiB profile) does not mitigate because the fatal threshold (~4–6 MB expanded output) is below every cap.

Location

  • packages/safe-bash/src/commands/text-programs/regex.ts:282-294replacementText() builds expansion with result += match.groups[0] per &/\N; no budget parameter, no step charging
  • packages/safe-bash/src/commands/text-programs/regex.ts:295-316substitute(): line 308 concatenates before checking; check counts logical chars only (shared.ts:27-33); loop fully synchronous
  • Consumers: src/commands/text-programs/sed.ts:314 (case "s"), src/commands/text-programs/awk-runtime.ts:384 (sub/gsub)

PoC (executed)

// node --max-old-space-size=128 --import tsx poc.ts
// files: { "/in": 1 MiB line of 'a' }, limits: { maxCommands:100, maxOutputBytes:65536, maxWallClockMs:10000, maxCpuMs:10000 }
await shell.exec("sed 's/a/&&&&…(30 &s)…/g' /in");

Measured:

  • 1 MiB line, default heap: 2.0 s, heap peak 740 MB, RSS 942 MB, stopped only post-hoc by maxOutputBytes. Same with explicit tight limits (64 KB output / 10 s): still 2.0 s + 734 MB heap — interpreter work precedes any flush; concat charges zero steps.
  • 8 MiB line, default heap: heap 781 MB, RSS 1203 MB; exit 2 only when the logical result crosses 32 MiB.
  • 1 MiB line at --max-old-space-size=128: FATAL ERROR: … JavaScript heap out of memory (uncatchable).
  • Fatal thresholds at 128 MB (all budgets pass): 192 KB × 30; 64 KB × 100; 32 KB × 300 (expanded outputs 5.8–9.6 MB — under maxOutputBytes 16 MB / maxBufferBytes 32 MB).
  • awk '{gsub(/a/,"<30&>"); …}', 1 MiB, 128 MB → fatal OOM (same substitute()).
  • Backreference variant sed -E 's/(a)/\1×40/g', 256 KB, default heap → exit 0, all budgets green, heap 302 MB / RSS 460 MB; fatal at 128 MB.
  • Amplification: ~30 B live heap per logical output byte (128 KB→3.84 MB logical ⇒ 116 MB heap).
  • Abort delivery: host abort at 500 ms honored at 1895 ms (1395 ms late)substitute() contains no yield.
  • Contrast: a single (non-global) substitution with 100k &s is safe (13 ms, heap 34 MB) — pathology is global substitution over dense small matches.

Suggested fix

In substitute(), pre-compute the expansion size per match (Σ &/\N counts × group lengths) and admit it against a materialized-bytes ledger before concatenating; charge budget.step(∝ appended bytes) per match; build into a flat/segmented byte buffer instead of += cons chains; make substitute async with checkpoint()/throwIfAborted() every N matches. Covers sed s and awk sub/gsub.

Status: NEW (new instance of v2 systemic classes 1–3; distinct from v2-H7 y///, which is fixed)

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