diff --git a/senbei-android-crypto/src/lib.rs b/senbei-android-crypto/src/lib.rs index 49fe6c9..36c6876 100644 --- a/senbei-android-crypto/src/lib.rs +++ b/senbei-android-crypto/src/lib.rs @@ -1,717 +1,8 @@ -//! Cryptographic and compression primitives used by the Android protector. +//! Cryptographic and container primitives used by Senbei Android. -use aes::Aes256; -use aes::cipher::{Block, BlockDecrypt, KeyInit}; +mod protector; -const RECORD_SIZE: usize = 0x5c; - -/// Errors raised while parsing or decoding protector containers. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("{0}")] - Invalid(String), -} - -type Result = std::result::Result; - -fn invalid(message: impl Into) -> Result { - Err(Error::Invalid(message.into())) -} - -fn range(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { - let end = offset - .checked_add(size) - .ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?; - data.get(offset..end).ok_or_else(|| { - Error::Invalid(format!( - "byte range 0x{offset:x}..0x{end:x} is out of bounds" - )) - }) -} - -fn read_u16(data: &[u8], offset: usize) -> Result { - let bytes: [u8; 2] = range(data, offset, 2)? - .try_into() - .map_err(|_| Error::Invalid("invalid u16 range".to_owned()))?; - Ok(u16::from_le_bytes(bytes)) -} - -fn read_u32(data: &[u8], offset: usize) -> Result { - let bytes: [u8; 4] = range(data, offset, 4)? - .try_into() - .map_err(|_| Error::Invalid("invalid u32 range".to_owned()))?; - Ok(u32::from_le_bytes(bytes)) -} - -fn align_up(value: usize, alignment: usize) -> Result { - let mask = alignment - .checked_sub(1) - .ok_or_else(|| Error::Invalid("zero alignment".to_owned()))?; - value - .checked_add(mask) - .map(|v| v & !mask) - .ok_or_else(|| Error::Invalid("alignment overflow".to_owned())) -} - -/// Multiply by the fixed element used by the native GF(2^32) transform. -#[must_use] -pub fn gf32_mul_fixed(mut value: u32) -> u32 { - let mut multiplier = 0x9451_1dd2_u32; - let mut result = 0_u32; - while multiplier != 0 { - if multiplier & 1 != 0 { - result ^= value; - } - let carry = value >> 31; - value = value.wrapping_shl(1); - if carry != 0 { - value ^= 0x5793_57eb; - } - multiplier >>= 1; - } - result -} - -fn mix_columns(block: [u8; 16]) -> [u8; 16] { - const fn xtime(value: u8) -> u8 { - (value << 1) ^ if value & 0x80 != 0 { 0x1b } else { 0 } - } - - let mut output = [0_u8; 16]; - for offset in (0..16).step_by(4) { - let [a, b, c, d] = block[offset..offset + 4] else { - unreachable!("fixed four-byte AES column") - }; - output[offset] = xtime(a) ^ (xtime(b) ^ b) ^ c ^ d; - output[offset + 1] = a ^ xtime(b) ^ (xtime(c) ^ c) ^ d; - output[offset + 2] = a ^ b ^ xtime(c) ^ (xtime(d) ^ d); - output[offset + 3] = (xtime(a) ^ a) ^ b ^ c ^ xtime(d); - } - output -} - -/// Static configuration recovered from module `0x9B`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Module9bConfig { - pub header_seed: u32, - pub container_seed: u32, - pub aes_key: [u8; 32], - pub skip_aes: bool, - pub schedule_offset: usize, -} - -impl Module9bConfig { - /// Parse the unique AES-256 decryption schedule and adjacent configuration. - pub fn parse(image: &[u8]) -> Result { - Self::parse_inner(image, true) - } - - /// Parse the decoder configuration embedded in the raw Stage 2 image. - /// - /// The embedded decoder ends before the interpreter-only `skip_aes` - /// field, so that flag is definitionally false for this layout. - pub fn parse_embedded(image: &[u8]) -> Result { - Self::parse_inner(image, false) - } - - fn parse_inner(image: &[u8], has_skip_aes: bool) -> Result { - const MARKER: [u8; 4] = [0x00, 0x01, 0x0e, 0x00]; - let mut matches = image - .windows(MARKER.len()) - .enumerate() - .filter_map(|(offset, bytes)| (bytes == MARKER).then_some(offset)); - let schedule_offset = matches - .next() - .ok_or_else(|| Error::Invalid("cannot locate the 0x9B AES-256 schedule".to_owned()))?; - if schedule_offset < 8 || matches.next().is_some() { - return invalid("cannot uniquely locate the 0x9B AES-256 schedule"); - } - - let header_seed = read_u32(image, schedule_offset - 8)?; - let schedule_size = read_u32(image, schedule_offset - 4)?; - if !matches!(schedule_size, 0 | 0xf4) { - return invalid(format!( - "unexpected 0x9B AES schedule size 0x{schedule_size:x}" - )); - } - let bits = read_u16(image, schedule_offset)?; - let rounds = read_u16(image, schedule_offset + 2)?; - if (bits, rounds) != (0x100, 14) { - return invalid(format!( - "unexpected AES schedule header 0x{bits:x}/{rounds}" - )); - } - - let schedule = range(image, schedule_offset + 4, 15 * 16)?; - let mut round_keys = [[0_u8; 16]; 15]; - for (round, output) in round_keys.iter_mut().enumerate() { - let source = &schedule[round * 16..round * 16 + 16]; - for word in 0..4 { - let start = word * 4; - for byte in 0..4 { - output[start + byte] = source[start + 3 - byte]; - } - } - } - let mut aes_key = [0_u8; 32]; - aes_key[..16].copy_from_slice(&round_keys[14]); - aes_key[16..].copy_from_slice(&mix_columns(round_keys[13])); - - let container_seed_offset = schedule_offset - .checked_add(0x100) - .ok_or_else(|| Error::Invalid("container seed offset overflow".to_owned()))?; - let skip_aes = if has_skip_aes { - let skip_aes_offset = schedule_offset - .checked_add(0x240) - .ok_or_else(|| Error::Invalid("skip-AES offset overflow".to_owned()))?; - *image.get(skip_aes_offset).ok_or_else(|| { - Error::Invalid("module static configuration exceeds its image".to_owned()) - })? != 0 - } else { - false - }; - - Ok(Self { - header_seed, - container_seed: if has_skip_aes { - read_u32(image, container_seed_offset)? - } else { - header_seed - }, - aes_key, - skip_aes, - schedule_offset, - }) - } -} - -/// Decrypted header at the start of direct-data object `0x9D`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ProtectedDescriptor { - pub command_id: u32, - pub flags: u32, - pub outer_offset: u32, - pub outer_expected_size: u32, - pub auxiliary_offset: u32, - pub auxiliary_expected_size: u32, -} - -impl ProtectedDescriptor { - /// Decrypt the `0x5c`-byte descriptor with the module header seed. - pub fn decrypt(data: &[u8], seed: u32) -> Result { - if data.len() < RECORD_SIZE { - return invalid("0x9D descriptor is truncated"); - } - let base0 = seed.wrapping_add(0xd3e8_7144).wrapping_mul(seed); - let base1 = base0.wrapping_add(seed.wrapping_mul(0x0bd9_418d)); - let mut words = [0_u32; RECORD_SIZE / 4]; - for (index, word) in words.iter_mut().enumerate() { - let cipher = read_u32(data, index * 4)?; - let subtractor = base0.wrapping_shl(if index & 1 != 0 { 4 } else { 0 }); - *word = cipher.wrapping_sub(subtractor) - ^ base1.wrapping_shr((seed.wrapping_add((index as u32).wrapping_mul(4))) & 7); - } - if words[6..].iter().any(|&word| word != 0) { - return invalid("unexpected nonzero reserved words in the 0x9D descriptor"); - } - let descriptor = Self { - command_id: words[0], - flags: words[1], - outer_offset: words[2], - outer_expected_size: words[3], - auxiliary_offset: words[4], - auxiliary_expected_size: words[5], - }; - if descriptor.command_id != 0x9d || descriptor.outer_offset as usize != RECORD_SIZE { - return invalid("unexpected decrypted 0x9D descriptor"); - } - Ok(descriptor) - } -} - -/// One encrypted segment in a decoded `0x9D` container header. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct EncodedSegment { - pub offset: u32, - pub size: u32, -} - -/// Parsed primary or auxiliary `0x9D` container. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ContainerHeader { - pub start: usize, - pub output_size: u32, - pub skip_aes: bool, - pub tree: Vec, - pub segments: Vec, -} - -impl ContainerHeader { - /// Parse and decrypt a container header, Huffman tree, and segment table. - pub fn parse(data: &[u8], start: usize, seed: u32) -> Result { - range(data, start, 12)?; - let seed_square = seed.wrapping_mul(seed); - let state = seed_square.wrapping_shr(17) ^ seed_square.wrapping_shl(11); - let raw0 = read_u32(data, start)?; - let raw1 = read_u32(data, start + 4)?; - let raw2 = read_u32(data, start + 8)?; - let output_size = 0xa21d_fb3a_u32 - .wrapping_shl(state & 7) - .wrapping_add(state.wrapping_mul(0xf87b_337c)) - .wrapping_add(gf32_mul_fixed(raw0)); - let flag_word = gf32_mul_fixed(raw1) - ^ state - .wrapping_add(0xbd19_c63c) - .wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)); - let segment_count = (flag_word & 0xff) as usize; - let skip_aes = (flag_word >> 8) & 0xff == 1; - let tree_size = 0x643a_3a3b_u32 - .wrapping_shl(state & 0x0b) - .wrapping_sub(state ^ 0x3b2b_f538) - .wrapping_add(gf32_mul_fixed(raw2)) as usize; - if segment_count == 0 || tree_size > 0x1b00 { - return invalid(format!( - "invalid container fields: segments={segment_count}, tree=0x{tree_size:x}" - )); - } - - let tree_start = start - .checked_add(12) - .ok_or_else(|| Error::Invalid("tree offset overflow".to_owned()))?; - let mut tree = range(data, tree_start, tree_size)?.to_vec(); - for offset in (0..tree_size & !3).step_by(4) { - let value = read_u32(&tree, offset)?; - tree[offset..offset + 4].copy_from_slice(&gf32_mul_fixed(value).to_le_bytes()); - } - let tree_state = state.wrapping_add(0xf1cb_5b81).wrapping_mul(state); - let tree_delta = tree_state.wrapping_sub(0x23b3_2203_u32.wrapping_mul(state)); - for (index, byte) in tree.iter_mut().enumerate() { - let shift = u32::try_from(index & 0x1b) - .map_err(|_| Error::Invalid("tree shift conversion failed".to_owned()))?; - let left = gf32_mul_fixed(tree_state.wrapping_shl(shift)); - let right = tree_delta.wrapping_shr((index & 0x17) as u32); - let adjustment = left.wrapping_sub(right).wrapping_shr((index & 0x1f) as u32); - *byte = byte.wrapping_add(adjustment as u8); - } - - let table_start = start - .checked_add(align_up(12 + tree_size, 4)?) - .ok_or_else(|| Error::Invalid("segment table offset overflow".to_owned()))?; - let table_size = segment_count - .checked_mul(8) - .ok_or_else(|| Error::Invalid("segment table size overflow".to_owned()))?; - let mut table = range(data, table_start, table_size)?.to_vec(); - let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state); - let table_xor = table_state.wrapping_shl(3); - let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state)); - for offset in (0..table_size).step_by(4) { - let value = read_u32(&table, offset)?; - let decoded = gf32_mul_fixed(value ^ table_xor) - .wrapping_add(table_add.wrapping_shr(((offset & 7) + 5) as u32)); - table[offset..offset + 4].copy_from_slice(&decoded.to_le_bytes()); - } - let mut segments = Vec::with_capacity(segment_count); - for index in 0..segment_count { - let offset = read_u32(&table, index * 8)?; - let size = read_u32(&table, index * 8 + 4)?; - let absolute = start - .checked_add(offset as usize) - .and_then(|value| value.checked_add(size as usize)); - if size == 0 || absolute.is_none_or(|end| end > data.len()) { - return invalid(format!("container segment {index} lies outside 0x9D")); - } - segments.push(EncodedSegment { offset, size }); - } - Ok(Self { - start, - output_size, - skip_aes, - tree, - segments, - }) - } - - /// End offset of the furthest encrypted segment. - pub fn encoded_end(&self) -> Result { - self.segments - .iter() - .map(|segment| { - self.start - .checked_add(segment.offset as usize) - .and_then(|value| value.checked_add(segment.size as usize)) - .ok_or_else(|| Error::Invalid("encoded segment end overflow".to_owned())) - }) - .collect::>>()? - .into_iter() - .max() - .ok_or_else(|| Error::Invalid("container has no encoded segments".to_owned())) - } -} - -/// Decoder for the protector's Huffman/LZ writer streams. -#[derive(Debug, Clone)] -pub struct HuffmanLzDecoder { - tree: Vec, - lookup_symbols: Vec, - lookup_bits: Vec, -} - -impl HuffmanLzDecoder { - /// Build the full 16-bit prefix lookup used by the static decoder. - pub fn new(tree: &[u8]) -> Result { - if tree.len() < 256 * 3 || tree.len() % 3 != 0 { - return invalid(format!("invalid Huffman tree size 0x{:x}", tree.len())); - } - let mut result = Self { - tree: tree.to_vec(), - lookup_symbols: vec![0; 0x1_0000], - lookup_bits: vec![0; 0x1_0000], - }; - for word in 0..0x1_0000_u32 { - let (symbol, bits) = result.decode_symbol(word)?; - if bits <= 16 { - result.lookup_symbols[word as usize] = symbol; - result.lookup_bits[word as usize] = bits; - } - } - Ok(result) - } - - fn entry(&self, index: usize) -> Result<(u16, bool, u8)> { - let offset = index - .checked_mul(3) - .ok_or_else(|| Error::Invalid("Huffman node offset overflow".to_owned()))?; - let bytes = range(&self.tree, offset, 3)?; - let raw = u16::from(bytes[0]) | (u16::from(bytes[1]) << 8); - Ok((raw & 0x7fff, raw & 0x8000 != 0, bytes[2])) - } - - fn decode_symbol(&self, word: u32) -> Result<(u16, u8)> { - let (mut value, leaf, extra) = self.entry((word & 0xff) as usize)?; - if leaf { - if extra == 0 { - return invalid("zero-width Huffman leaf"); - } - return Ok((value, extra)); - } - let mut bits = extra - .checked_add(1) - .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; - let mut mask = 1_u32.wrapping_shl(u32::from(extra)); - loop { - let branch = usize::from(word & mask != 0); - let (next, is_leaf, _) = self.entry(usize::from(value) + branch)?; - value = next; - if is_leaf { - return Ok((value, bits)); - } - mask = mask.wrapping_shl(1); - bits = bits - .checked_add(1) - .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; - if bits > 31 { - return invalid("Huffman code exceeds the native 32-bit window"); - } - } - } - - /// Decode one compressed writer payload to its exact expected size. - pub fn decode(&self, source: &[u8], output_size: usize) -> Result> { - let mut output = vec![0_u8; output_size]; - let mut source_pos = 0_usize; - let mut bit_buffer = 0_u64; - let mut available = 0_u8; - let mut consumed_bits = 0_usize; - let mut output_pos = 0_usize; - let mut prefix = 0_usize; - - while output_pos < output_size { - while available < 24 && source_pos < source.len() { - bit_buffer |= u64::from(source[source_pos]) << available; - source_pos += 1; - available += 8; - } - let key = (bit_buffer & 0xffff) as usize; - let mut bits = self.lookup_bits[key]; - let symbol = if bits != 0 { - self.lookup_symbols[key] - } else { - let mut value_offset = ((bit_buffer & 0xff) as usize) * 3; - let mut node = range(&self.tree, value_offset, 3)?; - let mut raw = u16::from(node[0]) | (u16::from(node[1]) << 8); - if raw & 0x8000 != 0 { - bits = node[2]; - raw & 0x7fff - } else { - let extra = node[2]; - bits = extra + 1; - let mut mask = 1_u64 << extra; - loop { - let branch = usize::from(bit_buffer & mask != 0); - let index = usize::from(raw & 0x7fff) + branch; - value_offset = index - .checked_mul(3) - .ok_or_else(|| Error::Invalid("Huffman node overflow".to_owned()))?; - node = range(&self.tree, value_offset, 3)?; - raw = u16::from(node[0]) | (u16::from(node[1]) << 8); - if raw & 0x8000 != 0 { - break raw & 0x7fff; - } - mask <<= 1; - bits += 1; - } - } - }; - if bits == 0 || bits > available { - return invalid("compressed stream ends inside a Huffman code"); - } - bit_buffer >>= bits; - available -= bits; - consumed_bits = consumed_bits - .checked_add(usize::from(bits)) - .ok_or_else(|| Error::Invalid("consumed bit count overflow".to_owned()))?; - - let kind = symbol & 0x300; - let value = usize::from(symbol & 0xff); - match kind { - 0 => { - output[output_pos] = value as u8; - output_pos += 1; - } - 0x100 => { - if prefix > 0xff { - return invalid("compressed prefix exceeds 16 bits"); - } - prefix = if prefix == 0 { - value - } else { - value | (prefix << 8) - }; - } - 0x200 => { - if prefix == 0 { - prefix = 1; - } - let count = value - .checked_mul(prefix) - .ok_or_else(|| Error::Invalid("repeat count overflow".to_owned()))?; - if !matches!(value, 1 | 2 | 4) - || value > output_pos - || output_pos - .checked_add(count) - .is_none_or(|end| end > output_size) - { - return invalid("invalid compressed repeated-pattern command"); - } - let pattern = output[output_pos - value..output_pos].to_vec(); - for chunk in output[output_pos..output_pos + count].chunks_exact_mut(value) { - chunk.copy_from_slice(&pattern); - } - output_pos += count; - prefix = 0; - } - 0x300 => { - let length = value; - let distance = prefix.checked_add(length).ok_or_else(|| { - Error::Invalid("back-reference distance overflow".to_owned()) - })?; - if distance > output_pos - || output_pos - .checked_add(length) - .is_none_or(|end| end > output_size) - { - return invalid("invalid compressed back-reference"); - } - let source_start = output_pos - distance; - output.copy_within(source_start..source_start + length, output_pos); - output_pos += length; - prefix = 0; - } - _ => unreachable!("masked Huffman symbol kind"), - } - } - if consumed_bits.div_ceil(8) != source.len() { - return invalid(format!( - "compressed input consumption mismatch: used=0x{:x}, size=0x{:x}", - consumed_bits.div_ceil(8), - source.len() - )); - } - Ok(output) - } -} - -/// Apply the native word transform and optional AES-256-CBC decryption. -pub fn transform_segment( - data: &[u8], - seed: u32, - aes_key: &[u8; 32], - decrypt_aes: bool, -) -> Result> { - let mut transformed = data.to_vec(); - let mut state = seed; - let mut left = 0xe34e_ac63_u32; - let mut right = 0x07b4_8238_u32; - for (index, chunk) in transformed.chunks_exact_mut(4).enumerate() { - let index32 = u32::try_from(index) - .map_err(|_| Error::Invalid("segment word index exceeds u32".to_owned()))?; - left = state - .wrapping_add(0x72f6_fcbe) - .wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left)) - .wrapping_shr(index32.wrapping_mul(index32) & 0x0f); - right = state - .wrapping_sub(0x71b6_a98d) - .wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right)) - .wrapping_shl(index32 & 7); - state = left ^ right; - let bytes: [u8; 4] = chunk - .try_into() - .map_err(|_| Error::Invalid("invalid transformed word".to_owned()))?; - let mut value = u32::from_le_bytes(bytes); - value = value.wrapping_add(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d)); - value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3); - value = value.wrapping_sub(state) ^ state; - chunk.copy_from_slice(&value.to_le_bytes()); - } - - if decrypt_aes { - let cipher = Aes256::new_from_slice(aes_key) - .map_err(|_| Error::Invalid("invalid AES-256 key length".to_owned()))?; - let aligned_size = transformed.len() & !0x0f; - let mut previous = [0_u8; 16]; - for chunk in transformed[..aligned_size].chunks_exact_mut(16) { - let mut ciphertext = [0_u8; 16]; - ciphertext.copy_from_slice(chunk); - cipher.decrypt_block(Block::::from_mut_slice(chunk)); - for (byte, prior) in chunk.iter_mut().zip(previous) { - *byte ^= prior; - } - previous = ciphertext; - } - } - Ok(transformed) -} - -/// Decode one complete protector container into its flat output buffer. -/// -/// This is the static equivalent of the decoder entrypoint embedded in Stage -/// 2 and in each nested interpreter module. -pub fn decode_container( - data: &[u8], - config: &Module9bConfig, - expected_size: usize, -) -> Result> { - let header = ContainerHeader::parse(data, 0, config.container_seed)?; - let header_size = usize::try_from(header.output_size) - .map_err(|_| Error::Invalid("container output size exceeds usize".to_owned()))?; - if header_size != expected_size { - return invalid(format!( - "container output size 0x{header_size:x} != expected 0x{expected_size:x}" - )); - } - let decoder = HuffmanLzDecoder::new(&header.tree)?; - let decrypt_aes = !(config.skip_aes || header.skip_aes); - let mut output = vec![0_u8; expected_size]; - - for (segment_index, encoded) in header.segments.iter().enumerate() { - let start = header - .start - .checked_add(encoded.offset as usize) - .ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?; - let encoded_data = range(data, start, encoded.size as usize)?; - let transformed = transform_segment( - encoded_data, - config.container_seed, - &config.aes_key, - decrypt_aes, - )?; - if transformed.len() < 16 { - return invalid(format!( - "decoded segment {segment_index} is shorter than its header" - )); - } - let base_offset = read_u32(&transformed, 0)? as usize; - let writer_count = read_u32(&transformed, 4)? as usize; - let table_offset = read_u32(&transformed, 8)? as usize; - let data_offset = read_u32(&transformed, 12)? as usize; - let table_size = writer_count - .checked_mul(16) - .ok_or_else(|| Error::Invalid("writer table size overflow".to_owned()))?; - let table_end = table_offset - .checked_add(table_size) - .ok_or_else(|| Error::Invalid("writer table end overflow".to_owned()))?; - if table_end > transformed.len() || data_offset > transformed.len() { - return invalid(format!( - "decoded segment {segment_index} has invalid writer offsets" - )); - } - - let mut data_cursor = data_offset; - for writer_index in 0..writer_count { - let record = - table_offset - .checked_add(writer_index.checked_mul(16).ok_or_else(|| { - Error::Invalid("writer record offset overflow".to_owned()) - })?) - .ok_or_else(|| Error::Invalid("writer record offset overflow".to_owned()))?; - let output_offset = read_u32(&transformed, record)? as usize; - let output_size = read_u32(&transformed, record + 4)? as usize; - let encoded_size = read_u32(&transformed, record + 8)? as usize; - let reserved = read_u32(&transformed, record + 12)?; - let encoded_end = data_cursor - .checked_add(encoded_size) - .ok_or_else(|| Error::Invalid("writer data end overflow".to_owned()))?; - if reserved != 0 || encoded_end > transformed.len() { - return invalid(format!( - "segment {segment_index} writer {writer_index} has invalid bounds" - )); - } - let source = &transformed[data_cursor..encoded_end]; - let decoded = if encoded_size == output_size { - None - } else { - Some(decoder.decode(source, output_size)?) - }; - let decoded = decoded.as_deref().unwrap_or(source); - let target = base_offset - .checked_add(output_offset) - .ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?; - let target_end = target - .checked_add(decoded.len()) - .ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?; - let destination = output.get_mut(target..target_end).ok_or_else(|| { - Error::Invalid(format!( - "segment {segment_index} writer {writer_index} target is out of range" - )) - })?; - destination.copy_from_slice(decoded); - data_cursor = encoded_end; - } - } - Ok(output) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn aes_mix_columns_matches_fips_example() { - let input = [ - 0xdb, 0x13, 0x53, 0x45, 0xf2, 0x0a, 0x22, 0x5c, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, - 0xc6, 0xc6, - ]; - assert_eq!( - mix_columns(input), - [ - 0x8e, 0x4d, 0xa1, 0xbc, 0x9f, 0xdc, 0x58, 0x9d, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, - 0xc6, 0xc6, - ] - ); - } - - #[test] - fn descriptor_rejects_truncated_input() { - assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err()); - } -} +pub use protector::{ + ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor, + decode_container, gf32_mul_fixed, transform_segment, +}; diff --git a/senbei-android-crypto/src/protector.rs b/senbei-android-crypto/src/protector.rs new file mode 100644 index 0000000..49fe6c9 --- /dev/null +++ b/senbei-android-crypto/src/protector.rs @@ -0,0 +1,717 @@ +//! Cryptographic and compression primitives used by the Android protector. + +use aes::Aes256; +use aes::cipher::{Block, BlockDecrypt, KeyInit}; + +const RECORD_SIZE: usize = 0x5c; + +/// Errors raised while parsing or decoding protector containers. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Invalid(String), +} + +type Result = std::result::Result; + +fn invalid(message: impl Into) -> Result { + Err(Error::Invalid(message.into())) +} + +fn range(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Invalid(format!( + "byte range 0x{offset:x}..0x{end:x} is out of bounds" + )) + }) +} + +fn read_u16(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 2] = range(data, offset, 2)? + .try_into() + .map_err(|_| Error::Invalid("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(bytes)) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 4] = range(data, offset, 4)? + .try_into() + .map_err(|_| Error::Invalid("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(bytes)) +} + +fn align_up(value: usize, alignment: usize) -> Result { + let mask = alignment + .checked_sub(1) + .ok_or_else(|| Error::Invalid("zero alignment".to_owned()))?; + value + .checked_add(mask) + .map(|v| v & !mask) + .ok_or_else(|| Error::Invalid("alignment overflow".to_owned())) +} + +/// Multiply by the fixed element used by the native GF(2^32) transform. +#[must_use] +pub fn gf32_mul_fixed(mut value: u32) -> u32 { + let mut multiplier = 0x9451_1dd2_u32; + let mut result = 0_u32; + while multiplier != 0 { + if multiplier & 1 != 0 { + result ^= value; + } + let carry = value >> 31; + value = value.wrapping_shl(1); + if carry != 0 { + value ^= 0x5793_57eb; + } + multiplier >>= 1; + } + result +} + +fn mix_columns(block: [u8; 16]) -> [u8; 16] { + const fn xtime(value: u8) -> u8 { + (value << 1) ^ if value & 0x80 != 0 { 0x1b } else { 0 } + } + + let mut output = [0_u8; 16]; + for offset in (0..16).step_by(4) { + let [a, b, c, d] = block[offset..offset + 4] else { + unreachable!("fixed four-byte AES column") + }; + output[offset] = xtime(a) ^ (xtime(b) ^ b) ^ c ^ d; + output[offset + 1] = a ^ xtime(b) ^ (xtime(c) ^ c) ^ d; + output[offset + 2] = a ^ b ^ xtime(c) ^ (xtime(d) ^ d); + output[offset + 3] = (xtime(a) ^ a) ^ b ^ c ^ xtime(d); + } + output +} + +/// Static configuration recovered from module `0x9B`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Module9bConfig { + pub header_seed: u32, + pub container_seed: u32, + pub aes_key: [u8; 32], + pub skip_aes: bool, + pub schedule_offset: usize, +} + +impl Module9bConfig { + /// Parse the unique AES-256 decryption schedule and adjacent configuration. + pub fn parse(image: &[u8]) -> Result { + Self::parse_inner(image, true) + } + + /// Parse the decoder configuration embedded in the raw Stage 2 image. + /// + /// The embedded decoder ends before the interpreter-only `skip_aes` + /// field, so that flag is definitionally false for this layout. + pub fn parse_embedded(image: &[u8]) -> Result { + Self::parse_inner(image, false) + } + + fn parse_inner(image: &[u8], has_skip_aes: bool) -> Result { + const MARKER: [u8; 4] = [0x00, 0x01, 0x0e, 0x00]; + let mut matches = image + .windows(MARKER.len()) + .enumerate() + .filter_map(|(offset, bytes)| (bytes == MARKER).then_some(offset)); + let schedule_offset = matches + .next() + .ok_or_else(|| Error::Invalid("cannot locate the 0x9B AES-256 schedule".to_owned()))?; + if schedule_offset < 8 || matches.next().is_some() { + return invalid("cannot uniquely locate the 0x9B AES-256 schedule"); + } + + let header_seed = read_u32(image, schedule_offset - 8)?; + let schedule_size = read_u32(image, schedule_offset - 4)?; + if !matches!(schedule_size, 0 | 0xf4) { + return invalid(format!( + "unexpected 0x9B AES schedule size 0x{schedule_size:x}" + )); + } + let bits = read_u16(image, schedule_offset)?; + let rounds = read_u16(image, schedule_offset + 2)?; + if (bits, rounds) != (0x100, 14) { + return invalid(format!( + "unexpected AES schedule header 0x{bits:x}/{rounds}" + )); + } + + let schedule = range(image, schedule_offset + 4, 15 * 16)?; + let mut round_keys = [[0_u8; 16]; 15]; + for (round, output) in round_keys.iter_mut().enumerate() { + let source = &schedule[round * 16..round * 16 + 16]; + for word in 0..4 { + let start = word * 4; + for byte in 0..4 { + output[start + byte] = source[start + 3 - byte]; + } + } + } + let mut aes_key = [0_u8; 32]; + aes_key[..16].copy_from_slice(&round_keys[14]); + aes_key[16..].copy_from_slice(&mix_columns(round_keys[13])); + + let container_seed_offset = schedule_offset + .checked_add(0x100) + .ok_or_else(|| Error::Invalid("container seed offset overflow".to_owned()))?; + let skip_aes = if has_skip_aes { + let skip_aes_offset = schedule_offset + .checked_add(0x240) + .ok_or_else(|| Error::Invalid("skip-AES offset overflow".to_owned()))?; + *image.get(skip_aes_offset).ok_or_else(|| { + Error::Invalid("module static configuration exceeds its image".to_owned()) + })? != 0 + } else { + false + }; + + Ok(Self { + header_seed, + container_seed: if has_skip_aes { + read_u32(image, container_seed_offset)? + } else { + header_seed + }, + aes_key, + skip_aes, + schedule_offset, + }) + } +} + +/// Decrypted header at the start of direct-data object `0x9D`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProtectedDescriptor { + pub command_id: u32, + pub flags: u32, + pub outer_offset: u32, + pub outer_expected_size: u32, + pub auxiliary_offset: u32, + pub auxiliary_expected_size: u32, +} + +impl ProtectedDescriptor { + /// Decrypt the `0x5c`-byte descriptor with the module header seed. + pub fn decrypt(data: &[u8], seed: u32) -> Result { + if data.len() < RECORD_SIZE { + return invalid("0x9D descriptor is truncated"); + } + let base0 = seed.wrapping_add(0xd3e8_7144).wrapping_mul(seed); + let base1 = base0.wrapping_add(seed.wrapping_mul(0x0bd9_418d)); + let mut words = [0_u32; RECORD_SIZE / 4]; + for (index, word) in words.iter_mut().enumerate() { + let cipher = read_u32(data, index * 4)?; + let subtractor = base0.wrapping_shl(if index & 1 != 0 { 4 } else { 0 }); + *word = cipher.wrapping_sub(subtractor) + ^ base1.wrapping_shr((seed.wrapping_add((index as u32).wrapping_mul(4))) & 7); + } + if words[6..].iter().any(|&word| word != 0) { + return invalid("unexpected nonzero reserved words in the 0x9D descriptor"); + } + let descriptor = Self { + command_id: words[0], + flags: words[1], + outer_offset: words[2], + outer_expected_size: words[3], + auxiliary_offset: words[4], + auxiliary_expected_size: words[5], + }; + if descriptor.command_id != 0x9d || descriptor.outer_offset as usize != RECORD_SIZE { + return invalid("unexpected decrypted 0x9D descriptor"); + } + Ok(descriptor) + } +} + +/// One encrypted segment in a decoded `0x9D` container header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncodedSegment { + pub offset: u32, + pub size: u32, +} + +/// Parsed primary or auxiliary `0x9D` container. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContainerHeader { + pub start: usize, + pub output_size: u32, + pub skip_aes: bool, + pub tree: Vec, + pub segments: Vec, +} + +impl ContainerHeader { + /// Parse and decrypt a container header, Huffman tree, and segment table. + pub fn parse(data: &[u8], start: usize, seed: u32) -> Result { + range(data, start, 12)?; + let seed_square = seed.wrapping_mul(seed); + let state = seed_square.wrapping_shr(17) ^ seed_square.wrapping_shl(11); + let raw0 = read_u32(data, start)?; + let raw1 = read_u32(data, start + 4)?; + let raw2 = read_u32(data, start + 8)?; + let output_size = 0xa21d_fb3a_u32 + .wrapping_shl(state & 7) + .wrapping_add(state.wrapping_mul(0xf87b_337c)) + .wrapping_add(gf32_mul_fixed(raw0)); + let flag_word = gf32_mul_fixed(raw1) + ^ state + .wrapping_add(0xbd19_c63c) + .wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)); + let segment_count = (flag_word & 0xff) as usize; + let skip_aes = (flag_word >> 8) & 0xff == 1; + let tree_size = 0x643a_3a3b_u32 + .wrapping_shl(state & 0x0b) + .wrapping_sub(state ^ 0x3b2b_f538) + .wrapping_add(gf32_mul_fixed(raw2)) as usize; + if segment_count == 0 || tree_size > 0x1b00 { + return invalid(format!( + "invalid container fields: segments={segment_count}, tree=0x{tree_size:x}" + )); + } + + let tree_start = start + .checked_add(12) + .ok_or_else(|| Error::Invalid("tree offset overflow".to_owned()))?; + let mut tree = range(data, tree_start, tree_size)?.to_vec(); + for offset in (0..tree_size & !3).step_by(4) { + let value = read_u32(&tree, offset)?; + tree[offset..offset + 4].copy_from_slice(&gf32_mul_fixed(value).to_le_bytes()); + } + let tree_state = state.wrapping_add(0xf1cb_5b81).wrapping_mul(state); + let tree_delta = tree_state.wrapping_sub(0x23b3_2203_u32.wrapping_mul(state)); + for (index, byte) in tree.iter_mut().enumerate() { + let shift = u32::try_from(index & 0x1b) + .map_err(|_| Error::Invalid("tree shift conversion failed".to_owned()))?; + let left = gf32_mul_fixed(tree_state.wrapping_shl(shift)); + let right = tree_delta.wrapping_shr((index & 0x17) as u32); + let adjustment = left.wrapping_sub(right).wrapping_shr((index & 0x1f) as u32); + *byte = byte.wrapping_add(adjustment as u8); + } + + let table_start = start + .checked_add(align_up(12 + tree_size, 4)?) + .ok_or_else(|| Error::Invalid("segment table offset overflow".to_owned()))?; + let table_size = segment_count + .checked_mul(8) + .ok_or_else(|| Error::Invalid("segment table size overflow".to_owned()))?; + let mut table = range(data, table_start, table_size)?.to_vec(); + let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state); + let table_xor = table_state.wrapping_shl(3); + let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state)); + for offset in (0..table_size).step_by(4) { + let value = read_u32(&table, offset)?; + let decoded = gf32_mul_fixed(value ^ table_xor) + .wrapping_add(table_add.wrapping_shr(((offset & 7) + 5) as u32)); + table[offset..offset + 4].copy_from_slice(&decoded.to_le_bytes()); + } + let mut segments = Vec::with_capacity(segment_count); + for index in 0..segment_count { + let offset = read_u32(&table, index * 8)?; + let size = read_u32(&table, index * 8 + 4)?; + let absolute = start + .checked_add(offset as usize) + .and_then(|value| value.checked_add(size as usize)); + if size == 0 || absolute.is_none_or(|end| end > data.len()) { + return invalid(format!("container segment {index} lies outside 0x9D")); + } + segments.push(EncodedSegment { offset, size }); + } + Ok(Self { + start, + output_size, + skip_aes, + tree, + segments, + }) + } + + /// End offset of the furthest encrypted segment. + pub fn encoded_end(&self) -> Result { + self.segments + .iter() + .map(|segment| { + self.start + .checked_add(segment.offset as usize) + .and_then(|value| value.checked_add(segment.size as usize)) + .ok_or_else(|| Error::Invalid("encoded segment end overflow".to_owned())) + }) + .collect::>>()? + .into_iter() + .max() + .ok_or_else(|| Error::Invalid("container has no encoded segments".to_owned())) + } +} + +/// Decoder for the protector's Huffman/LZ writer streams. +#[derive(Debug, Clone)] +pub struct HuffmanLzDecoder { + tree: Vec, + lookup_symbols: Vec, + lookup_bits: Vec, +} + +impl HuffmanLzDecoder { + /// Build the full 16-bit prefix lookup used by the static decoder. + pub fn new(tree: &[u8]) -> Result { + if tree.len() < 256 * 3 || tree.len() % 3 != 0 { + return invalid(format!("invalid Huffman tree size 0x{:x}", tree.len())); + } + let mut result = Self { + tree: tree.to_vec(), + lookup_symbols: vec![0; 0x1_0000], + lookup_bits: vec![0; 0x1_0000], + }; + for word in 0..0x1_0000_u32 { + let (symbol, bits) = result.decode_symbol(word)?; + if bits <= 16 { + result.lookup_symbols[word as usize] = symbol; + result.lookup_bits[word as usize] = bits; + } + } + Ok(result) + } + + fn entry(&self, index: usize) -> Result<(u16, bool, u8)> { + let offset = index + .checked_mul(3) + .ok_or_else(|| Error::Invalid("Huffman node offset overflow".to_owned()))?; + let bytes = range(&self.tree, offset, 3)?; + let raw = u16::from(bytes[0]) | (u16::from(bytes[1]) << 8); + Ok((raw & 0x7fff, raw & 0x8000 != 0, bytes[2])) + } + + fn decode_symbol(&self, word: u32) -> Result<(u16, u8)> { + let (mut value, leaf, extra) = self.entry((word & 0xff) as usize)?; + if leaf { + if extra == 0 { + return invalid("zero-width Huffman leaf"); + } + return Ok((value, extra)); + } + let mut bits = extra + .checked_add(1) + .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; + let mut mask = 1_u32.wrapping_shl(u32::from(extra)); + loop { + let branch = usize::from(word & mask != 0); + let (next, is_leaf, _) = self.entry(usize::from(value) + branch)?; + value = next; + if is_leaf { + return Ok((value, bits)); + } + mask = mask.wrapping_shl(1); + bits = bits + .checked_add(1) + .ok_or_else(|| Error::Invalid("Huffman bit count overflow".to_owned()))?; + if bits > 31 { + return invalid("Huffman code exceeds the native 32-bit window"); + } + } + } + + /// Decode one compressed writer payload to its exact expected size. + pub fn decode(&self, source: &[u8], output_size: usize) -> Result> { + let mut output = vec![0_u8; output_size]; + let mut source_pos = 0_usize; + let mut bit_buffer = 0_u64; + let mut available = 0_u8; + let mut consumed_bits = 0_usize; + let mut output_pos = 0_usize; + let mut prefix = 0_usize; + + while output_pos < output_size { + while available < 24 && source_pos < source.len() { + bit_buffer |= u64::from(source[source_pos]) << available; + source_pos += 1; + available += 8; + } + let key = (bit_buffer & 0xffff) as usize; + let mut bits = self.lookup_bits[key]; + let symbol = if bits != 0 { + self.lookup_symbols[key] + } else { + let mut value_offset = ((bit_buffer & 0xff) as usize) * 3; + let mut node = range(&self.tree, value_offset, 3)?; + let mut raw = u16::from(node[0]) | (u16::from(node[1]) << 8); + if raw & 0x8000 != 0 { + bits = node[2]; + raw & 0x7fff + } else { + let extra = node[2]; + bits = extra + 1; + let mut mask = 1_u64 << extra; + loop { + let branch = usize::from(bit_buffer & mask != 0); + let index = usize::from(raw & 0x7fff) + branch; + value_offset = index + .checked_mul(3) + .ok_or_else(|| Error::Invalid("Huffman node overflow".to_owned()))?; + node = range(&self.tree, value_offset, 3)?; + raw = u16::from(node[0]) | (u16::from(node[1]) << 8); + if raw & 0x8000 != 0 { + break raw & 0x7fff; + } + mask <<= 1; + bits += 1; + } + } + }; + if bits == 0 || bits > available { + return invalid("compressed stream ends inside a Huffman code"); + } + bit_buffer >>= bits; + available -= bits; + consumed_bits = consumed_bits + .checked_add(usize::from(bits)) + .ok_or_else(|| Error::Invalid("consumed bit count overflow".to_owned()))?; + + let kind = symbol & 0x300; + let value = usize::from(symbol & 0xff); + match kind { + 0 => { + output[output_pos] = value as u8; + output_pos += 1; + } + 0x100 => { + if prefix > 0xff { + return invalid("compressed prefix exceeds 16 bits"); + } + prefix = if prefix == 0 { + value + } else { + value | (prefix << 8) + }; + } + 0x200 => { + if prefix == 0 { + prefix = 1; + } + let count = value + .checked_mul(prefix) + .ok_or_else(|| Error::Invalid("repeat count overflow".to_owned()))?; + if !matches!(value, 1 | 2 | 4) + || value > output_pos + || output_pos + .checked_add(count) + .is_none_or(|end| end > output_size) + { + return invalid("invalid compressed repeated-pattern command"); + } + let pattern = output[output_pos - value..output_pos].to_vec(); + for chunk in output[output_pos..output_pos + count].chunks_exact_mut(value) { + chunk.copy_from_slice(&pattern); + } + output_pos += count; + prefix = 0; + } + 0x300 => { + let length = value; + let distance = prefix.checked_add(length).ok_or_else(|| { + Error::Invalid("back-reference distance overflow".to_owned()) + })?; + if distance > output_pos + || output_pos + .checked_add(length) + .is_none_or(|end| end > output_size) + { + return invalid("invalid compressed back-reference"); + } + let source_start = output_pos - distance; + output.copy_within(source_start..source_start + length, output_pos); + output_pos += length; + prefix = 0; + } + _ => unreachable!("masked Huffman symbol kind"), + } + } + if consumed_bits.div_ceil(8) != source.len() { + return invalid(format!( + "compressed input consumption mismatch: used=0x{:x}, size=0x{:x}", + consumed_bits.div_ceil(8), + source.len() + )); + } + Ok(output) + } +} + +/// Apply the native word transform and optional AES-256-CBC decryption. +pub fn transform_segment( + data: &[u8], + seed: u32, + aes_key: &[u8; 32], + decrypt_aes: bool, +) -> Result> { + let mut transformed = data.to_vec(); + let mut state = seed; + let mut left = 0xe34e_ac63_u32; + let mut right = 0x07b4_8238_u32; + for (index, chunk) in transformed.chunks_exact_mut(4).enumerate() { + let index32 = u32::try_from(index) + .map_err(|_| Error::Invalid("segment word index exceeds u32".to_owned()))?; + left = state + .wrapping_add(0x72f6_fcbe) + .wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left)) + .wrapping_shr(index32.wrapping_mul(index32) & 0x0f); + right = state + .wrapping_sub(0x71b6_a98d) + .wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right)) + .wrapping_shl(index32 & 7); + state = left ^ right; + let bytes: [u8; 4] = chunk + .try_into() + .map_err(|_| Error::Invalid("invalid transformed word".to_owned()))?; + let mut value = u32::from_le_bytes(bytes); + value = value.wrapping_add(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d)); + value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3); + value = value.wrapping_sub(state) ^ state; + chunk.copy_from_slice(&value.to_le_bytes()); + } + + if decrypt_aes { + let cipher = Aes256::new_from_slice(aes_key) + .map_err(|_| Error::Invalid("invalid AES-256 key length".to_owned()))?; + let aligned_size = transformed.len() & !0x0f; + let mut previous = [0_u8; 16]; + for chunk in transformed[..aligned_size].chunks_exact_mut(16) { + let mut ciphertext = [0_u8; 16]; + ciphertext.copy_from_slice(chunk); + cipher.decrypt_block(Block::::from_mut_slice(chunk)); + for (byte, prior) in chunk.iter_mut().zip(previous) { + *byte ^= prior; + } + previous = ciphertext; + } + } + Ok(transformed) +} + +/// Decode one complete protector container into its flat output buffer. +/// +/// This is the static equivalent of the decoder entrypoint embedded in Stage +/// 2 and in each nested interpreter module. +pub fn decode_container( + data: &[u8], + config: &Module9bConfig, + expected_size: usize, +) -> Result> { + let header = ContainerHeader::parse(data, 0, config.container_seed)?; + let header_size = usize::try_from(header.output_size) + .map_err(|_| Error::Invalid("container output size exceeds usize".to_owned()))?; + if header_size != expected_size { + return invalid(format!( + "container output size 0x{header_size:x} != expected 0x{expected_size:x}" + )); + } + let decoder = HuffmanLzDecoder::new(&header.tree)?; + let decrypt_aes = !(config.skip_aes || header.skip_aes); + let mut output = vec![0_u8; expected_size]; + + for (segment_index, encoded) in header.segments.iter().enumerate() { + let start = header + .start + .checked_add(encoded.offset as usize) + .ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?; + let encoded_data = range(data, start, encoded.size as usize)?; + let transformed = transform_segment( + encoded_data, + config.container_seed, + &config.aes_key, + decrypt_aes, + )?; + if transformed.len() < 16 { + return invalid(format!( + "decoded segment {segment_index} is shorter than its header" + )); + } + let base_offset = read_u32(&transformed, 0)? as usize; + let writer_count = read_u32(&transformed, 4)? as usize; + let table_offset = read_u32(&transformed, 8)? as usize; + let data_offset = read_u32(&transformed, 12)? as usize; + let table_size = writer_count + .checked_mul(16) + .ok_or_else(|| Error::Invalid("writer table size overflow".to_owned()))?; + let table_end = table_offset + .checked_add(table_size) + .ok_or_else(|| Error::Invalid("writer table end overflow".to_owned()))?; + if table_end > transformed.len() || data_offset > transformed.len() { + return invalid(format!( + "decoded segment {segment_index} has invalid writer offsets" + )); + } + + let mut data_cursor = data_offset; + for writer_index in 0..writer_count { + let record = + table_offset + .checked_add(writer_index.checked_mul(16).ok_or_else(|| { + Error::Invalid("writer record offset overflow".to_owned()) + })?) + .ok_or_else(|| Error::Invalid("writer record offset overflow".to_owned()))?; + let output_offset = read_u32(&transformed, record)? as usize; + let output_size = read_u32(&transformed, record + 4)? as usize; + let encoded_size = read_u32(&transformed, record + 8)? as usize; + let reserved = read_u32(&transformed, record + 12)?; + let encoded_end = data_cursor + .checked_add(encoded_size) + .ok_or_else(|| Error::Invalid("writer data end overflow".to_owned()))?; + if reserved != 0 || encoded_end > transformed.len() { + return invalid(format!( + "segment {segment_index} writer {writer_index} has invalid bounds" + )); + } + let source = &transformed[data_cursor..encoded_end]; + let decoded = if encoded_size == output_size { + None + } else { + Some(decoder.decode(source, output_size)?) + }; + let decoded = decoded.as_deref().unwrap_or(source); + let target = base_offset + .checked_add(output_offset) + .ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?; + let target_end = target + .checked_add(decoded.len()) + .ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?; + let destination = output.get_mut(target..target_end).ok_or_else(|| { + Error::Invalid(format!( + "segment {segment_index} writer {writer_index} target is out of range" + )) + })?; + destination.copy_from_slice(decoded); + data_cursor = encoded_end; + } + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aes_mix_columns_matches_fips_example() { + let input = [ + 0xdb, 0x13, 0x53, 0x45, 0xf2, 0x0a, 0x22, 0x5c, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, + 0xc6, 0xc6, + ]; + assert_eq!( + mix_columns(input), + [ + 0x8e, 0x4d, 0xa1, 0xbc, 0x9f, 0xdc, 0x58, 0x9d, 0x01, 0x01, 0x01, 0x01, 0xc6, 0xc6, + 0xc6, 0xc6, + ] + ); + } + + #[test] + fn descriptor_rejects_truncated_input() { + assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err()); + } +} diff --git a/senbei-android-engine/src/lib.rs b/senbei-android-engine/src/lib.rs index 780215d..386eb55 100644 --- a/senbei-android-engine/src/lib.rs +++ b/senbei-android-engine/src/lib.rs @@ -2,31 +2,13 @@ mod error; mod extract; +mod probe; mod report; mod stage1; mod stream; pub use error::Error; pub use extract::{ExtractOptions, extract_stage2}; +pub use probe::is_protected_libil2cpp; pub use report::ExtractionReport; pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE}; - -/// Return whether `data` has the protected AArch64 Stage 1 section layout. -/// -/// This is a cheap, read-only probe used by folder mode to distinguish the -/// protected target from ordinary Unity libraries before invoking extraction. -#[must_use] -pub fn is_protected_libil2cpp(data: &[u8]) -> bool { - if !stage1::looks_protected(data) { - return false; - } - let Ok(stage1) = stage1::inspect( - data, - std::path::Path::new(""), - DEFAULT_OUTER_SIZE, - DEFAULT_CIPHER_CONSTANT, - ) else { - return false; - }; - senbei_android_crypto::Module9bConfig::parse_embedded(&stage1.plaintext).is_ok() -} diff --git a/senbei-android-engine/src/probe.rs b/senbei-android-engine/src/probe.rs new file mode 100644 index 0000000..73c250d --- /dev/null +++ b/senbei-android-engine/src/probe.rs @@ -0,0 +1,22 @@ +use std::path::Path; + +use senbei_android_crypto::Module9bConfig; + +use crate::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE}; + +/// Return whether `data` has a supported protected AArch64 IL2CPP layout. +#[must_use] +pub fn is_protected_libil2cpp(data: &[u8]) -> bool { + if !stage1::looks_protected(data) { + return false; + } + let Ok(stage1) = stage1::inspect( + data, + Path::new(""), + DEFAULT_OUTER_SIZE, + DEFAULT_CIPHER_CONSTANT, + ) else { + return false; + }; + Module9bConfig::parse_embedded(&stage1.plaintext).is_ok() +} diff --git a/senbei-android-io/src/jobs.rs b/senbei-android-io/src/jobs.rs new file mode 100644 index 0000000..f67859d --- /dev/null +++ b/senbei-android-io/src/jobs.rs @@ -0,0 +1,192 @@ +//! File-oriented restoration jobs and atomic output helpers. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use senbei_android_elf::{RestoreOptions, RestoreReport, restore_libil2cpp}; +use senbei_android_engine::{ + DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, ExtractOptions, ExtractionReport, extract_stage2, +}; +use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; +use serde::Serialize; +use tempfile::NamedTempFile; + +/// Filesystem arguments for restoring one protected `libil2cpp.so`. +#[derive(Debug, Clone)] +pub struct RestoreSoJob { + pub input: PathBuf, + pub output: PathBuf, + pub index: Option, + pub report: Option, + pub dump_auxiliary: Option, + pub outer_only: bool, + pub preserve_entrypoint: bool, +} + +/// Filesystem arguments for restoring one `global-metadata.dat`. +#[derive(Debug, Clone)] +pub struct RestoreMetadataJob { + pub input: PathBuf, + pub output: PathBuf, + pub seed: u32, + pub report: Option, +} + +/// Filesystem arguments for pure-static Stage 1 and Stage 2 extraction. +#[derive(Debug, Clone)] +pub struct ExtractStage2Job { + pub input: PathBuf, + pub output_dir: PathBuf, + pub stage2_output: Option, + pub outer_size: usize, + pub cipher_constant: u32, +} + +impl ExtractStage2Job { + #[must_use] + pub fn new(input: PathBuf, output_dir: PathBuf) -> Self { + Self { + input, + output_dir, + stage2_output: None, + outer_size: DEFAULT_OUTER_SIZE, + cipher_constant: DEFAULT_CIPHER_CONSTANT, + } + } +} + +impl RestoreMetadataJob { + #[must_use] + pub fn new(input: PathBuf, output: PathBuf) -> Self { + Self { + input, + output, + seed: DEFAULT_METHOD_TOKEN_SEED, + report: None, + } + } +} + +/// Infer the Stage 2 module index produced for `libil2cpp.so`. +#[must_use] +pub fn default_module_index(input: &Path) -> PathBuf { + input + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("libil2cpp_stage2_modules") + .join("index.json") +} + +/// Run static SO restoration and optionally emit its JSON report. +pub fn run_restore_so(job: &RestoreSoJob) -> Result { + refuse_in_place(&job.input, &job.output)?; + let options = RestoreOptions { + input: job.input.clone(), + output: job.output.clone(), + index: job + .index + .clone() + .unwrap_or_else(|| default_module_index(&job.input)), + dump_auxiliary: job.dump_auxiliary.clone(), + outer_only: job.outer_only, + preserve_entrypoint: job.preserve_entrypoint, + }; + let result = restore_libil2cpp(&options).context("restore protected libil2cpp.so")?; + if let Some(path) = &job.report { + write_json_atomic(path, &result)?; + } + Ok(result) +} + +/// Restore MethodDef tokens and atomically write the cleaned metadata. +pub fn run_restore_metadata(job: &RestoreMetadataJob) -> Result { + refuse_in_place(&job.input, &job.output)?; + let input = + std::fs::read(&job.input).with_context(|| format!("read `{}`", job.input.display()))?; + let (output, result) = senbei_android_metadata::restore_method_tokens(&input, job.seed) + .with_context(|| format!("restore `{}`", job.input.display()))?; + write_atomic(&job.output, &output)?; + if let Some(path) = &job.report { + write_json_atomic(path, &result)?; + } + Ok(result) +} + +/// Extract Stage 2 modules directly from one protected ELF. +pub fn run_extract_stage2(job: &ExtractStage2Job) -> Result { + extract_stage2(&ExtractOptions { + input: job.input.clone(), + output_dir: job.output_dir.clone(), + stage2_output: job.stage2_output.clone(), + outer_size: job.outer_size, + cipher_constant: job.cipher_constant, + }) + .context("extract protected Stage 1/Stage 2 payload") +} + +fn refuse_in_place(input: &Path, output: &Path) -> Result<()> { + let input_absolute = absolute(input)?; + let output_absolute = absolute(output)?; + let same_existing_file = + output.exists() && std::fs::canonicalize(input).ok() == std::fs::canonicalize(output).ok(); + if input_absolute == output_absolute || same_existing_file { + bail!( + "refusing to overwrite input in place: `{}`", + input.display() + ); + } + Ok(()) +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir() + .context("query current directory")? + .join(path)) + } +} + +fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { + let mut data = serde_json::to_vec_pretty(value).context("serialize JSON report")?; + data.push(b'\n'); + write_atomic(path, &data) +} + +fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("create output directory `{}`", parent.display()))?; + let mut temporary = NamedTempFile::new_in(parent) + .with_context(|| format!("create temporary file in `{}`", parent.display()))?; + temporary + .write_all(data) + .and_then(|()| temporary.as_file().sync_all()) + .with_context(|| format!("write temporary output for `{}`", path.display()))?; + temporary + .persist(path) + .map_err(|error| error.error) + .with_context(|| format!("replace output `{}`", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_index_next_to_input() { + assert_eq!( + default_module_index(Path::new(r"C:\game\Native\libil2cpp.so")), + PathBuf::from(r"C:\game\Native\libil2cpp_stage2_modules\index.json") + ); + } + + #[test] + fn metadata_job_uses_current_seed() { + let job = RestoreMetadataJob::new(PathBuf::from("in"), PathBuf::from("out")); + assert_eq!(job.seed, DEFAULT_METHOD_TOKEN_SEED); + } +} diff --git a/senbei-android-io/src/lib.rs b/senbei-android-io/src/lib.rs index 34168fe..2128d55 100644 --- a/senbei-android-io/src/lib.rs +++ b/senbei-android-io/src/lib.rs @@ -1,196 +1,10 @@ -//! Filesystem orchestration for the Android restoration commands. +//! Filesystem orchestration interfaces for Senbei Android. mod folder; +mod jobs; pub use folder::{FolderSummary, run_folder}; - -use std::io::Write; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result, bail}; -use senbei_android_elf::{RestoreOptions, RestoreReport, restore_libil2cpp}; -use senbei_android_engine::{ - DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, ExtractOptions, ExtractionReport, extract_stage2, +pub use jobs::{ + ExtractStage2Job, RestoreMetadataJob, RestoreSoJob, default_module_index, run_extract_stage2, + run_restore_metadata, run_restore_so, }; -use senbei_android_metadata::{DEFAULT_METHOD_TOKEN_SEED, Report as MetadataReport}; -use serde::Serialize; -use tempfile::NamedTempFile; - -/// Filesystem arguments for restoring one protected `libil2cpp.so`. -#[derive(Debug, Clone)] -pub struct RestoreSoJob { - pub input: PathBuf, - pub output: PathBuf, - pub index: Option, - pub report: Option, - pub dump_auxiliary: Option, - pub outer_only: bool, - pub preserve_entrypoint: bool, -} - -/// Filesystem arguments for restoring one `global-metadata.dat`. -#[derive(Debug, Clone)] -pub struct RestoreMetadataJob { - pub input: PathBuf, - pub output: PathBuf, - pub seed: u32, - pub report: Option, -} - -/// Filesystem arguments for pure-static Stage 1 and Stage 2 extraction. -#[derive(Debug, Clone)] -pub struct ExtractStage2Job { - pub input: PathBuf, - pub output_dir: PathBuf, - pub stage2_output: Option, - pub outer_size: usize, - pub cipher_constant: u32, -} - -impl ExtractStage2Job { - #[must_use] - pub fn new(input: PathBuf, output_dir: PathBuf) -> Self { - Self { - input, - output_dir, - stage2_output: None, - outer_size: DEFAULT_OUTER_SIZE, - cipher_constant: DEFAULT_CIPHER_CONSTANT, - } - } -} - -impl RestoreMetadataJob { - #[must_use] - pub fn new(input: PathBuf, output: PathBuf) -> Self { - Self { - input, - output, - seed: DEFAULT_METHOD_TOKEN_SEED, - report: None, - } - } -} - -/// Infer the Stage 2 module index produced for `libil2cpp.so`. -#[must_use] -pub fn default_module_index(input: &Path) -> PathBuf { - input - .parent() - .unwrap_or_else(|| Path::new(".")) - .join("libil2cpp_stage2_modules") - .join("index.json") -} - -/// Run static SO restoration and optionally emit its JSON report. -pub fn run_restore_so(job: &RestoreSoJob) -> Result { - refuse_in_place(&job.input, &job.output)?; - let options = RestoreOptions { - input: job.input.clone(), - output: job.output.clone(), - index: job - .index - .clone() - .unwrap_or_else(|| default_module_index(&job.input)), - dump_auxiliary: job.dump_auxiliary.clone(), - outer_only: job.outer_only, - preserve_entrypoint: job.preserve_entrypoint, - }; - let result = restore_libil2cpp(&options).context("restore protected libil2cpp.so")?; - if let Some(path) = &job.report { - write_json_atomic(path, &result)?; - } - Ok(result) -} - -/// Restore MethodDef tokens and atomically write the cleaned metadata. -pub fn run_restore_metadata(job: &RestoreMetadataJob) -> Result { - refuse_in_place(&job.input, &job.output)?; - let input = - std::fs::read(&job.input).with_context(|| format!("read `{}`", job.input.display()))?; - let (output, result) = senbei_android_metadata::restore_method_tokens(&input, job.seed) - .with_context(|| format!("restore `{}`", job.input.display()))?; - write_atomic(&job.output, &output)?; - if let Some(path) = &job.report { - write_json_atomic(path, &result)?; - } - Ok(result) -} - -/// Extract Stage 2 modules directly from one protected ELF. -pub fn run_extract_stage2(job: &ExtractStage2Job) -> Result { - extract_stage2(&ExtractOptions { - input: job.input.clone(), - output_dir: job.output_dir.clone(), - stage2_output: job.stage2_output.clone(), - outer_size: job.outer_size, - cipher_constant: job.cipher_constant, - }) - .context("extract protected Stage 1/Stage 2 payload") -} - -fn refuse_in_place(input: &Path, output: &Path) -> Result<()> { - let input_absolute = absolute(input)?; - let output_absolute = absolute(output)?; - let same_existing_file = - output.exists() && std::fs::canonicalize(input).ok() == std::fs::canonicalize(output).ok(); - if input_absolute == output_absolute || same_existing_file { - bail!( - "refusing to overwrite input in place: `{}`", - input.display() - ); - } - Ok(()) -} - -fn absolute(path: &Path) -> Result { - if path.is_absolute() { - Ok(path.to_path_buf()) - } else { - Ok(std::env::current_dir() - .context("query current directory")? - .join(path)) - } -} - -fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { - let mut data = serde_json::to_vec_pretty(value).context("serialize JSON report")?; - data.push(b'\n'); - write_atomic(path, &data) -} - -fn write_atomic(path: &Path, data: &[u8]) -> Result<()> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - std::fs::create_dir_all(parent) - .with_context(|| format!("create output directory `{}`", parent.display()))?; - let mut temporary = NamedTempFile::new_in(parent) - .with_context(|| format!("create temporary file in `{}`", parent.display()))?; - temporary - .write_all(data) - .and_then(|()| temporary.as_file().sync_all()) - .with_context(|| format!("write temporary output for `{}`", path.display()))?; - temporary - .persist(path) - .map_err(|error| error.error) - .with_context(|| format!("replace output `{}`", path.display()))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn derives_index_next_to_input() { - assert_eq!( - default_module_index(Path::new(r"C:\game\Native\libil2cpp.so")), - PathBuf::from(r"C:\game\Native\libil2cpp_stage2_modules\index.json") - ); - } - - #[test] - fn metadata_job_uses_current_seed() { - let job = RestoreMetadataJob::new(PathBuf::from("in"), PathBuf::from("out")); - assert_eq!(job.seed, DEFAULT_METHOD_TOKEN_SEED); - } -} diff --git a/senbei-android-metadata/src/lib.rs b/senbei-android-metadata/src/lib.rs index 1a13817..e6741d8 100644 --- a/senbei-android-metadata/src/lib.rs +++ b/senbei-android-metadata/src/lib.rs @@ -1,659 +1,8 @@ -//! Static restoration of protected IL2CPP v31 method tokens. +//! Static IL2CPP metadata restoration interfaces. -use serde::Serialize; +mod method_tokens; -/// Seed embedded in the current `libil2cpp` module `0x0C`. -pub const DEFAULT_METHOD_TOKEN_SEED: u32 = 0xa6fa_e968; - -const MAGIC: u32 = 0xfab1_1baf; -const SUPPORTED_VERSION: u32 = 31; -const HDR_METHODS: usize = 0x30; -const HDR_TYPES: usize = 0xa0; -const HDR_IMAGES: usize = 0xa8; -const METHOD_STRIDE: usize = 0x24; -const METHOD_TOKEN_OFFSET: usize = 0x18; -const TYPE_STRIDE: usize = 0x58; -const TYPE_METHOD_START_OFFSET: usize = 0x24; -const TYPE_METHOD_COUNT_OFFSET: usize = 0x40; -const IMAGE_STRIDE: usize = 0x28; -const IMAGE_TYPE_START_OFFSET: usize = 0x08; -const IMAGE_TYPE_COUNT_OFFSET: usize = 0x0c; -const METHOD_TOKEN_TABLE: u32 = 0x0600_0000; - -/// Summary of one metadata restoration pass. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct Report { - pub version: u32, - pub seed: String, - pub encryption_status: String, - pub images: usize, - pub images_with_methods: usize, - pub types: usize, - pub methods: usize, - pub visited_methods: usize, - pub already_correct_before: usize, - pub correct_after: usize, - pub changed_tokens: usize, - pub transformed_images: usize, -} - -/// Per-image constraints recovered from the encrypted MethodDef RID -/// permutation. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct ImageKeyDiscovery { - pub image: usize, - pub method_count: u32, - pub modulus: u32, - pub clean: bool, - pub seed_residues: Vec, -} - -/// Result of statically testing the known five-round permutation against a -/// metadata file without assuming a seed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct SeedDiscoveryReport { - pub version: u32, - pub images: Vec, - pub seed_candidates: Vec, -} - -/// Metadata parsing or validation failure. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum Error { - #[error("not an IL2CPP global-metadata.dat")] - NotMetadata, - #[error("unsupported metadata version {0}")] - UnsupportedVersion(u32), - #[error("malformed metadata: {0}")] - Malformed(String), - #[error("method-token restoration failed: {0}")] - Validation(String), -} - -type Result = std::result::Result; - -fn malformed(message: impl Into) -> Result { - Err(Error::Malformed(message.into())) -} - -fn validation(message: impl Into) -> Result { - Err(Error::Validation(message.into())) -} - -fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { - let end = offset - .checked_add(size) - .ok_or_else(|| Error::Malformed("byte range overflow".to_owned()))?; - data.get(offset..end).ok_or_else(|| { - Error::Malformed(format!( - "byte range 0x{offset:x}..0x{end:x} is out of bounds" - )) - }) -} - -fn read_u16(data: &[u8], offset: usize) -> Result { - let value: [u8; 2] = bytes(data, offset, 2)? - .try_into() - .map_err(|_| Error::Malformed("invalid u16 range".to_owned()))?; - Ok(u16::from_le_bytes(value)) -} - -fn read_u32(data: &[u8], offset: usize) -> Result { - let value: [u8; 4] = bytes(data, offset, 4)? - .try_into() - .map_err(|_| Error::Malformed("invalid u32 range".to_owned()))?; - Ok(u32::from_le_bytes(value)) -} - -fn read_i32(data: &[u8], offset: usize) -> Result { - let value: [u8; 4] = bytes(data, offset, 4)? - .try_into() - .map_err(|_| Error::Malformed("invalid i32 range".to_owned()))?; - Ok(i32::from_le_bytes(value)) -} - -fn table(data: &[u8], header_offset: usize) -> Result<(usize, usize)> { - let offset = read_u32(data, header_offset)? as usize; - let size = read_u32(data, header_offset + 4)? as usize; - bytes(data, offset, size)?; - Ok((offset, size)) -} - -#[inline] -fn inverse_round(mut value: u32, count: u32, key: u32) -> u32 { - let mirror = count.wrapping_mul(2).wrapping_sub(1); - if value & 1 != 0 { - value = mirror.wrapping_sub(value); - } - value >>= 1; - if value >= count { - value = mirror.wrapping_sub(value); - } - let value = value.wrapping_sub(key); - if value > count { - value.wrapping_add(count) - } else { - value - } -} - -fn decrypt_rid(rid: u32, low: u32, high: u32, seed: u32) -> Result { - let count = high - .checked_add(1) - .and_then(|value| value.checked_sub(low)) - .ok_or_else(|| Error::Validation("invalid image RID interval".to_owned()))?; - if count < 2 { - return validation("RID inverse permutation requires at least two entries"); - } - let half = count / 2; - if half == 0 { - return validation("RID inverse permutation has a zero divisor"); - } - let key = seed % half + count / 4; - let mut value = rid - .checked_sub(low) - .ok_or_else(|| Error::Validation("encrypted RID lies below image minimum".to_owned()))?; - for _ in 0..5 { - value = inverse_round(value, count, key); - } - value - .checked_add(low) - .ok_or_else(|| Error::Validation("restored RID overflow".to_owned())) -} - -/// Restore MethodDef RID values exactly as module `0x0C` does. -/// -/// The operation is idempotent for tooling purposes: an image whose tokens are -/// already canonical is detected and left untouched instead of applying the -/// native inverse permutation a second time. -pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report)> { - if read_u32(data, 0).ok() != Some(MAGIC) { - return Err(Error::NotMetadata); - } - let version = read_u32(data, 4)?; - if version != SUPPORTED_VERSION { - return Err(Error::UnsupportedVersion(version)); - } - - let (method_offset, method_size) = table(data, HDR_METHODS)?; - let (type_offset, type_size) = table(data, HDR_TYPES)?; - let (image_offset, image_size) = table(data, HDR_IMAGES)?; - if method_size % METHOD_STRIDE != 0 - || type_size % TYPE_STRIDE != 0 - || image_size % IMAGE_STRIDE != 0 - { - return malformed("v31 table size is not divisible by its entry stride"); - } - let method_count = method_size / METHOD_STRIDE; - let type_count = type_size / TYPE_STRIDE; - let image_count = image_size / IMAGE_STRIDE; - let mut owners = vec![u32::MAX; method_count]; - let mut output = data.to_vec(); - let mut images_with_methods = 0_usize; - let mut visited_methods = 0_usize; - let mut already_correct_before = 0_usize; - let mut correct_after = 0_usize; - let mut changed_tokens = 0_usize; - let mut transformed_images = 0_usize; - - for image_index in 0..image_count { - let image_base = image_offset + image_index * IMAGE_STRIDE; - let type_start = read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?; - let type_start = usize::try_from(type_start) - .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; - let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; - let type_end = type_start - .checked_add(type_entries) - .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; - if type_end > type_count { - return malformed(format!("image {image_index} type range exceeds the table")); - } - - let mut methods = Vec::new(); - for type_index in type_start..type_end { - let type_base = type_offset + type_index * TYPE_STRIDE; - let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; - if method_entries == 0 { - continue; - } - let method_start = read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?; - let method_start = usize::try_from(method_start).map_err(|_| { - Error::Malformed(format!( - "type {type_index} has methods but negative methodStart" - )) - })?; - let method_end = method_start - .checked_add(method_entries) - .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; - if method_end > method_count { - return malformed(format!("type {type_index} method range exceeds the table")); - } - for (method_index, owner) in owners - .iter_mut() - .enumerate() - .take(method_end) - .skip(method_start) - { - if *owner != u32::MAX { - return malformed(format!("method {method_index} belongs to multiple images")); - } - *owner = u32::try_from(image_index) - .map_err(|_| Error::Malformed("image index exceeds u32".to_owned()))?; - methods.push(method_index); - } - } - if methods.is_empty() { - continue; - } - images_with_methods += 1; - visited_methods += methods.len(); - let method_base = *methods - .iter() - .min() - .ok_or_else(|| Error::Malformed("nonempty image lost its method minimum".to_owned()))?; - let method_last = *methods - .iter() - .max() - .ok_or_else(|| Error::Malformed("nonempty image lost its method maximum".to_owned()))?; - if method_last - method_base + 1 != methods.len() { - return malformed(format!( - "image {image_index} method block is not contiguous" - )); - } - - let mut tokens = Vec::with_capacity(methods.len()); - let mut image_already_clean = true; - for &method_index in &methods { - let token_offset = method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET; - let token = read_u32(data, token_offset)?; - if token & 0xff00_0000 != METHOD_TOKEN_TABLE { - return malformed(format!( - "method {method_index} has non-MethodDef token 0x{token:08x}" - )); - } - let expected = u32::try_from(method_index - method_base + 1) - .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; - let rid = token & 0x00ff_ffff; - if rid == expected { - already_correct_before += 1; - } else { - image_already_clean = false; - } - tokens.push((method_index, token_offset, token, expected)); - } - - if image_already_clean { - correct_after += tokens.len(); - continue; - } - transformed_images += 1; - let low = tokens - .iter() - .map(|(_, _, token, _)| token & 0x00ff_ffff) - .min() - .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; - let high = tokens - .iter() - .map(|(_, _, token, _)| token & 0x00ff_ffff) - .max() - .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; - if high <= 1 { - return validation(format!( - "image {image_index} is noncanonical but native R > 1 gate would skip it" - )); - } - let interval = high - low + 1; - if interval as usize != tokens.len() { - return validation(format!( - "image {image_index} RID interval {low}..={high} is not a permutation" - )); - } - for (method_index, token_offset, token, expected) in tokens { - let restored_rid = decrypt_rid(token & 0x00ff_ffff, low, high, seed)?; - if restored_rid != expected { - return validation(format!( - "method {method_index} restored RID {restored_rid} != expected {expected}" - )); - } - let restored_token = METHOD_TOKEN_TABLE | restored_rid; - if restored_token != token { - output[token_offset..token_offset + 4] - .copy_from_slice(&restored_token.to_le_bytes()); - changed_tokens += 1; - } - correct_after += 1; - } - } - - if owners.contains(&u32::MAX) { - return malformed("one or more method definitions are not owned by an image"); - } - if visited_methods != method_count || correct_after != method_count { - return validation(format!( - "method coverage mismatch: visited={visited_methods}, correct={correct_after}, total={method_count}" - )); - } - - Ok(( - output, - Report { - version, - seed: format!("0x{seed:08X}"), - encryption_status: if changed_tokens == 0 { - "clean".to_owned() - } else { - "encrypted".to_owned() - }, - images: image_count, - images_with_methods, - types: type_count, - methods: method_count, - visited_methods, - already_correct_before, - correct_after, - changed_tokens, - transformed_images, - }, - )) -} - -/// Discover seeds compatible with the known v31 five-round RID permutation. -/// -/// This is diagnostic and does not modify metadata. It enumerates the only -/// possible per-image key residues and intersects them over the 32-bit seed -/// domain. An empty candidate list means that the sample changed the -/// permutation itself rather than merely embedding a different seed. -pub fn discover_method_token_seeds(data: &[u8]) -> Result { - if read_u32(data, 0).ok() != Some(MAGIC) { - return Err(Error::NotMetadata); - } - let version = read_u32(data, 4)?; - if version != SUPPORTED_VERSION { - return Ok(SeedDiscoveryReport { - version, - images: Vec::new(), - seed_candidates: Vec::new(), - }); - } - let (method_offset, method_size) = table(data, HDR_METHODS)?; - let (type_offset, type_size) = table(data, HDR_TYPES)?; - let (image_offset, image_size) = table(data, HDR_IMAGES)?; - if method_size % METHOD_STRIDE != 0 - || type_size % TYPE_STRIDE != 0 - || image_size % IMAGE_STRIDE != 0 - { - return malformed("v31 table size is not divisible by its entry stride"); - } - let method_count = method_size / METHOD_STRIDE; - let type_count = type_size / TYPE_STRIDE; - let image_count = image_size / IMAGE_STRIDE; - let mut reports = Vec::with_capacity(image_count); - for image_index in 0..image_count { - let image_base = image_offset + image_index * IMAGE_STRIDE; - let type_start = usize::try_from(read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?) - .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; - let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; - let type_end = type_start - .checked_add(type_entries) - .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; - if type_end > type_count { - return malformed(format!("image {image_index} type range exceeds the table")); - } - let mut methods = Vec::new(); - for type_index in type_start..type_end { - let type_base = type_offset + type_index * TYPE_STRIDE; - let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; - if method_entries == 0 { - continue; - } - let method_start = - usize::try_from(read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?).map_err( - |_| Error::Malformed(format!("type {type_index} has negative methodStart")), - )?; - let method_end = method_start - .checked_add(method_entries) - .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; - if method_end > method_count { - return malformed(format!("type {type_index} method range exceeds the table")); - } - methods.extend(method_start..method_end); - } - if methods.is_empty() { - reports.push(ImageKeyDiscovery { - image: image_index, - method_count: 0, - modulus: 0, - clean: true, - seed_residues: Vec::new(), - }); - continue; - } - let method_base = *methods - .iter() - .min() - .ok_or_else(|| Error::Malformed("image method minimum is missing".to_owned()))?; - let method_last = *methods - .iter() - .max() - .ok_or_else(|| Error::Malformed("image method maximum is missing".to_owned()))?; - if method_last - method_base + 1 != methods.len() { - return validation(format!( - "image {image_index} method block is not contiguous" - )); - } - let mut values = Vec::with_capacity(methods.len()); - let mut clean = true; - for method_index in methods { - let token = read_u32( - data, - method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, - )?; - if token & 0xff00_0000 != METHOD_TOKEN_TABLE { - return validation(format!( - "method {method_index} has non-MethodDef token 0x{token:08x}" - )); - } - let expected = u32::try_from(method_index - method_base + 1) - .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; - let rid = token & 0x00ff_ffff; - clean &= rid == expected; - values.push((rid, expected)); - } - let count = u32::try_from(values.len()) - .map_err(|_| Error::Validation("image method count exceeds u32".to_owned()))?; - if clean { - reports.push(ImageKeyDiscovery { - image: image_index, - method_count: count, - modulus: count / 2, - clean, - seed_residues: Vec::new(), - }); - continue; - } - let low = values - .iter() - .map(|(rid, _)| *rid) - .min() - .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; - let high = values - .iter() - .map(|(rid, _)| *rid) - .max() - .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; - if high - low + 1 != count || count < 2 { - return validation(format!( - "image {image_index} RID interval is not a permutation" - )); - } - let half = count / 2; - let quarter = count / 4; - let mut residues = Vec::new(); - for key_delta in 0..half { - let key = quarter + key_delta; - let valid = values - .iter() - .all(|(rid, expected)| decrypt_rid_with_key(*rid, low, high, key) == *expected); - if valid { - residues.push(key_delta); - } - } - reports.push(ImageKeyDiscovery { - image: image_index, - method_count: count, - modulus: half, - clean, - seed_residues: residues, - }); - } - - let constraints = reports - .iter() - .filter(|report| !report.clean) - .collect::>(); - let mut seeds = Vec::new(); - if let Some(anchor) = constraints.iter().max_by_key(|report| report.modulus) { - for &residue in &anchor.seed_residues { - let mut candidate = u64::from(residue); - let modulus = u64::from(anchor.modulus); - while candidate <= u64::from(u32::MAX) { - let valid = constraints.iter().all(|report| { - report.modulus != 0 - && !report.seed_residues.is_empty() - && report - .seed_residues - .iter() - .any(|&value| candidate % u64::from(report.modulus) == u64::from(value)) - }); - if valid { - seeds.push(candidate as u32); - } - candidate = candidate.saturating_add(modulus); - } - } - } - seeds.sort_unstable(); - seeds.dedup(); - Ok(SeedDiscoveryReport { - version, - images: reports, - seed_candidates: seeds, - }) -} - -fn decrypt_rid_with_key(rid: u32, low: u32, high: u32, key: u32) -> u32 { - let count = high - low + 1; - let mut value = rid - low; - for _ in 0..5 { - value = inverse_round(value, count, key); - } - value + low -} - -#[cfg(test)] -mod tests { - use super::*; - - fn put_u16(data: &mut [u8], offset: usize, value: u16) { - data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); - } - - fn put_u32(data: &mut [u8], offset: usize, value: u32) { - data[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); - } - - fn encrypted_rid(expected: u32, count: u32, seed: u32) -> u32 { - (1..=count) - .find(|&candidate| decrypt_rid(candidate, 1, count, seed) == Ok(expected)) - .expect("inverse permutation must be bijective") - } - - fn build(tokens: &[u32]) -> (Vec, usize) { - let header_size = 0x100; - let images = header_size; - let types = images + IMAGE_STRIDE; - let methods = types + 2 * TYPE_STRIDE; - let mut data = vec![0_u8; methods + tokens.len() * METHOD_STRIDE]; - put_u32(&mut data, 0, MAGIC); - put_u32(&mut data, 4, SUPPORTED_VERSION); - put_u32(&mut data, HDR_METHODS, methods as u32); - put_u32( - &mut data, - HDR_METHODS + 4, - (tokens.len() * METHOD_STRIDE) as u32, - ); - put_u32(&mut data, HDR_TYPES, types as u32); - put_u32(&mut data, HDR_TYPES + 4, (2 * TYPE_STRIDE) as u32); - put_u32(&mut data, HDR_IMAGES, images as u32); - put_u32(&mut data, HDR_IMAGES + 4, IMAGE_STRIDE as u32); - put_u32(&mut data, images + IMAGE_TYPE_START_OFFSET, 0); - put_u32(&mut data, images + IMAGE_TYPE_COUNT_OFFSET, 2); - // Deliberately traverse the high method indices first. - put_u32(&mut data, types + TYPE_METHOD_START_OFFSET, 4); - put_u16(&mut data, types + TYPE_METHOD_COUNT_OFFSET, 3); - put_u32(&mut data, types + TYPE_STRIDE + TYPE_METHOD_START_OFFSET, 0); - put_u16(&mut data, types + TYPE_STRIDE + TYPE_METHOD_COUNT_OFFSET, 4); - for (index, &token) in tokens.iter().enumerate() { - put_u32( - &mut data, - methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, - token, - ); - } - (data, methods) - } - - #[test] - fn restores_five_round_permutation_by_physical_method_index() { - let tokens = (1..=7) - .map(|expected| { - METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) - }) - .collect::>(); - let (data, methods) = build(&tokens); - let (restored, report) = - restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); - assert_eq!(report.encryption_status, "encrypted"); - assert!(report.changed_tokens > 0); - assert_eq!(report.correct_after, 7); - for index in 0..7 { - assert_eq!( - read_u32( - &restored, - methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET - ) - .expect("token"), - METHOD_TOKEN_TABLE | (index as u32 + 1) - ); - } - } - - #[test] - fn clean_metadata_is_idempotent() { - let tokens = (1..=7) - .map(|rid| METHOD_TOKEN_TABLE | rid) - .collect::>(); - let (data, _) = build(&tokens); - let (restored, report) = - restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); - assert_eq!(report.encryption_status, "clean"); - assert_eq!(report.changed_tokens, 0); - assert_eq!(restored, data); - } - - #[test] - fn encrypted_metadata_rejects_the_wrong_seed() { - let tokens = (1..=7) - .map(|expected| { - METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) - }) - .collect::>(); - let (data, _) = build(&tokens); - let wrong_seed = DEFAULT_METHOD_TOKEN_SEED.wrapping_add(1); - - assert!(matches!( - restore_method_tokens(&data, wrong_seed), - Err(Error::Validation(_)) - )); - } -} +pub use method_tokens::{ + DEFAULT_METHOD_TOKEN_SEED, Error, ImageKeyDiscovery, Report, SeedDiscoveryReport, + discover_method_token_seeds, restore_method_tokens, +}; diff --git a/senbei-android-metadata/src/method_tokens.rs b/senbei-android-metadata/src/method_tokens.rs new file mode 100644 index 0000000..1a13817 --- /dev/null +++ b/senbei-android-metadata/src/method_tokens.rs @@ -0,0 +1,659 @@ +//! Static restoration of protected IL2CPP v31 method tokens. + +use serde::Serialize; + +/// Seed embedded in the current `libil2cpp` module `0x0C`. +pub const DEFAULT_METHOD_TOKEN_SEED: u32 = 0xa6fa_e968; + +const MAGIC: u32 = 0xfab1_1baf; +const SUPPORTED_VERSION: u32 = 31; +const HDR_METHODS: usize = 0x30; +const HDR_TYPES: usize = 0xa0; +const HDR_IMAGES: usize = 0xa8; +const METHOD_STRIDE: usize = 0x24; +const METHOD_TOKEN_OFFSET: usize = 0x18; +const TYPE_STRIDE: usize = 0x58; +const TYPE_METHOD_START_OFFSET: usize = 0x24; +const TYPE_METHOD_COUNT_OFFSET: usize = 0x40; +const IMAGE_STRIDE: usize = 0x28; +const IMAGE_TYPE_START_OFFSET: usize = 0x08; +const IMAGE_TYPE_COUNT_OFFSET: usize = 0x0c; +const METHOD_TOKEN_TABLE: u32 = 0x0600_0000; + +/// Summary of one metadata restoration pass. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Report { + pub version: u32, + pub seed: String, + pub encryption_status: String, + pub images: usize, + pub images_with_methods: usize, + pub types: usize, + pub methods: usize, + pub visited_methods: usize, + pub already_correct_before: usize, + pub correct_after: usize, + pub changed_tokens: usize, + pub transformed_images: usize, +} + +/// Per-image constraints recovered from the encrypted MethodDef RID +/// permutation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ImageKeyDiscovery { + pub image: usize, + pub method_count: u32, + pub modulus: u32, + pub clean: bool, + pub seed_residues: Vec, +} + +/// Result of statically testing the known five-round permutation against a +/// metadata file without assuming a seed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SeedDiscoveryReport { + pub version: u32, + pub images: Vec, + pub seed_candidates: Vec, +} + +/// Metadata parsing or validation failure. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("not an IL2CPP global-metadata.dat")] + NotMetadata, + #[error("unsupported metadata version {0}")] + UnsupportedVersion(u32), + #[error("malformed metadata: {0}")] + Malformed(String), + #[error("method-token restoration failed: {0}")] + Validation(String), +} + +type Result = std::result::Result; + +fn malformed(message: impl Into) -> Result { + Err(Error::Malformed(message.into())) +} + +fn validation(message: impl Into) -> Result { + Err(Error::Validation(message.into())) +} + +fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| Error::Malformed("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + Error::Malformed(format!( + "byte range 0x{offset:x}..0x{end:x} is out of bounds" + )) + }) +} + +fn read_u16(data: &[u8], offset: usize) -> Result { + let value: [u8; 2] = bytes(data, offset, 2)? + .try_into() + .map_err(|_| Error::Malformed("invalid u16 range".to_owned()))?; + Ok(u16::from_le_bytes(value)) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| Error::Malformed("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(value)) +} + +fn read_i32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| Error::Malformed("invalid i32 range".to_owned()))?; + Ok(i32::from_le_bytes(value)) +} + +fn table(data: &[u8], header_offset: usize) -> Result<(usize, usize)> { + let offset = read_u32(data, header_offset)? as usize; + let size = read_u32(data, header_offset + 4)? as usize; + bytes(data, offset, size)?; + Ok((offset, size)) +} + +#[inline] +fn inverse_round(mut value: u32, count: u32, key: u32) -> u32 { + let mirror = count.wrapping_mul(2).wrapping_sub(1); + if value & 1 != 0 { + value = mirror.wrapping_sub(value); + } + value >>= 1; + if value >= count { + value = mirror.wrapping_sub(value); + } + let value = value.wrapping_sub(key); + if value > count { + value.wrapping_add(count) + } else { + value + } +} + +fn decrypt_rid(rid: u32, low: u32, high: u32, seed: u32) -> Result { + let count = high + .checked_add(1) + .and_then(|value| value.checked_sub(low)) + .ok_or_else(|| Error::Validation("invalid image RID interval".to_owned()))?; + if count < 2 { + return validation("RID inverse permutation requires at least two entries"); + } + let half = count / 2; + if half == 0 { + return validation("RID inverse permutation has a zero divisor"); + } + let key = seed % half + count / 4; + let mut value = rid + .checked_sub(low) + .ok_or_else(|| Error::Validation("encrypted RID lies below image minimum".to_owned()))?; + for _ in 0..5 { + value = inverse_round(value, count, key); + } + value + .checked_add(low) + .ok_or_else(|| Error::Validation("restored RID overflow".to_owned())) +} + +/// Restore MethodDef RID values exactly as module `0x0C` does. +/// +/// The operation is idempotent for tooling purposes: an image whose tokens are +/// already canonical is detected and left untouched instead of applying the +/// native inverse permutation a second time. +pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec, Report)> { + if read_u32(data, 0).ok() != Some(MAGIC) { + return Err(Error::NotMetadata); + } + let version = read_u32(data, 4)?; + if version != SUPPORTED_VERSION { + return Err(Error::UnsupportedVersion(version)); + } + + let (method_offset, method_size) = table(data, HDR_METHODS)?; + let (type_offset, type_size) = table(data, HDR_TYPES)?; + let (image_offset, image_size) = table(data, HDR_IMAGES)?; + if method_size % METHOD_STRIDE != 0 + || type_size % TYPE_STRIDE != 0 + || image_size % IMAGE_STRIDE != 0 + { + return malformed("v31 table size is not divisible by its entry stride"); + } + let method_count = method_size / METHOD_STRIDE; + let type_count = type_size / TYPE_STRIDE; + let image_count = image_size / IMAGE_STRIDE; + let mut owners = vec![u32::MAX; method_count]; + let mut output = data.to_vec(); + let mut images_with_methods = 0_usize; + let mut visited_methods = 0_usize; + let mut already_correct_before = 0_usize; + let mut correct_after = 0_usize; + let mut changed_tokens = 0_usize; + let mut transformed_images = 0_usize; + + for image_index in 0..image_count { + let image_base = image_offset + image_index * IMAGE_STRIDE; + let type_start = read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?; + let type_start = usize::try_from(type_start) + .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; + let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; + let type_end = type_start + .checked_add(type_entries) + .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; + if type_end > type_count { + return malformed(format!("image {image_index} type range exceeds the table")); + } + + let mut methods = Vec::new(); + for type_index in type_start..type_end { + let type_base = type_offset + type_index * TYPE_STRIDE; + let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; + if method_entries == 0 { + continue; + } + let method_start = read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?; + let method_start = usize::try_from(method_start).map_err(|_| { + Error::Malformed(format!( + "type {type_index} has methods but negative methodStart" + )) + })?; + let method_end = method_start + .checked_add(method_entries) + .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; + if method_end > method_count { + return malformed(format!("type {type_index} method range exceeds the table")); + } + for (method_index, owner) in owners + .iter_mut() + .enumerate() + .take(method_end) + .skip(method_start) + { + if *owner != u32::MAX { + return malformed(format!("method {method_index} belongs to multiple images")); + } + *owner = u32::try_from(image_index) + .map_err(|_| Error::Malformed("image index exceeds u32".to_owned()))?; + methods.push(method_index); + } + } + if methods.is_empty() { + continue; + } + images_with_methods += 1; + visited_methods += methods.len(); + let method_base = *methods + .iter() + .min() + .ok_or_else(|| Error::Malformed("nonempty image lost its method minimum".to_owned()))?; + let method_last = *methods + .iter() + .max() + .ok_or_else(|| Error::Malformed("nonempty image lost its method maximum".to_owned()))?; + if method_last - method_base + 1 != methods.len() { + return malformed(format!( + "image {image_index} method block is not contiguous" + )); + } + + let mut tokens = Vec::with_capacity(methods.len()); + let mut image_already_clean = true; + for &method_index in &methods { + let token_offset = method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET; + let token = read_u32(data, token_offset)?; + if token & 0xff00_0000 != METHOD_TOKEN_TABLE { + return malformed(format!( + "method {method_index} has non-MethodDef token 0x{token:08x}" + )); + } + let expected = u32::try_from(method_index - method_base + 1) + .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; + let rid = token & 0x00ff_ffff; + if rid == expected { + already_correct_before += 1; + } else { + image_already_clean = false; + } + tokens.push((method_index, token_offset, token, expected)); + } + + if image_already_clean { + correct_after += tokens.len(); + continue; + } + transformed_images += 1; + let low = tokens + .iter() + .map(|(_, _, token, _)| token & 0x00ff_ffff) + .min() + .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; + let high = tokens + .iter() + .map(|(_, _, token, _)| token & 0x00ff_ffff) + .max() + .ok_or_else(|| Error::Validation("image has no MethodDef RID".to_owned()))?; + if high <= 1 { + return validation(format!( + "image {image_index} is noncanonical but native R > 1 gate would skip it" + )); + } + let interval = high - low + 1; + if interval as usize != tokens.len() { + return validation(format!( + "image {image_index} RID interval {low}..={high} is not a permutation" + )); + } + for (method_index, token_offset, token, expected) in tokens { + let restored_rid = decrypt_rid(token & 0x00ff_ffff, low, high, seed)?; + if restored_rid != expected { + return validation(format!( + "method {method_index} restored RID {restored_rid} != expected {expected}" + )); + } + let restored_token = METHOD_TOKEN_TABLE | restored_rid; + if restored_token != token { + output[token_offset..token_offset + 4] + .copy_from_slice(&restored_token.to_le_bytes()); + changed_tokens += 1; + } + correct_after += 1; + } + } + + if owners.contains(&u32::MAX) { + return malformed("one or more method definitions are not owned by an image"); + } + if visited_methods != method_count || correct_after != method_count { + return validation(format!( + "method coverage mismatch: visited={visited_methods}, correct={correct_after}, total={method_count}" + )); + } + + Ok(( + output, + Report { + version, + seed: format!("0x{seed:08X}"), + encryption_status: if changed_tokens == 0 { + "clean".to_owned() + } else { + "encrypted".to_owned() + }, + images: image_count, + images_with_methods, + types: type_count, + methods: method_count, + visited_methods, + already_correct_before, + correct_after, + changed_tokens, + transformed_images, + }, + )) +} + +/// Discover seeds compatible with the known v31 five-round RID permutation. +/// +/// This is diagnostic and does not modify metadata. It enumerates the only +/// possible per-image key residues and intersects them over the 32-bit seed +/// domain. An empty candidate list means that the sample changed the +/// permutation itself rather than merely embedding a different seed. +pub fn discover_method_token_seeds(data: &[u8]) -> Result { + if read_u32(data, 0).ok() != Some(MAGIC) { + return Err(Error::NotMetadata); + } + let version = read_u32(data, 4)?; + if version != SUPPORTED_VERSION { + return Ok(SeedDiscoveryReport { + version, + images: Vec::new(), + seed_candidates: Vec::new(), + }); + } + let (method_offset, method_size) = table(data, HDR_METHODS)?; + let (type_offset, type_size) = table(data, HDR_TYPES)?; + let (image_offset, image_size) = table(data, HDR_IMAGES)?; + if method_size % METHOD_STRIDE != 0 + || type_size % TYPE_STRIDE != 0 + || image_size % IMAGE_STRIDE != 0 + { + return malformed("v31 table size is not divisible by its entry stride"); + } + let method_count = method_size / METHOD_STRIDE; + let type_count = type_size / TYPE_STRIDE; + let image_count = image_size / IMAGE_STRIDE; + let mut reports = Vec::with_capacity(image_count); + for image_index in 0..image_count { + let image_base = image_offset + image_index * IMAGE_STRIDE; + let type_start = usize::try_from(read_i32(data, image_base + IMAGE_TYPE_START_OFFSET)?) + .map_err(|_| Error::Malformed(format!("image {image_index} has negative typeStart")))?; + let type_entries = read_u32(data, image_base + IMAGE_TYPE_COUNT_OFFSET)? as usize; + let type_end = type_start + .checked_add(type_entries) + .ok_or_else(|| Error::Malformed("image type range overflow".to_owned()))?; + if type_end > type_count { + return malformed(format!("image {image_index} type range exceeds the table")); + } + let mut methods = Vec::new(); + for type_index in type_start..type_end { + let type_base = type_offset + type_index * TYPE_STRIDE; + let method_entries = read_u16(data, type_base + TYPE_METHOD_COUNT_OFFSET)? as usize; + if method_entries == 0 { + continue; + } + let method_start = + usize::try_from(read_i32(data, type_base + TYPE_METHOD_START_OFFSET)?).map_err( + |_| Error::Malformed(format!("type {type_index} has negative methodStart")), + )?; + let method_end = method_start + .checked_add(method_entries) + .ok_or_else(|| Error::Malformed("type method range overflow".to_owned()))?; + if method_end > method_count { + return malformed(format!("type {type_index} method range exceeds the table")); + } + methods.extend(method_start..method_end); + } + if methods.is_empty() { + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: 0, + modulus: 0, + clean: true, + seed_residues: Vec::new(), + }); + continue; + } + let method_base = *methods + .iter() + .min() + .ok_or_else(|| Error::Malformed("image method minimum is missing".to_owned()))?; + let method_last = *methods + .iter() + .max() + .ok_or_else(|| Error::Malformed("image method maximum is missing".to_owned()))?; + if method_last - method_base + 1 != methods.len() { + return validation(format!( + "image {image_index} method block is not contiguous" + )); + } + let mut values = Vec::with_capacity(methods.len()); + let mut clean = true; + for method_index in methods { + let token = read_u32( + data, + method_offset + method_index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, + )?; + if token & 0xff00_0000 != METHOD_TOKEN_TABLE { + return validation(format!( + "method {method_index} has non-MethodDef token 0x{token:08x}" + )); + } + let expected = u32::try_from(method_index - method_base + 1) + .map_err(|_| Error::Validation("local method RID exceeds u32".to_owned()))?; + let rid = token & 0x00ff_ffff; + clean &= rid == expected; + values.push((rid, expected)); + } + let count = u32::try_from(values.len()) + .map_err(|_| Error::Validation("image method count exceeds u32".to_owned()))?; + if clean { + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: count, + modulus: count / 2, + clean, + seed_residues: Vec::new(), + }); + continue; + } + let low = values + .iter() + .map(|(rid, _)| *rid) + .min() + .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; + let high = values + .iter() + .map(|(rid, _)| *rid) + .max() + .ok_or_else(|| Error::Validation("image has no encrypted RID".to_owned()))?; + if high - low + 1 != count || count < 2 { + return validation(format!( + "image {image_index} RID interval is not a permutation" + )); + } + let half = count / 2; + let quarter = count / 4; + let mut residues = Vec::new(); + for key_delta in 0..half { + let key = quarter + key_delta; + let valid = values + .iter() + .all(|(rid, expected)| decrypt_rid_with_key(*rid, low, high, key) == *expected); + if valid { + residues.push(key_delta); + } + } + reports.push(ImageKeyDiscovery { + image: image_index, + method_count: count, + modulus: half, + clean, + seed_residues: residues, + }); + } + + let constraints = reports + .iter() + .filter(|report| !report.clean) + .collect::>(); + let mut seeds = Vec::new(); + if let Some(anchor) = constraints.iter().max_by_key(|report| report.modulus) { + for &residue in &anchor.seed_residues { + let mut candidate = u64::from(residue); + let modulus = u64::from(anchor.modulus); + while candidate <= u64::from(u32::MAX) { + let valid = constraints.iter().all(|report| { + report.modulus != 0 + && !report.seed_residues.is_empty() + && report + .seed_residues + .iter() + .any(|&value| candidate % u64::from(report.modulus) == u64::from(value)) + }); + if valid { + seeds.push(candidate as u32); + } + candidate = candidate.saturating_add(modulus); + } + } + } + seeds.sort_unstable(); + seeds.dedup(); + Ok(SeedDiscoveryReport { + version, + images: reports, + seed_candidates: seeds, + }) +} + +fn decrypt_rid_with_key(rid: u32, low: u32, high: u32, key: u32) -> u32 { + let count = high - low + 1; + let mut value = rid - low; + for _ in 0..5 { + value = inverse_round(value, count, key); + } + value + low +} + +#[cfg(test)] +mod tests { + use super::*; + + fn put_u16(data: &mut [u8], offset: usize, value: u16) { + data[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn put_u32(data: &mut [u8], offset: usize, value: u32) { + data[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn encrypted_rid(expected: u32, count: u32, seed: u32) -> u32 { + (1..=count) + .find(|&candidate| decrypt_rid(candidate, 1, count, seed) == Ok(expected)) + .expect("inverse permutation must be bijective") + } + + fn build(tokens: &[u32]) -> (Vec, usize) { + let header_size = 0x100; + let images = header_size; + let types = images + IMAGE_STRIDE; + let methods = types + 2 * TYPE_STRIDE; + let mut data = vec![0_u8; methods + tokens.len() * METHOD_STRIDE]; + put_u32(&mut data, 0, MAGIC); + put_u32(&mut data, 4, SUPPORTED_VERSION); + put_u32(&mut data, HDR_METHODS, methods as u32); + put_u32( + &mut data, + HDR_METHODS + 4, + (tokens.len() * METHOD_STRIDE) as u32, + ); + put_u32(&mut data, HDR_TYPES, types as u32); + put_u32(&mut data, HDR_TYPES + 4, (2 * TYPE_STRIDE) as u32); + put_u32(&mut data, HDR_IMAGES, images as u32); + put_u32(&mut data, HDR_IMAGES + 4, IMAGE_STRIDE as u32); + put_u32(&mut data, images + IMAGE_TYPE_START_OFFSET, 0); + put_u32(&mut data, images + IMAGE_TYPE_COUNT_OFFSET, 2); + // Deliberately traverse the high method indices first. + put_u32(&mut data, types + TYPE_METHOD_START_OFFSET, 4); + put_u16(&mut data, types + TYPE_METHOD_COUNT_OFFSET, 3); + put_u32(&mut data, types + TYPE_STRIDE + TYPE_METHOD_START_OFFSET, 0); + put_u16(&mut data, types + TYPE_STRIDE + TYPE_METHOD_COUNT_OFFSET, 4); + for (index, &token) in tokens.iter().enumerate() { + put_u32( + &mut data, + methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET, + token, + ); + } + (data, methods) + } + + #[test] + fn restores_five_round_permutation_by_physical_method_index() { + let tokens = (1..=7) + .map(|expected| { + METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) + }) + .collect::>(); + let (data, methods) = build(&tokens); + let (restored, report) = + restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.encryption_status, "encrypted"); + assert!(report.changed_tokens > 0); + assert_eq!(report.correct_after, 7); + for index in 0..7 { + assert_eq!( + read_u32( + &restored, + methods + index * METHOD_STRIDE + METHOD_TOKEN_OFFSET + ) + .expect("token"), + METHOD_TOKEN_TABLE | (index as u32 + 1) + ); + } + } + + #[test] + fn clean_metadata_is_idempotent() { + let tokens = (1..=7) + .map(|rid| METHOD_TOKEN_TABLE | rid) + .collect::>(); + let (data, _) = build(&tokens); + let (restored, report) = + restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("restore"); + assert_eq!(report.encryption_status, "clean"); + assert_eq!(report.changed_tokens, 0); + assert_eq!(restored, data); + } + + #[test] + fn encrypted_metadata_rejects_the_wrong_seed() { + let tokens = (1..=7) + .map(|expected| { + METHOD_TOKEN_TABLE | encrypted_rid(expected, 7, DEFAULT_METHOD_TOKEN_SEED) + }) + .collect::>(); + let (data, _) = build(&tokens); + let wrong_seed = DEFAULT_METHOD_TOKEN_SEED.wrapping_add(1); + + assert!(matches!( + restore_method_tokens(&data, wrong_seed), + Err(Error::Validation(_)) + )); + } +}