Merge Android (AArch64) shared-library restoration, bump to 1.2.0

Adds the Android protection-scheme pipeline: hollowed ELF64/AArch64
libraries are restored statically (stage-1/stage-2 module extraction,
container decode, dynamic-linker table rebuild), with app-package
(.apk/.apks/.xapk) container handling, cross-source content dedup, and
il2cpp metadata support for the Android variants (seeded RID permutation;
embedded XOR-wrapped blob extraction).

The single senbei CLI now routes single .so files, packages, and folders
by content; outputs follow the existing .unpack-infix naming under
<root>/unpack or --out. PE behavior is unchanged (35/35 goldens).
This commit is contained in:
2026-09-02 03:09:21 +08:00
42 changed files with 7165 additions and 125 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "senbei-android-metadata"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "IL2CPP metadata restoration for Senbei Android"
[dependencies]
serde.workspace = true
thiserror.workspace = true
[lints]
workspace = true
+137
View File
@@ -0,0 +1,137 @@
//! Extraction of the embedded-metadata packaging variant.
//!
//! Some protected il2cpp builds ship no `global-metadata.dat` in the app's
//! assets at all. Instead a slim metadata blob (an older header format with
//! custom record layouts) is embedded in the protected library's data section
//! and wrapped in a per-word XOR layer: a 0x100-byte header whose 64 words each
//! carry their own key, followed by exactly 256 segments with one u32 key each
//! at irregular boundaries. At runtime the protector's il2cpp-side modules
//! regenerate the keys and unwrap the blob in place; the keys are stored
//! nowhere in the image.
//!
//! For the one observed build using this variant the full keystream was
//! recovered from a ciphertext/plaintext pair and is embedded in
//! [`crate::keystream`]. Extraction is therefore content-gated: the wrapped
//! header's first plaintext words are known constants, so a restored image that
//! does not contain them (every other build) is skipped cheaply and nothing is
//! written.
//!
//! The unwrapped blob stores its patched sanity/version fields byte-swapped;
//! they are rewritten to the standard il2cpp metadata magic and version so the
//! output is a well-formed `global-metadata.dat`.
use crate::keystream::{HEADER_KEYS, SEGMENTS};
/// Standard il2cpp metadata sanity magic written over the patched header.
const STANDARD_MAGIC: u32 = 0xfab1_1baf;
/// Standard header version matching the blob's record layout.
const STANDARD_VERSION: u32 = 24;
/// Plaintext of the first two wrapped header words (the byte-swapped patched
/// sanity/version pair). Also the probe pattern: a restored image contains the
/// embedded blob iff `word[0] ^ HEADER_KEYS[0]` and `word[1] ^ HEADER_KEYS[1]`
/// equal these constants at some 4-aligned offset.
const PROBE_WORDS: [u32; 2] = [0x9732_ca38, 0xbac4_374f];
/// Size of the wrapped blob: the last segment's end offset.
pub fn embedded_metadata_size() -> usize {
SEGMENTS[SEGMENTS.len() - 1].0 as usize
}
/// Locate and unwrap the embedded metadata blob in a restored library image.
///
/// Returns a standalone, well-formed `global-metadata.dat`, or `None` when the
/// image carries no blob wrapped with the known keystream.
pub fn extract_embedded_metadata(image: &[u8]) -> Option<Vec<u8>> {
let total = embedded_metadata_size();
let offset = find_wrapped_header(image)?;
let blob = image.get(offset..offset.checked_add(total)?)?;
let mut out = blob.to_vec();
for (i, &key) in HEADER_KEYS.iter().enumerate() {
xor_word(&mut out, 4 * i, key);
}
let mut pos = 0x100_usize;
for &(end, key) in &SEGMENTS {
let end = end as usize;
let mut o = pos;
while o + 4 <= end {
xor_word(&mut out, o, key);
o += 4;
}
pos = end;
}
out[0..4].copy_from_slice(&STANDARD_MAGIC.to_le_bytes());
out[4..8].copy_from_slice(&STANDARD_VERSION.to_le_bytes());
Some(out)
}
/// Scan `image` for the wrapped header probe pattern (4-aligned).
fn find_wrapped_header(image: &[u8]) -> Option<usize> {
let mut off = 0;
while off + 8 <= image.len() {
let word = u32::from_le_bytes(image[off..off + 4].try_into().ok()?);
if word ^ HEADER_KEYS[0] == PROBE_WORDS[0] {
let next = u32::from_le_bytes(image[off + 4..off + 8].try_into().ok()?);
if next ^ HEADER_KEYS[1] == PROBE_WORDS[1] {
return Some(off);
}
}
off += 4;
}
None
}
fn xor_word(data: &mut [u8], offset: usize, key: u32) {
let word = u32::from_le_bytes(data[offset..offset + 4].try_into().expect("word in bounds"));
data[offset..offset + 4].copy_from_slice(&(word ^ key).to_le_bytes());
}
#[cfg(test)]
mod tests {
use super::*;
/// Wrap a synthetic blob with the keystream, then unwrap it back.
#[test]
fn roundtrip_wrapped_blob() {
let total = embedded_metadata_size();
let mut image = vec![0_u8; total + 0x40];
// Plaintext blob: standard probe words, then a ramp.
image[0..4].copy_from_slice(&PROBE_WORDS[0].to_le_bytes());
image[4..8].copy_from_slice(&PROBE_WORDS[1].to_le_bytes());
for o in (8..total).step_by(4) {
let v = (o as u32).wrapping_mul(0x9e37_79b1);
image[o..o + 4].copy_from_slice(&v.to_le_bytes());
}
// Wrap with the keystream.
for (i, &key) in HEADER_KEYS.iter().enumerate() {
xor_word(&mut image, 4 * i, key);
}
let mut pos = 0x100_usize;
for &(end, key) in &SEGMENTS {
let mut o = pos;
while o + 4 <= end as usize {
xor_word(&mut image, o, key);
o += 4;
}
pos = end as usize;
}
let out = extract_embedded_metadata(&image).expect("blob found");
assert_eq!(out.len(), total);
// Header rewritten to the standard magic/version…
assert_eq!(&out[0..4], &STANDARD_MAGIC.to_le_bytes());
assert_eq!(&out[4..8], &STANDARD_VERSION.to_le_bytes());
// …and the body round-trips.
for o in (8..total).step_by(4) {
let v = (o as u32).wrapping_mul(0x9e37_79b1);
assert_eq!(&out[o..o + 4], &v.to_le_bytes(), "word at {o:#x}");
}
}
#[test]
fn no_blob_in_plain_data() {
let image = vec![0xAB_u8; 0x1000];
assert!(extract_embedded_metadata(&image).is_none());
}
}
+274
View File
@@ -0,0 +1,274 @@
/// Per-word XOR keystream for the embedded-metadata packaging variant,
/// recovered from a ciphertext/plaintext pair of one observed build.
/// Key derivation for future builds is untraced; other builds simply do
/// not match the header probe and are left untouched.
pub(crate) const HEADER_KEYS: [u32; 64] = [
0x39184c70, 0xd901afd4, 0x19b98815, 0x132906ed, 0x663e8ace, 0x299b1952, 0xe5404ab8, 0xd93b331c,
0xb67d3761, 0x42da9259, 0xc29c7a59, 0x17cb841c, 0xd0bcb9c6, 0x21db779b, 0x43874deb, 0x89bf697b,
0x0b7f97b4, 0xbe1c59f7, 0xc653ad92, 0x8cdf4336, 0x5e0b6b68, 0x1bd4d668, 0x7250ed61, 0x31a36491,
0xaf144dcd, 0xc1e387d0, 0x9d6df5b7, 0x78514f32, 0xc2648cbf, 0x3b8272a5, 0xd2053679, 0x4b18af77,
0x71b9ebdd, 0x0094daaa, 0xf3adfed8, 0xc0d082bc, 0xae5e523c, 0xa8dec0be, 0x090a7784, 0x2c0483d6,
0x95f0e8f7, 0x234de6d4, 0xa7464527, 0x3b1c531d, 0xc2b31d82, 0xe1c60be0, 0x3d65a0c2, 0x2ea7d77a,
0x4ababadb, 0xce484b16, 0x59ab3f99, 0x10a9a463, 0x70e2f78a, 0x0ed71c9c, 0xf8996b2e, 0xff637928,
0xf413313d, 0x77c57bf9, 0xdab41dba, 0x0cd2ccbc, 0x3b2fbde3, 0x0b19b14d, 0xd2645dbc, 0x318113d4,
];
/// (segment end offset, segment key) pairs; offsets relative to blob start.
pub(crate) const SEGMENTS: [(u32, u32); 256] = [
(0x3915c, 0xbb5dda1a),
(0x736b4, 0x906dbe0f),
(0xaedc4, 0x1e4ca8bd),
(0xc506c, 0xe603cb21),
(0xdfe34, 0x93bec702),
(0xe8f10, 0x9a9f429f),
(0x139088, 0x125a8b3f),
(0x20bd30, 0x69a6395f),
(0x20c548, 0xca807b9a),
(0x20c774, 0x8b8880c4),
(0x300518, 0x48087852),
(0x36c07c, 0x32aa7b5b),
(0x448b7c, 0x2e668589),
(0x4592b0, 0x292e07d9),
(0x45d374, 0x83b0a0ef),
(0x474520, 0x8983245d),
(0x47a1d4, 0xefb941b7),
(0x4bdc74, 0x7c3b3458),
(0x4c34c8, 0xee0a87b3),
(0x4f1068, 0xf6a2069f),
(0x51601c, 0x2e83612b),
(0x549d40, 0xb413a58f),
(0x56a714, 0x95596da3),
(0x573c98, 0x68513e8d),
(0x59d058, 0xc4ff5f9a),
(0x5c4b34, 0x249ed022),
(0x5f19bc, 0xc27272d3),
(0x5f47c8, 0xd73aa37b),
(0x627df4, 0x002334ba),
(0x648f54, 0x868bb6c9),
(0x6718a4, 0x17ff0ef4),
(0x6a88b8, 0x22cfbc5f),
(0x742dbc, 0x152072dd),
(0x75603c, 0xbd31be45),
(0x783238, 0x45911d6a),
(0x7b94d8, 0x6f281add),
(0x800060, 0xcf58c8d0),
(0x819b50, 0xb40f0276),
(0x82dea4, 0x1a1a8402),
(0x880210, 0xf2c0824a),
(0x8e4f08, 0x86c9ba90),
(0x8ea544, 0x0e928544),
(0x931454, 0xc3fa017b),
(0x94eb70, 0x1dbe612a),
(0x95993c, 0x902498fe),
(0x98d2b0, 0xb7760451),
(0x992034, 0x711cddfc),
(0x9e14a0, 0x8bd95e64),
(0xa1d2d0, 0xbfdce920),
(0xa21ed8, 0x90cf0372),
(0xa4d2b8, 0x91e88c9c),
(0xa76f8c, 0x9c721e61),
(0xac5ba4, 0xbda16e3e),
(0xaf070c, 0xe02b6799),
(0xaf3a78, 0x32953b4e),
(0xb32510, 0x47ea48db),
(0xb46550, 0x1443e512),
(0xb54998, 0x9e123a75),
(0xb5e24c, 0xe11a8efd),
(0xb625cc, 0x3facfbf4),
(0xb66ec8, 0x76c0c452),
(0xb67ad0, 0x4de4ed6c),
(0xb7ba5c, 0xe622d97a),
(0xb85a90, 0x6f564f8b),
(0xbff8b8, 0x3e25d671),
(0xc03e50, 0x3563fc2b),
(0xc6e958, 0xda8bc3b0),
(0xc87f7c, 0x5a9d2269),
(0xcb36a4, 0x0ab420cc),
(0xcbe4d0, 0x9bbb091e),
(0xccd7dc, 0x9e4fd577),
(0xd078c0, 0x4b655ae1),
(0xd275dc, 0x5ca2a2f4),
(0xd2c840, 0xdb437f0d),
(0xd3296c, 0x66487f75),
(0xd7cbe8, 0xf5427945),
(0xd8e0a0, 0x9a65bdb6),
(0xda2ed4, 0x46dea4b3),
(0xda6f1c, 0xb9916a02),
(0xdee9ac, 0x18800a5c),
(0xe3673c, 0x4afab3cd),
(0xe65420, 0x52e80204),
(0xe861f8, 0x639a02d7),
(0xeb61c0, 0x21077eba),
(0xed51dc, 0x17be91d8),
(0xf048a4, 0xd30cc8cb),
(0xf3b274, 0xdfb43f3f),
(0xf76ac8, 0x63a8b363),
(0xf84b64, 0x16508a16),
(0xf8bb2c, 0x22ce110d),
(0xfb3390, 0xf09a4eb2),
(0xff07bc, 0xd2bb0e2c),
(0x102d32c, 0xb424012c),
(0x10795b0, 0x07338bb9),
(0x108d65c, 0x5d68f86e),
(0x10ce528, 0x1826c952),
(0x10d3528, 0xa7473860),
(0x10dca58, 0x92435967),
(0x1115c78, 0x061200f4),
(0x1171098, 0x94f538a1),
(0x117ebf0, 0xd8731d88),
(0x1186638, 0x4381b3f9),
(0x118afdc, 0xf25ff376),
(0x11ee3b4, 0x29605488),
(0x11f182c, 0x04367932),
(0x11f41dc, 0xaeaccadd),
(0x11ff0f4, 0x7c4d358e),
(0x120caac, 0xacbc8412),
(0x12437cc, 0x3e0ac7e9),
(0x124edf8, 0x06f523fd),
(0x1263ba0, 0x0a1b9763),
(0x12943f0, 0x24a86ba4),
(0x12d9230, 0x0cd82e2e),
(0x12fd9b4, 0xf3903fb9),
(0x135a198, 0x2887f4a3),
(0x1366180, 0x9f0d7ca5),
(0x13680a4, 0x61e9a459),
(0x13a1b44, 0xe61623a4),
(0x13a860c, 0xdc44c798),
(0x13c024c, 0xc90f7be6),
(0x1475c00, 0xc2f338b3),
(0x1480aa8, 0xb7b0609e),
(0x14fb82c, 0x748e3939),
(0x1511184, 0x98426fcf),
(0x153c144, 0x1a452d5d),
(0x1547838, 0x7dd360e9),
(0x15565d4, 0x1d8f093b),
(0x156e298, 0x102a1524),
(0x159df70, 0xe42613f7),
(0x15a13d0, 0xafc5fdc6),
(0x15e7f24, 0x84fcd342),
(0x15f0878, 0x55038958),
(0x1614210, 0xe0602ae4),
(0x1631b3c, 0xce2765f6),
(0x164eb70, 0xf772dac5),
(0x1688b68, 0x5f1a72c9),
(0x16d5f8c, 0x7c77747d),
(0x16e76dc, 0xac0e16fb),
(0x1726374, 0x4a1e7fd7),
(0x173455c, 0x870856b4),
(0x17697d8, 0xbb2f0a5c),
(0x176ed60, 0xc937b386),
(0x1784fa8, 0x5e676ab2),
(0x17ae3a0, 0xdbf662a1),
(0x1866c7c, 0x4e3f1a7d),
(0x186b844, 0xe30fce60),
(0x18b507c, 0xdfc73c88),
(0x18c2f64, 0xb7ee08e0),
(0x18c8010, 0xd1471a25),
(0x18de290, 0x292e6310),
(0x19140d0, 0x9f346f05),
(0x192c590, 0xf1eb61bf),
(0x194fca0, 0x8888b1df),
(0x1959d34, 0x92b89d15),
(0x196c0c4, 0x5e152de5),
(0x19a5710, 0x866e7bfa),
(0x19abfd4, 0x3084ae26),
(0x19b1550, 0x0581836f),
(0x19b7214, 0xeefc34eb),
(0x19c523c, 0xc980335d),
(0x19dc4c0, 0x019084e6),
(0x19dfb8c, 0xdb1a21a7),
(0x19fbf3c, 0xec84cc17),
(0x1a29b18, 0xcb31da7d),
(0x1a4c670, 0xc5fe570e),
(0x1a97024, 0xbbd80964),
(0x1ac33bc, 0xe186586d),
(0x1acd124, 0x1e413252),
(0x1ad9bac, 0x48fc4c75),
(0x1b1b728, 0x8071d7a5),
(0x1b31d78, 0x9d958013),
(0x1badb24, 0x2f236951),
(0x1bccc00, 0x7023c620),
(0x1bdab2c, 0x88b1e4b8),
(0x1c000dc, 0x9e43291a),
(0x1c9f0cc, 0x27a7d592),
(0x1cd1328, 0x9c0bcc88),
(0x1cd79a0, 0x63e0ed75),
(0x1d0e484, 0xf51a0d3d),
(0x1d17b10, 0xbfd2a7ac),
(0x1d930c4, 0xf6b9e877),
(0x1db115c, 0xf3eb7e37),
(0x1df16b4, 0x682326ff),
(0x1e389c0, 0xea11f566),
(0x1eb7e48, 0x3dc5fa76),
(0x1ec38fc, 0x296ffc1d),
(0x1ee87a0, 0x1b9f7fd4),
(0x1f19f88, 0x78972e8f),
(0x1f33a0c, 0x390c2deb),
(0x1f4e0fc, 0xe05e8c6b),
(0x1f5a718, 0x367432ae),
(0x1f61dcc, 0x7063e58a),
(0x1f85878, 0x21c00cea),
(0x1fc043c, 0x2676aaaa),
(0x1ffdb94, 0xc270eb02),
(0x202a618, 0x3a98aed2),
(0x2037b34, 0x115d5afc),
(0x203d92c, 0x11bced76),
(0x203da14, 0xf2628105),
(0x2066014, 0x97f32700),
(0x208a908, 0xa68e2f71),
(0x20ab8ac, 0x1daa2a78),
(0x20ba504, 0x73919ef6),
(0x20e71e0, 0x0b3fd1d3),
(0x2102278, 0x6c123def),
(0x21166dc, 0xee354161),
(0x2126478, 0x299493f4),
(0x2137090, 0x05ae2007),
(0x2148270, 0x34b52663),
(0x21482ac, 0xe381b5b6),
(0x21813b8, 0x94244de1),
(0x21a41e8, 0x02c38df5),
(0x21a8c4c, 0xf72700dd),
(0x21abbac, 0x34c2e7b5),
(0x21bcb24, 0x442739ad),
(0x21cbe84, 0x6e40d22c),
(0x21e2798, 0xdbf774d0),
(0x21f892c, 0xe90f1e0c),
(0x222beec, 0xa27f27f3),
(0x22394f4, 0x7f999a4f),
(0x22437ec, 0xf12d28f8),
(0x22480b0, 0xf58f3a7d),
(0x2261a0c, 0x89b28301),
(0x22a76c8, 0x1fe501e2),
(0x22b2018, 0xf079db5f),
(0x22cc610, 0xaf7d17b7),
(0x22cd4f8, 0x71c010cb),
(0x22d016c, 0x4a8daed0),
(0x22e1c04, 0xe1201aca),
(0x22f9994, 0xf3f0e4ee),
(0x2384f5c, 0x6b8a5eb1),
(0x23d5ecc, 0x5298a9c4),
(0x23e15b0, 0xc7bf0afb),
(0x23e248c, 0x2d67fecf),
(0x2407898, 0x4eef422a),
(0x241695c, 0x33ba9ce8),
(0x243e8b0, 0x833c1d2c),
(0x2460b64, 0x819c96ee),
(0x247caec, 0x0ebccbd6),
(0x24832b4, 0xf789d4b6),
(0x24938b8, 0x9f63baeb),
(0x24a7c64, 0x3384e552),
(0x24bce94, 0x7bfec208),
(0x24bd8f4, 0x9b5260cc),
(0x24ce8ec, 0xaf854888),
(0x24e741c, 0xda82f062),
(0x254401c, 0xbb1a5d5a),
(0x25a6a64, 0x24d202d3),
(0x2617878, 0x6ac71e5f),
(0x2617df0, 0x0e76bd90),
(0x268e9b8, 0x874d931c),
(0x26a8848, 0xefefd680),
(0x26e4f10, 0xfc2799f7),
(0x26e93d8, 0x1930ad55),
(0x26eea84, 0xfa2f742f),
(0x2701f30, 0xed9b92c4),
];
+11
View File
@@ -0,0 +1,11 @@
//! Static IL2CPP metadata restoration interfaces.
mod embedded;
mod keystream;
mod method_tokens;
pub use embedded::{embedded_metadata_size, extract_embedded_metadata};
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(_))
));
}
}