Files
libfreemkv/docs/css-keyless.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

12 KiB

CSS keyless title-key recovery (src/css/keyless.rs)

The cipher this attacks

The content descrambler ([super::lfsr::descramble_sector]) seeds its two LFSRs directly from key = title_key XOR sector_seed (seed = sector[0x54..0x59]): LFSR1 from key/seed bytes 0-1, LFSR0 (24-bit) from bytes 2-4 with the pre-conditioning r0 = r0*2 + 8 - (r0 & 7), and each body byte recovered as plain = TAB1[cipher] ^ (keystream & 0xff). There is no title-key mangling on the content path, so the recovery is a single inversion of the sector cipher.

The attack

  1. Known plaintext → keystream. Because descramble applies TAB1 to the ciphertext, the per-byte keystream is TAB1[cipher[i]] ^ plain[i].
  2. Brute the 16-bit LFSR1 seed. For each of 2^16 seeds, run LFSR1 forward; for the first four steps deduce the LFSR0 output bytes from the keystream (carry-tracked), reconstructing LFSR0's state. For the next six steps clock LFSR0 normally and check it reproduces the keystream — a wrong LFSR1 seed fails fast.
  3. Back-clock LFSR0. Run four backward steps (each a 256-way search for the byte shifted in) to reach the initial state, then undo the r0*2 + 8 - (r0 & 7) pre-conditioning to recover key[2..5].
  4. XOR back the seed. key[0..5] ^= sector_seed[0..5].

Known plaintext for step 1 comes from the longest periodic run in the cleartext sec[0x00..0x80], assumed to continue into the encrypted region at 0x80.

recover_title_key_from_plain

Core of the recovery. crypted is the ciphertext starting at sector byte 0x80; decrypted is the matching known plaintext; seed is sector[0x54..0x59]. On success returns the recovered 5-byte title key; None if no LFSR seed reproduces the keystream. At least 10 bytes of crypted/decrypted are required (the cipher is iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).

attack_crib

Scans the clear header sec[0x00..0x80] (never scrambled) for the longest run that repeats with a cycle length in 2..0x2F. If the run is long enough (plen > 3 and at least two full cycles), the plaintext at 0x80 is taken to be that periodic run continuing forward. Returns None for an unscrambled sector or one with no usable run — such a sector can be neither cracked nor key-validated, only descrambled with an externally-cached key.

The header is untouched by descramble_sector, so the crib is identical before and after descramble: the decrypt path uses it as a per-sector "did the cached key descramble correctly?" oracle (the predicted plaintext must reappear at 0x80), and the cracker uses it as its known plaintext.

Tests

synth_sector

Builds a synthetic scrambled sector for a given title key and seed, with plain placed as the plaintext at byte 0x80, scrambled with EXACTLY the cipher descramble_sector inverts. Returns (scrambled_sector, full_plaintext_body).

synth_periodic_sector

Builds a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends in a periodic run that continues into the encrypted region — the case crack_title_key is designed to crack.

recover_round_trips_known_keys

MANDATORY round-trip (Task C.1): synthesize a scrambled sector for a known (title_key, seed), then assert recover_title_key returns a key that descrambles the body back to plaintext. CSS title-key recovery is well-defined up to keys that scramble identically; we assert the full body round-trips (the true correctness property), and additionally that the EXACT key is returned for the common case.

descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with

descramble_matches is the ONLY gate between the LFSR search and a key handed back to the caller: both recover_title_key and the crib-driven crack_title_key_inner return a candidate only if this says the key really descrambles the sector to the known plaintext. A body that always answered true would let the first spurious LFSR-seed match through as the title key — the ripper would then descramble the whole title with a key that opens nothing, producing garbage rather than a "no key" error.

Pinned both directions: the genuine key is accepted, and EVERY key one bit away from it is rejected. The one-bit neighbours are the strongest form of wrong key — a gate that only rejects wildly different keys would still pass a near-miss out of the 2^16 seed search.

descramble_matches_does_not_disturb_the_caller_s_sector

The gate is applied to a COPY: verifying a candidate must not modify the caller's sector. recover_title_key runs the gate and then hands the sector on to be descrambled for real — if verification descrambled in place, that second descramble would run over already-transformed bytes (and, worse, a rejected candidate would leave the sector corrupted).

recover_rejects_a_sector_that_ends_inside_the_encrypted_region

A sector buffer that ENDS inside the encrypted region must be refused, not sliced. recover_title_key slices sector[0x80..0x8A] unconditionally after its length guard. The existing short-sector test uses SECTOR_BYTES - 1, which is still long enough for that slice to succeed — so the guard was never the thing producing the None, and dropping it (or weakening the || to &&, which a full-length crib satisfies) changed nothing observable. On a real short read this is an out-of-bounds panic on the rip thread.

a_buffer_longer_than_one_sector_still_yields_its_key

A buffer LONGER than one sector is still one sector: both entry points read the first SECTOR_BYTES and must recover the key from it. Callers read DVD data in multi-sector blocks, so an over-long slice is the normal case, not an exotic one. A length guard that rejected it (len > SECTOR_BYTES instead of <) would make every block-read caller silently unable to crack anything.

recover_accepts_more_than_ten_bytes_of_known_plaintext

recover_title_key accepts MORE than ten bytes of known plaintext, and uses all of it: the extra bytes tighten the descramble_matches gate. The ten-byte figure is a MINIMUM (the cipher is iterated ten times), not an exact requirement — a guard reading it as an upper bound would reject every caller that knows a longer crib.

a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused

The scramble-flag gate on a sector whose BODY really is ciphertext. Both entry points refuse a sector with sector[0x14] & 0x30 == 0: an unscrambled sector has no title key to recover, and its bytes at 0x80 are already plaintext. Every prior test of this gate used an all-zero or all-0x11 sector, where the recovery would have found nothing anyway — so widening the mask test (& to |, which makes it true for EVERY flag byte) produced the same None and went unseen.

Here the sector is genuinely scrambled and its key IS recoverable; only the cleared flag stands in the way. If the gate stops working, both functions start returning keys for sectors the disc says are in the clear.

attack_crib carries its own copy of the same gate and is the one that actually stops the crack (crack_title_key's is defensive duplication); it also doubles as the decrypt path's cached-key oracle.

descramble_matches_forces_the_scramble_flag_on_its_own_copy

The gate must verify a candidate against the sector's CIPHERTEXT regardless of what the sector's own flag byte says. descramble_matches forces 0x10 on its copy precisely because super::lfsr::descramble_sector is a no-op when the scramble bits are clear — without that, verifying a scrambled-but-unflagged sector compares raw ciphertext against the crib, and every candidate key is rejected. Nothing exercised it: every fixture already had the flag set, where forcing the bit is a no-op.

descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector

The gate compares the WHOLE supplied plaintext, clamped to the encrypted region. Two properties in one, because they are the two halves of plain.len().min(SECTOR_BYTES - ENCRYPTED_START):

  • it must compare beyond the first sixteen bytes, or a key that opens only the head of the crib is accepted; and
  • it must never compare past the end of the sector — a caller that knows more plaintext than the 1920-byte encrypted region holds otherwise indexes off the end of the buffer and panics.

sector_with_trailing_run

Builds a sector whose clear header ends in a period-length repeating run of exactly run_len bytes immediately before 0x80. The run is anchored to ABSOLUTE sector offset (sec[x] = pat[x % period]), which is what makes "the run continues past 0x80" a statement independent of the code under test: the byte at 0x80 + i of the underlying plaintext is pat[(0x80 + i) % period]. Everything before the run is 0x00 (the pattern bytes are all >= 0xD0, so the run cannot be extended backwards by accident), and the encrypted region is filled with 0xFF — so a crib that reads past 0x80 into "ciphertext" is immediately visible.

attack_crib_predicts_the_periodic_run_continuing_past_0x80

KNOWN ANSWER: for a run of run_len bytes with period 5 ending exactly at 0x80, the crib is the run continued forward — the same ten bytes for every run length, because the prediction depends only on the pattern and the phase, never on how many cycles happened to be visible. The short lengths are the load-bearing ones: at run_len = 11 the crib window starts at 0x76 and is only 10 bytes from the end of the header, so any drift in plain_start, in cycles * best_p, or in the i % best_p wrap reads the 0xFF "ciphertext" instead of the run.

attack_crib_refuses_a_run_shorter_than_two_cycles

A run of exactly ONE cycle (plus the trivial tail the detector counts) is not enough to predict forward: attack_crib requires at least two full cycles. Weakening that guard would let a one-off byte sequence be declared periodic and produce a confidently wrong crib — which the decrypt path uses as its "is my cached key still right?" oracle.

attack_crib_refuses_a_buffer_shorter_than_a_sector

attack_crib indexes sector[0x7f - j] with no per-access bound, so its own length guard is the only thing between a short buffer and an out-of-bounds read. Nothing reached it: every caller-level test used a full sector, and the entry points' guards fire first.

attack_crib_survives_a_header_that_is_periodic_to_offset_zero

A header that is periodic ALL THE WAY to offset 0 must not walk the backward scan off the front of the sector. The detector counts backwards from 0x7f while j < 0x80. On a fully periodic header the run never breaks, so j reaches 0x7f and the bound is the ONLY thing that stops it — one step further and 0x7f - j underflows a usize and panics. A constant or fully-patterned 128-byte header is ordinary DVD data (padding, a run of zeros), not a crafted input, and every existing fixture had a filler/run boundary well before offset 0 that stopped the scan early.

attack_crib_is_independent_of_the_encrypted_region

The crib is read from the CLEAR header only. A run that reaches 0x80 must predict from the header bytes, never from the encrypted region — the previously-fixed bug this function's doc comment records. Pinned by rewriting the encrypted region and requiring the crib not to move.

recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input

recover_title_key_from_plain unconditionally builds a 10-byte keystream buffer from crypted[0..10] and decrypted[0..10], so its length guard is the only thing standing between a short slice and an index-out-of-bounds PANIC. Nothing reached that guard before: recover_title_key rejects plain.len() < 10 at its own door and always hands on exactly ten ciphertext bytes, and crack_title_key_inner always passes a fixed [u8; 10] crib. The guard is a live contract for any future caller and was executed by no test at either boundary.

recover_title_key_from_plain_xors_the_sector_seed_back_out

The seed XOR-back (recover_title_key_from_plain's last step) is what turns the recovered LFSR key into the TITLE key: key ^= sector_seed. Pinned as a known answer across seeds that differ only in one byte — the same ciphertext/plaintext pair therefore must yield title keys differing in exactly that byte. Without this, a body that ORed the seed in (or dropped the step) still round-trips on any fixture whose seed is zero, and on the non-zero ones the failure looks like "no key found" rather than a wrong step.