Bound three allocations an untrusted disc can drive without limit
The Program Stream demuxer appended every fed byte and enforced its 4 MiB cap only inside a branch reached once a start code had been found. Input containing no start code anywhere therefore hit no cap at all, and since a whole title is fed through this demuxer, a zero-filled or ciphertext VOB extent buffered the entire title — up to ~90 GB. When the buffer holds no start code, only a two-byte `00 00` prefix can begin a PS unit on the next feed, so that is kept and the rest dropped. The bound is exact rather than a heuristic: a start code can straddle a feed boundary by at most its first two bytes, so no real byte is discarded, and a test feeding `FF FF 00 00` then `01 E0 ...` pins that. The existing test named for this case fed a real start code first, so the cap it exercised was the in-PES one. Renamed to say what it covers. The BD-J label path had a different shape to anything found so far: the cap is on the COMPRESSED size of a disc file while the allocation scales with the decompressed size. A `.class` gated only by a path prefix inflates to the 64 MiB ceiling, yielding ~33M retained strings from `ldc` operands or ~67M pushes onto a symbolic stack whose depth was unbounded despite the Code attribute's own `max_stack` being parsed and then ignored. Bounded both, the stack by `max_stack` itself (JVMS §4.7.3). The VMG TT_SRPT title count is an untrusted u16 with no de-duplication, so ~800 KB of crafted IFO re-parsed one PGC 65535 times. Capped at 99, the DVD-Video maximum, so no conformant disc is clipped. Every cap carries stated headroom against real media, and each has a test locking that real media still passes. I rewrote three of the new assertions before landing them. They compared the result against the very constant under test — `total <= MAX_TT_SRPT_TITLES` — which passes vacuously the moment someone raises the constant, the most likely future regression and the seventh instance of this tautology shape in this audit. They now assert literals derived from the spec. The TT_SRPT fixture also had to change: with 65535 identical entries the de-duplication collapsed them on its own and the cap was never what bounded the result, so the test passed with the cap removed entirely. Distinct entries defeat dedup and leave the cap as the only guard; de-duplication now has its own fixture. Verified by raising each of the three constants and confirming all three tests fail.
This commit is contained in:
+70
-1
@@ -340,10 +340,35 @@ impl PsDemuxer {
|
||||
}
|
||||
}
|
||||
|
||||
// Trim a start-code-free tail. Every other exit from the loop above
|
||||
// leaves the buffer bounded (a pack, system header or length-bounded
|
||||
// PES is at most ~64 KiB; a length-0 PES is force-flushed at
|
||||
// MAX_PS_BUFFER), but a buffer that holds no `00 00 01` at all never
|
||||
// reaches any of those branches: `find_start_code` returns None, `pos`
|
||||
// stays 0 and nothing drains. Input that never contains a start code —
|
||||
// a zero-filled VOB extent, or an AACS-encrypted clip probed as
|
||||
// ciphertext — would then grow the buffer to the size of the whole
|
||||
// title. Nothing in such a buffer can begin a PS unit except a 2-byte
|
||||
// `00 00` prefix of a start code straddling the feed boundary, so keep
|
||||
// exactly that and drop the rest. Lossless: the retained bytes are the
|
||||
// only ones a later feed could complete into a start code.
|
||||
if self.buffer.len() > START_CODE_PREFIX_KEEP && find_start_code(&self.buffer, 0).is_none()
|
||||
{
|
||||
let drop = self.buffer.len() - START_CODE_PREFIX_KEEP;
|
||||
self.buffer.drain(..drop);
|
||||
if self.has_base {
|
||||
self.buffer_base += drop as u64;
|
||||
}
|
||||
}
|
||||
|
||||
packets
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes retained when the buffer holds no start code: a `00 00 01` prefix can
|
||||
/// straddle a feed boundary by at most its first two bytes.
|
||||
const START_CODE_PREFIX_KEEP: usize = 2;
|
||||
|
||||
/// Find the next PS-layer unit boundary at or after `from`: a start code whose
|
||||
/// ID byte is a pack (0xBA), system header (0xBB), program-end (0xB9), or a
|
||||
/// payload-carrying PES stream ID (0xBD..=0xEF).
|
||||
@@ -897,9 +922,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_video_pes_buffer_is_bounded() {
|
||||
fn input_with_no_start_code_at_all_is_bounded() {
|
||||
// An extent that never contains a 00 00 01 start code — a zero-filled
|
||||
// VOB extent, or an AACS-encrypted clip probed as ciphertext (see
|
||||
// src/disc/hddvd.rs) — must not accumulate. The whole-title feed in
|
||||
// src/mux/disc.rs would otherwise grow the buffer to the size of the
|
||||
// title (up to ~90 GB for UHD).
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
let chunk = vec![0u8; 1024 * 1024];
|
||||
for _ in 0..(MAX_PS_BUFFER / chunk.len() + 8) {
|
||||
assert!(demuxer.feed(&chunk).is_empty(), "no start code → no PES");
|
||||
}
|
||||
assert!(
|
||||
demuxer.buffer.len() <= MAX_PS_BUFFER,
|
||||
"buffer grew to {} with no start code ever seen (cap {})",
|
||||
demuxer.buffer.len(),
|
||||
MAX_PS_BUFFER
|
||||
);
|
||||
// Nothing in a start-code-free buffer can ever begin a unit except a
|
||||
// 2-byte 00 00 prefix, so the retained tail is tiny.
|
||||
assert!(
|
||||
demuxer.buffer.len() <= 2,
|
||||
"start-code-free tail retained {} bytes",
|
||||
demuxer.buffer.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_code_split_across_feeds_still_parses() {
|
||||
// The start-code-free trim must keep the 2 bytes that can be the
|
||||
// prefix of a start code straddling a feed boundary.
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
assert!(demuxer.feed(&[0xFF, 0xFF, 0x00, 0x00]).is_empty());
|
||||
let mut rest = vec![0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00, 0xAA, 0xBB];
|
||||
rest.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // PS-layer boundary
|
||||
let packets = demuxer.feed(&rest);
|
||||
assert_eq!(packets.len(), 1, "split start code must still be found");
|
||||
assert_eq!(packets[0].stream_id, 0xE0);
|
||||
assert_eq!(packets[0].data, vec![0xAA, 0xBB]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_video_pes_over_cap_is_force_flushed() {
|
||||
// A corrupt stream declaring an unbounded PES followed by endless
|
||||
// non-boundary bytes must not grow the buffer without limit.
|
||||
// NOTE: this case feeds a real start code first, so it exercises only
|
||||
// the in-PES cap; the no-start-code path is covered by
|
||||
// `input_with_no_start_code_at_all_is_bounded`.
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
let header = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
||||
let packets = demuxer.feed(&header);
|
||||
|
||||
Reference in New Issue
Block a user