mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-09-19 03:57:59 -04:00
refactor: consolidate platform engines into senbei-engine
This commit is contained in:
@@ -1,98 +1,40 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Guidance for AI coding agents (and human contributors) working in this repo.
|
Guidance for contributors working in this repository.
|
||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
Senbei is a static unpacker for Crackproof-protected PE files and protected
|
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.
|
||||||
Android (AArch64) shared libraries: a Cargo workspace with a pure, panic-free,
|
|
||||||
no-I/O PE unpacker core (`senbei-pe/`, built on `senbei-crypto/`), il2cpp
|
Read `docs/design.md` before changing architecture or pipeline boundaries.
|
||||||
metadata de-obfuscators (`senbei-metadata/` for the Windows structural
|
|
||||||
variant, `senbei-android-metadata/` for the Android seeded-permutation and
|
|
||||||
embedded-blob variants), the native-only Android pipeline
|
|
||||||
(`senbei-android-crypto/`, `senbei-android-engine/`, `senbei-android-elf/`),
|
|
||||||
filesystem/CLI orchestration (`senbei-io/`, including the Android
|
|
||||||
single-library/package glue in `senbei-io/src/android.rs`), the `senbei`
|
|
||||||
binary (`senbei-cli/`), WebAssembly bindings (`senbei-wasm/`, outside the
|
|
||||||
workspace; builds into `web/pkg/`), and the static browser frontend assets
|
|
||||||
(`web/`). Read `docs/design.md` first.
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
cargo build --release :: CLI (default member: senbei-cli)
|
cargo build --release
|
||||||
cargo test --release --workspace :: full suite (golden corpus: samples/, git-ignored)
|
cargo test --release --workspace
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
cargo fmt --all
|
cargo fmt --all
|
||||||
cd senbei-wasm && wasm-pack build --target web --release --out-dir ../web/pkg :: browser build
|
cd senbei-wasm && wasm-pack build --target web --release --out-dir ../web/pkg
|
||||||
```
|
```
|
||||||
|
|
||||||
The `samples/` corpus is user-managed and absent on CI; without it the
|
The optional `samples/` corpus is user-managed and ignored by Git. The Android corpus is under `samples/android/` when present. Do not delete sample directories as part of routine cleanup.
|
||||||
samples test is a no-op pass. `SENBEI_REQUIRE_SAMPLES=1` makes an absent
|
|
||||||
corpus fail (use this on a private CI that *does* have the corpus). The
|
|
||||||
Android corpus lives in `samples/android/` (one extracted app tree per
|
|
||||||
subdirectory) and is covered by `tests/android_samples.rs`;
|
|
||||||
`SENBEI_ANDROID_SAMPLES` overrides that location. Do not
|
|
||||||
delete `samples/` with `rm -rf` — it may be a junction; use git
|
|
||||||
worktree-aware cleanup.
|
|
||||||
|
|
||||||
## Hard rules
|
## Crate Boundaries
|
||||||
|
|
||||||
- **The PE unpacker core stays pure**: `senbei-pe` and `senbei-crypto` have no
|
`senbei-pe` and `senbei-elf` contain basic format parsing and address mapping only. `senbei-engine/src/windows/` contains the PE unpacking pipeline; `senbei-engine/src/android/` contains Android extraction and ELF restoration. `senbei-crypto/src/android/` and `senbei-metadata/src/android/` contain Android-specific primitives; Windows metadata code is under `senbei-metadata/src/windows/`. Shared source stays directly under `src/`.
|
||||||
file I/O, no `unsafe`, no panics across the public boundary, no
|
|
||||||
platform-specific code. Everything `senbei-wasm` compiles must keep building
|
|
||||||
for `wasm32-unknown-unknown` (`cargo check --target wasm32-unknown-unknown`
|
|
||||||
at the workspace root covers it — the Android crates do compile to wasm, but
|
|
||||||
nothing on the wasm path calls them).
|
|
||||||
- **The Android crates are native-only orchestration-style crates**:
|
|
||||||
`senbei-android-engine`/`senbei-android-elf` memory-map inputs and write a
|
|
||||||
module workspace to disk (the restore is a two-phase design consuming that
|
|
||||||
workspace). Keep them off the web app's code paths; `senbei-io`'s
|
|
||||||
`android.rs` is the only caller the CLI uses.
|
|
||||||
- **`catch_unwind` does not work on wasm** (the prebuilt std can't unwind; a
|
|
||||||
caught panic becomes a fatal `unreachable` trap). Native code may rely on
|
|
||||||
`catch_unpack`, but any routing decision must also work without a catchable
|
|
||||||
panic: spliced companion inputs route straight to the EXE pipeline, and the
|
|
||||||
web app isolates every unpack in a disposable Web Worker, retrying trapped
|
|
||||||
DLLs with `job::unpack_bytes_force_exe`. Never make correctness on wasm
|
|
||||||
depend on catching a panic.
|
|
||||||
- **Byte-identical output is the contract.** Any pipeline change must re-run
|
|
||||||
the full golden corpus; a byte mismatch on any golden is a regression.
|
|
||||||
- **Trial-and-validate, never trust a heuristic.** A silently wrong offset
|
|
||||||
produces a silently broken binary — worse than an error. Every layout
|
|
||||||
candidate must be validated (checksum / structural oracle) with fall-through
|
|
||||||
to the next candidate.
|
|
||||||
- **Determinism under parallelism.** Block fan-out must stay byte-identical
|
|
||||||
regardless of thread count (`SENBEI_THREADS=1` is the sequential reference).
|
|
||||||
- **Folder scanning: deny-list, never allow-list.** Targets are recognised by
|
|
||||||
content, not extension, and can carry arbitrary names — there is no closed
|
|
||||||
set of target extensions an allow-list could enumerate. Only known
|
|
||||||
bulk-asset formats are excluded.
|
|
||||||
- **No binaries in the repo** — not as fixtures, not in commits. The only
|
|
||||||
corpus is the local git-ignored `samples/`. (Issue attachments of
|
|
||||||
protected inputs are fine when the user is authorized to share them, but
|
|
||||||
never commit them.)
|
|
||||||
|
|
||||||
## Public-repo hygiene (important)
|
The format crates and PE engine remain free of filesystem I/O. Native Android extraction and restoration may memory-map inputs and write temporary workspaces. The browser binding must continue to compile for `wasm32-unknown-unknown`.
|
||||||
|
|
||||||
This is a public research repository. In code comments, docs, tests, and
|
## Hard Rules
|
||||||
commit messages:
|
|
||||||
|
|
||||||
- **Never name specific games, publishers, or product codenames.** Refer to
|
- Outputs must be byte-identical to the available golden corpus.
|
||||||
build families generically ("older EXE-64 builds", "the marker-less
|
- Layout heuristics must trial and validate every candidate before accepting it.
|
||||||
layout", "external-companion builds"). Keep offsets/numbers — drop names.
|
- Deterministic parallel and sequential paths must produce identical bytes.
|
||||||
- **Never name specific protected filenames** from real distributions. Test
|
- Folder scanning must not open bulk assets. Windows candidates are `.exe`, `.dll`, and `global-metadata.dat`; Android candidates are `.so` and `global-metadata.dat`. Matching `.exe._` and `.dll._` files are auxiliary payloads and are not counted as skipped targets.
|
||||||
fixtures use generic names (`app.exe`, `managed.dll`, `daemon.exe`).
|
- APK, APKS, and XAPK processing must inspect manifests first and extract only `.so` and `global-metadata.dat` entries.
|
||||||
Exceptions (platform-standard technology names, allowed): `il2cpp`,
|
- Do not commit protected or restored binaries. Use generic fixture names and do not add product-specific names or external tool references to public code, docs, tests, or commit messages.
|
||||||
`Unity`, `global-metadata.dat`, the Crackproof magic `KONN`.
|
|
||||||
- **Never reference other tools, projects, implementations, or paths outside
|
|
||||||
this repo.** Describe behavior and layout directly; do not mention prior
|
|
||||||
art, porting, or where any algorithm came from.
|
|
||||||
|
|
||||||
## Conventions
|
## Documentation
|
||||||
|
|
||||||
- Comments explain *why* (layout rationale, observed variants, failure modes),
|
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.
|
||||||
not *what*.
|
|
||||||
- Rust 2024 edition; clippy-clean at `-D warnings`; rustfmt default style.
|
|
||||||
- CLI behavior (flags, exit codes, output naming) is documented in
|
|
||||||
`docs/usage.md` — update the doc when changing behavior.
|
|
||||||
|
|||||||
Generated
+29
-48
@@ -418,53 +418,11 @@ dependencies = [
|
|||||||
"syn 3.0.4",
|
"syn 3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "senbei-android-crypto"
|
|
||||||
version = "1.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"aes",
|
|
||||||
"thiserror",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "senbei-android-elf"
|
|
||||||
version = "1.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"memmap2",
|
|
||||||
"senbei-android-crypto",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "senbei-android-engine"
|
|
||||||
version = "1.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"goblin",
|
|
||||||
"memmap2",
|
|
||||||
"senbei-android-crypto",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "senbei-android-metadata"
|
|
||||||
version = "1.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"thiserror",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-cli"
|
name = "senbei-cli"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"senbei-engine",
|
||||||
"senbei-io",
|
"senbei-io",
|
||||||
"senbei-metadata",
|
"senbei-metadata",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -475,6 +433,29 @@ dependencies = [
|
|||||||
name = "senbei-crypto"
|
name = "senbei-crypto"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "senbei-elf"
|
||||||
|
version = "1.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"goblin",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "senbei-engine"
|
||||||
|
version = "1.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"goblin",
|
||||||
|
"memmap2",
|
||||||
|
"senbei-crypto",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -487,11 +468,8 @@ dependencies = [
|
|||||||
"indicatif",
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
"owo-colors",
|
"owo-colors",
|
||||||
"senbei-android-elf",
|
"senbei-engine",
|
||||||
"senbei-android-engine",
|
|
||||||
"senbei-android-metadata",
|
|
||||||
"senbei-metadata",
|
"senbei-metadata",
|
||||||
"senbei-pe",
|
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -502,12 +480,15 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-metadata"
|
name = "senbei-metadata"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-pe"
|
name = "senbei-pe"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"senbei-crypto",
|
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+4
-8
@@ -1,11 +1,9 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"senbei-android-crypto",
|
|
||||||
"senbei-android-elf",
|
|
||||||
"senbei-android-engine",
|
|
||||||
"senbei-android-metadata",
|
|
||||||
"senbei-cli",
|
"senbei-cli",
|
||||||
"senbei-crypto",
|
"senbei-crypto",
|
||||||
|
"senbei-elf",
|
||||||
|
"senbei-engine",
|
||||||
"senbei-io",
|
"senbei-io",
|
||||||
"senbei-metadata",
|
"senbei-metadata",
|
||||||
"senbei-pe",
|
"senbei-pe",
|
||||||
@@ -43,11 +41,9 @@ windows = { version = "0.62", features = [
|
|||||||
"Win32_System_SystemInformation",
|
"Win32_System_SystemInformation",
|
||||||
] }
|
] }
|
||||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||||
senbei-android-crypto = { path = "senbei-android-crypto" }
|
|
||||||
senbei-android-elf = { path = "senbei-android-elf" }
|
|
||||||
senbei-android-engine = { path = "senbei-android-engine" }
|
|
||||||
senbei-android-metadata = { path = "senbei-android-metadata" }
|
|
||||||
senbei-crypto = { path = "senbei-crypto" }
|
senbei-crypto = { path = "senbei-crypto" }
|
||||||
|
senbei-elf = { path = "senbei-elf" }
|
||||||
|
senbei-engine = { path = "senbei-engine" }
|
||||||
senbei-io = { path = "senbei-io" }
|
senbei-io = { path = "senbei-io" }
|
||||||
senbei-metadata = { path = "senbei-metadata" }
|
senbei-metadata = { path = "senbei-metadata" }
|
||||||
senbei-pe = { path = "senbei-pe" }
|
senbei-pe = { path = "senbei-pe" }
|
||||||
|
|||||||
@@ -1,89 +1,56 @@
|
|||||||
# Senbei
|
# Senbei
|
||||||
|
|
||||||
A static unpacker for Crackproof-protected 64-bit and 32-bit PE files and
|
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.
|
||||||
protected Android (AArch64) shared libraries. Point it at a file, an app
|
|
||||||
package, or a folder and it writes decrypted copies — no launch of the
|
|
||||||
protected program, no kernel driver, no code runs out of the protected binary.
|
|
||||||
|
|
||||||
> _"Crackproof"? It's senbei (煎餅 — rice cracker). Cracks itself._
|
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.
|
||||||
|
|
||||||
Senbei reads a protected `.exe` or `.dll`, replays the unpacking algorithm
|
## Crates
|
||||||
entirely in memory, and writes the recovered image to a new file. The core is a
|
|
||||||
pure, panic-free library with no file I/O; the CLI wraps it with scanning, a
|
|
||||||
progress bar, and a run log. A browser version (WebAssembly, fully client-side)
|
|
||||||
lives in [`web/`](web/).
|
|
||||||
|
|
||||||
## Legal notice and intended use
|
The workspace contains eight crates: `senbei-cli`, `senbei-crypto`, `senbei-io`, `senbei-metadata`, `senbei-pe`, `senbei-elf`, `senbei-engine`, and `senbei-wasm`.
|
||||||
|
|
||||||
**Read this before using Senbei.**
|
`senbei-pe` and `senbei-elf` contain only basic format parsing and address mapping. Protection-specific code is in `senbei-engine/src/windows/` and `senbei-engine/src/android/`. Platform-specific crypto and metadata code is grouped under `senbei-crypto/src/android/`, `senbei-metadata/src/windows/`, and `senbei-metadata/src/android/`.
|
||||||
|
|
||||||
- Senbei is a research and interoperability tool. It exists to enable lawful
|
## Supported Inputs
|
||||||
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.
|
|
||||||
- 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.)
|
|
||||||
- Senbei does not enable online play, license fraud, or cheating, and must not
|
|
||||||
be used to redistribute decrypted binaries. Do not upload outputs anywhere.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## What it handles
|
- 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.
|
||||||
|
|
||||||
| Kind | Description |
|
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.
|
||||||
| --- | --- |
|
|
||||||
| `NativeExe` | Crackproof-protected native executable (PE32+ and PE32). |
|
|
||||||
| `ManagedExe` | Protected .NET executable (has a CLR data directory). |
|
|
||||||
| `NativeDll` | Protected native (unmanaged) DLL. |
|
|
||||||
| `ManagedDll` | Protected .NET assembly (has a CLR data directory). |
|
|
||||||
| `._` companion | Stub + external encrypted payload layout, spliced automatically. |
|
|
||||||
| `global-metadata.dat` | il2cpp metadata with obfuscated method tokens, de-obfuscated in place. |
|
|
||||||
| Android `.so` | Protected AArch64 shared library, statically restored (hollowed sections + stripped dynamic tables rebuilt). |
|
|
||||||
| `.apk` / `.apks` / `.xapk` | App packages; protected entries inside are restored, preserving the package's internal layout. |
|
|
||||||
|
|
||||||
Detection is content-based (header key-table at offset 4096, magic `KONN`),
|
## Quick Start
|
||||||
not extension-based — app packages are the one exception, recognised by
|
|
||||||
extension plus the zip magic because they are containers. Anything
|
|
||||||
unrecognized is left untouched.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
senbei protected.exe
|
senbei protected.exe
|
||||||
:: -> unpack\protected.unpack.exe
|
|
||||||
|
|
||||||
senbei game.apk
|
senbei game.apk
|
||||||
:: -> unpack\game.apk\lib\arm64-v8a\libil2cpp.unpack.so
|
|
||||||
|
|
||||||
senbei "C:\Games\MyGame"
|
senbei "C:\Games\MyGame"
|
||||||
:: -> C:\Games\MyGame\unpack\... (recursive, skips non-targets)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Every output is sanity-checked statically; structurally broken results are
|
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.
|
||||||
flagged as suspect rather than silently trusted.
|
|
||||||
|
|
||||||
## Documentation
|
## Tests
|
||||||
|
|
||||||
- [Usage reference](docs/usage.md) — CLI flags, exit codes, integrity check
|
```cmd
|
||||||
- [Design](docs/design.md) — architecture, routing, and error model
|
cargo test --release --workspace
|
||||||
- [Development](docs/development.md) — building, testing, environment variables
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
- [Web version](web/README.md) — run Senbei in a browser
|
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.
|
||||||
|
|
||||||
|
## Web Build
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
cd senbei-wasm
|
||||||
|
wasm-pack build --target web --release --out-dir ../web/pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
+31
-215
@@ -1,238 +1,54 @@
|
|||||||
# Design
|
# Design
|
||||||
|
|
||||||
Senbei is a fully static unpacker: it replays the unpacking algorithm on the
|
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.
|
||||||
file bytes in memory and writes the recovered PE image. No code from the
|
|
||||||
protected binary is ever executed, no process is launched or attached to, and
|
|
||||||
no driver or proxy DLL is involved.
|
|
||||||
|
|
||||||
## Crate layout
|
## Crate Layout
|
||||||
|
|
||||||
Senbei is a Cargo workspace split into a pure core and thin shells around it:
|
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.
|
||||||
|
|
||||||
- **`senbei-pe/`** — the core. Pure functions over byte slices: no file I/O,
|
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/`.
|
||||||
no environment access (beyond a few debugging overrides, see
|
|
||||||
[development.md](development.md)), panic-free at the public boundary (all
|
|
||||||
internal panics are trapped and converted to `UnpackError::Corrupt`). This
|
|
||||||
is what the WebAssembly build embeds.
|
|
||||||
- **`senbei-crypto/`** — cryptographic, checksum, compression, and bytecode
|
|
||||||
primitives the core is built from. Same purity rules as `senbei-pe`.
|
|
||||||
- **`senbei-metadata/`** — il2cpp `global-metadata.dat` method-token
|
|
||||||
de-obfuscation (format version 31; other versions are left untouched).
|
|
||||||
- **`senbei-android-crypto/`** — container primitives of the Android
|
|
||||||
(AArch64) protection scheme: the word/record ciphers, the GF(2³²)
|
|
||||||
transform, the AES-augmented segment transform, and the Huffman/LZ decoder.
|
|
||||||
- **`senbei-android-engine/`** — stage-1/stage-2 extraction: finds the
|
|
||||||
appended payload section, decrypts the stage-1 header and stage-2 payload,
|
|
||||||
and walks the recursive record streams to decode every module. Native-only
|
|
||||||
(memory-maps the input, writes the module set to a workspace directory).
|
|
||||||
- **`senbei-android-elf/`** — the restore: replays the decoded target-image
|
|
||||||
and fixup containers onto a hollowed ELF and rebuilds the dynamic-linker
|
|
||||||
tables (hash tables, symbols, relocations) the protector stripped.
|
|
||||||
Native-only.
|
|
||||||
- **`senbei-android-metadata/`** — the Android metadata variants: the seeded
|
|
||||||
five-round MethodDef-RID permutation restore (v31), seed discovery, and the
|
|
||||||
embedded-metadata XOR unwrap (`keystream.rs`).
|
|
||||||
- **`senbei-io/`** — filesystem and orchestration: recursive folder scanning,
|
|
||||||
per-run log file, progress bar, Explorer-friendly exit pause, the
|
|
||||||
single-file/folder orchestration in `job.rs` (incl. the wasm-safe in-memory
|
|
||||||
byte API used by the web frontend), and `android.rs` — the Android
|
|
||||||
single-library / folder / app-package orchestration.
|
|
||||||
- **`senbei-cli/`** — the `senbei` binary: argument parsing + dispatch. The
|
|
||||||
integration test suite (incl. the golden corpus test) lives in
|
|
||||||
`senbei-cli/tests/`.
|
|
||||||
|
|
||||||
```
|
```text
|
||||||
senbei-cli/
|
senbei-cli/src/main.rs
|
||||||
└── src/main.rs argument parsing + dispatch
|
|
||||||
senbei-io/src/
|
|
||||||
├── job.rs single-file + folder orchestration, out-naming,
|
|
||||||
│ companion splice, stub overlay/TLS restore,
|
|
||||||
│ pipeline routing (incl. the wasm-safe byte API)
|
|
||||||
├── android.rs Android single-library / folder / package
|
|
||||||
│ orchestration, cross-source dedup
|
|
||||||
├── scan.rs recursive target discovery (PE + metadata + Android)
|
|
||||||
├── logfile.rs per-run timestamped log
|
|
||||||
├── ui.rs progress bar + status lines
|
|
||||||
└── pause.rs Explorer-friendly exit pause
|
|
||||||
senbei-metadata/src/
|
|
||||||
└── metadata.rs il2cpp global-metadata.dat de-obfuscation
|
|
||||||
senbei-crypto/src/
|
senbei-crypto/src/
|
||||||
├── primitives.rs decrypt_data* steps, key derivation
|
senbei-crypto/src/android/
|
||||||
├── bytecode.rs bytecode VM
|
senbei-elf/src/
|
||||||
├── tables.rs constant tables
|
senbei-engine/src/windows/
|
||||||
└── crc32.rs checksum
|
senbei-engine/src/android/
|
||||||
senbei-pe/src/engine/ pure, panic-free, no-I/O core
|
senbei-io/src/
|
||||||
├── mod.rs detection + unpack_auto dispatch
|
senbei-io/src/android/
|
||||||
├── error.rs structured error taxonomy
|
senbei-metadata/src/windows/
|
||||||
├── integrity.rs static post-unpack sanity check
|
senbei-metadata/src/android/
|
||||||
├── parallel.rs deterministic block-parallel fan-out
|
senbei-pe/src/
|
||||||
├── layout/ layout discovery + validation
|
senbei-wasm/src/
|
||||||
│ ├── dd8.rs .text dd8 key-formula + shift selection
|
|
||||||
│ ├── discovery.rs layout candidate discovery (trial-and-validate)
|
|
||||||
│ └── image.rs PE image reconstruction helpers
|
|
||||||
├── exe/
|
|
||||||
│ ├── pipeline.rs EXE pipeline (PE32+ and PE32 orchestration)
|
|
||||||
│ └── pipeline/pe32.rs PE32-specific EXE restore
|
|
||||||
└── dll/
|
|
||||||
└── pipeline.rs native + managed DLL pipeline
|
|
||||||
senbei-android-crypto/src/
|
|
||||||
└── protector.rs container ciphers, GF(2^32), Huffman/LZ decoder
|
|
||||||
senbei-android-engine/src/
|
|
||||||
├── stage1.rs payload-section discovery + stage-1 header/payload
|
|
||||||
├── stream.rs record-stream parsing
|
|
||||||
├── extract.rs recursive module extraction (writes the workspace)
|
|
||||||
├── probe.rs protected-library content probe
|
|
||||||
└── report.rs machine-readable extraction report
|
|
||||||
senbei-android-elf/src/
|
|
||||||
├── restore.rs image restore + dynamic-table rebuild
|
|
||||||
├── layout.rs ELF layout parsing
|
|
||||||
├── artifact.rs module-workspace index loading
|
|
||||||
└── hash.rs SysV/GNU hash table rebuild
|
|
||||||
senbei-android-metadata/src/
|
|
||||||
├── method_tokens.rs seeded RID permutation restore + seed discovery
|
|
||||||
├── embedded.rs embedded-metadata blob locate + XOR unwrap
|
|
||||||
└── keystream.rs recovered keystream table (one observed build)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Detection and routing
|
`senbei-pe` and `senbei-elf` are format crates only. They do not depend on the unpacking engines, filesystem code, or platform protection logic.
|
||||||
|
|
||||||
Detection is content-based (`unpacker::detect`), never extension-based: the
|
## Windows Engine
|
||||||
key table is derived from the file header and checked against the format
|
|
||||||
magic, then the PE characteristics classify the input as EXE or DLL and the
|
|
||||||
CLR data directory splits each into native vs managed (`NativeExe` /
|
|
||||||
`ManagedExe` / `NativeDll` / `ManagedDll`). The folder scan additionally
|
|
||||||
classifies Android targets: an ELF64/AArch64 prefix promotes the file to a
|
|
||||||
full protection probe (`senbei_android_engine::is_protected_libil2cpp`), and a
|
|
||||||
package extension plus zip magic marks an app package for container
|
|
||||||
extraction.
|
|
||||||
|
|
||||||
`unpack_auto` then dispatches:
|
`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.
|
||||||
|
|
||||||
- `NativeExe` / `ManagedExe` → the EXE pipeline (handles both PE32+ and
|
External companion inputs are reconstructed as `stub[..4096]` followed by the matching `._` payload. The stub's export and TLS data is overlaid after unpacking because those regions are not present in the encrypted companion.
|
||||||
PE32). Managed EXEs take the same path: their import-string table is null
|
|
||||||
(imports are the CLR bootstrap stub), the entry point comes from the
|
|
||||||
protected header (the config block stores 0 for managed images), and the
|
|
||||||
COR20 header, BSJB metadata stream, and CLR resources are restored verbatim
|
|
||||||
from the protected file, mirroring the managed-DLL restore.
|
|
||||||
- `NativeDll` / `ManagedDll` → the DLL pipeline first; on failure, the EXE
|
|
||||||
pipeline as a fallback. Two DLL layouts exist in the wild: an older layout
|
|
||||||
the DLL pipeline parses, and a newer one that protects DLLs with the
|
|
||||||
EXE-style shell layout instead. The DLL-first order keeps old-layout outputs
|
|
||||||
byte-identical (the EXE pipeline also "succeeds" on old-layout DLLs but
|
|
||||||
produces different bytes); the fallback handles the new layout (including
|
|
||||||
the managed-DLL .NET metadata restore).
|
|
||||||
|
|
||||||
One routing shortcut bypasses `unpack_auto`: inputs spliced from an external
|
## Android Engine
|
||||||
companion (`job.rs`, both the CLI and the wasm byte API) go **straight to the
|
|
||||||
EXE pipeline**. The companion layout is definitionally the EXE-style shell,
|
|
||||||
so the DLL probe can never be right for it — and the probe's rejection of
|
|
||||||
EXE-shell DLLs relies on a caught panic, which is a fatal trap on targets
|
|
||||||
without unwinding (WebAssembly). Output bytes are identical to the
|
|
||||||
probe-then-fallback route.
|
|
||||||
|
|
||||||
## External-companion inputs
|
`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.
|
||||||
|
|
||||||
Some builds split a protected module into an on-disk loader stub plus an
|
Android protection primitives are in `senbei-crypto/src/android/`. Android metadata restoration is in `senbei-metadata/src/android/` and only rewrites MethodDef token fields. The Windows structural metadata transform is in `senbei-metadata/src/windows/`.
|
||||||
encrypted `._` companion. When a `<name>._` sibling matches the stub's header
|
|
||||||
region, `job.rs` splices the two before unpacking and afterwards overlays the
|
|
||||||
export table and TLS directory from the stub — pieces the encrypted companion
|
|
||||||
does not carry. All overlay steps are best-effort no-ops when their inputs
|
|
||||||
can't be mapped, so a malformed stub can never corrupt an otherwise-good
|
|
||||||
unpack.
|
|
||||||
|
|
||||||
## Pipelines
|
## Scanning and Packages
|
||||||
|
|
||||||
Both pipelines are **heuristic with trial-and-validate**: where a layout
|
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`. A Windows `.exe._` or `.dll._` companion is auxiliary input for its sibling stub and is excluded from the skipped count.
|
||||||
leaves ambiguity (e.g. which block is the real file decryptor, or a page-XOR
|
|
||||||
shift), the pipeline tries candidates and validates the result structurally
|
|
||||||
(an entry-stub oracle, checksum stamps, cluster stamps) instead of trusting
|
|
||||||
the first match. A validation failure falls through to the next candidate
|
|
||||||
rather than producing silently wrong output.
|
|
||||||
|
|
||||||
Several protected stages are themselves little bytecode programs. The core
|
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.
|
||||||
includes a small VM (`bytecode.rs`) that generates and interprets those
|
|
||||||
programs rather than hardcoding each variant's constants.
|
|
||||||
|
|
||||||
## The Android pipeline
|
## Validation
|
||||||
|
|
||||||
The Android scheme hollows an ELF64/AArch64 shared object: section bodies are
|
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.
|
||||||
zeroed in the file and the original bytes move into an encrypted payload
|
|
||||||
appended as a `SHT_LOUSER` section (invisible to the dynamic loader). Restore
|
|
||||||
is two-phase:
|
|
||||||
|
|
||||||
1. **Extract** (`senbei-android-engine`): decrypt the stage-1 parameter block
|
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.
|
||||||
and stage-2 payload from the payload section, then walk the recursive
|
|
||||||
record streams — each decoded module may interpret a further nested stream
|
|
||||||
— into a temporary module workspace with a JSON index.
|
|
||||||
2. **Restore** (`senbei-android-elf`): decode the target-image container onto
|
|
||||||
a copy of the hollowed file, apply the compact fixup database (the
|
|
||||||
relocations stripped from `.rela.dyn`), and rebuild the dynamic-linker
|
|
||||||
tables the loader needs (SysV/GNU hash, symbol and string tables,
|
|
||||||
`.rela.dyn`/`.rela.plt`). Validation is structural and total: mismatched
|
|
||||||
container sizes, descriptor bounds, or a rebuilt table overhanging its
|
|
||||||
section fail the restore rather than emit a broken image.
|
|
||||||
|
|
||||||
il2cpp metadata comes in three shapes, all routed through
|
## WebAssembly
|
||||||
`job::deobfuscate_metadata_to` / `android::restore_metadata_bytes`:
|
|
||||||
|
|
||||||
- **structural (Windows `-GMD`)**: sparse method tokens remapped to the
|
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.
|
||||||
contiguous per-module range, keyless, idempotent (`senbei-metadata`).
|
|
||||||
- **seeded permutation (Android v31)**: MethodDef RIDs permuted by a keyed
|
|
||||||
five-round transform; the seed is recovered by intersecting per-image key
|
|
||||||
residues, and the restore validates every RID — a wrong seed errors and the
|
|
||||||
structural remap takes over (`senbei-android-metadata`).
|
|
||||||
- **embedded blob**: no metadata file in the app at all; a slim blob sits in
|
|
||||||
the library's data section under a per-word XOR layer. After a restore the
|
|
||||||
blob is located by content (two known plaintext header words against the
|
|
||||||
embedded keystream) and unwrapped to a standalone `global-metadata.dat`.
|
|
||||||
Key derivation is untraced — the shipped keystream covers the one observed
|
|
||||||
build, and other builds simply never match the probe.
|
|
||||||
|
|
||||||
Packages (`.apk`/`.apks`/`.xapk`) are containers, not targets: entries are
|
|
||||||
extracted to a temporary workspace and content-probed like loose files.
|
|
||||||
Cross-source duplicates (a library loose in the tree *and* inside its
|
|
||||||
package) are restored once, preferring the loose file, then the `.apk`, then
|
|
||||||
bundle splits.
|
|
||||||
|
|
||||||
## Integrity check
|
|
||||||
|
|
||||||
Every produced image passes through `integrity::check` — a static, execution-
|
|
||||||
free sanity check that only flags defects impossible in a correctly unpacked
|
|
||||||
image (malformed headers, unmapped/non-executable/all-zero/all-int3 entry
|
|
||||||
point, a native DLL with no base-relocation directory, any import descriptor
|
|
||||||
whose DLL name is still ciphertext, a managed image whose COR20 header or BSJB
|
|
||||||
metadata did not survive). See [usage.md](usage.md#integrity-check).
|
|
||||||
A clean report is not a proof of correctness; a non-clean report is a reliable
|
|
||||||
"broken" signal.
|
|
||||||
|
|
||||||
## Parallelism
|
|
||||||
|
|
||||||
Section decrypt/decompress blocks write disjoint output spans and read only
|
|
||||||
immutable input plus snapshotted key tables, so `parallel.rs` fans them out
|
|
||||||
across worker threads with **byte-identical** output regardless of thread
|
|
||||||
count. There is no `unsafe`: the buffer is carved with safe `split_at_mut`
|
|
||||||
chains so the borrow checker proves spans never alias. Overlapping spans (only
|
|
||||||
possible on corrupt input) degrade to the sequential whole-buffer pass,
|
|
||||||
preserving the deterministic last-writer-wins behavior of the serial
|
|
||||||
pipeline. `SENBEI_THREADS=1` forces the sequential path; on targets without
|
|
||||||
threads (WebAssembly) the sequential path is used automatically.
|
|
||||||
|
|
||||||
## Error model
|
|
||||||
|
|
||||||
The public API never panics: every pipeline runs under a `catch_unwind`
|
|
||||||
wrapper (`catch_unpack`) that converts a trapped panic to
|
|
||||||
`UnpackError::Corrupt`, with the default panic hook transiently suppressed.
|
|
||||||
Size requests are bounds-checked against a 1 GiB `MAX_IMAGE_SIZE` before
|
|
||||||
allocation so a crafted header cannot abort the process with a huge
|
|
||||||
allocation. In folder mode each file is isolated: one file's failure is logged
|
|
||||||
and counted, never fatal to the run.
|
|
||||||
|
|
||||||
**WebAssembly caveat:** the prebuilt wasm std cannot unwind, so a caught
|
|
||||||
panic becomes a fatal `unreachable` trap there. The DLL-routing probe relies
|
|
||||||
on this mechanism to reject EXE-shell-layout DLLs, so the web build routes
|
|
||||||
around it instead of through it: spliced companion inputs skip the probe
|
|
||||||
entirely (see "Detection and routing"), and the web app isolates every unpack
|
|
||||||
in a disposable Web Worker — a trapped DLL is retried once in a fresh worker
|
|
||||||
with the forced-EXE pipeline (`job::unpack_bytes_force_exe`), reproducing the
|
|
||||||
probe-then-fallback outcome without a catchable panic. A trap on any other
|
|
||||||
input is reported as a clean error rather than freezing the page.
|
|
||||||
|
|||||||
+34
-99
@@ -2,125 +2,60 @@
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires a Rust toolchain (MSVC backend is the default on Windows;
|
The pinned Rust toolchain is defined in `rust-toolchain.toml`. Build the CLI with `cargo build --release`; the binary is written to `target/release/senbei.exe` on Windows.
|
||||||
`rustup-init.exe` from <https://rustup.rs> installs it). The pinned toolchain
|
|
||||||
and targets are in `rust-toolchain.toml`.
|
|
||||||
|
|
||||||
```cmd
|
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`.
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
Output: `target\release\senbei.exe`. The binary is self-contained — no driver,
|
|
||||||
no proxy DLL, no external assets.
|
|
||||||
|
|
||||||
The library and CLI also build for Linux/macOS (`cfg`-gated platform code
|
|
||||||
only) and for `wasm32-unknown-unknown` (see the [web version](../web/README.md)).
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
cargo test --release
|
cargo test --release --workspace
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo fmt --all -- --check
|
||||||
```
|
```
|
||||||
|
|
||||||
The suite covers CLI behavior, detection, the folder driver, the run log, and
|
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.
|
||||||
byte-exact golden tests over `samples/` — a user-managed corpus (git-ignored,
|
|
||||||
see `samples/README.md`) of real Crackproof inputs plus `<base>.golden.<ext>`
|
|
||||||
reference outputs. Every input goes through `job::unpack_bytes` — the same
|
|
||||||
routing the CLI uses, so an `<input>._` companion in the corpus is spliced and
|
|
||||||
the stub export/TLS overlays run — and is gated on **two** checks: the static
|
|
||||||
integrity check (catches runtime-broken outputs even when a stale golden would
|
|
||||||
still byte-match) and, when a golden exists, a bit-for-bit comparison. il2cpp
|
|
||||||
`*.dat` inputs are routed through `metadata::deobfuscate` instead. An empty or
|
|
||||||
absent corpus is a no-op pass; set `SENBEI_REQUIRE_SAMPLES` to make it fail
|
|
||||||
instead (useful on a private CI that has the corpus — public CI never does,
|
|
||||||
since binaries are not committed).
|
|
||||||
|
|
||||||
> **Note:** goldens encode expected *bytes*, not runtime behavior. A golden
|
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.
|
||||||
> produced before a pipeline fix may byte-match while still being wrong — the
|
|
||||||
> integrity check is the second gate for exactly this reason. Re-verify
|
|
||||||
> goldens against real runs when touching the affected pipeline stages.
|
|
||||||
>
|
|
||||||
> **The corpus only protects what it contains.** Wire the test to the routing
|
|
||||||
> the CLI actually takes (it is), and keep a sample for every layout family —
|
|
||||||
> marker-based, marker-less, external-companion, PE32, PE32+, native, managed,
|
|
||||||
> metadata. An unrepresented family has no regression gate at all, which is
|
|
||||||
> how a "re-run the golden corpus" rule can pass while silently covering
|
|
||||||
> nothing.
|
|
||||||
|
|
||||||
## Debugging levers (environment variables)
|
## Environment Variables
|
||||||
|
|
||||||
- `DD8_SHIFT` — override the `decrypt_data8` page-XOR shift (`99` skips dd8
|
- `DD8_SHIFT` overrides the PE page-XOR shift; `99` skips that stage.
|
||||||
entirely).
|
- `SEL_DIAG` prints PE layout-selector diagnostics.
|
||||||
- `SEL_DIAG` — print the dd8 selector's scores: the per-shift `0xCC` counts and
|
- `SENBEI_THREADS` caps deterministic block fan-out; `1` forces the sequential reference path.
|
||||||
the plaintext baseline they are compared against (PE32+), and the per-formula
|
- `SENBEI_SCAN_ALL` enables the explicit scan-all mode for selected target names.
|
||||||
counts, baseline and net gain (PE32).
|
- `SENBEI_ANDROID_SAMPLES` overrides the Android sample corpus location.
|
||||||
- `SENBEI_THREADS` — cap the block-parallel fan-out (`1` forces the fully
|
|
||||||
sequential path).
|
|
||||||
- `SENBEI_SCAN_ALL` — same as `--scan-all` (probe every file in a folder).
|
|
||||||
- `SENBEI_ANDROID_SAMPLES` — override the Android corpus location (default
|
|
||||||
`samples/android/`; see `samples/README.md`). The Android corpus test pins
|
|
||||||
restored outputs with SHA-256 sidecar files next to each protected input
|
|
||||||
and documents known restore gaps with empty `<base>.restore-fails` markers.
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- The `senbei-pe/` core (and its `senbei-crypto/` base) is pure: no file I/O,
|
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/`.
|
||||||
no panics across the public boundary, no `unsafe`. Keep it that way — it is
|
|
||||||
what the WebAssembly build embeds.
|
|
||||||
- Layout heuristics must **trial-and-validate**: never pick a candidate offset
|
|
||||||
on shape alone and trust it; validate by decryption/checksum and fall
|
|
||||||
through to the next candidate on failure. A silent wrong offset produces a
|
|
||||||
silently broken output, which is worse than an error.
|
|
||||||
- Output must remain byte-identical against the golden corpus for every
|
|
||||||
supported layout. When fixing one build family, re-run the full golden
|
|
||||||
corpus to prove no other family regressed.
|
|
||||||
- Folder scanning uses a size floor plus an extension **deny**-list, never an
|
|
||||||
allow-list: targets are recognised by content, not extension, and can carry
|
|
||||||
arbitrary names, so only known bulk-asset extensions are excluded. The
|
|
||||||
pre-filter exists because folder-scan cost is per-file I/O latency, not the
|
|
||||||
walk — probe fewer files, don't parallelize the probe loop.
|
|
||||||
- `cargo fmt` and `cargo clippy` must stay clean (CI enforces both).
|
|
||||||
|
|
||||||
## Repository layout
|
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.
|
||||||
senbei/
|
|
||||||
├── Cargo.toml workspace root (members: the senbei-* crates)
|
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.
|
||||||
├── rust-toolchain.toml pinned toolchain + targets
|
|
||||||
├── senbei-cli/ senbei binary (default member)
|
## Repository Layout
|
||||||
│ └── tests/ CLI, detection, golden, and folder tests
|
|
||||||
├── senbei-pe/ pure unpacker core (see docs/design.md)
|
```text
|
||||||
├── senbei-crypto/ crypto/compression primitives
|
senbei-cli/ command-line binary and integration tests
|
||||||
├── senbei-metadata/ il2cpp metadata de-obfuscation
|
senbei-crypto/ shared crypto and Android crypto primitives
|
||||||
├── senbei-io/ filesystem, scanning, CLI orchestration
|
senbei-elf/ basic ELF parsing
|
||||||
├── senbei-wasm/ WebAssembly bindings crate (own Cargo.lock,
|
senbei-engine/ Windows and Android unpacking engines
|
||||||
│ outside the workspace; builds into web/pkg/)
|
senbei-io/ filesystem, package, scanning, and CLI orchestration
|
||||||
├── samples/ local-only test corpus (git-ignored)
|
senbei-metadata/ Windows and Android metadata restoration
|
||||||
├── web/ static browser frontend assets (+ built pkg/)
|
senbei-pe/ basic PE parsing
|
||||||
├── docs/ usage, design, and development documentation
|
senbei-wasm/ browser bindings and its own lockfile
|
||||||
└── .github/ CI workflows and issue templates
|
web/ static browser frontend
|
||||||
|
samples/ optional local corpus
|
||||||
```
|
```
|
||||||
|
|
||||||
## Web build
|
## Web Build
|
||||||
|
|
||||||
See [web/README.md](../web/README.md). In short:
|
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
cd senbei-wasm
|
cd senbei-wasm
|
||||||
wasm-pack build --target web --release --out-dir ../web/pkg
|
wasm-pack build --target web --release --out-dir ../web/pkg
|
||||||
```
|
```
|
||||||
|
|
||||||
then serve `web/` statically and open `index.html`. Everything runs
|
Serve `web/` with a static HTTP server after the build. The browser never uploads input files.
|
||||||
client-side; no file leaves the browser.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Issues and pull requests are welcome. A few ground rules:
|
|
||||||
|
|
||||||
- **Never commit binaries** (protected or decrypted) to the repository —
|
|
||||||
the only corpus is the local git-ignored `samples/`. Attaching a protected
|
|
||||||
input file to an issue is welcome if it helps diagnose the problem; only
|
|
||||||
attach files you are authorized to share.
|
|
||||||
- Run `cargo test --release`, `cargo clippy`, and `cargo fmt` before
|
|
||||||
submitting.
|
|
||||||
- Keep the unpacker core free of I/O, `unsafe`, and platform-specific code.
|
|
||||||
|
|||||||
+25
-133
@@ -1,163 +1,55 @@
|
|||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
```
|
```text
|
||||||
senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all]
|
senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]
|
||||||
[--no-log] [--no-pause] [-V|--version] [-h|--help]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Real runs print `Senbei <version>` once at start. Use `-V` / `--version` to
|
## Single File
|
||||||
print the version and exit.
|
|
||||||
|
|
||||||
## Single file
|
The output is written below `<parent>/unpack/` with `.unpack` inserted before the extension. `--out DIR` changes both the output and log directory.
|
||||||
|
|
||||||
The decrypted image is written under `<parent>/unpack/` with `.unpack` inserted
|
|
||||||
before the extension. A `senbei-<timestamp>.log` is written in the same
|
|
||||||
directory. With `--out DIR`, both the output and the log go into `DIR` instead:
|
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
senbei app.exe
|
senbei app.exe
|
||||||
:: -> unpack\app.unpack.exe
|
|
||||||
:: -> unpack\senbei-YYYYMMDD-HHMMSS.log
|
|
||||||
|
|
||||||
senbei app.exe --out C:\out
|
senbei app.exe --out C:\out
|
||||||
:: -> C:\out\app.unpack.exe
|
|
||||||
:: -> C:\out\senbei-YYYYMMDD-HHMMSS.log
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Pointing senbei directly at an il2cpp `global-metadata.dat` rewrites its
|
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.
|
||||||
obfuscated method tokens back to the contiguous per-module range il2cpp
|
|
||||||
expects; the output is `global-metadata.unpack.dat`, written only when tokens
|
|
||||||
actually changed. Only metadata format version 31 is rewritten; other versions
|
|
||||||
are reported and left untouched.
|
|
||||||
|
|
||||||
## Android targets
|
## Android Targets
|
||||||
|
|
||||||
Senbei also restores Android (AArch64) protected shared libraries and app
|
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.
|
||||||
packages:
|
|
||||||
|
|
||||||
- **`.so`** — a protected library is hollowed out on disk: its original
|
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.
|
||||||
sections live in an encrypted payload appended to the file, and senbei
|
|
||||||
rebuilds the static image from it. Output: `libil2cpp.unpack.so`.
|
|
||||||
- **`.apk`** — entries are extracted to a temporary workspace and
|
|
||||||
content-probed like loose files; protected libraries and metadata blobs
|
|
||||||
inside are restored to `<out>/<apk name>/<entry path>`.
|
|
||||||
- **`.apks` / `.xapk`** — split-package bundles; each nested `.apk` is opened
|
|
||||||
and searched the same way, under `<out>/<bundle name>/<split name>/...`.
|
|
||||||
|
|
||||||
When a restored il2cpp library carries its metadata embedded in its data
|
## Folder Mode
|
||||||
section (no standalone `global-metadata.dat` in the app at all), senbei
|
|
||||||
unwraps the blob and writes it next to the library as
|
|
||||||
`global-metadata.unpack.dat`. One observed packaging variant wraps the blob in
|
|
||||||
a per-word XOR layer whose keys are generated at runtime and stored nowhere;
|
|
||||||
senbei ships the keystream recovered from the one build known to use it and
|
|
||||||
content-probes for it — builds with a different keystream are silently
|
|
||||||
skipped (the library itself is still fully restored).
|
|
||||||
|
|
||||||
The same content may appear loose in a folder, in its `.apk`, and in a bundle
|
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.
|
||||||
side by side: identical content is restored once, at the loose file's
|
|
||||||
destination. A restored library is validated structurally by the restore
|
|
||||||
itself (the rebuild refuses inconsistent layouts); a protected library that
|
|
||||||
fails validation counts as an error, not a suspect.
|
|
||||||
|
|
||||||
## Folder mode
|
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.
|
||||||
|
|
||||||
Senbei walks the directory recursively, skips any subdirectory literally named
|
## Integrity Check
|
||||||
`unpack`, and unpacks every file it recognises as protected (by content, not
|
|
||||||
extension — renamed files and `.bak` backups are still found; packages are the
|
|
||||||
one exception, recognised by extension plus the zip magic because they are
|
|
||||||
containers). Results land under `<root>/unpack/` (or `--out DIR`), mirroring
|
|
||||||
the input tree's relative paths. The run log is written **in that same out
|
|
||||||
directory**:
|
|
||||||
|
|
||||||
```cmd
|
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.
|
||||||
senbei "C:\Games\MyGame"
|
|
||||||
:: -> C:\Games\MyGame\unpack\...
|
|
||||||
:: -> C:\Games\MyGame\unpack\senbei-YYYYMMDD-HHMMSS.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Folder mode also picks up `global-metadata.dat` files and external-companion
|
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.
|
||||||
`._` payloads: a module whose `<name>._` sibling matches its header region is
|
|
||||||
spliced with the companion automatically (no flag needed) and unpacked as one
|
|
||||||
image, with the output named for the stub.
|
|
||||||
|
|
||||||
Each file is processed in isolation: an error or panic on one file is caught,
|
|
||||||
counted, and logged, and the run continues. Folder mode finishes with a summary
|
|
||||||
line, then duration:
|
|
||||||
|
|
||||||
```
|
|
||||||
12 unpacked · 3 skipped · 0 errors · 1 suspect · 2 metadata
|
|
||||||
done in 1234 ms
|
|
||||||
```
|
|
||||||
|
|
||||||
The `packages` count appears (as `· N packages`) only when Android app
|
|
||||||
packages were processed.
|
|
||||||
|
|
||||||
## Integrity check
|
|
||||||
|
|
||||||
(PE outputs only — Android restores carry their own structural validation; see
|
|
||||||
[Android targets](#android-targets).)
|
|
||||||
|
|
||||||
A successful unpack is not always a runnable one: a layout heuristic can pick
|
|
||||||
the wrong offset and leave the entry-point stub or import strings encrypted, so
|
|
||||||
the pipeline reports success but the OS loader faults at runtime (typically
|
|
||||||
`0xC0000005`, STATUS_ACCESS_VIOLATION). To catch this, senbei runs a static
|
|
||||||
sanity check over every output it produces — inspecting the bytes alone, with
|
|
||||||
no reference image and no execution.
|
|
||||||
|
|
||||||
It flags only defects that cannot occur in a correctly unpacked image:
|
|
||||||
|
|
||||||
- malformed DOS/PE headers, bad optional-header magic, implausible section
|
|
||||||
count, zero `SizeOfImage`, or section raw-data ranges that run past EOF;
|
|
||||||
- an entry point that doesn't map into a section, isn't in an executable
|
|
||||||
section, or whose stub is all zeros or all `0xCC` int3 padding (the classic
|
|
||||||
left-encrypted symptom);
|
|
||||||
- a native (unmanaged) DLL with no base-relocation directory — it cannot
|
|
||||||
survive being mapped at a non-preferred base;
|
|
||||||
- **any** import descriptor whose DLL name doesn't resolve or isn't readable
|
|
||||||
ASCII (imports left encrypted) — the whole table is walked, not just the
|
|
||||||
first entry;
|
|
||||||
- for a managed assembly, a COR20 header whose `cb` isn't `0x48` or a
|
|
||||||
MetaData stream missing its `BSJB` signature (the CLR would reject the
|
|
||||||
image outright).
|
|
||||||
|
|
||||||
The entry-point and import checks are skipped for managed assemblies, whose
|
|
||||||
native EP and import stub are legitimately not what the native loader expects.
|
|
||||||
|
|
||||||
The check is deliberately conservative: a clean report is **not** a proof of
|
|
||||||
correctness, but a non-clean report is a reliable "this is broken" signal. A
|
|
||||||
suspect file is still written (the bytes are the best available) and flagged —
|
|
||||||
single-file mode prints a warning to stderr, folder mode prints a yellow `!`
|
|
||||||
line, adds a `SUSPECT` entry to the run log, and counts it in the summary's
|
|
||||||
`suspect` total (which is additive to `unpacked`).
|
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
| Flag | Behavior |
|
| Flag | Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `--out DIR` | Write outputs (and the log, unless `--no-log`) under `DIR`. |
|
| `--out DIR` | Write outputs and logs below `DIR`. |
|
||||||
| `-v`, `--verbose` | Print detailed per-stage progress (and the destination path) for each file — `[N/9]` stages for PE targets, container/segment lines for Android libraries. In folder mode this replaces the progress bar. |
|
| `-v`, `--verbose` | Print per-stage progress. |
|
||||||
| `-q`, `--quiet` | Once: hide progress bar and per-file lines; keep banner, summary, and duration. Twice (`-q -q`): suppress all stdio (exit code only). |
|
| `-q`, `--quiet` | Hide progress and per-file lines; repeat to suppress all standard output. |
|
||||||
| `--no-log` | Do not write `senbei-*.log`. Console output is unchanged by this flag alone. |
|
| `--no-log` | Do not write a run log. |
|
||||||
| `--scan-all` | Probe every file in a folder, including ones the scan pre-filter skips (under 4128 bytes, or a bulk-asset extension like `.ab`/`.xml`/`.acb`). Much slower on large game trees; finds the same targets in practice. |
|
| `--scan-all` | Probe every selected target-name candidate, including files below the size floor. |
|
||||||
| `--no-pause` | Skip the "Press Enter to exit" prompt (for scripted runs). |
|
| `--no-pause` | Disable the Explorer-friendly Windows exit prompt. |
|
||||||
| `-V`, `--version` | Print `Senbei <version>` and exit. |
|
| `-V`, `--version` | Print the version and exit. |
|
||||||
| `-h`, `--help` | Show usage. |
|
| `-h`, `--help` | Show usage. |
|
||||||
|
|
||||||
On Windows, when launched from Explorer (the process owns its console) senbei
|
## Exit Codes
|
||||||
pauses for Enter before exiting so the window doesn't vanish. `--no-pause`
|
|
||||||
disables this; it has no effect when stdout is piped or run from another
|
|
||||||
process.
|
|
||||||
|
|
||||||
## Exit codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `0` | Success (single file restored, or folder run with no errors). |
|
| `0` | The requested restore completed without errors. |
|
||||||
| `1` | At least one file failed, a scan probe was unreadable, or a single-file unpack errored. |
|
| `1` | A target failed, a scan probe was unreadable, or a single-file restore errored. |
|
||||||
| `2` | Usage error: no path given, unknown option, missing `--out` value, or multiple input paths (help printed). |
|
| `2` | The command line was invalid. |
|
||||||
|
|
||||||
A folder run also fails with `1` when parts of the tree could not be scanned
|
|
||||||
(unreadable directory entries or files that failed the content probe) — those
|
|
||||||
are potential missed targets, not clean skips. An il2cpp metadata blob whose
|
|
||||||
format version senbei does not handle is *not* an error: it is reported, left
|
|
||||||
untouched, and counted as skipped.
|
|
||||||
|
|||||||
+15
-97
@@ -1,105 +1,23 @@
|
|||||||
# senbei/samples
|
# Samples
|
||||||
|
|
||||||
Drop-in corpus for the `samples` integration test (`tests/samples.rs`).
|
This ignored directory is the optional local corpus used by the samples integration test. Protected binaries and restored outputs must never be committed.
|
||||||
|
|
||||||
This folder is **git-ignored** (only this `README.md` is tracked), so it holds
|
Place protected `.exe` and `.dll` files, exact `global-metadata.dat` files, and matching `.exe._` or `.dll._` companion payloads here. A golden output may sit beside an input as `<base>.golden.<ext>`.
|
||||||
whatever Crackproof binaries happen to be on your machine. Nothing here is
|
|
||||||
committed.
|
|
||||||
|
|
||||||
## What to put here
|
```text
|
||||||
|
|
||||||
Place protected inputs directly in this folder:
|
|
||||||
|
|
||||||
- `*.exe` — Crackproof-protected executables (PE32 or PE32+)
|
|
||||||
- `*.dll` — Crackproof-protected DLLs (native or managed)
|
|
||||||
- `*.dat` — il2cpp `global-metadata.dat` blobs (method-token de-obfuscation)
|
|
||||||
|
|
||||||
For an **external-companion** module, copy the `<name>._` payload in as well,
|
|
||||||
keeping the exact `._` suffix on the full file name. The test splices it the
|
|
||||||
same way the CLI does; without it the loader stub alone is meaningless and the
|
|
||||||
splice / export-overlay / TLS-restore code is never exercised.
|
|
||||||
|
|
||||||
Optionally, place a **golden** next to each input — the known-good unpacked
|
|
||||||
output, named `<base>.golden.<ext>`:
|
|
||||||
|
|
||||||
```
|
|
||||||
samples/
|
samples/
|
||||||
app.exe <- input
|
app.exe
|
||||||
app.golden.exe <- golden (optional)
|
app.golden.exe
|
||||||
managed.dll <- input
|
managed.dll
|
||||||
managed.golden.dll <- golden (optional)
|
stub.dll
|
||||||
stub.dll <- input (external-companion layout)
|
stub.dll._
|
||||||
stub.dll._ <- its encrypted payload (NOT an input itself)
|
stub.golden.dll
|
||||||
stub.golden.dll <- golden
|
global-metadata.dat
|
||||||
global-metadata.dat <- input
|
global-metadata.golden.dat
|
||||||
global-metadata.golden.dat<- golden
|
|
||||||
mystery.exe <- input, no golden
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The type (EXE vs native/managed DLL vs metadata) is auto-detected from the file
|
Run `cargo test --release --test samples -- --nocapture`. A missing golden prints a warning; a mismatched golden or failed restore fails the test. An empty corpus is a no-op pass.
|
||||||
contents, not the extension, so you don't need to classify anything by hand.
|
|
||||||
|
|
||||||
Since the corpus is the only regression gate on byte-identical output, keep it
|
## Android Corpus
|
||||||
broad: each build family, each layout (marker-based and marker-less), and at
|
|
||||||
least one external-companion pair. A family with no sample here is a family no
|
|
||||||
test protects.
|
|
||||||
|
|
||||||
## How the test treats each input
|
`samples/android/` may contain one extracted app tree per subdirectory. Protected libraries are restored through the real pipeline and can carry SHA-256 sidecars named `<base>.golden.so.sha256` and `<base>.golden.metadata.sha256`. An empty `<base>.restore-fails` marker documents a known restore gap.
|
||||||
|
|
||||||
Run with:
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo test --release --test samples
|
|
||||||
```
|
|
||||||
|
|
||||||
For every input file, the test runs the same routing the CLI uses
|
|
||||||
(`job::unpack_bytes`, so companions splice and the stub overlays run) — or
|
|
||||||
`metadata::deobfuscate` for an il2cpp blob — and then:
|
|
||||||
|
|
||||||
| Situation | Result |
|
|
||||||
| ------------------------------------------- | ------------------------------- |
|
|
||||||
| Golden present, bytes **identical** | **pass** |
|
|
||||||
| Golden present, bytes **differ** | **fail** (test fails) |
|
|
||||||
| **No golden** found | **warning** (needs manual check)|
|
|
||||||
| Unpack errored / file unreadable | **fail** |
|
|
||||||
|
|
||||||
Warnings are printed but do not fail the test — they flag outputs you should
|
|
||||||
eyeball or promote to a golden once verified. Failures fail the test. An empty
|
|
||||||
or absent folder is a no-op pass.
|
|
||||||
|
|
||||||
To see the per-file warning/pass/fail summary, run with output shown:
|
|
||||||
|
|
||||||
```
|
|
||||||
cargo test --release --test samples -- --nocapture
|
|
||||||
```
|
|
||||||
|
|
||||||
## Naming rules
|
|
||||||
|
|
||||||
- An **input** is any `*.exe` / `*.dll` / `*.dat` whose name does **not**
|
|
||||||
contain the `.golden.` segment.
|
|
||||||
- A **golden** is `<base>.golden.<ext>` sitting next to its input. Files with
|
|
||||||
`.golden.` in the name are never treated as inputs.
|
|
||||||
- A **companion** is `<input file name>._` (e.g. `stub.dll._` for `stub.dll`).
|
|
||||||
Its extension is `_`, so it is never picked up as an input of its own; it is
|
|
||||||
read only when its base module is processed.
|
|
||||||
|
|
||||||
## Android corpus (`samples/android/`)
|
|
||||||
|
|
||||||
The `android/` subfolder holds Android samples, one **extracted app tree** per
|
|
||||||
subdirectory (the layout an APK unpacks to: `lib/<abi>/*.so`,
|
|
||||||
`assets/.../global-metadata.dat`, ...). The test
|
|
||||||
(`tests/android_samples.rs`) finds protected AArch64 libraries by content and
|
|
||||||
restores them through the real pipeline. `SENBEI_ANDROID_SAMPLES` overrides
|
|
||||||
the corpus location.
|
|
||||||
|
|
||||||
Sidecar conventions (all next to the protected `.so` input):
|
|
||||||
|
|
||||||
| File | Meaning |
|
|
||||||
| ---- | ------- |
|
|
||||||
| `<base>.golden.so.sha256` | Expected SHA-256 of the restored library |
|
|
||||||
| `<base>.golden.metadata.sha256` | Expected SHA-256 of the unwrapped embedded metadata blob (when the library carries one) |
|
|
||||||
| `<base>.restore-fails` | Empty marker: this input's restore is a known gap and *must* fail (a future fix fails the test, prompting marker removal) |
|
|
||||||
|
|
||||||
A missing sidecar is a warning (with the computed digest printed, ready to
|
|
||||||
promote), never a failure. App packages (`.apk`/`.apks`/`.xapk`) dropped into
|
|
||||||
a tree are exercised by folder mode as containers.
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
//! Static restoration of the current protected AArch64 `libil2cpp.so`.
|
|
||||||
|
|
||||||
mod artifact;
|
|
||||||
mod error;
|
|
||||||
mod hash;
|
|
||||||
mod layout;
|
|
||||||
mod restore;
|
|
||||||
|
|
||||||
pub use error::Error;
|
|
||||||
pub use restore::{RestoreOptions, RestoreReport, restore_libil2cpp};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "senbei-android-engine"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
description = "Static Stage 1 and Stage 2 extraction for Senbei Android"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
goblin.workspace = true
|
|
||||||
memmap2.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
sha2.workspace = true
|
|
||||||
tempfile.workspace = true
|
|
||||||
thiserror.workspace = true
|
|
||||||
senbei-android-crypto.workspace = true
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "senbei-android-metadata"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
rust-version.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
description = "IL2CPP metadata restoration for Senbei Android"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde.workspace = true
|
|
||||||
thiserror.workspace = true
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
@@ -13,6 +13,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
senbei-io.workspace = true
|
senbei-io.workspace = true
|
||||||
|
senbei-engine.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
senbei-io.workspace = true
|
senbei-io.workspace = true
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ license.workspace = true
|
|||||||
description = "Cryptographic and compression primitives for Senbei"
|
description = "Cryptographic and compression primitives for Senbei"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aes.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Cryptographic and container primitives used by Senbei Android.
|
//! Android container cryptography and decoding primitives.
|
||||||
|
|
||||||
mod protector;
|
mod protector;
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ pub struct HuffmanLzDecoder {
|
|||||||
impl HuffmanLzDecoder {
|
impl HuffmanLzDecoder {
|
||||||
/// Build the full 16-bit prefix lookup used by the static decoder.
|
/// Build the full 16-bit prefix lookup used by the static decoder.
|
||||||
pub fn new(tree: &[u8]) -> Result<Self> {
|
pub fn new(tree: &[u8]) -> Result<Self> {
|
||||||
if tree.len() < 256 * 3 || tree.len() % 3 != 0 {
|
if tree.len() < 256 * 3 || !tree.len().is_multiple_of(3) {
|
||||||
return invalid(format!("invalid Huffman tree size 0x{:x}", tree.len()));
|
return invalid(format!("invalid Huffman tree size 0x{:x}", tree.len()));
|
||||||
}
|
}
|
||||||
let mut result = Self {
|
let mut result = Self {
|
||||||
@@ -542,6 +542,7 @@ impl HuffmanLzDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply the native word transform and optional AES-256-CBC decryption.
|
/// Apply the native word transform and optional AES-256-CBC decryption.
|
||||||
|
#[allow(clippy::chunks_exact_to_as_chunks)]
|
||||||
pub fn transform_segment(
|
pub fn transform_segment(
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
seed: u32,
|
seed: u32,
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Cryptographic, checksum, compression, and bytecode primitives.
|
//! Cryptographic, checksum, compression, and bytecode primitives.
|
||||||
|
|
||||||
|
pub mod android;
|
||||||
pub mod bytecode;
|
pub mod bytecode;
|
||||||
pub mod crc32;
|
pub mod crc32;
|
||||||
pub mod primitives;
|
pub mod primitives;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "senbei-android-crypto"
|
name = "senbei-elf"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Protector container primitives for Senbei Android"
|
description = "ELF format parsing and structural utilities for Senbei"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aes.workspace = true
|
goblin.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! Basic ELF format parsing shared by the unpacking engine.
|
||||||
|
|
||||||
|
use goblin::elf::{Elf, header::EM_AARCH64, program_header::PT_LOAD};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("ELF parse failed: {0}")]
|
||||||
|
Parse(#[from] goblin::error::Error),
|
||||||
|
#[error("input is not an ELF64 little-endian image")]
|
||||||
|
NotElf64,
|
||||||
|
#[error("input is not an AArch64 image")]
|
||||||
|
NotAarch64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// Parse an ELF64 little-endian image.
|
||||||
|
pub fn parse(data: &[u8]) -> Result<Elf<'_>> {
|
||||||
|
let elf = Elf::parse(data)?;
|
||||||
|
if elf.header.e_ident[4] != 2 || elf.header.e_ident[5] != 1 {
|
||||||
|
return Err(Error::NotElf64);
|
||||||
|
}
|
||||||
|
Ok(elf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return true when `data` starts with a valid AArch64 ELF64 image.
|
||||||
|
pub fn is_aarch64(data: &[u8]) -> bool {
|
||||||
|
parse(data)
|
||||||
|
.map(|elf| elf.header.e_machine == EM_AARCH64)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the maximum file end among PT_LOAD segments.
|
||||||
|
pub fn load_file_end(data: &[u8]) -> Result<u64> {
|
||||||
|
let elf = parse(data)?;
|
||||||
|
Ok(elf
|
||||||
|
.program_headers
|
||||||
|
.iter()
|
||||||
|
.filter(|ph| ph.p_type == PT_LOAD)
|
||||||
|
.map(|ph| ph.p_offset.saturating_add(ph.p_filesz))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_elf() {
|
||||||
|
assert!(matches!(parse(b"not elf"), Err(Error::Parse(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "senbei-android-elf"
|
name = "senbei-engine"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "AArch64 ELF restoration for Senbei Android"
|
description = "Platform unpacking engines for Senbei"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
goblin.workspace = true
|
||||||
memmap2.workspace = true
|
memmap2.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
senbei-android-crypto.workspace = true
|
senbei-crypto.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -19,7 +19,7 @@ pub enum Error {
|
|||||||
#[error("serialize extraction index: {0}")]
|
#[error("serialize extraction index: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
#[error("embedded Stage 2 decoder configuration: {0}")]
|
#[error("embedded Stage 2 decoder configuration: {0}")]
|
||||||
EmbeddedConfig(#[source] senbei_android_crypto::Error),
|
EmbeddedConfig(#[source] senbei_crypto::android::Error),
|
||||||
#[error(
|
#[error(
|
||||||
"depth {depth} stream 0x{stream_id:02X} interpreter 0x{interpreter_id:02X} configuration: {source}"
|
"depth {depth} stream 0x{stream_id:02X} interpreter 0x{interpreter_id:02X} configuration: {source}"
|
||||||
)]
|
)]
|
||||||
@@ -28,7 +28,7 @@ pub enum Error {
|
|||||||
stream_id: u32,
|
stream_id: u32,
|
||||||
interpreter_id: u32,
|
interpreter_id: u32,
|
||||||
#[source]
|
#[source]
|
||||||
source: senbei_android_crypto::Error,
|
source: senbei_crypto::android::Error,
|
||||||
},
|
},
|
||||||
#[error(
|
#[error(
|
||||||
"depth {depth} stream 0x{stream_id:02X} record {record_index} command 0x{command_id:02X} {part}: {source}"
|
"depth {depth} stream 0x{stream_id:02X} record {record_index} command 0x{command_id:02X} {part}: {source}"
|
||||||
@@ -40,7 +40,7 @@ pub enum Error {
|
|||||||
command_id: u32,
|
command_id: u32,
|
||||||
part: &'static str,
|
part: &'static str,
|
||||||
#[source]
|
#[source]
|
||||||
source: senbei_android_crypto::Error,
|
source: senbei_crypto::android::Error,
|
||||||
},
|
},
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Invalid(String),
|
Invalid(String),
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
//! Pure-static Stage 1 decryption and recursive Stage 2 module extraction.
|
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
mod extract;
|
mod pipeline;
|
||||||
mod probe;
|
mod probe;
|
||||||
mod report;
|
mod report;
|
||||||
mod stage1;
|
mod stage1;
|
||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use extract::{ExtractOptions, extract_stage2};
|
pub use pipeline::{ExtractOptions, extract_stage2};
|
||||||
pub use probe::is_protected_libil2cpp;
|
pub use probe::is_protected_libil2cpp;
|
||||||
pub use report::ExtractionReport;
|
pub use report::ExtractionReport;
|
||||||
pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
||||||
+5
-5
@@ -4,20 +4,20 @@ use std::io::Write;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use memmap2::MmapOptions;
|
use memmap2::MmapOptions;
|
||||||
use senbei_android_crypto::{Module9bConfig, decode_container};
|
use senbei_crypto::android::{Module9bConfig, decode_container};
|
||||||
use serde_json::to_vec_pretty;
|
use serde_json::to_vec_pretty;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
use crate::error::{Error, Result, invalid};
|
use super::error::{Error, Result, invalid};
|
||||||
use crate::report::{
|
use super::report::{
|
||||||
ArtifactReport, DecoderReport, ExtractionReport, ModuleRegistryEntry, RecordReport,
|
ArtifactReport, DecoderReport, ExtractionReport, ModuleRegistryEntry, RecordReport,
|
||||||
Stage1Report, StreamParent, StreamReport,
|
Stage1Report, StreamParent, StreamReport,
|
||||||
};
|
};
|
||||||
use crate::stage1::{
|
use super::stage1::{
|
||||||
DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, SHT_LOUSER, Stage1Result, inspect,
|
DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, SHT_LOUSER, Stage1Result, inspect,
|
||||||
};
|
};
|
||||||
use crate::stream::{DIRECT_FLAG, Record, parse_record_stream};
|
use super::stream::{DIRECT_FLAG, Record, parse_record_stream};
|
||||||
|
|
||||||
/// Inputs and output locations for one complete static Stage 2 extraction.
|
/// Inputs and output locations for one complete static Stage 2 extraction.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use senbei_android_crypto::Module9bConfig;
|
use senbei_crypto::android::Module9bConfig;
|
||||||
|
|
||||||
use crate::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
use super::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
||||||
|
|
||||||
/// Return whether `data` has a supported protected AArch64 IL2CPP layout.
|
/// Return whether `data` has a supported protected AArch64 IL2CPP layout.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -2,7 +2,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use goblin::elf::{Elf, header::EM_AARCH64};
|
use goblin::elf::{Elf, header::EM_AARCH64};
|
||||||
|
|
||||||
use crate::error::{Error, Result, invalid};
|
use super::error::{Error, Result, invalid};
|
||||||
|
|
||||||
pub(crate) const SHT_LOUSER: u32 = 0x8000_0000;
|
pub(crate) const SHT_LOUSER: u32 = 0x8000_0000;
|
||||||
pub const DEFAULT_CIPHER_CONSTANT: u32 = 0xbf20_165d;
|
pub const DEFAULT_CIPHER_CONSTANT: u32 = 0xbf20_165d;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use senbei_android_crypto::gf32_mul_fixed;
|
use senbei_crypto::android::gf32_mul_fixed;
|
||||||
|
|
||||||
use crate::error::{Error, Result, invalid};
|
use super::error::{Error, Result, invalid};
|
||||||
|
|
||||||
pub(crate) const RECORD_SIZE: usize = 0x5c;
|
pub(crate) const RECORD_SIZE: usize = 0x5c;
|
||||||
pub(crate) const DIRECT_FLAG: u32 = 2;
|
pub(crate) const DIRECT_FLAG: u32 = 2;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//! Android AArch64 extraction and ELF restoration.
|
||||||
|
|
||||||
|
mod extract;
|
||||||
|
mod restore;
|
||||||
|
|
||||||
|
pub use extract::{
|
||||||
|
DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, Error as ExtractionError, ExtractOptions,
|
||||||
|
ExtractionReport, extract_stage2, is_protected_libil2cpp,
|
||||||
|
};
|
||||||
|
pub use restore::{Error as RestoreError, RestoreOptions, RestoreReport, restore_libil2cpp};
|
||||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::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];
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ pub enum Error {
|
|||||||
#[error("cannot parse module index: {0}")]
|
#[error("cannot parse module index: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Crypto(#[from] senbei_android_crypto::Error),
|
Crypto(#[from] senbei_crypto::android::Error),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Invalid(String),
|
Invalid(String),
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::error::{Error, Result, invalid};
|
use super::error::{Error, Result, invalid};
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn elf_hash(name: &[u8]) -> u32 {
|
pub(crate) fn elf_hash(name: &[u8]) -> u32 {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::error::{Error, Result, invalid};
|
use super::error::{Error, Result, invalid};
|
||||||
|
|
||||||
pub(crate) const SHT_NOBITS: u32 = 8;
|
pub(crate) const SHT_NOBITS: u32 = 8;
|
||||||
pub(crate) const SHT_LOUSER: u32 = 0x8000_0000;
|
pub(crate) const SHT_LOUSER: u32 = 0x8000_0000;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
mod artifact;
|
||||||
|
mod error;
|
||||||
|
mod hash;
|
||||||
|
mod layout;
|
||||||
|
mod pipeline;
|
||||||
|
|
||||||
|
pub use error::Error;
|
||||||
|
pub use pipeline::{RestoreOptions, RestoreReport, restore_libil2cpp};
|
||||||
@@ -5,17 +5,17 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use memmap2::{Mmap, MmapMut, MmapOptions};
|
use memmap2::{Mmap, MmapMut, MmapOptions};
|
||||||
use senbei_android_crypto::{
|
use senbei_crypto::android::{
|
||||||
ContainerHeader, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor, transform_segment,
|
ContainerHeader, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor, transform_segment,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
use crate::artifact::load_artifacts;
|
use super::artifact::load_artifacts;
|
||||||
use crate::error::{Error, Result, invalid};
|
use super::error::{Error, Result, invalid};
|
||||||
use crate::hash::{build_gnu_hash, build_sysv_hash};
|
use super::hash::{build_gnu_hash, build_sysv_hash};
|
||||||
use crate::layout::{
|
use super::layout::{
|
||||||
ElfLayout, SHF_ALLOC, SHT_LOUSER, SHT_NOBITS, SectionHeader, align_up, read_i64, read_u32,
|
ElfLayout, SHF_ALLOC, SHT_LOUSER, SHT_NOBITS, SectionHeader, align_up, read_i64, read_u32,
|
||||||
read_u64, slice, slice_u64, usize_from_u64,
|
read_u64, slice, slice_u64, usize_from_u64,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! Platform-specific unpacking engines.
|
||||||
|
|
||||||
|
pub mod android;
|
||||||
|
pub mod windows;
|
||||||
|
|
||||||
|
pub use windows::{
|
||||||
|
Detected, IntegrityReport, Kind, UnpackError, check_integrity, detect, unpack_auto,
|
||||||
|
unpack_auto_v, unpack_dll, unpack_dll_v, unpack_exe, unpack_exe_v,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Deterministic worker-thread cap shared by filesystem scanning and engines.
|
||||||
|
pub fn thread_cap() -> usize {
|
||||||
|
windows::thread_cap()
|
||||||
|
}
|
||||||
@@ -407,7 +407,7 @@ impl<'a> Unpacker<'a> {
|
|||||||
// non-critical for false-positive rejection.
|
// non-critical for false-positive rejection.
|
||||||
if v8 < info6 {
|
if v8 < info6 {
|
||||||
let delta = info6.wrapping_sub(v8);
|
let delta = info6.wrapping_sub(v8);
|
||||||
if delta <= 0x1000 && delta.is_multiple_of(0x200) {
|
if delta <= 0x1000 && delta % 0x200 == 0 {
|
||||||
anchor = Some(probe);
|
anchor = Some(probe);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -350,8 +350,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(message, "test panic");
|
assert_eq!(message, "test panic");
|
||||||
assert!(
|
assert!(
|
||||||
file.ends_with("senbei-pe/src/engine/mod.rs")
|
file.ends_with("senbei-engine/src/windows/mod.rs")
|
||||||
|| file.ends_with("senbei-pe\\src\\engine\\mod.rs")
|
|| file.ends_with("senbei-engine\\src\\windows\\mod.rs")
|
||||||
);
|
);
|
||||||
assert!(line > 0);
|
assert!(line > 0);
|
||||||
assert!(column > 0);
|
assert!(column > 0);
|
||||||
@@ -382,8 +382,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(message, "worker panic");
|
assert_eq!(message, "worker panic");
|
||||||
assert!(
|
assert!(
|
||||||
file.ends_with("senbei-pe/src/engine/mod.rs")
|
file.ends_with("senbei-engine/src/windows/mod.rs")
|
||||||
|| file.ends_with("senbei-pe\\src\\engine\\mod.rs")
|
|| file.ends_with("senbei-engine\\src\\windows\\mod.rs")
|
||||||
);
|
);
|
||||||
assert!(line > 0);
|
assert!(line > 0);
|
||||||
assert!(column > 0);
|
assert!(column > 0);
|
||||||
@@ -10,11 +10,8 @@ anyhow.workspace = true
|
|||||||
flate2.workspace = true
|
flate2.workspace = true
|
||||||
indicatif.workspace = true
|
indicatif.workspace = true
|
||||||
owo-colors.workspace = true
|
owo-colors.workspace = true
|
||||||
senbei-android-elf.workspace = true
|
senbei-engine.workspace = true
|
||||||
senbei-android-engine.workspace = true
|
|
||||||
senbei-android-metadata.workspace = true
|
|
||||||
senbei-metadata.workspace = true
|
senbei-metadata.workspace = true
|
||||||
senbei-pe.workspace = true
|
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
walkdir.workspace = true
|
walkdir.workspace = true
|
||||||
|
|||||||
@@ -5,24 +5,24 @@
|
|||||||
//! 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`
|
||||||
//! section; restoration extracts the stage-2 module set
|
//! section; restoration extracts the stage-2 module set
|
||||||
//! ([`senbei_android_engine`]) and rebuilds the static image
|
//! ([`senbei_engine::android`]) and rebuilds the static image
|
||||||
//! ([`senbei_android_elf`]). Some il2cpp builds additionally embed their
|
//! ([`senbei_engine::android`]). Some il2cpp builds additionally embed their
|
||||||
//! metadata blob — XOR-wrapped, with no standalone `global-metadata.dat` in
|
//! metadata blob — XOR-wrapped, with no standalone `global-metadata.dat` in
|
||||||
//! the assets — inside the library's data section; after a successful restore
|
//! the assets — inside the library's data section; after a successful restore
|
||||||
//! the blob is located by content and unwrapped
|
//! the blob is located by content and unwrapped
|
||||||
//! ([`senbei_android_metadata::extract_embedded_metadata`]).
|
//! ([`senbei_metadata::android::extract_embedded_metadata`]).
|
||||||
//!
|
//!
|
||||||
//! All functions in this module are native filesystem orchestration; the web
|
//! All functions in this module are native filesystem orchestration; the web
|
||||||
//! app (wasm) never touches them.
|
//! app (wasm) never touches them.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::io::Read;
|
use std::io::{BufWriter, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use flate2::read::DeflateDecoder;
|
use flate2::read::DeflateDecoder;
|
||||||
use senbei_android_elf::{RestoreOptions, restore_libil2cpp};
|
use senbei_engine::android::{ExtractOptions, extract_stage2, is_protected_libil2cpp};
|
||||||
use senbei_android_engine::{ExtractOptions, extract_stage2, is_protected_libil2cpp};
|
use senbei_engine::android::{RestoreOptions, restore_libil2cpp};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use zip::ZipArchive;
|
use zip::ZipArchive;
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ 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_android_metadata::extract_embedded_metadata(
|
Ok(senbei_metadata::android::extract_embedded_metadata(
|
||||||
&restored,
|
&restored,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -122,20 +122,19 @@ 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)> {
|
||||||
if let Ok(discovery) = senbei_android_metadata::discover_method_token_seeds(data)
|
if let Ok(discovery) = senbei_metadata::android::discover_method_token_seeds(data)
|
||||||
&& discovery.version == 31
|
&& matches!(discovery.version, 31 | 39)
|
||||||
&& discovery.images.iter().any(|image| !image.clean)
|
|
||||||
{
|
{
|
||||||
let mut seeds = discovery.seed_candidates.clone();
|
let mut seeds = discovery.seed_candidates.clone();
|
||||||
if seeds.is_empty() {
|
if seeds.is_empty() {
|
||||||
seeds.push(senbei_android_metadata::DEFAULT_METHOD_TOKEN_SEED);
|
seeds.push(senbei_metadata::android::DEFAULT_METHOD_TOKEN_SEED);
|
||||||
}
|
}
|
||||||
// Trial-and-validate: a wrong seed fails the restore's full-coverage
|
// Trial-and-validate: a wrong seed fails the restore's full-coverage
|
||||||
// RID check, so ambiguous candidates cost one extra pass each and a
|
// RID check, so ambiguous candidates cost one extra pass each and a
|
||||||
// build with an unseeded permutation falls through to the structural
|
// build with an unseeded permutation falls through to the structural
|
||||||
// remap rather than producing a silently wrong file.
|
// remap rather than producing a silently wrong file.
|
||||||
for seed in seeds {
|
for seed in seeds {
|
||||||
if let Ok((out, report)) = senbei_android_metadata::restore_method_tokens(data, seed) {
|
if let Ok((out, report)) = senbei_metadata::android::restore_method_tokens(data, seed) {
|
||||||
return Ok((
|
return Ok((
|
||||||
out,
|
out,
|
||||||
senbei_metadata::Report {
|
senbei_metadata::Report {
|
||||||
@@ -234,9 +233,11 @@ pub fn restore_package(
|
|||||||
nested.push((index, name));
|
nested.push((index, name));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if crate::scan::is_android_entry_name(&name) {
|
||||||
direct.push((index, name));
|
direct.push((index, name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
drop(archive);
|
drop(archive);
|
||||||
|
|
||||||
for (index, name) in direct {
|
for (index, name) in direct {
|
||||||
@@ -262,9 +263,11 @@ pub fn restore_package(
|
|||||||
let Some(entry_name) = entry_name else {
|
let Some(entry_name) = entry_name else {
|
||||||
bail!("unsafe entry path in `{}`", nested_label.display());
|
bail!("unsafe entry path in `{}`", nested_label.display());
|
||||||
};
|
};
|
||||||
|
if crate::scan::is_android_entry_name(&entry_name) {
|
||||||
entries.push((nested_index, entry_name));
|
entries.push((nested_index, entry_name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
drop(nested_archive);
|
drop(nested_archive);
|
||||||
// Keep the nested package's stem in the output layout so two splits
|
// Keep the nested package's stem in the output layout so two splits
|
||||||
// carrying same-named entries cannot collide.
|
// carrying same-named entries cannot collide.
|
||||||
@@ -324,6 +327,7 @@ fn restore_package_entry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if is_so {
|
if is_so {
|
||||||
|
drop(data);
|
||||||
return Ok(match restore_so_file(&entry_path, dest, verbose) {
|
return Ok(match restore_so_file(&entry_path, dest, verbose) {
|
||||||
Ok(embedded) => {
|
Ok(embedded) => {
|
||||||
let mut outcomes = vec![outcome(EntryKind::So, EntryStatus::Restored)];
|
let mut outcomes = vec![outcome(EntryKind::So, EntryStatus::Restored)];
|
||||||
@@ -407,27 +411,23 @@ fn extract_entry(
|
|||||||
// `:` appears in `package::entry` labels and is invalid in Windows file
|
// `:` appears in `package::entry` labels and is invalid in Windows file
|
||||||
// names; sanitize every path-ish separator.
|
// names; sanitize every path-ish separator.
|
||||||
let destination = temporary.path().join(key.replace(['\\', '/', ':'], "_"));
|
let destination = temporary.path().join(key.replace(['\\', '/', ':'], "_"));
|
||||||
let compressed_size = usize::try_from(entry.compressed_size())
|
let output_size = entry.size();
|
||||||
.map_err(|_| anyhow::anyhow!("entry compressed size exceeds usize"))?;
|
let mut output = BufWriter::new(std::fs::File::create(&destination)?);
|
||||||
let output_size =
|
let written = match entry.compression() {
|
||||||
usize::try_from(entry.size()).map_err(|_| anyhow::anyhow!("entry size exceeds usize"))?;
|
zip::CompressionMethod::Stored => std::io::copy(&mut entry, &mut output)?,
|
||||||
let mut compressed = vec![0_u8; compressed_size];
|
|
||||||
entry.read_exact(&mut compressed)?;
|
|
||||||
let mut output = Vec::with_capacity(output_size);
|
|
||||||
match entry.compression() {
|
|
||||||
zip::CompressionMethod::Stored => output.extend_from_slice(&compressed),
|
|
||||||
zip::CompressionMethod::Deflated => {
|
zip::CompressionMethod::Deflated => {
|
||||||
DeflateDecoder::new(compressed.as_slice()).read_to_end(&mut output)?;
|
let mut decoder = DeflateDecoder::new(&mut entry);
|
||||||
|
std::io::copy(&mut decoder, &mut output)?
|
||||||
}
|
}
|
||||||
method => bail!("unsupported compression method {method:?} in entry `{key}`"),
|
method => bail!("unsupported compression method {method:?} in entry `{key}`"),
|
||||||
}
|
};
|
||||||
if output.len() != output_size {
|
output.flush()?;
|
||||||
|
if written != output_size {
|
||||||
bail!(
|
bail!(
|
||||||
"entry `{key}` decompressed to 0x{:x}, expected 0x{output_size:x}",
|
"entry `{key}` decompressed to 0x{:x}, expected 0x{output_size:x}",
|
||||||
output.len()
|
written
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
std::fs::write(&destination, &output)?;
|
|
||||||
Ok(destination)
|
Ok(destination)
|
||||||
}
|
}
|
||||||
/// Lowercase hex of a digest output (sha2 0.11's `Array` no longer formats as
|
/// Lowercase hex of a digest output (sha2 0.11's `Array` no longer formats as
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use senbei_pe as unpacker;
|
use senbei_engine as unpacker;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Crackproof header key table lives at this fixed file offset. For the
|
/// Crackproof header key table lives at this fixed file offset. For the
|
||||||
@@ -818,7 +818,7 @@ pub fn run_file_v(
|
|||||||
// handled entry-by-entry. Anything else falls through to the PE pipeline.
|
// handled entry-by-entry. Anything else falls through to the PE pipeline.
|
||||||
let is_android_so = crate::android::is_elf64_aarch64(&prefix)
|
let is_android_so = crate::android::is_elf64_aarch64(&prefix)
|
||||||
&& std::fs::read(input)
|
&& std::fs::read(input)
|
||||||
.map(|bytes| senbei_android_engine::is_protected_libil2cpp(&bytes))
|
.map(|bytes| senbei_engine::android::is_protected_libil2cpp(&bytes))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let is_android_package = !is_android_so && crate::android::is_app_package(input, &prefix);
|
let is_android_package = !is_android_so && crate::android::is_app_package(input, &prefix);
|
||||||
|
|
||||||
|
|||||||
+102
-32
@@ -1,4 +1,4 @@
|
|||||||
use senbei_pe::detect;
|
use senbei_engine::detect;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
@@ -16,7 +16,7 @@ const DETECT_PREFIX: u64 = 8 * 1024;
|
|||||||
/// Smallest file that can possibly be a target, so anything shorter is skipped
|
/// Smallest file that can possibly be a target, so anything shorter is skipped
|
||||||
/// without ever being opened.
|
/// without ever being opened.
|
||||||
///
|
///
|
||||||
/// A Crackproof module needs ≥ 4128 bytes for [`senbei_pe::detect`]'s key
|
/// A Crackproof module needs ≥ 4128 bytes for [`senbei_engine::detect`]'s key
|
||||||
/// table (it reads the dword at 4124), so the bound is exact for the unpack
|
/// table (it reads the dword at 4124), so the bound is exact for the unpack
|
||||||
/// path. An il2cpp `global-metadata.dat` only needs 4 bytes to match its magic,
|
/// path. An il2cpp `global-metadata.dat` only needs 4 bytes to match its magic,
|
||||||
/// but its header alone runs to offset 0xB0 and the images/types/methods tables
|
/// but its header alone runs to offset 0xB0 and the images/types/methods tables
|
||||||
@@ -25,14 +25,59 @@ const DETECT_PREFIX: u64 = 8 * 1024;
|
|||||||
/// processable is lost.
|
/// processable is lost.
|
||||||
const MIN_SIZE: u64 = 4128;
|
const MIN_SIZE: u64 = 4128;
|
||||||
|
|
||||||
/// File extensions that are bulk data by construction and can never be a PE
|
const METADATA_FILE_NAME: &str = "global-metadata.dat";
|
||||||
/// image or an il2cpp metadata blob.
|
|
||||||
///
|
fn is_metadata_name(path: &Path) -> bool {
|
||||||
/// This is deliberately a **deny**-list, not an executable allow-list: unknown
|
path.file_name()
|
||||||
/// extensions are still probed. Extensionless files are handled separately by
|
.and_then(|name| name.to_str())
|
||||||
/// [`denied_name`] because asset stores commonly contain tens of thousands of
|
.is_some_and(|name| name.eq_ignore_ascii_case(METADATA_FILE_NAME))
|
||||||
/// extensionless chunks; exhaustive probing remains available through
|
}
|
||||||
/// `--scan-all`.
|
|
||||||
|
fn is_target_extension(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| {
|
||||||
|
ext.eq_ignore_ascii_case("exe")
|
||||||
|
|| ext.eq_ignore_ascii_case("dll")
|
||||||
|
|| ext.eq_ignore_ascii_case("so")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_android_package_name(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| {
|
||||||
|
ext.eq_ignore_ascii_case("apk")
|
||||||
|
|| ext.eq_ignore_ascii_case("apks")
|
||||||
|
|| ext.eq_ignore_ascii_case("xapk")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// External Windows payloads are consumed through their sibling `.exe`/`.dll`
|
||||||
|
/// stub. They are valid input bytes, but are not independent unpack targets.
|
||||||
|
pub(crate) fn is_windows_companion(path: &Path) -> bool {
|
||||||
|
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(stub_name) = name.strip_suffix("._") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let stub_path = Path::new(stub_name);
|
||||||
|
stub_path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe") || ext.eq_ignore_ascii_case("dll"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_android_entry_name(path: &Path) -> bool {
|
||||||
|
is_metadata_name(path)
|
||||||
|
|| path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("so"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File extensions that are bulk data by construction and can never be a target.
|
||||||
///
|
///
|
||||||
/// Set `SENBEI_SCAN_ALL=1` (or pass `--scan-all`) to probe every file regardless.
|
/// Set `SENBEI_SCAN_ALL=1` (or pass `--scan-all`) to probe every file regardless.
|
||||||
const DENY_EXT: &[&str] = &[
|
const DENY_EXT: &[&str] = &[
|
||||||
@@ -89,9 +134,7 @@ const DENY_EXT: &[&str] = &[
|
|||||||
"sr",
|
"sr",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Whether `path` can be skipped from its name alone. Extensionless files and
|
/// Whether `path` can be skipped from its name alone.
|
||||||
/// files whose extension is on [`DENY_EXT`] are not opened during a default
|
|
||||||
/// scan. `--scan-all` remains available when exhaustive probing is required.
|
|
||||||
fn denied_name(path: &Path) -> bool {
|
fn denied_name(path: &Path) -> bool {
|
||||||
let Some(ext) = path.extension() else {
|
let Some(ext) = path.extension() else {
|
||||||
return true;
|
return true;
|
||||||
@@ -151,16 +194,14 @@ pub struct ScanResult {
|
|||||||
/// per-file I/O latency, not bandwidth (that tree lives on a user-mode virtual
|
/// per-file I/O latency, not bandwidth (that tree lives on a user-mode virtual
|
||||||
/// disk that tops out near 1,300 IOPS). Thread count barely moves it either.
|
/// disk that tops out near 1,300 IOPS). Thread count barely moves it either.
|
||||||
///
|
///
|
||||||
/// So the only lever is **probing fewer files**, which is what [`MIN_SIZE`] and
|
/// So the only lever is **probing fewer files**, which is what the target-name
|
||||||
/// [`DENY_EXT`] do — both decided from the free directory metadata, before any
|
/// filter and [`MIN_SIZE`] do — both decided before any file is opened.
|
||||||
/// file is opened. On that tree they cut 46,446 probes to 1,814 and the scan
|
|
||||||
/// from ~40 s to ~2 s while still finding every target.
|
|
||||||
///
|
///
|
||||||
/// The surviving probes (open + short read + magic test) are fanned out across
|
/// The surviving probes (open + short read + magic test) are fanned out across
|
||||||
/// worker threads. Directory traversal itself stays serial (one cheap `readdir`
|
/// worker threads. Directory traversal itself stays serial (one cheap `readdir`
|
||||||
/// pass, no file opens) because it feeds the parallel probe.
|
/// pass, no file opens) because it feeds the parallel probe.
|
||||||
///
|
///
|
||||||
/// Thread count follows [`crate::unpacker::parallel::thread_cap`] (honoring
|
/// Thread count follows [`senbei_engine::thread_cap`] (honoring
|
||||||
/// `SENBEI_THREADS`, `1` = fully sequential). Output order is independent of
|
/// `SENBEI_THREADS`, `1` = fully sequential). Output order is independent of
|
||||||
/// thread count: each worker owns a disjoint contiguous slice of the path list
|
/// thread count: each worker owns a disjoint contiguous slice of the path list
|
||||||
/// and writes the matching disjoint slice of the class list, so results are
|
/// and writes the matching disjoint slice of the class list, so results are
|
||||||
@@ -224,6 +265,15 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
|||||||
if !entry.file_type().is_file() {
|
if !entry.file_type().is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if is_windows_companion(entry.path()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !is_metadata_name(entry.path())
|
||||||
|
&& !is_target_extension(entry.path())
|
||||||
|
&& !is_android_package_name(entry.path())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if !scan_all {
|
if !scan_all {
|
||||||
// Name checks come first so extensionless asset chunks never
|
// Name checks come first so extensionless asset chunks never
|
||||||
// trigger even an explicit metadata query.
|
// trigger even an explicit metadata query.
|
||||||
@@ -247,7 +297,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
|||||||
// `Some(Class::None)` means "probed, matched neither detector".
|
// `Some(Class::None)` means "probed, matched neither detector".
|
||||||
let n = paths.len();
|
let n = paths.len();
|
||||||
let mut class: Vec<Option<Class>> = vec![Some(Class::None); n];
|
let mut class: Vec<Option<Class>> = vec![Some(Class::None); n];
|
||||||
let workers = senbei_pe::thread_cap().clamp(1, n.max(1));
|
let workers = senbei_engine::thread_cap().clamp(1, n.max(1));
|
||||||
if workers <= 1 {
|
if workers <= 1 {
|
||||||
for (p, c) in paths.iter().zip(class.iter_mut()) {
|
for (p, c) in paths.iter().zip(class.iter_mut()) {
|
||||||
*c = classify(p);
|
*c = classify(p);
|
||||||
@@ -314,9 +364,8 @@ pub fn scan_all_env() -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify one file by content. Reads a short prefix once and tests the
|
/// Classify one named candidate by content. Reads a short prefix once and tests
|
||||||
/// Crackproof detector first, then the il2cpp metadata magic, then the
|
/// the detector for that platform. Returns `None` when the file could not be classified at
|
||||||
/// Android probes. Returns `None` when the file could not be classified at
|
|
||||||
/// all — an I/O error opening it (locked, permissions) or a panic inside a
|
/// all — an I/O error opening it (locked, permissions) or a panic inside a
|
||||||
/// detector — so the caller counts it as a probe error rather than a clean
|
/// detector — so the caller counts it as a probe error rather than a clean
|
||||||
/// "not a target" skip.
|
/// "not a target" skip.
|
||||||
@@ -337,22 +386,31 @@ pub fn scan_all_env() -> bool {
|
|||||||
fn classify(path: &Path) -> Option<Class> {
|
fn classify(path: &Path) -> Option<Class> {
|
||||||
let head = read_prefix(path, DETECT_PREFIX)?;
|
let head = read_prefix(path, DETECT_PREFIX)?;
|
||||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
if detect(&head).is_some() {
|
if is_android_package_name(path) && crate::android::is_app_package(path, &head) {
|
||||||
return Class::Crackproof;
|
return Class::AndroidPackage;
|
||||||
}
|
}
|
||||||
if senbei_metadata::is_metadata(&head) {
|
if is_metadata_name(path) && senbei_metadata::is_metadata(&head) {
|
||||||
return Class::Metadata;
|
return Class::Metadata;
|
||||||
}
|
}
|
||||||
if crate::android::is_elf64_aarch64(&head)
|
if path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe") || ext.eq_ignore_ascii_case("dll"))
|
||||||
|
&& detect(&head).is_some()
|
||||||
|
{
|
||||||
|
return Class::Crackproof;
|
||||||
|
}
|
||||||
|
if path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("so"))
|
||||||
|
&& crate::android::is_elf64_aarch64(&head)
|
||||||
&& std::fs::read(path)
|
&& std::fs::read(path)
|
||||||
.map(|bytes| senbei_android_engine::is_protected_libil2cpp(&bytes))
|
.map(|bytes| senbei_engine::android::is_protected_libil2cpp(&bytes))
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
return Class::AndroidSo;
|
return Class::AndroidSo;
|
||||||
}
|
}
|
||||||
if crate::android::is_app_package(path, &head) {
|
|
||||||
return Class::AndroidPackage;
|
|
||||||
}
|
|
||||||
Class::None
|
Class::None
|
||||||
}));
|
}));
|
||||||
r.ok()
|
r.ok()
|
||||||
@@ -403,7 +461,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extensionless_targets_require_exhaustive_scan() {
|
fn extensionless_targets_are_not_candidates() {
|
||||||
let td = tempfile::tempdir().unwrap();
|
let td = tempfile::tempdir().unwrap();
|
||||||
let root = td.path();
|
let root = td.path();
|
||||||
let mut blob = vec![0u8; MIN_SIZE as usize + 1];
|
let mut blob = vec![0u8; MIN_SIZE as usize + 1];
|
||||||
@@ -414,7 +472,7 @@ mod tests {
|
|||||||
assert!(filtered.metadata.is_empty());
|
assert!(filtered.metadata.is_empty());
|
||||||
|
|
||||||
let exhaustive = find_targets_opts(root, true);
|
let exhaustive = find_targets_opts(root, true);
|
||||||
assert_eq!(exhaustive.metadata.len(), 1);
|
assert!(exhaustive.metadata.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file below the Crackproof key-table bound is skipped without being
|
/// A file below the Crackproof key-table bound is skipped without being
|
||||||
@@ -479,4 +537,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(scan.stats.walk_errors, 0);
|
assert_eq!(scan.stats.walk_errors, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn companion_payload_is_not_counted_as_skipped() {
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
let root = td.path();
|
||||||
|
std::fs::write(root.join("app.exe"), vec![0u8; MIN_SIZE as usize]).unwrap();
|
||||||
|
std::fs::write(root.join("app.exe._"), vec![0u8; MIN_SIZE as usize]).unwrap();
|
||||||
|
|
||||||
|
let scan = find_targets_opts(root, false);
|
||||||
|
assert_eq!(scan.stats.skipped, 1, "only the stub was probed");
|
||||||
|
assert!(is_windows_companion(&root.join("app.exe._")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
use indicatif::{ProgressBar, ProgressStyle};
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
use senbei_pe::{IntegrityReport, Kind};
|
use senbei_engine::{IntegrityReport, Kind};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Create a progress bar for `n` items. Hidden when `quiet` is true.
|
/// Create a progress bar for `n` items. Hidden when `quiet` is true.
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Unity il2cpp metadata de-obfuscation for Senbei"
|
description = "Unity il2cpp metadata de-obfuscation for Senbei"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
//! they are rewritten to the standard il2cpp metadata magic and version so the
|
//! they are rewritten to the standard il2cpp metadata magic and version so the
|
||||||
//! output is a well-formed `global-metadata.dat`.
|
//! output is a well-formed `global-metadata.dat`.
|
||||||
|
|
||||||
use crate::keystream::{HEADER_KEYS, SEGMENTS};
|
use super::keystream::{HEADER_KEYS, SEGMENTS};
|
||||||
|
|
||||||
/// Standard il2cpp metadata sanity magic written over the patched header.
|
/// Standard il2cpp metadata sanity magic written over the patched header.
|
||||||
const STANDARD_MAGIC: u32 = 0xfab1_1baf;
|
const STANDARD_MAGIC: u32 = 0xfab1_1baf;
|
||||||
+520
@@ -171,6 +171,9 @@ pub fn restore_method_tokens(data: &[u8], seed: u32) -> Result<(Vec<u8>, Report)
|
|||||||
return Err(Error::NotMetadata);
|
return Err(Error::NotMetadata);
|
||||||
}
|
}
|
||||||
let version = read_u32(data, 4)?;
|
let version = read_u32(data, 4)?;
|
||||||
|
if version == 39 {
|
||||||
|
return restore_v39(data, seed);
|
||||||
|
}
|
||||||
if version != SUPPORTED_VERSION {
|
if version != SUPPORTED_VERSION {
|
||||||
return Err(Error::UnsupportedVersion(version));
|
return Err(Error::UnsupportedVersion(version));
|
||||||
}
|
}
|
||||||
@@ -368,6 +371,9 @@ pub fn discover_method_token_seeds(data: &[u8]) -> Result<SeedDiscoveryReport> {
|
|||||||
return Err(Error::NotMetadata);
|
return Err(Error::NotMetadata);
|
||||||
}
|
}
|
||||||
let version = read_u32(data, 4)?;
|
let version = read_u32(data, 4)?;
|
||||||
|
if version == 39 {
|
||||||
|
return discover_v39(data);
|
||||||
|
}
|
||||||
if version != SUPPORTED_VERSION {
|
if version != SUPPORTED_VERSION {
|
||||||
return Ok(SeedDiscoveryReport {
|
return Ok(SeedDiscoveryReport {
|
||||||
version,
|
version,
|
||||||
@@ -550,6 +556,458 @@ fn decrypt_rid_with_key(rid: u32, low: u32, high: u32, key: u32) -> u32 {
|
|||||||
value + low
|
value + low
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const V39_METHODS: usize = 5;
|
||||||
|
const V39_PARAMETERS: usize = 10;
|
||||||
|
const V39_GENERIC_CONTAINERS: usize = 14;
|
||||||
|
const V39_INTERFACE_OFFSETS: usize = 18;
|
||||||
|
const V39_TYPES: usize = 19;
|
||||||
|
const V39_IMAGES: usize = 20;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct V39Layout {
|
||||||
|
method_offset: usize,
|
||||||
|
method_count: usize,
|
||||||
|
method_stride: usize,
|
||||||
|
method_token_offset: usize,
|
||||||
|
type_definition_index_width: usize,
|
||||||
|
type_offset: usize,
|
||||||
|
type_count: usize,
|
||||||
|
type_stride: usize,
|
||||||
|
type_method_start_offset: usize,
|
||||||
|
type_method_count_offset: usize,
|
||||||
|
image_offset: usize,
|
||||||
|
image_count: usize,
|
||||||
|
image_stride: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn v39_section(data: &[u8], index: usize) -> Result<(usize, usize, usize)> {
|
||||||
|
let header = 8_usize
|
||||||
|
.checked_add(
|
||||||
|
index
|
||||||
|
.checked_mul(12)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 section header offset overflow".to_owned()))?,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 section header offset overflow".to_owned()))?;
|
||||||
|
let offset = read_u32(data, header)? as usize;
|
||||||
|
let size = read_u32(data, header + 4)? as usize;
|
||||||
|
let count = read_u32(data, header + 8)? as usize;
|
||||||
|
bytes(data, offset, size)?;
|
||||||
|
Ok((offset, size, count))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn v39_index_width(count: usize) -> usize {
|
||||||
|
if count <= u8::MAX as usize {
|
||||||
|
1
|
||||||
|
} else if count <= u16::MAX as usize {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_v39_index(data: &[u8], offset: usize, width: usize) -> Result<usize> {
|
||||||
|
match width {
|
||||||
|
1 => Ok(bytes(data, offset, 1)?[0] as usize),
|
||||||
|
2 => Ok(read_u16(data, offset)? as usize),
|
||||||
|
4 => Ok(read_u32(data, offset)? as usize),
|
||||||
|
_ => malformed("v39 index has an unsupported width"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_v39(data: &[u8]) -> Result<V39Layout> {
|
||||||
|
let (method_offset, method_size, method_count) = v39_section(data, V39_METHODS)?;
|
||||||
|
let (_, parameter_size, parameter_count) = v39_section(data, V39_PARAMETERS)?;
|
||||||
|
let (_, _, generic_count) = v39_section(data, V39_GENERIC_CONTAINERS)?;
|
||||||
|
let (_interface_offset, interface_size, interface_count) =
|
||||||
|
v39_section(data, V39_INTERFACE_OFFSETS)?;
|
||||||
|
let (type_offset, type_size, type_count) = v39_section(data, V39_TYPES)?;
|
||||||
|
let (image_offset, image_size, image_count) = v39_section(data, V39_IMAGES)?;
|
||||||
|
let parameter_index_width = v39_index_width(parameter_count);
|
||||||
|
let generic_container_index_width = v39_index_width(generic_count);
|
||||||
|
let type_definition_index_width = v39_index_width(type_count);
|
||||||
|
let type_index_width = if interface_count == 0 {
|
||||||
|
4
|
||||||
|
} else {
|
||||||
|
let element_size = interface_size
|
||||||
|
.checked_div(interface_count)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 interface-offset size is invalid".to_owned()))?;
|
||||||
|
element_size
|
||||||
|
.checked_sub(4)
|
||||||
|
.filter(|width| matches!(width, 1 | 2 | 4))
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 type-index width is invalid".to_owned()))?
|
||||||
|
};
|
||||||
|
let method_stride = 20_usize
|
||||||
|
.checked_add(type_definition_index_width)
|
||||||
|
.and_then(|size| size.checked_add(type_index_width))
|
||||||
|
.and_then(|size| size.checked_add(parameter_index_width))
|
||||||
|
.and_then(|size| size.checked_add(generic_container_index_width))
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 method stride overflow".to_owned()))?;
|
||||||
|
let type_stride = 68_usize
|
||||||
|
.checked_add(
|
||||||
|
type_index_width
|
||||||
|
.checked_mul(3)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 type stride overflow".to_owned()))?,
|
||||||
|
)
|
||||||
|
.and_then(|size| size.checked_add(generic_container_index_width))
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 type stride overflow".to_owned()))?;
|
||||||
|
let image_stride = 32_usize
|
||||||
|
.checked_add(
|
||||||
|
type_definition_index_width
|
||||||
|
.checked_mul(2)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 image stride overflow".to_owned()))?,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 image stride overflow".to_owned()))?;
|
||||||
|
if method_count.checked_mul(method_stride) != Some(method_size)
|
||||||
|
|| type_count.checked_mul(type_stride) != Some(type_size)
|
||||||
|
|| image_count.checked_mul(image_stride) != Some(image_size)
|
||||||
|
|| parameter_count == 0 && parameter_size != 0
|
||||||
|
{
|
||||||
|
return malformed("v39 table size does not match its compact entry layout");
|
||||||
|
}
|
||||||
|
let type_method_start_offset = 16_usize
|
||||||
|
.checked_add(
|
||||||
|
type_index_width
|
||||||
|
.checked_mul(3)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 type method offset overflow".to_owned()))?,
|
||||||
|
)
|
||||||
|
.and_then(|offset| offset.checked_add(generic_container_index_width))
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 type method offset overflow".to_owned()))?;
|
||||||
|
let type_method_count_offset = type_method_start_offset
|
||||||
|
.checked_add(7 * 4)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 type method count offset overflow".to_owned()))?;
|
||||||
|
let method_token_offset = 4_usize
|
||||||
|
.checked_add(type_definition_index_width)
|
||||||
|
.and_then(|offset| offset.checked_add(type_index_width))
|
||||||
|
.and_then(|offset| offset.checked_add(4))
|
||||||
|
.and_then(|offset| offset.checked_add(parameter_index_width))
|
||||||
|
.and_then(|offset| offset.checked_add(generic_container_index_width))
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 method token offset overflow".to_owned()))?;
|
||||||
|
if method_token_offset
|
||||||
|
.checked_add(4)
|
||||||
|
.is_none_or(|end| end > method_stride)
|
||||||
|
|| type_method_count_offset
|
||||||
|
.checked_add(2)
|
||||||
|
.is_none_or(|end| end > type_stride)
|
||||||
|
{
|
||||||
|
return malformed("v39 compact layout fields exceed their records");
|
||||||
|
}
|
||||||
|
// Touch the section base so malformed headers fail before any output copy.
|
||||||
|
Ok(V39Layout {
|
||||||
|
method_offset,
|
||||||
|
method_count,
|
||||||
|
method_stride,
|
||||||
|
method_token_offset,
|
||||||
|
type_definition_index_width,
|
||||||
|
type_offset,
|
||||||
|
type_count,
|
||||||
|
type_stride,
|
||||||
|
type_method_start_offset,
|
||||||
|
type_method_count_offset,
|
||||||
|
image_offset,
|
||||||
|
image_count,
|
||||||
|
image_stride,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn v39_image_methods(data: &[u8], layout: V39Layout, image: usize) -> Result<Vec<usize>> {
|
||||||
|
let image_base = layout
|
||||||
|
.image_offset
|
||||||
|
.checked_add(
|
||||||
|
image
|
||||||
|
.checked_mul(layout.image_stride)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 image offset overflow".to_owned()))?,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 image offset overflow".to_owned()))?;
|
||||||
|
let type_start = read_v39_index(data, image_base + 8, layout.type_definition_index_width)?;
|
||||||
|
let type_count = read_u32(data, image_base + 8 + layout.type_definition_index_width)? as usize;
|
||||||
|
let type_end = type_start
|
||||||
|
.checked_add(type_count)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 image type range overflow".to_owned()))?;
|
||||||
|
if type_end > layout.type_count {
|
||||||
|
return malformed("v39 image type range exceeds the type table");
|
||||||
|
}
|
||||||
|
let mut methods = Vec::new();
|
||||||
|
for type_index in type_start..type_end {
|
||||||
|
let type_base = layout
|
||||||
|
.type_offset
|
||||||
|
.checked_add(
|
||||||
|
type_index
|
||||||
|
.checked_mul(layout.type_stride)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 type offset overflow".to_owned()))?,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 type offset overflow".to_owned()))?;
|
||||||
|
let method_start = read_u32(data, type_base + layout.type_method_start_offset)?;
|
||||||
|
let method_count = read_u16(data, type_base + layout.type_method_count_offset)? as usize;
|
||||||
|
if method_start == u32::MAX || method_count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let method_start = method_start as usize;
|
||||||
|
let method_end = method_start
|
||||||
|
.checked_add(method_count)
|
||||||
|
.ok_or_else(|| Error::Malformed("v39 type method range overflow".to_owned()))?;
|
||||||
|
if method_end > layout.method_count {
|
||||||
|
return malformed("v39 type method range exceeds the method table");
|
||||||
|
}
|
||||||
|
methods.extend(method_start..method_end);
|
||||||
|
}
|
||||||
|
Ok(methods)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn v39_report(
|
||||||
|
layout: V39Layout,
|
||||||
|
changed_tokens: usize,
|
||||||
|
images_with_methods: usize,
|
||||||
|
visited_methods: usize,
|
||||||
|
already_correct_before: usize,
|
||||||
|
correct_after: usize,
|
||||||
|
transformed_images: usize,
|
||||||
|
seed: u32,
|
||||||
|
) -> Report {
|
||||||
|
Report {
|
||||||
|
version: 39,
|
||||||
|
seed: format!("0x{seed:08X}"),
|
||||||
|
encryption_status: if changed_tokens == 0 {
|
||||||
|
"clean".to_owned()
|
||||||
|
} else {
|
||||||
|
"encrypted".to_owned()
|
||||||
|
},
|
||||||
|
images: layout.image_count,
|
||||||
|
images_with_methods,
|
||||||
|
types: layout.type_count,
|
||||||
|
methods: layout.method_count,
|
||||||
|
visited_methods,
|
||||||
|
already_correct_before,
|
||||||
|
correct_after,
|
||||||
|
changed_tokens,
|
||||||
|
transformed_images,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_v39(data: &[u8], seed: u32) -> Result<(Vec<u8>, Report)> {
|
||||||
|
let layout = parse_v39(data)?;
|
||||||
|
let mut owners = vec![u32::MAX; layout.method_count];
|
||||||
|
let mut output = data.to_vec();
|
||||||
|
let mut images_with_methods = 0;
|
||||||
|
let mut visited_methods = 0;
|
||||||
|
let mut already_correct_before = 0;
|
||||||
|
let mut correct_after = 0;
|
||||||
|
let mut changed_tokens = 0;
|
||||||
|
let mut transformed_images = 0;
|
||||||
|
for image in 0..layout.image_count {
|
||||||
|
let methods = v39_image_methods(data, layout, image)?;
|
||||||
|
if methods.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
images_with_methods += 1;
|
||||||
|
visited_methods += methods.len();
|
||||||
|
let method_base = *methods.iter().min().ok_or_else(|| {
|
||||||
|
Error::Malformed("v39 nonempty image lost its method minimum".to_owned())
|
||||||
|
})?;
|
||||||
|
let method_last = *methods.iter().max().ok_or_else(|| {
|
||||||
|
Error::Malformed("v39 nonempty image lost its method maximum".to_owned())
|
||||||
|
})?;
|
||||||
|
if method_last - method_base + 1 != methods.len() {
|
||||||
|
return validation(format!("v39 image {image} method block is not contiguous"));
|
||||||
|
}
|
||||||
|
for &method in &methods {
|
||||||
|
if owners[method] != u32::MAX {
|
||||||
|
return malformed(format!("v39 method {method} belongs to multiple images"));
|
||||||
|
}
|
||||||
|
owners[method] = image as u32;
|
||||||
|
}
|
||||||
|
let mut tokens = Vec::with_capacity(methods.len());
|
||||||
|
let mut clean = true;
|
||||||
|
for method in methods {
|
||||||
|
let offset =
|
||||||
|
layout.method_offset + method * layout.method_stride + layout.method_token_offset;
|
||||||
|
let token = read_u32(data, offset)?;
|
||||||
|
if token & 0xff00_0000 != METHOD_TOKEN_TABLE {
|
||||||
|
return malformed(format!("v39 method {method} has a non-MethodDef token"));
|
||||||
|
}
|
||||||
|
let expected = (method - method_base + 1) as u32;
|
||||||
|
let rid = token & 0x00ff_ffff;
|
||||||
|
if rid == expected {
|
||||||
|
already_correct_before += 1;
|
||||||
|
} else {
|
||||||
|
clean = false;
|
||||||
|
}
|
||||||
|
tokens.push((offset, token, expected));
|
||||||
|
}
|
||||||
|
if clean {
|
||||||
|
correct_after += tokens.len();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
transformed_images += 1;
|
||||||
|
let low = tokens
|
||||||
|
.iter()
|
||||||
|
.map(|(_, token, _)| token & 0x00ff_ffff)
|
||||||
|
.min()
|
||||||
|
.unwrap();
|
||||||
|
let high = tokens
|
||||||
|
.iter()
|
||||||
|
.map(|(_, token, _)| token & 0x00ff_ffff)
|
||||||
|
.max()
|
||||||
|
.unwrap();
|
||||||
|
if high - low + 1 != tokens.len() as u32 {
|
||||||
|
return validation(format!(
|
||||||
|
"v39 image {image} RID interval is not a permutation"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (offset, token, expected) in tokens {
|
||||||
|
let restored = decrypt_rid(token & 0x00ff_ffff, low, high, seed)?;
|
||||||
|
if restored != expected {
|
||||||
|
return validation(format!(
|
||||||
|
"v39 restored RID {restored} != expected {expected}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let restored_token = METHOD_TOKEN_TABLE | restored;
|
||||||
|
if restored_token != token {
|
||||||
|
output[offset..offset + 4].copy_from_slice(&restored_token.to_le_bytes());
|
||||||
|
changed_tokens += 1;
|
||||||
|
}
|
||||||
|
correct_after += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if owners.contains(&u32::MAX) {
|
||||||
|
return malformed("v39 method definitions are not all owned by an image");
|
||||||
|
}
|
||||||
|
if visited_methods != layout.method_count || correct_after != layout.method_count {
|
||||||
|
return validation(format!(
|
||||||
|
"v39 method coverage mismatch: visited={visited_methods}, correct={correct_after}, total={}",
|
||||||
|
layout.method_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
output,
|
||||||
|
v39_report(
|
||||||
|
layout,
|
||||||
|
changed_tokens,
|
||||||
|
images_with_methods,
|
||||||
|
visited_methods,
|
||||||
|
already_correct_before,
|
||||||
|
correct_after,
|
||||||
|
transformed_images,
|
||||||
|
seed,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_v39(data: &[u8]) -> Result<SeedDiscoveryReport> {
|
||||||
|
let layout = parse_v39(data)?;
|
||||||
|
let mut reports = Vec::with_capacity(layout.image_count);
|
||||||
|
for image in 0..layout.image_count {
|
||||||
|
let methods = v39_image_methods(data, layout, image)?;
|
||||||
|
if methods.is_empty() {
|
||||||
|
reports.push(ImageKeyDiscovery {
|
||||||
|
image,
|
||||||
|
method_count: 0,
|
||||||
|
modulus: 0,
|
||||||
|
clean: true,
|
||||||
|
seed_residues: Vec::new(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let base = *methods.iter().min().unwrap();
|
||||||
|
let last = *methods.iter().max().unwrap();
|
||||||
|
if last - base + 1 != methods.len() {
|
||||||
|
return validation(format!("v39 image {image} method block is not contiguous"));
|
||||||
|
}
|
||||||
|
let values = methods
|
||||||
|
.iter()
|
||||||
|
.map(|&method| {
|
||||||
|
let offset = layout.method_offset
|
||||||
|
+ method * layout.method_stride
|
||||||
|
+ layout.method_token_offset;
|
||||||
|
let token = read_u32(data, offset)?;
|
||||||
|
if token & 0xff00_0000 != METHOD_TOKEN_TABLE {
|
||||||
|
return malformed(format!("v39 method {method} has a non-MethodDef token"));
|
||||||
|
}
|
||||||
|
Ok((token & 0x00ff_ffff, (method - base + 1) as u32))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
let count = u32::try_from(values.len())
|
||||||
|
.map_err(|_| Error::Validation("v39 image method count exceeds u32".to_owned()))?;
|
||||||
|
let clean = values.iter().all(|(rid, expected)| rid == expected);
|
||||||
|
if clean {
|
||||||
|
reports.push(ImageKeyDiscovery {
|
||||||
|
image,
|
||||||
|
method_count: count,
|
||||||
|
modulus: count / 2,
|
||||||
|
clean: true,
|
||||||
|
seed_residues: Vec::new(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let low = values.iter().map(|(rid, _)| *rid).min().unwrap();
|
||||||
|
let high = values.iter().map(|(rid, _)| *rid).max().unwrap();
|
||||||
|
if high - low + 1 != count || count < 2 {
|
||||||
|
return validation(format!(
|
||||||
|
"v39 image {image} RID interval is not a permutation"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let half = count / 2;
|
||||||
|
let quarter = count / 4;
|
||||||
|
let mut residues = Vec::new();
|
||||||
|
for residue in 0..half {
|
||||||
|
let key = quarter + residue;
|
||||||
|
if values
|
||||||
|
.iter()
|
||||||
|
.all(|(rid, expected)| decrypt_rid_with_key(*rid, low, high, key) == *expected)
|
||||||
|
{
|
||||||
|
residues.push(residue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reports.push(ImageKeyDiscovery {
|
||||||
|
image,
|
||||||
|
method_count: count,
|
||||||
|
modulus: half,
|
||||||
|
clean: false,
|
||||||
|
seed_residues: residues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let constraints = reports
|
||||||
|
.iter()
|
||||||
|
.filter(|image| !image.clean)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
if let Some(anchor) = constraints.iter().max_by_key(|image| image.modulus) {
|
||||||
|
// Returning every 32-bit seed is not representable for tiny synthetic
|
||||||
|
// images (for example, a two-entry image has billions of candidates).
|
||||||
|
// The caller still tries the known default seed and validates it fully.
|
||||||
|
if anchor.modulus < 1024 {
|
||||||
|
return Ok(SeedDiscoveryReport {
|
||||||
|
version: 39,
|
||||||
|
images: reports,
|
||||||
|
seed_candidates: candidates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for &residue in &anchor.seed_residues {
|
||||||
|
let modulus = u64::from(anchor.modulus);
|
||||||
|
let mut candidate = u64::from(residue);
|
||||||
|
while candidate <= u64::from(u32::MAX) {
|
||||||
|
if constraints.iter().all(|image| {
|
||||||
|
image.modulus != 0
|
||||||
|
&& image
|
||||||
|
.seed_residues
|
||||||
|
.iter()
|
||||||
|
.any(|value| candidate % u64::from(image.modulus) == u64::from(*value))
|
||||||
|
}) {
|
||||||
|
candidates.push(candidate as u32);
|
||||||
|
}
|
||||||
|
candidate = candidate.saturating_add(modulus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort_unstable();
|
||||||
|
candidates.dedup();
|
||||||
|
Ok(SeedDiscoveryReport {
|
||||||
|
version: 39,
|
||||||
|
images: reports,
|
||||||
|
seed_candidates: candidates,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -656,4 +1114,66 @@ mod tests {
|
|||||||
Err(Error::Validation(_))
|
Err(Error::Validation(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restores_compact_v39_method_tokens_without_touching_other_fields() {
|
||||||
|
let method_stride = 25;
|
||||||
|
let type_stride = 75;
|
||||||
|
let image_stride = 34;
|
||||||
|
let method_offset = 0x400;
|
||||||
|
let type_offset = 0x600;
|
||||||
|
let image_offset = 0x700;
|
||||||
|
let method_count = 7_u32;
|
||||||
|
let mut data = vec![0_u8; image_offset + image_stride];
|
||||||
|
put_u32(&mut data, 0, MAGIC);
|
||||||
|
put_u32(&mut data, 4, 39);
|
||||||
|
let section = |data: &mut [u8], index: usize, offset: usize, size: usize, count: usize| {
|
||||||
|
let header = 8 + index * 12;
|
||||||
|
put_u32(data, header, offset as u32);
|
||||||
|
put_u32(data, header + 4, size as u32);
|
||||||
|
put_u32(data, header + 8, count as u32);
|
||||||
|
};
|
||||||
|
section(
|
||||||
|
&mut data,
|
||||||
|
V39_METHODS,
|
||||||
|
method_offset,
|
||||||
|
method_stride * method_count as usize,
|
||||||
|
method_count as usize,
|
||||||
|
);
|
||||||
|
section(&mut data, V39_PARAMETERS, 0x300, 1, 1);
|
||||||
|
section(&mut data, V39_GENERIC_CONTAINERS, 0x320, 1, 1);
|
||||||
|
section(&mut data, V39_INTERFACE_OFFSETS, 0x340, 6, 1);
|
||||||
|
section(&mut data, V39_TYPES, type_offset, type_stride, 1);
|
||||||
|
section(&mut data, V39_IMAGES, image_offset, image_stride, 1);
|
||||||
|
data.resize(image_offset + image_stride, 0);
|
||||||
|
// Compact v39 type definition: firstMethod at offset 23, methodCount at 51.
|
||||||
|
put_u32(&mut data, type_offset + 23, 0);
|
||||||
|
put_u16(&mut data, type_offset + 51, method_count as u16);
|
||||||
|
// Compact v39 image definition: firstTypeIndex (one byte) and typeCount.
|
||||||
|
data[image_offset + 8] = 0;
|
||||||
|
put_u32(&mut data, image_offset + 9, 1);
|
||||||
|
for expected in 1..=method_count {
|
||||||
|
let token = METHOD_TOKEN_TABLE
|
||||||
|
| encrypted_rid(expected, method_count, DEFAULT_METHOD_TOKEN_SEED);
|
||||||
|
put_u32(
|
||||||
|
&mut data,
|
||||||
|
method_offset + (expected as usize - 1) * method_stride + 13,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (out, report) =
|
||||||
|
restore_method_tokens(&data, DEFAULT_METHOD_TOKEN_SEED).expect("v39 restore");
|
||||||
|
assert_eq!(report.version, 39);
|
||||||
|
assert_eq!(report.changed_tokens, method_count as usize);
|
||||||
|
for expected in 1..=method_count {
|
||||||
|
let offset = method_offset + (expected as usize - 1) * method_stride + 13;
|
||||||
|
assert_eq!(
|
||||||
|
read_u32(&out, offset).unwrap(),
|
||||||
|
METHOD_TOKEN_TABLE | expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let discovery = discover_method_token_seeds(&data).expect("v39 discovery");
|
||||||
|
assert_eq!(discovery.version, 39);
|
||||||
|
assert!(discovery.seed_candidates.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Unity il2cpp metadata de-obfuscation.
|
//! Unity il2cpp metadata restoration.
|
||||||
|
|
||||||
mod metadata;
|
pub mod android;
|
||||||
|
pub mod windows;
|
||||||
|
|
||||||
pub use metadata::*;
|
pub use windows::*;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//! Windows metadata restoration.
|
||||||
|
|
||||||
|
mod metadata;
|
||||||
|
|
||||||
|
pub use metadata::*;
|
||||||
@@ -3,8 +3,7 @@ name = "senbei-pe"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "PE detection, unpacking, and validation for Senbei"
|
description = "PE format parsing and address mapping for Senbei"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
senbei-crypto.workspace = true
|
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
+137
-3
@@ -1,5 +1,139 @@
|
|||||||
//! PE detection, unpacking, and structural validation.
|
//! Basic PE format parsing and address mapping.
|
||||||
|
|
||||||
mod engine;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub use engine::*;
|
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("input is not a PE image")]
|
||||||
|
Invalid,
|
||||||
|
#[error("PE range is outside the input")]
|
||||||
|
OutOfBounds,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Section {
|
||||||
|
pub virtual_address: u32,
|
||||||
|
pub virtual_size: u32,
|
||||||
|
pub raw_offset: u32,
|
||||||
|
pub raw_size: u32,
|
||||||
|
pub characteristics: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Headers {
|
||||||
|
pub pe_offset: usize,
|
||||||
|
pub is_pe32_plus: bool,
|
||||||
|
pub image_base: u64,
|
||||||
|
pub size_of_image: u32,
|
||||||
|
pub entry_rva: u32,
|
||||||
|
pub sections_offset: usize,
|
||||||
|
pub sections: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(data: &[u8]) -> Result<Headers> {
|
||||||
|
if data.get(0..2) != Some(b"MZ") {
|
||||||
|
return Err(Error::Invalid);
|
||||||
|
}
|
||||||
|
let pe_offset = read_u32(data, 0x3c)? as usize;
|
||||||
|
if data.get(pe_offset..pe_offset + 4) != Some(b"PE\0\0") {
|
||||||
|
return Err(Error::Invalid);
|
||||||
|
}
|
||||||
|
let sections = read_u16(data, pe_offset + 6)?;
|
||||||
|
let optional_size = read_u16(data, pe_offset + 20)? as usize;
|
||||||
|
let optional = pe_offset.checked_add(24).ok_or(Error::OutOfBounds)?;
|
||||||
|
let magic = read_u16(data, optional)?;
|
||||||
|
let is_pe32_plus = magic == 0x20b;
|
||||||
|
if !is_pe32_plus && magic != 0x10b {
|
||||||
|
return Err(Error::Invalid);
|
||||||
|
}
|
||||||
|
let entry_rva = read_u32(data, optional + 16)?;
|
||||||
|
let image_base = if is_pe32_plus {
|
||||||
|
read_u64(data, optional + 24)?
|
||||||
|
} else {
|
||||||
|
read_u32(data, optional + 28)? as u64
|
||||||
|
};
|
||||||
|
let size_of_image = read_u32(data, optional + 56)?;
|
||||||
|
let sections_offset = optional
|
||||||
|
.checked_add(optional_size)
|
||||||
|
.ok_or(Error::OutOfBounds)?;
|
||||||
|
let table_size = usize::from(sections)
|
||||||
|
.checked_mul(40)
|
||||||
|
.ok_or(Error::OutOfBounds)?;
|
||||||
|
data.get(sections_offset..sections_offset + table_size)
|
||||||
|
.ok_or(Error::OutOfBounds)?;
|
||||||
|
Ok(Headers {
|
||||||
|
pe_offset,
|
||||||
|
is_pe32_plus,
|
||||||
|
image_base,
|
||||||
|
size_of_image,
|
||||||
|
entry_rva,
|
||||||
|
sections_offset,
|
||||||
|
sections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sections(data: &[u8], headers: Headers) -> Result<Vec<Section>> {
|
||||||
|
(0..headers.sections)
|
||||||
|
.map(|index| {
|
||||||
|
let offset = headers
|
||||||
|
.sections_offset
|
||||||
|
.checked_add(usize::from(index) * 40)
|
||||||
|
.ok_or(Error::OutOfBounds)?;
|
||||||
|
Ok(Section {
|
||||||
|
virtual_size: read_u32(data, offset + 8)?,
|
||||||
|
virtual_address: read_u32(data, offset + 12)?,
|
||||||
|
raw_size: read_u32(data, offset + 16)?,
|
||||||
|
raw_offset: read_u32(data, offset + 20)?,
|
||||||
|
characteristics: read_u32(data, offset + 36)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rva_to_offset(data: &[u8], headers: Headers, rva: u32) -> Result<usize> {
|
||||||
|
if rva < headers.sections_offset as u32 {
|
||||||
|
return Ok(rva as usize);
|
||||||
|
}
|
||||||
|
for section in sections(data, headers)? {
|
||||||
|
let span = section.virtual_size.max(section.raw_size);
|
||||||
|
if rva >= section.virtual_address && rva < section.virtual_address.saturating_add(span) {
|
||||||
|
let offset = section
|
||||||
|
.raw_offset
|
||||||
|
.checked_add(rva - section.virtual_address)
|
||||||
|
.ok_or(Error::OutOfBounds)? as usize;
|
||||||
|
if offset < data.len() {
|
||||||
|
return Ok(offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(Error::OutOfBounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u16(data: &[u8], offset: usize) -> Result<u16> {
|
||||||
|
let bytes: [u8; 2] = data
|
||||||
|
.get(offset..offset + 2)
|
||||||
|
.ok_or(Error::OutOfBounds)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| Error::OutOfBounds)?;
|
||||||
|
Ok(u16::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||||
|
let bytes: [u8; 4] = data
|
||||||
|
.get(offset..offset + 4)
|
||||||
|
.ok_or(Error::OutOfBounds)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| Error::OutOfBounds)?;
|
||||||
|
Ok(u32::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u64(data: &[u8], offset: usize) -> Result<u64> {
|
||||||
|
let bytes: [u8; 8] = data
|
||||||
|
.get(offset..offset + 8)
|
||||||
|
.ok_or(Error::OutOfBounds)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| Error::OutOfBounds)?;
|
||||||
|
Ok(u64::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|||||||
Generated
+6
-41
@@ -429,7 +429,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-android-crypto"
|
name = "senbei-crypto"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
@@ -437,25 +437,12 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-android-elf"
|
name = "senbei-engine"
|
||||||
version = "1.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"memmap2",
|
|
||||||
"senbei-android-crypto",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "senbei-android-engine"
|
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"goblin",
|
"goblin",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"senbei-android-crypto",
|
"senbei-crypto",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -463,21 +450,6 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "senbei-android-metadata"
|
|
||||||
version = "1.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"thiserror",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "senbei-crypto"
|
|
||||||
version = "1.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-io"
|
name = "senbei-io"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -487,11 +459,8 @@ dependencies = [
|
|||||||
"indicatif",
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
"owo-colors",
|
"owo-colors",
|
||||||
"senbei-android-elf",
|
"senbei-engine",
|
||||||
"senbei-android-engine",
|
|
||||||
"senbei-android-metadata",
|
|
||||||
"senbei-metadata",
|
"senbei-metadata",
|
||||||
"senbei-pe",
|
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -502,12 +471,8 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-metadata"
|
name = "senbei-metadata"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "senbei-pe"
|
|
||||||
version = "1.2.0"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"senbei-crypto",
|
"serde",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -516,9 +481,9 @@ name = "senbei-wasm"
|
|||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
|
"senbei-engine",
|
||||||
"senbei-io",
|
"senbei-io",
|
||||||
"senbei-metadata",
|
"senbei-metadata",
|
||||||
"senbei-pe",
|
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ crate-type = ["cdylib"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
senbei-io = { path = "../senbei-io" }
|
senbei-io = { path = "../senbei-io" }
|
||||||
senbei-metadata = { path = "../senbei-metadata" }
|
senbei-metadata = { path = "../senbei-metadata" }
|
||||||
senbei-pe = { path = "../senbei-pe" }
|
senbei-engine = { path = "../senbei-engine" }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
|
||||||
|
|||||||
@@ -101,12 +101,12 @@ impl MetadataResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kind_str(kind: senbei_pe::Kind) -> &'static str {
|
fn kind_str(kind: senbei_engine::Kind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
senbei_pe::Kind::NativeExe => "native-exe",
|
senbei_engine::Kind::NativeExe => "native-exe",
|
||||||
senbei_pe::Kind::ManagedExe => "managed-exe",
|
senbei_engine::Kind::ManagedExe => "managed-exe",
|
||||||
senbei_pe::Kind::NativeDll => "native-dll",
|
senbei_engine::Kind::NativeDll => "native-dll",
|
||||||
senbei_pe::Kind::ManagedDll => "managed-dll",
|
senbei_engine::Kind::ManagedDll => "managed-dll",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ pub fn detect(input: &[u8]) -> Option<String> {
|
|||||||
if senbei_metadata::is_metadata(input) {
|
if senbei_metadata::is_metadata(input) {
|
||||||
return Some("metadata".to_string());
|
return Some("metadata".to_string());
|
||||||
}
|
}
|
||||||
senbei_pe::detect(input).map(|d| kind_str(d.kind).to_string())
|
senbei_engine::detect(input).map(|d| kind_str(d.kind).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unpack a protected module.
|
/// Unpack a protected module.
|
||||||
|
|||||||
+10
-61
@@ -1,76 +1,25 @@
|
|||||||
# Senbei web
|
# Senbei Web
|
||||||
|
|
||||||
Senbei running in the browser: the unpacker core compiled to WebAssembly,
|
Senbei runs in the browser through the `senbei-wasm` crate. Files are read locally, unpacked in a worker, and offered back as downloads; no server receives input bytes.
|
||||||
wrapped in a small static page. Everything is client-side — files are read
|
|
||||||
into the page, unpacked locally, and offered back as downloads. Nothing is
|
|
||||||
uploaded; there is no server component.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- A legal notice is shown as a blocking dialog on page open; the tool is
|
- Protected `.exe` and `.dll` files produce `<name>.unpack.*` downloads.
|
||||||
unusable until it is acknowledged.
|
- External `.exe._` and `.dll._` companions are paired by filename.
|
||||||
- Dropped files land in a file list, not unpacked immediately: review the
|
- `global-metadata.dat` produces `global-metadata.unpack.dat` when tokens change.
|
||||||
batch, remove mistakes, then press **Unpack**. A module and its `._`
|
- Each output receives the same static integrity check as the CLI.
|
||||||
companion can be dropped in any order (or in separate drops) — companions
|
|
||||||
auto-pair by name (`Foo.dll._` → `Foo.dll`) and show as a badge on the
|
|
||||||
module's row; removing a module removes its companion too.
|
|
||||||
- Rows show state at a glance: black while staged, an animated blue bar
|
|
||||||
while unpacking, green on success (with a download button) and red on
|
|
||||||
failure.
|
|
||||||
- Drop one or more protected `.exe` / `.dll` modules → get `<name>.unpack.*`
|
|
||||||
downloads.
|
|
||||||
- Drop an il2cpp `global-metadata.dat` → de-obfuscated
|
|
||||||
`global-metadata.unpack.dat` (only when tokens actually change).
|
|
||||||
- Each output passes the same static integrity check as the CLI; suspect
|
|
||||||
outputs are flagged with the specific defects found.
|
|
||||||
|
|
||||||
## Architecture notes
|
Every unpack uses a disposable Web Worker so a WebAssembly trap cannot freeze the page. A trapped DLL can be retried through the forced-EXE path, matching native routing.
|
||||||
|
|
||||||
- Every unpack runs in a **disposable Web Worker** (fresh wasm instance per
|
## Build
|
||||||
file): the UI stays responsive on 100 MB+ modules, and a wasm trap is
|
|
||||||
isolated to that worker.
|
|
||||||
- **Why workers matter for correctness:** the DLL-first routing probe relies
|
|
||||||
on `catch_unwind` to reject EXE-shell-layout DLLs, and panics cannot be
|
|
||||||
caught in WebAssembly — the probe traps the whole call. When a DLL unpack
|
|
||||||
traps, the app retries once in a new worker with the forced-EXE pipeline
|
|
||||||
(`unpack_file_force_exe`), reproducing the CLI's dll-first/exe-fallback
|
|
||||||
outcome. Spliced companion inputs skip the probe entirely (they are always
|
|
||||||
EXE-shell layout), exactly like the CLI.
|
|
||||||
- Rust panic messages are forwarded to the browser console
|
|
||||||
(`console_error_panic_hook`) — check devtools when reporting an issue.
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
Requires a Rust toolchain (`rust-toolchain.toml` in the repo root pins one,
|
|
||||||
including the `wasm32-unknown-unknown` target) and
|
|
||||||
[wasm-pack](https://rustwasm.github.io/wasm-pack/installer/).
|
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
cd senbei-wasm
|
cd senbei-wasm
|
||||||
wasm-pack build --target web --release --out-dir ../web/pkg
|
wasm-pack build --target web --release --out-dir ../web/pkg
|
||||||
```
|
```
|
||||||
|
|
||||||
This produces `web/pkg/` (git-ignored). Then serve the `web/` directory with
|
Serve `web/` with a static HTTP server, for example `python -m http.server -d web 8000`. Opening `index.html` with `file://` does not work because browser modules require HTTP.
|
||||||
any static file server and open `index.html`:
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
python -m http.server -d web 8000
|
|
||||||
:: -> http://localhost:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
(Opening `index.html` via `file://` won't work — ES modules require HTTP.)
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
`senbei-wasm/src/lib.rs` contains the bindings. `web/app.js` manages the dropzone and downloads, `web/worker.js` runs one unpack job per worker, and `web/pkg/` contains ignored wasm-pack output.
|
||||||
senbei-wasm/ the senbei-wasm cdylib crate (own Cargo.lock, outside the
|
|
||||||
workspace; depends on the senbei-pe/-io/-metadata crates)
|
|
||||||
└── src/lib.rs #[wasm_bindgen] bindings: detect / unpack_file /
|
|
||||||
unpack_file_force_exe / deobfuscate_metadata
|
|
||||||
web/
|
|
||||||
├── index.html the page
|
|
||||||
├── app.js dropzone, file list, worker orchestration, downloads
|
|
||||||
├── worker.js one-shot unpack worker (fresh wasm instance per file)
|
|
||||||
├── style.css
|
|
||||||
└── pkg/ wasm-pack output (git-ignored; build from senbei-wasm/)
|
|
||||||
```
|
|
||||||
|
|||||||
Reference in New Issue
Block a user