Remote media has no decode budget anywhere — one untrusted-input mention in the whole corpus, and it is about WASM modules
Источник:
dev/fera-research/docs/oc5/media-decode-no-budget-untrusted-input-unnamed.md(read-only; сгенерировано scripts/sync-docs.mjs)
Remote media has no decode budget anywhere — one untrusted-input mention in the whole corpus, and it is about WASM modules
Section titled “Remote media has no decode budget anywhere — one untrusted-input mention in the whole corpus, and it is about WASM modules”- kind: risk
- area: modules / media / security
- severity: mid
- evidence:
~/dev/fera/project-design/context/TECHSPEC.md:176-180— the entire decoder contract:No budget, cap, dimension limit, timeout, or allocation parameter.trait MediaDecoder {fn decode_frame(&self, input: &[u8], format: MediaFormat) -> DecodedFrame;}TECHSPEC.md:184-193— decoders are pure-Rust baseline (rav1e/dav1d,openh264,symphonia,image) plus an optionalffmpeg-next-backed module shipped as a separate signed dylib, downloaded on demandTECHSPEC.md:195-202— sticker cache format,mmap2, keyed by content hash:header { frame_count: u32, width: u16, height: u16,frame_offsets: [u64; frame_count],frame_duration_ms: [u16; frame_count] }<raw or per-frame-compressed RGBA frames>width/heightareu16(max 65535²) andframe_countisu32— the header is fully attacker-controlled, and the format has no declared bounds.~/dev/fera/project-design/context/ADR.md:43— the only occurrence of “untrusted” in the entire corpus, and it is about native dylibsRESEARCH-OQ-003.md:305— “truncated/corrupt cache file is indistinguishable from a valid one” (cache integrity, adjacent)- grep of
*.mdforuntrusted|malicious|bomb|crafted|fuzz|corrupt→ 2 hits, neither about media - grep for
max_alloc|quota|OOM|decode bomb|allocation limit→ nothing ~/dev/fera/fera/core/crates/media/src/lib.rs:1-5— 5-line placeholder, no[dependencies]
Substrate
Section titled “Substrate”Media in Fera is remote, user-supplied, attacker-supplied data that
is decoded on the client’s own machine. The corpus treats this as a
format-selection problem (“which decoder handles this codec”) and never
as a resource-exhaustion problem. MediaDecoder::decode_frame takes
input: &[u8] and returns DecodedFrame with no bound of any kind on
the output.
The sticker cache is the sharpest instance
Section titled “The sticker cache is the sharpest instance”TECHSPEC.md:195-202 specifies an on-disk cache keyed by content
hash, holding pre-decoded frames, mmaped. Its header is
attacker-controlled on the write side:
width: u16,height: u16→ up to 65535 × 65535 × 4 bytes = ~17 GiB per frame of RGBA.frame_count: u32→ up to ~4.3 billion entries inframe_offsets: [u64; frame_count], i.e. the header itself is a multi-terabyte allocation before a single frame is read.
The spec asks one question about this format — “decide raw-vs-compressed
per-frame tradeoff (mmap benefit vs CPU decompress cost) empirically”
(TECHSPEC.md:201-202) — and that is a performance question. Nobody
asked what happens when the dimensions in that header are nonsense or
hostile. A cache that is mmaped and content-addressed is exactly the
place where “trust the header, allocate accordingly” turns into a
one-message client crash or OOM kill.
The codec path is the second instance
Section titled “The codec path is the second instance”An ffmpeg-next module is an optional, downloaded-on-demand, signed
dylib (TECHSPEC.md:189-191). That means:
- The decoder for an arbitrary format may be fetched at runtime — so the “hostile input” path and the “download new native code” path are the same path.
- ffmpeg has a long CVE history in exactly this area (crafted streams causing over-allocation / hangs / overflows). Shipping it as a downloadable module without a decode budget means those become reachable from a forwarded message.
Note TECHSPEC.md:195-202 says the sticker header is
raw or per-frame-compressed RGBA frames — the spec is undecided on the
representation, which means the bounds question has never been reached
either way.
Why this matters more here than in a typical client
Section titled “Why this matters more here than in a typical client”The corpus’s own threat model (ADR-002) is that Fera loads native
dylibs with no sandbox to hit performance. So a hostile media file does
not land in a sandboxed decoder: it lands in either pure-Rust decoders
sharing the main process, or an unsandboxed downloaded dylib. And per
hazard-pointers-cannot-allow-dylib-unmap.md / no-panic-containment-at-dylib-boundary.md,
there is no fault containment at that boundary either. A decode that
panics, hangs, or allocates without bound hits the whole client.
Options (tradeoffs)
Section titled “Options (tradeoffs)”- Put the budget in the trait. Change
decode_frame(&self, input, format, budget: &DecodeBudget) -> Result<DecodedFrame, DecodeError>, whereDecodeBudgetcaps decoded bytes, output dimensions, and frame count. Enforced by the trait, so every decoder gets it. Cost: touches the trait signature early (cheap now — one trait, zero impls,media/src/lib.rsis a 5-line placeholder). - Cap in the cache header specifically. Validate
width/height/frame_countagainst a declared maximum at cache-write and again at cache-read, and treat a header that disagrees with the actual file length as corrupt (ties intoRESEARCH-OQ-003.md:305, which already flags that truncated/corrupt is indistinguishable from valid). - Decode off the main thread with a hard deadline. Already implied
by OQ-024 (
spawn_blocking, dedicated decode threads) but stated as a power optimization, not a safety one. A wall-clock deadline on a decode bounds the hang class. - Memory-bounded decode via
decoding_webp/imagelimits / ffmpegmax_alloc. Codec-specific, cheap, and the industry-standard answer — but it has to be applied per-decoder, which option 1 avoids by making it structural. - Do nothing. Acceptable only if the stance is “a forwarded hostile file may kill the client, as it would any Telegram client.” That is a legitimate position — but it is currently an accident, not a decision, because no document acknowledges the input class at all.
Proposed solution
Section titled “Proposed solution”- Options 1 + 3 together, now, while
MediaDecoderhas zero implementations andmedia/src/lib.rsis 5 lines. The signature change is free today and expensive later. - Option 2 for the sticker cache specifically, plus an explicit
maximum-dimension constant in the format section (e.g. a hard
MAX_DIMENSIONandMAX_FRAME_COUNT, with the u16/u32 header fields validated against them before any allocation). A content-hash-keyed cache must not be writable with an unvalidated header — that is the durable-local-attack case: the cache file persists and re-triggers on every render of that sticker. - Name the threat class in the corpus. Add one sentence to ADR-017 (or a new ADR) stating that decoded media is untrusted remote input and that every decode path is bounded in bytes, dimensions, frames and wall-clock. Right now the corpus’s only “untrusted” mention is about dylibs, which is what makes the media path invisible to review.
- Add a QA gate next to
QA.md:172-176(which is about the calls module boundary): “a crafted media file exceeding the declared dimension/frame/size budget fails withDecodeErrorrather than allocating, and the client survives.”
Calibration: mid, not high, because a mmaped content-addressed cache
needs a prior successful decode to be populated — the write path is
downstream of a decode that already consumed the bomb. It becomes high
if the cache is ever populated from a server-provided blob without a
local decode first, which is worth an explicit decision either way.