21 Commits
Author SHA1 Message Date
bfloat16 6e233bbf18 fix(windows): preserve companion export tables
Guard PE32 export copies when the companion omits plaintext stub data. Reserve the complete export directory during compaction and validate file backing before overlaying it to prevent writes into the next section.

Add regression coverage for zero-filled export space, invalid directory ranges, and truncated source or destination sections.
2026-10-08 23:49:36 +08:00
Momoko-Ayase 5bd1b0b07d chore: bump version to 1.3.2 2026-09-27 14:12:42 +08:00
Momoko-Ayase 01fdc58d2b Remove CLAUDE.md symlink
Starting Claude Code 2.1.277 if no CLAUDE.md it will check for AGENTS.md.
2026-09-27 14:05:04 +08:00
Momoko-Ayase f7d2d8b7e1 Merge pull request #8 from y52en/chatgpt/android-runtime-restoration-v2
fix(android): restore protected IL2CPP runtime state
2026-09-27 13:54:37 +08:00
y52 7728c6e7c2 refactor(android): remove sample-specific runtime fingerprint 2026-09-27 13:52:44 +08:00
y52 921cd4bfb4 fix(android): address runtime restoration review gaps 2026-09-27 13:52:44 +08:00
y52 53f31d2210 fix(android): restore newer runtime protection profiles 2026-09-27 13:52:44 +08:00
y52 9083c60d45 fix(android): restore protected runtime ELF state 2026-09-27 13:52:44 +08:00
Momoko-Ayase 25939c9738 Merge PR #7: restore IL2CPP 24.1 method indices 2026-09-20 10:04:49 +08:00
Momoko-Ayase 2501aabc57 Merge PR #6: support SysV-only ELF hash tables 2026-09-20 10:04:23 +08:00
Momoko-Ayase da06b4f5fd Merge PR #5: preserve program headers when extending crowded ELFs 2026-09-20 10:03:48 +08:00
y52 b355d8ea8f fix(android): carry v24 runtime context across package scans 2026-09-19 06:10:41 +00:00
y52 539861acbd fix(elf): relocate crowded program header tables 2026-09-18 19:36:03 +00:00
y52 a45cc8aa90 fix(metadata): restore IL2CPP 24.1 method indices 2026-09-17 21:07:18 +00:00
y52 d4613305fd fix(android): support SysV-only ELF hash tables 2026-09-17 18:12:36 +00:00
Momoko-Ayase ba380a5774 docs: point README at GitBook and restore the legal-notice heading [skip ci]
Product docs already live on crackproof-research; keep a minimal README
(usage, legal notice, license) so CI can still extract LEGAL-NOTICE.md.
2026-09-15 23:27:24 +08:00
Momoko-Ayase d6224e639c chore: bump version to 1.3.1 2026-09-10 20:58:44 +08:00
Momoko-Ayase 9b8024b132 fix(windows): re-arm neutralized TLS field relocations for PE32 DLLs
The packer demotes the four base-relocation entries covering the TLS
directory's VA fields to IMAGE_REL_BASED_ABSOLUTE padding, because its
own loader fixes TLS up by hand. Restored verbatim, a DLL mapped off its
preferred base keeps stale VAs in its TLS directory and the OS loader
faults in LdrpAllocateTlsEntry while writing the TLS slot index through
the unrelocated AddressOfIndex (observed as a 0xc0000005 startup
failure).

Walk the restored BaseReloc blocks and promote those entries back to
IMAGE_REL_BASED_HIGHLOW, gated on is_dll so EXE output stays
byte-identical.
2026-09-10 20:42:39 +08:00
Momoko-Ayase 512a627066 web: recognize Android targets and point users at the CLI
Dropping an .apk/.apks/.xapk package or a .so library reported
"not recognized — will be skipped", which reads as "not protected"
when the real gap is that the Android pipeline (filesystem
orchestration in senbei-io) has no wasm build. These files now get an
explicit android pseudo-kind at staging time: the row explains that
the CLI handles them, and they neither enable the Unpack button nor
error out mid-run.
2026-09-10 09:32:58 +08:00
Momoko-Ayase a230c37281 web: stack file-row status below the name on narrow screens
On phone-width viewports the right-aligned status column shared a flex
line with a long file name and wrapped one word per line. Below 560px
the row now wraps: name and action icons keep the first line and the
status takes a full-width line underneath.
2026-09-10 09:29:23 +08:00
Momoko-Ayase 4f59e25652 ci: declare rustfmt/clippy components in rust-toolchain.toml
The 1.98.1 pin installs without rustfmt and clippy, so the fmt and
clippy jobs fail at the shim before running. Listing the components in
the toolchain file makes rustup auto-install them on every host,
including the CI runners that invoke cargo with no setup action.
2026-09-10 09:28:50 +08:00
27 changed files with 2493 additions and 437 deletions

No files matched your search

+2 -2
View File
@@ -6,7 +6,7 @@ Guidance for contributors working in this repository.
Senbei is a static unpacker for protected PE files and Android AArch64 shared libraries. The workspace contains `senbei-cli`, `senbei-crypto`, `senbei-elf`, `senbei-engine`, `senbei-io`, `senbei-metadata`, and `senbei-pe`; `senbei-wasm` is a separate crate for the browser frontend. Senbei is a static unpacker for protected PE files and Android AArch64 shared libraries. The workspace contains `senbei-cli`, `senbei-crypto`, `senbei-elf`, `senbei-engine`, `senbei-io`, `senbei-metadata`, and `senbei-pe`; `senbei-wasm` is a separate crate for the browser frontend.
Read `docs/design.md` before changing architecture or pipeline boundaries. Read the [Senbei design notes](https://xn--ri8h.gitbook.io/crackproof-research/senbei) before changing architecture or pipeline boundaries.
## Commands ## Commands
@@ -37,4 +37,4 @@ The format crates and PE engine remain free of filesystem I/O. Native Android ex
## Documentation ## Documentation
Use one line for each normal Markdown paragraph. Keep code blocks, table rows, and list items structurally separate. Update `docs/usage.md` when CLI behavior changes. Use one line for each normal Markdown paragraph. Keep code blocks, table rows, and list items structurally separate. Product documentation lives at <https://xn--ri8h.gitbook.io/crackproof-research/senbei>; update that site (not this repository) when CLI behavior changes.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
Generated
+7 -7
View File
@@ -402,7 +402,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-cli" name = "senbei-cli"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"senbei-engine", "senbei-engine",
"senbei-io", "senbei-io",
@@ -413,7 +413,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-crypto" name = "senbei-crypto"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"aes", "aes",
"thiserror", "thiserror",
@@ -421,7 +421,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-elf" name = "senbei-elf"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"goblin", "goblin",
"thiserror", "thiserror",
@@ -429,7 +429,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-engine" name = "senbei-engine"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"memmap2", "memmap2",
"senbei-crypto", "senbei-crypto",
@@ -444,7 +444,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-io" name = "senbei-io"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indicatif", "indicatif",
@@ -465,7 +465,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-metadata" name = "senbei-metadata"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"serde", "serde",
"thiserror", "thiserror",
@@ -473,7 +473,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-pe" name = "senbei-pe"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"thiserror", "thiserror",
] ]
+1 -1
View File
@@ -15,7 +15,7 @@ exclude = ["senbei-wasm"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "1.3.0" version = "1.3.2"
edition = "2024" edition = "2024"
rust-version = "1.98.1" rust-version = "1.98.1"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+13 -37
View File
@@ -1,24 +1,10 @@
# Senbei # Senbei
A static unpacker for Crackproof-protected 64-bit and 32-bit PE files and protected Android AArch64 shared libraries. Point it at a file, an app package, or a folder and it writes decrypted copies without launching the protected program. A static unpacker for CrackProof-protected Windows PE files and Android AArch64 shared libraries.
Senbei reads protected input bytes and replays the unpacking algorithm statically. The command-line tool adds filesystem scanning, progress reporting, and logs; `senbei-wasm` provides the browser binding. > _"Crackproof"? It's senbei (煎餅 — rice cracker). Cracks itself._
## Crates ## Usage
The workspace contains eight crates: `senbei-cli`, `senbei-crypto`, `senbei-io`, `senbei-metadata`, `senbei-pe`, `senbei-elf`, `senbei-engine`, and `senbei-wasm`.
`senbei-pe` and `senbei-elf` contain validated format parsing, address mapping, and ELF dynamic-table helpers. Protection-specific code is in `senbei-engine/src/windows/` and `senbei-engine/src/android/`. Platform-specific crypto is grouped under `senbei-crypto/src/windows/` and `senbei-crypto/src/android/`; metadata code shared by both platforms stays at the `senbei-metadata` root, with seeded Android code under `src/android/`.
## Supported Inputs
- Protected Windows `.exe` and `.dll` files, including external `<name>.exe._` and `<name>.dll._` payloads.
- `global-metadata.dat` files with supported method-token layouts.
- Protected Android `.so` files and Android `.apk`, `.apks`, and `.xapk` packages.
Windows scanning probes only `.exe`, `.dll`, and `global-metadata.dat`; companion payloads are consumed through their matching stub and are not counted as skipped files. Android scanning probes only `.so` and `global-metadata.dat`. Android packages are inspected from their ZIP manifests and only matching `.so` and metadata entries are extracted.
## Quick Start
```cmd ```cmd
cargo build --release cargo build --release
@@ -27,30 +13,20 @@ senbei game.apk
senbei "C:\Games\MyGame" senbei "C:\Games\MyGame"
``` ```
Outputs are written below an `unpack` directory unless `--out` is supplied. Every restored PE or ELF image passes a structural validation step before it is reported as successful. Outputs are written below an `unpack` directory unless `--out` is supplied.
## Tests Full documentation: <https://xn--ri8h.gitbook.io/crackproof-research/senbei>
```cmd ## Legal notice and intended use
cargo test --release --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check
```
The local `test/` corpus can be passed to the CLI for real sample verification. The tracked `samples/` corpus is optional and remains user-managed. **Read this before using Senbei.**
## Web Build - Senbei is a research and interoperability tool. It exists to enable lawful reverse engineering, security research, preservation, and interoperability with software you already legitimately possess.
- **Only process binaries you own or are explicitly authorized to analyze.** Depending on your jurisdiction and license agreements, circumventing technological protection measures may be restricted (for example under DMCA §1201 in the United States, which contains exemptions for security research and interoperability). It is your responsibility to ensure your use is lawful.
```cmd - Senbei does not bypass any access control for you: it performs a purely static transformation of a file already on your disk. It derives everything it needs from the input file itself, contains no vendor code, and distributes no cracks or copyrighted content. (One Android packaging variant's embedded metadata layer is unwrapped with an XOR keystream recovered from a ciphertext/plaintext pair during analysis of a single build; that keystream is research output shipped with the unpacker, not a vendor-distributed key, and builds it doesn't match are left alone.)
cd senbei-wasm - Senbei does not enable online play, license fraud, or cheating, and must not be used to redistribute decrypted binaries. Do not upload outputs anywhere.
wasm-pack build --target web --release --out-dir ../web/pkg - The authors provide this software "as is", without warranty of any kind, and accept no liability for misuse. See [LICENSE](LICENSE) (AGPL-3.0).
``` - "Crackproof" is a trademark of its respective owner; this project is not affiliated with or endorsed by the protection vendor or any software publisher. Names are used for identification only.
The generated package is written to the ignored `web/pkg/` directory and can be served with any static HTTP server.
## Legal Notice
Use Senbei only for software you own or are authorized to analyze. The project is intended for lawful reverse engineering, security research, preservation, and interoperability.
## License ## License
-59
View File
@@ -1,59 +0,0 @@
# Design
Senbei is a fully static unpacker. It reads protected bytes, replays the protection algorithm, validates the result, and writes a recovered image without launching or attaching to the protected program.
## Crate Layout
The workspace is organized into eight crates. `senbei-cli` is the command-line entry point, `senbei-io` owns filesystem orchestration, `senbei-wasm` provides browser bindings, `senbei-pe` and `senbei-elf` provide basic format parsing, `senbei-crypto` provides shared primitives, `senbei-metadata` restores metadata, and `senbei-engine` owns protection-specific pipelines.
Single-platform source stays directly under `src/`. Multi-platform crates keep platform code below `src/windows/` and `src/android/`, with shared code directly below `src/`.
```text
senbei-cli/src/main.rs
senbei-crypto/src/
senbei-crypto/src/android/
senbei-crypto/src/windows/
senbei-elf/src/
senbei-engine/src/windows/
senbei-engine/src/android/
senbei-io/src/
senbei-io/src/android/
senbei-io/src/windows/
senbei-metadata/src/
senbei-metadata/src/windows/
senbei-metadata/src/android/
senbei-pe/src/
senbei-wasm/src/
```
`senbei-pe` and `senbei-elf` own validated format models, address mapping, and ELF dynamic hash helpers. They do not depend on the unpacking engines, filesystem code, or platform protection logic.
## Windows Engine
`senbei-engine/src/windows/` contains PE detection, layout discovery, EXE and DLL restoration, deterministic block parallelism, and structural integrity checks. Candidate layouts are trial-decrypted and validated before an output is accepted.
External companion inputs are reconstructed as `stub[..4096]` followed by the matching `._` payload. The stub's export, TLS, and declared CLR regions are overlaid after unpacking because those regions are not present in the encrypted companion. Managed restoration follows the COR20 directory and referenced metadata, resources, and vtable fixups through each file's RVA mapping, preserving the decrypted method bodies.
## Android Engine
`senbei-engine/src/android/extract/` decrypts the stage-1 header and stage-2 record streams and writes a temporary module workspace. `senbei-engine/src/android/restore/` applies decoded image and fixup containers to the hollowed ELF and rebuilds dynamic-linker tables. Both phases validate bounds and table placement before writing output.
Windows protection primitives are in `senbei-crypto/src/windows/`, while Android protection primitives are in `senbei-crypto/src/android/`. Android seeded metadata restoration is in `senbei-metadata/src/android/`; the structural MethodDef transform is shared at the metadata crate root because both platform paths use it.
Android ELF dynamic tables are located from the input section table and its actual file ranges. When the original gap is too small, restoration adds a validated read-only `PT_LOAD` after the existing load image and updates the dynamic tags; it never overwrites an adjacent section or emits a partial image.
## Scanning and Packages
Folder scanning uses platform target names to avoid opening bulk assets: Windows candidates are `.exe`, `.dll`, and `global-metadata.dat`; Android candidates are `.so` and `global-metadata.dat`. The shared walker is in `senbei-io/src/scan.rs`; platform name filters and PE companion byte adaptation are in `senbei-io/src/windows/`, and Android package adaptation is in `senbei-io/src/android/`. A Windows `.exe._` or `.dll._` companion is auxiliary input for its sibling stub and is excluded from the skipped count.
APK, APKS, and XAPK files are containers. Senbei reads their ZIP manifests first, follows nested APK entries when necessary, and extracts only `.so` and exact `global-metadata.dat` entries. Extraction streams directly to temporary files, so compressed and decompressed copies are not held in memory together.
## Validation
Every heuristic layout uses trial-and-validate. A candidate that fails structural checks, checksums, or table bounds is rejected and the next candidate is tried. A failed restore is reported as an error rather than emitting a silently damaged binary.
The PE integrity check verifies headers, section ranges, entry-point mapping, import names, relocation requirements, and managed metadata signatures. Android restoration validates ELF ranges, decoded container sizes, fixup bounds, and rebuilt dynamic tables.
## WebAssembly
The browser binding depends on `senbei-engine` through the I/O byte API. Native filesystem and Android package orchestration remain outside the browser workflow. Each browser unpack runs in a disposable worker because WebAssembly cannot recover from a caught panic in the same way as native code.
-61
View File
@@ -1,61 +0,0 @@
# Development
## Building
Rust 1.98.1 is required and pinned in `rust-toolchain.toml`. Build the CLI with `cargo build --release`; the binary is written to `target/release/senbei.exe` on Windows.
The workspace crates are portable where their APIs are pure. The browser binding is outside the workspace and is checked with `cargo check --manifest-path senbei-wasm/Cargo.toml` or built with `wasm-pack`.
## Testing
```cmd
cargo test --release --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check
```
The tracked test suite is safe without protected samples. The optional local `samples/` corpus is user-managed and the ignored `test/` folder can be used for real Windows and Android runs.
For an Android package, use one command at a time because a protected `.so` can be hundreds of megabytes. APK, APKS, and XAPK tests read the ZIP manifest first and extract only `.so` and `global-metadata.dat` entries.
## Environment Variables
- `DD8_SHIFT` overrides the PE page-XOR shift; `99` skips that stage.
- `SEL_DIAG` prints PE layout-selector diagnostics.
- `SENBEI_THREADS` caps deterministic block fan-out; `1` forces the sequential reference path.
- `SENBEI_SCAN_ALL` enables probing selected target names below the size floor; it never enables arbitrary filenames.
- `SENBEI_ANDROID_SAMPLES` overrides the Android sample corpus location.
## Conventions
Format crates stay free of filesystem I/O and protection-specific logic. Windows engine code lives below `senbei-engine/src/windows/`, Android engine code below `senbei-engine/src/android/`, and shared code stays directly under each crate's `src/`.
Layout heuristics must trial and validate every candidate. A failed validation is an error or a fall-through, never a silently accepted offset.
Outputs must remain byte-identical against the available golden corpus. Run the full workspace tests after changing a pipeline or a metadata layout.
Folder scanning uses explicit target names to avoid opening bulk assets. External `.exe._` and `.dll._` files are auxiliary data for their sibling stubs and are not independent scan targets.
## Repository Layout
```text
senbei-cli/ command-line binary and integration tests
senbei-crypto/ Windows and Android crypto primitives
senbei-elf/ ELF parsing, mapping, and dynamic-table helpers
senbei-engine/ Windows and Android unpacking engines
senbei-io/ filesystem, package, scanning, and platform adapters
senbei-metadata/ shared, Windows, and Android metadata restoration
senbei-pe/ PE parsing, data directories, and RVA mapping
senbei-wasm/ browser bindings and its own lockfile
web/ static browser frontend
samples/ optional local corpus
```
## Web Build
```cmd
cd senbei-wasm
wasm-pack build --target web --release --out-dir ../web/pkg
```
Serve `web/` with a static HTTP server after the build. The browser never uploads input files.
-57
View File
@@ -1,57 +0,0 @@
# Usage
```text
senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]
```
## Single File
The output is written below `<parent>/unpack/` with `.unpack` inserted before the extension. `--out DIR` changes both the output and log directory.
```cmd
senbei app.exe
senbei app.exe --out C:\out
```
For `global-metadata.dat`, Senbei writes `global-metadata.unpack.dat` only when method tokens change. Unsupported metadata versions remain untouched and are reported as skipped.
## Android Targets
Protected `.so` files are restored from their encrypted payload sections and written as `libil2cpp.unpack.so` or the corresponding input name. APK, APKS, and XAPK files are treated as containers: their manifests are read first, nested APKs are followed when necessary, and only `.so` and exact `global-metadata.dat` entries are extracted.
If a restored library contains embedded metadata, the unwrapped blob is written beside it as `global-metadata.unpack.dat`. Identical loose and package entries are restored once, preferring the loose file.
## Folder Mode
Folder mode walks recursively, skips directories named `unpack`, and mirrors recognized outputs below `<root>/unpack/` or `--out DIR`. Windows candidates are `.exe`, `.dll`, and `global-metadata.dat`; Android candidates are `.so` and `global-metadata.dat`. A matching `.exe._` or `.dll._` payload is consumed by its stub and is excluded from the skipped count.
Managed DLL companions retain CLR metadata and related runtime tables in the original DLL. Both the DLL and its matching `._` file must be available; Senbei restores the declared CLR regions from the DLL while retaining method bodies decrypted from the companion. Invalid or missing referenced regions are reported as errors.
The summary has the form `12 unpacked · 3 skipped · 0 errors · 1 suspect · 2 metadata`; the package count is appended when packages were opened. Each file is isolated so one failed target does not stop the folder run.
## Integrity Check
PE outputs are checked for valid headers, section ranges, entry-point mapping, readable import names, relocation requirements, and managed metadata signatures. Android outputs are validated during ELF restoration, including decoded container sizes, fixup bounds, and rebuilt dynamic tables.
A clean report is not a proof of correctness, but a non-clean report is a reliable broken-output signal. Suspect PE files are still written and counted separately.
## Flags
| Flag | Behavior |
| --- | --- |
| `--out DIR` | Write outputs and logs below `DIR`. |
| `-v`, `--verbose` | Print per-stage progress. |
| `-q`, `--quiet` | Hide progress and per-file lines; repeat to suppress all standard output. |
| `--no-log` | Do not write a run log. |
| `--scan-all` | Probe every selected target-name candidate, including files below the size floor. |
| `--no-pause` | Disable the Explorer-friendly Windows exit prompt. |
| `-V`, `--version` | Print the version and exit. |
| `-h`, `--help` | Show usage. |
## Exit Codes
| Code | Meaning |
| --- | --- |
| `0` | The requested restore completed without errors. |
| `1` | A target failed, a scan probe was unreadable, or a single-file restore errored. |
| `2` | The command line was invalid. |
+4
View File
@@ -1,3 +1,7 @@
[toolchain] [toolchain]
channel = "1.98.1" channel = "1.98.1"
# CI invokes rustfmt/clippy through the rustup shim with no setup action, so
# the pinned toolchain must declare its components here — the runner images
# only preinstall them for their default toolchain.
components = ["rustfmt", "clippy"]
targets = ["x86_64-pc-windows-msvc", "wasm32-unknown-unknown"] targets = ["x86_64-pc-windows-msvc", "wasm32-unknown-unknown"]
+362 -33
View File
@@ -5,6 +5,8 @@ pub const SHT_STRTAB: u32 = 3;
pub const SHT_LOUSER: u32 = 0x8000_0000; pub const SHT_LOUSER: u32 = 0x8000_0000;
pub const SHF_ALLOC: u64 = 2; pub const SHF_ALLOC: u64 = 2;
const PT_LOAD: u32 = 1; const PT_LOAD: u32 = 1;
const PT_NOTE: u32 = 4;
const PT_PHDR: u32 = 6;
pub const PF_R: u32 = 4; pub const PF_R: u32 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -220,7 +222,89 @@ impl ElfLayout {
Ok(alignment) Ok(alignment)
} }
pub fn append_load_segment(&self, output: &mut [u8], segment: LoadSegment) -> Result<Self> { pub fn additional_program_header_reservation(&self) -> Result<usize> {
if self.program_header_size != 0x38 {
return invalid("unexpected ELF program header size");
}
let new_count = self
.program_header_count
.checked_add(1)
.ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?;
let header_offset = checked_index(
self.program_header_offset,
self.program_header_count,
self.program_header_size,
)?;
let header_end = header_offset
.checked_add(self.program_header_size)
.ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?;
let first_file_section = self
.section_headers
.iter()
.filter(|section| section.section_type != SHT_NOBITS && section.size != 0)
.map(|section| section.offset)
.min();
if first_file_section.is_none_or(|offset| header_end as u64 <= offset) {
return Ok(0);
}
new_count
.checked_mul(self.program_header_size)
.ok_or_else(|| Error::Invalid("relocated program header size overflow".to_owned()))
}
fn has_program_header_type(&self, data: &[u8], program_type: u32) -> Result<bool> {
for index in 0..self.program_header_count {
let offset =
checked_index(self.program_header_offset, index, self.program_header_size)?;
if read_u32(data, offset)? == program_type {
return Ok(true);
}
}
Ok(false)
}
fn reusable_note_program_header(&self, data: &[u8]) -> Result<Option<usize>> {
let mut note_count = 0_usize;
let mut candidates = Vec::new();
for index in 0..self.program_header_count {
let offset =
checked_index(self.program_header_offset, index, self.program_header_size)?;
if read_u32(data, offset)? != PT_NOTE {
continue;
}
note_count += 1;
let file_offset = read_u64(data, offset + 8)?;
let file_size = read_u64(data, offset + 0x20)?;
let file_end = file_offset
.checked_add(file_size)
.ok_or_else(|| Error::Invalid("PT_NOTE file range overflow".to_owned()))?;
let contained = self.program_headers.iter().any(|segment| {
segment
.offset
.checked_add(segment.file_size)
.is_some_and(|segment_end| {
segment.offset <= file_offset && file_end <= segment_end
})
});
if contained {
candidates.push((file_offset, index));
}
}
if note_count < 2 {
return Ok(None);
}
Ok(candidates
.into_iter()
.max_by_key(|(file_offset, _)| *file_offset)
.map(|(_, index)| index))
}
pub fn append_load_segment(
&self,
output: &mut [u8],
segment: LoadSegment,
reserved_program_header_bytes: usize,
) -> Result<Self> {
if self.program_header_size != 0x38 { if self.program_header_size != 0x38 {
return invalid("unexpected ELF program header size"); return invalid("unexpected ELF program header size");
} }
@@ -274,23 +358,11 @@ impl ElfLayout {
.ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?; .ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?;
let new_count_u16 = u16::try_from(new_count) let new_count_u16 = u16::try_from(new_count)
.map_err(|_| Error::Invalid("program header count exceeds u16".to_owned()))?; .map_err(|_| Error::Invalid("program header count exceeds u16".to_owned()))?;
let header_offset = checked_index( let reservation = self.additional_program_header_reservation()?;
self.program_header_offset, if reserved_program_header_bytes != reservation {
self.program_header_count, return invalid(format!(
self.program_header_size, "program-header reservation mismatch: expected 0x{reservation:x}, got 0x{reserved_program_header_bytes:x}"
)?; ));
let header_end = header_offset
.checked_add(self.program_header_size)
.ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?;
slice(output, header_offset, self.program_header_size)?;
let first_file_section = self
.section_headers
.iter()
.filter(|section| section.section_type != SHT_NOBITS && section.size != 0)
.map(|section| section.offset)
.min();
if first_file_section.is_some_and(|offset| header_end as u64 > offset) {
return invalid("no space for an additional program header");
} }
let mut header = [0_u8; 0x38]; let mut header = [0_u8; 0x38];
@@ -302,17 +374,95 @@ impl ElfLayout {
header[0x20..0x28].copy_from_slice(&segment.file_size.to_le_bytes()); header[0x20..0x28].copy_from_slice(&segment.file_size.to_le_bytes());
header[0x28..0x30].copy_from_slice(&segment.memory_size.to_le_bytes()); header[0x28..0x30].copy_from_slice(&segment.memory_size.to_le_bytes());
header[0x30..0x38].copy_from_slice(&segment.alignment.to_le_bytes()); header[0x30..0x38].copy_from_slice(&segment.alignment.to_le_bytes());
output
.get_mut(header_offset..header_end) let old_table_size = self
.ok_or_else(|| Error::Invalid("new program header exceeds output".to_owned()))? .program_header_count
.copy_from_slice(&header); .checked_mul(self.program_header_size)
output .ok_or_else(|| Error::Invalid("program header table size overflow".to_owned()))?;
.get_mut(0x38..0x3a) let new_table_size = new_count
.ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))? .checked_mul(self.program_header_size)
.copy_from_slice(&new_count_u16.to_le_bytes()); .ok_or_else(|| Error::Invalid("program header table size overflow".to_owned()))?;
let mut updated_program_header_offset = self.program_header_offset;
let mut updated_program_header_count = new_count;
if reservation == 0 {
let header_offset = checked_index(
self.program_header_offset,
self.program_header_count,
self.program_header_size,
)?;
let header_end = header_offset
.checked_add(self.program_header_size)
.ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?;
output
.get_mut(header_offset..header_end)
.ok_or_else(|| Error::Invalid("new program header exceeds output".to_owned()))?
.copy_from_slice(&header);
update_pt_phdr(
output,
self.program_header_offset,
self.program_header_count,
self.program_header_size,
None,
new_table_size as u64,
)?;
} else if let Some(note_index) = self.reusable_note_program_header(output)? {
let header_offset = checked_index(
self.program_header_offset,
note_index,
self.program_header_size,
)?;
let header_end = header_offset
.checked_add(self.program_header_size)
.ok_or_else(|| Error::Invalid("reused program header range overflow".to_owned()))?;
output
.get_mut(header_offset..header_end)
.ok_or_else(|| Error::Invalid("reused program header exceeds output".to_owned()))?
.copy_from_slice(&header);
updated_program_header_count = self.program_header_count;
} else {
if !self.has_program_header_type(output, PT_PHDR)? {
return invalid(
"cannot safely relocate program headers without PT_PHDR or a redundant PT_NOTE",
);
}
if reservation != new_table_size {
return invalid("relocated program-header reservation has unexpected size");
}
if segment.file_size < reservation as u64 {
return invalid("new PT_LOAD is too small for relocated program headers");
}
let relocated = usize_from_u64(segment.offset, "relocated program header offset")?;
let relocated_end = relocated.checked_add(new_table_size).ok_or_else(|| {
Error::Invalid("relocated program header range overflow".to_owned())
})?;
slice(output, relocated, new_table_size)?;
let old_table = slice(output, self.program_header_offset, old_table_size)?.to_vec();
output[relocated..relocated + old_table_size].copy_from_slice(&old_table);
output[relocated + old_table_size..relocated_end].copy_from_slice(&header);
output
.get_mut(0x20..0x28)
.ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))?
.copy_from_slice(&segment.offset.to_le_bytes());
update_pt_phdr(
output,
relocated,
self.program_header_count,
self.program_header_size,
Some((segment.offset, segment.virtual_address)),
new_table_size as u64,
)?;
updated_program_header_offset = relocated;
}
if updated_program_header_count == new_count {
output
.get_mut(0x38..0x3a)
.ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))?
.copy_from_slice(&new_count_u16.to_le_bytes());
}
let mut updated = self.clone(); let mut updated = self.clone();
updated.program_header_count = new_count; updated.program_header_offset = updated_program_header_offset;
updated.program_header_count = updated_program_header_count;
updated.program_headers.push(segment); updated.program_headers.push(segment);
Ok(updated) Ok(updated)
} }
@@ -394,6 +544,47 @@ impl ElfLayout {
} }
} }
fn update_pt_phdr(
output: &mut [u8],
table_offset: usize,
old_count: usize,
entry_size: usize,
relocated: Option<(u64, u64)>,
table_size: u64,
) -> Result<()> {
for index in 0..old_count {
let offset = checked_index(table_offset, index, entry_size)?;
if read_u32(output, offset)? != PT_PHDR {
continue;
}
if let Some((file_offset, virtual_address)) = relocated {
output
.get_mut(offset + 8..offset + 0x10)
.ok_or_else(|| Error::Invalid("PT_PHDR file offset exceeds output".to_owned()))?
.copy_from_slice(&file_offset.to_le_bytes());
output
.get_mut(offset + 0x10..offset + 0x18)
.ok_or_else(|| Error::Invalid("PT_PHDR virtual address exceeds output".to_owned()))?
.copy_from_slice(&virtual_address.to_le_bytes());
output
.get_mut(offset + 0x18..offset + 0x20)
.ok_or_else(|| {
Error::Invalid("PT_PHDR physical address exceeds output".to_owned())
})?
.copy_from_slice(&virtual_address.to_le_bytes());
}
output
.get_mut(offset + 0x20..offset + 0x28)
.ok_or_else(|| Error::Invalid("PT_PHDR file size exceeds output".to_owned()))?
.copy_from_slice(&table_size.to_le_bytes());
output
.get_mut(offset + 0x28..offset + 0x30)
.ok_or_else(|| Error::Invalid("PT_PHDR memory size exceeds output".to_owned()))?
.copy_from_slice(&table_size.to_le_bytes());
}
Ok(())
}
pub fn slice(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> { pub fn slice(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> {
let end = offset let end = offset
.checked_add(size) .checked_add(size)
@@ -585,6 +776,7 @@ mod tests {
flags: PF_R, flags: PF_R,
alignment: 0x1000, alignment: 0x1000,
}, },
0,
) )
.expect("append segment"); .expect("append segment");
assert_eq!(updated.program_header_count, 1); assert_eq!(updated.program_header_count, 1);
@@ -594,7 +786,7 @@ mod tests {
} }
#[test] #[test]
fn append_load_segment_rejects_program_header_overlap() { fn append_load_segment_fails_closed_when_no_safe_phdr_slot() {
let mut elf_layout = ElfLayout { let mut elf_layout = ElfLayout {
entrypoint: 0, entrypoint: 0,
program_header_offset: 0, program_header_offset: 0,
@@ -617,20 +809,157 @@ mod tests {
alignment: 1, alignment: 1,
entry_size: 0, entry_size: 0,
}); });
let mut output = vec![0_u8; 0x100]; let reservation = elf_layout
.additional_program_header_reservation()
.expect("reservation");
assert_eq!(reservation, 0x38);
let mut output = vec![0_u8; 0x200];
let error = elf_layout let error = elf_layout
.append_load_segment( .append_load_segment(
&mut output, &mut output,
LoadSegment { LoadSegment {
offset: 0x80, offset: 0x80,
virtual_address: 0x1080, virtual_address: 0x1080,
file_size: 0x20, file_size: 0x80,
memory_size: 0x20, memory_size: 0x80,
flags: PF_R, flags: PF_R,
alignment: 0x1000, alignment: 0x1000,
}, },
reservation,
) )
.expect_err("overlapping program header"); .expect_err("unsafe relocation must fail");
assert!(error.to_string().contains("additional program header")); assert!(error.to_string().contains("cannot safely relocate"));
}
#[test]
fn append_load_segment_reuses_redundant_note_when_no_slack() {
let mut elf_layout = ElfLayout {
entrypoint: 0,
program_header_offset: 0x40,
program_header_size: 0x38,
program_header_count: 3,
program_headers: vec![LoadSegment {
offset: 0,
virtual_address: 0,
file_size: 0x400,
memory_size: 0x400,
flags: PF_R,
alignment: 0x1000,
}],
section_headers: Vec::new(),
section_name_index: 0,
private_section_index: usize::MAX,
};
elf_layout.section_headers.push(SectionHeader {
name: 0,
section_type: 1,
flags: 0,
address: 0,
offset: 0xe8,
size: 1,
link: 0,
info: 0,
alignment: 1,
entry_size: 0,
});
let mut output = vec![0_u8; 0x2000];
output[0x38..0x3a].copy_from_slice(&3_u16.to_le_bytes());
output[0x40..0x44].copy_from_slice(&PT_LOAD.to_le_bytes());
output[0x78..0x7c].copy_from_slice(&PT_NOTE.to_le_bytes());
output[0x80..0x88].copy_from_slice(&0x100_u64.to_le_bytes());
output[0x98..0xa0].copy_from_slice(&0x20_u64.to_le_bytes());
output[0xb0..0xb4].copy_from_slice(&PT_NOTE.to_le_bytes());
output[0xb8..0xc0].copy_from_slice(&0x200_u64.to_le_bytes());
output[0xd0..0xd8].copy_from_slice(&0x20_u64.to_le_bytes());
let reservation = elf_layout
.additional_program_header_reservation()
.expect("reservation");
assert_eq!(reservation, 0xe0);
let updated = elf_layout
.append_load_segment(
&mut output,
LoadSegment {
offset: 0x1000,
virtual_address: 0x2000,
file_size: 0x100,
memory_size: 0x100,
flags: PF_R,
alignment: 0x1000,
},
reservation,
)
.expect("reuse redundant PT_NOTE");
assert_eq!(updated.program_header_offset, 0x40);
assert_eq!(updated.program_header_count, 3);
assert_eq!(updated.program_headers.len(), 2);
assert_eq!(&output[0x38..0x3a], &3_u16.to_le_bytes());
assert_eq!(&output[0x78..0x7c], &PT_NOTE.to_le_bytes());
assert_eq!(&output[0xb0..0xb4], &PT_LOAD.to_le_bytes());
assert_eq!(&output[0xb8..0xc0], &0x1000_u64.to_le_bytes());
assert_eq!(&output[0xc0..0xc8], &0x2000_u64.to_le_bytes());
}
#[test]
fn relocated_program_headers_update_pt_phdr() {
let mut elf_layout = ElfLayout {
entrypoint: 0,
program_header_offset: 0x40,
program_header_size: 0x38,
program_header_count: 1,
program_headers: Vec::new(),
section_headers: Vec::new(),
section_name_index: 0,
private_section_index: usize::MAX,
};
elf_layout.section_headers.push(SectionHeader {
name: 0,
section_type: 1,
flags: 0,
address: 0,
offset: 0x78,
size: 1,
link: 0,
info: 0,
alignment: 1,
entry_size: 0,
});
let mut output = vec![0_u8; 0x2000];
output[0x20..0x28].copy_from_slice(&0x40_u64.to_le_bytes());
output[0x40..0x44].copy_from_slice(&PT_PHDR.to_le_bytes());
output[0x48..0x50].copy_from_slice(&0x40_u64.to_le_bytes());
output[0x50..0x58].copy_from_slice(&0x40_u64.to_le_bytes());
output[0x58..0x60].copy_from_slice(&0x40_u64.to_le_bytes());
output[0x60..0x68].copy_from_slice(&0x38_u64.to_le_bytes());
output[0x68..0x70].copy_from_slice(&0x38_u64.to_le_bytes());
let reservation = elf_layout
.additional_program_header_reservation()
.expect("reservation");
assert_eq!(reservation, 0x70);
let updated = elf_layout
.append_load_segment(
&mut output,
LoadSegment {
offset: 0x1000,
virtual_address: 0x2000,
file_size: 0x100,
memory_size: 0x100,
flags: PF_R,
alignment: 0x1000,
},
reservation,
)
.expect("relocate program headers");
assert_eq!(updated.program_header_offset, 0x1000);
assert_eq!(&output[0x20..0x28], &0x1000_u64.to_le_bytes());
assert_eq!(&output[0x1000..0x1004], &PT_PHDR.to_le_bytes());
assert_eq!(&output[0x1008..0x1010], &0x1000_u64.to_le_bytes());
assert_eq!(&output[0x1010..0x1018], &0x2000_u64.to_le_bytes());
assert_eq!(&output[0x1020..0x1028], &0x70_u64.to_le_bytes());
assert_eq!(&output[0x1028..0x1030], &0x70_u64.to_le_bytes());
assert_eq!(&output[0x1038..0x103c], &PT_LOAD.to_le_bytes());
} }
} }
+7 -10
View File
@@ -15,26 +15,23 @@ pub use layout::{
/// ELF machine identifier for AArch64. /// ELF machine identifier for AArch64.
pub const AARCH64_MACHINE: u16 = EM_AARCH64; pub const AARCH64_MACHINE: u16 = EM_AARCH64;
/// Dynamic sections required by the restored AArch64 loader image. /// Dynamic sections required by every restored AArch64 loader image.
pub const DYNAMIC_SECTION_NAMES: [&str; 8] = [ pub const DYNAMIC_SECTION_NAMES: [&str; 7] = [
".dynsym", ".dynsym",
".gnu.version", ".gnu.version",
".gnu.version_r", ".gnu.version_r",
".gnu.hash",
".dynstr", ".dynstr",
".rela.dyn", ".rela.dyn",
".rela.plt", ".rela.plt",
".dynamic", ".dynamic",
]; ];
/// Supported dynamic symbol-hash sections. At least one must be present.
pub const SYMBOL_HASH_SECTION_NAMES: [&str; 2] = [".gnu.hash", ".hash"];
/// Dynamic sections needed to identify a protected image before extraction. /// Dynamic sections needed to identify a protected image before extraction.
pub const PROBE_SECTION_NAMES: [&str; 5] = [ /// Hash tables are checked separately because either GNU or SysV hashing is valid.
".dynsym", pub const PROBE_SECTION_NAMES: [&str; 4] = [".dynsym", ".dynstr", ".gnu.version", ".gnu.version_r"];
".dynstr",
".gnu.hash",
".gnu.version",
".gnu.version_r",
];
/// ELF64 dynamic table record sizes. /// ELF64 dynamic table record sizes.
pub const ELF64_SYMBOL_SIZE: usize = 0x18; pub const ELF64_SYMBOL_SIZE: usize = 0x18;
@@ -70,6 +70,16 @@ pub(crate) fn inspect(
return invalid(format!("protected ELF lacks required section {wanted}")); return invalid(format!("protected ELF lacks required section {wanted}"));
} }
} }
let has_symbol_hash = senbei_elf::SYMBOL_HASH_SECTION_NAMES.iter().any(|wanted| {
elf.section_headers.iter().any(|section| {
elf.shdr_strtab
.get_at(section.sh_name)
.is_some_and(|name| name == *wanted)
})
});
if !has_symbol_hash {
return invalid("protected ELF lacks a .gnu.hash or .hash symbol-hash section");
}
let (section_index, section) = matches[0]; let (section_index, section) = matches[0];
let section_offset = usize::try_from(section.sh_offset) let section_offset = usize::try_from(section.sh_offset)
.map_err(|_| Error::Invalid("SHT_LOUSER offset exceeds usize".to_owned()))?; .map_err(|_| Error::Invalid("SHT_LOUSER offset exceeds usize".to_owned()))?;
@@ -6,6 +6,11 @@ use serde_json::Value;
use super::error::{Error, Result, invalid}; use super::error::{Error, Result, invalid};
const REQUIRED_IDS: [u32; 3] = [0x9b, 0x9d, 0x9e]; const REQUIRED_IDS: [u32; 3] = [0x9b, 0x9d, 0x9e];
const OPTIONAL_IDS: [u32; 2] = [0x96, 0x98];
fn wanted_id(command_id: u32) -> bool {
REQUIRED_IDS.contains(&command_id) || OPTIONAL_IDS.contains(&command_id)
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Artifact { pub(crate) struct Artifact {
@@ -27,7 +32,7 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
}; };
let command_id = u32::try_from(command_id) let command_id = u32::try_from(command_id)
.map_err(|_| Error::Invalid("module command ID exceeds u32".to_owned()))?; .map_err(|_| Error::Invalid("module command ID exceeds u32".to_owned()))?;
if !REQUIRED_IDS.contains(&command_id) { if !wanted_id(command_id) {
continue; continue;
} }
let Some(path) = item.get("image_path").and_then(Value::as_str) else { let Some(path) = item.get("image_path").and_then(Value::as_str) else {
@@ -57,7 +62,7 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
}; };
let command_id = u32::try_from(command_id) let command_id = u32::try_from(command_id)
.map_err(|_| Error::Invalid("record command ID exceeds u32".to_owned()))?; .map_err(|_| Error::Invalid("record command ID exceeds u32".to_owned()))?;
if !REQUIRED_IDS.contains(&command_id) { if !wanted_id(command_id) {
continue; continue;
} }
let Some(image) = record.get("image") else { let Some(image) = record.get("image") else {
File diff suppressed because it is too large. Load diff
+61 -1
View File
@@ -823,7 +823,15 @@ impl<'a> Unpacker<'a> {
let d = export_va as usize; let d = export_va as usize;
let s = export_file_off as usize; let s = export_file_off as usize;
let n = export_size as usize; let n = export_size as usize;
self.decompressed[d..d + n].copy_from_slice(&self.file_data[s..s + n]); // Spliced companions may omit the stub's plaintext exports;
// the I/O adapter overlays those from the original stub later.
if s.checked_add(n)
.is_some_and(|end| end <= self.file_data.len())
&& d.checked_add(n)
.is_some_and(|end| end <= self.decompressed.len())
{
self.decompressed[d..d + n].copy_from_slice(&self.file_data[s..s + n]);
}
} }
// ---- .text decrypt with decrypt_data8 (PE32 auto-detected formula) ---- // ---- .text decrypt with decrypt_data8 (PE32 auto-detected formula) ----
@@ -976,6 +984,58 @@ impl<'a> Unpacker<'a> {
} }
} }
// ---- Re-arm TLS field base relocations (PE32 DLL) ----
// The packer neutralizes the four relocations covering the TLS
// directory's VA fields (Start/EndAddressOfRawData, AddressOfIndex,
// AddressOfCallBacks) by demoting them to IMAGE_REL_BASED_ABSOLUTE
// padding, because its own loader fixes TLS up by hand. Restored
// verbatim, a DLL mapped off its preferred base keeps stale VAs in its
// TLS directory, and the OS loader faults in LdrpAllocateTlsEntry when
// it writes the TLS slot index through the unrelocated AddressOfIndex.
// Promote those entries back to IMAGE_REL_BASED_HIGHLOW. EXE output
// is untouched: its BaseReloc directory is zeroed above, and its
// goldens must stay byte-identical.
if is_dll && tls_dir_rva > 0 {
let reloc_rva = get_u32(&self.decompressed, exe_pe.wrapping_add(0xA0));
let reloc_size = get_u32(&self.decompressed, exe_pe.wrapping_add(0xA4));
let reloc_end = reloc_rva.wrapping_add(reloc_size);
let dlen = self.decompressed.len() as u32;
if reloc_rva > 0 && reloc_size >= 8 && reloc_end <= dlen {
let mut rearmed = 0u32;
let mut block = reloc_rva;
while block.wrapping_add(8) <= reloc_end {
let page = get_u32(&self.decompressed, block);
let block_size = get_u32(&self.decompressed, block.wrapping_add(4));
if block_size < 8
|| !block_size.is_multiple_of(2)
|| block.wrapping_add(block_size) > reloc_end
{
break;
}
let entries = (block_size - 8) / 2;
for i in 0..entries {
let entry_off = block.wrapping_add(8).wrapping_add(i.wrapping_mul(2));
let entry = get_u16(&self.decompressed, entry_off);
let entry_rva = page.wrapping_add((entry & 0x0FFF) as u32);
// IMAGE_REL_BASED_ABSOLUTE with a real offset is
// neutralized, not padding (padding keeps offset 0).
if entry >> 12 == 0
&& entry & 0x0FFF != 0
&& entry_rva >= tls_dir_rva
&& entry_rva < tls_dir_rva.wrapping_add(16)
{
write_u16(&mut self.decompressed, entry_off, (entry | 0x3000) as u32);
rearmed = rearmed.wrapping_add(1);
}
}
block = block.wrapping_add(block_size);
}
if verbose && rearmed > 0 {
println!(" re-armed {} TLS field relocations", rearmed);
}
}
}
// ---- Import table (PE32, 4-byte thunks) ---- // ---- Import table (PE32, 4-byte thunks) ----
if verbose { if verbose {
println!("[8/9] Decrypting import strings (PE32)..."); println!("[8/9] Decrypting import strings (PE32)...");
+57 -1
View File
@@ -331,6 +331,13 @@ pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>
let opt_hdr = pe_header.wrapping_add(24); let opt_hdr = pe_header.wrapping_add(24);
let sec_table = opt_hdr.wrapping_add(opt_hdr_size); let sec_table = opt_hdr.wrapping_add(opt_hdr_size);
let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32; let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32;
let export_dir = opt_hdr.wrapping_add(if get_u16(data, opt_hdr) == 0x20B {
112
} else {
96
});
let export_rva = get_u32(data, export_dir);
let export_size = get_u32(data, export_dir.wrapping_add(4));
struct SecLayout { struct SecLayout {
sec_off: u32, sec_off: u32,
@@ -365,11 +372,21 @@ pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>
break; break;
} }
} }
let meaningful = if last_nonzero >= 0 { let mut meaningful = if last_nonzero >= 0 {
(last_nonzero + 1) as u32 (last_nonzero + 1) as u32
} else { } else {
0 0
}; };
// Companion exports are overlaid after compaction. Keep their whole
// range file-backed even when the payload leaves it zero-filled.
if export_size != 0
&& let Some(end) = export_rva
.checked_sub(va)
.and_then(|delta| delta.checked_add(export_size))
&& end as usize <= section_data.len()
{
meaningful = meaningful.max(end);
}
let mut raw_size = if meaningful != 0 { let mut raw_size = if meaningful != 0 {
align_up_u32(meaningful, FILE_ALIGNMENT) align_up_u32(meaningful, FILE_ALIGNMENT)
} else { } else {
@@ -433,6 +450,45 @@ pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn compaction_keeps_export_space_without_changing_the_next_section() {
for (rva, size, expected_raw_size) in [
(0, 0, 0x200),
(0x1280, 0x181, 0x600),
(0x1f00, 0x200, 0x200),
(0xffff_fff0, 0x40, 0x200),
] {
let mut data = vec![0; 0x3000];
data[..2].copy_from_slice(b"MZ");
data[0x80..0x84].copy_from_slice(b"PE\0\0");
write_u16(&mut data, 0x86, 2);
write_u16(&mut data, 0x94, 0xe0);
write_u16(&mut data, 0x98, 0x10b);
for (offset, value) in [
(0x3c, 0x80),
(0xf8, rva),
(0xfc, size),
(0x180, 0x1000),
(0x184, 0x1000),
(0x1a8, 0x1000),
(0x1ac, 0x2000),
] {
write_u32(&mut data, offset, value);
}
data[0x1000] = 0xaa;
data[0x2000..0x200c].copy_from_slice(b"next section");
let out = compact_memory_image_to_pe(&data, 0x80).unwrap();
assert_eq!(get_u32(&out, 0x188), expected_raw_size);
let headers = senbei_pe::parse(&out).unwrap();
let next = senbei_pe::rva_range(&out, headers, 0x2000, 12).unwrap();
assert_eq!(&out[next], b"next section");
if rva == 0x1280 {
let exports = senbei_pe::rva_range(&out, headers, rva, size).unwrap();
assert!(out[exports].iter().all(|&byte| byte == 0));
}
}
}
/// Review regression: a zero last-section VA (corrupt section table) must /// Review regression: a zero last-section VA (corrupt section table) must
/// bail instead of building .kmiat at RVA 0 — the old code zeroed /// bail instead of building .kmiat at RVA 0 — the old code zeroed
/// `[0, 0x7000)`, wiping the DOS/PE headers, and returned the broken image /// `[0, 0x7000)`, wiping the DOS/PE headers, and returned the broken image
+192 -50
View File
@@ -1,6 +1,6 @@
//! Android target orchestration: protected AArch64 shared libraries (`.so`), //! Android target orchestration: protected AArch64 shared libraries (`.so`),
//! app packages (`.apk` / `.apks` / `.xapk`), and the Android variant of the //! app packages (`.apk` / `.apks` / `.xapk`), and the Android variant of the
//! il2cpp method-token obfuscation. //! il2cpp method-token/method-index obfuscation.
//! //!
//! The protection scheme hollows out an ELF64/AArch64 shared object and moves //! The protection scheme hollows out an ELF64/AArch64 shared object and moves
//! the original bytes into an encrypted payload appended as a `SHT_LOUSER` //! the original bytes into an encrypted payload appended as a `SHT_LOUSER`
@@ -100,14 +100,32 @@ pub fn file_content_identity(path: &Path) -> std::io::Result<String> {
/// implementation detail of the two-phase restore, not user-facing output). /// implementation detail of the two-phase restore, not user-facing output).
/// Returns the unwrapped embedded metadata blob when the restored image /// Returns the unwrapped embedded metadata blob when the restored image
/// carries one (see the module docs); the caller decides where to write it. /// 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>>> { struct SoRestoreContext {
embedded_metadata: Option<Vec<u8>>,
method_index_module: Option<Vec<u8>>,
}
fn restore_so_file_with_context(
input: &Path,
dest: &Path,
verbose: bool,
) -> Result<SoRestoreContext> {
let temporary = tempfile::tempdir().context("create stage-2 workspace")?; let temporary = tempfile::tempdir().context("create stage-2 workspace")?;
let stage2_dir = temporary.path().join("stage2"); let stage2_dir = temporary.path().join("stage2");
extract_stage2(&ExtractOptions::with_defaults( let extraction = extract_stage2(&ExtractOptions::with_defaults(
input.to_path_buf(), input.to_path_buf(),
stage2_dir.clone(), stage2_dir.clone(),
)) ))
.context("extract stage-1/stage-2 payload")?; .context("extract stage-1/stage-2 payload")?;
let method_index_module = extraction
.module_registry
.iter()
.find(|module| module.command_id == 0x0c)
.map(|module| {
std::fs::read(stage2_dir.join(&module.image_path))
.with_context(|| format!("read decoded module 0x0C `{}`", module.image_path))
})
.transpose()?;
restore_libil2cpp(&RestoreOptions { restore_libil2cpp(&RestoreOptions {
input: input.to_path_buf(), input: input.to_path_buf(),
output: dest.to_path_buf(), output: dest.to_path_buf(),
@@ -120,9 +138,28 @@ pub fn restore_so_file(input: &Path, dest: &Path, verbose: bool) -> Result<Optio
.context("restore protected library")?; .context("restore protected library")?;
let restored = let restored =
std::fs::read(dest).with_context(|| format!("read restored `{}`", dest.display()))?; std::fs::read(dest).with_context(|| format!("read restored `{}`", dest.display()))?;
Ok(senbei_metadata::android::extract_embedded_metadata( Ok(SoRestoreContext {
&restored, embedded_metadata: senbei_metadata::android::extract_embedded_metadata(&restored),
)) method_index_module,
})
}
pub fn restore_so_file(input: &Path, dest: &Path, verbose: bool) -> Result<Option<Vec<u8>>> {
let mut method_index_module = None;
restore_so_file_with_method_index_module(input, dest, verbose, &mut method_index_module)
}
pub(crate) fn restore_so_file_with_method_index_module(
input: &Path,
dest: &Path,
verbose: bool,
method_index_module: &mut Option<Vec<u8>>,
) -> Result<Option<Vec<u8>>> {
let restored = restore_so_file_with_context(input, dest, verbose)?;
if let Some(module) = restored.method_index_module {
*method_index_module = Some(module);
}
Ok(restored.embedded_metadata)
} }
/// Content identity for cross-source deduplication: the same library may /// Content identity for cross-source deduplication: the same library may
@@ -134,8 +171,10 @@ pub fn content_identity(data: &[u8]) -> String {
hex_digest(&digest.finalize()) hex_digest(&digest.finalize())
} }
/// Restore an il2cpp metadata blob (Android seeded permutation first, then the /// Restore an il2cpp metadata blob. Paired v24.1 Android packages use the
/// structural remap used by the Windows builds). /// method-index profile recovered from module 0x0C; later Android layouts use
/// the seeded MethodDef RID permutation before falling back to the structural
/// remap used by the Windows builds.
/// ///
/// The Android variant obfuscates MethodDef RIDs with a keyed five-round /// The Android variant obfuscates MethodDef RIDs with a keyed five-round
/// permutation; the correct seed is recovered by intersecting per-image key /// permutation; the correct seed is recovered by intersecting per-image key
@@ -145,6 +184,32 @@ pub fn content_identity(data: &[u8]) -> String {
/// canonical form. Both paths are no-ops (`remapped == 0`) on an /// canonical form. Both paths are no-ops (`remapped == 0`) on an
/// already-clean blob. /// already-clean blob.
pub fn restore_metadata_bytes(data: &[u8]) -> anyhow::Result<(Vec<u8>, senbei_metadata::Report)> { pub fn restore_metadata_bytes(data: &[u8]) -> anyhow::Result<(Vec<u8>, senbei_metadata::Report)> {
restore_metadata_bytes_with_module(data, None)
}
pub(crate) fn restore_metadata_bytes_with_module(
data: &[u8],
method_index_module: Option<&[u8]>,
) -> anyhow::Result<(Vec<u8>, senbei_metadata::Report)> {
let version = data
.get(4..8)
.and_then(|bytes| <[u8; 4]>::try_from(bytes).ok())
.map(u32::from_le_bytes);
if version == Some(24)
&& let Some(module) = method_index_module
{
let (out, report) = senbei_metadata::android::restore_method_indices_v24_1(data, module)
.map_err(anyhow::Error::new)?;
return Ok((
out,
senbei_metadata::Report {
version: report.version,
methods: report.methods,
remapped: report.changed_indices,
modules: 0,
},
));
}
if let Ok(discovery) = senbei_metadata::android::discover_method_token_seeds(data) if let Ok(discovery) = senbei_metadata::android::discover_method_token_seeds(data)
&& matches!(discovery.version, 29 | 31 | 39) && matches!(discovery.version, 29 | 31 | 39)
{ {
@@ -190,7 +255,7 @@ pub struct EntryOutcome {
pub enum EntryKind { pub enum EntryKind {
/// A protected shared library, restored. /// A protected shared library, restored.
So, So,
/// An il2cpp metadata blob, de-obfuscated (`remapped` tokens changed). /// An il2cpp metadata blob, de-obfuscated (`remapped` method fields changed).
Metadata { remapped: usize }, Metadata { remapped: usize },
/// A metadata blob unwrapped from a restored library's data section. /// A metadata blob unwrapped from a restored library's data section.
EmbeddedMetadata, EmbeddedMetadata,
@@ -204,12 +269,30 @@ pub enum EntryStatus {
Duplicate, Duplicate,
/// Content-probed but not a target (unprotected library). /// Content-probed but not a target (unprotected library).
NotTarget, NotTarget,
/// A metadata blob whose tokens were already canonical; no copy written. /// A metadata blob whose protected method fields were already canonical; no copy written.
Unchanged, Unchanged,
/// Recognised as a target but the restore failed. /// Recognised as a target but the restore failed.
Failed(anyhow::Error), Failed(anyhow::Error),
} }
struct PackageRestoreContext<'a> {
method_index_module: &'a mut Option<Vec<u8>>,
verbose: bool,
}
impl<'a> PackageRestoreContext<'a> {
fn new(verbose: bool, method_index_module: &'a mut Option<Vec<u8>>) -> Self {
Self {
method_index_module,
verbose,
}
}
}
fn package_entry_priority(path: &Path) -> u8 {
if is_so_name(path) { 0 } else { 1 }
}
/// Restore every protected library and metadata blob inside one app package. /// 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 /// `rel` is the package's path relative to the scanned root (or its bare file
@@ -223,6 +306,25 @@ pub fn restore_package(
out_root: &Path, out_root: &Path,
seen: &mut HashSet<String>, seen: &mut HashSet<String>,
verbose: bool, verbose: bool,
) -> Result<Vec<EntryOutcome>> {
let mut method_index_module = None;
restore_package_with_method_index_module(
package,
rel,
out_root,
seen,
verbose,
&mut method_index_module,
)
}
pub(crate) fn restore_package_with_method_index_module(
package: &Path,
rel: &Path,
out_root: &Path,
seen: &mut HashSet<String>,
verbose: bool,
method_index_module: &mut Option<Vec<u8>>,
) -> Result<Vec<EntryOutcome>> { ) -> Result<Vec<EntryOutcome>> {
let bundle = package let bundle = package
.extension() .extension()
@@ -233,6 +335,7 @@ pub fn restore_package(
let mut archive = open_package(package)?; let mut archive = open_package(package)?;
let temporary = tempfile::tempdir().context("create package workspace")?; let temporary = tempfile::tempdir().context("create package workspace")?;
let mut outcomes = Vec::new(); let mut outcomes = Vec::new();
let mut context = PackageRestoreContext::new(verbose, method_index_module);
let mut direct = Vec::new(); let mut direct = Vec::new();
let mut nested = Vec::new(); let mut nested = Vec::new();
@@ -261,6 +364,7 @@ pub fn restore_package(
} }
} }
} }
direct.sort_by_key(|(_, name)| package_entry_priority(name));
for (index, name) in direct { for (index, name) in direct {
let label = format!("{}::{}", rel.display(), name.display()); let label = format!("{}::{}", rel.display(), name.display());
let dest = out_root.join(rel).join(crate::job::out_name(&name)); let dest = out_root.join(rel).join(crate::job::out_name(&name));
@@ -271,11 +375,19 @@ pub fn restore_package(
&dest, &dest,
&temporary, &temporary,
seen, seen,
verbose, &mut context,
) )
.with_context(|| format!("extract `{label}`"))?; .with_context(|| format!("extract `{label}`"))?;
outcomes.append(&mut entry_outcomes); outcomes.append(&mut entry_outcomes);
} }
struct NestedPackage {
label: PathBuf,
path: PathBuf,
base: PathBuf,
entries: Vec<(usize, PathBuf)>,
}
let mut nested_packages = Vec::with_capacity(nested.len());
for (index, name) in nested { for (index, name) in nested {
let nested_label = rel.join(&name); let nested_label = rel.join(&name);
let nested_path = extract_entry(&mut archive, index, &temporary, &nested_label) let nested_path = extract_entry(&mut archive, index, &temporary, &nested_label)
@@ -296,23 +408,45 @@ pub fn restore_package(
} }
} }
} }
// Keep the nested package's stem in the output layout so two splits nested_packages.push(NestedPackage {
// carrying same-named entries cannot collide. label: nested_label,
let base = rel.join(name.with_extension("")); path: nested_path,
for (nested_index, entry_name) in entries { base: rel.join(name.with_extension("")),
let label = format!("{}::{}", nested_label.display(), entry_name.display()); entries,
let dest = out_root.join(&base).join(crate::job::out_name(&entry_name)); });
let mut entry_outcomes = restore_package_entry( }
&mut nested_archive,
nested_index, // A bundle can put libil2cpp.so in an ABI split while metadata stays in
&label, // base.apk. Share one context across the whole bundle and process all SOs
&dest, // before any metadata, regardless of which nested APK owns each entry.
&temporary, for priority in [0_u8, 1_u8] {
seen, for nested_package in &nested_packages {
verbose, let mut nested_archive = open_package(&nested_package.path)?;
) for (nested_index, entry_name) in nested_package
.with_context(|| format!("extract `{label}`"))?; .entries
outcomes.append(&mut entry_outcomes); .iter()
.filter(|(_, entry_name)| package_entry_priority(entry_name) == priority)
{
let label = format!(
"{}::{}",
nested_package.label.display(),
entry_name.display()
);
let dest = out_root
.join(&nested_package.base)
.join(crate::job::out_name(entry_name));
let mut entry_outcomes = restore_package_entry(
&mut nested_archive,
*nested_index,
&label,
&dest,
&temporary,
seen,
&mut context,
)
.with_context(|| format!("extract `{label}`"))?;
outcomes.append(&mut entry_outcomes);
}
} }
} }
Ok(outcomes) Ok(outcomes)
@@ -328,7 +462,7 @@ fn restore_package_entry<R: Read + Seek>(
dest: &Path, dest: &Path,
temporary: &tempfile::TempDir, temporary: &tempfile::TempDir,
seen: &mut HashSet<String>, seen: &mut HashSet<String>,
verbose: bool, context: &mut PackageRestoreContext<'_>,
) -> Result<Vec<EntryOutcome>> { ) -> Result<Vec<EntryOutcome>> {
let entry_path = extract_entry(archive, index, temporary, Path::new(label))?; let entry_path = extract_entry(archive, index, temporary, Path::new(label))?;
let entry_file = let entry_file =
@@ -358,31 +492,39 @@ fn restore_package_entry<R: Read + Seek>(
if is_so { if is_so {
drop(entry_data); drop(entry_data);
drop(entry_file); drop(entry_file);
return Ok(match restore_so_file(&entry_path, dest, verbose) { return Ok(
Ok(embedded) => { match restore_so_file_with_context(&entry_path, dest, context.verbose) {
let mut outcomes = vec![outcome(EntryKind::So, EntryStatus::Restored)]; Ok(restored) => {
if let Some(blob) = embedded { if let Some(module) = restored.method_index_module {
let meta_dest = embedded_metadata_dest(dest); *context.method_index_module = Some(module);
let status = match write_metadata_blob(&meta_dest, &blob) { }
Ok(()) => EntryStatus::Restored, let mut outcomes = vec![outcome(EntryKind::So, EntryStatus::Restored)];
Err(error) => EntryStatus::Failed(error), if let Some(blob) = restored.embedded_metadata {
}; let meta_dest = embedded_metadata_dest(dest);
outcomes.push(EntryOutcome { let status = match write_metadata_blob(&meta_dest, &blob) {
label: format!("{label} (embedded metadata)"), Ok(()) => EntryStatus::Restored,
dest: meta_dest, Err(error) => EntryStatus::Failed(error),
kind: EntryKind::EmbeddedMetadata, };
status, outcomes.push(EntryOutcome {
}); label: format!("{label} (embedded metadata)"),
dest: meta_dest,
kind: EntryKind::EmbeddedMetadata,
status,
});
}
outcomes
} }
outcomes Err(error) => vec![outcome(EntryKind::So, EntryStatus::Failed(error))],
} },
Err(error) => vec![outcome(EntryKind::So, EntryStatus::Failed(error))], );
});
} }
// Metadata entry: write only when the restore actually changed tokens — // Metadata entry: write only when the restore actually changed protected method fields —
// a clean blob needs no copy (same contract as loose metadata files). // a clean blob needs no copy (same contract as loose metadata files).
let kind_and_status = match restore_metadata_bytes(&entry_data) { let kind_and_status = match restore_metadata_bytes_with_module(
&entry_data,
context.method_index_module.as_deref(),
) {
Ok((out, report)) if report.remapped > 0 => { Ok((out, report)) if report.remapped > 0 => {
let kind = EntryKind::Metadata { let kind = EntryKind::Metadata {
remapped: report.remapped, remapped: report.remapped,
+31 -9
View File
@@ -15,7 +15,7 @@ pub struct Summary {
/// — likely to crash at runtime (e.g. 0xC0000005). Counted in addition to /// — likely to crash at runtime (e.g. 0xC0000005). Counted in addition to
/// `unpacked` (a suspect file is still written). /// `unpacked` (a suspect file is still written).
pub suspect: usize, pub suspect: usize,
/// il2cpp `global-metadata.dat` files de-obfuscated (method tokens remapped), /// il2cpp `global-metadata.dat` files de-obfuscated (protected method fields remapped),
/// including blobs unwrapped from restored Android libraries. /// including blobs unwrapped from restored Android libraries.
pub metadata: usize, pub metadata: usize,
/// Android app packages (`.apk`/`.apks`/`.xapk`) opened and searched. /// Android app packages (`.apk`/`.apks`/`.xapk`) opened and searched.
@@ -221,6 +221,7 @@ pub fn run_folder_opts(
// files restore first so the cross-source dedup keeps them over a copy // files restore first so the cross-source dedup keeps them over a copy
// inside a package (loose beats `.apk` beats `.apks`/`.xapk` bundle). // inside a package (loose beats `.apk` beats `.apks`/`.xapk` bundle).
let mut android_seen = std::collections::HashSet::new(); let mut android_seen = std::collections::HashSet::new();
let mut android_method_index_module = None;
// Hashing a protected library costs a full read, so only pay it when a // Hashing a protected library costs a full read, so only pay it when a
// duplicate source can actually exist in this run. // duplicate source can actually exist in this run.
let android_dedup = scan.android_so.len() > 1 || !scan.android_packages.is_empty(); let android_dedup = scan.android_so.len() > 1 || !scan.android_packages.is_empty();
@@ -242,7 +243,12 @@ pub fn run_folder_opts(
let input_owned = input.clone(); let input_owned = input.clone();
let dest_owned = dest.clone(); let dest_owned = dest.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::android::restore_so_file(&input_owned, &dest_owned, verbose_steps) crate::android::restore_so_file_with_method_index_module(
&input_owned,
&dest_owned,
verbose_steps,
&mut android_method_index_module,
)
})); }));
match result { match result {
Ok(Ok(embedded)) => { Ok(Ok(embedded)) => {
@@ -310,12 +316,13 @@ pub fn run_folder_opts(
let out_root_owned = out_root.clone(); let out_root_owned = out_root.clone();
let mut seen_taken = std::mem::take(&mut android_seen); let mut seen_taken = std::mem::take(&mut android_seen);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let outcomes = crate::android::restore_package( let outcomes = crate::android::restore_package_with_method_index_module(
&package_owned, &package_owned,
&rel_owned, &rel_owned,
&out_root_owned, &out_root_owned,
&mut seen_taken, &mut seen_taken,
verbose_steps, verbose_steps,
&mut android_method_index_module,
); );
(outcomes, seen_taken) (outcomes, seen_taken)
})); }));
@@ -361,7 +368,12 @@ pub fn run_folder_opts(
let meta_owned = meta.clone(); let meta_owned = meta.clone();
let dest_owned = dest.clone(); let dest_owned = dest.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
deobfuscate_metadata_to(&meta_owned, &dest_owned, verbose_steps) deobfuscate_metadata_to_with_module(
&meta_owned,
&dest_owned,
verbose_steps,
android_method_index_module.as_deref(),
)
})); }));
match result { match result {
Ok(Ok(report)) if report.remapped > 0 => { Ok(Ok(report)) if report.remapped > 0 => {
@@ -369,7 +381,7 @@ pub fn run_folder_opts(
crate::ui::metadata(&bar, suppress_file_lines, &rel, report.remapped, &dest); crate::ui::metadata(&bar, suppress_file_lines, &rel, report.remapped, &dest);
if let Some(log) = &log { if let Some(log) = &log {
log.step(&format!( log.step(&format!(
"META {rel:?} -> {dest:?}: v{} remapped {} method tokens", "META {rel:?} -> {dest:?}: v{} remapped {} method fields",
report.version, report.remapped report.version, report.remapped
)); ));
} }
@@ -506,13 +518,13 @@ pub fn run_file_v(
s.metadata = 1; s.metadata = 1;
if let Some(log) = &log { if let Some(log) = &log {
log.step(&format!( log.step(&format!(
"META {:?} -> {:?}: v{} remapped {} method tokens", "META {:?} -> {:?}: v{} remapped {} method fields",
input, dest, report.version, report.remapped input, dest, report.version, report.remapped
)); ));
} }
if quiet == 0 { if quiet == 0 {
println!( println!(
"✓ metadata v{} -> {:?} ({} method tokens remapped)", "✓ metadata v{} -> {:?} ({} method fields remapped)",
report.version, dest, report.remapped report.version, dest, report.remapped
); );
} }
@@ -735,6 +747,15 @@ pub fn deobfuscate_metadata_to(
input: &Path, input: &Path,
dest: &Path, dest: &Path,
verbose: bool, verbose: bool,
) -> anyhow::Result<senbei_metadata::Report> {
deobfuscate_metadata_to_with_module(input, dest, verbose, None)
}
fn deobfuscate_metadata_to_with_module(
input: &Path,
dest: &Path,
verbose: bool,
method_index_module: Option<&[u8]>,
) -> anyhow::Result<senbei_metadata::Report> { ) -> anyhow::Result<senbei_metadata::Report> {
let data = std::fs::read(input)?; let data = std::fs::read(input)?;
// The Android seeded-permutation variant is tried first (it validates // The Android seeded-permutation variant is tried first (it validates
@@ -742,8 +763,9 @@ pub fn deobfuscate_metadata_to(
// Windows path. The [`senbei_metadata::Error`] is preserved in the chain // Windows path. The [`senbei_metadata::Error`] is preserved in the chain
// (rather than stringified) so the folder driver can apply its // (rather than stringified) so the folder driver can apply its
// unsupported-version policy. // unsupported-version policy.
let (out, report) = crate::android::restore_metadata_bytes(&data) let (out, report) =
.map_err(|e| e.context(format!("{input:?}")))?; crate::android::restore_metadata_bytes_with_module(&data, method_index_module)
.map_err(|e| e.context(format!("{input:?}")))?;
if report.remapped > 0 { if report.remapped > 0 {
if let Some(parent) = dest.parent() { if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
+2 -2
View File
@@ -39,13 +39,13 @@ pub fn ok_label(bar: &ProgressBar, quiet: bool, rel: &str, label: &str, dest: &P
} }
/// Print a green success line for a de-obfuscated il2cpp `global-metadata.dat`, /// Print a green success line for a de-obfuscated il2cpp `global-metadata.dat`,
/// reporting how many method tokens were remapped. /// reporting how many protected method fields were remapped.
pub fn metadata(bar: &ProgressBar, quiet: bool, rel: &Path, remapped: usize, dest: &Path) { pub fn metadata(bar: &ProgressBar, quiet: bool, rel: &Path, remapped: usize, dest: &Path) {
if quiet { if quiet {
return; return;
} }
let msg = format!( let msg = format!(
"{} metadata {} -> {} ({} method tokens remapped)", "{} metadata {} -> {} ({} method fields remapped)",
"✓".green(), "✓".green(),
rel.display(), rel.display(),
dest.display(), dest.display(),
+39 -10
View File
@@ -128,18 +128,19 @@ pub(crate) fn overlay_exports_from_stub(out: &mut [u8], stub: &[u8]) {
Some(v) if v.1 != 0 => v, Some(v) if v.1 != 0 => v,
_ => return, _ => return,
}; };
let dst = match rva_to_file_off(out, export_rva) { let dst = match senbei_pe::parse(out)
Some(o) => o, .and_then(|headers| senbei_pe::rva_range(out, headers, export_rva, export_size))
None => return, {
Ok(range) => range,
Err(_) => return,
}; };
let src = match rva_to_file_off(stub, export_rva) { let src = match senbei_pe::parse(stub)
Some(o) => o, .and_then(|headers| senbei_pe::rva_range(stub, headers, export_rva, export_size))
None => return, {
Ok(range) => range,
Err(_) => return,
}; };
let n = export_size as usize; out[dst].copy_from_slice(&stub[src]);
if dst + n <= out.len() && src + n <= stub.len() {
out[dst..dst + n].copy_from_slice(&stub[src..src + n]);
}
} }
/// Restore the CLR regions retained by an external-companion loader stub. /// Restore the CLR regions retained by an external-companion loader stub.
@@ -633,6 +634,34 @@ mod tests {
data data
} }
#[test]
fn export_overlay_requires_the_entire_range_to_be_file_backed() {
for is_pe32_plus in [false, true] {
let dirs = 0x98 + if is_pe32_plus { 112 } else { 96 };
let section = 0x98 + if is_pe32_plus { 0xf0 } else { 0xe0 };
let mut stub = managed_fixture(is_pe32_plus, 0x600);
stub[0x780..0x880].fill(0x5a);
for (stub_raw_size, out_raw_size) in
[(0x600u32, 0x600u32), (0x200, 0x600), (0x600, 0x200)]
{
let mut out = managed_fixture(is_pe32_plus, 0x400);
out[dirs..dirs + 4].copy_from_slice(&0x2180u32.to_le_bytes());
out[dirs + 4..dirs + 8].copy_from_slice(&0x100u32.to_le_bytes());
out[section + 16..section + 20].copy_from_slice(&out_raw_size.to_le_bytes());
stub[section + 16..section + 20].copy_from_slice(&stub_raw_size.to_le_bytes());
let before = out.clone();
overlay_exports_from_stub(&mut out, &stub);
if stub_raw_size == 0x600 && out_raw_size == 0x600 {
assert_eq!(&out[0x580..0x680], &[0x5a; 0x100]);
assert_eq!(&out[..0x580], &before[..0x580]);
assert_eq!(&out[0x680..], &before[0x680..]);
} else {
assert_eq!(out, before);
}
}
}
}
#[test] #[test]
fn managed_companion_restores_rva_mapped_regions_without_overwriting_il() { fn managed_companion_restores_rva_mapped_regions_without_overwriting_il() {
for is_pe32_plus in [false, true] { for is_pe32_plus in [false, true] {
@@ -0,0 +1,467 @@
//! Restoration of IL2CPP v24.1 `Il2CppMethodDefinition.methodIndex` values.
//!
//! CrackProof's v24.1 Android module rewrites the global method-index permutation
//! at runtime. The transform parameters are carried by the decoded module `0x0C`,
//! so the restore derives them from that module instead of hard-coding a
//! game-specific table.
use serde::Serialize;
use crate::common::MAGIC;
const RAW_VERSION_24: u32 = 24;
const HDR_METHODS: usize = 0x30;
const V24_1_METHOD_STRIDE: usize = 0x34;
const V24_1_METHOD_INDEX_OFFSET: usize = 0x14;
const V24_1_SELECTOR: u32 = 1;
const EXCEPTION_COUNT: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MethodIndexReport {
pub version: u32,
pub methods: usize,
pub active_methods: usize,
pub changed_indices: usize,
pub exception_count: usize,
pub seed: String,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MethodIndexError {
#[error("not an IL2CPP global-metadata.dat")]
NotMetadata,
#[error("unsupported metadata version {0}")]
UnsupportedVersion(u32),
#[error("malformed v24.1 metadata: {0}")]
Malformed(String),
#[error("v24.1 method-index profile was not found in module 0x0C")]
ProfileNotFound,
#[error("multiple v24.1 method-index profiles matched module 0x0C")]
AmbiguousProfile,
#[error("v24.1 method-index restoration failed validation: {0}")]
Validation(String),
}
type Result<T> = std::result::Result<T, MethodIndexError>;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Profile {
seed: u32,
exception_values: [u32; EXCEPTION_COUNT],
}
fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> {
let end = offset
.checked_add(size)
.ok_or_else(|| MethodIndexError::Malformed("byte range overflow".to_owned()))?;
data.get(offset..end).ok_or_else(|| {
MethodIndexError::Malformed(format!(
"byte range 0x{offset:x}..0x{end:x} is out of bounds"
))
})
}
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
let value: [u8; 4] = bytes(data, offset, 4)?
.try_into()
.map_err(|_| MethodIndexError::Malformed("invalid u32 range".to_owned()))?;
Ok(u32::from_le_bytes(value))
}
fn read_i32(data: &[u8], offset: usize) -> Result<i32> {
let value: [u8; 4] = bytes(data, offset, 4)?
.try_into()
.map_err(|_| MethodIndexError::Malformed("invalid i32 range".to_owned()))?;
Ok(i32::from_le_bytes(value))
}
fn write_i32(data: &mut [u8], offset: usize, value: i32) -> Result<()> {
let destination = data.get_mut(offset..offset + 4).ok_or_else(|| {
MethodIndexError::Malformed("method-index write is out of bounds".to_owned())
})?;
destination.copy_from_slice(&value.to_le_bytes());
Ok(())
}
fn method_table(data: &[u8]) -> Result<(usize, usize)> {
if read_u32(data, 0)? != MAGIC {
return Err(MethodIndexError::NotMetadata);
}
let version = read_u32(data, 4)?;
if version != RAW_VERSION_24 {
return Err(MethodIndexError::UnsupportedVersion(version));
}
let offset = read_u32(data, HDR_METHODS)? as usize;
let size = read_u32(data, HDR_METHODS + 4)? as usize;
bytes(data, offset, size)?;
if !size.is_multiple_of(V24_1_METHOD_STRIDE) {
return Err(MethodIndexError::Malformed(format!(
"method table size 0x{size:x} is not divisible by v24.1 stride 0x{V24_1_METHOD_STRIDE:x}"
)));
}
Ok((offset, size / V24_1_METHOD_STRIDE))
}
fn method_indices(data: &[u8]) -> Result<(usize, Vec<Option<u32>>)> {
let (offset, count) = method_table(data)?;
let mut indices = Vec::with_capacity(count);
for method in 0..count {
let value = read_i32(
data,
offset + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET,
)?;
indices.push((value >= 0).then_some(value as u32));
}
Ok((offset, indices))
}
fn is_complete_permutation(indices: &[Option<u32>]) -> bool {
let active_count = indices.iter().filter(|value| value.is_some()).count();
if active_count == 0 || active_count > u32::MAX as usize {
return false;
}
let mut seen = vec![false; active_count];
for value in indices.iter().flatten().copied() {
let Ok(index) = usize::try_from(value) else {
return false;
};
if index >= active_count || seen[index] {
return false;
}
seen[index] = true;
}
seen.into_iter().all(|value| value)
}
fn permutation_key(seed: u32, count: u32) -> Result<u32> {
if count < 2 {
return Err(MethodIndexError::Validation(
"method-index permutation requires at least two active methods".to_owned(),
));
}
if count > u32::MAX / 2 {
return Err(MethodIndexError::Validation(
"active method count is too large for the permutation mirror".to_owned(),
));
}
let half = count / 2;
if half == 0 {
return Err(MethodIndexError::Validation(
"method-index permutation has a zero divisor".to_owned(),
));
}
Ok(seed % half + count / 4)
}
fn permutation_round(mut value: u32, count: u32, key: u32) -> u32 {
let mirror = count * 2 - 1;
if value & 1 != 0 {
value = mirror - value;
}
value >>= 1;
if value >= count {
value = mirror - value;
}
let adjusted = i64::from(value) - i64::from(key);
if adjusted < 0 {
(adjusted + i64::from(count)) as u32
} else {
adjusted as u32
}
}
fn transform_index(mut value: u32, count: u32, seed: u32) -> Result<u32> {
let key = permutation_key(seed, count)?;
for _ in 0..5 {
value = permutation_round(value, count, key);
}
Ok(value)
}
fn transformed_values(indices: &[Option<u32>], seed: u32) -> Result<Vec<Option<u32>>> {
let active_count = indices.iter().filter(|value| value.is_some()).count();
let count = u32::try_from(active_count)
.map_err(|_| MethodIndexError::Validation("active method count exceeds u32".to_owned()))?;
indices
.iter()
.map(|value| {
value
.map(|value| transform_index(value, count, seed))
.transpose()
})
.collect()
}
fn missing_values(indices: &[Option<u32>]) -> Option<Vec<u32>> {
let active_count = indices.iter().filter(|value| value.is_some()).count();
let mut seen = vec![false; active_count];
for value in indices.iter().flatten().copied() {
let index = usize::try_from(value).ok()?;
if index >= active_count {
return None;
}
seen[index] = true;
}
Some(
seen.into_iter()
.enumerate()
.filter_map(|(index, present)| (!present).then_some(index as u32))
.collect(),
)
}
fn find_exception_table(
module: &[u8],
count: u32,
missing: &[u32],
) -> Option<[u32; EXCEPTION_COUNT]> {
if missing.len() != EXCEPTION_COUNT || count == 0 {
return None;
}
let mut wanted = vec![false; count as usize];
for &value in missing {
let slot = wanted.get_mut(value as usize)?;
*slot = true;
}
let table_bytes = EXCEPTION_COUNT * 4;
for offset in (8..=module.len().checked_sub(table_bytes)?).step_by(4) {
if read_u32(module, offset - 8).ok()? != V24_1_SELECTOR
|| read_u32(module, offset - 4).ok()? != count - 1
{
continue;
}
let mut values = [0_u32; EXCEPTION_COUNT];
let mut seen = vec![false; count as usize];
let mut matches = true;
for (index, slot) in values.iter_mut().enumerate() {
let value = read_u32(module, offset + index * 4).ok()?;
let Some(is_wanted) = wanted.get(value as usize) else {
matches = false;
break;
};
if !*is_wanted || seen[value as usize] {
matches = false;
break;
}
seen[value as usize] = true;
*slot = value;
}
if matches {
return Some(values);
}
}
None
}
fn profile_candidates(module: &[u8], indices: &[Option<u32>]) -> Result<Vec<Profile>> {
let active_count = indices.iter().filter(|value| value.is_some()).count();
let count = u32::try_from(active_count)
.map_err(|_| MethodIndexError::Validation("active method count exceeds u32".to_owned()))?;
if count <= EXCEPTION_COUNT as u32 {
return Err(MethodIndexError::Validation(format!(
"active method count {count} is too small for the v24.1 exception table"
)));
}
let mut profiles = Vec::new();
for offset in (0..module.len().saturating_sub(8)).step_by(4) {
let Ok(version) = read_u32(module, offset + 4) else {
continue;
};
if version != RAW_VERSION_24 {
continue;
}
let seed = read_u32(module, offset)?;
let transformed = transformed_values(indices, seed)?;
let Some(missing) = missing_values(&transformed) else {
continue;
};
let Some(exception_values) = find_exception_table(module, count, &missing) else {
continue;
};
let mut candidate = transformed;
let mut exception = exception_values.iter().copied();
for value in candidate.iter_mut().flatten() {
if let Some(replacement) = exception.next() {
*value = replacement;
} else {
break;
}
}
if exception.next().is_none() && is_complete_permutation(&candidate) {
profiles.push(Profile {
seed,
exception_values,
});
}
}
profiles.sort_by_key(|profile| profile.seed);
profiles.dedup();
Ok(profiles)
}
/// Restore the v24.1 `methodIndex` permutation using the decoded CrackProof
/// module `0x0C` that accompanied the protected library.
///
/// The profile is accepted only when the module supplies a seed plus a
/// 256-entry exception table that turns the restored non-negative indices into
/// the exact permutation `0..active_method_count`. This makes a wrong module or
/// incompatible v24 sub-layout fail without mutating the metadata.
pub fn restore_method_indices_v24_1(
data: &[u8],
module_0c: &[u8],
) -> Result<(Vec<u8>, MethodIndexReport)> {
let (method_offset, indices) = method_indices(data)?;
let active_count = indices.iter().filter(|value| value.is_some()).count();
if is_complete_permutation(&indices) {
return Ok((
data.to_vec(),
MethodIndexReport {
version: RAW_VERSION_24,
methods: indices.len(),
active_methods: active_count,
changed_indices: 0,
exception_count: 0,
seed: "clean".to_owned(),
},
));
}
let profiles = profile_candidates(module_0c, &indices)?;
let profile = match profiles.as_slice() {
[] => return Err(MethodIndexError::ProfileNotFound),
[profile] => profile,
_ => return Err(MethodIndexError::AmbiguousProfile),
};
let mut restored = transformed_values(&indices, profile.seed)?;
let mut exception = profile.exception_values.iter().copied();
for value in restored.iter_mut().flatten() {
if let Some(replacement) = exception.next() {
*value = replacement;
} else {
break;
}
}
if exception.next().is_some() {
return Err(MethodIndexError::Validation(
"metadata has fewer active methods than the exception table".to_owned(),
));
}
if !is_complete_permutation(&restored) {
return Err(MethodIndexError::Validation(
"restored methodIndex values are not a complete permutation".to_owned(),
));
}
let mut output = data.to_vec();
let mut changed_indices = 0_usize;
for (method, value) in restored.iter().enumerate() {
let Some(value) = value else {
continue;
};
let offset = method_offset + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET;
let old = read_i32(data, offset)?;
let new = i32::try_from(*value).map_err(|_| {
MethodIndexError::Validation("restored methodIndex exceeds i32".to_owned())
})?;
if old != new {
write_i32(&mut output, offset, new)?;
changed_indices += 1;
}
}
Ok((
output,
MethodIndexReport {
version: RAW_VERSION_24,
methods: indices.len(),
active_methods: active_count,
changed_indices,
exception_count: EXCEPTION_COUNT,
seed: format!("0x{:08X}", profile.seed),
},
))
}
#[cfg(test)]
mod tests {
use super::*;
fn inverse_map(count: u32, seed: u32) -> Vec<u32> {
let mut inverse = vec![u32::MAX; count as usize];
for value in 0..count {
let transformed = transform_index(value, count, seed).unwrap();
inverse[transformed as usize] = value;
}
assert!(inverse.iter().all(|value| *value != u32::MAX));
inverse
}
fn fixture() -> (Vec<u8>, Vec<u8>) {
let active_count = 512_u32;
let seed = 0x7770_0dcc;
let inverse = inverse_map(active_count, seed);
let header_size = 0x100usize;
let method_size = active_count as usize * V24_1_METHOD_STRIDE;
let mut metadata = vec![0_u8; header_size + method_size];
metadata[0..4].copy_from_slice(&MAGIC.to_le_bytes());
metadata[4..8].copy_from_slice(&RAW_VERSION_24.to_le_bytes());
metadata[HDR_METHODS..HDR_METHODS + 4].copy_from_slice(&(header_size as u32).to_le_bytes());
metadata[HDR_METHODS + 4..HDR_METHODS + 8]
.copy_from_slice(&(method_size as u32).to_le_bytes());
// Before the exception patch the first half duplicates the second half,
// leaving 0..255 missing. The module's 256-entry table fills exactly
// those first active methods after the five-round transform.
for method in 0..active_count as usize {
let transformed = 256 + (method % 256) as u32;
let protected = inverse[transformed as usize];
let offset = header_size + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET;
metadata[offset..offset + 4].copy_from_slice(&(protected as i32).to_le_bytes());
}
let mut module = vec![0_u8; 0x1000];
module[0x100..0x104].copy_from_slice(&seed.to_le_bytes());
module[0x104..0x108].copy_from_slice(&RAW_VERSION_24.to_le_bytes());
module[0x3f8..0x3fc].copy_from_slice(&V24_1_SELECTOR.to_le_bytes());
module[0x3fc..0x400].copy_from_slice(&(active_count - 1).to_le_bytes());
for value in 0..EXCEPTION_COUNT as u32 {
let offset = 0x400 + value as usize * 4;
module[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
(metadata, module)
}
#[test]
fn restores_v24_1_method_index_permutation() {
let (metadata, module) = fixture();
let (restored, report) = restore_method_indices_v24_1(&metadata, &module).unwrap();
assert_eq!(report.active_methods, 512);
assert_eq!(report.exception_count, 256);
assert_eq!(report.seed, "0x77700DCC");
let (offset, indices) = method_indices(&restored).unwrap();
assert_eq!(offset, 0x100);
assert!(is_complete_permutation(&indices));
for (method, value) in indices.into_iter().enumerate() {
assert_eq!(value, Some(method as u32));
}
}
#[test]
fn clean_v24_1_metadata_is_idempotent() {
let (metadata, module) = fixture();
let (restored, _) = restore_method_indices_v24_1(&metadata, &module).unwrap();
let (again, report) = restore_method_indices_v24_1(&restored, &module).unwrap();
assert_eq!(again, restored);
assert_eq!(report.changed_indices, 0);
assert_eq!(report.seed, "clean");
}
#[test]
fn rejects_unrelated_module() {
let (metadata, _) = fixture();
let error = restore_method_indices_v24_1(&metadata, &[0_u8; 0x1000]).unwrap_err();
assert_eq!(error, MethodIndexError::ProfileNotFound);
}
}
+2
View File
@@ -2,9 +2,11 @@
mod embedded; mod embedded;
mod keystream; mod keystream;
mod method_indices;
mod method_tokens; mod method_tokens;
pub use embedded::{embedded_metadata_size, extract_embedded_metadata}; pub use embedded::{embedded_metadata_size, extract_embedded_metadata};
pub use method_indices::{MethodIndexError, MethodIndexReport, restore_method_indices_v24_1};
pub use method_tokens::{ pub use method_tokens::{
DEFAULT_METHOD_TOKEN_SEED, Error, ImageKeyDiscovery, Report, SeedDiscoveryReport, DEFAULT_METHOD_TOKEN_SEED, Error, ImageKeyDiscovery, Report, SeedDiscoveryReport,
discover_method_token_seeds, restore_method_tokens, discover_method_token_seeds, restore_method_tokens,
+7 -7
View File
@@ -412,7 +412,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-crypto" name = "senbei-crypto"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"aes", "aes",
"thiserror", "thiserror",
@@ -420,7 +420,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-elf" name = "senbei-elf"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"goblin", "goblin",
"thiserror", "thiserror",
@@ -428,7 +428,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-engine" name = "senbei-engine"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"memmap2", "memmap2",
"senbei-crypto", "senbei-crypto",
@@ -443,7 +443,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-io" name = "senbei-io"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indicatif", "indicatif",
@@ -464,7 +464,7 @@ dependencies = [
[[package]] [[package]]
name = "senbei-metadata" name = "senbei-metadata"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"serde", "serde",
"thiserror", "thiserror",
@@ -472,14 +472,14 @@ dependencies = [
[[package]] [[package]]
name = "senbei-pe" name = "senbei-pe"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"thiserror", "thiserror",
] ]
[[package]] [[package]]
name = "senbei-wasm" name = "senbei-wasm"
version = "1.3.0" version = "1.3.2"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"senbei-engine", "senbei-engine",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "senbei-wasm" name = "senbei-wasm"
version = "1.3.0" version = "1.3.2"
edition = "2024" edition = "2024"
rust-version = "1.98.1" rust-version = "1.98.1"
description = "WebAssembly bindings for senbei (browser frontend assets live in web/)" description = "WebAssembly bindings for senbei (browser frontend assets live in web/)"
+31 -3
View File
@@ -50,6 +50,22 @@ const KIND_LABEL = {
'native-dll': 'protected native DLL', 'native-dll': 'protected native DLL',
'managed-dll': 'protected managed DLL', 'managed-dll': 'protected managed DLL',
metadata: 'il2cpp metadata', metadata: 'il2cpp metadata',
'android-package':
'Android app package — the web build cannot unpack these yet; use the senbei CLI',
'android-so':
'Android AArch64 library — the web build cannot unpack these yet; use the senbei CLI',
};
// Android targets are recognized by extension so the row can explain the
// situation instead of reporting a protected file as unrecognized: the
// Android pipeline is filesystem orchestration (senbei-io) with no wasm
// build, so these files need the CLI. The pseudo-kinds are labels only —
// they never reach the worker.
const ANDROID_KIND = {
apk: 'android-package',
apks: 'android-package',
xapk: 'android-package',
so: 'android-so',
}; };
const COMPANION_SVG = const COMPANION_SVG =
@@ -91,8 +107,13 @@ async function stageFiles(list) {
// the PE header fields); read a small slice, not the whole file. // the PE header fields); read a small slice, not the whole file.
const head = new Uint8Array(await file.slice(0, 65536).arrayBuffer()); const head = new Uint8Array(await file.slice(0, 65536).arrayBuffer());
// Companions are ciphertext fragments; detect() only makes sense on the // Companions are ciphertext fragments; detect() only makes sense on the
// base module, so skip it for `._` files. // base module, so skip it for `._` files. Android targets short-circuit
const kind = file.name.endsWith('._') ? undefined : detect(head); // detect() as well: an ELF/zip never classifies as a protected PE, and
// the row must carry the android pseudo-kind for its status line.
const ext = file.name.slice(file.name.lastIndexOf('.') + 1).toLowerCase();
const kind = file.name.endsWith('._')
? undefined
: (ANDROID_KIND[ext] ?? detect(head));
const old = files.get(file.name); const old = files.get(file.name);
files.set(file.name, { files.set(file.name, {
file, file,
@@ -284,7 +305,10 @@ function render() {
const unpackable = [...files].some( const unpackable = [...files].some(
([name, e]) => ([name, e]) =>
!name.endsWith('._') && e.kind !== undefined && e.state === 'staged', !name.endsWith('._') &&
e.kind !== undefined &&
!e.kind.startsWith('android-') &&
e.state === 'staged',
); );
unpackBtn.disabled = !unpackable; unpackBtn.disabled = !unpackable;
actions.hidden = files.size === 0; actions.hidden = files.size === 0;
@@ -364,6 +388,10 @@ unpackBtn.addEventListener('click', async () => {
continue; continue;
} }
// Android rows are informational only (no wasm pipeline); their staged
// status line already says to use the CLI.
if (entry.kind.startsWith('android-')) continue;
entry.state = 'working'; entry.state = 'working';
render(); render();
try { try {
+3 -1
View File
@@ -56,7 +56,9 @@
<p class="hint"> <p class="hint">
Protected <code>.exe</code> / <code>.dll</code> modules, optional Protected <code>.exe</code> / <code>.dll</code> modules, optional
<code>._</code> companions, or an il2cpp <code>._</code> companions, or an il2cpp
<code>global-metadata.dat</code>. <code>global-metadata.dat</code>. Android packages
(<code>.apk</code> / <code>.apks</code> / <code>.xapk</code>) and
<code>.so</code> libraries are recognized but need the CLI.
</p> </p>
<input type="file" id="picker" multiple hidden> <input type="file" id="picker" multiple hidden>
</div> </div>
+20
View File
@@ -333,6 +333,26 @@ footer {
} }
footer a { color: var(--dim); } footer a { color: var(--dim); }
/* --- narrow screens --- */
/* On a narrow viewport the status column (flex: 1, right-aligned) is
squeezed by a long file name into a one-word-per-line vertical strip.
Stack the row instead: name + icons on the first line, the status on its
own full-width line below, left-aligned. */
@media (max-width: 560px) {
main { padding: 1.5rem 0.9rem 2.5rem; }
#dropzone { padding: 1.75rem 1rem; }
.file .row { flex-wrap: wrap; }
.file .name { flex: 1; min-width: 0; }
.file .status {
order: 6; /* after the download/remove icons */
flex-basis: 100%;
text-align: left;
}
}
/* --- animations --- */ /* --- animations --- */
@keyframes fadeIn { @keyframes fadeIn {