Round 4: fix 26 defects across crypto, resource use and codec paths

Twenty-six confirmed findings from the fourth audit round, landed as one
cluster because they were found by agents working over disjoint file sets.

The one worth calling out is a pair of AACS tests that could not fail.
Both asserted CBC behaviour against a hand-rolled expectation that
happened to be IV-independent, so replacing AACS_IV with sixteen zero
bytes left them passing — they were pinning the code's own arithmetic,
not the published constant. Replaced with a literal witness of the
published IV plus the NIST SP 800-38A F.2.2 CBC-AES128 vector, and
verified the other way round: zeroing AACS_IV now fails three tests.

The rest are allocation and correctness work on hot paths: the Annex-B
writer in demux_sink allocated and freed a whole-frame Vec per frame,
which for a UHD title is ~200,000 allocations over the mmap threshold
plus the page faults to first-touch each one; it now reuses a buffer on
the writer, and still takes the NAL prefix width from the configuration
record rather than assuming four.

Six findings whose real fix lives in a consumer crate are recorded for
re-filing rather than patched here.
This commit is contained in:
Matthew Jackson
2026-07-29 22:09:52 -07:00
parent 4fcd28b487
commit 0bbceed985
19 changed files with 1342 additions and 126 deletions
+99 -3
View File
@@ -1315,6 +1315,15 @@ impl BufferedSectorReader<'_> {
// Cap to 8192 sectors (16 MiB) so a disc-controlled ad_len cannot
// drive a multi-hundred-MiB allocation before any sectors are read.
let count = count.min(8192);
// Clamp the range to the u32 LBA space. `start_lba` comes straight off
// the disc (ECMA-167 §14.1 `extent_location` is an unconstrained Uint32),
// so a metadata partition declared near the top of the space made
// `start_lba + offset` below overflow: an 'attempt to add with overflow'
// panic in debug inside the public `Disc::scan`, and in release a wrap to
// a low LBA that filled the sliding cache with a completely different
// region while `cache_start` still claimed the high one. Sectors past
// `u32::MAX` cannot be addressed at all, so dropping them loses nothing.
let count = count.min(u32::MAX - start_lba);
let total = count as usize * 2048;
self.cache.resize(total, 0);
let mut offset = 0u32;
@@ -1359,6 +1368,16 @@ impl BufferedSectorReader<'_> {
let mut done: u64 = 0;
let mut hb = crate::progress::Heartbeat::new("udf_prefetch");
for &(start, count) in ranges {
// Clamp each range to the u32 LBA space before walking it. Ranges come
// from `collect_file_ranges`, whose allocation descriptors are
// unconstrained ECMA-167 §14.14.1 Uint32s, and nothing bounds
// `start + count`; a range within one batch of the top of the space
// made `start + offset` and `start + offset + i` below overflow —
// a debug panic inside the public `Disc::scan`, and in release a wrap
// that seeded the PERMANENT cache with this file's bytes keyed at low
// LBAs, so every later single-sector read of those LBAs (the AVDP/VDS
// re-reads) silently parsed the wrong sector.
let count = count.min(u32::MAX - start);
let mut offset = 0u32;
while offset < count {
hb.tick(done, total);
@@ -1409,8 +1428,13 @@ impl SectorSource for BufferedSectorReader<'_> {
buf[..2048].copy_from_slice(data);
return Ok(2048);
}
// Check sliding cache
if lba >= self.cache_start && lba < self.cache_start + self.cache_sectors {
// Check sliding cache. Tested as a DISTANCE from `cache_start`, not
// as `cache_start + cache_sectors`: `cache_start` is a disc-controlled
// LBA (and the batch-read path below sets it verbatim), so the sum
// overflowed for a window near `u32::MAX` — a debug panic inside
// `SectorSource::read_sectors`, and in release a wrap to a small value
// that silently disabled the cache.
if lba >= self.cache_start && lba - self.cache_start < self.cache_sectors {
let offset = (lba - self.cache_start) as usize * 2048;
buf[..2048].copy_from_slice(&self.cache[offset..offset + 2048]);
return Ok(2048);
@@ -1487,7 +1511,14 @@ mod tests {
}
for i in 0..count as u32 {
let off = i as usize * 2048;
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
// `wrapping_add`: the near-`u32::MAX` overflow regressions below
// hand this harness LBAs at the top of the space on purpose, and
// the harness's own bookkeeping must not be what panics.
let s = self
.sectors
.get(&lba.wrapping_add(i))
.copied()
.unwrap_or([0u8; 2048]);
buf[off..off + 2048].copy_from_slice(&s);
}
Ok(need)
@@ -2332,6 +2363,71 @@ mod tests {
);
}
/// `prefetch` advances the read LBA with `start_lba + offset`. A UDF whose
/// metadata descriptor declares a partition near the top of the LBA space
/// (ECMA-167 §14.1: `extent_location` is an unconstrained Uint32) drove that
/// add past `u32::MAX` — an 'attempt to add with overflow' panic in debug
/// inside the public `Disc::scan`, and in release a wrap to a low LBA that
/// filled the sliding cache with a completely different region while
/// `cache_start` still claimed the high one.
#[test]
fn prefetch_near_u32_max_does_not_overflow() {
let mut inner = MapReader::new();
let mut br = BufferedSectorReader::new(&mut inner, 60);
// start + count = 0xFFFF_FFC0 + 200 > u32::MAX: the second batch
// iteration evaluates 0xFFFF_FFC0 + 60.
br.prefetch(0xFFFF_FFC0, 200);
// Nothing read (MapReader serves no sector here), but crucially the
// walk must never form an LBA above u32::MAX.
assert!(
br.cache_start.checked_add(br.cache_sectors).is_some(),
"cache window must stay inside the u32 LBA space: {} + {}",
br.cache_start,
br.cache_sectors
);
}
/// `prefetch_ranges` walks each disc-derived `(start_lba, sector_count)`
/// range with `start + offset + i`. `collect_file_ranges` permits any LBA up
/// to `u32::MAX` (ECMA-167 §14.14.1 allocation descriptors are unconstrained
/// Uint32s) and nothing bounds `start + count`, so a range within one batch
/// of the top of the space overflowed: debug panic inside `Disc::scan`, or in
/// release a wrap that seeded the PERMANENT cache with this file's bytes keyed
/// at LBA 0 — every later single-sector read of those low LBAs (the AVDP/VDS
/// re-reads) then returned the wrong sector.
#[test]
fn prefetch_ranges_near_u32_max_does_not_overflow() {
let mut inner = MapReader::new();
let mut br = BufferedSectorReader::new(&mut inner, 60);
br.prefetch_ranges(&[(0xFFFF_FFF0, 512)]);
// Every key the permanent cache holds must be a real LBA, i.e. inside
// the declared range — never a wrapped low sector.
for &lba in br.prefetched.keys() {
assert!(
lba >= 0xFFFF_FFF0,
"prefetch_ranges wrapped a near-u32::MAX LBA to {lba}"
);
}
}
/// The sliding-cache hit test computed `cache_start + cache_sectors`. Once
/// `cache_start` is a disc-controlled LBA near `u32::MAX` (set by the batch
/// read below) that add overflowed: debug panic inside
/// `SectorSource::read_sectors`, release wrap to a small value that silently
/// disabled the cache.
#[test]
fn cache_hit_test_near_u32_max_does_not_overflow() {
let mut inner = MapReader::new();
let mut br = BufferedSectorReader::new(&mut inner, 60);
let mut buf = [0u8; 2048];
// First read seeds cache_start = 0xFFFF_FFF5, cache_sectors = 60.
br.read_sectors(0xFFFF_FFF5, 1, &mut buf, true)
.expect("seed read");
// Second read of the same LBA evaluates the hit test: 0xFFFF_FFF5 + 60.
br.read_sectors(0xFFFF_FFF5, 1, &mut buf, true)
.expect("cache hit test must not overflow");
}
/// Build a 2048-byte directory sector containing `count` minimal file FIDs.
///
/// Each FID uses a 2-byte name (compression-id `8` + `b'A'`), so l_fi=2