mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
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:
@@ -7,11 +7,18 @@ description = "Filesystem, scanning, logging, and CLI orchestration for Senbei"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
flate2.workspace = true
|
||||
indicatif.workspace = true
|
||||
owo-colors.workspace = true
|
||||
senbei-android-elf.workspace = true
|
||||
senbei-android-engine.workspace = true
|
||||
senbei-android-metadata.workspace = true
|
||||
senbei-metadata.workspace = true
|
||||
senbei-pe.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
walkdir.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows.workspace = true
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! 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_android_engine`]) and rebuilds the static image
|
||||
//! ([`senbei_android_elf`]). 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_android_metadata::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::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use flate2::read::DeflateDecoder;
|
||||
use senbei_android_elf::{RestoreOptions, restore_libil2cpp};
|
||||
use senbei_android_engine::{ExtractOptions, extract_stage2, is_protected_libil2cpp};
|
||||
use sha2::{Digest, Sha256};
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// File name of an il2cpp metadata blob (a platform-standard technology name).
|
||||
pub const METADATA_FILE_NAME: &str = "global-metadata.dat";
|
||||
|
||||
/// Package extensions recognised as Android app packages. Packages are
|
||||
/// *containers*: membership is decided by extension plus the zip magic, while
|
||||
/// every file pulled out of one is still content-probed like a loose file.
|
||||
const PACKAGE_EXTENSIONS: [&str; 3] = ["apk", "apks", "xapk"];
|
||||
|
||||
/// 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 {
|
||||
prefix.len() >= 20
|
||||
&& prefix[0..4] == [0x7f, b'E', b'L', b'F']
|
||||
&& prefix[4] == 2 // ELFCLASS64
|
||||
&& prefix[5] == 1 // ELFDATA2LSB
|
||||
&& u16::from_le_bytes([prefix[18], prefix[19]]) == 0xB7 // EM_AARCH64
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let is_package_ext = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| {
|
||||
PACKAGE_EXTENSIONS
|
||||
.iter()
|
||||
.any(|ext| value.eq_ignore_ascii_case(ext))
|
||||
});
|
||||
is_package_ext && 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(bytes) = std::fs::read(path) else {
|
||||
return false;
|
||||
};
|
||||
is_elf64_aarch64(&bytes) && is_protected_libil2cpp(&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<Option<Vec<u8>>> {
|
||||
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_android_metadata::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);
|
||||
format!("{:x}", 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<u8>, senbei_metadata::Report)> {
|
||||
if let Ok(discovery) = senbei_android_metadata::discover_method_token_seeds(data)
|
||||
&& discovery.version == 31
|
||||
&& discovery.images.iter().any(|image| !image.clean)
|
||||
{
|
||||
let mut seeds = discovery.seed_candidates.clone();
|
||||
if seeds.is_empty() {
|
||||
seeds.push(senbei_android_metadata::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_android_metadata::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<String>,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<EntryOutcome>> {
|
||||
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().map(PathBuf::from), 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 {
|
||||
direct.push((index, name));
|
||||
}
|
||||
}
|
||||
drop(archive);
|
||||
|
||||
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(package, 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(package, 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().map(PathBuf::from), entry.is_dir())
|
||||
};
|
||||
if !is_dir {
|
||||
let Some(entry_name) = entry_name else {
|
||||
bail!("unsafe entry path in `{}`", nested_label.display());
|
||||
};
|
||||
entries.push((nested_index, entry_name));
|
||||
}
|
||||
}
|
||||
drop(nested_archive);
|
||||
// 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(
|
||||
&nested_path,
|
||||
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(
|
||||
package: &Path,
|
||||
index: usize,
|
||||
label: &str,
|
||||
dest: &Path,
|
||||
temporary: &tempfile::TempDir,
|
||||
seen: &mut HashSet<String>,
|
||||
verbose: bool,
|
||||
) -> Result<Vec<EntryOutcome>> {
|
||||
let entry_path = extract_entry(package, index, temporary, Path::new(label))?;
|
||||
let data = std::fs::read(&entry_path).with_context(|| format!("read extracted `{label}`"))?;
|
||||
|
||||
let is_so = is_elf64_aarch64(&data) && is_protected_libil2cpp(&data);
|
||||
let is_meta = !is_so && senbei_metadata::is_metadata(&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(&data)) {
|
||||
let kind = if is_so {
|
||||
EntryKind::So
|
||||
} else {
|
||||
EntryKind::Metadata { remapped: 0 }
|
||||
};
|
||||
return Ok(vec![outcome(kind, EntryStatus::Duplicate)]);
|
||||
}
|
||||
|
||||
if is_so {
|
||||
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(&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 go through the job layer's
|
||||
/// atomic write to share the 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::job::write_atomic(dest, data)
|
||||
.map_err(anyhow::Error::from)
|
||||
.context("write metadata output")
|
||||
}
|
||||
|
||||
fn open_package(path: &Path) -> Result<ZipArchive<std::fs::File>> {
|
||||
let file = std::fs::File::open(path).with_context(|| format!("open `{}`", path.display()))?;
|
||||
ZipArchive::new(file).with_context(|| format!("read package `{}`", path.display()))
|
||||
}
|
||||
|
||||
/// Extract one package entry to the temporary workspace, streaming stored
|
||||
/// entries and inflating deflated ones by hand so compression-method
|
||||
/// surprises fail loudly instead of producing a truncated file.
|
||||
fn extract_entry(
|
||||
package: &Path,
|
||||
index: usize,
|
||||
temporary: &tempfile::TempDir,
|
||||
label: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
let mut archive = open_package(package)?;
|
||||
let mut entry = archive.by_index_raw(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 compressed_size = usize::try_from(entry.compressed_size())
|
||||
.map_err(|_| anyhow::anyhow!("entry compressed size exceeds usize"))?;
|
||||
let output_size =
|
||||
usize::try_from(entry.size()).map_err(|_| anyhow::anyhow!("entry size exceeds usize"))?;
|
||||
let mut compressed = vec![0_u8; compressed_size];
|
||||
entry.read_exact(&mut compressed)?;
|
||||
let mut output = Vec::with_capacity(output_size);
|
||||
match entry.compression() {
|
||||
zip::CompressionMethod::Stored => output.extend_from_slice(&compressed),
|
||||
zip::CompressionMethod::Deflated => {
|
||||
DeflateDecoder::new(compressed.as_slice()).read_to_end(&mut output)?;
|
||||
}
|
||||
method => bail!("unsupported compression method {method:?} in entry `{key}`"),
|
||||
}
|
||||
if output.len() != output_size {
|
||||
bail!(
|
||||
"entry `{key}` decompressed to 0x{:x}, expected 0x{output_size:x}",
|
||||
output.len()
|
||||
);
|
||||
}
|
||||
std::fs::write(&destination, &output)?;
|
||||
Ok(destination)
|
||||
}
|
||||
+339
-39
@@ -323,6 +323,7 @@ fn splice_companion(stub: &[u8], comp: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// Summary of a folder-mode run.
|
||||
#[derive(Default)]
|
||||
pub struct Summary {
|
||||
pub unpacked: usize,
|
||||
pub skipped: usize,
|
||||
@@ -331,12 +332,29 @@ 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 (method tokens remapped),
|
||||
/// including blobs unwrapped from restored Android libraries.
|
||||
pub metadata: usize,
|
||||
/// Android app packages (`.apk`/`.apks`/`.xapk`) opened and searched.
|
||||
pub packages: usize,
|
||||
/// Wall-clock duration of the folder run in milliseconds.
|
||||
pub duration_ms: u128,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
/// The summary line shared by CLI output and the log file.
|
||||
pub fn line(&self) -> String {
|
||||
let mut line = format!(
|
||||
"{} unpacked · {} skipped · {} errors · {} suspect · {} metadata",
|
||||
self.unpacked, self.skipped, self.errors, self.suspect, self.metadata
|
||||
);
|
||||
if self.packages > 0 {
|
||||
line.push_str(&format!(" · {} packages", self.packages));
|
||||
}
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
/// Default output root for a folder unpack: `<root>/unpack`.
|
||||
pub fn default_out_root_for_folder(root: &Path) -> PathBuf {
|
||||
root.join("unpack")
|
||||
@@ -416,12 +434,15 @@ pub fn run_folder_opts(
|
||||
log.step(&format!("out {}", out_root.display()));
|
||||
Some(log)
|
||||
};
|
||||
// Single merged directory walk: returns Crackproof unpack candidates and
|
||||
// il2cpp metadata blobs from one traversal (see
|
||||
// Single merged directory walk: returns Crackproof unpack candidates, il2cpp
|
||||
// metadata blobs, and Android targets from one traversal (see
|
||||
// [`crate::scan::find_targets_opts`]). Files the free directory metadata
|
||||
// already rules out are never opened — on asset-heavy trees the per-file
|
||||
// open+read latency, not the traversal, is the whole cost.
|
||||
let (candidates, metas, scan_stats) = crate::scan::find_targets_opts(root, scan_all);
|
||||
let scan = crate::scan::find_targets_opts(root, scan_all);
|
||||
let candidates = scan.crackproof.as_slice();
|
||||
let metas = scan.metadata.as_slice();
|
||||
let scan_stats = &scan.stats;
|
||||
// Files the scan could not classify are potential missed targets, not
|
||||
// clean skips: an unreadable directory or a locked il2cpp game assembly must
|
||||
// fail the run (exit 1) rather than report "0 errors" over a partial scan.
|
||||
@@ -453,21 +474,22 @@ pub fn run_folder_opts(
|
||||
// Verbose mode prints multi-line `[N/9]` step output per file straight to
|
||||
// stdout; an active progress bar would be clobbered by it, so hide the bar
|
||||
// (its per-file ok/err lines still print) when verbose is on.
|
||||
let bar = crate::ui::progress(candidates.len() as u64, quiet >= 1 || verbose);
|
||||
let android_targets = scan.android_so.len() + scan.android_packages.len();
|
||||
let bar = crate::ui::progress(
|
||||
(candidates.len() + android_targets) as u64,
|
||||
quiet >= 1 || verbose,
|
||||
);
|
||||
let mut s = Summary {
|
||||
unpacked: 0,
|
||||
skipped: scan_stats.skipped,
|
||||
errors: scan_failed,
|
||||
suspect: 0,
|
||||
metadata: 0,
|
||||
duration_ms: 0,
|
||||
..Summary::default()
|
||||
};
|
||||
|
||||
// Silence the default panic hook's stderr spew during per-file processing.
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(|_| {})); // suppress "thread panicked" messages
|
||||
|
||||
for input in &candidates {
|
||||
for input in candidates {
|
||||
let rel = rel_in_tree(root, input);
|
||||
let dest = out_root.join(out_name(&rel));
|
||||
|
||||
@@ -515,14 +537,146 @@ pub fn run_folder_opts(
|
||||
bar.inc(1);
|
||||
}
|
||||
|
||||
// Android pass: protected AArch64 libraries and app packages. Loose `.so`
|
||||
// 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();
|
||||
// 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();
|
||||
for input in &scan.android_so {
|
||||
let rel = rel_in_tree(root, input);
|
||||
let dest = out_root.join(out_name(&rel));
|
||||
// Unreadable here is fine: the restore reports the same error.
|
||||
if android_dedup
|
||||
&& let Ok(bytes) = std::fs::read(input)
|
||||
&& !android_seen.insert(crate::android::content_identity(&bytes))
|
||||
{
|
||||
s.skipped += 1;
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("SKIP {rel:?}: duplicate of an earlier target"));
|
||||
}
|
||||
bar.inc(1);
|
||||
continue;
|
||||
}
|
||||
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)
|
||||
}));
|
||||
match result {
|
||||
Ok(Ok(embedded)) => {
|
||||
s.unpacked += 1;
|
||||
crate::ui::ok_label(
|
||||
&bar,
|
||||
suppress_file_lines,
|
||||
&rel.display().to_string(),
|
||||
"So",
|
||||
&dest,
|
||||
);
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("OK {rel:?} -> {dest:?} (Android SO)"));
|
||||
}
|
||||
match write_embedded_metadata(embedded, &dest) {
|
||||
Ok(Some(meta_dest)) => {
|
||||
s.metadata += 1;
|
||||
crate::ui::ok_label(
|
||||
&bar,
|
||||
suppress_file_lines,
|
||||
&format!("{} (embedded metadata)", rel.display()),
|
||||
"metadata",
|
||||
&meta_dest,
|
||||
);
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("META {rel:?} (embedded) -> {meta_dest:?}"));
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
s.errors += 1;
|
||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("ERR {rel:?}: embedded metadata: {e:#}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
s.errors += 1;
|
||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("ERR {rel:?}: {e:#}"));
|
||||
}
|
||||
}
|
||||
Err(panic) => {
|
||||
s.errors += 1;
|
||||
let e = anyhow::anyhow!("unexpected panic: {}", panic_payload(&panic));
|
||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!(
|
||||
"ERR {rel:?}: panic during restore: {}",
|
||||
panic_payload(&panic)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
bar.inc(1);
|
||||
}
|
||||
for package in &scan.android_packages {
|
||||
let rel = rel_in_tree(root, package);
|
||||
s.packages += 1;
|
||||
let package_owned = package.clone();
|
||||
let rel_owned = rel.clone().into_owned();
|
||||
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(
|
||||
&package_owned,
|
||||
&rel_owned,
|
||||
&out_root_owned,
|
||||
&mut seen_taken,
|
||||
verbose_steps,
|
||||
);
|
||||
(outcomes, seen_taken)
|
||||
}));
|
||||
match result {
|
||||
Ok((Ok(outcomes), seen_back)) => {
|
||||
android_seen = seen_back;
|
||||
apply_package_outcomes(outcomes, &mut s, &bar, suppress_file_lines, &log);
|
||||
}
|
||||
Ok((Err(e), seen_back)) => {
|
||||
android_seen = seen_back;
|
||||
s.errors += 1;
|
||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("ERR {rel:?}: {e:#}"));
|
||||
}
|
||||
}
|
||||
Err(panic) => {
|
||||
// The dedup set may be in an unknown state after a panic; a
|
||||
// re-scan costs a duplicate restore at worst, never corruption.
|
||||
let e = anyhow::anyhow!("unexpected panic: {}", panic_payload(&panic));
|
||||
s.errors += 1;
|
||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!(
|
||||
"ERR {rel:?}: panic during package restore: {}",
|
||||
panic_payload(&panic)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
bar.inc(1);
|
||||
}
|
||||
|
||||
// il2cpp metadata pass. Crackproof's `-GMD` option obfuscates the method
|
||||
// tokens in `global-metadata.dat`; de-obfuscate any we find so the unpacked
|
||||
// il2cpp game assembly resolves methods instead of indexing its per-module
|
||||
// tables out of bounds (see [`senbei_metadata`]). This is additive to the
|
||||
// Crackproof module unpack above — the metadata blob is not itself a
|
||||
// Crackproof file.
|
||||
for meta in metas {
|
||||
let rel = rel_in_tree(root, &meta);
|
||||
for meta in metas.iter() {
|
||||
let rel = rel_in_tree(root, meta);
|
||||
let dest = out_root.join(out_name(&rel));
|
||||
let meta_owned = meta.clone();
|
||||
let dest_owned = dest.clone();
|
||||
@@ -604,10 +758,7 @@ pub fn run_folder_opts(
|
||||
s.duration_ms = t0.elapsed().as_millis();
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("done in {} ms", s.duration_ms));
|
||||
log.step(&format!(
|
||||
"summary: {} unpacked · {} skipped · {} errors · {} suspect · {} metadata",
|
||||
s.unpacked, s.skipped, s.errors, s.suspect, s.metadata
|
||||
));
|
||||
log.step(&format!("summary: {}", s.line()));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
@@ -646,23 +797,30 @@ pub fn run_file_v(
|
||||
|
||||
let name = out_name(Path::new(input.file_name().unwrap_or_default()));
|
||||
let dest = out_root.join(name);
|
||||
let mut s = Summary {
|
||||
unpacked: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
suspect: 0,
|
||||
metadata: 0,
|
||||
duration_ms: 0,
|
||||
};
|
||||
let mut s = Summary::default();
|
||||
|
||||
let is_meta = {
|
||||
let prefix = {
|
||||
use std::io::Read;
|
||||
let mut buf = [0u8; 4];
|
||||
std::fs::File::open(input)
|
||||
.and_then(|mut f| f.read_exact(&mut buf))
|
||||
.map(|_| senbei_metadata::is_metadata(&buf))
|
||||
.unwrap_or(false)
|
||||
let mut buf = vec![0u8; 8 * 1024];
|
||||
match std::fs::File::open(input).and_then(|mut f| f.read(&mut buf).map(|n| (buf, n))) {
|
||||
Ok((buf, n)) => {
|
||||
let mut b = buf;
|
||||
b.truncate(n);
|
||||
b
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
};
|
||||
let is_meta = senbei_metadata::is_metadata(&prefix);
|
||||
// Android single-file targets are routed by content: a protected AArch64
|
||||
// library probe needs the whole file (its payload section is found through
|
||||
// the section-header table at the end), while a package is a container
|
||||
// handled entry-by-entry. Anything else falls through to the PE pipeline.
|
||||
let is_android_so = crate::android::is_elf64_aarch64(&prefix)
|
||||
&& std::fs::read(input)
|
||||
.map(|bytes| senbei_android_engine::is_protected_libil2cpp(&bytes))
|
||||
.unwrap_or(false);
|
||||
let is_android_package = !is_android_so && crate::android::is_app_package(input, &prefix);
|
||||
|
||||
if is_meta {
|
||||
match deobfuscate_metadata_to(input, &dest, verbose && quiet == 0) {
|
||||
@@ -706,6 +864,77 @@ pub fn run_file_v(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if is_android_so {
|
||||
match crate::android::restore_so_file(input, &dest, verbose && quiet == 0) {
|
||||
Ok(embedded) => {
|
||||
s.unpacked = 1;
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("OK {:?} -> {:?} (Android SO)", input, dest));
|
||||
}
|
||||
if quiet == 0 {
|
||||
println!("✓ So {} -> {}", input.display(), dest.display());
|
||||
}
|
||||
match write_embedded_metadata(embedded, &dest) {
|
||||
Ok(Some(meta_dest)) => {
|
||||
s.metadata += 1;
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("META {:?} (embedded) -> {:?}", input, meta_dest));
|
||||
}
|
||||
if quiet == 0 {
|
||||
println!(
|
||||
"✓ metadata {} (embedded) -> {}",
|
||||
input.display(),
|
||||
meta_dest.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
s.errors += 1;
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("ERR {:?}: embedded metadata: {e:#}", input));
|
||||
}
|
||||
if quiet == 0 {
|
||||
eprintln!("error: embedded metadata: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
s.errors = 1;
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("ERR {:?}: {e:#}", input));
|
||||
}
|
||||
if quiet == 0 {
|
||||
eprintln!("error: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if is_android_package {
|
||||
s.packages = 1;
|
||||
let rel = PathBuf::from(input.file_name().unwrap_or_default());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
match crate::android::restore_package(
|
||||
input,
|
||||
&rel,
|
||||
&out_root,
|
||||
&mut seen,
|
||||
verbose && quiet == 0,
|
||||
) {
|
||||
Ok(outcomes) => {
|
||||
let bar = crate::ui::progress(0, true);
|
||||
apply_package_outcomes(outcomes, &mut s, &bar, quiet >= 1, &log);
|
||||
}
|
||||
Err(e) => {
|
||||
s.errors = 1;
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("ERR {:?}: {e:#}", input));
|
||||
}
|
||||
if quiet == 0 {
|
||||
eprintln!("error: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match unpack_one_v(input, &dest, verbose && quiet == 0) {
|
||||
Ok((kind, report)) => {
|
||||
@@ -748,10 +977,7 @@ pub fn run_file_v(
|
||||
s.duration_ms = t0.elapsed().as_millis();
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("done in {} ms", s.duration_ms));
|
||||
log.step(&format!(
|
||||
"summary: {} unpacked · {} skipped · {} errors · {} suspect · {} metadata",
|
||||
s.unpacked, s.skipped, s.errors, s.suspect, s.metadata
|
||||
));
|
||||
log.step(&format!("summary: {}", s.line()));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
@@ -820,7 +1046,7 @@ fn unsupported_version(e: &anyhow::Error) -> Option<u32> {
|
||||
/// failure (disk full, AV lock, quota) destroys a previously good unpack at
|
||||
/// the same path; the temp+rename keeps the old file until the new one is
|
||||
/// complete. Best-effort temp cleanup on failure.
|
||||
fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
pub(crate) fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut tmp_name = dest.as_os_str().to_os_string();
|
||||
tmp_name.push(".senbei-tmp");
|
||||
let tmp = PathBuf::from(tmp_name);
|
||||
@@ -966,10 +1192,13 @@ pub fn deobfuscate_metadata_to(
|
||||
verbose: bool,
|
||||
) -> anyhow::Result<senbei_metadata::Report> {
|
||||
let data = std::fs::read(input)?;
|
||||
// Preserve the metadata::Error in the chain (rather than stringifying it)
|
||||
// so the folder driver can apply its unsupported-version policy.
|
||||
let (out, report) = senbei_metadata::deobfuscate(&data)
|
||||
.map_err(|e| anyhow::Error::new(e).context(format!("{input:?}")))?;
|
||||
// The Android seeded-permutation variant is tried first (it validates
|
||||
// every restored RID); the structural remap is the fallback and the
|
||||
// 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:?}")))?;
|
||||
if report.remapped > 0 {
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
@@ -982,6 +1211,77 @@ pub fn deobfuscate_metadata_to(
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Write an embedded metadata blob (unwrapped from a restored Android
|
||||
/// library) next to the restored library. Returns the destination when a
|
||||
/// blob was written.
|
||||
fn write_embedded_metadata(
|
||||
embedded: Option<Vec<u8>>,
|
||||
so_dest: &Path,
|
||||
) -> anyhow::Result<Option<PathBuf>> {
|
||||
let Some(blob) = embedded else {
|
||||
return Ok(None);
|
||||
};
|
||||
let dest = crate::android::embedded_metadata_dest(so_dest);
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
write_atomic(&dest, &blob)?;
|
||||
Ok(Some(dest))
|
||||
}
|
||||
|
||||
/// Fold one package's per-entry outcomes into the run summary, UI, and log.
|
||||
fn apply_package_outcomes(
|
||||
outcomes: Vec<crate::android::EntryOutcome>,
|
||||
s: &mut Summary,
|
||||
bar: &indicatif::ProgressBar,
|
||||
quiet: bool,
|
||||
log: &Option<crate::logfile::Log>,
|
||||
) {
|
||||
use crate::android::{EntryKind, EntryStatus};
|
||||
for outcome in outcomes {
|
||||
match outcome.status {
|
||||
EntryStatus::Restored => {
|
||||
match outcome.kind {
|
||||
EntryKind::So => {
|
||||
s.unpacked += 1;
|
||||
crate::ui::ok_label(bar, quiet, &outcome.label, "So", &outcome.dest);
|
||||
}
|
||||
EntryKind::Metadata { remapped } => {
|
||||
s.metadata += 1;
|
||||
crate::ui::metadata(
|
||||
bar,
|
||||
quiet,
|
||||
Path::new(&outcome.label),
|
||||
remapped,
|
||||
&outcome.dest,
|
||||
);
|
||||
}
|
||||
EntryKind::EmbeddedMetadata => {
|
||||
s.metadata += 1;
|
||||
crate::ui::ok_label(bar, quiet, &outcome.label, "metadata", &outcome.dest);
|
||||
}
|
||||
}
|
||||
if let Some(log) = log {
|
||||
log.step(&format!("OK {} -> {:?}", outcome.label, outcome.dest));
|
||||
}
|
||||
}
|
||||
EntryStatus::Duplicate | EntryStatus::NotTarget | EntryStatus::Unchanged => {
|
||||
s.skipped += 1;
|
||||
if let Some(log) = log {
|
||||
log.step(&format!("SKIP {} ({:?})", outcome.label, outcome.kind));
|
||||
}
|
||||
}
|
||||
EntryStatus::Failed(e) => {
|
||||
s.errors += 1;
|
||||
crate::ui::err(bar, quiet, Path::new(&outcome.label), &e);
|
||||
if let Some(log) = log {
|
||||
log.step(&format!("ERR {}: {e:#}", outcome.label));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Filesystem and command-line orchestration.
|
||||
|
||||
pub mod android;
|
||||
pub mod job;
|
||||
pub mod logfile;
|
||||
pub mod pause;
|
||||
|
||||
+76
-33
@@ -115,6 +115,25 @@ enum Class {
|
||||
Crackproof,
|
||||
/// An il2cpp `global-metadata.dat` (de-obfuscation target).
|
||||
Metadata,
|
||||
/// A protected AArch64 shared library (Android restore target).
|
||||
AndroidSo,
|
||||
/// An Android app package (`.apk`/`.apks`/`.xapk`) — a container whose
|
||||
/// entries are content-probed individually during the Android pass.
|
||||
AndroidPackage,
|
||||
}
|
||||
|
||||
/// Everything one [`find_targets_opts`] walk found, plus non-target tallies.
|
||||
#[derive(Default)]
|
||||
pub struct ScanResult {
|
||||
/// Crackproof-protected PE files.
|
||||
pub crackproof: Vec<PathBuf>,
|
||||
/// il2cpp `global-metadata.dat` blobs.
|
||||
pub metadata: Vec<PathBuf>,
|
||||
/// Protected AArch64 shared libraries.
|
||||
pub android_so: Vec<PathBuf>,
|
||||
/// Android app packages (containers restored entry-by-entry).
|
||||
pub android_packages: Vec<PathBuf>,
|
||||
pub stats: ScanStats,
|
||||
}
|
||||
|
||||
/// Walk `root` recursively (skipping any directory literally named `"unpack"`)
|
||||
@@ -146,7 +165,7 @@ enum Class {
|
||||
/// thread count: each worker owns a disjoint contiguous slice of the path list
|
||||
/// and writes the matching disjoint slice of the class list, so results are
|
||||
/// deterministic.
|
||||
pub fn find_targets(root: &Path) -> (Vec<PathBuf>, Vec<PathBuf>, ScanStats) {
|
||||
pub fn find_targets(root: &Path) -> ScanResult {
|
||||
find_targets_opts(root, scan_all_env())
|
||||
}
|
||||
|
||||
@@ -169,7 +188,7 @@ pub struct ScanStats {
|
||||
/// [`find_targets`], but with the pre-filter explicitly controlled. When
|
||||
/// `scan_all` is true every regular file is probed, restoring the exhaustive
|
||||
/// (and on asset-heavy trees, far slower) behavior.
|
||||
pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<PathBuf>, ScanStats) {
|
||||
pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
||||
// Phase 1: serial traversal collecting regular-file paths only. No file is
|
||||
// opened here; `readdir` is fast relative to the content probe that follows,
|
||||
// and `entry.metadata()` is served from the directory entry on Windows, so
|
||||
@@ -246,19 +265,23 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<Path
|
||||
});
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let mut metadata = Vec::new();
|
||||
let mut result = ScanResult {
|
||||
stats,
|
||||
..ScanResult::default()
|
||||
};
|
||||
for (p, c) in paths.into_iter().zip(class) {
|
||||
match c {
|
||||
Some(Class::Crackproof) => candidates.push(p),
|
||||
Some(Class::Metadata) => metadata.push(p),
|
||||
Some(Class::None) => stats.skipped += 1,
|
||||
Some(Class::Crackproof) => result.crackproof.push(p),
|
||||
Some(Class::Metadata) => result.metadata.push(p),
|
||||
Some(Class::AndroidSo) => result.android_so.push(p),
|
||||
Some(Class::AndroidPackage) => result.android_packages.push(p),
|
||||
Some(Class::None) => result.stats.skipped += 1,
|
||||
// Unreadable / panicking probe: NOT skipped — the scan could not
|
||||
// classify it, so it may be a target we failed to unpack.
|
||||
None => stats.probe_errors += 1,
|
||||
None => result.stats.probe_errors += 1,
|
||||
}
|
||||
}
|
||||
(candidates, metadata, stats)
|
||||
result
|
||||
}
|
||||
|
||||
/// True if a walked directory entry is a reparse point (junction or symlink).
|
||||
@@ -292,10 +315,11 @@ pub fn scan_all_env() -> bool {
|
||||
}
|
||||
|
||||
/// Classify one file by content. Reads a short prefix once and tests the
|
||||
/// Crackproof detector first, then the il2cpp metadata magic. Returns `None`
|
||||
/// when the file could not be classified at all — an I/O error opening it
|
||||
/// (locked, permissions) or a panic inside a detector — so the caller counts
|
||||
/// it as a probe error rather than a clean "not a target" skip.
|
||||
/// Crackproof detector first, then the il2cpp metadata magic, then the
|
||||
/// Android probes. Returns `None` when the file could not be classified at
|
||||
/// all — an I/O error opening it (locked, permissions) or a panic inside a
|
||||
/// detector — so the caller counts it as a probe error rather than a clean
|
||||
/// "not a target" skip.
|
||||
///
|
||||
/// The detector is wrapped in `catch_unwind` because a panic in a scan worker
|
||||
/// thread would otherwise abort the whole folder run (a scoped-thread panic
|
||||
@@ -304,16 +328,32 @@ pub fn scan_all_env() -> bool {
|
||||
///
|
||||
/// A Crackproof PE never matches the metadata magic (it is a PE, not a
|
||||
/// metadata blob) and vice versa, so the order is immaterial.
|
||||
///
|
||||
/// The Android library probe needs more than the prefix: the protection
|
||||
/// payload lives in a section found via the section-header table at the *end*
|
||||
/// of the file, so an ELF64/AArch64 prefix triggers a full-file read. Only
|
||||
/// aarch64 images pay for it — a handful of `.so` files per app tree, against
|
||||
/// tens of thousands of assets the free name/size checks already rejected.
|
||||
fn classify(path: &Path) -> Option<Class> {
|
||||
let head = read_prefix(path, DETECT_PREFIX)?;
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
if detect(&head).is_some() {
|
||||
Class::Crackproof
|
||||
} else if senbei_metadata::is_metadata(&head) {
|
||||
Class::Metadata
|
||||
} else {
|
||||
Class::None
|
||||
return Class::Crackproof;
|
||||
}
|
||||
if senbei_metadata::is_metadata(&head) {
|
||||
return Class::Metadata;
|
||||
}
|
||||
if crate::android::is_elf64_aarch64(&head)
|
||||
&& std::fs::read(path)
|
||||
.map(|bytes| senbei_android_engine::is_protected_libil2cpp(&bytes))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Class::AndroidSo;
|
||||
}
|
||||
if crate::android::is_app_package(path, &head) {
|
||||
return Class::AndroidPackage;
|
||||
}
|
||||
Class::None
|
||||
}));
|
||||
r.ok()
|
||||
}
|
||||
@@ -370,11 +410,11 @@ mod tests {
|
||||
blob[..4].copy_from_slice(&0xFAB1_1BAFu32.to_le_bytes());
|
||||
std::fs::write(root.join("metadata"), &blob).unwrap();
|
||||
|
||||
let (_, filtered, _) = find_targets_opts(root, false);
|
||||
assert!(filtered.is_empty());
|
||||
let filtered = find_targets_opts(root, false);
|
||||
assert!(filtered.metadata.is_empty());
|
||||
|
||||
let (_, exhaustive, _) = find_targets_opts(root, true);
|
||||
assert_eq!(exhaustive.len(), 1);
|
||||
let exhaustive = find_targets_opts(root, true);
|
||||
assert_eq!(exhaustive.metadata.len(), 1);
|
||||
}
|
||||
|
||||
/// A file below the Crackproof key-table bound is skipped without being
|
||||
@@ -389,10 +429,10 @@ mod tests {
|
||||
|
||||
// None of them are Crackproof, so both modes find nothing; the point is
|
||||
// that the filtered walk does not panic and honors `scan_all`.
|
||||
let (c, m, _) = find_targets_opts(root, false);
|
||||
assert!(c.is_empty() && m.is_empty());
|
||||
let (c, m, _) = find_targets_opts(root, true);
|
||||
assert!(c.is_empty() && m.is_empty());
|
||||
let scan = find_targets_opts(root, false);
|
||||
assert!(scan.crackproof.is_empty() && scan.metadata.is_empty());
|
||||
let scan = find_targets_opts(root, true);
|
||||
assert!(scan.crackproof.is_empty() && scan.metadata.is_empty());
|
||||
}
|
||||
|
||||
/// An il2cpp metadata blob is found by the filtered scan: `.dat` is not on
|
||||
@@ -407,9 +447,9 @@ mod tests {
|
||||
// Same magic but too small to be processable — skipped by the size floor.
|
||||
std::fs::write(root.join("stub.dat"), &blob[..64]).unwrap();
|
||||
|
||||
let (_, m, _) = find_targets_opts(root, false);
|
||||
assert_eq!(m.len(), 1);
|
||||
assert!(m[0].ends_with("global-metadata.dat"));
|
||||
let scan = find_targets_opts(root, false);
|
||||
assert_eq!(scan.metadata.len(), 1);
|
||||
assert!(scan.metadata[0].ends_with("global-metadata.dat"));
|
||||
}
|
||||
|
||||
/// Review regression: a previous output tree is pruned case-insensitively
|
||||
@@ -428,12 +468,15 @@ mod tests {
|
||||
// A big non-target file at the root: probed, then skipped.
|
||||
std::fs::write(root.join("plain.dll"), vec![0u8; 100_000]).unwrap();
|
||||
|
||||
let (c, m, stats) = find_targets_opts(root, false);
|
||||
let scan = find_targets_opts(root, false);
|
||||
assert!(
|
||||
c.is_empty() && m.is_empty(),
|
||||
scan.crackproof.is_empty() && scan.metadata.is_empty(),
|
||||
"old output tree must be pruned"
|
||||
);
|
||||
assert_eq!(stats.skipped, 1, "the probed non-target counts as skipped");
|
||||
assert_eq!(stats.walk_errors, 0);
|
||||
assert_eq!(
|
||||
scan.stats.skipped, 1,
|
||||
"the probed non-target counts as skipped"
|
||||
);
|
||||
assert_eq!(scan.stats.walk_errors, 0);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -19,16 +19,22 @@ pub fn progress(n: u64, quiet: bool) -> ProgressBar {
|
||||
|
||||
/// Print a green success line, suspending the progress bar.
|
||||
pub fn ok(bar: &ProgressBar, quiet: bool, rel: &Path, kind: Kind, dest: &Path) {
|
||||
ok_label(
|
||||
bar,
|
||||
quiet,
|
||||
&rel.display().to_string(),
|
||||
&format!("{kind:?}"),
|
||||
dest,
|
||||
);
|
||||
}
|
||||
|
||||
/// Print a green success line with a free-form kind label (Android targets),
|
||||
/// suspending the progress bar.
|
||||
pub fn ok_label(bar: &ProgressBar, quiet: bool, rel: &str, label: &str, dest: &Path) {
|
||||
if quiet {
|
||||
return;
|
||||
}
|
||||
let msg = format!(
|
||||
"{} {:?} {} -> {}",
|
||||
"✓".green(),
|
||||
kind,
|
||||
rel.display(),
|
||||
dest.display()
|
||||
);
|
||||
let msg = format!("{} {} {} -> {}", "✓".green(), label, rel, dest.display());
|
||||
bar.suspend(|| println!("{msg}"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user