From a018e1adc436dd1f1d76e3a8f655b5ed9ac17de3 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:59:14 -0700 Subject: [PATCH] Require a pack start code before descrambling a sector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit css::is_scrambled reads bits 4-5 of byte 0x14 and nothing else. That is a sound test once a caller has committed to a title's VOB data, where every sector is an MPEG-2 PS pack and byte 0x14 always means what it says. descramble_region is not such a caller: it is handed arbitrary regions of a disc, so it also sees IFO, UDF and ISO 9660 sectors — raw structures where byte 0x14 is whatever that format happens to store there. Measured on a real disc: the second sector of VIDEO_TS.IFO holds 0x15 at offset 0x14 while starting 00 26 00 00, which is not a pack. The flag test read it as scrambled, descrambled it, and destroyed 1912 of its 2048 bytes. That sector carries TT_SRPT, so the title table went with it — the disc enumerated 38 titles and an image decrypted from it enumerated 10, silently, at exit 0. is_scrambled_pack already existed with the right predicate. Use it here. It costs nothing: a genuinely scrambled VOB sector always carries the pack start code, and no IFO sector does. Verified end to end — the decrypted image's `info` output is now identical to the source disc's, 38 titles both, differing only in the CSS: Encrypted line. The fixtures moved with it. Four of them built a sector by setting byte 0x14 alone, which no real scrambled sector looks like; they now build packs. --- src/css/mod.rs | 85 ++++++++++++++++++++++++++++++++++++++- src/decrypt.rs | 9 +++++ src/disc/extract.rs | 6 +++ src/sector/decrypting.rs | 3 ++ tests/pass_n_patch_fix.rs | 4 ++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/css/mod.rs b/src/css/mod.rs index f8cc8b3..ef9409a 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -379,7 +379,21 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { /// than apply a neighbouring CPS unit's key. pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::error::Result { for chunk in buf.chunks_mut(2048) { - if chunk.len() < 2048 || !is_scrambled(chunk) { + // `is_scrambled_pack`, NOT the looser `is_scrambled`. The raw flag test + // is only safe once a caller has committed to a title's VOB data, where + // every sector is a pack and byte 0x14 always means what it says. This + // function is handed arbitrary regions of a disc, so it also sees IFO, + // UDF and ISO 9660 sectors — raw structures where byte 0x14 is whatever + // that format stores there. + // + // Measured: `VIDEO_TS.IFO` on one disc holds 0x15 at offset 0x14 of its + // second sector. The raw test read bits 4-5 as "scrambled", descrambled + // it, and destroyed 1912 of 2048 bytes. That sector carries TT_SRPT, so + // the title table went with it: the disc enumerated 38 titles and an + // image decrypted from it enumerated 10, silently, at exit 0. Requiring + // the pack start code first costs nothing — a genuinely scrambled VOB + // sector always has it, and no IFO sector does. + if chunk.len() < 2048 || !is_scrambled_pack(chunk) { continue; } let crib = stevenson::attack_crib(chunk); @@ -825,6 +839,75 @@ mod tests { assert!(crack_key(&mut MockSource::new(0x30), &extents, 1).is_none()); } + /// `descramble_region` is handed arbitrary disc regions, so it also sees + /// IFO/UDF/ISO-9660 sectors — raw structures where byte 0x14 is whatever + /// that format happens to store there, NOT a scrambling-control field. + /// + /// Measured on a real disc: the second sector of `VIDEO_TS.IFO` holds 0x15 + /// at offset 0x14 (bits 4-5 set) while starting `00 26 00 00`, which is not + /// a pack. Descrambling it destroyed 1912 of its 2048 bytes, and because + /// that sector carries TT_SRPT the whole title table went with it — the + /// disc enumerated 38 titles, an image decrypted from it enumerated 10, at + /// exit 0 with no diagnostic. Guard on the pack start code, not the flag. + #[test] + fn descramble_region_leaves_a_non_pack_sector_alone_even_with_the_flag_set() { + let mut ifo_like = vec![0u8; 2048]; + for (i, b) in ifo_like.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(37).wrapping_add(11); + } + ifo_like[0x00..0x04].copy_from_slice(&[0x00, 0x26, 0x00, 0x00]); // not a pack + ifo_like[0x14] = 0x15; // bits 4-5 set: reads as "scrambled" to the raw test + assert!( + is_scrambled(&ifo_like) && !is_scrambled_pack(&ifo_like), + "fixture must be exactly the case the two predicates disagree on" + ); + + let pristine = ifo_like.clone(); + let mut key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + descramble_region(&mut ifo_like, &mut key).expect("region descramble"); + + assert_eq!( + ifo_like, pristine, + "a non-pack sector must survive byte-identical — descrambling it \ + corrupts the very structures that enumerate titles" + ); + } + + /// The other half of the contract: the guard must not cost coverage of + /// sectors that genuinely ARE scrambled VOB data. + #[test] + fn descramble_region_still_descrambles_a_real_scrambled_pack() { + let mut pack = vec![0u8; 2048]; + for (i, b) in pack.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(29).wrapping_add(3); + } + pack[0x00..0x04].copy_from_slice(&PACK_START); + pack[0x14] = 0x30; + assert!(is_scrambled_pack(&pack)); + + let scrambled = pack.clone(); + let mut key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + descramble_region(&mut pack, &mut key).expect("region descramble"); + + assert_ne!( + pack[0x80..], + scrambled[0x80..], + "the encrypted region of a real pack must actually be transformed" + ); + assert_eq!( + (pack[0x14] >> 4) & 0x03, + 0, + "the scrambling-control bits must be cleared once descrambled" + ); + assert_eq!( + pack[0x15..0x80], + scrambled[0x15..0x80], + "CSS only scrambles from 0x80 on; the rest of the header, byte 0x14 \ + aside, must be untouched" + ); + assert_eq!(pack[..0x14], scrambled[..0x14]); + } + /// Even when every read FAILS, a scan that never managed to observe a /// scrambled sector reports `Unencrypted` (we cannot prove encryption from /// unreadable data alone — the AACS/keydb paths and the disc-level diff --git a/src/decrypt.rs b/src/decrypt.rs index 39c21ac..1dd77e3 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -987,6 +987,12 @@ mod tests { /// scramble flag. fn make_css_sector(title_key: &[u8; 5], seed: &[u8; 5], body_fill: u8) -> (Vec, Vec) { let mut sector = vec![body_fill; 2048]; + // A real scrambled DVD sector is an MPEG-2 PS pack, so it begins with + // the pack start code. The descrambler requires it before trusting + // byte 0x14 — without it this fixture is a sector shape that cannot + // occur on a disc, and the test would pass while the production gate + // rejected every sector like it. + sector[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); sector[0x14] = 0x30; // scramble flag (bits 4-5) sector[0x54..0x59].copy_from_slice(seed); let plaintext = sector.clone(); @@ -1064,6 +1070,9 @@ mod tests { period: usize, ) -> (Vec, Vec) { let mut plaintext = vec![0u8; 2048]; + // Real scrambled DVD sectors are MPEG-2 PS packs; the scramble policy + // requires the pack start code as well as the flag bits. + plaintext[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); plaintext[0x14] = 0x10; // scramble flag // Periodic run from 0x59 (just above the seed) through 0x80 and on into // the encrypted region; phase anchored to offset 0 so it is continuous diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 633e353..53877f0 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -1288,6 +1288,9 @@ mod tests { // mirroring css::mod tests' `crackable_sector`. let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; let mut plain = vec![0u8; 2048]; + // Pack start code: a real scrambled sector is an MPEG-2 PS pack, and + // the descrambler requires it before trusting byte 0x14. + plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); plain[0x14] = 0x10; // scramble flag let pat: Vec = (0..8) .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) @@ -1759,6 +1762,9 @@ mod tests { // VTS groups regardless of which extents were gathered, masking // this exact regression. plain[0x00..0x04].copy_from_slice(&crate::css::PACK_START); + // Pack start code: a real scrambled sector is an MPEG-2 PS pack, and + // the descrambler requires it before trusting byte 0x14. + plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); plain[0x14] = 0x10; // scramble flag let pat: Vec = (0..8) .map(|k| (0xA0u8.wrapping_add(k as u8) ^ marker) ^ 0x5A) diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index ed28e30..727fb23 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -682,6 +682,9 @@ mod tests { for (i, b) in template.iter_mut().enumerate() { *b = (i as u8).wrapping_mul(29).wrapping_add(3); } + // Real scrambled DVD sectors are MPEG-2 PS packs; the scramble policy + // requires the pack start code as well as the flag bits. + template[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); template[0x14] = 0x30; // scramble bits (4-5) set → flags == 0x03 let pristine = template; diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index d977ded..f0ab5cf 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -42,6 +42,10 @@ fn css_decrypt_of_an_uncrackable_sector_still_descrambles() { // unit key either opens a unit or does not, whereas a CSS title key is // recovered from data whose recoverability varies sector by sector. let mut sector = vec![0xFFu8; 2048]; + // Scrambled DVD sectors are MPEG-2 PS packs. The descramble policy requires + // the pack start code as well as the flag bits, because byte 0x14 means + // something else entirely in an IFO, UDF or ISO 9660 sector. + sector[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]); sector[0x14] |= 0x30; // CSS scramble flag, bits 4-5 let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];