Skip to content

File-controlled metadata can produce an invalid Rust &str #286

Description

@marius-momeu

DictionaryRef::get and its iterator convert FFmpeg dictionary bytes with from_utf8_unchecked. FFmpeg does not guarantee that container metadata is UTF-8; for example, RIFF INFO tags commonly contain legacy encodings.

Consequently, an ordinary media file can make a safe API return a &str that violates Rust's UTF-8 validity invariant. This is the one report in this set whose trigger is untrusted input. I confirmed language-level UB but did not demonstrate memory corruption or exploitability.

Minimal reproduction

First generate a small valid WAV containing a non-UTF-8 title:

import struct

def chunk(name, data):
    size = len(data)
    if size % 2:
        data += b'\0'
    return name + struct.pack('<I', size) + data

title = b'x\xff\xbf\xbf\xbfy\0'
info = chunk(b'LIST', b'INFO' + chunk(b'INAM', title))
fmt = chunk(b'fmt ', struct.pack('<HHIIHH', 1, 1, 8000, 8000, 1, 8))
data = chunk(b'data', b'\x80' * 16)
body = b'WAVE' + info + fmt + data
open('badchar.wav', 'wb').write(b'RIFF' + struct.pack('<I', len(body)) + body)

Then, with ffmpeg-next = "=9.0.0":

use ffmpeg_next as ffmpeg;
use ffmpeg::format;

fn main() {
    ffmpeg::init().unwrap();
    let input = format::input("badchar.wav").unwrap();
    let metadata = input.metadata();
    let title = metadata.get("title").unwrap(); // safe API returns `&str`

    println!("bytes: {:x?}", title.as_bytes());
    println!("checked: {:?}", std::str::from_utf8(title.as_bytes()));
    let chars: Vec<char> = title.chars().collect();
    println!("{chars:?}");
}
$ python3 make_wav.py
$ cargo run
bytes: [78, ff, bf, bf, bf, 79]
checked: Err(Utf8Error { ... })
thread 'main' panicked ... unsafe precondition(s) violated: invalid value for `char`
thread caused non-unwinding panic. aborting.

The crafted title above is chosen to make the decoder produce a specific invalid scalar, but a crafted file is not required. An ordinary Latin-1 tag — caf\xe9 na\xefve \xff\xfe latin-1, i.e. what a real-world legacy RIFF file looks like — produces the same outcome: from_utf8 returns Err, chars() yields characters that are not in the file ('\u{982e}', '\u{fda5}'), and the iteration then trips the same check.

thread 'main' panicked at core/src/char/methods.rs:243:18:
unsafe precondition(s) violated: invalid value for `char`
thread caused non-unwinding panic. aborting.

The debug-only precondition check is what turns this into an abort. In release it is absent and the invalid scalar simply propagates, which is the worse outcome:

$ cargo run --release
bytes           = [78, ff, bf, bf, bf, 79]
is valid UTF-8? = false
  char scalar = 0x78      valid = true
  char scalar = 0x1fffff  valid = false     <- not a Unicode scalar value
  char scalar = 0x79      valid = true

0x1fffff is outside the range char is permitted to hold, so every downstream consumer of that &str is operating on a value the language says cannot exist.

Environment

Reproduced with ffmpeg-next/ffmpeg-sys-next 9.0.0, x86_64-unknown-linux-gnu, default features, on two independent stacks:

  • Debian 12 — FFmpeg 5.1.9 (libavformat.so.59.27.100)
  • Debian 13 — FFmpeg 7.1.5 (libavformat.so.61.7.103), rustc 1.98.0 stable

The generator, both reproductions, and the debug/release transcripts are available on request.

Proposed direction

Possible APIs include checked conversion plus a raw get_bytes accessor, lossy conversion, or returning None/an error for invalid UTF-8. The iterator needs the same decision for both keys and values.

I have not opened a PR because this changes API or behavior. Which policy would you prefer, and would you welcome a PR implementing it?

Found by Crustify, an experimental UB/soundness auditing agent developed at UC Berkeley and running on Claude Opus 5, then manually reviewed and independently reproduced.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions