From d4613305fd6bddbba34fb21da14621fccc237288 Mon Sep 17 00:00:00 2001 From: y52 Date: Thu, 17 Sep 2026 18:12:36 +0000 Subject: [PATCH 1/4] fix(android): support SysV-only ELF hash tables --- senbei-elf/src/lib.rs | 17 ++- senbei-engine/src/android/extract/stage1.rs | 10 ++ senbei-engine/src/android/restore/pipeline.rs | 113 +++++++++++++----- 3 files changed, 101 insertions(+), 39 deletions(-) diff --git a/senbei-elf/src/lib.rs b/senbei-elf/src/lib.rs index 43aba57..a95dde9 100644 --- a/senbei-elf/src/lib.rs +++ b/senbei-elf/src/lib.rs @@ -15,26 +15,23 @@ pub use layout::{ /// ELF machine identifier for AArch64. pub const AARCH64_MACHINE: u16 = EM_AARCH64; -/// Dynamic sections required by the restored AArch64 loader image. -pub const DYNAMIC_SECTION_NAMES: [&str; 8] = [ +/// Dynamic sections required by every restored AArch64 loader image. +pub const DYNAMIC_SECTION_NAMES: [&str; 7] = [ ".dynsym", ".gnu.version", ".gnu.version_r", - ".gnu.hash", ".dynstr", ".rela.dyn", ".rela.plt", ".dynamic", ]; +/// Supported dynamic symbol-hash sections. At least one must be present. +pub const SYMBOL_HASH_SECTION_NAMES: [&str; 2] = [".gnu.hash", ".hash"]; + /// Dynamic sections needed to identify a protected image before extraction. -pub const PROBE_SECTION_NAMES: [&str; 5] = [ - ".dynsym", - ".dynstr", - ".gnu.hash", - ".gnu.version", - ".gnu.version_r", -]; +/// Hash tables are checked separately because either GNU or SysV hashing is valid. +pub const PROBE_SECTION_NAMES: [&str; 4] = [".dynsym", ".dynstr", ".gnu.version", ".gnu.version_r"]; /// ELF64 dynamic table record sizes. pub const ELF64_SYMBOL_SIZE: usize = 0x18; diff --git a/senbei-engine/src/android/extract/stage1.rs b/senbei-engine/src/android/extract/stage1.rs index d18600d..da53087 100644 --- a/senbei-engine/src/android/extract/stage1.rs +++ b/senbei-engine/src/android/extract/stage1.rs @@ -70,6 +70,16 @@ pub(crate) fn inspect( return invalid(format!("protected ELF lacks required section {wanted}")); } } + let has_symbol_hash = senbei_elf::SYMBOL_HASH_SECTION_NAMES.iter().any(|wanted| { + elf.section_headers.iter().any(|section| { + elf.shdr_strtab + .get_at(section.sh_name) + .is_some_and(|name| name == *wanted) + }) + }); + if !has_symbol_hash { + return invalid("protected ELF lacks a .gnu.hash or .hash symbol-hash section"); + } let (section_index, section) = matches[0]; let section_offset = usize::try_from(section.sh_offset) .map_err(|_| Error::Invalid("SHT_LOUSER offset exceeds usize".to_owned()))?; diff --git a/senbei-engine/src/android/restore/pipeline.rs b/senbei-engine/src/android/restore/pipeline.rs index 482bdce..002b217 100644 --- a/senbei-engine/src/android/restore/pipeline.rs +++ b/senbei-engine/src/android/restore/pipeline.rs @@ -757,7 +757,18 @@ fn dynamic_contains_tag(output: &[u8], dynamic: SectionHeader, wanted: u64) -> R } fn required_section_indices(names: &[String]) -> Result> { - let mut result = HashMap::with_capacity(senbei_elf::DYNAMIC_SECTION_NAMES.len()); + let present = senbei_elf::SYMBOL_HASH_SECTION_NAMES + .iter() + .filter(|name| names.iter().any(|section| section == *name)) + .count(); + if present == 0 { + return invalid( + "ELF must carry at least one of the .gnu.hash / .hash symbol-hash sections", + ); + } + let mut result = HashMap::with_capacity( + senbei_elf::DYNAMIC_SECTION_NAMES.len() + senbei_elf::SYMBOL_HASH_SECTION_NAMES.len(), + ); for required in senbei_elf::DYNAMIC_SECTION_NAMES { let indices = names .iter() @@ -772,17 +783,19 @@ fn required_section_indices(names: &[String]) -> Result return invalid(format!("ELF contains duplicate section {required}")), } } - let sysv_hash = names - .iter() - .enumerate() - .filter_map(|(index, name)| (name == ".hash").then_some(index)) - .collect::>(); - match sysv_hash.as_slice() { - [index] => { - result.insert(".hash", *index); + for hash_table in senbei_elf::SYMBOL_HASH_SECTION_NAMES { + let indices = names + .iter() + .enumerate() + .filter_map(|(index, name)| (name == hash_table).then_some(index)) + .collect::>(); + match indices.as_slice() { + [index] => { + result.insert(hash_table, *index); + } + [] => {} + _ => return invalid(format!("ELF contains duplicate section {hash_table}")), } - [] => {} - _ => return invalid("ELF contains duplicate section .hash"), } Ok(result) } @@ -862,17 +875,21 @@ fn metadata_mapping_length( usize_from_u64(rela_plt.size / ELF64_RELA_SIZE as u64, ".rela.plt count")? .checked_add(auxiliary.relocation2_count as usize) .ok_or_else(|| Error::Invalid("merged .rela.plt count overflow".to_owned()))?; - let gnu_hash_size = 28_usize - .checked_add( - new_symbol_count - .checked_sub(1) - .ok_or_else(|| { - Error::Invalid("dynamic symbol table is unexpectedly empty".to_owned()) - })? - .checked_mul(4) - .ok_or_else(|| Error::Invalid("GNU hash size overflow".to_owned()))?, - ) - .ok_or_else(|| Error::Invalid("GNU hash size overflow".to_owned()))?; + let gnu_hash_size = if indices.contains_key(".gnu.hash") { + 28_usize + .checked_add( + new_symbol_count + .checked_sub(1) + .ok_or_else(|| { + Error::Invalid("dynamic symbol table is unexpectedly empty".to_owned()) + })? + .checked_mul(4) + .ok_or_else(|| Error::Invalid("GNU hash size overflow".to_owned()))?, + ) + .ok_or_else(|| Error::Invalid("GNU hash size overflow".to_owned()))? + } else { + 0 + }; let sysv_hash_size = indices.contains_key(".hash").then(|| { new_symbol_count .checked_mul(2) @@ -1029,7 +1046,10 @@ fn materialize_static_elf_tables( .contains_key(".hash") .then(|| build_sysv_hash(&merged_names)) .transpose()?; - let gnu_hash_table = build_gnu_hash(&merged_names)?; + let gnu_hash_table = indices + .contains_key(".gnu.hash") + .then(|| build_gnu_hash(&merged_names)) + .transpose()?; let new_symbol_count = merged_names.len(); let new_dynstr_size = merged_strings.len(); @@ -1100,6 +1120,11 @@ fn materialize_static_elf_tables( let rela_dyn_count = merged_rela_dyn.len() / ELF64_RELA_SIZE; let rela_plt_count = merged_rela_plt.len() / ELF64_RELA_SIZE; + let gnu_hash = gnu_hash_table.map(|data| TablePayload { + name: ".gnu.hash", + alignment: 8, + data, + }); let mut tables = vec![ TablePayload { name: ".dynsym", @@ -1116,12 +1141,10 @@ fn materialize_static_elf_tables( alignment: 4, data: version_requirements, }, - TablePayload { - name: ".gnu.hash", - alignment: 8, - data: gnu_hash_table, - }, ]; + if let Some(payload) = gnu_hash { + tables.push(payload); + } if let Some(data) = sysv_hash { tables.push(TablePayload { name: ".hash", @@ -1224,10 +1247,12 @@ fn materialize_static_elf_tables( (DT_RELASZ, (rela_dyn_count * ELF64_RELA_SIZE) as u64), (DT_STRSZ, new_dynstr_size as u64), (DT_JMPREL, section_address(".rela.plt")), - (DT_GNU_HASH, section_address(".gnu.hash")), (DT_VERSYM, section_address(".gnu.version")), (DT_VERNEED, section_address(".gnu.version_r")), ]); + if indices.contains_key(".gnu.hash") { + dynamic_values.insert(DT_GNU_HASH, section_address(".gnu.hash")); + } if dynamic_contains_tag(output, dynamic, DT_RELACOUNT)? { dynamic_values.insert(DT_RELACOUNT, relative_count as u64); } @@ -1720,4 +1745,34 @@ mod tests { let error = AuxiliaryElfImage::parse(&auxiliary_image(0)).expect_err("missing null symbol"); assert!(error.to_string().contains("no null entry")); } + + fn dynamic_section_names(hash_sections: &[&str]) -> Vec { + senbei_elf::DYNAMIC_SECTION_NAMES + .into_iter() + .chain(hash_sections.iter().copied()) + .map(str::to_owned) + .collect() + } + + #[test] + fn required_sections_accept_each_hash_layout() { + for hashes in [ + [".gnu.hash"].as_slice(), + [".hash"].as_slice(), + [".gnu.hash", ".hash"].as_slice(), + ] { + let indices = required_section_indices(&dynamic_section_names(hashes)) + .expect("valid hash layout"); + for hash in hashes { + assert!(indices.contains_key(hash)); + } + } + } + + #[test] + fn required_sections_reject_missing_symbol_hash() { + let error = + required_section_indices(&dynamic_section_names(&[])).expect_err("missing hash"); + assert!(error.to_string().contains("at least one")); + } } From a45cc8aa90bfc20095fe33b5f741152430587d0d Mon Sep 17 00:00:00 2001 From: y52 Date: Thu, 17 Sep 2026 18:12:36 +0000 Subject: [PATCH 2/4] fix(metadata): restore IL2CPP 24.1 method indices --- senbei-io/src/android/mod.rs | 151 ++++-- senbei-io/src/job.rs | 8 +- senbei-io/src/ui.rs | 4 +- senbei-metadata/src/android/method_indices.rs | 467 ++++++++++++++++++ senbei-metadata/src/android/mod.rs | 2 + 5 files changed, 592 insertions(+), 40 deletions(-) create mode 100644 senbei-metadata/src/android/method_indices.rs diff --git a/senbei-io/src/android/mod.rs b/senbei-io/src/android/mod.rs index f338eb4..8f216b9 100644 --- a/senbei-io/src/android/mod.rs +++ b/senbei-io/src/android/mod.rs @@ -1,6 +1,6 @@ //! Android target orchestration: protected AArch64 shared libraries (`.so`), //! app packages (`.apk` / `.apks` / `.xapk`), and the Android variant of the -//! il2cpp method-token obfuscation. +//! il2cpp method-token/method-index obfuscation. //! //! The protection scheme hollows out an ELF64/AArch64 shared object and moves //! the original bytes into an encrypted payload appended as a `SHT_LOUSER` @@ -100,14 +100,32 @@ pub fn file_content_identity(path: &Path) -> std::io::Result { /// implementation detail of the two-phase restore, not user-facing output). /// Returns the unwrapped embedded metadata blob when the restored image /// carries one (see the module docs); the caller decides where to write it. -pub fn restore_so_file(input: &Path, dest: &Path, verbose: bool) -> Result>> { +struct SoRestoreContext { + embedded_metadata: Option>, + method_index_module: Option>, +} + +fn restore_so_file_with_context( + input: &Path, + dest: &Path, + verbose: bool, +) -> Result { let temporary = tempfile::tempdir().context("create stage-2 workspace")?; let stage2_dir = temporary.path().join("stage2"); - extract_stage2(&ExtractOptions::with_defaults( + let extraction = extract_stage2(&ExtractOptions::with_defaults( input.to_path_buf(), stage2_dir.clone(), )) .context("extract stage-1/stage-2 payload")?; + let method_index_module = extraction + .module_registry + .iter() + .find(|module| module.command_id == 0x0c) + .map(|module| { + std::fs::read(stage2_dir.join(&module.image_path)) + .with_context(|| format!("read decoded module 0x0C `{}`", module.image_path)) + }) + .transpose()?; restore_libil2cpp(&RestoreOptions { input: input.to_path_buf(), output: dest.to_path_buf(), @@ -120,9 +138,14 @@ pub fn restore_so_file(input: &Path, dest: &Path, verbose: bool) -> Result Result>> { + Ok(restore_so_file_with_context(input, dest, verbose)?.embedded_metadata) } /// Content identity for cross-source deduplication: the same library may @@ -134,8 +157,10 @@ pub fn content_identity(data: &[u8]) -> String { hex_digest(&digest.finalize()) } -/// Restore an il2cpp metadata blob (Android seeded permutation first, then the -/// structural remap used by the Windows builds). +/// Restore an il2cpp metadata blob. Paired v24.1 Android packages use the +/// method-index profile recovered from module 0x0C; later Android layouts use +/// the seeded MethodDef RID permutation before falling back to the structural +/// remap used by the Windows builds. /// /// The Android variant obfuscates MethodDef RIDs with a keyed five-round /// permutation; the correct seed is recovered by intersecting per-image key @@ -145,6 +170,32 @@ pub fn content_identity(data: &[u8]) -> String { /// canonical form. Both paths are no-ops (`remapped == 0`) on an /// already-clean blob. pub fn restore_metadata_bytes(data: &[u8]) -> anyhow::Result<(Vec, senbei_metadata::Report)> { + restore_metadata_bytes_with_module(data, None) +} + +fn restore_metadata_bytes_with_module( + data: &[u8], + method_index_module: Option<&[u8]>, +) -> anyhow::Result<(Vec, senbei_metadata::Report)> { + let version = data + .get(4..8) + .and_then(|bytes| <[u8; 4]>::try_from(bytes).ok()) + .map(u32::from_le_bytes); + if version == Some(24) + && let Some(module) = method_index_module + { + let (out, report) = senbei_metadata::android::restore_method_indices_v24_1(data, module) + .map_err(anyhow::Error::new)?; + return Ok(( + out, + senbei_metadata::Report { + version: report.version, + methods: report.methods, + remapped: report.changed_indices, + modules: 0, + }, + )); + } if let Ok(discovery) = senbei_metadata::android::discover_method_token_seeds(data) && matches!(discovery.version, 29 | 31 | 39) { @@ -190,7 +241,7 @@ pub struct EntryOutcome { pub enum EntryKind { /// A protected shared library, restored. So, - /// An il2cpp metadata blob, de-obfuscated (`remapped` tokens changed). + /// An il2cpp metadata blob, de-obfuscated (`remapped` method fields changed). Metadata { remapped: usize }, /// A metadata blob unwrapped from a restored library's data section. EmbeddedMetadata, @@ -204,12 +255,30 @@ pub enum EntryStatus { Duplicate, /// Content-probed but not a target (unprotected library). NotTarget, - /// A metadata blob whose tokens were already canonical; no copy written. + /// A metadata blob whose protected method fields were already canonical; no copy written. Unchanged, /// Recognised as a target but the restore failed. Failed(anyhow::Error), } +struct PackageRestoreContext { + method_index_module: Option>, + verbose: bool, +} + +impl PackageRestoreContext { + fn new(verbose: bool) -> Self { + Self { + method_index_module: None, + verbose, + } + } +} + +fn package_entry_priority(path: &Path) -> u8 { + if is_so_name(path) { 0 } else { 1 } +} + /// Restore every protected library and metadata blob inside one app package. /// /// `rel` is the package's path relative to the scanned root (or its bare file @@ -233,6 +302,7 @@ pub fn restore_package( let mut archive = open_package(package)?; let temporary = tempfile::tempdir().context("create package workspace")?; let mut outcomes = Vec::new(); + let mut context = PackageRestoreContext::new(verbose); let mut direct = Vec::new(); let mut nested = Vec::new(); @@ -261,6 +331,7 @@ pub fn restore_package( } } } + direct.sort_by_key(|(_, name)| package_entry_priority(name)); for (index, name) in direct { let label = format!("{}::{}", rel.display(), name.display()); let dest = out_root.join(rel).join(crate::job::out_name(&name)); @@ -271,12 +342,13 @@ pub fn restore_package( &dest, &temporary, seen, - verbose, + &mut context, ) .with_context(|| format!("extract `{label}`"))?; outcomes.append(&mut entry_outcomes); } for (index, name) in nested { + let mut nested_context = PackageRestoreContext::new(verbose); let nested_label = rel.join(&name); let nested_path = extract_entry(&mut archive, index, &temporary, &nested_label) .with_context(|| format!("extract `{}`", nested_label.display()))?; @@ -296,6 +368,9 @@ pub fn restore_package( } } } + // Restore the protected library before metadata so v24.1 metadata can + // consume the runtime profile recovered from module 0x0C. + entries.sort_by_key(|(_, entry_name)| package_entry_priority(entry_name)); // Keep the nested package's stem in the output layout so two splits // carrying same-named entries cannot collide. let base = rel.join(name.with_extension("")); @@ -309,7 +384,7 @@ pub fn restore_package( &dest, &temporary, seen, - verbose, + &mut nested_context, ) .with_context(|| format!("extract `{label}`"))?; outcomes.append(&mut entry_outcomes); @@ -328,7 +403,7 @@ fn restore_package_entry( dest: &Path, temporary: &tempfile::TempDir, seen: &mut HashSet, - verbose: bool, + context: &mut PackageRestoreContext, ) -> Result> { let entry_path = extract_entry(archive, index, temporary, Path::new(label))?; let entry_file = @@ -358,31 +433,39 @@ fn restore_package_entry( if is_so { drop(entry_data); drop(entry_file); - return Ok(match restore_so_file(&entry_path, dest, verbose) { - Ok(embedded) => { - let mut outcomes = vec![outcome(EntryKind::So, EntryStatus::Restored)]; - if let Some(blob) = embedded { - let meta_dest = embedded_metadata_dest(dest); - let status = match write_metadata_blob(&meta_dest, &blob) { - Ok(()) => EntryStatus::Restored, - Err(error) => EntryStatus::Failed(error), - }; - outcomes.push(EntryOutcome { - label: format!("{label} (embedded metadata)"), - dest: meta_dest, - kind: EntryKind::EmbeddedMetadata, - status, - }); + return Ok( + match restore_so_file_with_context(&entry_path, dest, context.verbose) { + Ok(restored) => { + if let Some(module) = restored.method_index_module { + context.method_index_module = Some(module); + } + let mut outcomes = vec![outcome(EntryKind::So, EntryStatus::Restored)]; + if let Some(blob) = restored.embedded_metadata { + let meta_dest = embedded_metadata_dest(dest); + let status = match write_metadata_blob(&meta_dest, &blob) { + Ok(()) => EntryStatus::Restored, + Err(error) => EntryStatus::Failed(error), + }; + outcomes.push(EntryOutcome { + label: format!("{label} (embedded metadata)"), + dest: meta_dest, + kind: EntryKind::EmbeddedMetadata, + status, + }); + } + outcomes } - outcomes - } - Err(error) => vec![outcome(EntryKind::So, EntryStatus::Failed(error))], - }); + Err(error) => vec![outcome(EntryKind::So, EntryStatus::Failed(error))], + }, + ); } - // Metadata entry: write only when the restore actually changed tokens — + // Metadata entry: write only when the restore actually changed protected method fields — // a clean blob needs no copy (same contract as loose metadata files). - let kind_and_status = match restore_metadata_bytes(&entry_data) { + let kind_and_status = match restore_metadata_bytes_with_module( + &entry_data, + context.method_index_module.as_deref(), + ) { Ok((out, report)) if report.remapped > 0 => { let kind = EntryKind::Metadata { remapped: report.remapped, diff --git a/senbei-io/src/job.rs b/senbei-io/src/job.rs index 16fafaa..01ecb66 100644 --- a/senbei-io/src/job.rs +++ b/senbei-io/src/job.rs @@ -15,7 +15,7 @@ pub struct Summary { /// — likely to crash at runtime (e.g. 0xC0000005). Counted in addition to /// `unpacked` (a suspect file is still written). pub suspect: usize, - /// il2cpp `global-metadata.dat` files de-obfuscated (method tokens remapped), + /// il2cpp `global-metadata.dat` files de-obfuscated (protected method fields remapped), /// including blobs unwrapped from restored Android libraries. pub metadata: usize, /// Android app packages (`.apk`/`.apks`/`.xapk`) opened and searched. @@ -369,7 +369,7 @@ pub fn run_folder_opts( crate::ui::metadata(&bar, suppress_file_lines, &rel, report.remapped, &dest); if let Some(log) = &log { log.step(&format!( - "META {rel:?} -> {dest:?}: v{} remapped {} method tokens", + "META {rel:?} -> {dest:?}: v{} remapped {} method fields", report.version, report.remapped )); } @@ -506,13 +506,13 @@ pub fn run_file_v( s.metadata = 1; if let Some(log) = &log { log.step(&format!( - "META {:?} -> {:?}: v{} remapped {} method tokens", + "META {:?} -> {:?}: v{} remapped {} method fields", input, dest, report.version, report.remapped )); } if quiet == 0 { println!( - "✓ metadata v{} -> {:?} ({} method tokens remapped)", + "✓ metadata v{} -> {:?} ({} method fields remapped)", report.version, dest, report.remapped ); } diff --git a/senbei-io/src/ui.rs b/senbei-io/src/ui.rs index 87f8f19..90ca6ef 100644 --- a/senbei-io/src/ui.rs +++ b/senbei-io/src/ui.rs @@ -39,13 +39,13 @@ pub fn ok_label(bar: &ProgressBar, quiet: bool, rel: &str, label: &str, dest: &P } /// Print a green success line for a de-obfuscated il2cpp `global-metadata.dat`, -/// reporting how many method tokens were remapped. +/// reporting how many protected method fields were remapped. pub fn metadata(bar: &ProgressBar, quiet: bool, rel: &Path, remapped: usize, dest: &Path) { if quiet { return; } let msg = format!( - "{} metadata {} -> {} ({} method tokens remapped)", + "{} metadata {} -> {} ({} method fields remapped)", "✓".green(), rel.display(), dest.display(), diff --git a/senbei-metadata/src/android/method_indices.rs b/senbei-metadata/src/android/method_indices.rs new file mode 100644 index 0000000..57c4fac --- /dev/null +++ b/senbei-metadata/src/android/method_indices.rs @@ -0,0 +1,467 @@ +//! Restoration of IL2CPP v24.1 `Il2CppMethodDefinition.methodIndex` values. +//! +//! CrackProof's v24.1 Android module rewrites the global method-index permutation +//! at runtime. The transform parameters are carried by the decoded module `0x0C`, +//! so the restore derives them from that module instead of hard-coding a +//! game-specific table. + +use serde::Serialize; + +use crate::common::MAGIC; + +const RAW_VERSION_24: u32 = 24; +const HDR_METHODS: usize = 0x30; +const V24_1_METHOD_STRIDE: usize = 0x34; +const V24_1_METHOD_INDEX_OFFSET: usize = 0x14; +const V24_1_SELECTOR: u32 = 1; +const EXCEPTION_COUNT: usize = 256; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MethodIndexReport { + pub version: u32, + pub methods: usize, + pub active_methods: usize, + pub changed_indices: usize, + pub exception_count: usize, + pub seed: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum MethodIndexError { + #[error("not an IL2CPP global-metadata.dat")] + NotMetadata, + #[error("unsupported metadata version {0}")] + UnsupportedVersion(u32), + #[error("malformed v24.1 metadata: {0}")] + Malformed(String), + #[error("v24.1 method-index profile was not found in module 0x0C")] + ProfileNotFound, + #[error("multiple v24.1 method-index profiles matched module 0x0C")] + AmbiguousProfile, + #[error("v24.1 method-index restoration failed validation: {0}")] + Validation(String), +} + +type Result = std::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Profile { + seed: u32, + exception_values: [u32; EXCEPTION_COUNT], +} + +fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { + let end = offset + .checked_add(size) + .ok_or_else(|| MethodIndexError::Malformed("byte range overflow".to_owned()))?; + data.get(offset..end).ok_or_else(|| { + MethodIndexError::Malformed(format!( + "byte range 0x{offset:x}..0x{end:x} is out of bounds" + )) + }) +} + +fn read_u32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| MethodIndexError::Malformed("invalid u32 range".to_owned()))?; + Ok(u32::from_le_bytes(value)) +} + +fn read_i32(data: &[u8], offset: usize) -> Result { + let value: [u8; 4] = bytes(data, offset, 4)? + .try_into() + .map_err(|_| MethodIndexError::Malformed("invalid i32 range".to_owned()))?; + Ok(i32::from_le_bytes(value)) +} + +fn write_i32(data: &mut [u8], offset: usize, value: i32) -> Result<()> { + let destination = data.get_mut(offset..offset + 4).ok_or_else(|| { + MethodIndexError::Malformed("method-index write is out of bounds".to_owned()) + })?; + destination.copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +fn method_table(data: &[u8]) -> Result<(usize, usize)> { + if read_u32(data, 0)? != MAGIC { + return Err(MethodIndexError::NotMetadata); + } + let version = read_u32(data, 4)?; + if version != RAW_VERSION_24 { + return Err(MethodIndexError::UnsupportedVersion(version)); + } + let offset = read_u32(data, HDR_METHODS)? as usize; + let size = read_u32(data, HDR_METHODS + 4)? as usize; + bytes(data, offset, size)?; + if !size.is_multiple_of(V24_1_METHOD_STRIDE) { + return Err(MethodIndexError::Malformed(format!( + "method table size 0x{size:x} is not divisible by v24.1 stride 0x{V24_1_METHOD_STRIDE:x}" + ))); + } + Ok((offset, size / V24_1_METHOD_STRIDE)) +} + +fn method_indices(data: &[u8]) -> Result<(usize, Vec>)> { + let (offset, count) = method_table(data)?; + let mut indices = Vec::with_capacity(count); + for method in 0..count { + let value = read_i32( + data, + offset + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET, + )?; + indices.push((value >= 0).then_some(value as u32)); + } + Ok((offset, indices)) +} + +fn is_complete_permutation(indices: &[Option]) -> bool { + let active_count = indices.iter().filter(|value| value.is_some()).count(); + if active_count == 0 || active_count > u32::MAX as usize { + return false; + } + let mut seen = vec![false; active_count]; + for value in indices.iter().flatten().copied() { + let Ok(index) = usize::try_from(value) else { + return false; + }; + if index >= active_count || seen[index] { + return false; + } + seen[index] = true; + } + seen.into_iter().all(|value| value) +} + +fn permutation_key(seed: u32, count: u32) -> Result { + if count < 2 { + return Err(MethodIndexError::Validation( + "method-index permutation requires at least two active methods".to_owned(), + )); + } + if count > u32::MAX / 2 { + return Err(MethodIndexError::Validation( + "active method count is too large for the permutation mirror".to_owned(), + )); + } + let half = count / 2; + if half == 0 { + return Err(MethodIndexError::Validation( + "method-index permutation has a zero divisor".to_owned(), + )); + } + Ok(seed % half + count / 4) +} + +fn permutation_round(mut value: u32, count: u32, key: u32) -> u32 { + let mirror = count * 2 - 1; + if value & 1 != 0 { + value = mirror - value; + } + value >>= 1; + if value >= count { + value = mirror - value; + } + let adjusted = i64::from(value) - i64::from(key); + if adjusted < 0 { + (adjusted + i64::from(count)) as u32 + } else { + adjusted as u32 + } +} + +fn transform_index(mut value: u32, count: u32, seed: u32) -> Result { + let key = permutation_key(seed, count)?; + for _ in 0..5 { + value = permutation_round(value, count, key); + } + Ok(value) +} + +fn transformed_values(indices: &[Option], seed: u32) -> Result>> { + let active_count = indices.iter().filter(|value| value.is_some()).count(); + let count = u32::try_from(active_count) + .map_err(|_| MethodIndexError::Validation("active method count exceeds u32".to_owned()))?; + indices + .iter() + .map(|value| { + value + .map(|value| transform_index(value, count, seed)) + .transpose() + }) + .collect() +} + +fn missing_values(indices: &[Option]) -> Option> { + let active_count = indices.iter().filter(|value| value.is_some()).count(); + let mut seen = vec![false; active_count]; + for value in indices.iter().flatten().copied() { + let index = usize::try_from(value).ok()?; + if index >= active_count { + return None; + } + seen[index] = true; + } + Some( + seen.into_iter() + .enumerate() + .filter_map(|(index, present)| (!present).then_some(index as u32)) + .collect(), + ) +} + +fn find_exception_table( + module: &[u8], + count: u32, + missing: &[u32], +) -> Option<[u32; EXCEPTION_COUNT]> { + if missing.len() != EXCEPTION_COUNT || count == 0 { + return None; + } + let mut wanted = vec![false; count as usize]; + for &value in missing { + let slot = wanted.get_mut(value as usize)?; + *slot = true; + } + let table_bytes = EXCEPTION_COUNT * 4; + for offset in (8..=module.len().checked_sub(table_bytes)?).step_by(4) { + if read_u32(module, offset - 8).ok()? != V24_1_SELECTOR + || read_u32(module, offset - 4).ok()? != count - 1 + { + continue; + } + let mut values = [0_u32; EXCEPTION_COUNT]; + let mut seen = vec![false; count as usize]; + let mut matches = true; + for (index, slot) in values.iter_mut().enumerate() { + let value = read_u32(module, offset + index * 4).ok()?; + let Some(is_wanted) = wanted.get(value as usize) else { + matches = false; + break; + }; + if !*is_wanted || seen[value as usize] { + matches = false; + break; + } + seen[value as usize] = true; + *slot = value; + } + if matches { + return Some(values); + } + } + None +} + +fn profile_candidates(module: &[u8], indices: &[Option]) -> Result> { + let active_count = indices.iter().filter(|value| value.is_some()).count(); + let count = u32::try_from(active_count) + .map_err(|_| MethodIndexError::Validation("active method count exceeds u32".to_owned()))?; + if count <= EXCEPTION_COUNT as u32 { + return Err(MethodIndexError::Validation(format!( + "active method count {count} is too small for the v24.1 exception table" + ))); + } + + let mut profiles = Vec::new(); + for offset in (0..module.len().saturating_sub(8)).step_by(4) { + let Ok(version) = read_u32(module, offset + 4) else { + continue; + }; + if version != RAW_VERSION_24 { + continue; + } + let seed = read_u32(module, offset)?; + let transformed = transformed_values(indices, seed)?; + let Some(missing) = missing_values(&transformed) else { + continue; + }; + let Some(exception_values) = find_exception_table(module, count, &missing) else { + continue; + }; + let mut candidate = transformed; + let mut exception = exception_values.iter().copied(); + for value in candidate.iter_mut().flatten() { + if let Some(replacement) = exception.next() { + *value = replacement; + } else { + break; + } + } + if exception.next().is_none() && is_complete_permutation(&candidate) { + profiles.push(Profile { + seed, + exception_values, + }); + } + } + profiles.sort_by_key(|profile| profile.seed); + profiles.dedup(); + Ok(profiles) +} + +/// Restore the v24.1 `methodIndex` permutation using the decoded CrackProof +/// module `0x0C` that accompanied the protected library. +/// +/// The profile is accepted only when the module supplies a seed plus a +/// 256-entry exception table that turns the restored non-negative indices into +/// the exact permutation `0..active_method_count`. This makes a wrong module or +/// incompatible v24 sub-layout fail without mutating the metadata. +pub fn restore_method_indices_v24_1( + data: &[u8], + module_0c: &[u8], +) -> Result<(Vec, MethodIndexReport)> { + let (method_offset, indices) = method_indices(data)?; + let active_count = indices.iter().filter(|value| value.is_some()).count(); + if is_complete_permutation(&indices) { + return Ok(( + data.to_vec(), + MethodIndexReport { + version: RAW_VERSION_24, + methods: indices.len(), + active_methods: active_count, + changed_indices: 0, + exception_count: 0, + seed: "clean".to_owned(), + }, + )); + } + + let profiles = profile_candidates(module_0c, &indices)?; + let profile = match profiles.as_slice() { + [] => return Err(MethodIndexError::ProfileNotFound), + [profile] => profile, + _ => return Err(MethodIndexError::AmbiguousProfile), + }; + + let mut restored = transformed_values(&indices, profile.seed)?; + let mut exception = profile.exception_values.iter().copied(); + for value in restored.iter_mut().flatten() { + if let Some(replacement) = exception.next() { + *value = replacement; + } else { + break; + } + } + if exception.next().is_some() { + return Err(MethodIndexError::Validation( + "metadata has fewer active methods than the exception table".to_owned(), + )); + } + if !is_complete_permutation(&restored) { + return Err(MethodIndexError::Validation( + "restored methodIndex values are not a complete permutation".to_owned(), + )); + } + + let mut output = data.to_vec(); + let mut changed_indices = 0_usize; + for (method, value) in restored.iter().enumerate() { + let Some(value) = value else { + continue; + }; + let offset = method_offset + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET; + let old = read_i32(data, offset)?; + let new = i32::try_from(*value).map_err(|_| { + MethodIndexError::Validation("restored methodIndex exceeds i32".to_owned()) + })?; + if old != new { + write_i32(&mut output, offset, new)?; + changed_indices += 1; + } + } + + Ok(( + output, + MethodIndexReport { + version: RAW_VERSION_24, + methods: indices.len(), + active_methods: active_count, + changed_indices, + exception_count: EXCEPTION_COUNT, + seed: format!("0x{:08X}", profile.seed), + }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inverse_map(count: u32, seed: u32) -> Vec { + let mut inverse = vec![u32::MAX; count as usize]; + for value in 0..count { + let transformed = transform_index(value, count, seed).unwrap(); + inverse[transformed as usize] = value; + } + assert!(inverse.iter().all(|value| *value != u32::MAX)); + inverse + } + + fn fixture() -> (Vec, Vec) { + let active_count = 512_u32; + let seed = 0x7770_0dcc; + let inverse = inverse_map(active_count, seed); + let header_size = 0x100usize; + let method_size = active_count as usize * V24_1_METHOD_STRIDE; + let mut metadata = vec![0_u8; header_size + method_size]; + metadata[0..4].copy_from_slice(&MAGIC.to_le_bytes()); + metadata[4..8].copy_from_slice(&RAW_VERSION_24.to_le_bytes()); + metadata[HDR_METHODS..HDR_METHODS + 4].copy_from_slice(&(header_size as u32).to_le_bytes()); + metadata[HDR_METHODS + 4..HDR_METHODS + 8] + .copy_from_slice(&(method_size as u32).to_le_bytes()); + + // Before the exception patch the first half duplicates the second half, + // leaving 0..255 missing. The module's 256-entry table fills exactly + // those first active methods after the five-round transform. + for method in 0..active_count as usize { + let transformed = 256 + (method % 256) as u32; + let protected = inverse[transformed as usize]; + let offset = header_size + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET; + metadata[offset..offset + 4].copy_from_slice(&(protected as i32).to_le_bytes()); + } + + let mut module = vec![0_u8; 0x1000]; + module[0x100..0x104].copy_from_slice(&seed.to_le_bytes()); + module[0x104..0x108].copy_from_slice(&RAW_VERSION_24.to_le_bytes()); + module[0x3f8..0x3fc].copy_from_slice(&V24_1_SELECTOR.to_le_bytes()); + module[0x3fc..0x400].copy_from_slice(&(active_count - 1).to_le_bytes()); + for value in 0..EXCEPTION_COUNT as u32 { + let offset = 0x400 + value as usize * 4; + module[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + (metadata, module) + } + + #[test] + fn restores_v24_1_method_index_permutation() { + let (metadata, module) = fixture(); + let (restored, report) = restore_method_indices_v24_1(&metadata, &module).unwrap(); + assert_eq!(report.active_methods, 512); + assert_eq!(report.exception_count, 256); + assert_eq!(report.seed, "0x77700DCC"); + let (offset, indices) = method_indices(&restored).unwrap(); + assert_eq!(offset, 0x100); + assert!(is_complete_permutation(&indices)); + for (method, value) in indices.into_iter().enumerate() { + assert_eq!(value, Some(method as u32)); + } + } + + #[test] + fn clean_v24_1_metadata_is_idempotent() { + let (metadata, module) = fixture(); + let (restored, _) = restore_method_indices_v24_1(&metadata, &module).unwrap(); + let (again, report) = restore_method_indices_v24_1(&restored, &module).unwrap(); + assert_eq!(again, restored); + assert_eq!(report.changed_indices, 0); + assert_eq!(report.seed, "clean"); + } + + #[test] + fn rejects_unrelated_module() { + let (metadata, _) = fixture(); + let error = restore_method_indices_v24_1(&metadata, &[0_u8; 0x1000]).unwrap_err(); + assert_eq!(error, MethodIndexError::ProfileNotFound); + } +} diff --git a/senbei-metadata/src/android/mod.rs b/senbei-metadata/src/android/mod.rs index 0251c69..e704f0c 100644 --- a/senbei-metadata/src/android/mod.rs +++ b/senbei-metadata/src/android/mod.rs @@ -2,9 +2,11 @@ mod embedded; mod keystream; +mod method_indices; mod method_tokens; pub use embedded::{embedded_metadata_size, extract_embedded_metadata}; +pub use method_indices::{MethodIndexError, MethodIndexReport, restore_method_indices_v24_1}; pub use method_tokens::{ DEFAULT_METHOD_TOKEN_SEED, Error, ImageKeyDiscovery, Report, SeedDiscoveryReport, discover_method_token_seeds, restore_method_tokens, From 539861acbda0170955dcedbbe38bd2edcea62813 Mon Sep 17 00:00:00 2001 From: y52 Date: Thu, 17 Sep 2026 18:12:35 +0000 Subject: [PATCH 3/4] fix(elf): relocate crowded program header tables --- senbei-elf/src/layout.rs | 395 ++++++++++++++++-- senbei-engine/src/android/restore/pipeline.rs | 13 +- 2 files changed, 372 insertions(+), 36 deletions(-) diff --git a/senbei-elf/src/layout.rs b/senbei-elf/src/layout.rs index c1bf4e2..0772ca5 100644 --- a/senbei-elf/src/layout.rs +++ b/senbei-elf/src/layout.rs @@ -5,6 +5,8 @@ pub const SHT_STRTAB: u32 = 3; pub const SHT_LOUSER: u32 = 0x8000_0000; pub const SHF_ALLOC: u64 = 2; const PT_LOAD: u32 = 1; +const PT_NOTE: u32 = 4; +const PT_PHDR: u32 = 6; pub const PF_R: u32 = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -220,7 +222,89 @@ impl ElfLayout { Ok(alignment) } - pub fn append_load_segment(&self, output: &mut [u8], segment: LoadSegment) -> Result { + pub fn additional_program_header_reservation(&self) -> Result { + if self.program_header_size != 0x38 { + return invalid("unexpected ELF program header size"); + } + let new_count = self + .program_header_count + .checked_add(1) + .ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?; + let header_offset = checked_index( + self.program_header_offset, + self.program_header_count, + self.program_header_size, + )?; + let header_end = header_offset + .checked_add(self.program_header_size) + .ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?; + let first_file_section = self + .section_headers + .iter() + .filter(|section| section.section_type != SHT_NOBITS && section.size != 0) + .map(|section| section.offset) + .min(); + if first_file_section.is_none_or(|offset| header_end as u64 <= offset) { + return Ok(0); + } + new_count + .checked_mul(self.program_header_size) + .ok_or_else(|| Error::Invalid("relocated program header size overflow".to_owned())) + } + + fn has_program_header_type(&self, data: &[u8], program_type: u32) -> Result { + for index in 0..self.program_header_count { + let offset = + checked_index(self.program_header_offset, index, self.program_header_size)?; + if read_u32(data, offset)? == program_type { + return Ok(true); + } + } + Ok(false) + } + + fn reusable_note_program_header(&self, data: &[u8]) -> Result> { + let mut note_count = 0_usize; + let mut candidates = Vec::new(); + for index in 0..self.program_header_count { + let offset = + checked_index(self.program_header_offset, index, self.program_header_size)?; + if read_u32(data, offset)? != PT_NOTE { + continue; + } + note_count += 1; + let file_offset = read_u64(data, offset + 8)?; + let file_size = read_u64(data, offset + 0x20)?; + let file_end = file_offset + .checked_add(file_size) + .ok_or_else(|| Error::Invalid("PT_NOTE file range overflow".to_owned()))?; + let contained = self.program_headers.iter().any(|segment| { + segment + .offset + .checked_add(segment.file_size) + .is_some_and(|segment_end| { + segment.offset <= file_offset && file_end <= segment_end + }) + }); + if contained { + candidates.push((file_offset, index)); + } + } + if note_count < 2 { + return Ok(None); + } + Ok(candidates + .into_iter() + .max_by_key(|(file_offset, _)| *file_offset) + .map(|(_, index)| index)) + } + + pub fn append_load_segment( + &self, + output: &mut [u8], + segment: LoadSegment, + reserved_program_header_bytes: usize, + ) -> Result { if self.program_header_size != 0x38 { return invalid("unexpected ELF program header size"); } @@ -274,23 +358,11 @@ impl ElfLayout { .ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?; let new_count_u16 = u16::try_from(new_count) .map_err(|_| Error::Invalid("program header count exceeds u16".to_owned()))?; - let header_offset = checked_index( - self.program_header_offset, - self.program_header_count, - self.program_header_size, - )?; - let header_end = header_offset - .checked_add(self.program_header_size) - .ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?; - slice(output, header_offset, self.program_header_size)?; - let first_file_section = self - .section_headers - .iter() - .filter(|section| section.section_type != SHT_NOBITS && section.size != 0) - .map(|section| section.offset) - .min(); - if first_file_section.is_some_and(|offset| header_end as u64 > offset) { - return invalid("no space for an additional program header"); + let reservation = self.additional_program_header_reservation()?; + if reserved_program_header_bytes != reservation { + return invalid(format!( + "program-header reservation mismatch: expected 0x{reservation:x}, got 0x{reserved_program_header_bytes:x}" + )); } let mut header = [0_u8; 0x38]; @@ -302,17 +374,95 @@ impl ElfLayout { header[0x20..0x28].copy_from_slice(&segment.file_size.to_le_bytes()); header[0x28..0x30].copy_from_slice(&segment.memory_size.to_le_bytes()); header[0x30..0x38].copy_from_slice(&segment.alignment.to_le_bytes()); - output - .get_mut(header_offset..header_end) - .ok_or_else(|| Error::Invalid("new program header exceeds output".to_owned()))? - .copy_from_slice(&header); - output - .get_mut(0x38..0x3a) - .ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))? - .copy_from_slice(&new_count_u16.to_le_bytes()); + + let old_table_size = self + .program_header_count + .checked_mul(self.program_header_size) + .ok_or_else(|| Error::Invalid("program header table size overflow".to_owned()))?; + let new_table_size = new_count + .checked_mul(self.program_header_size) + .ok_or_else(|| Error::Invalid("program header table size overflow".to_owned()))?; + let mut updated_program_header_offset = self.program_header_offset; + let mut updated_program_header_count = new_count; + if reservation == 0 { + let header_offset = checked_index( + self.program_header_offset, + self.program_header_count, + self.program_header_size, + )?; + let header_end = header_offset + .checked_add(self.program_header_size) + .ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?; + output + .get_mut(header_offset..header_end) + .ok_or_else(|| Error::Invalid("new program header exceeds output".to_owned()))? + .copy_from_slice(&header); + update_pt_phdr( + output, + self.program_header_offset, + self.program_header_count, + self.program_header_size, + None, + new_table_size as u64, + )?; + } else if let Some(note_index) = self.reusable_note_program_header(output)? { + let header_offset = checked_index( + self.program_header_offset, + note_index, + self.program_header_size, + )?; + let header_end = header_offset + .checked_add(self.program_header_size) + .ok_or_else(|| Error::Invalid("reused program header range overflow".to_owned()))?; + output + .get_mut(header_offset..header_end) + .ok_or_else(|| Error::Invalid("reused program header exceeds output".to_owned()))? + .copy_from_slice(&header); + updated_program_header_count = self.program_header_count; + } else { + if !self.has_program_header_type(output, PT_PHDR)? { + return invalid( + "cannot safely relocate program headers without PT_PHDR or a redundant PT_NOTE", + ); + } + if reservation != new_table_size { + return invalid("relocated program-header reservation has unexpected size"); + } + if segment.file_size < reservation as u64 { + return invalid("new PT_LOAD is too small for relocated program headers"); + } + let relocated = usize_from_u64(segment.offset, "relocated program header offset")?; + let relocated_end = relocated.checked_add(new_table_size).ok_or_else(|| { + Error::Invalid("relocated program header range overflow".to_owned()) + })?; + slice(output, relocated, new_table_size)?; + let old_table = slice(output, self.program_header_offset, old_table_size)?.to_vec(); + output[relocated..relocated + old_table_size].copy_from_slice(&old_table); + output[relocated + old_table_size..relocated_end].copy_from_slice(&header); + output + .get_mut(0x20..0x28) + .ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))? + .copy_from_slice(&segment.offset.to_le_bytes()); + update_pt_phdr( + output, + relocated, + self.program_header_count, + self.program_header_size, + Some((segment.offset, segment.virtual_address)), + new_table_size as u64, + )?; + updated_program_header_offset = relocated; + } + if updated_program_header_count == new_count { + output + .get_mut(0x38..0x3a) + .ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))? + .copy_from_slice(&new_count_u16.to_le_bytes()); + } let mut updated = self.clone(); - updated.program_header_count = new_count; + updated.program_header_offset = updated_program_header_offset; + updated.program_header_count = updated_program_header_count; updated.program_headers.push(segment); Ok(updated) } @@ -394,6 +544,47 @@ impl ElfLayout { } } +fn update_pt_phdr( + output: &mut [u8], + table_offset: usize, + old_count: usize, + entry_size: usize, + relocated: Option<(u64, u64)>, + table_size: u64, +) -> Result<()> { + for index in 0..old_count { + let offset = checked_index(table_offset, index, entry_size)?; + if read_u32(output, offset)? != PT_PHDR { + continue; + } + if let Some((file_offset, virtual_address)) = relocated { + output + .get_mut(offset + 8..offset + 0x10) + .ok_or_else(|| Error::Invalid("PT_PHDR file offset exceeds output".to_owned()))? + .copy_from_slice(&file_offset.to_le_bytes()); + output + .get_mut(offset + 0x10..offset + 0x18) + .ok_or_else(|| Error::Invalid("PT_PHDR virtual address exceeds output".to_owned()))? + .copy_from_slice(&virtual_address.to_le_bytes()); + output + .get_mut(offset + 0x18..offset + 0x20) + .ok_or_else(|| { + Error::Invalid("PT_PHDR physical address exceeds output".to_owned()) + })? + .copy_from_slice(&virtual_address.to_le_bytes()); + } + output + .get_mut(offset + 0x20..offset + 0x28) + .ok_or_else(|| Error::Invalid("PT_PHDR file size exceeds output".to_owned()))? + .copy_from_slice(&table_size.to_le_bytes()); + output + .get_mut(offset + 0x28..offset + 0x30) + .ok_or_else(|| Error::Invalid("PT_PHDR memory size exceeds output".to_owned()))? + .copy_from_slice(&table_size.to_le_bytes()); + } + Ok(()) +} + pub fn slice(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { let end = offset .checked_add(size) @@ -585,6 +776,7 @@ mod tests { flags: PF_R, alignment: 0x1000, }, + 0, ) .expect("append segment"); assert_eq!(updated.program_header_count, 1); @@ -594,7 +786,7 @@ mod tests { } #[test] - fn append_load_segment_rejects_program_header_overlap() { + fn append_load_segment_fails_closed_when_no_safe_phdr_slot() { let mut elf_layout = ElfLayout { entrypoint: 0, program_header_offset: 0, @@ -617,20 +809,157 @@ mod tests { alignment: 1, entry_size: 0, }); - let mut output = vec![0_u8; 0x100]; + let reservation = elf_layout + .additional_program_header_reservation() + .expect("reservation"); + assert_eq!(reservation, 0x38); + let mut output = vec![0_u8; 0x200]; let error = elf_layout .append_load_segment( &mut output, LoadSegment { offset: 0x80, virtual_address: 0x1080, - file_size: 0x20, - memory_size: 0x20, + file_size: 0x80, + memory_size: 0x80, + flags: PF_R, + alignment: 0x1000, + }, + reservation, + ) + .expect_err("unsafe relocation must fail"); + assert!(error.to_string().contains("cannot safely relocate")); + } + + #[test] + fn append_load_segment_reuses_redundant_note_when_no_slack() { + let mut elf_layout = ElfLayout { + entrypoint: 0, + program_header_offset: 0x40, + program_header_size: 0x38, + program_header_count: 3, + program_headers: vec![LoadSegment { + offset: 0, + virtual_address: 0, + file_size: 0x400, + memory_size: 0x400, + flags: PF_R, + alignment: 0x1000, + }], + section_headers: Vec::new(), + section_name_index: 0, + private_section_index: usize::MAX, + }; + elf_layout.section_headers.push(SectionHeader { + name: 0, + section_type: 1, + flags: 0, + address: 0, + offset: 0xe8, + size: 1, + link: 0, + info: 0, + alignment: 1, + entry_size: 0, + }); + let mut output = vec![0_u8; 0x2000]; + output[0x38..0x3a].copy_from_slice(&3_u16.to_le_bytes()); + output[0x40..0x44].copy_from_slice(&PT_LOAD.to_le_bytes()); + output[0x78..0x7c].copy_from_slice(&PT_NOTE.to_le_bytes()); + output[0x80..0x88].copy_from_slice(&0x100_u64.to_le_bytes()); + output[0x98..0xa0].copy_from_slice(&0x20_u64.to_le_bytes()); + output[0xb0..0xb4].copy_from_slice(&PT_NOTE.to_le_bytes()); + output[0xb8..0xc0].copy_from_slice(&0x200_u64.to_le_bytes()); + output[0xd0..0xd8].copy_from_slice(&0x20_u64.to_le_bytes()); + + let reservation = elf_layout + .additional_program_header_reservation() + .expect("reservation"); + assert_eq!(reservation, 0xe0); + let updated = elf_layout + .append_load_segment( + &mut output, + LoadSegment { + offset: 0x1000, + virtual_address: 0x2000, + file_size: 0x100, + memory_size: 0x100, + flags: PF_R, + alignment: 0x1000, + }, + reservation, + ) + .expect("reuse redundant PT_NOTE"); + + assert_eq!(updated.program_header_offset, 0x40); + assert_eq!(updated.program_header_count, 3); + assert_eq!(updated.program_headers.len(), 2); + assert_eq!(&output[0x38..0x3a], &3_u16.to_le_bytes()); + assert_eq!(&output[0x78..0x7c], &PT_NOTE.to_le_bytes()); + assert_eq!(&output[0xb0..0xb4], &PT_LOAD.to_le_bytes()); + assert_eq!(&output[0xb8..0xc0], &0x1000_u64.to_le_bytes()); + assert_eq!(&output[0xc0..0xc8], &0x2000_u64.to_le_bytes()); + } + + #[test] + fn relocated_program_headers_update_pt_phdr() { + let mut elf_layout = ElfLayout { + entrypoint: 0, + program_header_offset: 0x40, + program_header_size: 0x38, + program_header_count: 1, + program_headers: Vec::new(), + section_headers: Vec::new(), + section_name_index: 0, + private_section_index: usize::MAX, + }; + elf_layout.section_headers.push(SectionHeader { + name: 0, + section_type: 1, + flags: 0, + address: 0, + offset: 0x78, + size: 1, + link: 0, + info: 0, + alignment: 1, + entry_size: 0, + }); + let mut output = vec![0_u8; 0x2000]; + output[0x20..0x28].copy_from_slice(&0x40_u64.to_le_bytes()); + output[0x40..0x44].copy_from_slice(&PT_PHDR.to_le_bytes()); + output[0x48..0x50].copy_from_slice(&0x40_u64.to_le_bytes()); + output[0x50..0x58].copy_from_slice(&0x40_u64.to_le_bytes()); + output[0x58..0x60].copy_from_slice(&0x40_u64.to_le_bytes()); + output[0x60..0x68].copy_from_slice(&0x38_u64.to_le_bytes()); + output[0x68..0x70].copy_from_slice(&0x38_u64.to_le_bytes()); + + let reservation = elf_layout + .additional_program_header_reservation() + .expect("reservation"); + assert_eq!(reservation, 0x70); + let updated = elf_layout + .append_load_segment( + &mut output, + LoadSegment { + offset: 0x1000, + virtual_address: 0x2000, + file_size: 0x100, + memory_size: 0x100, flags: PF_R, alignment: 0x1000, }, + reservation, ) - .expect_err("overlapping program header"); - assert!(error.to_string().contains("additional program header")); + .expect("relocate program headers"); + + assert_eq!(updated.program_header_offset, 0x1000); + assert_eq!(&output[0x20..0x28], &0x1000_u64.to_le_bytes()); + assert_eq!(&output[0x1000..0x1004], &PT_PHDR.to_le_bytes()); + assert_eq!(&output[0x1008..0x1010], &0x1000_u64.to_le_bytes()); + assert_eq!(&output[0x1010..0x1018], &0x2000_u64.to_le_bytes()); + assert_eq!(&output[0x1020..0x1028], &0x70_u64.to_le_bytes()); + assert_eq!(&output[0x1028..0x1030], &0x70_u64.to_le_bytes()); + assert_eq!(&output[0x1038..0x103c], &PT_LOAD.to_le_bytes()); } } diff --git a/senbei-engine/src/android/restore/pipeline.rs b/senbei-engine/src/android/restore/pipeline.rs index 482bdce..63ea019 100644 --- a/senbei-engine/src/android/restore/pipeline.rs +++ b/senbei-engine/src/android/restore/pipeline.rs @@ -884,7 +884,9 @@ fn metadata_mapping_length( Some(size) => size?, None => 0, }; - let mut cursor = 0_u64; + let program_header_reservation = u64::try_from(layout.additional_program_header_reservation()?) + .map_err(|_| Error::Invalid("program-header reservation exceeds u64".to_owned()))?; + let mut cursor = program_header_reservation; for (size, alignment) in [ ( new_symbol_count @@ -1152,8 +1154,12 @@ fn materialize_static_elf_tables( let mut capacity_end = metadata_capacity_end(layout, &indices, metadata_start)?; if cursor > capacity_end { let alignment = layout.load_alignment()?; + let program_header_reservation = layout.additional_program_header_reservation()?; let extension_start = align_up(layout.private_section()?.offset, alignment)?; - let extension_end = table_end(&tables, extension_start)?; + let extension_metadata_start = extension_start + .checked_add(program_header_reservation as u64) + .ok_or_else(|| Error::Invalid("dynamic-table extension start overflow".to_owned()))?; + let extension_end = table_end(&tables, extension_metadata_start)?; let extension_size = extension_end .checked_sub(extension_start) .ok_or_else(|| Error::Invalid("dynamic-table extension underflow".to_owned()))?; @@ -1168,8 +1174,9 @@ fn materialize_static_elf_tables( flags: PF_R, alignment, }, + program_header_reservation, )?; - metadata_start = extension_start; + metadata_start = extension_metadata_start; (placements, cursor) = table_placements(&tables, metadata_start)?; capacity_end = cursor; } From b355d8ea8f56cdd3801e9f84f13a8ad72d2feac8 Mon Sep 17 00:00:00 2001 From: y52 Date: Sat, 19 Sep 2026 06:10:41 +0000 Subject: [PATCH 4/4] fix(android): carry v24 runtime context across package scans --- senbei-io/src/android/mod.rs | 121 ++++++++++++++++++++++++++--------- senbei-io/src/job.rs | 32 +++++++-- 2 files changed, 117 insertions(+), 36 deletions(-) diff --git a/senbei-io/src/android/mod.rs b/senbei-io/src/android/mod.rs index 8f216b9..e151abe 100644 --- a/senbei-io/src/android/mod.rs +++ b/senbei-io/src/android/mod.rs @@ -145,7 +145,21 @@ fn restore_so_file_with_context( } pub fn restore_so_file(input: &Path, dest: &Path, verbose: bool) -> Result>> { - Ok(restore_so_file_with_context(input, dest, verbose)?.embedded_metadata) + let mut method_index_module = None; + restore_so_file_with_method_index_module(input, dest, verbose, &mut method_index_module) +} + +pub(crate) fn restore_so_file_with_method_index_module( + input: &Path, + dest: &Path, + verbose: bool, + method_index_module: &mut Option>, +) -> Result>> { + let restored = restore_so_file_with_context(input, dest, verbose)?; + if let Some(module) = restored.method_index_module { + *method_index_module = Some(module); + } + Ok(restored.embedded_metadata) } /// Content identity for cross-source deduplication: the same library may @@ -173,7 +187,7 @@ pub fn restore_metadata_bytes(data: &[u8]) -> anyhow::Result<(Vec, senbei_me restore_metadata_bytes_with_module(data, None) } -fn restore_metadata_bytes_with_module( +pub(crate) fn restore_metadata_bytes_with_module( data: &[u8], method_index_module: Option<&[u8]>, ) -> anyhow::Result<(Vec, senbei_metadata::Report)> { @@ -261,15 +275,15 @@ pub enum EntryStatus { Failed(anyhow::Error), } -struct PackageRestoreContext { - method_index_module: Option>, +struct PackageRestoreContext<'a> { + method_index_module: &'a mut Option>, verbose: bool, } -impl PackageRestoreContext { - fn new(verbose: bool) -> Self { +impl<'a> PackageRestoreContext<'a> { + fn new(verbose: bool, method_index_module: &'a mut Option>) -> Self { Self { - method_index_module: None, + method_index_module, verbose, } } @@ -292,6 +306,25 @@ pub fn restore_package( out_root: &Path, seen: &mut HashSet, verbose: bool, +) -> Result> { + let mut method_index_module = None; + restore_package_with_method_index_module( + package, + rel, + out_root, + seen, + verbose, + &mut method_index_module, + ) +} + +pub(crate) fn restore_package_with_method_index_module( + package: &Path, + rel: &Path, + out_root: &Path, + seen: &mut HashSet, + verbose: bool, + method_index_module: &mut Option>, ) -> Result> { let bundle = package .extension() @@ -302,7 +335,7 @@ pub fn restore_package( let mut archive = open_package(package)?; let temporary = tempfile::tempdir().context("create package workspace")?; let mut outcomes = Vec::new(); - let mut context = PackageRestoreContext::new(verbose); + let mut context = PackageRestoreContext::new(verbose, method_index_module); let mut direct = Vec::new(); let mut nested = Vec::new(); @@ -347,8 +380,15 @@ pub fn restore_package( .with_context(|| format!("extract `{label}`"))?; outcomes.append(&mut entry_outcomes); } + struct NestedPackage { + label: PathBuf, + path: PathBuf, + base: PathBuf, + entries: Vec<(usize, PathBuf)>, + } + + let mut nested_packages = Vec::with_capacity(nested.len()); for (index, name) in nested { - let mut nested_context = PackageRestoreContext::new(verbose); let nested_label = rel.join(&name); let nested_path = extract_entry(&mut archive, index, &temporary, &nested_label) .with_context(|| format!("extract `{}`", nested_label.display()))?; @@ -368,26 +408,45 @@ pub fn restore_package( } } } - // Restore the protected library before metadata so v24.1 metadata can - // consume the runtime profile recovered from module 0x0C. - entries.sort_by_key(|(_, entry_name)| package_entry_priority(entry_name)); - // Keep the nested package's stem in the output layout so two splits - // carrying same-named entries cannot collide. - let base = rel.join(name.with_extension("")); - for (nested_index, entry_name) in entries { - let label = format!("{}::{}", nested_label.display(), entry_name.display()); - let dest = out_root.join(&base).join(crate::job::out_name(&entry_name)); - let mut entry_outcomes = restore_package_entry( - &mut nested_archive, - nested_index, - &label, - &dest, - &temporary, - seen, - &mut nested_context, - ) - .with_context(|| format!("extract `{label}`"))?; - outcomes.append(&mut entry_outcomes); + nested_packages.push(NestedPackage { + label: nested_label, + path: nested_path, + base: rel.join(name.with_extension("")), + entries, + }); + } + + // A bundle can put libil2cpp.so in an ABI split while metadata stays in + // base.apk. Share one context across the whole bundle and process all SOs + // before any metadata, regardless of which nested APK owns each entry. + for priority in [0_u8, 1_u8] { + for nested_package in &nested_packages { + let mut nested_archive = open_package(&nested_package.path)?; + for (nested_index, entry_name) in nested_package + .entries + .iter() + .filter(|(_, entry_name)| package_entry_priority(entry_name) == priority) + { + let label = format!( + "{}::{}", + nested_package.label.display(), + entry_name.display() + ); + let dest = out_root + .join(&nested_package.base) + .join(crate::job::out_name(entry_name)); + let mut entry_outcomes = restore_package_entry( + &mut nested_archive, + *nested_index, + &label, + &dest, + &temporary, + seen, + &mut context, + ) + .with_context(|| format!("extract `{label}`"))?; + outcomes.append(&mut entry_outcomes); + } } } Ok(outcomes) @@ -403,7 +462,7 @@ fn restore_package_entry( dest: &Path, temporary: &tempfile::TempDir, seen: &mut HashSet, - context: &mut PackageRestoreContext, + context: &mut PackageRestoreContext<'_>, ) -> Result> { let entry_path = extract_entry(archive, index, temporary, Path::new(label))?; let entry_file = @@ -437,7 +496,7 @@ fn restore_package_entry( match restore_so_file_with_context(&entry_path, dest, context.verbose) { Ok(restored) => { if let Some(module) = restored.method_index_module { - context.method_index_module = Some(module); + *context.method_index_module = Some(module); } let mut outcomes = vec![outcome(EntryKind::So, EntryStatus::Restored)]; if let Some(blob) = restored.embedded_metadata { diff --git a/senbei-io/src/job.rs b/senbei-io/src/job.rs index 01ecb66..0990228 100644 --- a/senbei-io/src/job.rs +++ b/senbei-io/src/job.rs @@ -221,6 +221,7 @@ pub fn run_folder_opts( // files restore first so the cross-source dedup keeps them over a copy // inside a package (loose beats `.apk` beats `.apks`/`.xapk` bundle). let mut android_seen = std::collections::HashSet::new(); + let mut android_method_index_module = None; // Hashing a protected library costs a full read, so only pay it when a // duplicate source can actually exist in this run. let android_dedup = scan.android_so.len() > 1 || !scan.android_packages.is_empty(); @@ -242,7 +243,12 @@ pub fn run_folder_opts( let input_owned = input.clone(); let dest_owned = dest.clone(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - crate::android::restore_so_file(&input_owned, &dest_owned, verbose_steps) + crate::android::restore_so_file_with_method_index_module( + &input_owned, + &dest_owned, + verbose_steps, + &mut android_method_index_module, + ) })); match result { Ok(Ok(embedded)) => { @@ -310,12 +316,13 @@ pub fn run_folder_opts( let out_root_owned = out_root.clone(); let mut seen_taken = std::mem::take(&mut android_seen); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let outcomes = crate::android::restore_package( + let outcomes = crate::android::restore_package_with_method_index_module( &package_owned, &rel_owned, &out_root_owned, &mut seen_taken, verbose_steps, + &mut android_method_index_module, ); (outcomes, seen_taken) })); @@ -361,7 +368,12 @@ pub fn run_folder_opts( let meta_owned = meta.clone(); let dest_owned = dest.clone(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - deobfuscate_metadata_to(&meta_owned, &dest_owned, verbose_steps) + deobfuscate_metadata_to_with_module( + &meta_owned, + &dest_owned, + verbose_steps, + android_method_index_module.as_deref(), + ) })); match result { Ok(Ok(report)) if report.remapped > 0 => { @@ -735,6 +747,15 @@ pub fn deobfuscate_metadata_to( input: &Path, dest: &Path, verbose: bool, +) -> anyhow::Result { + deobfuscate_metadata_to_with_module(input, dest, verbose, None) +} + +fn deobfuscate_metadata_to_with_module( + input: &Path, + dest: &Path, + verbose: bool, + method_index_module: Option<&[u8]>, ) -> anyhow::Result { let data = std::fs::read(input)?; // The Android seeded-permutation variant is tried first (it validates @@ -742,8 +763,9 @@ pub fn deobfuscate_metadata_to( // Windows path. The [`senbei_metadata::Error`] is preserved in the chain // (rather than stringified) so the folder driver can apply its // unsupported-version policy. - let (out, report) = crate::android::restore_metadata_bytes(&data) - .map_err(|e| e.context(format!("{input:?}")))?; + let (out, report) = + crate::android::restore_metadata_bytes_with_module(&data, method_index_module) + .map_err(|e| e.context(format!("{input:?}")))?; if report.remapped > 0 { if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent)?;