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-294 — replacementText() 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-316 — substitute(): 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)
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///gand awksub/gsubreplacement expansion (&/\Nbackreferences) assembles the result withresult += …per match — one V8 cons cell (~30 B live heap) per append.Budget.checkcounts logical characters only, so the 32 MiBmaxBufferBytesauthorizes ~1 GB of real heap;maxOutputBytesfires only at flush, after substitution;maxStepsis never charged for concat byte-work; andsubstitute()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-294—replacementText()builds expansion withresult += match.groups[0]per&/\N; no budget parameter, no step chargingpackages/safe-bash/src/commands/text-programs/regex.ts:295-316—substitute(): line 308 concatenates before checking; check counts logical chars only (shared.ts:27-33); loop fully synchronoussrc/commands/text-programs/sed.ts:314(case "s"),src/commands/text-programs/awk-runtime.ts:384(sub/gsub)PoC (executed)
Measured:
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.--max-old-space-size=128:FATAL ERROR: … JavaScript heap out of memory(uncatchable).awk '{gsub(/a/,"<30&>"); …}', 1 MiB, 128 MB → fatal OOM (samesubstitute()).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.substitute()contains no yield.&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 (Σ&/\Ncounts × group lengths) and admit it against a materialized-bytes ledger before concatenating; chargebudget.step(∝ appended bytes)per match; build into a flat/segmented byte buffer instead of+=cons chains; makesubstituteasync withcheckpoint()/throwIfAborted()every N matches. Covers sedsand awksub/gsub.Status: NEW (new instance of v2 systemic classes 1–3; distinct from v2-H7
y///, which is fixed)