Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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:
      trait MediaDecoder {
      fn decode_frame(&self, input: &[u8], format: MediaFormat) -> DecodedFrame;
      }
      No budget, cap, dimension limit, timeout, or allocation parameter.
    • TECHSPEC.md:184-193 — decoders are pure-Rust baseline (rav1e/dav1d, openh264, symphonia, image) plus an optional ffmpeg-next-backed module shipped as a separate signed dylib, downloaded on demand
    • TECHSPEC.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/height are u16 (max 65535²) and frame_count is u32 — 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 dylibs
    • RESEARCH-OQ-003.md:305 — “truncated/corrupt cache file is indistinguishable from a valid one” (cache integrity, adjacent)
    • grep of *.md for untrusted|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]

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 in frame_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.

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.

  1. Put the budget in the trait. Change decode_frame(&self, input, format, budget: &DecodeBudget) -> Result<DecodedFrame, DecodeError>, where DecodeBudget caps 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.rs is a 5-line placeholder).
  2. Cap in the cache header specifically. Validate width/height/ frame_count against 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 into RESEARCH-OQ-003.md:305, which already flags that truncated/corrupt is indistinguishable from valid).
  3. 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.
  4. Memory-bounded decode via decoding_webp/image limits / ffmpeg max_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.
  5. 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.
  • Options 1 + 3 together, now, while MediaDecoder has zero implementations and media/src/lib.rs is 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_DIMENSION and MAX_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 with DecodeError rather 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.