//! Android target orchestration: protected AArch64 shared libraries (`.so`), //! app packages (`.apk` / `.apks` / `.xapk`), and the Android variant of the //! il2cpp method-token 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` //! section; restoration extracts the stage-2 module set //! ([`senbei_engine::android`]) and rebuilds the static image //! ([`senbei_engine::android`]). Some il2cpp builds additionally embed their //! metadata blob — XOR-wrapped, with no standalone `global-metadata.dat` in //! the assets — inside the library's data section; after a successful restore //! the blob is located by content and unwrapped //! ([`senbei_metadata::android::extract_embedded_metadata`]). //! //! All functions in this module are native filesystem orchestration; the web //! app (wasm) never touches them. use std::collections::HashSet; use std::fs::File; use std::io::{BufWriter, Read, Seek, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use memmap2::{Mmap, MmapOptions}; use senbei_crypto::hex_digest; use senbei_engine::android::{ExtractOptions, extract_stage2, is_protected_libil2cpp}; use senbei_engine::android::{RestoreOptions, restore_libil2cpp}; use sha2::{Digest, Sha256}; use zip::ZipArchive; pub use crate::METADATA_FILE_NAME; /// Package extensions recognised as Android app packages. Packages are /// *containers*: membership is decided by extension plus the ZIP magic, while /// only `.so` and `global-metadata.dat` entries are read. const PACKAGE_EXTENSIONS: [&str; 3] = ["apk", "apks", "xapk"]; pub(crate) fn is_package_name(path: &Path) -> bool { path.extension() .and_then(|value| value.to_str()) .is_some_and(|value| { PACKAGE_EXTENSIONS .iter() .any(|ext| value.eq_ignore_ascii_case(ext)) }) } pub(crate) fn is_so_name(path: &Path) -> bool { path.extension() .and_then(|value| value.to_str()) .is_some_and(|value| value.eq_ignore_ascii_case("so")) } pub(crate) fn is_android_entry_name(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.eq_ignore_ascii_case(METADATA_FILE_NAME)) || is_so_name(path) } /// Whether `prefix` (the first bytes of a file) is an ELF64/AArch64 image. /// Only those can be protected Android libraries, so the folder scan uses this /// cheap check to decide when the full-file protection probe is worth its /// read. pub fn is_elf64_aarch64(prefix: &[u8]) -> bool { senbei_elf::is_aarch64_prefix(prefix) } /// Whether `path` is an Android app package: a recognised package extension /// and the local-file-header zip magic in `prefix`. pub fn is_app_package(path: &Path, prefix: &[u8]) -> bool { is_package_name(path) && prefix.starts_with(b"PK\x03\x04") } /// Probe a file on disk: true when it is a protected AArch64 library. /// Reads the whole file (the payload section is found through the /// section-header table at the end); call only after [`is_elf64_aarch64`] /// has matched a prefix. pub fn is_protected_so_file(path: &Path) -> bool { let Ok(file) = File::open(path) else { return false; }; let Ok(bytes) = map_read_only(&file, path) else { return false; }; is_elf64_aarch64(&bytes) && is_protected_libil2cpp(&bytes) } pub fn file_content_identity(path: &Path) -> std::io::Result { let file = File::open(path)?; // SAFETY: the file remains open for the mapping lifetime and the mapping // is read-only. let bytes = unsafe { MmapOptions::new().map(&file)? }; Ok(content_identity(&bytes)) } /// Restore one protected `.so` to `dest`. /// /// The stage-2 module set is extracted into a temporary workspace (it is an /// 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>> { let temporary = tempfile::tempdir().context("create stage-2 workspace")?; let stage2_dir = temporary.path().join("stage2"); extract_stage2(&ExtractOptions::with_defaults( input.to_path_buf(), stage2_dir.clone(), )) .context("extract stage-1/stage-2 payload")?; restore_libil2cpp(&RestoreOptions { input: input.to_path_buf(), output: dest.to_path_buf(), index: stage2_dir.join("index.json"), dump_auxiliary: None, outer_only: false, preserve_entrypoint: false, verbose, }) .context("restore protected library")?; let restored = std::fs::read(dest).with_context(|| format!("read restored `{}`", dest.display()))?; Ok(senbei_metadata::android::extract_embedded_metadata( &restored, )) } /// Content identity for cross-source deduplication: the same library may /// appear loose in a tree, in its `.apk`, and again in an `.apks`/`.xapk` /// bundle — restore it once, at the highest-priority source's destination. pub fn content_identity(data: &[u8]) -> String { let mut digest = Sha256::new(); digest.update(data); hex_digest(&digest.finalize()) } /// Restore an il2cpp metadata blob (Android seeded permutation first, then 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 /// residues, and the restore *validates* every restored RID against its /// canonical per-module index — so an unusable seed fails loudly and the /// caller falls through to the structural remap, which targets the same /// 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)> { if let Ok(discovery) = senbei_metadata::android::discover_method_token_seeds(data) && matches!(discovery.version, 29 | 31 | 39) { let mut seeds = discovery.seed_candidates.clone(); if seeds.is_empty() { seeds.push(senbei_metadata::android::DEFAULT_METHOD_TOKEN_SEED); } // Trial-and-validate: a wrong seed fails the restore's full-coverage // RID check, so ambiguous candidates cost one extra pass each and a // build with an unseeded permutation falls through to the structural // remap rather than producing a silently wrong file. for seed in seeds { if let Ok((out, report)) = senbei_metadata::android::restore_method_tokens(data, seed) { return Ok(( out, senbei_metadata::Report { version: report.version, methods: report.methods, remapped: report.changed_tokens, modules: report.images_with_methods, }, )); } } } let (out, report) = senbei_metadata::deobfuscate(data).map_err(anyhow::Error::new)?; Ok((out, report)) } /// What happened to one archive entry (or one loose Android target). #[derive(Debug)] pub struct EntryOutcome { /// Human-readable source label, e.g. `base.apk::lib/arm64-v8a/libil2cpp.so`. pub label: String, /// Where the restored bytes were written (meaningless unless `status` is /// `Restored`). pub dest: PathBuf, pub kind: EntryKind, pub status: EntryStatus, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntryKind { /// A protected shared library, restored. So, /// An il2cpp metadata blob, de-obfuscated (`remapped` tokens changed). Metadata { remapped: usize }, /// A metadata blob unwrapped from a restored library's data section. EmbeddedMetadata, } #[derive(Debug)] pub enum EntryStatus { Restored, /// Byte-identical content was already restored from a higher-priority /// source; no output written. Duplicate, /// Content-probed but not a target (unprotected library). NotTarget, /// A metadata blob whose tokens were already canonical; no copy written. Unchanged, /// Recognised as a target but the restore failed. Failed(anyhow::Error), } /// 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 /// name in single-file mode); outputs mirror the package's internal layout /// under `out_root/rel/`, with [`crate::job::out_name`] renaming. `seen` /// carries content identities already restored from higher-priority sources /// (loose files first, then `.apk`, then bundles) across the whole run. pub fn restore_package( package: &Path, rel: &Path, out_root: &Path, seen: &mut HashSet, verbose: bool, ) -> Result> { let bundle = package .extension() .and_then(|value| value.to_str()) .is_some_and(|value| { value.eq_ignore_ascii_case("apks") || value.eq_ignore_ascii_case("xapk") }); let mut archive = open_package(package)?; let temporary = tempfile::tempdir().context("create package workspace")?; let mut outcomes = Vec::new(); let mut direct = Vec::new(); let mut nested = Vec::new(); for index in 0..archive.len() { let (name, is_dir) = { let entry = archive.by_index(index)?; (entry.enclosed_name(), entry.is_dir()) }; if is_dir { continue; } let Some(name) = name else { bail!("unsafe entry path in package `{}`", package.display()); }; if bundle { if name .extension() .and_then(|value| value.to_str()) .is_some_and(|value| value.eq_ignore_ascii_case("apk")) { nested.push((index, name)); } } else { if is_android_entry_name(&name) { direct.push((index, 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)); let mut entry_outcomes = restore_package_entry( &mut archive, index, &label, &dest, &temporary, seen, verbose, ) .with_context(|| format!("extract `{label}`"))?; outcomes.append(&mut entry_outcomes); } for (index, name) in nested { let nested_label = rel.join(&name); let nested_path = extract_entry(&mut archive, index, &temporary, &nested_label) .with_context(|| format!("extract `{}`", nested_label.display()))?; let mut nested_archive = open_package(&nested_path)?; let mut entries = Vec::new(); for nested_index in 0..nested_archive.len() { let (entry_name, is_dir) = { let entry = nested_archive.by_index(nested_index)?; (entry.enclosed_name(), entry.is_dir()) }; if !is_dir { let Some(entry_name) = entry_name else { bail!("unsafe entry path in `{}`", nested_label.display()); }; if is_android_entry_name(&entry_name) { entries.push((nested_index, 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, verbose, ) .with_context(|| format!("extract `{label}`"))?; outcomes.append(&mut entry_outcomes); } } Ok(outcomes) } /// Probe one extracted package entry and restore it when it is a target. /// Returns one outcome per produced/consumed artifact: the entry itself, plus /// an `EmbeddedMetadata` outcome when the restored library carried a blob. fn restore_package_entry( archive: &mut ZipArchive, index: usize, label: &str, dest: &Path, temporary: &tempfile::TempDir, seen: &mut HashSet, verbose: bool, ) -> Result> { let entry_path = extract_entry(archive, index, temporary, Path::new(label))?; let entry_file = File::open(&entry_path).with_context(|| format!("open extracted `{label}`"))?; let entry_data = map_read_only(&entry_file, &entry_path)?; let is_so = is_elf64_aarch64(&entry_data) && is_protected_libil2cpp(&entry_data); let is_meta = !is_so && senbei_metadata::is_metadata(&entry_data); let outcome = |kind, status| EntryOutcome { label: label.to_owned(), dest: dest.to_path_buf(), kind, status, }; if !is_so && !is_meta { return Ok(vec![outcome(EntryKind::So, EntryStatus::NotTarget)]); } if !seen.insert(content_identity(&entry_data)) { let kind = if is_so { EntryKind::So } else { EntryKind::Metadata { remapped: 0 } }; return Ok(vec![outcome(kind, EntryStatus::Duplicate)]); } 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, }); } outcomes } Err(error) => vec![outcome(EntryKind::So, EntryStatus::Failed(error))], }); } // Metadata entry: write only when the restore actually changed tokens — // a clean blob needs no copy (same contract as loose metadata files). let kind_and_status = match restore_metadata_bytes(&entry_data) { Ok((out, report)) if report.remapped > 0 => { let kind = EntryKind::Metadata { remapped: report.remapped, }; match write_metadata_blob(dest, &out) { Ok(()) => (kind, EntryStatus::Restored), Err(error) => (kind, EntryStatus::Failed(error)), } } Ok(_) => (EntryKind::Metadata { remapped: 0 }, EntryStatus::Unchanged), Err(error) => ( EntryKind::Metadata { remapped: 0 }, EntryStatus::Failed(error), ), }; Ok(vec![outcome(kind_and_status.0, kind_and_status.1)]) } /// Output path for a metadata blob unwrapped from a restored library: next to /// the library, under the standard file name (with the usual `.unpack` infix). pub fn embedded_metadata_dest(restored_so: &Path) -> PathBuf { let dir = restored_so.parent().unwrap_or_else(|| Path::new(".")); dir.join(crate::job::out_name(Path::new(METADATA_FILE_NAME))) } /// Write a metadata blob, creating the parent directory. The restore writes /// its own output atomically; metadata blobs use the shared orchestration /// atomic writer to keep the same mid-write failure semantics. fn write_metadata_blob(dest: &Path, data: &[u8]) -> Result<()> { if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("create `{}`", parent.display()))?; } crate::atomic::write_atomic(dest, data) .map_err(anyhow::Error::from) .context("write metadata output") } fn open_package(path: &Path) -> Result> { let file = std::fs::File::open(path).with_context(|| format!("open `{}`", path.display()))?; ZipArchive::new(file).with_context(|| format!("read package `{}`", path.display())) } /// Stream one package entry to a temporary, seekable file. The Android engine /// needs random access to ELF section tables, while the ZIP reader itself is /// consumed directly without creating an in-memory compressed or decompressed /// copy. fn extract_entry( archive: &mut ZipArchive, index: usize, temporary: &tempfile::TempDir, label: &Path, ) -> Result { let mut entry = archive.by_index(index)?; let key = format!("{}-{index:08x}", label.display()); // `:` appears in `package::entry` labels and is invalid in Windows file // names; sanitize every path-ish separator. let destination = temporary.path().join(key.replace(['\\', '/', ':'], "_")); let output_size = entry.size(); let mut output = BufWriter::new(std::fs::File::create(&destination)?); let written = std::io::copy(&mut entry, &mut output)?; output.flush()?; if written != output_size { bail!( "entry `{key}` decompressed to 0x{:x}, expected 0x{output_size:x}", written ); } Ok(destination) } fn map_read_only(file: &File, path: &Path) -> Result { // SAFETY: the file descriptor remains open for the returned mapping's // lifetime, and this mapping is read-only. unsafe { MmapOptions::new().map(file) } .with_context(|| format!("map extracted `{}`", path.display())) }