Skip to content

transcribe: all four subprocess sites buffer child output unbounded (CombinedOutput/Output) while at most an 800-byte tail is read — record's stderrRetain/probeSink bound is unapplied #71

Description

@REPPL

Wrong assumption

"A media tool's diagnostic output is small." internal/record already identified and fixed this assumption twice — stderrRetain (internal/record/proc.go:57-64) caps recorder stderr at 8 KiB with an explicit rationale that unbounded ffmpeg stderr "could grow by hundreds of MB … and OOM the parent", and probeSink (internal/record/recorders.go:162-168) caps the device listing at 1 MiB ("the cap only bounds a misbehaving binary"). internal/transcribe — the one package that hands attacker-authorable media to a media parser — applies no bound at any of its four subprocess sites, while consuming at most the last 800 bytes (tail(), internal/transcribe/transcribe.go:595-616) or a single JSON field.

Sites

Site Call What is unbounded What is actually consumed
internal/transcribe/ffmpeg.go:92-93 (convertRunner) cmd.CombinedOutput() ffmpeg stdout+stderr, default log level, no -loglevel/-nostats last 800 bytes, and only on error
internal/transcribe/ffmpeg.go:211-212 (deriveOffset) cmd.Output() stdout: the full -show_format JSON, including verbatim metadata tag values (-v quiet governs stderr log level only; stderr is separately capped by the stdlib's 64 KiB prefix/suffix saver) one field, format.tags.creation_time
internal/transcribe/whispercpp.go:57 (runWhisperCpp) cmd.CombinedOutput() whisper-cli's full stdout transcript + progress chatter last 800 bytes, only on error
internal/transcribe/whisperx.go:68 (runWhisperX) cmd.CombinedOutput() whisperx's full stdout/stderr for the whole run last 800 bytes, only on error

Go's CombinedOutput wires both pipes into one bare bytes.Buffer — no cap (verified against the go1.24 os/exec source).

Trigger

The inputs driving these children cross the trust boundaries this package's own comments defend: an external -audio recording ("a crafted input file's container metadata can reach it" — tail()'s comment; "ffprobe is a media parser, so pointing it at a file … widens its exposure" — checkExternalAudio), and a received session's audio.wav ("a session that was shared or downloaded rather than recorded here" — checkSessionAudio).

  • A crafted or damaged container whose declared packet structure makes ffmpeg emit a per-packet warning produces stderr volume governed by packet count, not file size; CombinedOutput accumulates it for the whole run — including a run that ends in exit 0, after which 100% of the buffer is discarded. Observed: parent memory grows with child chatter, up to OOM. Correct: retain a bounded tail, as record does for the identical stream.
  • ffprobe -show_format prints every format-level tag whole (JSON-escaped, so control bytes inflate several-fold); a crafted file carrying huge metadata makes deriveOffset buffer all of it to read one timestamp.
  • On a successful CPU-only whisper run (hours long), the engine's entire stdout is buffered and then thrown away in the non-error path.

Aggravating detail from validation: transcribe's children are spawned with no SysProcAttr (only internal/record/record.go:498 sets Setpgid), so an OOM-killed parent leaves ffmpeg running and atomicConvert's defer os.Remove(tmpPath) never fires — a still-growing .audio-*.wav temp is left inside the session directory whose byte-for-byte integrity on a refused run transcribe.go defends at length.

Sibling sweep

The repo has exactly two subprocess-spawning packages. internal/record bounds both of its sinks (lockedBuffer at record.go:499-500, probeSink at recorders.go:212-214), each with written rationale; internal/transcribe bounds none of its four. .abcd/work/DECISIONS.md (round 41) noticed whisperx.go:68's CombinedOutput only as a progress-feedback usability observation, explicitly "no wrong behaviour, not actioned" — the memory question was never assessed, so this is an omission, not a recorded decision.

CWE

CWE-400 (uncontrolled resource consumption).

Validation

  • Validator 1 (reachability lens): "The same 'a media tool's diagnostic output is small' assumption that record bounded at 8 KiB with an explicit OOM rationale is left unbounded at all four transcribe subprocess sites, where ffmpeg's per-packet warnings and ffprobe's whole-metadata dump are driven by attacker-authored container structure the repo's own comments already treat as untrusted — and where at most 800 bytes of the buffer is ever read, and none at all on the success path."
  • Validator 2 (correctness lens): "Transcribe spawns four subprocesses through CombinedOutput/Output — a bytes.Buffer with no cap in Go's own implementation — while consuming at most an 800-byte tail or a single JSON field, leaving record's explicitly-reasoned stderrRetain/probeSink bounds (whose comments cite ffmpeg flooding and 'the cap only bounds a misbehaving binary') unapplied to the one package that hands attacker-authorable media to a media parser at default log level."

Severity is filed as minor: the failure is resource exhaustion of the transcribe command itself, not wrong output — atomicConvert renames only on success, so no artefact is corrupted.

Fix direction

Retain only a bounded tail (and, for ffprobe, a bounded head) of each child's output, as record's existing sinks already do.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions