mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
refactor: move library implementations into modules
This commit is contained in:
@@ -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<T> = std::result::Result<T, Error>;
|
||||
|
||||
fn invalid<T>(message: impl Into<String>) -> Result<T> {
|
||||
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<u16> {
|
||||
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<u32> {
|
||||
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<usize> {
|
||||
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> {
|
||||
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> {
|
||||
Self::parse_inner(image, false)
|
||||
}
|
||||
|
||||
fn parse_inner(image: &[u8], has_skip_aes: bool) -> Result<Self> {
|
||||
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<Self> {
|
||||
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<u8>,
|
||||
pub segments: Vec<EncodedSegment>,
|
||||
}
|
||||
|
||||
impl ContainerHeader {
|
||||
/// Parse and decrypt a container header, Huffman tree, and segment table.
|
||||
pub fn parse(data: &[u8], start: usize, seed: u32) -> Result<Self> {
|
||||
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<usize> {
|
||||
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::<Result<Vec<_>>>()?
|
||||
.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<u8>,
|
||||
lookup_symbols: Vec<u16>,
|
||||
lookup_bits: Vec<u8>,
|
||||
}
|
||||
|
||||
impl HuffmanLzDecoder {
|
||||
/// Build the full 16-bit prefix lookup used by the static decoder.
|
||||
pub fn new(tree: &[u8]) -> Result<Self> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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::<Aes256>::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<Vec<u8>> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<T> = std::result::Result<T, Error>;
|
||||
|
||||
fn invalid<T>(message: impl Into<String>) -> Result<T> {
|
||||
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<u16> {
|
||||
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<u32> {
|
||||
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<usize> {
|
||||
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> {
|
||||
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> {
|
||||
Self::parse_inner(image, false)
|
||||
}
|
||||
|
||||
fn parse_inner(image: &[u8], has_skip_aes: bool) -> Result<Self> {
|
||||
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<Self> {
|
||||
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<u8>,
|
||||
pub segments: Vec<EncodedSegment>,
|
||||
}
|
||||
|
||||
impl ContainerHeader {
|
||||
/// Parse and decrypt a container header, Huffman tree, and segment table.
|
||||
pub fn parse(data: &[u8], start: usize, seed: u32) -> Result<Self> {
|
||||
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<usize> {
|
||||
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::<Result<Vec<_>>>()?
|
||||
.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<u8>,
|
||||
lookup_symbols: Vec<u16>,
|
||||
lookup_bits: Vec<u8>,
|
||||
}
|
||||
|
||||
impl HuffmanLzDecoder {
|
||||
/// Build the full 16-bit prefix lookup used by the static decoder.
|
||||
pub fn new(tree: &[u8]) -> Result<Self> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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::<Aes256>::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<Vec<u8>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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("<probe>"),
|
||||
DEFAULT_OUTER_SIZE,
|
||||
DEFAULT_CIPHER_CONSTANT,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
senbei_android_crypto::Module9bConfig::parse_embedded(&stage1.plaintext).is_ok()
|
||||
}
|
||||
|
||||
@@ -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("<probe>"),
|
||||
DEFAULT_OUTER_SIZE,
|
||||
DEFAULT_CIPHER_CONSTANT,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
Module9bConfig::parse_embedded(&stage1.plaintext).is_ok()
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
pub report: Option<PathBuf>,
|
||||
pub dump_auxiliary: Option<PathBuf>,
|
||||
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<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>,
|
||||
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<RestoreReport> {
|
||||
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<MetadataReport> {
|
||||
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<ExtractionReport> {
|
||||
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<PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf>,
|
||||
pub report: Option<PathBuf>,
|
||||
pub dump_auxiliary: Option<PathBuf>,
|
||||
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<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>,
|
||||
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<RestoreReport> {
|
||||
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<MetadataReport> {
|
||||
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<ExtractionReport> {
|
||||
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<PathBuf> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u32>,
|
||||
}
|
||||
|
||||
/// 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<ImageKeyDiscovery>,
|
||||
pub seed_candidates: Vec<u32>,
|
||||
}
|
||||
|
||||
/// 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<T> = std::result::Result<T, Error>;
|
||||
|
||||
fn malformed<T>(message: impl Into<String>) -> Result<T> {
|
||||
Err(Error::Malformed(message.into()))
|
||||
}
|
||||
|
||||
fn validation<T>(message: impl Into<String>) -> Result<T> {
|
||||
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<u16> {
|
||||
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<u32> {
|
||||
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<i32> {
|
||||
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<u32> {
|
||||
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<u8>, 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<SeedDiscoveryReport> {
|
||||
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::<Vec<_>>();
|
||||
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<u8>, 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<u32>,
|
||||
}
|
||||
|
||||
/// 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<ImageKeyDiscovery>,
|
||||
pub seed_candidates: Vec<u32>,
|
||||
}
|
||||
|
||||
/// 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<T> = std::result::Result<T, Error>;
|
||||
|
||||
fn malformed<T>(message: impl Into<String>) -> Result<T> {
|
||||
Err(Error::Malformed(message.into()))
|
||||
}
|
||||
|
||||
fn validation<T>(message: impl Into<String>) -> Result<T> {
|
||||
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<u16> {
|
||||
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<u32> {
|
||||
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<i32> {
|
||||
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<u32> {
|
||||
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<u8>, 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<SeedDiscoveryReport> {
|
||||
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::<Vec<_>>();
|
||||
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<u8>, 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user