Require a pack start code before descrambling a sector

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.
This commit is contained in:
Matthew Jackson
2026-08-05 20:59:14 -07:00
parent 1d35dcf7c6
commit a018e1adc4
5 changed files with 106 additions and 1 deletions
+84 -1
View File
@@ -379,7 +379,21 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
/// than apply a neighbouring CPS unit's key. /// than apply a neighbouring CPS unit's key.
pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::error::Result<usize> { pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::error::Result<usize> {
for chunk in buf.chunks_mut(2048) { 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; continue;
} }
let crib = stevenson::attack_crib(chunk); let crib = stevenson::attack_crib(chunk);
@@ -825,6 +839,75 @@ mod tests {
assert!(crack_key(&mut MockSource::new(0x30), &extents, 1).is_none()); 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 /// Even when every read FAILS, a scan that never managed to observe a
/// scrambled sector reports `Unencrypted` (we cannot prove encryption from /// scrambled sector reports `Unencrypted` (we cannot prove encryption from
/// unreadable data alone — the AACS/keydb paths and the disc-level /// unreadable data alone — the AACS/keydb paths and the disc-level
+9
View File
@@ -987,6 +987,12 @@ mod tests {
/// scramble flag. /// scramble flag.
fn make_css_sector(title_key: &[u8; 5], seed: &[u8; 5], body_fill: u8) -> (Vec<u8>, Vec<u8>) { fn make_css_sector(title_key: &[u8; 5], seed: &[u8; 5], body_fill: u8) -> (Vec<u8>, Vec<u8>) {
let mut sector = vec![body_fill; 2048]; 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[0x14] = 0x30; // scramble flag (bits 4-5)
sector[0x54..0x59].copy_from_slice(seed); sector[0x54..0x59].copy_from_slice(seed);
let plaintext = sector.clone(); let plaintext = sector.clone();
@@ -1064,6 +1070,9 @@ mod tests {
period: usize, period: usize,
) -> (Vec<u8>, Vec<u8>) { ) -> (Vec<u8>, Vec<u8>) {
let mut plaintext = vec![0u8; 2048]; 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 plaintext[0x14] = 0x10; // scramble flag
// Periodic run from 0x59 (just above the seed) through 0x80 and on into // 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 // the encrypted region; phase anchored to offset 0 so it is continuous
+6
View File
@@ -1288,6 +1288,9 @@ mod tests {
// mirroring css::mod tests' `crackable_sector`. // mirroring css::mod tests' `crackable_sector`.
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let mut plain = vec![0u8; 2048]; 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 plain[0x14] = 0x10; // scramble flag
let pat: Vec<u8> = (0..8) let pat: Vec<u8> = (0..8)
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
@@ -1759,6 +1762,9 @@ mod tests {
// VTS groups regardless of which extents were gathered, masking // VTS groups regardless of which extents were gathered, masking
// this exact regression. // this exact regression.
plain[0x00..0x04].copy_from_slice(&crate::css::PACK_START); 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 plain[0x14] = 0x10; // scramble flag
let pat: Vec<u8> = (0..8) let pat: Vec<u8> = (0..8)
.map(|k| (0xA0u8.wrapping_add(k as u8) ^ marker) ^ 0x5A) .map(|k| (0xA0u8.wrapping_add(k as u8) ^ marker) ^ 0x5A)
+3
View File
@@ -682,6 +682,9 @@ mod tests {
for (i, b) in template.iter_mut().enumerate() { for (i, b) in template.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(29).wrapping_add(3); *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 template[0x14] = 0x30; // scramble bits (4-5) set → flags == 0x03
let pristine = template; let pristine = template;
+4
View File
@@ -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 // unit key either opens a unit or does not, whereas a CSS title key is
// recovered from data whose recoverability varies sector by sector. // recovered from data whose recoverability varies sector by sector.
let mut sector = vec![0xFFu8; 2048]; 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 sector[0x14] |= 0x30; // CSS scramble flag, bits 4-5
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];