mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
refactor: align platform crate boundaries
This commit is contained in:
@@ -7,10 +7,13 @@ description = "Filesystem, scanning, logging, and CLI orchestration for Senbei"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
senbei-crypto.workspace = true
|
||||
indicatif.workspace = true
|
||||
memmap2.workspace = true
|
||||
owo-colors.workspace = true
|
||||
senbei-engine.workspace = true
|
||||
senbei-elf.workspace = true
|
||||
senbei-pe.workspace = true
|
||||
senbei-metadata.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -22,43 +22,54 @@ 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;
|
||||
|
||||
/// File name of an il2cpp metadata blob (a platform-standard technology name).
|
||||
pub const METADATA_FILE_NAME: &str = "global-metadata.dat";
|
||||
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"];
|
||||
|
||||
/// 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()
|
||||
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))
|
||||
});
|
||||
is_package_ext && prefix.starts_with(b"PK\x03\x04")
|
||||
})
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -245,7 +256,7 @@ pub fn restore_package(
|
||||
nested.push((index, name));
|
||||
}
|
||||
} else {
|
||||
if crate::scan::is_android_entry_name(&name) {
|
||||
if is_android_entry_name(&name) {
|
||||
direct.push((index, name));
|
||||
}
|
||||
}
|
||||
@@ -280,7 +291,7 @@ pub fn restore_package(
|
||||
let Some(entry_name) = entry_name else {
|
||||
bail!("unsafe entry path in `{}`", nested_label.display());
|
||||
};
|
||||
if crate::scan::is_android_entry_name(&entry_name) {
|
||||
if is_android_entry_name(&entry_name) {
|
||||
entries.push((nested_index, entry_name));
|
||||
}
|
||||
}
|
||||
@@ -398,14 +409,14 @@ pub fn embedded_metadata_dest(restored_so: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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::job::write_atomic(dest, data)
|
||||
crate::atomic::write_atomic(dest, data)
|
||||
.map_err(anyhow::Error::from)
|
||||
.context("write metadata output")
|
||||
}
|
||||
@@ -449,12 +460,3 @@ fn map_read_only(file: &File, path: &Path) -> Result<Mmap> {
|
||||
unsafe { MmapOptions::new().map(file) }
|
||||
.with_context(|| format!("map extracted `{}`", path.display()))
|
||||
}
|
||||
/// Lowercase hex of a digest output (sha2 0.11's `Array` no longer formats as
|
||||
/// hex directly).
|
||||
fn hex_digest(data: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(data.len() * 2);
|
||||
for byte in data {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Shared atomic filesystem writes for native orchestration.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Write `bytes` through a sibling temporary file and replace `dest` only after
|
||||
/// the complete write succeeds.
|
||||
pub(crate) fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut temporary_name = dest.as_os_str().to_os_string();
|
||||
temporary_name.push(".senbei-tmp");
|
||||
let temporary = PathBuf::from(temporary_name);
|
||||
let result = std::fs::write(&temporary, bytes).and_then(|()| std::fs::rename(&temporary, dest));
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
+4
-494
@@ -1,326 +1,9 @@
|
||||
use senbei_engine as unpacker;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Crackproof header key table lives at this fixed file offset. For the
|
||||
/// external-companion layout, the companion payload aligns to the stub here.
|
||||
const HEADER_OFF: usize = 4096;
|
||||
|
||||
/// Build the unpacker input for `input`, transparently handling the
|
||||
/// **external-companion** layout used by some il2cpp games.
|
||||
///
|
||||
/// In that layout a protected module is split into a thin on-disk loader stub
|
||||
/// (`Foo.dll`, whose code sections are stripped to one page) plus an encrypted
|
||||
/// `Foo.dll._` companion holding the real payload. The companion is byte-for-byte
|
||||
/// the stub's payload region starting at the Crackproof header (offset 4096), so
|
||||
/// `stub[..4096] ++ companion` reconstructs the ordinary embedded-payload file
|
||||
/// the existing pipelines already unpack. The runtime loader does exactly this:
|
||||
/// it maps `Foo.dll._` and feeds it through the standard Crackproof unpack.
|
||||
///
|
||||
/// The splice fires only when a sibling `<input>._` exists *and* its first 32
|
||||
/// bytes equal the stub's header at offset 4096 — a precise signal that the
|
||||
/// companion is this stub's payload. Otherwise the file is returned untouched,
|
||||
/// so normal (embedded-payload) inputs are unaffected.
|
||||
fn read_unpacker_input(input: &Path) -> std::io::Result<UnpackerInput> {
|
||||
let stub = std::fs::read(input)?;
|
||||
|
||||
// Companion path: append "._" to the full file name (Foo.dll -> Foo.dll._).
|
||||
let companion = match input.file_name() {
|
||||
Some(name) => {
|
||||
let mut n = name.to_os_string();
|
||||
n.push("._");
|
||||
input.with_file_name(n)
|
||||
}
|
||||
None => {
|
||||
return Ok(UnpackerInput {
|
||||
bytes: stub,
|
||||
stub: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
if !companion.is_file() {
|
||||
return Ok(UnpackerInput {
|
||||
bytes: stub,
|
||||
stub: None,
|
||||
});
|
||||
}
|
||||
let comp = std::fs::read(&companion)?;
|
||||
match splice_companion(&stub, &comp) {
|
||||
// A splice fired: keep the stub so its plaintext export table can be
|
||||
// overlaid onto the unpacked image (the companion does not carry it).
|
||||
Some(spliced) => Ok(UnpackerInput {
|
||||
bytes: spliced,
|
||||
stub: Some(stub),
|
||||
}),
|
||||
None => Ok(UnpackerInput {
|
||||
bytes: stub,
|
||||
stub: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes fed to the unpacker, plus the original loader stub when the input
|
||||
/// was reconstructed from an external companion. The stub is retained because
|
||||
/// the crackproof loader rebuilds the PE export table at runtime from data kept
|
||||
/// in the stub — that table is *not* present in the encrypted companion, so the
|
||||
/// unpacked image needs it overlaid from the stub afterwards
|
||||
/// (see [`overlay_exports_from_stub`]).
|
||||
struct UnpackerInput {
|
||||
bytes: Vec<u8>,
|
||||
stub: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Overlay the PE export table from the loader `stub` onto the unpacked image
|
||||
/// `out`, for the external-companion layout.
|
||||
///
|
||||
/// In that layout the encrypted companion carries the real `.text`/`il2cpp`
|
||||
/// payload but **not** a usable export directory: the crackproof loader rebuilds
|
||||
/// exports at runtime from the plaintext copy retained in the stub's `.rdata`.
|
||||
/// Statically, the spliced input therefore decrypts to a garbage export
|
||||
/// directory (`NumberOfFunctions` etc. are ciphertext), which makes downstream
|
||||
/// tools (IL2CppDumper, IDA) choke when they parse it. The fix does what the
|
||||
/// loader does: copy the export-directory region byte-for-byte from the stub to
|
||||
/// the same RVA in the unpacked image.
|
||||
///
|
||||
/// No-op (leaves `out` untouched) if there is no export directory, or if the
|
||||
/// region cannot be mapped in either image — so a malformed stub can never
|
||||
/// corrupt an otherwise-good unpack.
|
||||
fn overlay_exports_from_stub(out: &mut [u8], stub: &[u8]) {
|
||||
let (export_rva, export_size) = match pe_export_dir(out) {
|
||||
Some(v) if v.1 != 0 => v,
|
||||
_ => return,
|
||||
};
|
||||
let dst = match rva_to_file_off(out, export_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
let src = match rva_to_file_off(stub, export_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
let n = export_size as usize;
|
||||
if dst + n <= out.len() && src + n <= stub.len() {
|
||||
out[dst..dst + n].copy_from_slice(&stub[src..src + n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the TLS directory from the loader `stub` onto the unpacked image
|
||||
/// `out`, for the external-companion layout.
|
||||
///
|
||||
/// Crackproof strips the whole `IMAGE_TLS_DIRECTORY` from the encrypted payload
|
||||
/// — the data-directory entry, the directory struct, the raw-data template, and
|
||||
/// the base relocations for the struct's four 64-bit pointer fields — and
|
||||
/// re-installs TLS itself from data kept in the stub when it loads the module.
|
||||
/// A statically-unpacked DLL is loaded by the ordinary Windows loader instead,
|
||||
/// which needs a valid TLS directory or it never allocates a TLS slot for the
|
||||
/// module nor writes `_tls_index`. The module's C++ `thread_local` accesses then
|
||||
/// read a garbage TLS slot — observed as a `0xC0000005` deep in IL2CPP type
|
||||
/// resolution (a TypeDef token used as a raw `s_TypeInfoTable` index).
|
||||
///
|
||||
/// The stub retains the full plaintext `.rdata` (only `.text`/`il2cpp` are
|
||||
/// stripped to one page), so the directory struct and its raw-data template are
|
||||
/// copied back byte-for-byte at their RVAs, the data-directory entry is taken
|
||||
/// from the stub header (the unpacked image's was overwritten with the zeroed
|
||||
/// saved-header blob), and four DIR64 relocations are appended to `.reloc`.
|
||||
///
|
||||
/// No-op if the stub declares no TLS directory or if any required region cannot
|
||||
/// be mapped/relocated — so it can never corrupt an otherwise-good unpack.
|
||||
fn restore_tls_from_stub(out: &mut [u8], stub: &[u8]) {
|
||||
let pe = match read_u32(out, 0x3C) {
|
||||
Some(v) => v as usize,
|
||||
None => return,
|
||||
};
|
||||
if out.get(pe..pe + 4) != Some(&b"PE\0\0"[..]) {
|
||||
return;
|
||||
}
|
||||
// This restore is PE32+-only: it copies a 40-byte IMAGE_TLS_DIRECTORY64,
|
||||
// converts fields with a 64-bit image base, and appends DIR64 relocs. A
|
||||
// PE32 module needs the 24-byte struct / DIR32 handling (the unpacker core
|
||||
// does that itself — see `restore_pe32_tls_from_stub`), so bail rather than
|
||||
// read the data directories at the wrong (PE32+) offset and write garbage.
|
||||
if read_u16(out, pe + 24) != Some(0x20B) {
|
||||
return;
|
||||
}
|
||||
// TLS is data-directory index 9 (PE32+ directories at optional header +112).
|
||||
let tls_dd = match pe.checked_add(24 + 112 + 9 * 8) {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
// The genuine entry survives in the stub header; the unpacked image's copy
|
||||
// was clobbered by the (zeroed-TLS) saved-header blob.
|
||||
let (tls_rva, tls_size) = match (read_u32(stub, tls_dd), read_u32(stub, tls_dd + 4)) {
|
||||
(Some(r), Some(s)) if r != 0 && s != 0 => (r, s),
|
||||
_ => return, // module has no TLS — nothing to restore
|
||||
};
|
||||
// Image base (PE32+, optional header +24) converts the struct's absolute VAs
|
||||
// back to RVAs for the raw-data template overlay.
|
||||
let image_base = match read_u64(out, pe + 24 + 24) {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 1) Overlay the IMAGE_TLS_DIRECTORY struct from the stub at its RVA.
|
||||
let dst = match rva_to_file_off(out, tls_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
let src = match rva_to_file_off(stub, tls_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
let n = tls_size as usize;
|
||||
if dst.checked_add(n).is_none_or(|e| e > out.len())
|
||||
|| src.checked_add(n).is_none_or(|e| e > stub.len())
|
||||
{
|
||||
return;
|
||||
}
|
||||
out[dst..dst + n].copy_from_slice(&stub[src..src + n]);
|
||||
|
||||
// 2) Restore the data-directory entry so the loader processes TLS at all.
|
||||
write_u32_at(out, tls_dd, tls_rva);
|
||||
write_u32_at(out, tls_dd + 4, tls_size);
|
||||
|
||||
// 3) Overlay the raw-data template [StartAddressOfRawData, EndAddressOfRawData).
|
||||
if let (Some(start_va), Some(end_va)) = (read_u64(out, dst), read_u64(out, dst + 8))
|
||||
&& end_va > start_va
|
||||
&& start_va >= image_base
|
||||
{
|
||||
let tpl_rva = (start_va - image_base) as u32;
|
||||
let tpl_len = (end_va - start_va) as usize;
|
||||
if let (Some(td), Some(ts)) = (
|
||||
rva_to_file_off(out, tpl_rva),
|
||||
rva_to_file_off(stub, tpl_rva),
|
||||
) && td.checked_add(tpl_len).is_some_and(|e| e <= out.len())
|
||||
&& ts.checked_add(tpl_len).is_some_and(|e| e <= stub.len())
|
||||
{
|
||||
out[td..td + tpl_len].copy_from_slice(&stub[ts..ts + tpl_len]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Append DIR64 relocations for the struct's four 64-bit pointer fields
|
||||
// (Start/End/Index/CallBacks at +0/+8/+0x10/+0x18). Without them the
|
||||
// loader would leave preferred-base VAs in a rebased image.
|
||||
add_tls_relocs(out, pe, tls_rva);
|
||||
}
|
||||
|
||||
/// Append a single base-relocation block covering the four 64-bit pointer fields
|
||||
/// of the TLS directory struct at `tls_rva`. The block is written immediately
|
||||
/// after the existing relocation table (which must be free space and in bounds)
|
||||
/// and the BaseReloc directory size is grown to include it. No-op if the table
|
||||
/// is absent, the fields straddle a relocation page, or the slot is not free.
|
||||
fn add_tls_relocs(out: &mut [u8], pe: usize, tls_rva: u32) {
|
||||
let reloc_dd = pe + 24 + 112 + 5 * 8; // BaseReloc = directory index 5
|
||||
let (reloc_rva, reloc_size) = match (read_u32(out, reloc_dd), read_u32(out, reloc_dd + 4)) {
|
||||
(Some(r), Some(s)) if r != 0 => (r, s),
|
||||
_ => return,
|
||||
};
|
||||
// All four fields (last at +0x18) must share one 0x1000 relocation page.
|
||||
let page = tls_rva & !0xFFF;
|
||||
if (tls_rva.wrapping_add(0x18)) & !0xFFF != page {
|
||||
return;
|
||||
}
|
||||
const BLOCK: usize = 8 + 4 * 2; // header + four DIR64 entries
|
||||
let at = match rva_to_file_off(out, reloc_rva.wrapping_add(reloc_size)) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
if at.checked_add(BLOCK).is_none_or(|e| e > out.len()) {
|
||||
return;
|
||||
}
|
||||
if out[at..at + BLOCK].iter().any(|&b| b != 0) {
|
||||
return; // refuse to clobber existing data
|
||||
}
|
||||
write_u32_at(out, at, page);
|
||||
write_u32_at(out, at + 4, BLOCK as u32);
|
||||
for (i, off) in [0u32, 8, 0x10, 0x18].iter().enumerate() {
|
||||
let entry = (10u16 << 12) | (((tls_rva.wrapping_add(*off)) & 0xFFF) as u16);
|
||||
let p = at + 8 + i * 2;
|
||||
out[p..p + 2].copy_from_slice(&entry.to_le_bytes());
|
||||
}
|
||||
write_u32_at(out, reloc_dd + 4, reloc_size.wrapping_add(BLOCK as u32));
|
||||
}
|
||||
|
||||
/// Read the Export data-directory (RVA, size) from a PE image, or `None` if the
|
||||
/// headers are too short/invalid to parse.
|
||||
fn pe_export_dir(buf: &[u8]) -> Option<(u32, u32)> {
|
||||
let pe = read_u32(buf, 0x3C)? as usize;
|
||||
if buf.get(pe..pe + 4)? != b"PE\0\0" {
|
||||
return None;
|
||||
}
|
||||
// Optional header at pe+24; data directories start at +96 on PE32 (0x10B)
|
||||
// and +112 on PE32+ (0x20B); Export is index 0.
|
||||
let dd_base = match read_u16(buf, pe + 24)? {
|
||||
0x20B => 112,
|
||||
0x10B => 96,
|
||||
_ => return None,
|
||||
};
|
||||
let dd = pe.checked_add(24 + dd_base)?;
|
||||
Some((read_u32(buf, dd)?, read_u32(buf, dd + 4)?))
|
||||
}
|
||||
|
||||
/// Map an RVA to a file offset using the PE section table. Returns `None` if no
|
||||
/// section contains the RVA or the headers cannot be parsed.
|
||||
fn rva_to_file_off(buf: &[u8], rva: u32) -> Option<usize> {
|
||||
let pe = read_u32(buf, 0x3C)? as usize;
|
||||
if buf.get(pe..pe + 4)? != b"PE\0\0" {
|
||||
return None;
|
||||
}
|
||||
let nsec = read_u16(buf, pe + 6)? as usize;
|
||||
let opt_size = read_u16(buf, pe + 20)? as usize;
|
||||
let sh = pe.checked_add(24)?.checked_add(opt_size)?;
|
||||
for i in 0..nsec {
|
||||
let o = sh.checked_add(i.checked_mul(40)?)?;
|
||||
let vsz = read_u32(buf, o + 8)?;
|
||||
let va = read_u32(buf, o + 12)?;
|
||||
let raw = read_u32(buf, o + 20)?;
|
||||
if rva >= va && rva < va.wrapping_add(vsz.max(1)) {
|
||||
return Some((rva - va).wrapping_add(raw) as usize);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_u32(buf: &[u8], off: usize) -> Option<u32> {
|
||||
let b = buf.get(off..off + 4)?;
|
||||
Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
fn read_u16(buf: &[u8], off: usize) -> Option<u16> {
|
||||
let b = buf.get(off..off + 2)?;
|
||||
Some(u16::from_le_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
fn read_u64(buf: &[u8], off: usize) -> Option<u64> {
|
||||
let b = buf.get(off..off + 8)?;
|
||||
Some(u64::from_le_bytes([
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
]))
|
||||
}
|
||||
|
||||
/// Write a little-endian `u32` at `off`, silently doing nothing if out of bounds.
|
||||
fn write_u32_at(buf: &mut [u8], off: usize, val: u32) {
|
||||
if let Some(slot) = buf.get_mut(off..off + 4) {
|
||||
slot.copy_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Splice a stub and its external-companion payload into the embedded-payload
|
||||
/// form the pipelines expect, or `None` if `comp` is not this stub's payload.
|
||||
///
|
||||
/// The companion is byte-for-byte the stub's payload region from the Crackproof
|
||||
/// header (offset 4096) onward, so the result is `stub[..4096] ++ comp`. The
|
||||
/// splice fires only when the first 32 bytes of `comp` equal the stub's header
|
||||
/// at offset 4096 — a 32-byte match on the key-table/magic region that confirms
|
||||
/// the pairing and leaves ordinary (non-companion) inputs untouched.
|
||||
fn splice_companion(stub: &[u8], comp: &[u8]) -> Option<Vec<u8>> {
|
||||
let hdr_end = HEADER_OFF + 32;
|
||||
if stub.len() >= hdr_end && comp.len() >= 32 && stub[HEADER_OFF..hdr_end] == comp[..32] {
|
||||
let mut spliced = Vec::with_capacity(HEADER_OFF + comp.len());
|
||||
spliced.extend_from_slice(&stub[..HEADER_OFF]);
|
||||
spliced.extend_from_slice(comp);
|
||||
return Some(spliced);
|
||||
}
|
||||
None
|
||||
}
|
||||
use crate::atomic::write_atomic;
|
||||
pub use crate::windows::{
|
||||
UnpackedImage, unpack_bytes, unpack_bytes_force_exe, unpack_one, unpack_one_v,
|
||||
};
|
||||
|
||||
/// Summary of a folder-mode run.
|
||||
#[derive(Default)]
|
||||
@@ -1036,139 +719,6 @@ fn unsupported_version(e: &anyhow::Error) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Write `bytes` to `dest` atomically: a sibling temp file, then a rename.
|
||||
/// A direct `std::fs::write` truncates the destination first, so a mid-write
|
||||
/// 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.
|
||||
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);
|
||||
let r = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, dest));
|
||||
if r.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Detect `bytes` and run the right pipeline. Spliced external companions use
|
||||
/// the EXE pipeline directly because that layout is definitionally EXE-style.
|
||||
///
|
||||
/// Routing spliced inputs straight to the EXE pipeline is safe: the
|
||||
/// companion layout is definitionally the EXE-style shell (the runtime
|
||||
/// loader maps the companion and runs the standard shell unpack), so the DLL
|
||||
/// pipeline probe can never be right for it. Output bytes are identical to the
|
||||
/// DLL-first + EXE-fallback route for every input that route handles.
|
||||
fn unpack_spliced_or_auto(
|
||||
bytes: &[u8],
|
||||
spliced: bool,
|
||||
force_exe: bool,
|
||||
verbose: bool,
|
||||
) -> Result<(unpacker::Kind, Vec<u8>), unpacker::UnpackError> {
|
||||
if spliced || force_exe {
|
||||
let detected = unpacker::detect(bytes).ok_or(unpacker::UnpackError::NotCrackproof)?;
|
||||
let out = unpacker::unpack_exe_v(bytes, verbose)?;
|
||||
return Ok((detected.kind, out));
|
||||
}
|
||||
unpacker::unpack_auto_v(bytes, verbose)
|
||||
}
|
||||
|
||||
/// Unpack a single file to `dest`. Returns the Kind and integrity report on success.
|
||||
pub fn unpack_one(
|
||||
input: &Path,
|
||||
dest: &Path,
|
||||
) -> anyhow::Result<(unpacker::Kind, unpacker::IntegrityReport)> {
|
||||
unpack_one_v(input, dest, false)
|
||||
}
|
||||
|
||||
/// Outcome of a byte-level unpack ([`unpack_bytes`]): the image, its detected
|
||||
/// kind, and its integrity report. No file I/O is involved.
|
||||
pub struct UnpackedImage {
|
||||
pub kind: unpacker::Kind,
|
||||
pub bytes: Vec<u8>,
|
||||
pub integrity: unpacker::IntegrityReport,
|
||||
/// True when the input was reconstructed from an external companion (the
|
||||
/// `._` layout), i.e. the export/TLS overlays ran.
|
||||
pub companion: bool,
|
||||
}
|
||||
|
||||
/// Unpack in-memory `input` bytes, optionally paired with an external
|
||||
/// companion payload `companion` (the `<input>._` file's contents).
|
||||
///
|
||||
/// This is the in-memory counterpart of [`unpack_one_v`]: splice a matching
|
||||
/// companion, unpack, overlay the export table and TLS directory from the stub,
|
||||
/// then run the static integrity check.
|
||||
pub fn unpack_bytes(
|
||||
input: &[u8],
|
||||
companion: Option<&[u8]>,
|
||||
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||
unpack_bytes_impl(input, companion, false)
|
||||
}
|
||||
|
||||
/// Like [`unpack_bytes`], but forces the EXE pipeline (no DLL-pipeline
|
||||
/// probe). This is the web app's recovery path: the DLL-first probe relies
|
||||
/// on `catch_unwind` to reject EXE-shell-layout DLLs, and panics cannot be
|
||||
/// caught on wasm — the probe traps the whole call. The web app runs each
|
||||
/// unpack in a disposable Web Worker and retries trapped DLLs with this
|
||||
/// entry point, reproducing the CLI's dll-first/exe-fallback routing.
|
||||
pub fn unpack_bytes_force_exe(
|
||||
input: &[u8],
|
||||
companion: Option<&[u8]>,
|
||||
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||
unpack_bytes_impl(input, companion, true)
|
||||
}
|
||||
|
||||
fn unpack_bytes_impl(
|
||||
input: &[u8],
|
||||
companion: Option<&[u8]>,
|
||||
force_exe: bool,
|
||||
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||
let spliced = companion.and_then(|c| splice_companion(input, c));
|
||||
let bytes: &[u8] = spliced.as_deref().unwrap_or(input);
|
||||
let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), force_exe, false)?;
|
||||
if spliced.is_some() {
|
||||
overlay_exports_from_stub(&mut out, input);
|
||||
restore_tls_from_stub(&mut out, input);
|
||||
}
|
||||
let integrity = unpacker::check_integrity(&out);
|
||||
Ok(UnpackedImage {
|
||||
kind,
|
||||
bytes: out,
|
||||
integrity,
|
||||
companion: spliced.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Like [`unpack_one`], but prints detailed `[N/9]` step progress (and a final
|
||||
/// `Write to <dest>` line) to stdout when `verbose` is true.
|
||||
pub fn unpack_one_v(
|
||||
input: &Path,
|
||||
dest: &Path,
|
||||
verbose: bool,
|
||||
) -> anyhow::Result<(unpacker::Kind, unpacker::IntegrityReport)> {
|
||||
let UnpackerInput { bytes, stub } = read_unpacker_input(input)?;
|
||||
let (kind, mut out) = unpack_spliced_or_auto(&bytes, stub.is_some(), false, verbose)?;
|
||||
// External-companion layout: restore the export table from the stub, which
|
||||
// the encrypted companion does not carry (the loader rebuilds it at runtime).
|
||||
if let Some(stub) = stub {
|
||||
overlay_exports_from_stub(&mut out, &stub);
|
||||
// ...and the TLS directory, which Crackproof strips from the payload and
|
||||
// re-installs at runtime; the ordinary loader needs it or thread_local
|
||||
// access crashes (see [`restore_tls_from_stub`]).
|
||||
restore_tls_from_stub(&mut out, &stub);
|
||||
}
|
||||
let report = unpacker::check_integrity(&out);
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
write_atomic(dest, &out)?;
|
||||
if verbose {
|
||||
println!("Write to {}", dest.display());
|
||||
}
|
||||
Ok((kind, report))
|
||||
}
|
||||
|
||||
/// De-obfuscate an il2cpp `global-metadata.dat` to `dest`.
|
||||
///
|
||||
/// Crackproof's `-GMD` option scrambles each `Il2CppMethodDefinition`'s token
|
||||
@@ -1297,44 +847,4 @@ mod tests {
|
||||
let rel = rel_in_tree(root, under);
|
||||
assert_eq!(rel.as_ref(), Path::new(r"bin\app.exe"));
|
||||
}
|
||||
|
||||
fn stub_with_header(header: &[u8; 32], extra: usize) -> Vec<u8> {
|
||||
let mut s = vec![0u8; HEADER_OFF];
|
||||
s.extend_from_slice(header);
|
||||
s.extend_from_slice(&vec![0xAAu8; extra]);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splices_when_header_matches() {
|
||||
let header = [7u8; 32];
|
||||
let stub = stub_with_header(&header, 16);
|
||||
// Companion: same 32-byte header, then the real (longer) payload.
|
||||
let mut comp = header.to_vec();
|
||||
comp.extend_from_slice(&[0x42u8; 1000]);
|
||||
|
||||
let out = splice_companion(&stub, &comp).expect("should splice");
|
||||
assert_eq!(out.len(), HEADER_OFF + comp.len());
|
||||
assert_eq!(&out[..HEADER_OFF], &stub[..HEADER_OFF]);
|
||||
assert_eq!(&out[HEADER_OFF..], &comp[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_splice_when_header_differs() {
|
||||
let stub = stub_with_header(&[7u8; 32], 16);
|
||||
let mut comp = vec![9u8; 32]; // different header
|
||||
comp.extend_from_slice(&[0x42u8; 1000]);
|
||||
assert!(splice_companion(&stub, &comp).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_splice_when_too_short() {
|
||||
let short_stub = vec![0u8; HEADER_OFF + 8]; // < HEADER_OFF + 32
|
||||
let comp = vec![0u8; 64];
|
||||
assert!(splice_companion(&short_stub, &comp).is_none());
|
||||
|
||||
let stub = stub_with_header(&[1u8; 32], 0);
|
||||
let short_comp = vec![1u8; 16]; // < 32
|
||||
assert!(splice_companion(&stub, &short_comp).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! Filesystem and command-line orchestration.
|
||||
|
||||
/// File name of an IL2CPP metadata blob shared by both platform scanners.
|
||||
pub const METADATA_FILE_NAME: &str = "global-metadata.dat";
|
||||
|
||||
pub mod android;
|
||||
mod atomic;
|
||||
pub mod job;
|
||||
pub mod logfile;
|
||||
pub mod pause;
|
||||
pub mod scan;
|
||||
pub mod ui;
|
||||
pub mod windows;
|
||||
|
||||
+17
-95
@@ -1,6 +1,4 @@
|
||||
use memmap2::MmapOptions;
|
||||
use senbei_engine::detect;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
@@ -27,56 +25,10 @@ const DETECT_PREFIX: u64 = 8 * 1024;
|
||||
/// processable is lost.
|
||||
const MIN_SIZE: u64 = 4128;
|
||||
|
||||
const METADATA_FILE_NAME: &str = "global-metadata.dat";
|
||||
|
||||
fn is_metadata_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))
|
||||
}
|
||||
|
||||
fn is_target_extension(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
ext.eq_ignore_ascii_case("exe")
|
||||
|| ext.eq_ignore_ascii_case("dll")
|
||||
|| ext.eq_ignore_ascii_case("so")
|
||||
})
|
||||
}
|
||||
|
||||
fn is_android_package_name(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
ext.eq_ignore_ascii_case("apk")
|
||||
|| ext.eq_ignore_ascii_case("apks")
|
||||
|| ext.eq_ignore_ascii_case("xapk")
|
||||
})
|
||||
}
|
||||
|
||||
/// External Windows payloads are consumed through their sibling `.exe`/`.dll`
|
||||
/// stub. They are valid input bytes, but are not independent unpack targets.
|
||||
pub(crate) fn is_windows_companion(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(stub_name) = name.strip_suffix("._") else {
|
||||
return false;
|
||||
};
|
||||
let stub_path = Path::new(stub_name);
|
||||
stub_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe") || ext.eq_ignore_ascii_case("dll"))
|
||||
}
|
||||
|
||||
pub(crate) fn is_android_entry_name(path: &Path) -> bool {
|
||||
is_metadata_name(path)
|
||||
|| path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("so"))
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(crate::METADATA_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Content classification of a single file.
|
||||
@@ -182,7 +134,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
||||
// Skip reparse-point directories (junctions, symlink-dirs): they point
|
||||
// outside the scanned tree — walking one would silently unpack an
|
||||
// entire foreign tree (e.g. a `samples` junction into the golden corpus).
|
||||
!is_reparse_point(e)
|
||||
!crate::windows::is_reparse_point(e)
|
||||
}) {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
@@ -194,12 +146,13 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
if is_windows_companion(entry.path()) {
|
||||
if crate::windows::is_companion(entry.path()) {
|
||||
continue;
|
||||
}
|
||||
if !is_metadata_name(entry.path())
|
||||
&& !is_target_extension(entry.path())
|
||||
&& !is_android_package_name(entry.path())
|
||||
&& !crate::windows::is_pe_extension(entry.path())
|
||||
&& !crate::android::is_so_name(entry.path())
|
||||
&& !crate::android::is_package_name(entry.path())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -258,26 +211,6 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
||||
result
|
||||
}
|
||||
|
||||
/// True if a walked directory entry is a reparse point (junction or symlink).
|
||||
///
|
||||
/// `DirEntry::file_type` only flags true symlinks; NTFS junctions report as
|
||||
/// ordinary directories, so without this check the walker descends into them.
|
||||
/// Off-Windows there are no junctions — symlink dirs are already excluded
|
||||
/// because `follow_links` is off (their `file_type().is_dir()` is false).
|
||||
#[cfg(windows)]
|
||||
fn is_reparse_point(e: &walkdir::DirEntry) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
e.metadata()
|
||||
.map(|m| m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn is_reparse_point(_e: &walkdir::DirEntry) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether the size pre-filter is disabled via `SENBEI_SCAN_ALL`. Any value
|
||||
/// other than `0`/empty enables probing small selected target names. It never
|
||||
/// expands the platform filename boundary.
|
||||
@@ -309,33 +242,18 @@ pub fn scan_all_env() -> bool {
|
||||
fn classify(path: &Path) -> Option<Class> {
|
||||
let head = read_prefix(path, DETECT_PREFIX)?;
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
if is_android_package_name(path) && crate::android::is_app_package(path, &head) {
|
||||
if crate::android::is_package_name(path) && crate::android::is_app_package(path, &head) {
|
||||
return Class::AndroidPackage;
|
||||
}
|
||||
if is_metadata_name(path) && senbei_metadata::is_metadata(&head) {
|
||||
return Class::Metadata;
|
||||
}
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe") || ext.eq_ignore_ascii_case("dll"))
|
||||
&& detect(&head).is_some()
|
||||
{
|
||||
if crate::windows::is_pe_extension(path) && detect(&head).is_some() {
|
||||
return Class::Crackproof;
|
||||
}
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("so"))
|
||||
if crate::android::is_so_name(path)
|
||||
&& crate::android::is_elf64_aarch64(&head)
|
||||
&& File::open(path)
|
||||
.and_then(|file| {
|
||||
// SAFETY: the file remains open for the mapping lifetime
|
||||
// and the mapping is read-only.
|
||||
unsafe { MmapOptions::new().map(&file) }
|
||||
})
|
||||
.map(|bytes| senbei_engine::android::is_protected_libil2cpp(&bytes))
|
||||
.unwrap_or(false)
|
||||
&& crate::android::is_protected_so_file(path)
|
||||
{
|
||||
return Class::AndroidSo;
|
||||
}
|
||||
@@ -366,7 +284,9 @@ mod tests {
|
||||
"global-metadata.dat",
|
||||
] {
|
||||
assert!(
|
||||
is_metadata_name(Path::new(p)) || is_target_extension(Path::new(p)),
|
||||
is_metadata_name(Path::new(p))
|
||||
|| crate::windows::is_pe_extension(Path::new(p))
|
||||
|| crate::android::is_so_name(Path::new(p)),
|
||||
"{p} should be a candidate"
|
||||
);
|
||||
}
|
||||
@@ -379,7 +299,9 @@ mod tests {
|
||||
"a.ab",
|
||||
] {
|
||||
assert!(
|
||||
!is_metadata_name(Path::new(p)) && !is_target_extension(Path::new(p)),
|
||||
!is_metadata_name(Path::new(p))
|
||||
&& !crate::windows::is_pe_extension(Path::new(p))
|
||||
&& !crate::android::is_so_name(Path::new(p)),
|
||||
"{p} must not be a candidate"
|
||||
);
|
||||
}
|
||||
@@ -472,7 +394,7 @@ mod tests {
|
||||
|
||||
let scan = find_targets_opts(root, false);
|
||||
assert_eq!(scan.stats.skipped, 1, "only the stub was probed");
|
||||
assert!(is_windows_companion(&root.join("app.exe._")));
|
||||
assert!(crate::windows::is_companion(&root.join("app.exe._")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
//! Windows filesystem adapter for PE companion payloads and byte APIs.
|
||||
|
||||
use senbei_engine as unpacker;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::atomic::write_atomic;
|
||||
|
||||
pub(crate) fn is_pe_extension(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe") || ext.eq_ignore_ascii_case("dll"))
|
||||
}
|
||||
|
||||
pub(crate) fn is_companion(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(stub_name) = name.strip_suffix("._") else {
|
||||
return false;
|
||||
};
|
||||
is_pe_extension(Path::new(stub_name))
|
||||
}
|
||||
|
||||
/// Return whether a directory entry is an NTFS reparse point. The scanner keeps
|
||||
/// this host-specific check in the Windows adapter while the traversal itself
|
||||
/// remains platform-neutral.
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn is_reparse_point(entry: &walkdir::DirEntry) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
entry
|
||||
.metadata()
|
||||
.map(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub(crate) fn is_reparse_point(_entry: &walkdir::DirEntry) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Crackproof header key table lives at this fixed file offset. For the
|
||||
/// external-companion layout, the companion payload aligns to the stub here.
|
||||
const HEADER_OFF: usize = 4096;
|
||||
|
||||
/// Build the unpacker input for `input`, transparently handling the
|
||||
/// **external-companion** layout used by some il2cpp games.
|
||||
///
|
||||
/// In that layout a protected module is split into a thin on-disk loader stub
|
||||
/// (`Foo.dll`, whose code sections are stripped to one page) plus an encrypted
|
||||
/// `Foo.dll._` companion holding the real payload. The companion is byte-for-byte
|
||||
/// the stub's payload region starting at the Crackproof header (offset 4096), so
|
||||
/// `stub[..4096] ++ companion` reconstructs the ordinary embedded-payload file
|
||||
/// the existing pipelines already unpack. The runtime loader does exactly this:
|
||||
/// it maps `Foo.dll._` and feeds it through the standard Crackproof unpack.
|
||||
///
|
||||
/// The splice fires only when a sibling `<input>._` exists *and* its first 32
|
||||
/// bytes equal the stub's header at offset 4096 — a precise signal that the
|
||||
/// companion is this stub's payload. Otherwise the file is returned untouched,
|
||||
/// so normal (embedded-payload) inputs are unaffected.
|
||||
pub(crate) fn read_unpacker_input(input: &Path) -> std::io::Result<UnpackerInput> {
|
||||
let stub = std::fs::read(input)?;
|
||||
|
||||
// Companion path: append "._" to the full file name (Foo.dll -> Foo.dll._).
|
||||
let companion = match input.file_name() {
|
||||
Some(name) => {
|
||||
let mut n = name.to_os_string();
|
||||
n.push("._");
|
||||
input.with_file_name(n)
|
||||
}
|
||||
None => {
|
||||
return Ok(UnpackerInput {
|
||||
bytes: stub,
|
||||
stub: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
if !companion.is_file() {
|
||||
return Ok(UnpackerInput {
|
||||
bytes: stub,
|
||||
stub: None,
|
||||
});
|
||||
}
|
||||
let comp = std::fs::read(&companion)?;
|
||||
match splice_companion(&stub, &comp) {
|
||||
// A splice fired: keep the stub so its plaintext export table can be
|
||||
// overlaid onto the unpacked image (the companion does not carry it).
|
||||
Some(spliced) => Ok(UnpackerInput {
|
||||
bytes: spliced,
|
||||
stub: Some(stub),
|
||||
}),
|
||||
None => Ok(UnpackerInput {
|
||||
bytes: stub,
|
||||
stub: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes fed to the unpacker, plus the original loader stub when the input
|
||||
/// was reconstructed from an external companion. The stub is retained because
|
||||
/// the crackproof loader rebuilds the PE export table at runtime from data kept
|
||||
/// in the stub — that table is *not* present in the encrypted companion, so the
|
||||
/// unpacked image needs it overlaid from the stub afterwards
|
||||
/// (see [`overlay_exports_from_stub`]).
|
||||
pub(crate) struct UnpackerInput {
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) stub: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Overlay the PE export table from the loader `stub` onto the unpacked image
|
||||
/// `out`, for the external-companion layout.
|
||||
///
|
||||
/// In that layout the encrypted companion carries the real `.text`/`il2cpp`
|
||||
/// payload but **not** a usable export directory: the crackproof loader rebuilds
|
||||
/// exports at runtime from the plaintext copy retained in the stub's `.rdata`.
|
||||
/// Statically, the spliced input therefore decrypts to a garbage export
|
||||
/// directory (`NumberOfFunctions` etc. are ciphertext), which makes downstream
|
||||
/// tools (IL2CppDumper, IDA) choke when they parse it. The fix does what the
|
||||
/// loader does: copy the export-directory region byte-for-byte from the stub to
|
||||
/// the same RVA in the unpacked image.
|
||||
///
|
||||
/// No-op (leaves `out` untouched) if there is no export directory, or if the
|
||||
/// region cannot be mapped in either image — so a malformed stub can never
|
||||
/// corrupt an otherwise-good unpack.
|
||||
pub(crate) fn overlay_exports_from_stub(out: &mut [u8], stub: &[u8]) {
|
||||
let (export_rva, export_size) = match pe_export_dir(out) {
|
||||
Some(v) if v.1 != 0 => v,
|
||||
_ => return,
|
||||
};
|
||||
let dst = match rva_to_file_off(out, export_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
let src = match rva_to_file_off(stub, export_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
let n = export_size as usize;
|
||||
if dst + n <= out.len() && src + n <= stub.len() {
|
||||
out[dst..dst + n].copy_from_slice(&stub[src..src + n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the TLS directory from the loader `stub` onto the unpacked image
|
||||
/// `out`, for the external-companion layout.
|
||||
///
|
||||
/// Crackproof strips the whole `IMAGE_TLS_DIRECTORY` from the encrypted payload
|
||||
/// — the data-directory entry, the directory struct, the raw-data template, and
|
||||
/// the base relocations for the struct's four 64-bit pointer fields — and
|
||||
/// re-installs TLS itself from data kept in the stub when it loads the module.
|
||||
/// A statically-unpacked DLL is loaded by the ordinary Windows loader instead,
|
||||
/// which needs a valid TLS directory or it never allocates a TLS slot for the
|
||||
/// module nor writes `_tls_index`. The module's C++ `thread_local` accesses then
|
||||
/// read a garbage TLS slot — observed as a `0xC0000005` deep in IL2CPP type
|
||||
/// resolution (a TypeDef token used as a raw `s_TypeInfoTable` index).
|
||||
///
|
||||
/// The stub retains the full plaintext `.rdata` (only `.text`/`il2cpp` are
|
||||
/// stripped to one page), so the directory struct and its raw-data template are
|
||||
/// copied back byte-for-byte at their RVAs, the data-directory entry is taken
|
||||
/// from the stub header (the unpacked image's was overwritten with the zeroed
|
||||
/// saved-header blob), and four DIR64 relocations are appended to `.reloc`.
|
||||
///
|
||||
/// No-op if the stub declares no TLS directory or if any required region cannot
|
||||
/// be mapped/relocated — so it can never corrupt an otherwise-good unpack.
|
||||
pub(crate) fn restore_tls_from_stub(out: &mut [u8], stub: &[u8]) {
|
||||
let pe = match read_u32(out, 0x3C) {
|
||||
Some(v) => v as usize,
|
||||
None => return,
|
||||
};
|
||||
if out.get(pe..pe + 4) != Some(&b"PE\0\0"[..]) {
|
||||
return;
|
||||
}
|
||||
// This restore is PE32+-only: it copies a 40-byte IMAGE_TLS_DIRECTORY64,
|
||||
// converts fields with a 64-bit image base, and appends DIR64 relocs. A
|
||||
// PE32 module needs the 24-byte struct / DIR32 handling (the unpacker core
|
||||
// does that itself — see `restore_pe32_tls_from_stub`), so bail rather than
|
||||
// read the data directories at the wrong (PE32+) offset and write garbage.
|
||||
if read_u16(out, pe + 24) != Some(0x20B) {
|
||||
return;
|
||||
}
|
||||
// TLS is data-directory index 9 (PE32+ directories at optional header +112).
|
||||
let tls_dd = match pe.checked_add(24 + 112 + 9 * 8) {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
// The genuine entry survives in the stub header; the unpacked image's copy
|
||||
// was clobbered by the (zeroed-TLS) saved-header blob.
|
||||
let (tls_rva, tls_size) = match (read_u32(stub, tls_dd), read_u32(stub, tls_dd + 4)) {
|
||||
(Some(r), Some(s)) if r != 0 && s != 0 => (r, s),
|
||||
_ => return, // module has no TLS — nothing to restore
|
||||
};
|
||||
// Image base (PE32+, optional header +24) converts the struct's absolute VAs
|
||||
// back to RVAs for the raw-data template overlay.
|
||||
let image_base = match read_u64(out, pe + 24 + 24) {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 1) Overlay the IMAGE_TLS_DIRECTORY struct from the stub at its RVA.
|
||||
let dst = match rva_to_file_off(out, tls_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
let src = match rva_to_file_off(stub, tls_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
let n = tls_size as usize;
|
||||
if dst.checked_add(n).is_none_or(|e| e > out.len())
|
||||
|| src.checked_add(n).is_none_or(|e| e > stub.len())
|
||||
{
|
||||
return;
|
||||
}
|
||||
out[dst..dst + n].copy_from_slice(&stub[src..src + n]);
|
||||
|
||||
// 2) Restore the data-directory entry so the loader processes TLS at all.
|
||||
write_u32_at(out, tls_dd, tls_rva);
|
||||
write_u32_at(out, tls_dd + 4, tls_size);
|
||||
|
||||
// 3) Overlay the raw-data template [StartAddressOfRawData, EndAddressOfRawData).
|
||||
if let (Some(start_va), Some(end_va)) = (read_u64(out, dst), read_u64(out, dst + 8))
|
||||
&& end_va > start_va
|
||||
&& start_va >= image_base
|
||||
{
|
||||
let tpl_rva = (start_va - image_base) as u32;
|
||||
let tpl_len = (end_va - start_va) as usize;
|
||||
if let (Some(td), Some(ts)) = (
|
||||
rva_to_file_off(out, tpl_rva),
|
||||
rva_to_file_off(stub, tpl_rva),
|
||||
) && td.checked_add(tpl_len).is_some_and(|e| e <= out.len())
|
||||
&& ts.checked_add(tpl_len).is_some_and(|e| e <= stub.len())
|
||||
{
|
||||
out[td..td + tpl_len].copy_from_slice(&stub[ts..ts + tpl_len]);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Append DIR64 relocations for the struct's four 64-bit pointer fields
|
||||
// (Start/End/Index/CallBacks at +0/+8/+0x10/+0x18). Without them the
|
||||
// loader would leave preferred-base VAs in a rebased image.
|
||||
add_tls_relocs(out, pe, tls_rva);
|
||||
}
|
||||
|
||||
/// Append a single base-relocation block covering the four 64-bit pointer fields
|
||||
/// of the TLS directory struct at `tls_rva`. The block is written immediately
|
||||
/// after the existing relocation table (which must be free space and in bounds)
|
||||
/// and the BaseReloc directory size is grown to include it. No-op if the table
|
||||
/// is absent, the fields straddle a relocation page, or the slot is not free.
|
||||
fn add_tls_relocs(out: &mut [u8], pe: usize, tls_rva: u32) {
|
||||
let reloc_dd = pe + 24 + 112 + 5 * 8; // BaseReloc = directory index 5
|
||||
let (reloc_rva, reloc_size) = match (read_u32(out, reloc_dd), read_u32(out, reloc_dd + 4)) {
|
||||
(Some(r), Some(s)) if r != 0 => (r, s),
|
||||
_ => return,
|
||||
};
|
||||
// All four fields (last at +0x18) must share one 0x1000 relocation page.
|
||||
let page = tls_rva & !0xFFF;
|
||||
if (tls_rva.wrapping_add(0x18)) & !0xFFF != page {
|
||||
return;
|
||||
}
|
||||
const BLOCK: usize = 8 + 4 * 2; // header + four DIR64 entries
|
||||
let at = match rva_to_file_off(out, reloc_rva.wrapping_add(reloc_size)) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
};
|
||||
if at.checked_add(BLOCK).is_none_or(|e| e > out.len()) {
|
||||
return;
|
||||
}
|
||||
if out[at..at + BLOCK].iter().any(|&b| b != 0) {
|
||||
return; // refuse to clobber existing data
|
||||
}
|
||||
write_u32_at(out, at, page);
|
||||
write_u32_at(out, at + 4, BLOCK as u32);
|
||||
for (i, off) in [0u32, 8, 0x10, 0x18].iter().enumerate() {
|
||||
let entry = (10u16 << 12) | (((tls_rva.wrapping_add(*off)) & 0xFFF) as u16);
|
||||
let p = at + 8 + i * 2;
|
||||
out[p..p + 2].copy_from_slice(&entry.to_le_bytes());
|
||||
}
|
||||
write_u32_at(out, reloc_dd + 4, reloc_size.wrapping_add(BLOCK as u32));
|
||||
}
|
||||
|
||||
/// Read the Export data-directory (RVA, size) from a PE image, or `None` if the
|
||||
/// headers are too short/invalid to parse.
|
||||
fn pe_export_dir(buf: &[u8]) -> Option<(u32, u32)> {
|
||||
let headers = senbei_pe::parse(buf).ok()?;
|
||||
senbei_pe::data_directory(buf, headers, 0).ok()
|
||||
}
|
||||
|
||||
/// Map an RVA to a file offset using the PE section table. Returns `None` if no
|
||||
/// section contains the RVA or the headers cannot be parsed.
|
||||
fn rva_to_file_off(buf: &[u8], rva: u32) -> Option<usize> {
|
||||
let headers = senbei_pe::parse(buf).ok()?;
|
||||
senbei_pe::rva_to_offset(buf, headers, rva).ok()
|
||||
}
|
||||
|
||||
fn read_u32(buf: &[u8], off: usize) -> Option<u32> {
|
||||
let b = buf.get(off..off + 4)?;
|
||||
Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
fn read_u16(buf: &[u8], off: usize) -> Option<u16> {
|
||||
let b = buf.get(off..off + 2)?;
|
||||
Some(u16::from_le_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
fn read_u64(buf: &[u8], off: usize) -> Option<u64> {
|
||||
let b = buf.get(off..off + 8)?;
|
||||
Some(u64::from_le_bytes([
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
]))
|
||||
}
|
||||
|
||||
/// Write a little-endian `u32` at `off`, silently doing nothing if out of bounds.
|
||||
fn write_u32_at(buf: &mut [u8], off: usize, val: u32) {
|
||||
if let Some(slot) = buf.get_mut(off..off + 4) {
|
||||
slot.copy_from_slice(&val.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Splice a stub and its external-companion payload into the embedded-payload
|
||||
/// form the pipelines expect, or `None` if `comp` is not this stub's payload.
|
||||
///
|
||||
/// The companion is byte-for-byte the stub's payload region from the Crackproof
|
||||
/// header (offset 4096) onward, so the result is `stub[..4096] ++ comp`. The
|
||||
/// splice fires only when the first 32 bytes of `comp` equal the stub's header
|
||||
/// at offset 4096 — a 32-byte match on the key-table/magic region that confirms
|
||||
/// the pairing and leaves ordinary (non-companion) inputs untouched.
|
||||
pub(crate) fn splice_companion(stub: &[u8], comp: &[u8]) -> Option<Vec<u8>> {
|
||||
let hdr_end = HEADER_OFF + 32;
|
||||
if stub.len() >= hdr_end && comp.len() >= 32 && stub[HEADER_OFF..hdr_end] == comp[..32] {
|
||||
let mut spliced = Vec::with_capacity(HEADER_OFF + comp.len());
|
||||
spliced.extend_from_slice(&stub[..HEADER_OFF]);
|
||||
spliced.extend_from_slice(comp);
|
||||
return Some(spliced);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Detect `bytes` and run the right pipeline. Spliced external companions use
|
||||
/// the EXE pipeline directly because that layout is definitionally EXE-style.
|
||||
///
|
||||
/// Routing spliced inputs straight to the EXE pipeline is safe: the
|
||||
/// companion layout is definitionally the EXE-style shell (the runtime
|
||||
/// loader maps the companion and runs the standard shell unpack), so the DLL
|
||||
/// pipeline probe can never be right for it. Output bytes are identical to the
|
||||
/// DLL-first + EXE-fallback route for every input that route handles.
|
||||
pub(crate) fn unpack_spliced_or_auto(
|
||||
bytes: &[u8],
|
||||
spliced: bool,
|
||||
force_exe: bool,
|
||||
verbose: bool,
|
||||
) -> Result<(unpacker::Kind, Vec<u8>), unpacker::UnpackError> {
|
||||
if spliced || force_exe {
|
||||
let detected = unpacker::detect(bytes).ok_or(unpacker::UnpackError::NotCrackproof)?;
|
||||
let out = unpacker::unpack_exe_v(bytes, verbose)?;
|
||||
return Ok((detected.kind, out));
|
||||
}
|
||||
unpacker::unpack_auto_v(bytes, verbose)
|
||||
}
|
||||
|
||||
/// Unpack a single file to `dest`. Returns the Kind and integrity report on success.
|
||||
pub fn unpack_one(
|
||||
input: &Path,
|
||||
dest: &Path,
|
||||
) -> anyhow::Result<(unpacker::Kind, unpacker::IntegrityReport)> {
|
||||
unpack_one_v(input, dest, false)
|
||||
}
|
||||
|
||||
/// Outcome of a byte-level unpack ([`unpack_bytes`]): the image, its detected
|
||||
/// kind, and its integrity report. No file I/O is involved.
|
||||
pub struct UnpackedImage {
|
||||
pub kind: unpacker::Kind,
|
||||
pub bytes: Vec<u8>,
|
||||
pub integrity: unpacker::IntegrityReport,
|
||||
/// True when the input was reconstructed from an external companion (the
|
||||
/// `._` layout), i.e. the export/TLS overlays ran.
|
||||
pub companion: bool,
|
||||
}
|
||||
|
||||
/// Unpack in-memory `input` bytes, optionally paired with an external
|
||||
/// companion payload `companion` (the `<input>._` file's contents).
|
||||
///
|
||||
/// This is the in-memory counterpart of [`unpack_one_v`]: splice a matching
|
||||
/// companion, unpack, overlay the export table and TLS directory from the stub,
|
||||
/// then run the static integrity check.
|
||||
pub fn unpack_bytes(
|
||||
input: &[u8],
|
||||
companion: Option<&[u8]>,
|
||||
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||
unpack_bytes_impl(input, companion, false)
|
||||
}
|
||||
|
||||
/// Like [`unpack_bytes`], but forces the EXE pipeline (no DLL-pipeline
|
||||
/// probe). This is the web app's recovery path: the DLL-first probe relies
|
||||
/// on `catch_unwind` to reject EXE-shell-layout DLLs, and panics cannot be
|
||||
/// caught on wasm — the probe traps the whole call. The web app runs each
|
||||
/// unpack in a disposable Web Worker and retries trapped DLLs with this
|
||||
/// entry point, reproducing the CLI's dll-first/exe-fallback routing.
|
||||
pub fn unpack_bytes_force_exe(
|
||||
input: &[u8],
|
||||
companion: Option<&[u8]>,
|
||||
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||
unpack_bytes_impl(input, companion, true)
|
||||
}
|
||||
|
||||
fn unpack_bytes_impl(
|
||||
input: &[u8],
|
||||
companion: Option<&[u8]>,
|
||||
force_exe: bool,
|
||||
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||
let spliced = companion.and_then(|c| splice_companion(input, c));
|
||||
let bytes: &[u8] = spliced.as_deref().unwrap_or(input);
|
||||
let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), force_exe, false)?;
|
||||
if spliced.is_some() {
|
||||
overlay_exports_from_stub(&mut out, input);
|
||||
restore_tls_from_stub(&mut out, input);
|
||||
}
|
||||
let integrity = unpacker::check_integrity(&out);
|
||||
Ok(UnpackedImage {
|
||||
kind,
|
||||
bytes: out,
|
||||
integrity,
|
||||
companion: spliced.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Like [`unpack_one`], but prints detailed `[N/9]` step progress (and a final
|
||||
/// `Write to <dest>` line) to stdout when `verbose` is true.
|
||||
pub fn unpack_one_v(
|
||||
input: &Path,
|
||||
dest: &Path,
|
||||
verbose: bool,
|
||||
) -> anyhow::Result<(unpacker::Kind, unpacker::IntegrityReport)> {
|
||||
let UnpackerInput { bytes, stub } = read_unpacker_input(input)?;
|
||||
let (kind, mut out) = unpack_spliced_or_auto(&bytes, stub.is_some(), false, verbose)?;
|
||||
// External-companion layout: restore the export table from the stub, which
|
||||
// the encrypted companion does not carry (the loader rebuilds it at runtime).
|
||||
if let Some(stub) = stub {
|
||||
overlay_exports_from_stub(&mut out, &stub);
|
||||
// ...and the TLS directory, which Crackproof strips from the payload and
|
||||
// re-installs at runtime; the ordinary loader needs it or thread_local
|
||||
// access crashes (see [`restore_tls_from_stub`]).
|
||||
restore_tls_from_stub(&mut out, &stub);
|
||||
}
|
||||
let report = unpacker::check_integrity(&out);
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
write_atomic(dest, &out)?;
|
||||
if verbose {
|
||||
println!("Write to {}", dest.display());
|
||||
}
|
||||
Ok((kind, report))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HEADER_OFF: usize = 4096;
|
||||
|
||||
fn stub_with_header(header: &[u8; 32], extra: usize) -> Vec<u8> {
|
||||
let mut stub = vec![0_u8; HEADER_OFF];
|
||||
stub.extend_from_slice(header);
|
||||
stub.extend_from_slice(&vec![0xAA_u8; extra]);
|
||||
stub
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splices_when_header_matches() {
|
||||
let header = [7_u8; 32];
|
||||
let stub = stub_with_header(&header, 16);
|
||||
let mut companion = header.to_vec();
|
||||
companion.extend_from_slice(&[0x42_u8; 1000]);
|
||||
|
||||
let output = splice_companion(&stub, &companion).expect("should splice");
|
||||
assert_eq!(output.len(), HEADER_OFF + companion.len());
|
||||
assert_eq!(&output[..HEADER_OFF], &stub[..HEADER_OFF]);
|
||||
assert_eq!(&output[HEADER_OFF..], &companion[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_splice_when_header_differs() {
|
||||
let stub = stub_with_header(&[7_u8; 32], 16);
|
||||
let mut companion = vec![9_u8; 32];
|
||||
companion.extend_from_slice(&[0x42_u8; 1000]);
|
||||
assert!(splice_companion(&stub, &companion).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_splice_when_too_short() {
|
||||
let short_stub = vec![0_u8; HEADER_OFF + 8];
|
||||
let companion = vec![0_u8; 64];
|
||||
assert!(splice_companion(&short_stub, &companion).is_none());
|
||||
|
||||
let stub = stub_with_header(&[1_u8; 32], 0);
|
||||
let short_companion = vec![1_u8; 16];
|
||||
assert!(splice_companion(&stub, &short_companion).is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user