mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-11-04 10:31:29 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0961d23538 | ||
|
|
b59d97c625 | ||
|
|
a380031567 | ||
|
|
22dd92982e | ||
|
|
303aaa6283 | ||
|
|
312a70b7c6 | ||
|
|
db2729737f | ||
|
|
3baf986a84 | ||
|
|
d16f1c9394 | ||
|
|
f5896b3428 | ||
|
|
dfff21604a | ||
|
|
366a19c2a1 | ||
|
|
ba1dacc0b2 | ||
|
|
31a0c4139c | ||
|
|
ea92d26d28 | ||
|
|
6e233bbf18 | ||
|
|
5bd1b0b07d | ||
|
|
01fdc58d2b | ||
|
|
f7d2d8b7e1 | ||
|
|
7728c6e7c2 | ||
|
|
921cd4bfb4 | ||
|
|
53f31d2210 | ||
|
|
9083c60d45 | ||
|
|
25939c9738 | ||
|
|
2501aabc57 | ||
|
|
da06b4f5fd | ||
|
|
b355d8ea8f | ||
|
|
539861acbd | ||
|
|
a45cc8aa90 | ||
|
|
d4613305fd | ||
|
|
ba380a5774 | ||
|
|
d6224e639c | ||
|
|
9b8024b132 | ||
|
|
512a627066 | ||
|
|
a230c37281 | ||
|
|
4f59e25652 | ||
|
|
f862633512 | ||
|
|
ed2731f8e0 | ||
|
|
53ef36c837 | ||
|
|
6250ca4e98 | ||
|
|
aa1bcaa2eb | ||
|
|
2d92360d87 | ||
|
|
4d73406ab1 | ||
|
|
776d246065 | ||
|
|
d436a200ba | ||
|
|
cbfacbc31f | ||
|
|
d3dd1a8ff8 | ||
|
|
35076848b6 | ||
|
|
18886272e5 | ||
|
|
b534de872d | ||
|
|
a9aaf95e01 | ||
|
|
7f827d6400 | ||
|
|
131ced6db5 | ||
|
|
b1d3699df3 | ||
|
|
9433b4dcca |
No files matched your search
@@ -1,82 +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: a Cargo
|
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.
|
||||||
workspace with a pure, panic-free, no-I/O unpacker core (`senbei-pe/`, built
|
|
||||||
on `senbei-crypto/`), an il2cpp metadata de-obfuscator (`senbei-metadata/`),
|
Read the [Senbei design notes](https://xn--ri8h.gitbook.io/crackproof-research/senbei) before changing architecture or pipeline boundaries.
|
||||||
filesystem/CLI orchestration (`senbei-io/`), 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). Do not
|
|
||||||
delete `samples/` with `rm -rf` — it may be a junction; use git
|
|
||||||
worktree-aware cleanup.
|
|
||||||
|
|
||||||
## Hard rules
|
## Crate Boundaries
|
||||||
|
|
||||||
- **The unpacker core stays pure**: no file I/O, no `unsafe`, no panics across
|
`senbei-pe` and `senbei-elf` contain format parsing, address mapping, and ELF dynamic-table helpers only. `senbei-engine/src/windows/` contains the PE unpacking pipeline; `senbei-engine/src/android/` contains Android extraction and ELF restoration. `senbei-crypto/src/windows/` and `senbei-crypto/src/android/` contain platform-specific primitives; seeded Android metadata code is under `senbei-metadata/src/android/`, while the structural metadata transform is shared at the metadata crate root. Shared source stays directly under `src/`.
|
||||||
the public boundary, no platform-specific code. It must keep compiling to
|
|
||||||
`wasm32-unknown-unknown` (`cargo check --target wasm32-unknown-unknown`).
|
|
||||||
- **`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. (Unless a change that's designed to break the golden corpus, in which case it must have a runtime test to check if it still runnable.)
|
||||||
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. Product documentation lives at <https://xn--ri8h.gitbook.io/crackproof-research/senbei>; update that site (not this repository) 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
+403
-42
@@ -2,6 +2,17 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
"cpubits",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
@@ -10,9 +21,18 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
@@ -22,15 +42,31 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console"
|
name = "console"
|
||||||
version = "0.16.4"
|
version = "0.16.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
|
checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encode_unicode",
|
"encode_unicode",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -38,12 +74,68 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpubits"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encode_unicode"
|
name = "encode_unicode"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -60,6 +152,15 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -91,8 +192,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "goblin"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "17582616a7718cca54cec18e534a76c7c4aec11a8b9a85695712f262fd15a4c8"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"plain",
|
||||||
|
"scroll",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -109,10 +248,25 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "inout"
|
||||||
version = "0.3.104"
|
version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -121,9 +275,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.191"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
@@ -131,6 +285,39 @@ version = "0.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memmap2"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -149,6 +336,12 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plain"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -181,9 +374,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"errno",
|
"errno",
|
||||||
@@ -208,48 +401,163 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-cli"
|
name = "scroll"
|
||||||
version = "1.1.0"
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"scroll_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scroll_derive"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "senbei-cli"
|
||||||
|
version = "1.3.4"
|
||||||
|
dependencies = [
|
||||||
|
"senbei-engine",
|
||||||
"senbei-io",
|
"senbei-io",
|
||||||
"senbei-metadata",
|
"senbei-metadata",
|
||||||
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-crypto"
|
name = "senbei-crypto"
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "senbei-elf"
|
||||||
|
version = "1.3.4"
|
||||||
|
dependencies = [
|
||||||
|
"goblin",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "senbei-engine"
|
||||||
|
version = "1.3.4"
|
||||||
|
dependencies = [
|
||||||
|
"memmap2",
|
||||||
|
"senbei-crypto",
|
||||||
|
"senbei-elf",
|
||||||
|
"senbei-pe",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-io"
|
name = "senbei-io"
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
|
"memmap2",
|
||||||
"owo-colors",
|
"owo-colors",
|
||||||
|
"senbei-crypto",
|
||||||
|
"senbei-elf",
|
||||||
|
"senbei-engine",
|
||||||
"senbei-metadata",
|
"senbei-metadata",
|
||||||
"senbei-pe",
|
"senbei-pe",
|
||||||
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows",
|
"windows",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-metadata"
|
name = "senbei-metadata"
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-pe"
|
name = "senbei-pe"
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"senbei-crypto",
|
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -269,9 +577,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.4"
|
version = "3.0.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -293,35 +601,47 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.20"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "typed-path"
|
||||||
version = "1.0.24"
|
version = "0.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unit-prefix"
|
name = "unit-prefix"
|
||||||
@@ -341,12 +661,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.127"
|
version = "0.2.130"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"wasm-bindgen-macro",
|
"wasm-bindgen-macro",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
@@ -354,9 +673,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.127"
|
version = "0.2.130"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -364,22 +683,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.127"
|
version = "0.2.130"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.9",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.127"
|
version = "0.2.130"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -521,3 +840,45 @@ checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "9.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"flate2",
|
||||||
|
"getrandom",
|
||||||
|
"indexmap",
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"nix",
|
||||||
|
"typed-path",
|
||||||
|
"windows-sys",
|
||||||
|
"zopfli",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
+22
-1
@@ -2,6 +2,8 @@
|
|||||||
members = [
|
members = [
|
||||||
"senbei-cli",
|
"senbei-cli",
|
||||||
"senbei-crypto",
|
"senbei-crypto",
|
||||||
|
"senbei-elf",
|
||||||
|
"senbei-engine",
|
||||||
"senbei-io",
|
"senbei-io",
|
||||||
"senbei-metadata",
|
"senbei-metadata",
|
||||||
"senbei-pe",
|
"senbei-pe",
|
||||||
@@ -13,15 +15,22 @@ exclude = ["senbei-wasm"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version = "1.99.1"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
aes = "0.9"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
goblin = "0.10"
|
||||||
indicatif = "0.18"
|
indicatif = "0.18"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
memmap2 = "0.9"
|
||||||
owo-colors = "4"
|
owo-colors = "4"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.11"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
@@ -30,11 +39,23 @@ windows = { version = "0.62", features = [
|
|||||||
"Win32_System_Console",
|
"Win32_System_Console",
|
||||||
"Win32_System_SystemInformation",
|
"Win32_System_SystemInformation",
|
||||||
] }
|
] }
|
||||||
|
zip = { version = "9.3", default-features = false, features = ["deflate"] }
|
||||||
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" }
|
||||||
|
|
||||||
|
[workspace.lints.rust]
|
||||||
|
unsafe_op_in_unsafe_fn = "deny"
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
correctness = { level = "deny", priority = -1 }
|
||||||
|
suspicious = { level = "warn", priority = -1 }
|
||||||
|
complexity = { level = "warn", priority = -1 }
|
||||||
|
perf = { level = "warn", priority = -1 }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -1,77 +1,32 @@
|
|||||||
# Senbei
|
# Senbei
|
||||||
|
|
||||||
A static unpacker for Crackproof-protected 64-bit and 32-bit PE files. Point it
|
A static unpacker for CrackProof-protected Windows PE files and Android AArch64 shared libraries.
|
||||||
at a file 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._
|
> _"Crackproof"? It's senbei (煎餅 — rice cracker). Cracks itself._
|
||||||
|
|
||||||
Senbei reads a protected `.exe` or `.dll`, replays the unpacking algorithm
|
## Usage
|
||||||
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
|
```cmd
|
||||||
progress bar, and a run log. A browser version (WebAssembly, fully client-side)
|
cargo build --release
|
||||||
lives in [`web/`](web/).
|
senbei protected.exe
|
||||||
|
senbei game.apk
|
||||||
|
senbei "C:\Games\MyGame"
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs are written below an `unpack` directory unless `--out` is supplied.
|
||||||
|
|
||||||
|
Full documentation: <https://xn--ri8h.gitbook.io/crackproof-research/senbei>
|
||||||
|
|
||||||
## Legal notice and intended use
|
## Legal notice and intended use
|
||||||
|
|
||||||
**Read this before using Senbei.**
|
**Read this before using Senbei.**
|
||||||
|
|
||||||
- Senbei is a research and interoperability tool. It exists to enable lawful
|
- Senbei is a research and interoperability tool. It exists to enable lawful reverse engineering, security research, preservation, and interoperability with software you already legitimately possess.
|
||||||
reverse engineering, security research, preservation, and interoperability
|
- **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.
|
||||||
with software you already legitimately possess.
|
- 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.)
|
||||||
- **Only process binaries you own or are explicitly authorized to analyze.**
|
- Senbei does not enable online play, license fraud, or cheating, and must not be used to redistribute decrypted binaries. Do not upload outputs anywhere.
|
||||||
Depending on your jurisdiction and license agreements, circumventing
|
- The authors provide this software "as is", without warranty of any kind, and accept no liability for misuse. See [LICENSE](LICENSE) (AGPL-3.0).
|
||||||
technological protection measures may be restricted (for example under
|
- "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.
|
||||||
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 or secrets, and
|
|
||||||
distributes no keys, cracks, or copyrighted content.
|
|
||||||
- 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
|
|
||||||
|
|
||||||
| Kind | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| `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. |
|
|
||||||
|
|
||||||
Detection is content-based (header key-table at offset 4096, magic `KONN`),
|
|
||||||
not extension-based. Anything unrecognized is left untouched.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
senbei protected.exe
|
|
||||||
:: -> unpack\protected.unpack.exe
|
|
||||||
|
|
||||||
senbei "C:\Games\MyGame"
|
|
||||||
:: -> C:\Games\MyGame\unpack\... (recursive, skips non-targets)
|
|
||||||
```
|
|
||||||
|
|
||||||
Every output is sanity-checked statically; structurally broken results are
|
|
||||||
flagged as suspect rather than silently trusted.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [Usage reference](docs/usage.md) — CLI flags, exit codes, integrity check
|
|
||||||
- [Design](docs/design.md) — architecture, routing, and error model
|
|
||||||
- [Development](docs/development.md) — building, testing, environment variables
|
|
||||||
- [Web version](web/README.md) — run Senbei in a browser
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
-159
@@ -1,159 +0,0 @@
|
|||||||
# Design
|
|
||||||
|
|
||||||
Senbei is a fully static unpacker: it replays the unpacking algorithm on the
|
|
||||||
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
|
|
||||||
|
|
||||||
Senbei is a Cargo workspace split into a pure core and thin shells around it:
|
|
||||||
|
|
||||||
- **`senbei-pe/`** — the core. Pure functions over byte slices: no file I/O,
|
|
||||||
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-io/`** — filesystem and orchestration: recursive folder scanning,
|
|
||||||
per-run log file, progress bar, Explorer-friendly exit pause, and the
|
|
||||||
single-file/folder orchestration in `job.rs` (incl. the wasm-safe in-memory
|
|
||||||
byte API used by the web frontend).
|
|
||||||
- **`senbei-cli/`** — the `senbei` binary: argument parsing + dispatch. The
|
|
||||||
integration test suite (incl. the golden corpus test) lives in
|
|
||||||
`senbei-cli/tests/`.
|
|
||||||
|
|
||||||
```
|
|
||||||
senbei-cli/
|
|
||||||
└── 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)
|
|
||||||
├── scan.rs recursive Crackproof + metadata discovery
|
|
||||||
├── 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/
|
|
||||||
├── primitives.rs decrypt_data* steps, key derivation
|
|
||||||
├── bytecode.rs bytecode VM
|
|
||||||
├── tables.rs constant tables
|
|
||||||
└── crc32.rs checksum
|
|
||||||
senbei-pe/src/engine/ pure, panic-free, no-I/O core
|
|
||||||
├── mod.rs detection + unpack_auto dispatch
|
|
||||||
├── error.rs structured error taxonomy
|
|
||||||
├── integrity.rs static post-unpack sanity check
|
|
||||||
├── parallel.rs deterministic block-parallel fan-out
|
|
||||||
├── layout/ layout discovery + validation
|
|
||||||
│ ├── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Detection and routing
|
|
||||||
|
|
||||||
Detection is content-based (`unpacker::detect`), never extension-based: the
|
|
||||||
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`).
|
|
||||||
|
|
||||||
`unpack_auto` then dispatches:
|
|
||||||
|
|
||||||
- `NativeExe` / `ManagedExe` → the EXE pipeline (handles both PE32+ and
|
|
||||||
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
|
|
||||||
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
|
|
||||||
|
|
||||||
Some builds split a protected module into an on-disk loader stub plus an
|
|
||||||
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
|
|
||||||
|
|
||||||
Both pipelines are **heuristic with trial-and-validate**: where a layout
|
|
||||||
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
|
|
||||||
includes a small VM (`bytecode.rs`) that generates and interprets those
|
|
||||||
programs rather than hardcoding each variant's constants.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# Development
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
Requires a Rust toolchain (MSVC backend is the default on Windows;
|
|
||||||
`rustup-init.exe` from <https://rustup.rs> installs it). The pinned toolchain
|
|
||||||
and targets are in `rust-toolchain.toml`.
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
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
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
cargo test --release
|
|
||||||
```
|
|
||||||
|
|
||||||
The suite covers CLI behavior, detection, the folder driver, the run log, and
|
|
||||||
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
|
|
||||||
> 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)
|
|
||||||
|
|
||||||
- `DD8_SHIFT` — override the `decrypt_data8` page-XOR shift (`99` skips dd8
|
|
||||||
entirely).
|
|
||||||
- `SEL_DIAG` — print the dd8 selector's scores: the per-shift `0xCC` counts and
|
|
||||||
the plaintext baseline they are compared against (PE32+), and the per-formula
|
|
||||||
counts, baseline and net gain (PE32).
|
|
||||||
- `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).
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- The `senbei-pe/` core (and its `senbei-crypto/` base) is pure: no file I/O,
|
|
||||||
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
|
|
||||||
|
|
||||||
```
|
|
||||||
senbei/
|
|
||||||
├── Cargo.toml workspace root (members: the senbei-* crates)
|
|
||||||
├── rust-toolchain.toml pinned toolchain + targets
|
|
||||||
├── senbei-cli/ senbei binary (default member)
|
|
||||||
│ └── tests/ CLI, detection, golden, and folder tests
|
|
||||||
├── senbei-pe/ pure unpacker core (see docs/design.md)
|
|
||||||
├── senbei-crypto/ crypto/compression primitives
|
|
||||||
├── senbei-metadata/ il2cpp metadata de-obfuscation
|
|
||||||
├── senbei-io/ filesystem, scanning, CLI orchestration
|
|
||||||
├── senbei-wasm/ WebAssembly bindings crate (own Cargo.lock,
|
|
||||||
│ outside the workspace; builds into web/pkg/)
|
|
||||||
├── samples/ local-only test corpus (git-ignored)
|
|
||||||
├── web/ static browser frontend assets (+ built pkg/)
|
|
||||||
├── docs/ usage, design, and development documentation
|
|
||||||
└── .github/ CI workflows and issue templates
|
|
||||||
```
|
|
||||||
|
|
||||||
## Web build
|
|
||||||
|
|
||||||
See [web/README.md](../web/README.md). In short:
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
cd senbei-wasm
|
|
||||||
wasm-pack build --target web --release --out-dir ../web/pkg
|
|
||||||
```
|
|
||||||
|
|
||||||
then serve `web/` statically and open `index.html`. Everything runs
|
|
||||||
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.
|
|
||||||
-126
@@ -1,126 +0,0 @@
|
|||||||
# Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all]
|
|
||||||
[--no-log] [--no-pause] [-V|--version] [-h|--help]
|
|
||||||
```
|
|
||||||
|
|
||||||
Real runs print `Senbei <version>` once at start. Use `-V` / `--version` to
|
|
||||||
print the version and exit.
|
|
||||||
|
|
||||||
## Single file
|
|
||||||
|
|
||||||
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
|
|
||||||
senbei app.exe
|
|
||||||
:: -> unpack\app.unpack.exe
|
|
||||||
:: -> unpack\senbei-YYYYMMDD-HHMMSS.log
|
|
||||||
|
|
||||||
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
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Folder mode
|
|
||||||
|
|
||||||
Senbei walks the directory recursively, skips any subdirectory literally named
|
|
||||||
`unpack`, and unpacks every file it recognises as Crackproof-protected (by
|
|
||||||
content, not extension — renamed files and `.bak` backups are still found).
|
|
||||||
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
|
|
||||||
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
|
|
||||||
`._` 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integrity check
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
| Flag | Behavior |
|
|
||||||
| --- | --- |
|
|
||||||
| `--out DIR` | Write outputs (and the log, unless `--no-log`) under `DIR`. |
|
|
||||||
| `-v`, `--verbose` | Print detailed `[N/9]` per-stage unpack progress (and the destination path) for each file. In folder mode this replaces the progress bar. |
|
|
||||||
| `-q`, `--quiet` | Once: hide progress bar and per-file lines; keep banner, summary, and duration. Twice (`-q -q`): suppress all stdio (exit code only). |
|
|
||||||
| `--no-log` | Do not write `senbei-*.log`. Console output is unchanged by this flag alone. |
|
|
||||||
| `--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. |
|
|
||||||
| `--no-pause` | Skip the "Press Enter to exit" prompt (for scripted runs). |
|
|
||||||
| `-V`, `--version` | Print `Senbei <version>` and exit. |
|
|
||||||
| `-h`, `--help` | Show usage. |
|
|
||||||
|
|
||||||
On Windows, when launched from Explorer (the process owns its console) senbei
|
|
||||||
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 |
|
|
||||||
| --- | --- |
|
|
||||||
| `0` | Success (single file unpacked, or folder run with no errors). |
|
|
||||||
| `1` | At least one file failed, a scan probe was unreadable, or a single-file unpack errored. |
|
|
||||||
| `2` | Usage error: no path given, unknown option, missing `--out` value, or multiple input paths (help printed). |
|
|
||||||
|
|
||||||
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.
|
|
||||||
+5
-1
@@ -1,3 +1,7 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "stable"
|
channel = "1.99.1"
|
||||||
|
# CI invokes rustfmt/clippy through the rustup shim with no setup action, so
|
||||||
|
# the pinned toolchain must declare its components here — the runner images
|
||||||
|
# only preinstall them for their default toolchain.
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
targets = ["x86_64-pc-windows-msvc", "wasm32-unknown-unknown"]
|
targets = ["x86_64-pc-windows-msvc", "wasm32-unknown-unknown"]
|
||||||
+15
-76
@@ -1,84 +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.
|
|
||||||
@@ -13,8 +13,10 @@ 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
|
||||||
senbei-metadata.workspace = true
|
senbei-metadata.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
+187
-16
@@ -1,3 +1,4 @@
|
|||||||
|
use senbei_engine::android::ResourceLimits;
|
||||||
use senbei_io::{job, pause, scan};
|
use senbei_io::{job, pause, scan};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ fn main() -> std::process::ExitCode {
|
|||||||
let mut no_log = false;
|
let mut no_log = false;
|
||||||
let mut verbose = false;
|
let mut verbose = false;
|
||||||
let mut scan_all = false;
|
let mut scan_all = false;
|
||||||
|
let mut strict_integrity = false;
|
||||||
|
let mut limits = ResourceLimits::default();
|
||||||
|
|
||||||
while let Some(a) = args.next() {
|
while let Some(a) = args.next() {
|
||||||
match a.as_str() {
|
match a.as_str() {
|
||||||
@@ -26,6 +29,14 @@ fn main() -> std::process::ExitCode {
|
|||||||
"--no-pause" => no_pause = true,
|
"--no-pause" => no_pause = true,
|
||||||
"--no-log" => no_log = true,
|
"--no-log" => no_log = true,
|
||||||
"--scan-all" => scan_all = true,
|
"--scan-all" => scan_all = true,
|
||||||
|
"--strict-integrity" => strict_integrity = true,
|
||||||
|
flag @ ("--max-entry-mib" | "--max-total-mib" | "--max-entries" | "--max-depth") => {
|
||||||
|
if let Err(message) = set_resource_limit(&mut limits, flag, args.next().as_deref())
|
||||||
|
{
|
||||||
|
eprintln!("error: {message}");
|
||||||
|
return std::process::ExitCode::from(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
"--out" => match args.next() {
|
"--out" => match args.next() {
|
||||||
Some(v) if !v.starts_with('-') => out = Some(v),
|
Some(v) if !v.starts_with('-') => out = Some(v),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -60,31 +71,25 @@ fn main() -> std::process::ExitCode {
|
|||||||
let p = Path::new(&p);
|
let p = Path::new(&p);
|
||||||
let out_path = out.as_deref().map(Path::new);
|
let out_path = out.as_deref().map(Path::new);
|
||||||
let result = if p.is_dir() {
|
let result = if p.is_dir() {
|
||||||
job::run_folder_opts(
|
job::run_folder_with_limits(
|
||||||
p,
|
p,
|
||||||
out_path,
|
out_path,
|
||||||
quiet,
|
quiet,
|
||||||
verbose,
|
verbose,
|
||||||
no_log,
|
no_log,
|
||||||
scan_all || scan::scan_all_env(),
|
scan_all || scan::scan_all_env(),
|
||||||
|
&limits,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
job::run_file_v(p, out_path, quiet, verbose, no_log)
|
job::run_file_with_limits(p, out_path, quiet, verbose, no_log, &limits)
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(summary) => {
|
Ok(summary) => {
|
||||||
if quiet < 2 {
|
if quiet < 2 {
|
||||||
println!(
|
println!("{}", summary.line());
|
||||||
"{} unpacked · {} skipped · {} errors · {} suspect · {} metadata",
|
|
||||||
summary.unpacked,
|
|
||||||
summary.skipped,
|
|
||||||
summary.errors,
|
|
||||||
summary.suspect,
|
|
||||||
summary.metadata
|
|
||||||
);
|
|
||||||
println!("done in {} ms", summary.duration_ms);
|
println!("done in {} ms", summary.duration_ms);
|
||||||
}
|
}
|
||||||
if summary.errors > 0 { 1 } else { 0 }
|
exit_code(&summary, strict_integrity)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if quiet < 2 {
|
if quiet < 2 {
|
||||||
@@ -97,16 +102,182 @@ fn main() -> std::process::ExitCode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pause::maybe_pause(no_pause);
|
pause::maybe_pause(no_pause);
|
||||||
std::process::ExitCode::from(code as u8)
|
std::process::ExitCode::from(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process status for a finished run.
|
||||||
|
///
|
||||||
|
/// Suspect outputs are still written. By default a suspect file is a warning
|
||||||
|
/// and the process stays successful; strict mode treats it like an error so a
|
||||||
|
/// caller can fail the run. An error fails the run either way.
|
||||||
|
fn exit_code(summary: &job::Summary, strict_integrity: bool) -> u8 {
|
||||||
|
if summary.errors > 0 || (strict_integrity && summary.suspect > 0) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_resource_limit(
|
||||||
|
limits: &mut ResourceLimits,
|
||||||
|
flag: &str,
|
||||||
|
value: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let invalid = || format!("{flag} requires an unsigned integer argument");
|
||||||
|
let text = value
|
||||||
|
.filter(|text| !text.is_empty() && text.bytes().all(|byte| byte.is_ascii_digit()))
|
||||||
|
.ok_or_else(invalid)?;
|
||||||
|
let value: u64 = text.parse().map_err(|_| invalid())?;
|
||||||
|
if value == 0 && flag != "--max-depth" {
|
||||||
|
return Err(format!("{flag} must be greater than zero"));
|
||||||
|
}
|
||||||
|
match flag {
|
||||||
|
"--max-entry-mib" | "--max-total-mib" => {
|
||||||
|
let bytes = value
|
||||||
|
.checked_mul(1024 * 1024)
|
||||||
|
.ok_or_else(|| format!("{flag} exceeds the supported byte range"))?;
|
||||||
|
if flag == "--max-entry-mib" {
|
||||||
|
limits.max_entry_bytes = bytes;
|
||||||
|
} else {
|
||||||
|
limits.max_total_bytes = bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--max-entries" => limits.max_entries = usize::try_from(value).map_err(|_| invalid())?,
|
||||||
|
"--max-depth" => limits.max_depth = usize::try_from(value).map_err(|_| invalid())?,
|
||||||
|
_ => return Err(format!("unknown resource option '{flag}'")),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]"
|
"senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--strict-integrity] [--max-entry-mib N] [--max-total-mib N] [--max-entries N] [--max-depth N] [--no-log] [--no-pause] [-V|--version] [-h|--help]"
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
" --scan-all probe every file in a folder, including ones the scan\n\
|
" input a Crackproof PE (.exe/.dll), an il2cpp global-metadata.dat,\n\
|
||||||
\x20 pre-filter skips (under 4128 bytes, extensionless,\n\
|
\x20 a protected Android AArch64 library (.so), an Android app\n\
|
||||||
\x20 or a bulk-asset extension). Much slower on large trees."
|
\x20 package (.apk/.apks/.xapk), or a folder containing any of\n\
|
||||||
|
\x20 these"
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" --scan-all probe selected .exe/.dll/.so/metadata names below the\n\
|
||||||
|
\x20 size floor; other filenames remain excluded."
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" --strict-integrity\n\
|
||||||
|
\x20 exit non-zero when any output fails the static integrity\n\
|
||||||
|
\x20 check (a suspect) or a file errors. Off by default: a\n\
|
||||||
|
\x20 suspect is still written and the process exits 0 unless\n\
|
||||||
|
\x20 a file errors. Quiet levels are unchanged."
|
||||||
|
);
|
||||||
|
let defaults = ResourceLimits::default();
|
||||||
|
println!(
|
||||||
|
" Android resource limits (MiB = 1048576 bytes):\n\
|
||||||
|
\x20 --max-entry-mib N single archive entry/decoded buffer/output (default {})\n\
|
||||||
|
\x20 --max-total-mib N cumulative archive bytes and per-engine task budget (default {})\n\
|
||||||
|
\x20 --max-entries N archive entries/stage-2 records (default {})\n\
|
||||||
|
\x20 --max-depth N nesting depth, root = 0 (default {})\n\
|
||||||
|
\x20 Limits are checked before extraction/allocation; they are not a peak-RSS cap.",
|
||||||
|
defaults.max_entry_bytes / (1024 * 1024),
|
||||||
|
defaults.max_total_bytes / (1024 * 1024),
|
||||||
|
defaults.max_entries,
|
||||||
|
defaults.max_depth,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod strict_integrity {
|
||||||
|
use super::exit_code;
|
||||||
|
use senbei_io::job::Summary;
|
||||||
|
|
||||||
|
fn summary(errors: usize, suspect: usize) -> Summary {
|
||||||
|
Summary {
|
||||||
|
errors,
|
||||||
|
suspect,
|
||||||
|
..Summary::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_run_exits_0_in_both_modes() {
|
||||||
|
let summary = summary(0, 0);
|
||||||
|
assert_eq!(exit_code(&summary, false), 0);
|
||||||
|
assert_eq!(exit_code(&summary, true), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_exit_1_whether_or_not_strict() {
|
||||||
|
let summary = summary(2, 0);
|
||||||
|
assert_eq!(exit_code(&summary, false), 1);
|
||||||
|
assert_eq!(exit_code(&summary, true), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspect_exits_0_by_default_and_1_when_strict() {
|
||||||
|
let summary = summary(0, 1);
|
||||||
|
assert_eq!(exit_code(&summary, false), 0);
|
||||||
|
assert_eq!(exit_code(&summary, true), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_with_suspects_exit_1_in_both_modes() {
|
||||||
|
let summary = summary(1, 3);
|
||||||
|
assert_eq!(exit_code(&summary, false), 1);
|
||||||
|
assert_eq!(exit_code(&summary, true), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod resource_limits {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_override_only_the_selected_limit() {
|
||||||
|
let mut limits = ResourceLimits::default();
|
||||||
|
set_resource_limit(&mut limits, "--max-entry-mib", Some("4096")).unwrap();
|
||||||
|
assert_eq!(limits.max_entry_bytes, 4096 * 1024 * 1024);
|
||||||
|
assert_eq!(
|
||||||
|
limits.max_total_bytes,
|
||||||
|
ResourceLimits::default().max_total_bytes
|
||||||
|
);
|
||||||
|
set_resource_limit(&mut limits, "--max-total-mib", Some("16384")).unwrap();
|
||||||
|
set_resource_limit(&mut limits, "--max-entries", Some("123")).unwrap();
|
||||||
|
set_resource_limit(&mut limits, "--max-depth", Some("0")).unwrap();
|
||||||
|
assert_eq!(limits.max_total_bytes, 16384 * 1024 * 1024);
|
||||||
|
assert_eq!(limits.max_entries, 123);
|
||||||
|
assert_eq!(limits.max_depth, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_and_overflowing_limits_are_usage_errors() {
|
||||||
|
let mut limits = ResourceLimits::default();
|
||||||
|
for flag in [
|
||||||
|
"--max-entry-mib",
|
||||||
|
"--max-total-mib",
|
||||||
|
"--max-entries",
|
||||||
|
"--max-depth",
|
||||||
|
] {
|
||||||
|
for value in [
|
||||||
|
None,
|
||||||
|
Some(""),
|
||||||
|
Some("-1"),
|
||||||
|
Some("1.5"),
|
||||||
|
Some("--no-log"),
|
||||||
|
Some("18446744073709551616"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
set_resource_limit(&mut limits, flag, value).is_err(),
|
||||||
|
"{flag} {value:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for flag in ["--max-entry-mib", "--max-total-mib", "--max-entries"] {
|
||||||
|
assert!(set_resource_limit(&mut limits, flag, Some("0")).is_err());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
set_resource_limit(&mut limits, "--max-entry-mib", Some("18446744073709551615"))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(limits, ResourceLimits::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
//! Corpus test over the user-managed Android samples.
|
||||||
|
//!
|
||||||
|
//! Each immediate subdirectory of `samples/android/` that contains a `lib/`
|
||||||
|
//! tree is one app-package sample (an extracted APK layout). For every
|
||||||
|
//! protected AArch64 `.so` found by content probe, the test runs the real
|
||||||
|
//! restore pipeline and checks the result:
|
||||||
|
//!
|
||||||
|
//! - `<name>.golden.so.sha256` next to the input pins the restored bytes
|
||||||
|
//! (byte-identity through the digest; absent sidecar -> WARNING).
|
||||||
|
//! - `<name>.restore-fails` (empty marker) documents an input whose restore
|
||||||
|
//! is known to fail; the test then *requires* failure, so a future fix
|
||||||
|
//! surfaces as a test failure too. Without the marker a failed restore is
|
||||||
|
//! a test failure.
|
||||||
|
//! - A restored library carrying an unwrappable embedded metadata blob must
|
||||||
|
//! produce one, pinned by `<name>.golden.metadata.sha256`.
|
||||||
|
//!
|
||||||
|
//! The folder-mode driver is then run over each app dir to exercise the
|
||||||
|
//! scan/restore/write path end to end; its error count must equal the number
|
||||||
|
//! of marked known-failures.
|
||||||
|
//!
|
||||||
|
//! The corpus is git-ignored and absent on CI (no binaries in the repo);
|
||||||
|
//! `SENBEI_REQUIRE_SAMPLES=1` turns an absent corpus into a failure, and
|
||||||
|
//! `SENBEI_ANDROID_SAMPLES` overrides the corpus location.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use senbei_io::{android, job};
|
||||||
|
|
||||||
|
fn corpus_dir() -> PathBuf {
|
||||||
|
if let Some(dir) = std::env::var_os("SENBEI_ANDROID_SAMPLES") {
|
||||||
|
return PathBuf::from(dir);
|
||||||
|
}
|
||||||
|
common::samples_dir().join("android")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immediate subdirectories of `root` that hold an app tree (a `lib/`
|
||||||
|
/// folder) — research notes, dumps, and other non-app material in the corpus
|
||||||
|
/// never match.
|
||||||
|
fn app_dirs(root: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
|
||||||
|
.unwrap_or_else(|e| panic!("read {}: {e}", root.display()))
|
||||||
|
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||||
|
.filter(|path| path.is_dir() && path.join("lib").is_dir())
|
||||||
|
.collect();
|
||||||
|
dirs.sort();
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every regular `.so` below `dir`, skipping previous output trees.
|
||||||
|
fn collect_so_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||||
|
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||||
|
.unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
|
||||||
|
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||||
|
.collect();
|
||||||
|
entries.sort();
|
||||||
|
for path in entries {
|
||||||
|
if path.is_dir() {
|
||||||
|
if path
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|name| !name.eq_ignore_ascii_case("unpack"))
|
||||||
|
{
|
||||||
|
collect_so_files(&path, out);
|
||||||
|
}
|
||||||
|
} else if path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("so"))
|
||||||
|
{
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(data: &[u8]) -> String {
|
||||||
|
use sha2::Digest;
|
||||||
|
let mut digest = sha2::Sha256::new();
|
||||||
|
digest.update(data);
|
||||||
|
let mut out = String::with_capacity(64);
|
||||||
|
for byte in digest.finalize() {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<stem>.golden.so.sha256` next to `input`.
|
||||||
|
fn golden_sidecar(input: &Path, artifact: &str) -> PathBuf {
|
||||||
|
let file = input.file_name().unwrap().to_string_lossy();
|
||||||
|
let stem = file.strip_suffix(".so").unwrap_or(&file);
|
||||||
|
input.with_file_name(format!("{stem}.golden.{artifact}.sha256"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_sidecar(path: &Path) -> Option<String> {
|
||||||
|
std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.map(|text| text.trim().to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_samples_restore_against_goldens() {
|
||||||
|
let root = corpus_dir();
|
||||||
|
// Same opt-in gate as the PE corpus test: an absent corpus is a no-op
|
||||||
|
// pass unless CI explicitly requires it.
|
||||||
|
let require = std::env::var_os("SENBEI_REQUIRE_SAMPLES").is_some();
|
||||||
|
if !root.is_dir() {
|
||||||
|
assert!(
|
||||||
|
!require,
|
||||||
|
"android samples: {} does not exist — corpus required (CI)",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
eprintln!(
|
||||||
|
"android samples: {} does not exist, nothing to test",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let apps = app_dirs(&root);
|
||||||
|
if apps.is_empty() {
|
||||||
|
assert!(
|
||||||
|
!require,
|
||||||
|
"android samples: no app trees under {} — corpus required (CI)",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
eprintln!("android samples: no app trees under {}", root.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut passed = 0usize;
|
||||||
|
let mut warnings: Vec<String> = Vec::new();
|
||||||
|
let mut failures: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for app in &apps {
|
||||||
|
let mut so_files = Vec::new();
|
||||||
|
collect_so_files(app, &mut so_files);
|
||||||
|
let protected: Vec<PathBuf> = so_files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|path| android::is_protected_so_file(path))
|
||||||
|
.collect();
|
||||||
|
let mut known_failures = 0usize;
|
||||||
|
|
||||||
|
for input in &protected {
|
||||||
|
let name = input.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
let known_fails = input.with_file_name(format!(
|
||||||
|
"{}.restore-fails",
|
||||||
|
name.strip_suffix(".so").unwrap_or(&name)
|
||||||
|
));
|
||||||
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
let dest = temp.path().join("restored.so");
|
||||||
|
match android::restore_so_file(input, &dest, false) {
|
||||||
|
Ok(embedded) => {
|
||||||
|
if known_fails.is_file() {
|
||||||
|
failures.push(format!(
|
||||||
|
"{name}: restore succeeded but a restore-fails marker exists \
|
||||||
|
(delete the marker — the gap is fixed)"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(&dest).expect("read restored output");
|
||||||
|
match read_sidecar(&golden_sidecar(input, "so")) {
|
||||||
|
Some(expected) if expected == sha256_hex(&bytes) => passed += 1,
|
||||||
|
Some(expected) => failures.push(format!(
|
||||||
|
"{name}: restored bytes differ from golden\n expected sha256 {expected}\n actual sha256 {}",
|
||||||
|
sha256_hex(&bytes)
|
||||||
|
)),
|
||||||
|
None => warnings.push(format!(
|
||||||
|
"{name}: no golden sidecar — restored sha256 {}",
|
||||||
|
sha256_hex(&bytes)
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
if let Some(blob) = embedded {
|
||||||
|
match read_sidecar(&golden_sidecar(input, "metadata")) {
|
||||||
|
Some(expected) if expected == sha256_hex(&blob) => {}
|
||||||
|
Some(expected) => failures.push(format!(
|
||||||
|
"{name}: embedded metadata differs from golden\n expected sha256 {expected}\n actual sha256 {}",
|
||||||
|
sha256_hex(&blob)
|
||||||
|
)),
|
||||||
|
None => warnings.push(format!(
|
||||||
|
"{name}: no embedded-metadata sidecar — sha256 {}",
|
||||||
|
sha256_hex(&blob)
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
if known_fails.is_file() {
|
||||||
|
known_failures += 1;
|
||||||
|
} else {
|
||||||
|
failures.push(format!("{name}: restore failed: {error:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder-mode smoke run: the scan must route every protected library,
|
||||||
|
// and only the marked known-failures may error.
|
||||||
|
let out_temp = tempfile::tempdir().expect("tempdir");
|
||||||
|
match job::run_folder_opts(app, Some(out_temp.path()), 2, false, true, false) {
|
||||||
|
Ok(summary) => {
|
||||||
|
if summary.errors != known_failures {
|
||||||
|
failures.push(format!(
|
||||||
|
"{}: folder run errors {} != known-failure markers {known_failures}",
|
||||||
|
app.display(),
|
||||||
|
summary.errors
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.unpacked < protected.len().saturating_sub(known_failures) {
|
||||||
|
failures.push(format!(
|
||||||
|
"{}: folder run restored {} libraries, per-file pass found {} ({} known-failing)",
|
||||||
|
app.display(),
|
||||||
|
summary.unpacked,
|
||||||
|
protected.len(),
|
||||||
|
known_failures
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => failures.push(format!("{}: folder run failed: {error:#}", app.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for warning in &warnings {
|
||||||
|
eprintln!("WARNING: {warning}");
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"android samples: {} app tree(s) — {passed} pass, {} warning(s), {} failure(s)",
|
||||||
|
apps.len(),
|
||||||
|
warnings.len(),
|
||||||
|
failures.len()
|
||||||
|
);
|
||||||
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::sync::{Arc, Barrier};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folder_runs_preserve_the_host_panic_hook() {
|
||||||
|
const CHILD: &str = "SENBEI_PANIC_HOOK_TEST_CHILD";
|
||||||
|
if std::env::var_os(CHILD).is_none() {
|
||||||
|
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args([
|
||||||
|
"--exact",
|
||||||
|
"folder_runs_preserve_the_host_panic_hook",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env(CHILD, "1")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"child failed: {}\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
static CALLS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
std::panic::set_hook(Box::new(|_| {
|
||||||
|
CALLS.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}));
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
// Exercise repeated job lifetimes both before and after engine capture.
|
||||||
|
for _ in 0..2 {
|
||||||
|
senbei_io::job::run_folder_opts(root.path(), None, 2, false, true, false).unwrap();
|
||||||
|
assert!(senbei_engine::catch_unwind_quiet(|| panic!("captured")).is_err());
|
||||||
|
assert!(std::panic::catch_unwind(|| panic!("host")).is_err());
|
||||||
|
}
|
||||||
|
assert_eq!(CALLS.load(Ordering::SeqCst), 2);
|
||||||
|
|
||||||
|
let barrier = Arc::new(Barrier::new(2));
|
||||||
|
let worker_barrier = barrier.clone();
|
||||||
|
let worker = std::thread::spawn(move || {
|
||||||
|
senbei_engine::catch_unwind_quiet(|| {
|
||||||
|
worker_barrier.wait();
|
||||||
|
worker_barrier.wait();
|
||||||
|
panic!("captured in another thread");
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
});
|
||||||
|
barrier.wait();
|
||||||
|
// A quiet capture in another thread must not silence unrelated panics.
|
||||||
|
assert!(std::panic::catch_unwind(|| panic!("unrelated host panic")).is_err());
|
||||||
|
barrier.wait();
|
||||||
|
worker.join().unwrap();
|
||||||
|
assert_eq!(CALLS.load(Ordering::SeqCst), 3);
|
||||||
|
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
for _ in 0..4 {
|
||||||
|
let path = root.path();
|
||||||
|
scope.spawn(move || {
|
||||||
|
senbei_io::job::run_folder_opts(path, None, 2, false, true, false).unwrap();
|
||||||
|
assert!(
|
||||||
|
senbei_engine::catch_unwind_quiet(|| panic!("after concurrent job")).is_err()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(CALLS.load(Ordering::SeqCst), 3);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn command() -> Command {
|
||||||
|
let mut command = Command::new(env!("CARGO_BIN_EXE_senbei"));
|
||||||
|
command.args(["--no-pause", "--no-log"]);
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_lists_resource_limits_and_defaults() {
|
||||||
|
let output = command().arg("--help").output().unwrap();
|
||||||
|
assert!(output.status.success());
|
||||||
|
let text = String::from_utf8(output.stdout).unwrap();
|
||||||
|
for option in [
|
||||||
|
"--max-entry-mib",
|
||||||
|
"--max-total-mib",
|
||||||
|
"--max-entries",
|
||||||
|
"--max-depth",
|
||||||
|
] {
|
||||||
|
assert!(text.contains(option), "missing {option}");
|
||||||
|
}
|
||||||
|
for value in ["2048", "8192", "100000", "32", "not a peak-RSS cap"] {
|
||||||
|
assert!(text.contains(value), "missing {value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_limits_exit_with_usage_error() {
|
||||||
|
for option in [
|
||||||
|
"--max-entry-mib",
|
||||||
|
"--max-total-mib",
|
||||||
|
"--max-entries",
|
||||||
|
"--max-depth",
|
||||||
|
] {
|
||||||
|
for value in [
|
||||||
|
None,
|
||||||
|
Some("-1"),
|
||||||
|
Some("invalid"),
|
||||||
|
Some("18446744073709551616"),
|
||||||
|
] {
|
||||||
|
let mut command = command();
|
||||||
|
command.arg(option);
|
||||||
|
if let Some(value) = value {
|
||||||
|
command.arg(value);
|
||||||
|
}
|
||||||
|
let output = command.output().unwrap();
|
||||||
|
assert_eq!(output.status.code(), Some(2), "{option} {value:?}");
|
||||||
|
assert!(String::from_utf8_lossy(&output.stderr).contains(option));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limits_compose_with_strict_and_quiet_modes() {
|
||||||
|
let input = tempfile::tempdir().unwrap();
|
||||||
|
let out = tempfile::tempdir().unwrap();
|
||||||
|
let output = command()
|
||||||
|
.arg(input.path())
|
||||||
|
.arg("--out")
|
||||||
|
.arg(out.path())
|
||||||
|
.args([
|
||||||
|
"--max-entry-mib",
|
||||||
|
"1",
|
||||||
|
"--max-total-mib",
|
||||||
|
"2",
|
||||||
|
"--max-entries",
|
||||||
|
"1",
|
||||||
|
"--max-depth",
|
||||||
|
"0",
|
||||||
|
"--strict-integrity",
|
||||||
|
"-q",
|
||||||
|
"-q",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(output.status.success(), "{output:?}");
|
||||||
|
assert!(output.stdout.is_empty());
|
||||||
|
assert!(output.stderr.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zip_entry_limit_rejects_before_central_directory_indexing() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let package = temp.path().join("large.apk");
|
||||||
|
// A ZIP end record advertising two entries is enough to exercise the
|
||||||
|
// preflight. Its directory is intentionally absent: the count limit must
|
||||||
|
// reject it before the ZIP parser tries to read that directory.
|
||||||
|
let mut bytes = b"PK\x03\x04".to_vec();
|
||||||
|
let mut end = [0_u8; 22];
|
||||||
|
end[..4].copy_from_slice(b"PK\x05\x06");
|
||||||
|
end[8..10].copy_from_slice(&2_u16.to_le_bytes());
|
||||||
|
end[10..12].copy_from_slice(&2_u16.to_le_bytes());
|
||||||
|
bytes.extend_from_slice(&end);
|
||||||
|
std::fs::write(&package, bytes).unwrap();
|
||||||
|
let output = command()
|
||||||
|
.arg(&package)
|
||||||
|
.arg("--out")
|
||||||
|
.arg(temp.path().join("out"))
|
||||||
|
.args(["--max-entries", "1"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output.status.code(), Some(1));
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&output.stderr).contains("entry limit"),
|
||||||
|
"{output:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! CLI behavior of `--strict-integrity`.
|
||||||
|
//!
|
||||||
|
//! The flag is off by default and only changes the exit status (see the unit
|
||||||
|
//! tests in `src/main.rs` for the errors-versus-suspect matrix). These tests
|
||||||
|
//! drive the binary: help text, flag parsing, the error exit, and the quiet
|
||||||
|
//! contract. The suspect-only exit policy is covered separately by unit tests
|
||||||
|
//! so these process tests do not depend on the optional protected corpus.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn senbei() -> Command {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_senbei"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: &[&str]) -> std::process::Output {
|
||||||
|
senbei().args(args).output().expect("spawn senbei")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_silent(output: &std::process::Output) {
|
||||||
|
assert!(
|
||||||
|
output.stdout.is_empty() && output.stderr.is_empty(),
|
||||||
|
"quiet>=2 must stay silent, stdout={:?} stderr={:?}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_plain(dir: &Path) -> std::path::PathBuf {
|
||||||
|
let path = dir.join("plain.bin");
|
||||||
|
std::fs::write(&path, b"not a protected image").unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_logs(dir: &Path) -> Vec<std::path::PathBuf> {
|
||||||
|
std::fs::read_dir(dir)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|path| {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| name.starts_with("senbei-") && name.ends_with(".log"))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_documents_strict_integrity_behavior() {
|
||||||
|
let output = run(&["--help"]);
|
||||||
|
assert!(output.status.success());
|
||||||
|
let text = String::from_utf8(output.stdout).unwrap();
|
||||||
|
assert!(text.contains("--strict-integrity"));
|
||||||
|
assert!(text.contains("--quiet"));
|
||||||
|
assert!(text.contains("exit non-zero"));
|
||||||
|
assert!(text.contains("suspect"));
|
||||||
|
assert!(text.contains("Off by default"));
|
||||||
|
assert!(text.contains("still written"));
|
||||||
|
assert!(text.contains("exits 0 unless"));
|
||||||
|
assert!(text.contains("a file errors"));
|
||||||
|
assert!(text.contains("Quiet levels are unchanged"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_help_and_version_ignore_the_flag_position() {
|
||||||
|
let help = run(&["-h", "--strict-integrity"]);
|
||||||
|
assert!(help.status.success());
|
||||||
|
assert!(String::from_utf8_lossy(&help.stdout).contains("--strict-integrity"));
|
||||||
|
|
||||||
|
let version = run(&["--version"]);
|
||||||
|
assert!(version.status.success());
|
||||||
|
assert!(String::from_utf8_lossy(&version.stdout).starts_with("Senbei "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_flag_still_exits_2() {
|
||||||
|
let output = run(&["--strict"]);
|
||||||
|
assert_eq!(output.status.code(), Some(2));
|
||||||
|
let err = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(err.contains("unknown option '--strict'"));
|
||||||
|
assert!(String::from_utf8_lossy(&output.stdout).contains("--strict-integrity"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_input_exits_2_with_the_flag() {
|
||||||
|
let output = run(&["--strict-integrity", "--no-pause"]);
|
||||||
|
assert_eq!(output.status.code(), Some(2));
|
||||||
|
assert!(String::from_utf8_lossy(&output.stdout).contains("--strict-integrity"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_exit_1_by_default_and_when_strict_and_write_nothing() {
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
let input = write_plain(td.path());
|
||||||
|
for extra in [None, Some("--strict-integrity")] {
|
||||||
|
let out = td
|
||||||
|
.path()
|
||||||
|
.join(if extra.is_some() { "strict" } else { "default" });
|
||||||
|
let mut args = vec![
|
||||||
|
input.to_str().unwrap(),
|
||||||
|
"--out",
|
||||||
|
out.to_str().unwrap(),
|
||||||
|
"--no-log",
|
||||||
|
"--no-pause",
|
||||||
|
];
|
||||||
|
if let Some(flag) = extra {
|
||||||
|
args.push(flag);
|
||||||
|
}
|
||||||
|
let output = run(&args);
|
||||||
|
assert_eq!(output.status.code(), Some(1), "{output:?}");
|
||||||
|
assert!(
|
||||||
|
!out.join("plain.unpack.bin").exists(),
|
||||||
|
"an error must not leave an artifact"
|
||||||
|
);
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(text.contains("0 unpacked"));
|
||||||
|
assert!(text.contains("1 errors"));
|
||||||
|
assert!(text.contains("0 suspect"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_quiet_stays_silent_for_errors_in_both_modes() {
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
let input = write_plain(td.path());
|
||||||
|
let out = td.path().join("out");
|
||||||
|
for extra in [None, Some("--strict-integrity")] {
|
||||||
|
let mut args = vec![
|
||||||
|
input.to_str().unwrap(),
|
||||||
|
"--out",
|
||||||
|
out.to_str().unwrap(),
|
||||||
|
"--no-log",
|
||||||
|
"--no-pause",
|
||||||
|
"-q",
|
||||||
|
"-q",
|
||||||
|
];
|
||||||
|
if let Some(flag) = extra {
|
||||||
|
args.push(flag);
|
||||||
|
}
|
||||||
|
let output = run(&args);
|
||||||
|
assert_eq!(output.status.code(), Some(1));
|
||||||
|
assert_silent(&output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_quiet_keeps_the_summary_and_the_error() {
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
let input = write_plain(td.path());
|
||||||
|
let out = td.path().join("out");
|
||||||
|
let output = run(&[
|
||||||
|
input.to_str().unwrap(),
|
||||||
|
"--out",
|
||||||
|
out.to_str().unwrap(),
|
||||||
|
"--no-log",
|
||||||
|
"--no-pause",
|
||||||
|
"-q",
|
||||||
|
"--strict-integrity",
|
||||||
|
]);
|
||||||
|
assert_eq!(output.status.code(), Some(1));
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
// One -q only suppresses per-file lines. The banner and the summary still
|
||||||
|
// print; that is the existing quiet contract and strict mode must not
|
||||||
|
// change it.
|
||||||
|
assert!(stdout.contains("Senbei "), "{stdout}");
|
||||||
|
assert!(stdout.contains("1 errors"), "{stdout}");
|
||||||
|
assert!(stdout.contains("0 suspect"), "{stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_does_not_change_what_the_log_records() {
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
let input = write_plain(td.path());
|
||||||
|
let out = td.path().join("out");
|
||||||
|
let output = run(&[
|
||||||
|
input.to_str().unwrap(),
|
||||||
|
"--out",
|
||||||
|
out.to_str().unwrap(),
|
||||||
|
"--strict-integrity",
|
||||||
|
"--no-pause",
|
||||||
|
"-q",
|
||||||
|
"-q",
|
||||||
|
]);
|
||||||
|
assert_eq!(output.status.code(), Some(1));
|
||||||
|
assert_silent(&output);
|
||||||
|
let logs = list_logs(&out);
|
||||||
|
assert_eq!(logs.len(), 1);
|
||||||
|
let text = std::fs::read_to_string(&logs[0]).unwrap();
|
||||||
|
assert!(text.contains("summary:"));
|
||||||
|
assert!(text.contains("1 errors"));
|
||||||
|
assert!(text.contains("0 suspect"));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//! Android container cryptography and decoding primitives.
|
||||||
|
|
||||||
|
mod protector;
|
||||||
|
|
||||||
|
pub use protector::{
|
||||||
|
ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, OutputLimit,
|
||||||
|
ProtectedDescriptor, decode_container, decode_container_with_limit, gf32_mul_fixed,
|
||||||
|
transform_segment,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large.
Load diff
+15
-72
@@ -1,77 +1,20 @@
|
|||||||
//! Cryptographic, checksum, compression, and bytecode primitives.
|
//! Cryptographic and compression primitives for the supported protection
|
||||||
|
//! formats.
|
||||||
|
|
||||||
pub mod bytecode;
|
pub mod android;
|
||||||
pub mod crc32;
|
pub mod windows;
|
||||||
pub mod primitives;
|
|
||||||
mod tables;
|
|
||||||
|
|
||||||
/// Maximum buffer size accepted by allocation-sensitive transforms.
|
// Keep the historical flat paths available to downstream callers while the
|
||||||
pub const MAX_IMAGE_SIZE: u64 = 1 << 30;
|
// implementations themselves live under their platform boundary.
|
||||||
|
pub use windows::{BufferOperation, DecompressionFailure, Error, MAX_IMAGE_SIZE};
|
||||||
|
pub use windows::{bytecode, crc32, primitives};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// Lowercase hexadecimal representation for digest and diagnostic bytes.
|
||||||
pub enum BufferOperation {
|
#[must_use]
|
||||||
Read,
|
pub fn hex_digest(data: &[u8]) -> String {
|
||||||
CopySource,
|
let mut output = String::with_capacity(data.len() * 2);
|
||||||
CopyDestination,
|
for byte in data {
|
||||||
ZeroFill,
|
output.push_str(&format!("{byte:02x}"));
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for BufferOperation {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(match self {
|
|
||||||
Self::Read => "read",
|
|
||||||
Self::CopySource => "copy source",
|
|
||||||
Self::CopyDestination => "copy destination",
|
|
||||||
Self::ZeroFill => "zero-fill",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
output
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
||||||
pub enum Error {
|
|
||||||
#[error(
|
|
||||||
"{operation} range out of bounds (offset {offset}, size {size}, buffer length {buffer_len})"
|
|
||||||
)]
|
|
||||||
BufferRangeOutOfBounds {
|
|
||||||
operation: BufferOperation,
|
|
||||||
offset: usize,
|
|
||||||
size: usize,
|
|
||||||
buffer_len: usize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum DecompressionFailure {
|
|
||||||
#[error("compressed source size {size} exceeds limit {max}")]
|
|
||||||
SourceTooLarge { size: u32, max: u64 },
|
|
||||||
#[error("Huffman code length {bits} is invalid")]
|
|
||||||
InvalidCodeLength { bits: u8 },
|
|
||||||
#[error("Huffman tree traversal exceeded 64 levels")]
|
|
||||||
HuffmanTraversalLimit,
|
|
||||||
#[error("pending length accumulator overflowed at {pending}")]
|
|
||||||
PendingLengthOverflow { pending: u32 },
|
|
||||||
#[error("output step {step} at byte {written} exceeds expected size {expected}")]
|
|
||||||
OutputOverflow {
|
|
||||||
written: u32,
|
|
||||||
step: u32,
|
|
||||||
expected: u32,
|
|
||||||
},
|
|
||||||
#[error("run-fill width {width} reads before output offset 0x{destination:08X}")]
|
|
||||||
RunFillBeforeOutput { width: u32, destination: u32 },
|
|
||||||
#[error("run-fill width {width} is unsupported")]
|
|
||||||
InvalidRunFillWidth { width: u32 },
|
|
||||||
#[error("back-reference distance {distance} exceeds {written} written bytes")]
|
|
||||||
InvalidBackReference { distance: u32, written: u32 },
|
|
||||||
#[error("Huffman symbol consumed no input and produced no output")]
|
|
||||||
NoProgress,
|
|
||||||
#[error(
|
|
||||||
"output size mismatch (wrote {written}/{expected} bytes after consuming {consumed}/{source_size})"
|
|
||||||
)]
|
|
||||||
OutputSizeMismatch {
|
|
||||||
written: u32,
|
|
||||||
expected: u32,
|
|
||||||
consumed: u32,
|
|
||||||
source_size: u32,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,77 @@
|
|||||||
|
//! Windows PE protection primitives.
|
||||||
|
|
||||||
|
pub mod bytecode;
|
||||||
|
pub mod crc32;
|
||||||
|
pub mod primitives;
|
||||||
|
mod tables;
|
||||||
|
|
||||||
|
/// Maximum buffer size accepted by allocation-sensitive PE transforms.
|
||||||
|
pub const MAX_IMAGE_SIZE: u64 = 1 << 30;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BufferOperation {
|
||||||
|
Read,
|
||||||
|
CopySource,
|
||||||
|
CopyDestination,
|
||||||
|
ZeroFill,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for BufferOperation {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Read => "read",
|
||||||
|
Self::CopySource => "copy source",
|
||||||
|
Self::CopyDestination => "copy destination",
|
||||||
|
Self::ZeroFill => "zero-fill",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error(
|
||||||
|
"{operation} range out of bounds (offset {offset}, size {size}, buffer length {buffer_len})"
|
||||||
|
)]
|
||||||
|
BufferRangeOutOfBounds {
|
||||||
|
operation: BufferOperation,
|
||||||
|
offset: usize,
|
||||||
|
size: usize,
|
||||||
|
buffer_len: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum DecompressionFailure {
|
||||||
|
#[error("compressed source size {size} exceeds limit {max}")]
|
||||||
|
SourceTooLarge { size: u32, max: u64 },
|
||||||
|
#[error("Huffman code length {bits} is invalid")]
|
||||||
|
InvalidCodeLength { bits: u8 },
|
||||||
|
#[error("Huffman tree traversal exceeded 64 levels")]
|
||||||
|
HuffmanTraversalLimit,
|
||||||
|
#[error("pending length accumulator overflowed at {pending}")]
|
||||||
|
PendingLengthOverflow { pending: u32 },
|
||||||
|
#[error("output step {step} at byte {written} exceeds expected size {expected}")]
|
||||||
|
OutputOverflow {
|
||||||
|
written: u32,
|
||||||
|
step: u32,
|
||||||
|
expected: u32,
|
||||||
|
},
|
||||||
|
#[error("run-fill width {width} reads before output offset 0x{destination:08X}")]
|
||||||
|
RunFillBeforeOutput { width: u32, destination: u32 },
|
||||||
|
#[error("run-fill width {width} is unsupported")]
|
||||||
|
InvalidRunFillWidth { width: u32 },
|
||||||
|
#[error("back-reference distance {distance} exceeds {written} written bytes")]
|
||||||
|
InvalidBackReference { distance: u32, written: u32 },
|
||||||
|
#[error("Huffman symbol consumed no input and produced no output")]
|
||||||
|
NoProgress,
|
||||||
|
#[error(
|
||||||
|
"output size mismatch (wrote {written}/{expected} bytes after consuming {consumed}/{source_size})"
|
||||||
|
)]
|
||||||
|
OutputSizeMismatch {
|
||||||
|
written: u32,
|
||||||
|
expected: u32,
|
||||||
|
consumed: u32,
|
||||||
|
source_size: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
//! Each free function is self-contained: it takes the relevant byte buffer(s)
|
//! Each free function is self-contained: it takes the relevant byte buffer(s)
|
||||||
//! and parameters explicitly, with no coupling to the EXE `Unpacker` struct.
|
//! and parameters explicitly, with no coupling to the EXE `Unpacker` struct.
|
||||||
|
|
||||||
|
use super::tables::{COLUMMIX1, COLUMMIX2, COLUMMIX3, COLUMMIX4, SBOX};
|
||||||
use crate::bytecode::{Op, OpsLut};
|
use crate::bytecode::{Op, OpsLut};
|
||||||
use crate::crc32;
|
use crate::crc32;
|
||||||
use crate::tables::{COLUMMIX1, COLUMMIX2, COLUMMIX3, COLUMMIX4, SBOX};
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
File renamed without changes.
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "senbei-elf"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "ELF format parsing and structural utilities for Senbei"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
goblin.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
use crate::{Error, Result, invalid};
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn elf_hash(name: &[u8]) -> u32 {
|
||||||
|
let mut value = 0_u32;
|
||||||
|
for &byte in name {
|
||||||
|
value = value.wrapping_shl(4).wrapping_add(u32::from(byte));
|
||||||
|
let high = value & 0xf000_0000;
|
||||||
|
if high != 0 {
|
||||||
|
value ^= high >> 24;
|
||||||
|
value &= !high;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn gnu_hash(name: &[u8]) -> u32 {
|
||||||
|
name.iter().fold(5381_u32, |value, &byte| {
|
||||||
|
value.wrapping_mul(33).wrapping_add(u32::from(byte))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_sysv_hash(names: &[Vec<u8>]) -> Result<Vec<u8>> {
|
||||||
|
if names.len() < 2 {
|
||||||
|
return invalid("dynamic symbol table is unexpectedly empty");
|
||||||
|
}
|
||||||
|
let bucket_count = names.len();
|
||||||
|
let symbol_count = names.len();
|
||||||
|
let mut buckets = vec![0_u32; bucket_count];
|
||||||
|
let mut chains = vec![0_u32; symbol_count];
|
||||||
|
for (symbol_index, name) in names.iter().enumerate().skip(1) {
|
||||||
|
let bucket_index = elf_hash(name) as usize % bucket_count;
|
||||||
|
let symbol_index32 = u32::try_from(symbol_index)
|
||||||
|
.map_err(|_| Error::Invalid("dynamic symbol index exceeds u32".to_owned()))?;
|
||||||
|
if buckets[bucket_index] == 0 {
|
||||||
|
buckets[bucket_index] = symbol_index32;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut chain_index = buckets[bucket_index] as usize;
|
||||||
|
while chains[chain_index] != 0 {
|
||||||
|
chain_index = chains[chain_index] as usize;
|
||||||
|
}
|
||||||
|
chains[chain_index] = symbol_index32;
|
||||||
|
}
|
||||||
|
let mut output = Vec::with_capacity((2 + bucket_count + symbol_count) * 4);
|
||||||
|
output.extend_from_slice(
|
||||||
|
&u32::try_from(bucket_count)
|
||||||
|
.map_err(|_| Error::Invalid("SysV bucket count exceeds u32".to_owned()))?
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
output.extend_from_slice(
|
||||||
|
&u32::try_from(symbol_count)
|
||||||
|
.map_err(|_| Error::Invalid("SysV symbol count exceeds u32".to_owned()))?
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
for value in buckets.into_iter().chain(chains) {
|
||||||
|
output.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_gnu_hash(names: &[Vec<u8>]) -> Result<Vec<u8>> {
|
||||||
|
let hashes = names
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.map(|name| gnu_hash(name))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if hashes.is_empty() {
|
||||||
|
return invalid("GNU hash requires at least one dynamic symbol");
|
||||||
|
}
|
||||||
|
let bloom_shift = 5_u32;
|
||||||
|
let mut bloom_word = 0_u64;
|
||||||
|
for &value in &hashes {
|
||||||
|
bloom_word |= 1_u64 << (value & 63);
|
||||||
|
bloom_word |= 1_u64 << ((value >> bloom_shift) & 63);
|
||||||
|
}
|
||||||
|
let mut chains = hashes
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value & !1)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let last = chains
|
||||||
|
.last_mut()
|
||||||
|
.ok_or_else(|| Error::Invalid("GNU hash chain is empty".to_owned()))?;
|
||||||
|
*last |= 1;
|
||||||
|
let mut output = Vec::with_capacity(28 + chains.len() * 4);
|
||||||
|
for value in [1_u32, 1, 1, bloom_shift] {
|
||||||
|
output.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
output.extend_from_slice(&bloom_word.to_le_bytes());
|
||||||
|
output.extend_from_slice(&1_u32.to_le_bytes());
|
||||||
|
for value in chains {
|
||||||
|
output.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_elf_hash_is_stable() {
|
||||||
|
assert_eq!(elf_hash(b"printf"), 0x0779_05a6);
|
||||||
|
assert_eq!(gnu_hash(b"printf"), 0x156b_2bb8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,965 @@
|
|||||||
|
use crate::{Error, Result, invalid};
|
||||||
|
|
||||||
|
pub const SHT_NOBITS: u32 = 8;
|
||||||
|
pub const SHT_STRTAB: u32 = 3;
|
||||||
|
pub const SHT_LOUSER: u32 = 0x8000_0000;
|
||||||
|
pub const SHF_ALLOC: u64 = 2;
|
||||||
|
const PT_LOAD: u32 = 1;
|
||||||
|
const PT_NOTE: u32 = 4;
|
||||||
|
const PT_PHDR: u32 = 6;
|
||||||
|
pub const PF_R: u32 = 4;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct LoadSegment {
|
||||||
|
pub offset: u64,
|
||||||
|
pub virtual_address: u64,
|
||||||
|
pub file_size: u64,
|
||||||
|
pub memory_size: u64,
|
||||||
|
pub flags: u32,
|
||||||
|
pub alignment: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SectionHeader {
|
||||||
|
pub name: u32,
|
||||||
|
pub section_type: u32,
|
||||||
|
pub flags: u64,
|
||||||
|
pub address: u64,
|
||||||
|
pub offset: u64,
|
||||||
|
pub size: u64,
|
||||||
|
pub link: u32,
|
||||||
|
pub info: u32,
|
||||||
|
pub alignment: u64,
|
||||||
|
pub entry_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectionHeader {
|
||||||
|
pub const SIZE: usize = 0x40;
|
||||||
|
|
||||||
|
fn parse(data: &[u8], offset: usize) -> Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
name: read_u32(data, offset)?,
|
||||||
|
section_type: read_u32(data, offset + 4)?,
|
||||||
|
flags: read_u64(data, offset + 8)?,
|
||||||
|
address: read_u64(data, offset + 0x10)?,
|
||||||
|
offset: read_u64(data, offset + 0x18)?,
|
||||||
|
size: read_u64(data, offset + 0x20)?,
|
||||||
|
link: read_u32(data, offset + 0x28)?,
|
||||||
|
info: read_u32(data, offset + 0x2c)?,
|
||||||
|
alignment: read_u64(data, offset + 0x30)?,
|
||||||
|
entry_size: read_u64(data, offset + 0x38)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(self) -> [u8; Self::SIZE] {
|
||||||
|
let mut output = [0_u8; Self::SIZE];
|
||||||
|
output[0..4].copy_from_slice(&self.name.to_le_bytes());
|
||||||
|
output[4..8].copy_from_slice(&self.section_type.to_le_bytes());
|
||||||
|
output[8..0x10].copy_from_slice(&self.flags.to_le_bytes());
|
||||||
|
output[0x10..0x18].copy_from_slice(&self.address.to_le_bytes());
|
||||||
|
output[0x18..0x20].copy_from_slice(&self.offset.to_le_bytes());
|
||||||
|
output[0x20..0x28].copy_from_slice(&self.size.to_le_bytes());
|
||||||
|
output[0x28..0x2c].copy_from_slice(&self.link.to_le_bytes());
|
||||||
|
output[0x2c..0x30].copy_from_slice(&self.info.to_le_bytes());
|
||||||
|
output[0x30..0x38].copy_from_slice(&self.alignment.to_le_bytes());
|
||||||
|
output[0x38..0x40].copy_from_slice(&self.entry_size.to_le_bytes());
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ElfLayout {
|
||||||
|
pub entrypoint: u64,
|
||||||
|
pub program_header_offset: usize,
|
||||||
|
pub program_header_size: usize,
|
||||||
|
pub program_header_count: usize,
|
||||||
|
pub program_headers: Vec<LoadSegment>,
|
||||||
|
pub section_headers: Vec<SectionHeader>,
|
||||||
|
pub section_name_index: usize,
|
||||||
|
pub private_section_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ElfLayout {
|
||||||
|
pub fn parse(data: &[u8], require_private: bool) -> Result<Self> {
|
||||||
|
let ident = slice(data, 0, 6)?;
|
||||||
|
if ident[..4] != *b"\x7fELF" || ident[4] != 2 || ident[5] != 1 {
|
||||||
|
return invalid("input is not a little-endian ELF64 file");
|
||||||
|
}
|
||||||
|
if read_u16(data, 0x12)? != crate::AARCH64_MACHINE {
|
||||||
|
return invalid("input is not an AArch64 ELF");
|
||||||
|
}
|
||||||
|
let entrypoint = read_u64(data, 0x18)?;
|
||||||
|
let program_header_offset = usize_from_u64(read_u64(data, 0x20)?, "program header offset")?;
|
||||||
|
let section_header_offset = usize_from_u64(read_u64(data, 0x28)?, "section header offset")?;
|
||||||
|
let program_header_size = usize::from(read_u16(data, 0x36)?);
|
||||||
|
let program_header_count = usize::from(read_u16(data, 0x38)?);
|
||||||
|
let section_header_size = usize::from(read_u16(data, 0x3a)?);
|
||||||
|
let section_header_count = usize::from(read_u16(data, 0x3c)?);
|
||||||
|
let section_name_index = usize::from(read_u16(data, 0x3e)?);
|
||||||
|
if program_header_size != 0x38 || section_header_size != SectionHeader::SIZE {
|
||||||
|
return invalid("unexpected ELF program/section header size");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut program_headers = Vec::new();
|
||||||
|
for index in 0..program_header_count {
|
||||||
|
let offset = checked_index(program_header_offset, index, program_header_size)?;
|
||||||
|
if read_u32(data, offset)? != PT_LOAD {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let segment = LoadSegment {
|
||||||
|
flags: read_u32(data, offset + 4)?,
|
||||||
|
offset: read_u64(data, offset + 8)?,
|
||||||
|
virtual_address: read_u64(data, offset + 0x10)?,
|
||||||
|
file_size: read_u64(data, offset + 0x20)?,
|
||||||
|
memory_size: read_u64(data, offset + 0x28)?,
|
||||||
|
alignment: read_u64(data, offset + 0x30)?,
|
||||||
|
};
|
||||||
|
let file_end = segment
|
||||||
|
.offset
|
||||||
|
.checked_add(segment.file_size)
|
||||||
|
.ok_or_else(|| Error::Invalid(format!("PT_LOAD {index} file range overflow")))?;
|
||||||
|
if file_end > data.len() as u64 {
|
||||||
|
return invalid(format!("PT_LOAD {index} exceeds input file"));
|
||||||
|
}
|
||||||
|
program_headers.push(segment);
|
||||||
|
}
|
||||||
|
if program_headers.is_empty() {
|
||||||
|
return invalid("input ELF contains no PT_LOAD segments");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut section_headers = Vec::with_capacity(section_header_count);
|
||||||
|
for index in 0..section_header_count {
|
||||||
|
let offset = checked_index(section_header_offset, index, section_header_size)?;
|
||||||
|
section_headers.push(SectionHeader::parse(data, offset)?);
|
||||||
|
}
|
||||||
|
if section_name_index >= section_headers.len() {
|
||||||
|
return invalid("ELF section-name index is out of range");
|
||||||
|
}
|
||||||
|
let private = section_headers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, section)| (section.section_type == SHT_LOUSER).then_some(index))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let private_section_index = match private.as_slice() {
|
||||||
|
[index] => *index,
|
||||||
|
[] if !require_private => usize::MAX,
|
||||||
|
_ => {
|
||||||
|
return invalid(format!(
|
||||||
|
"expected {} SHT_LOUSER section, found {}",
|
||||||
|
if require_private {
|
||||||
|
"one"
|
||||||
|
} else {
|
||||||
|
"at most one"
|
||||||
|
},
|
||||||
|
private.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let layout = Self {
|
||||||
|
entrypoint,
|
||||||
|
program_header_offset,
|
||||||
|
program_header_size,
|
||||||
|
program_header_count,
|
||||||
|
program_headers,
|
||||||
|
section_headers,
|
||||||
|
section_name_index,
|
||||||
|
private_section_index,
|
||||||
|
};
|
||||||
|
// Section roles are resolved from the ELF's own string table. Validate
|
||||||
|
// it at the format boundary so callers cannot silently continue with
|
||||||
|
// fabricated or lossy section names.
|
||||||
|
layout.section_names(data)?;
|
||||||
|
Ok(layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn private_section(&self) -> Result<SectionHeader> {
|
||||||
|
self.section_headers
|
||||||
|
.get(self.private_section_index)
|
||||||
|
.copied()
|
||||||
|
.ok_or_else(|| Error::Invalid("ELF has no private section".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_end(&self) -> Result<u64> {
|
||||||
|
self.program_headers
|
||||||
|
.iter()
|
||||||
|
.map(|segment| {
|
||||||
|
segment
|
||||||
|
.virtual_address
|
||||||
|
.checked_add(segment.memory_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_LOAD memory end overflow".to_owned()))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?
|
||||||
|
.into_iter()
|
||||||
|
.max()
|
||||||
|
.ok_or_else(|| Error::Invalid("ELF has no PT_LOAD memory range".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_load_end(&self) -> Result<u64> {
|
||||||
|
self.program_headers
|
||||||
|
.iter()
|
||||||
|
.map(|segment| {
|
||||||
|
segment
|
||||||
|
.offset
|
||||||
|
.checked_add(segment.file_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_LOAD file end overflow".to_owned()))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?
|
||||||
|
.into_iter()
|
||||||
|
.max()
|
||||||
|
.ok_or_else(|| Error::Invalid("ELF has no PT_LOAD file range".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_alignment(&self) -> Result<u64> {
|
||||||
|
let alignment = self
|
||||||
|
.program_headers
|
||||||
|
.iter()
|
||||||
|
.map(|segment| segment.alignment)
|
||||||
|
.max()
|
||||||
|
.ok_or_else(|| Error::Invalid("ELF has no PT_LOAD alignment".to_owned()))?;
|
||||||
|
if alignment == 0 || !alignment.is_power_of_two() {
|
||||||
|
return invalid(format!("invalid PT_LOAD alignment 0x{alignment:x}"));
|
||||||
|
}
|
||||||
|
Ok(alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn additional_program_header_reservation(&self) -> Result<usize> {
|
||||||
|
if self.program_header_size != 0x38 {
|
||||||
|
return invalid("unexpected ELF program header size");
|
||||||
|
}
|
||||||
|
let new_count = self
|
||||||
|
.program_header_count
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?;
|
||||||
|
let header_offset = checked_index(
|
||||||
|
self.program_header_offset,
|
||||||
|
self.program_header_count,
|
||||||
|
self.program_header_size,
|
||||||
|
)?;
|
||||||
|
let header_end = header_offset
|
||||||
|
.checked_add(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?;
|
||||||
|
let first_file_section = self
|
||||||
|
.section_headers
|
||||||
|
.iter()
|
||||||
|
.filter(|section| section.section_type != SHT_NOBITS && section.size != 0)
|
||||||
|
.map(|section| section.offset)
|
||||||
|
.min();
|
||||||
|
if first_file_section.is_none_or(|offset| header_end as u64 <= offset) {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
new_count
|
||||||
|
.checked_mul(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("relocated program header size overflow".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_program_header_type(&self, data: &[u8], program_type: u32) -> Result<bool> {
|
||||||
|
for index in 0..self.program_header_count {
|
||||||
|
let offset =
|
||||||
|
checked_index(self.program_header_offset, index, self.program_header_size)?;
|
||||||
|
if read_u32(data, offset)? == program_type {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reusable_note_program_header(&self, data: &[u8]) -> Result<Option<usize>> {
|
||||||
|
let mut note_count = 0_usize;
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for index in 0..self.program_header_count {
|
||||||
|
let offset =
|
||||||
|
checked_index(self.program_header_offset, index, self.program_header_size)?;
|
||||||
|
if read_u32(data, offset)? != PT_NOTE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
note_count += 1;
|
||||||
|
let file_offset = read_u64(data, offset + 8)?;
|
||||||
|
let file_size = read_u64(data, offset + 0x20)?;
|
||||||
|
let file_end = file_offset
|
||||||
|
.checked_add(file_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_NOTE file range overflow".to_owned()))?;
|
||||||
|
let contained = self.program_headers.iter().any(|segment| {
|
||||||
|
segment
|
||||||
|
.offset
|
||||||
|
.checked_add(segment.file_size)
|
||||||
|
.is_some_and(|segment_end| {
|
||||||
|
segment.offset <= file_offset && file_end <= segment_end
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if contained {
|
||||||
|
candidates.push((file_offset, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if note_count < 2 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(candidates
|
||||||
|
.into_iter()
|
||||||
|
.max_by_key(|(file_offset, _)| *file_offset)
|
||||||
|
.map(|(_, index)| index))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_load_segment(
|
||||||
|
&self,
|
||||||
|
output: &mut [u8],
|
||||||
|
segment: LoadSegment,
|
||||||
|
reserved_program_header_bytes: usize,
|
||||||
|
) -> Result<Self> {
|
||||||
|
if self.program_header_size != 0x38 {
|
||||||
|
return invalid("unexpected ELF program header size");
|
||||||
|
}
|
||||||
|
if segment.file_size == 0 {
|
||||||
|
return invalid("new PT_LOAD has no file contents");
|
||||||
|
}
|
||||||
|
if segment.memory_size < segment.file_size {
|
||||||
|
return invalid("new PT_LOAD memory size is smaller than file size");
|
||||||
|
}
|
||||||
|
if segment.alignment == 0 || !segment.alignment.is_power_of_two() {
|
||||||
|
return invalid(format!(
|
||||||
|
"invalid new PT_LOAD alignment 0x{:x}",
|
||||||
|
segment.alignment
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if segment.offset % segment.alignment != segment.virtual_address % segment.alignment {
|
||||||
|
return invalid("new PT_LOAD offset and address are misaligned");
|
||||||
|
}
|
||||||
|
let segment_file_end = segment
|
||||||
|
.offset
|
||||||
|
.checked_add(segment.file_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("new PT_LOAD file range overflow".to_owned()))?;
|
||||||
|
let segment_memory_end = segment
|
||||||
|
.virtual_address
|
||||||
|
.checked_add(segment.memory_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("new PT_LOAD memory range overflow".to_owned()))?;
|
||||||
|
if segment_file_end > output.len() as u64 {
|
||||||
|
return invalid("new PT_LOAD exceeds output mapping");
|
||||||
|
}
|
||||||
|
for existing in &self.program_headers {
|
||||||
|
let existing_file_end = existing
|
||||||
|
.offset
|
||||||
|
.checked_add(existing.file_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_LOAD file range overflow".to_owned()))?;
|
||||||
|
if segment.offset < existing_file_end && existing.offset < segment_file_end {
|
||||||
|
return invalid("new PT_LOAD overlaps an existing file range");
|
||||||
|
}
|
||||||
|
let existing_memory_end = existing
|
||||||
|
.virtual_address
|
||||||
|
.checked_add(existing.memory_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_LOAD memory range overflow".to_owned()))?;
|
||||||
|
if segment.virtual_address < existing_memory_end
|
||||||
|
&& existing.virtual_address < segment_memory_end
|
||||||
|
{
|
||||||
|
return invalid("new PT_LOAD overlaps an existing memory range");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let new_count = self
|
||||||
|
.program_header_count
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?;
|
||||||
|
let new_count_u16 = u16::try_from(new_count)
|
||||||
|
.map_err(|_| Error::Invalid("program header count exceeds u16".to_owned()))?;
|
||||||
|
let reservation = self.additional_program_header_reservation()?;
|
||||||
|
if reserved_program_header_bytes != reservation {
|
||||||
|
return invalid(format!(
|
||||||
|
"program-header reservation mismatch: expected 0x{reservation:x}, got 0x{reserved_program_header_bytes:x}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut header = [0_u8; 0x38];
|
||||||
|
header[0..4].copy_from_slice(&PT_LOAD.to_le_bytes());
|
||||||
|
header[4..8].copy_from_slice(&segment.flags.to_le_bytes());
|
||||||
|
header[8..0x10].copy_from_slice(&segment.offset.to_le_bytes());
|
||||||
|
header[0x10..0x18].copy_from_slice(&segment.virtual_address.to_le_bytes());
|
||||||
|
header[0x18..0x20].copy_from_slice(&segment.virtual_address.to_le_bytes());
|
||||||
|
header[0x20..0x28].copy_from_slice(&segment.file_size.to_le_bytes());
|
||||||
|
header[0x28..0x30].copy_from_slice(&segment.memory_size.to_le_bytes());
|
||||||
|
header[0x30..0x38].copy_from_slice(&segment.alignment.to_le_bytes());
|
||||||
|
|
||||||
|
let old_table_size = self
|
||||||
|
.program_header_count
|
||||||
|
.checked_mul(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("program header table size overflow".to_owned()))?;
|
||||||
|
let new_table_size = new_count
|
||||||
|
.checked_mul(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("program header table size overflow".to_owned()))?;
|
||||||
|
let mut updated_program_header_offset = self.program_header_offset;
|
||||||
|
let mut updated_program_header_count = new_count;
|
||||||
|
if reservation == 0 {
|
||||||
|
let header_offset = checked_index(
|
||||||
|
self.program_header_offset,
|
||||||
|
self.program_header_count,
|
||||||
|
self.program_header_size,
|
||||||
|
)?;
|
||||||
|
let header_end = header_offset
|
||||||
|
.checked_add(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?;
|
||||||
|
output
|
||||||
|
.get_mut(header_offset..header_end)
|
||||||
|
.ok_or_else(|| Error::Invalid("new program header exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&header);
|
||||||
|
update_pt_phdr(
|
||||||
|
output,
|
||||||
|
self.program_header_offset,
|
||||||
|
self.program_header_count,
|
||||||
|
self.program_header_size,
|
||||||
|
None,
|
||||||
|
new_table_size as u64,
|
||||||
|
)?;
|
||||||
|
} else if let Some(note_index) = self.reusable_note_program_header(output)? {
|
||||||
|
let header_offset = checked_index(
|
||||||
|
self.program_header_offset,
|
||||||
|
note_index,
|
||||||
|
self.program_header_size,
|
||||||
|
)?;
|
||||||
|
let header_end = header_offset
|
||||||
|
.checked_add(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("reused program header range overflow".to_owned()))?;
|
||||||
|
output
|
||||||
|
.get_mut(header_offset..header_end)
|
||||||
|
.ok_or_else(|| Error::Invalid("reused program header exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&header);
|
||||||
|
updated_program_header_count = self.program_header_count;
|
||||||
|
} else {
|
||||||
|
if !self.has_program_header_type(output, PT_PHDR)? {
|
||||||
|
return invalid(
|
||||||
|
"cannot safely relocate program headers without PT_PHDR or a redundant PT_NOTE",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if reservation != new_table_size {
|
||||||
|
return invalid("relocated program-header reservation has unexpected size");
|
||||||
|
}
|
||||||
|
if segment.file_size < reservation as u64 {
|
||||||
|
return invalid("new PT_LOAD is too small for relocated program headers");
|
||||||
|
}
|
||||||
|
let relocated = usize_from_u64(segment.offset, "relocated program header offset")?;
|
||||||
|
let relocated_end = relocated.checked_add(new_table_size).ok_or_else(|| {
|
||||||
|
Error::Invalid("relocated program header range overflow".to_owned())
|
||||||
|
})?;
|
||||||
|
slice(output, relocated, new_table_size)?;
|
||||||
|
let old_table = slice(output, self.program_header_offset, old_table_size)?.to_vec();
|
||||||
|
output[relocated..relocated + old_table_size].copy_from_slice(&old_table);
|
||||||
|
output[relocated + old_table_size..relocated_end].copy_from_slice(&header);
|
||||||
|
output
|
||||||
|
.get_mut(0x20..0x28)
|
||||||
|
.ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))?
|
||||||
|
.copy_from_slice(&segment.offset.to_le_bytes());
|
||||||
|
update_pt_phdr(
|
||||||
|
output,
|
||||||
|
relocated,
|
||||||
|
self.program_header_count,
|
||||||
|
self.program_header_size,
|
||||||
|
Some((segment.offset, segment.virtual_address)),
|
||||||
|
new_table_size as u64,
|
||||||
|
)?;
|
||||||
|
updated_program_header_offset = relocated;
|
||||||
|
}
|
||||||
|
if updated_program_header_count == new_count {
|
||||||
|
output
|
||||||
|
.get_mut(0x38..0x3a)
|
||||||
|
.ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))?
|
||||||
|
.copy_from_slice(&new_count_u16.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut updated = self.clone();
|
||||||
|
updated.program_header_offset = updated_program_header_offset;
|
||||||
|
updated.program_header_count = updated_program_header_count;
|
||||||
|
updated.program_headers.push(segment);
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve every section's name from the ELF `shstrtab` section.
|
||||||
|
///
|
||||||
|
/// The returned names are source data, not role labels supplied by the
|
||||||
|
/// caller. Any malformed string-table reference is an input error.
|
||||||
|
pub fn section_names(&self, data: &[u8]) -> Result<Vec<String>> {
|
||||||
|
let table = self
|
||||||
|
.section_headers
|
||||||
|
.get(self.section_name_index)
|
||||||
|
.copied()
|
||||||
|
.ok_or_else(|| Error::Invalid("ELF section-name index is out of range".to_owned()))?;
|
||||||
|
if table.section_type != SHT_STRTAB {
|
||||||
|
return invalid(format!(
|
||||||
|
"ELF section-name table has unexpected type 0x{:x}",
|
||||||
|
table.section_type
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let strings = slice_u64(data, table.offset, table.size)?;
|
||||||
|
if strings.is_empty() || strings[0] != 0 {
|
||||||
|
return invalid("ELF section-name table does not start with NUL");
|
||||||
|
}
|
||||||
|
if strings.last().copied() != Some(0) {
|
||||||
|
return invalid("ELF section-name table is not NUL terminated");
|
||||||
|
}
|
||||||
|
self.section_headers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, section)| {
|
||||||
|
let offset = section.name as usize;
|
||||||
|
if offset >= strings.len() {
|
||||||
|
return invalid(format!(
|
||||||
|
"ELF section {index} name offset 0x{offset:x} exceeds section-name table"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let end = strings[offset..]
|
||||||
|
.iter()
|
||||||
|
.position(|&byte| byte == 0)
|
||||||
|
.map(|length| offset + length)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::Invalid(format!(
|
||||||
|
"ELF section {index} name at 0x{offset:x} is unterminated"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let name = std::str::from_utf8(&strings[offset..end]).map_err(|error| {
|
||||||
|
Error::Invalid(format!(
|
||||||
|
"ELF section {index} name at 0x{offset:x} is not UTF-8: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if index == 0 && section.name != 0 {
|
||||||
|
return invalid("ELF null section has a nonzero name offset");
|
||||||
|
}
|
||||||
|
Ok(name.to_owned())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_offset_to_virtual_address(&self, offset: u64, size: u64) -> Result<u64> {
|
||||||
|
let end = offset
|
||||||
|
.checked_add(size)
|
||||||
|
.ok_or_else(|| Error::Invalid("file range overflow".to_owned()))?;
|
||||||
|
for segment in &self.program_headers {
|
||||||
|
let segment_end = segment
|
||||||
|
.offset
|
||||||
|
.checked_add(segment.file_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_LOAD file range overflow".to_owned()))?;
|
||||||
|
if segment.offset <= offset && end <= segment_end {
|
||||||
|
return segment
|
||||||
|
.virtual_address
|
||||||
|
.checked_add(offset - segment.offset)
|
||||||
|
.ok_or_else(|| Error::Invalid("virtual address overflow".to_owned()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalid(format!(
|
||||||
|
"file range 0x{offset:x}..0x{end:x} is not in PT_LOAD"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_pt_phdr(
|
||||||
|
output: &mut [u8],
|
||||||
|
table_offset: usize,
|
||||||
|
old_count: usize,
|
||||||
|
entry_size: usize,
|
||||||
|
relocated: Option<(u64, u64)>,
|
||||||
|
table_size: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
for index in 0..old_count {
|
||||||
|
let offset = checked_index(table_offset, index, entry_size)?;
|
||||||
|
if read_u32(output, offset)? != PT_PHDR {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((file_offset, virtual_address)) = relocated {
|
||||||
|
output
|
||||||
|
.get_mut(offset + 8..offset + 0x10)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_PHDR file offset exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&file_offset.to_le_bytes());
|
||||||
|
output
|
||||||
|
.get_mut(offset + 0x10..offset + 0x18)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_PHDR virtual address exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&virtual_address.to_le_bytes());
|
||||||
|
output
|
||||||
|
.get_mut(offset + 0x18..offset + 0x20)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::Invalid("PT_PHDR physical address exceeds output".to_owned())
|
||||||
|
})?
|
||||||
|
.copy_from_slice(&virtual_address.to_le_bytes());
|
||||||
|
}
|
||||||
|
output
|
||||||
|
.get_mut(offset + 0x20..offset + 0x28)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_PHDR file size exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&table_size.to_le_bytes());
|
||||||
|
output
|
||||||
|
.get_mut(offset + 0x28..offset + 0x30)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_PHDR memory size exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&table_size.to_le_bytes());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn slice(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> {
|
||||||
|
let end = offset
|
||||||
|
.checked_add(size)
|
||||||
|
.ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?;
|
||||||
|
data.get(offset..end).ok_or_else(|| {
|
||||||
|
Error::Invalid(format!(
|
||||||
|
"byte range 0x{offset:x}..0x{end:x} is out of bounds"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn slice_u64(data: &[u8], offset: u64, size: u64) -> Result<&[u8]> {
|
||||||
|
slice(
|
||||||
|
data,
|
||||||
|
usize_from_u64(offset, "file offset")?,
|
||||||
|
usize_from_u64(size, "file size")?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_u16(data: &[u8], offset: usize) -> Result<u16> {
|
||||||
|
let bytes: [u8; 2] = slice(data, offset, 2)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| Error::Invalid("invalid u16 range".to_owned()))?;
|
||||||
|
Ok(u16::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||||
|
let bytes: [u8; 4] = slice(data, offset, 4)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| Error::Invalid("invalid u32 range".to_owned()))?;
|
||||||
|
Ok(u32::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_u64(data: &[u8], offset: usize) -> Result<u64> {
|
||||||
|
let bytes: [u8; 8] = slice(data, offset, 8)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| Error::Invalid("invalid u64 range".to_owned()))?;
|
||||||
|
Ok(u64::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_i64(data: &[u8], offset: usize) -> Result<i64> {
|
||||||
|
let bytes: [u8; 8] = slice(data, offset, 8)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| Error::Invalid("invalid i64 range".to_owned()))?;
|
||||||
|
Ok(i64::from_le_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn usize_from_u64(value: u64, field: &str) -> Result<usize> {
|
||||||
|
usize::try_from(value).map_err(|_| Error::Invalid(format!("{field} 0x{value:x} exceeds usize")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checked_index(base: usize, index: usize, stride: usize) -> Result<usize> {
|
||||||
|
index
|
||||||
|
.checked_mul(stride)
|
||||||
|
.and_then(|value| base.checked_add(value))
|
||||||
|
.ok_or_else(|| Error::Invalid("table index overflow".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn align_up(value: u64, alignment: u64) -> Result<u64> {
|
||||||
|
if alignment == 0 || !alignment.is_power_of_two() {
|
||||||
|
return invalid(format!("invalid alignment {alignment}"));
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.checked_add(alignment - 1)
|
||||||
|
.map(|aligned| aligned & !(alignment - 1))
|
||||||
|
.ok_or_else(|| Error::Invalid("alignment overflow".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn layout(name_index: u32) -> ElfLayout {
|
||||||
|
ElfLayout {
|
||||||
|
entrypoint: 0,
|
||||||
|
program_header_offset: 0,
|
||||||
|
program_header_size: 0x38,
|
||||||
|
program_header_count: 0,
|
||||||
|
program_headers: Vec::new(),
|
||||||
|
section_headers: vec![
|
||||||
|
SectionHeader {
|
||||||
|
name: 0,
|
||||||
|
section_type: 0,
|
||||||
|
flags: 0,
|
||||||
|
address: 0,
|
||||||
|
offset: 0,
|
||||||
|
size: 0,
|
||||||
|
link: 0,
|
||||||
|
info: 0,
|
||||||
|
alignment: 0,
|
||||||
|
entry_size: 0,
|
||||||
|
},
|
||||||
|
SectionHeader {
|
||||||
|
name: name_index,
|
||||||
|
section_type: 1,
|
||||||
|
flags: 0,
|
||||||
|
address: 0,
|
||||||
|
offset: 0,
|
||||||
|
size: 0,
|
||||||
|
link: 0,
|
||||||
|
info: 0,
|
||||||
|
alignment: 0,
|
||||||
|
entry_size: 0,
|
||||||
|
},
|
||||||
|
SectionHeader {
|
||||||
|
name: 1,
|
||||||
|
section_type: SHT_STRTAB,
|
||||||
|
flags: 0,
|
||||||
|
address: 0,
|
||||||
|
offset: 0,
|
||||||
|
size: 8,
|
||||||
|
link: 0,
|
||||||
|
info: 0,
|
||||||
|
alignment: 1,
|
||||||
|
entry_size: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
section_name_index: 2,
|
||||||
|
private_section_index: usize::MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn section_names_resolve_from_elf_string_table() {
|
||||||
|
let names = layout(1)
|
||||||
|
.section_names(b"\0text\0\0\0")
|
||||||
|
.expect("valid names");
|
||||||
|
assert_eq!(names, ["", "text", "text"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn section_names_reject_out_of_range_name_offsets() {
|
||||||
|
let error = layout(8)
|
||||||
|
.section_names(b"\0text\0\0\0")
|
||||||
|
.expect_err("invalid offset");
|
||||||
|
assert!(error.to_string().contains("exceeds section-name table"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn section_names_reject_invalid_utf8() {
|
||||||
|
let mut elf_layout = layout(1);
|
||||||
|
elf_layout.section_headers[1].name = 1;
|
||||||
|
let error = elf_layout
|
||||||
|
.section_names(b"\0\xff\0\0\0\0\0\0")
|
||||||
|
.expect_err("invalid UTF-8");
|
||||||
|
assert!(error.to_string().contains("is not UTF-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn section_names_reject_non_string_table() {
|
||||||
|
let mut elf_layout = layout(1);
|
||||||
|
elf_layout.section_headers[2].section_type = 1;
|
||||||
|
let error = elf_layout
|
||||||
|
.section_names(b"\0text\0\0\0")
|
||||||
|
.expect_err("wrong section type");
|
||||||
|
assert!(error.to_string().contains("unexpected type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn section_names_reject_unterminated_table() {
|
||||||
|
let elf_layout = layout(1);
|
||||||
|
let error = elf_layout
|
||||||
|
.section_names(b"\0text\0\x01\x01")
|
||||||
|
.expect_err("unterminated table");
|
||||||
|
assert!(error.to_string().contains("not NUL terminated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_load_segment_updates_program_headers() {
|
||||||
|
let elf_layout = ElfLayout {
|
||||||
|
entrypoint: 0,
|
||||||
|
program_header_offset: 0,
|
||||||
|
program_header_size: 0x38,
|
||||||
|
program_header_count: 0,
|
||||||
|
program_headers: Vec::new(),
|
||||||
|
section_headers: Vec::new(),
|
||||||
|
section_name_index: 0,
|
||||||
|
private_section_index: usize::MAX,
|
||||||
|
};
|
||||||
|
let mut output = vec![0_u8; 0x2000];
|
||||||
|
let updated = elf_layout
|
||||||
|
.append_load_segment(
|
||||||
|
&mut output,
|
||||||
|
LoadSegment {
|
||||||
|
offset: 0x1000,
|
||||||
|
virtual_address: 0x2000,
|
||||||
|
file_size: 0x20,
|
||||||
|
memory_size: 0x20,
|
||||||
|
flags: PF_R,
|
||||||
|
alignment: 0x1000,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.expect("append segment");
|
||||||
|
assert_eq!(updated.program_header_count, 1);
|
||||||
|
assert_eq!(updated.program_headers[0].virtual_address, 0x2000);
|
||||||
|
assert_eq!(&output[0..4], &PT_LOAD.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x38..0x3a], &1_u16.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_load_segment_fails_closed_when_no_safe_phdr_slot() {
|
||||||
|
let mut elf_layout = ElfLayout {
|
||||||
|
entrypoint: 0,
|
||||||
|
program_header_offset: 0,
|
||||||
|
program_header_size: 0x38,
|
||||||
|
program_header_count: 0,
|
||||||
|
program_headers: Vec::new(),
|
||||||
|
section_headers: Vec::new(),
|
||||||
|
section_name_index: 0,
|
||||||
|
private_section_index: usize::MAX,
|
||||||
|
};
|
||||||
|
elf_layout.section_headers.push(SectionHeader {
|
||||||
|
name: 0,
|
||||||
|
section_type: 1,
|
||||||
|
flags: 0,
|
||||||
|
address: 0,
|
||||||
|
offset: 0x20,
|
||||||
|
size: 1,
|
||||||
|
link: 0,
|
||||||
|
info: 0,
|
||||||
|
alignment: 1,
|
||||||
|
entry_size: 0,
|
||||||
|
});
|
||||||
|
let reservation = elf_layout
|
||||||
|
.additional_program_header_reservation()
|
||||||
|
.expect("reservation");
|
||||||
|
assert_eq!(reservation, 0x38);
|
||||||
|
let mut output = vec![0_u8; 0x200];
|
||||||
|
let error = elf_layout
|
||||||
|
.append_load_segment(
|
||||||
|
&mut output,
|
||||||
|
LoadSegment {
|
||||||
|
offset: 0x80,
|
||||||
|
virtual_address: 0x1080,
|
||||||
|
file_size: 0x80,
|
||||||
|
memory_size: 0x80,
|
||||||
|
flags: PF_R,
|
||||||
|
alignment: 0x1000,
|
||||||
|
},
|
||||||
|
reservation,
|
||||||
|
)
|
||||||
|
.expect_err("unsafe relocation must fail");
|
||||||
|
assert!(error.to_string().contains("cannot safely relocate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_load_segment_reuses_redundant_note_when_no_slack() {
|
||||||
|
let mut elf_layout = ElfLayout {
|
||||||
|
entrypoint: 0,
|
||||||
|
program_header_offset: 0x40,
|
||||||
|
program_header_size: 0x38,
|
||||||
|
program_header_count: 3,
|
||||||
|
program_headers: vec![LoadSegment {
|
||||||
|
offset: 0,
|
||||||
|
virtual_address: 0,
|
||||||
|
file_size: 0x400,
|
||||||
|
memory_size: 0x400,
|
||||||
|
flags: PF_R,
|
||||||
|
alignment: 0x1000,
|
||||||
|
}],
|
||||||
|
section_headers: Vec::new(),
|
||||||
|
section_name_index: 0,
|
||||||
|
private_section_index: usize::MAX,
|
||||||
|
};
|
||||||
|
elf_layout.section_headers.push(SectionHeader {
|
||||||
|
name: 0,
|
||||||
|
section_type: 1,
|
||||||
|
flags: 0,
|
||||||
|
address: 0,
|
||||||
|
offset: 0xe8,
|
||||||
|
size: 1,
|
||||||
|
link: 0,
|
||||||
|
info: 0,
|
||||||
|
alignment: 1,
|
||||||
|
entry_size: 0,
|
||||||
|
});
|
||||||
|
let mut output = vec![0_u8; 0x2000];
|
||||||
|
output[0x38..0x3a].copy_from_slice(&3_u16.to_le_bytes());
|
||||||
|
output[0x40..0x44].copy_from_slice(&PT_LOAD.to_le_bytes());
|
||||||
|
output[0x78..0x7c].copy_from_slice(&PT_NOTE.to_le_bytes());
|
||||||
|
output[0x80..0x88].copy_from_slice(&0x100_u64.to_le_bytes());
|
||||||
|
output[0x98..0xa0].copy_from_slice(&0x20_u64.to_le_bytes());
|
||||||
|
output[0xb0..0xb4].copy_from_slice(&PT_NOTE.to_le_bytes());
|
||||||
|
output[0xb8..0xc0].copy_from_slice(&0x200_u64.to_le_bytes());
|
||||||
|
output[0xd0..0xd8].copy_from_slice(&0x20_u64.to_le_bytes());
|
||||||
|
|
||||||
|
let reservation = elf_layout
|
||||||
|
.additional_program_header_reservation()
|
||||||
|
.expect("reservation");
|
||||||
|
assert_eq!(reservation, 0xe0);
|
||||||
|
let updated = elf_layout
|
||||||
|
.append_load_segment(
|
||||||
|
&mut output,
|
||||||
|
LoadSegment {
|
||||||
|
offset: 0x1000,
|
||||||
|
virtual_address: 0x2000,
|
||||||
|
file_size: 0x100,
|
||||||
|
memory_size: 0x100,
|
||||||
|
flags: PF_R,
|
||||||
|
alignment: 0x1000,
|
||||||
|
},
|
||||||
|
reservation,
|
||||||
|
)
|
||||||
|
.expect("reuse redundant PT_NOTE");
|
||||||
|
|
||||||
|
assert_eq!(updated.program_header_offset, 0x40);
|
||||||
|
assert_eq!(updated.program_header_count, 3);
|
||||||
|
assert_eq!(updated.program_headers.len(), 2);
|
||||||
|
assert_eq!(&output[0x38..0x3a], &3_u16.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x78..0x7c], &PT_NOTE.to_le_bytes());
|
||||||
|
assert_eq!(&output[0xb0..0xb4], &PT_LOAD.to_le_bytes());
|
||||||
|
assert_eq!(&output[0xb8..0xc0], &0x1000_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0xc0..0xc8], &0x2000_u64.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relocated_program_headers_update_pt_phdr() {
|
||||||
|
let mut elf_layout = ElfLayout {
|
||||||
|
entrypoint: 0,
|
||||||
|
program_header_offset: 0x40,
|
||||||
|
program_header_size: 0x38,
|
||||||
|
program_header_count: 1,
|
||||||
|
program_headers: Vec::new(),
|
||||||
|
section_headers: Vec::new(),
|
||||||
|
section_name_index: 0,
|
||||||
|
private_section_index: usize::MAX,
|
||||||
|
};
|
||||||
|
elf_layout.section_headers.push(SectionHeader {
|
||||||
|
name: 0,
|
||||||
|
section_type: 1,
|
||||||
|
flags: 0,
|
||||||
|
address: 0,
|
||||||
|
offset: 0x78,
|
||||||
|
size: 1,
|
||||||
|
link: 0,
|
||||||
|
info: 0,
|
||||||
|
alignment: 1,
|
||||||
|
entry_size: 0,
|
||||||
|
});
|
||||||
|
let mut output = vec![0_u8; 0x2000];
|
||||||
|
output[0x20..0x28].copy_from_slice(&0x40_u64.to_le_bytes());
|
||||||
|
output[0x40..0x44].copy_from_slice(&PT_PHDR.to_le_bytes());
|
||||||
|
output[0x48..0x50].copy_from_slice(&0x40_u64.to_le_bytes());
|
||||||
|
output[0x50..0x58].copy_from_slice(&0x40_u64.to_le_bytes());
|
||||||
|
output[0x58..0x60].copy_from_slice(&0x40_u64.to_le_bytes());
|
||||||
|
output[0x60..0x68].copy_from_slice(&0x38_u64.to_le_bytes());
|
||||||
|
output[0x68..0x70].copy_from_slice(&0x38_u64.to_le_bytes());
|
||||||
|
|
||||||
|
let reservation = elf_layout
|
||||||
|
.additional_program_header_reservation()
|
||||||
|
.expect("reservation");
|
||||||
|
assert_eq!(reservation, 0x70);
|
||||||
|
let updated = elf_layout
|
||||||
|
.append_load_segment(
|
||||||
|
&mut output,
|
||||||
|
LoadSegment {
|
||||||
|
offset: 0x1000,
|
||||||
|
virtual_address: 0x2000,
|
||||||
|
file_size: 0x100,
|
||||||
|
memory_size: 0x100,
|
||||||
|
flags: PF_R,
|
||||||
|
alignment: 0x1000,
|
||||||
|
},
|
||||||
|
reservation,
|
||||||
|
)
|
||||||
|
.expect("relocate program headers");
|
||||||
|
|
||||||
|
assert_eq!(updated.program_header_offset, 0x1000);
|
||||||
|
assert_eq!(&output[0x20..0x28], &0x1000_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1000..0x1004], &PT_PHDR.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1008..0x1010], &0x1000_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1010..0x1018], &0x2000_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1020..0x1028], &0x70_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1028..0x1030], &0x70_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1038..0x103c], &PT_LOAD.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! Basic ELF format parsing shared by the unpacking engine.
|
||||||
|
|
||||||
|
use goblin::elf::{Elf, header::EM_AARCH64, program_header::PT_LOAD};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub mod hash;
|
||||||
|
pub mod layout;
|
||||||
|
|
||||||
|
pub use hash::{build_gnu_hash, build_sysv_hash};
|
||||||
|
pub use layout::{
|
||||||
|
ElfLayout, LoadSegment, PF_R, SHF_ALLOC, SHT_LOUSER, SHT_NOBITS, SectionHeader, align_up,
|
||||||
|
checked_index, read_i64, read_u16, read_u32, read_u64, slice, slice_u64, usize_from_u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// ELF machine identifier for AArch64.
|
||||||
|
pub const AARCH64_MACHINE: u16 = EM_AARCH64;
|
||||||
|
|
||||||
|
/// Dynamic sections required by every restored AArch64 loader image.
|
||||||
|
pub const DYNAMIC_SECTION_NAMES: [&str; 7] = [
|
||||||
|
".dynsym",
|
||||||
|
".gnu.version",
|
||||||
|
".gnu.version_r",
|
||||||
|
".dynstr",
|
||||||
|
".rela.dyn",
|
||||||
|
".rela.plt",
|
||||||
|
".dynamic",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Supported dynamic symbol-hash sections. At least one must be present.
|
||||||
|
pub const SYMBOL_HASH_SECTION_NAMES: [&str; 2] = [".gnu.hash", ".hash"];
|
||||||
|
|
||||||
|
/// Dynamic sections needed to identify a protected image before extraction.
|
||||||
|
/// Hash tables are checked separately because either GNU or SysV hashing is valid.
|
||||||
|
pub const PROBE_SECTION_NAMES: [&str; 4] = [".dynsym", ".dynstr", ".gnu.version", ".gnu.version_r"];
|
||||||
|
|
||||||
|
/// ELF64 dynamic table record sizes.
|
||||||
|
pub const ELF64_SYMBOL_SIZE: usize = 0x18;
|
||||||
|
pub const ELF64_RELA_SIZE: usize = 0x18;
|
||||||
|
|
||||||
|
/// AArch64 relocation kinds used by the dynamic linker.
|
||||||
|
pub const R_AARCH64_ABS64: u32 = 0x101;
|
||||||
|
pub const R_AARCH64_GLOB_DAT: u32 = 0x401;
|
||||||
|
pub const R_AARCH64_JUMP_SLOT: u32 = 0x402;
|
||||||
|
pub const R_AARCH64_RELATIVE: u32 = 0x403;
|
||||||
|
pub const VER_NDX_GLOBAL: u16 = 1;
|
||||||
|
|
||||||
|
/// ELF dynamic-table tag identifiers used by restored images.
|
||||||
|
pub const DT_PLTRELSZ: u64 = 2;
|
||||||
|
pub const DT_HASH: u64 = 4;
|
||||||
|
pub const DT_STRTAB: u64 = 5;
|
||||||
|
pub const DT_SYMTAB: u64 = 6;
|
||||||
|
pub const DT_RELA: u64 = 7;
|
||||||
|
pub const DT_RELASZ: u64 = 8;
|
||||||
|
pub const DT_STRSZ: u64 = 10;
|
||||||
|
pub const DT_JMPREL: u64 = 23;
|
||||||
|
pub const DT_GNU_HASH: u64 = 0x6fff_fef5;
|
||||||
|
pub const DT_VERSYM: u64 = 0x6fff_fff0;
|
||||||
|
pub const DT_RELACOUNT: u64 = 0x6fff_fff9;
|
||||||
|
pub const DT_VERNEED: u64 = 0x6fff_fffe;
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
#[error("invalid ELF layout: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
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 whether a short prefix identifies an ELF64 little-endian AArch64
|
||||||
|
/// image. This is intentionally a prefix-only check for filesystem scanners;
|
||||||
|
/// callers that need structural guarantees must use [`parse`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_aarch64_prefix(data: &[u8]) -> bool {
|
||||||
|
data.get(0..6) == Some(b"\x7fELF\x02\x01")
|
||||||
|
&& data
|
||||||
|
.get(18..20)
|
||||||
|
.is_some_and(|bytes| u16::from_le_bytes([bytes[0], bytes[1]]) == EM_AARCH64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn invalid<T>(message: impl Into<String>) -> Result<T> {
|
||||||
|
Err(Error::Invalid(message.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_elf() {
|
||||||
|
assert!(matches!(parse(b"not elf"), Err(Error::Parse(_))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "senbei-engine"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Platform unpacking engines for Senbei"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
memmap2.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
senbei-crypto.workspace = true
|
||||||
|
senbei-elf.workspace = true
|
||||||
|
senbei-pe.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! Shared Android engine filesystem and digest helpers.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
pub(crate) fn absolute(path: &Path) -> std::io::Result<PathBuf> {
|
||||||
|
if path.is_absolute() {
|
||||||
|
Ok(path.to_path_buf())
|
||||||
|
} else {
|
||||||
|
std::env::current_dir().map(|current| current.join(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||||
|
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
let mut temporary = NamedTempFile::new_in(parent)?;
|
||||||
|
temporary.write_all(data)?;
|
||||||
|
temporary.as_file().sync_all()?;
|
||||||
|
temporary.persist(path).map_err(|error| error.error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn sha256(data: &[u8]) -> String {
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
digest.update(data);
|
||||||
|
senbei_crypto::hex_digest(&digest.finalize())
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Stage 1 or Stage 2 extraction failure.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("{action} `{path}`: {source}")]
|
||||||
|
Io {
|
||||||
|
action: &'static str,
|
||||||
|
path: PathBuf,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("parse ELF `{path}`: {source}")]
|
||||||
|
Elf {
|
||||||
|
path: PathBuf,
|
||||||
|
#[source]
|
||||||
|
source: senbei_elf::Error,
|
||||||
|
},
|
||||||
|
#[error("serialize extraction index: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error("embedded Stage 2 decoder configuration: {0}")]
|
||||||
|
EmbeddedConfig(#[source] senbei_crypto::android::Error),
|
||||||
|
#[error(
|
||||||
|
"depth {depth} stream 0x{stream_id:02X} interpreter 0x{interpreter_id:02X} configuration: {source}"
|
||||||
|
)]
|
||||||
|
InterpreterConfig {
|
||||||
|
depth: usize,
|
||||||
|
stream_id: u32,
|
||||||
|
interpreter_id: u32,
|
||||||
|
#[source]
|
||||||
|
source: senbei_crypto::android::Error,
|
||||||
|
},
|
||||||
|
#[error(
|
||||||
|
"depth {depth} stream 0x{stream_id:02X} record {record_index} command 0x{command_id:02X} {part}: {source}"
|
||||||
|
)]
|
||||||
|
RecordDecode {
|
||||||
|
depth: usize,
|
||||||
|
stream_id: u32,
|
||||||
|
record_index: usize,
|
||||||
|
command_id: u32,
|
||||||
|
part: &'static str,
|
||||||
|
#[source]
|
||||||
|
source: senbei_crypto::android::Error,
|
||||||
|
},
|
||||||
|
#[error("{0}")]
|
||||||
|
Invalid(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Limit(#[from] crate::android::LimitExceeded),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub(crate) fn io(action: &'static str, path: &Path, source: std::io::Error) -> Self {
|
||||||
|
Self::Io {
|
||||||
|
action,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
pub(crate) fn invalid<T>(message: impl Into<String>) -> Result<T> {
|
||||||
|
Err(Error::Invalid(message.into()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
mod error;
|
||||||
|
mod pipeline;
|
||||||
|
mod probe;
|
||||||
|
mod report;
|
||||||
|
mod stage1;
|
||||||
|
mod stream;
|
||||||
|
|
||||||
|
pub use error::Error;
|
||||||
|
pub use pipeline::{ExtractOptions, extract_stage2, extract_stage2_with_limits};
|
||||||
|
pub use probe::{is_protected_libil2cpp, probe_libil2cpp_with_limits};
|
||||||
|
pub use report::ExtractionReport;
|
||||||
|
pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use memmap2::MmapOptions;
|
||||||
|
use senbei_crypto::android::{Module9bConfig, decode_container_with_limit};
|
||||||
|
|
||||||
|
use crate::android::{ByteBudget, EntryBudget, ResourceLimits};
|
||||||
|
use serde_json::to_vec_pretty;
|
||||||
|
|
||||||
|
use super::super::common;
|
||||||
|
use super::error::{Error, Result, invalid};
|
||||||
|
use super::report::{
|
||||||
|
ArtifactReport, DecoderReport, ExtractionReport, ModuleRegistryEntry, RecordReport,
|
||||||
|
Stage1Report, StreamParent, StreamReport,
|
||||||
|
};
|
||||||
|
use super::stage1::{
|
||||||
|
DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, SHT_LOUSER, Stage1Result, inspect,
|
||||||
|
};
|
||||||
|
use super::stream::{DIRECT_FLAG, Record, parse_record_stream};
|
||||||
|
|
||||||
|
/// Inputs and output locations for one complete static Stage 2 extraction.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExtractOptions {
|
||||||
|
pub input: PathBuf,
|
||||||
|
pub output_dir: PathBuf,
|
||||||
|
pub stage2_output: Option<PathBuf>,
|
||||||
|
pub outer_size: usize,
|
||||||
|
pub cipher_constant: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExtractOptions {
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_defaults(input: PathBuf, output_dir: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
input,
|
||||||
|
output_dir,
|
||||||
|
stage2_output: None,
|
||||||
|
outer_size: DEFAULT_OUTER_SIZE,
|
||||||
|
cipher_constant: DEFAULT_CIPHER_CONSTANT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct LoadedModule {
|
||||||
|
image: Vec<u8>,
|
||||||
|
metadata: Option<Vec<u8>>,
|
||||||
|
image_path: String,
|
||||||
|
metadata_path: Option<String>,
|
||||||
|
sha256: String,
|
||||||
|
depth: usize,
|
||||||
|
record_index: usize,
|
||||||
|
command_id: u32,
|
||||||
|
init_offset: u32,
|
||||||
|
entry_offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct ArtifactSpec<'a> {
|
||||||
|
suffix: &'a str,
|
||||||
|
kind: &'a str,
|
||||||
|
classification: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DecodeBudget {
|
||||||
|
limits: ResourceLimits,
|
||||||
|
bytes: ByteBudget,
|
||||||
|
records: EntryBudget,
|
||||||
|
config: Module9bConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Extractor {
|
||||||
|
output_dir: PathBuf,
|
||||||
|
streams: Vec<StreamReport>,
|
||||||
|
artifacts: Vec<ArtifactReport>,
|
||||||
|
registry: BTreeMap<u32, LoadedModule>,
|
||||||
|
seen_streams: HashSet<(u32, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
|
||||||
|
extract_stage2_with_limits(options, &ResourceLimits::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`extract_stage2`] with explicit ceilings for decoded records and nesting.
|
||||||
|
///
|
||||||
|
/// `limits.max_entries` counts decoded stage-2 records. `limits.max_depth`
|
||||||
|
/// counts nested interpreter streams, with the bootstrap stream at depth 0.
|
||||||
|
/// `limits.max_entry_bytes` bounds each decoded container and its writers, and
|
||||||
|
/// `limits.max_total_bytes` sums those decoded payloads for this call. Neither
|
||||||
|
/// budget is a peak resident-memory measurement.
|
||||||
|
pub fn extract_stage2_with_limits(
|
||||||
|
options: &ExtractOptions,
|
||||||
|
limits: &ResourceLimits,
|
||||||
|
) -> Result<ExtractionReport> {
|
||||||
|
let input_path = absolute(&options.input)?;
|
||||||
|
let output_dir = absolute(&options.output_dir)?;
|
||||||
|
if !input_path.is_file() {
|
||||||
|
return invalid(format!(
|
||||||
|
"protected ELF does not exist: {}",
|
||||||
|
input_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(stage2_output) = &options.stage2_output {
|
||||||
|
let stage2_output = absolute(stage2_output)?;
|
||||||
|
if stage2_output == input_path {
|
||||||
|
return invalid("refusing to overwrite the protected ELF with Stage 2 output");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(&output_dir)
|
||||||
|
.map_err(|source| Error::io("create Stage 2 output directory", &output_dir, source))?;
|
||||||
|
|
||||||
|
let file = File::open(&input_path)
|
||||||
|
.map_err(|source| Error::io("open protected ELF", &input_path, source))?;
|
||||||
|
// SAFETY: the mapping is read-only, the file remains open for the mapping
|
||||||
|
// lifetime, and extraction never mutates or truncates the source.
|
||||||
|
let source = unsafe { MmapOptions::new().map(&file) }
|
||||||
|
.map_err(|source| Error::io("map protected ELF", &input_path, source))?;
|
||||||
|
let stage1 = inspect(
|
||||||
|
&source,
|
||||||
|
&input_path,
|
||||||
|
options.outer_size,
|
||||||
|
options.cipher_constant,
|
||||||
|
// Stage 1 plaintext is the first thing this task retains. Its copy
|
||||||
|
// is capped by both the single-entry ceiling and whatever remains of
|
||||||
|
// the task total, which is the whole total at this point.
|
||||||
|
Some(limits.max_entry_bytes.min(limits.max_total_bytes)),
|
||||||
|
)?;
|
||||||
|
if let Some(stage2_output) = &options.stage2_output {
|
||||||
|
write_atomic(&absolute(stage2_output)?, &stage1.plaintext)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let core_config =
|
||||||
|
Module9bConfig::parse_embedded(&stage1.plaintext).map_err(Error::EmbeddedConfig)?;
|
||||||
|
let bootstrap_end = stage1
|
||||||
|
.remaining_file_offset
|
||||||
|
.checked_add(stage1.remaining_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("Stage 2 bootstrap range overflow".to_owned()))?;
|
||||||
|
let bootstrap = source
|
||||||
|
.get(stage1.remaining_file_offset..bootstrap_end)
|
||||||
|
.ok_or_else(|| Error::Invalid("Stage 2 bootstrap range is outside the ELF".to_owned()))?;
|
||||||
|
let mut extractor = Extractor {
|
||||||
|
output_dir: output_dir.clone(),
|
||||||
|
streams: Vec::new(),
|
||||||
|
artifacts: Vec::new(),
|
||||||
|
registry: BTreeMap::new(),
|
||||||
|
seen_streams: HashSet::new(),
|
||||||
|
};
|
||||||
|
let mut budget = DecodeBudget {
|
||||||
|
limits: *limits,
|
||||||
|
bytes: ByteBudget::new(limits),
|
||||||
|
records: EntryBudget::new(limits),
|
||||||
|
config: core_config,
|
||||||
|
};
|
||||||
|
budget
|
||||||
|
.bytes
|
||||||
|
.charge(u64::try_from(stage1.plaintext.len()).unwrap_or(u64::MAX))?;
|
||||||
|
extractor.extract_stream(
|
||||||
|
bootstrap,
|
||||||
|
0xe2,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
Some(stage1.remaining_file_offset),
|
||||||
|
&mut budget,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let module_registry = extractor
|
||||||
|
.registry
|
||||||
|
.values()
|
||||||
|
.map(|module| ModuleRegistryEntry {
|
||||||
|
command_id: module.command_id,
|
||||||
|
size: module.image.len(),
|
||||||
|
sha256: module.sha256.clone(),
|
||||||
|
depth: module.depth,
|
||||||
|
record_index: module.record_index,
|
||||||
|
image_path: module.image_path.clone(),
|
||||||
|
metadata_path: module.metadata_path.clone(),
|
||||||
|
init_offset: module.init_offset,
|
||||||
|
entry_offset: module.entry_offset,
|
||||||
|
classification: if module.metadata.is_some() {
|
||||||
|
"module_image".to_owned()
|
||||||
|
} else {
|
||||||
|
"decoded_data".to_owned()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let report = ExtractionReport {
|
||||||
|
format_version: 4,
|
||||||
|
protected_elf: input_path.display().to_string(),
|
||||||
|
output_dir: output_dir.display().to_string(),
|
||||||
|
stage1: stage1_report(&stage1, options.outer_size),
|
||||||
|
streams: extractor.streams,
|
||||||
|
artifacts: extractor.artifacts,
|
||||||
|
errors: Vec::new(),
|
||||||
|
module_registry,
|
||||||
|
};
|
||||||
|
write_json_atomic(&output_dir.join("index.json"), &report)?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Extractor {
|
||||||
|
fn extract_stream(
|
||||||
|
&mut self,
|
||||||
|
stream: &[u8],
|
||||||
|
stream_id: u32,
|
||||||
|
depth: usize,
|
||||||
|
parent: Option<StreamParent>,
|
||||||
|
source_file_offset: Option<usize>,
|
||||||
|
budget: &mut DecodeBudget,
|
||||||
|
) -> Result<()> {
|
||||||
|
budget.limits.check_depth(depth)?;
|
||||||
|
let digest = sha256(stream);
|
||||||
|
if !self.seen_streams.insert((stream_id, digest.clone())) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (header, records, table_size) =
|
||||||
|
parse_record_stream(stream, stream_id).map_err(|source| {
|
||||||
|
Error::Invalid(format!(
|
||||||
|
"depth {depth} stream 0x{stream_id:02X} record table: {source}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut stream_report = StreamReport {
|
||||||
|
depth,
|
||||||
|
stream_id,
|
||||||
|
parent,
|
||||||
|
source_file_offset,
|
||||||
|
available_size: stream.len(),
|
||||||
|
descriptor_table_size: table_size,
|
||||||
|
encrypted_header_words: header.encrypted_words,
|
||||||
|
decrypted_header_words: header.decrypted_words,
|
||||||
|
record_state: header.record_state,
|
||||||
|
sha256: digest,
|
||||||
|
decoder: decoder_report(
|
||||||
|
if depth == 0 {
|
||||||
|
"embedded_stage2"
|
||||||
|
} else {
|
||||||
|
"decoded_interpreter"
|
||||||
|
},
|
||||||
|
(depth != 0).then_some(stream_id),
|
||||||
|
&budget.config,
|
||||||
|
),
|
||||||
|
records: Vec::new(),
|
||||||
|
};
|
||||||
|
budget.records.charge(records.len())?;
|
||||||
|
stream_report
|
||||||
|
.records
|
||||||
|
.try_reserve_exact(records.len())
|
||||||
|
.map_err(|_| Error::Invalid("stage-2 record report reservation failed".to_owned()))?;
|
||||||
|
let mut direct_records = Vec::new();
|
||||||
|
let mut modules_at_level = BTreeSet::new();
|
||||||
|
|
||||||
|
for record in records {
|
||||||
|
let mut result = record_report(record);
|
||||||
|
let mut image_data = None;
|
||||||
|
let mut metadata_data = None;
|
||||||
|
|
||||||
|
if !record.direct() && record.image_size != 0 {
|
||||||
|
// Charge the declared size before the decoder reserves it.
|
||||||
|
// A metadata-only record is charged below, on the same rule.
|
||||||
|
budget.bytes.charge(u64::from(record.image_size))?;
|
||||||
|
let image_source = record_tail(stream, record.image_offset)?;
|
||||||
|
let image = decode_container_with_limit(
|
||||||
|
image_source,
|
||||||
|
&budget.config,
|
||||||
|
record.image_size as usize,
|
||||||
|
budget.limits.output_limit(),
|
||||||
|
)
|
||||||
|
.map_err(|source| Error::RecordDecode {
|
||||||
|
depth,
|
||||||
|
stream_id,
|
||||||
|
record_index: record.index,
|
||||||
|
command_id: record.command_id,
|
||||||
|
part: "image decode",
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let classification = if record.metadata_size != 0 {
|
||||||
|
"module_image"
|
||||||
|
} else {
|
||||||
|
"decoded_data"
|
||||||
|
};
|
||||||
|
let artifact = self.write_artifact(
|
||||||
|
&record,
|
||||||
|
depth,
|
||||||
|
stream_id,
|
||||||
|
ArtifactSpec {
|
||||||
|
suffix: "module.bin",
|
||||||
|
kind: "decoded_container",
|
||||||
|
classification,
|
||||||
|
},
|
||||||
|
&image,
|
||||||
|
)?;
|
||||||
|
result.image = Some(artifact.clone());
|
||||||
|
image_data = Some((image, artifact));
|
||||||
|
}
|
||||||
|
if record.metadata_size != 0 {
|
||||||
|
budget.bytes.charge(u64::from(record.metadata_size))?;
|
||||||
|
let metadata_source = record_tail(stream, record.metadata_offset)?;
|
||||||
|
let metadata = decode_container_with_limit(
|
||||||
|
metadata_source,
|
||||||
|
&budget.config,
|
||||||
|
record.metadata_size as usize,
|
||||||
|
budget.limits.output_limit(),
|
||||||
|
)
|
||||||
|
.map_err(|source| Error::RecordDecode {
|
||||||
|
depth,
|
||||||
|
stream_id,
|
||||||
|
record_index: record.index,
|
||||||
|
command_id: record.command_id,
|
||||||
|
part: "metadata decode",
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let artifact = self.write_artifact(
|
||||||
|
&record,
|
||||||
|
depth,
|
||||||
|
stream_id,
|
||||||
|
ArtifactSpec {
|
||||||
|
suffix: "metadata.bin",
|
||||||
|
kind: "decoded_metadata",
|
||||||
|
classification: "decoded_metadata",
|
||||||
|
},
|
||||||
|
&metadata,
|
||||||
|
)?;
|
||||||
|
result.metadata = Some(artifact.clone());
|
||||||
|
metadata_data = Some((metadata, artifact));
|
||||||
|
}
|
||||||
|
if let Some((image, image_artifact)) = image_data {
|
||||||
|
let (metadata, metadata_path) = if let Some((data, artifact)) = metadata_data {
|
||||||
|
(Some(data), Some(artifact.path))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
self.register_module(LoadedModule {
|
||||||
|
sha256: image_artifact.sha256.clone(),
|
||||||
|
image_path: image_artifact.path.clone(),
|
||||||
|
metadata_path,
|
||||||
|
image,
|
||||||
|
metadata,
|
||||||
|
depth,
|
||||||
|
record_index: record.index,
|
||||||
|
command_id: record.command_id,
|
||||||
|
init_offset: record.init_offset,
|
||||||
|
entry_offset: record.entry_offset,
|
||||||
|
})?;
|
||||||
|
modules_at_level.insert(record.command_id);
|
||||||
|
}
|
||||||
|
if record.direct() && record.image_size != 0 {
|
||||||
|
direct_records.push((record, stream_report.records.len()));
|
||||||
|
}
|
||||||
|
stream_report.records.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut children = Vec::new();
|
||||||
|
for (record, report_index) in direct_records {
|
||||||
|
let next_stream_id = record.command_id.wrapping_sub(0x10);
|
||||||
|
if modules_at_level.contains(&next_stream_id) {
|
||||||
|
stream_report.records[report_index].nested_stream_id = Some(next_stream_id);
|
||||||
|
children.push((record, next_stream_id));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let direct_data = record_slice(stream, record.image_offset, record.image_size)?;
|
||||||
|
let artifact = self.write_artifact(
|
||||||
|
&record,
|
||||||
|
depth,
|
||||||
|
stream_id,
|
||||||
|
ArtifactSpec {
|
||||||
|
suffix: "direct.bin",
|
||||||
|
kind: "direct",
|
||||||
|
classification: "direct_data",
|
||||||
|
},
|
||||||
|
direct_data,
|
||||||
|
)?;
|
||||||
|
stream_report.records[report_index].image = Some(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.streams.push(stream_report);
|
||||||
|
for (record, next_stream_id) in children {
|
||||||
|
let child_data = record_slice(stream, record.image_offset, record.image_size)?;
|
||||||
|
let parent = StreamParent {
|
||||||
|
stream_id,
|
||||||
|
record_index: record.index,
|
||||||
|
command_id: record.command_id,
|
||||||
|
};
|
||||||
|
let interpreter = self.registry.get(&next_stream_id).ok_or_else(|| {
|
||||||
|
Error::Invalid(format!(
|
||||||
|
"depth {depth} stream 0x{stream_id:02X} child 0x{next_stream_id:02X} has no interpreter module"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let interpreter_config =
|
||||||
|
Module9bConfig::parse(&interpreter.image).map_err(|source| {
|
||||||
|
Error::InterpreterConfig {
|
||||||
|
depth: depth + 1,
|
||||||
|
stream_id: next_stream_id,
|
||||||
|
interpreter_id: next_stream_id,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
budget.limits.check_depth(depth + 1)?;
|
||||||
|
let parent_config = std::mem::replace(&mut budget.config, interpreter_config);
|
||||||
|
self.extract_stream(
|
||||||
|
child_data,
|
||||||
|
next_stream_id,
|
||||||
|
depth + 1,
|
||||||
|
Some(parent),
|
||||||
|
None,
|
||||||
|
budget,
|
||||||
|
)?;
|
||||||
|
budget.config = parent_config;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_module(&mut self, module: LoadedModule) -> Result<()> {
|
||||||
|
if let Some(previous) = self.registry.get(&module.command_id) {
|
||||||
|
if previous.sha256 != module.sha256 {
|
||||||
|
return invalid(format!(
|
||||||
|
"module 0x{:02X} produced conflicting images: {} and {}",
|
||||||
|
module.command_id, previous.sha256, module.sha256
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.registry.insert(module.command_id, module);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_artifact(
|
||||||
|
&mut self,
|
||||||
|
record: &Record,
|
||||||
|
depth: usize,
|
||||||
|
stream_id: u32,
|
||||||
|
spec: ArtifactSpec<'_>,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<ArtifactReport> {
|
||||||
|
let digest = sha256(data);
|
||||||
|
let filename = format!(
|
||||||
|
"d{depth:02}_s{stream_id:02X}_r{:03}_id{:08X}_{}.{}",
|
||||||
|
record.index,
|
||||||
|
record.command_id,
|
||||||
|
&digest[..12],
|
||||||
|
spec.suffix
|
||||||
|
);
|
||||||
|
let path = self.output_dir.join(filename);
|
||||||
|
write_atomic(&path, data)?;
|
||||||
|
let artifact = ArtifactReport {
|
||||||
|
kind: spec.kind.to_owned(),
|
||||||
|
path: path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| Error::Invalid("artifact path has no file name".to_owned()))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
size: data.len(),
|
||||||
|
sha256: digest,
|
||||||
|
depth,
|
||||||
|
stream_id,
|
||||||
|
record_index: Some(record.index),
|
||||||
|
command_id: Some(record.command_id),
|
||||||
|
classification: spec.classification.to_owned(),
|
||||||
|
};
|
||||||
|
self.artifacts.push(artifact.clone());
|
||||||
|
Ok(artifact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_report(record: Record) -> RecordReport {
|
||||||
|
RecordReport {
|
||||||
|
index: record.index,
|
||||||
|
command_id: record.command_id,
|
||||||
|
flags: record.flags,
|
||||||
|
image_offset: record.image_offset,
|
||||||
|
image_size: record.image_size,
|
||||||
|
metadata_offset: record.metadata_offset,
|
||||||
|
metadata_size: record.metadata_size,
|
||||||
|
id_copy: record.id_copy,
|
||||||
|
entry_offset: record.entry_offset,
|
||||||
|
init_offset: record.init_offset,
|
||||||
|
direct: record.flags & DIRECT_FLAG != 0,
|
||||||
|
extraction_status: "complete".to_owned(),
|
||||||
|
image: None,
|
||||||
|
metadata: None,
|
||||||
|
nested_stream_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decoder_report(
|
||||||
|
kind: &str,
|
||||||
|
interpreter_id: Option<u32>,
|
||||||
|
config: &Module9bConfig,
|
||||||
|
) -> DecoderReport {
|
||||||
|
DecoderReport {
|
||||||
|
kind: kind.to_owned(),
|
||||||
|
interpreter_id,
|
||||||
|
header_seed: config.header_seed,
|
||||||
|
container_seed: config.container_seed,
|
||||||
|
schedule_offset: config.schedule_offset,
|
||||||
|
aes_key_sha256: sha256(&config.aes_key),
|
||||||
|
skip_aes: config.skip_aes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_slice(stream: &[u8], offset: u32, size: u32) -> Result<&[u8]> {
|
||||||
|
let offset = usize::try_from(offset)
|
||||||
|
.map_err(|_| Error::Invalid("record payload offset exceeds usize".to_owned()))?;
|
||||||
|
let size = usize::try_from(size)
|
||||||
|
.map_err(|_| Error::Invalid("record payload size exceeds usize".to_owned()))?;
|
||||||
|
let end = offset
|
||||||
|
.checked_add(size)
|
||||||
|
.ok_or_else(|| Error::Invalid("record payload range overflows usize".to_owned()))?;
|
||||||
|
stream.get(offset..end).ok_or_else(|| {
|
||||||
|
Error::Invalid(format!(
|
||||||
|
"record payload range 0x{offset:x}..0x{end:x} exceeds stream 0x{:x}",
|
||||||
|
stream.len()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_tail(stream: &[u8], offset: u32) -> Result<&[u8]> {
|
||||||
|
let offset = usize::try_from(offset)
|
||||||
|
.map_err(|_| Error::Invalid("record container offset exceeds usize".to_owned()))?;
|
||||||
|
stream.get(offset..).ok_or_else(|| {
|
||||||
|
Error::Invalid(format!(
|
||||||
|
"record container offset 0x{offset:x} exceeds stream 0x{:x}",
|
||||||
|
stream.len()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage1_report(stage1: &Stage1Result, outer_size: usize) -> Stage1Report {
|
||||||
|
Stage1Report {
|
||||||
|
section_index: stage1.section_index,
|
||||||
|
section_type: SHT_LOUSER,
|
||||||
|
section_offset: stage1.section_offset,
|
||||||
|
section_size: stage1.section_size,
|
||||||
|
outer_size,
|
||||||
|
header_offset: stage1.header_offset,
|
||||||
|
header_key: stage1.header.key,
|
||||||
|
payload_offset: stage1.header.payload_offset,
|
||||||
|
payload_size: stage1.header.payload_size,
|
||||||
|
payload_key: stage1.header.payload_key,
|
||||||
|
entry_offset: stage1.header.entry_offset,
|
||||||
|
protect_size: stage1.header.protect_size,
|
||||||
|
stage2_file_offset: stage1.payload_file_offset,
|
||||||
|
stage2_size: stage1.plaintext.len(),
|
||||||
|
stage2_sha256: sha256(&stage1.plaintext),
|
||||||
|
remaining_file_offset: stage1.remaining_file_offset,
|
||||||
|
remaining_size: stage1.remaining_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_json_atomic(path: &Path, value: &impl serde::Serialize) -> Result<()> {
|
||||||
|
let mut bytes = to_vec_pretty(value)?;
|
||||||
|
bytes.push(b'\n');
|
||||||
|
write_atomic(path, &bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
|
||||||
|
common::write_atomic(path, data)
|
||||||
|
.map_err(|source| Error::io("write temporary output", path, source))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn absolute(path: &Path) -> Result<PathBuf> {
|
||||||
|
common::absolute(path).map_err(|source| Error::io("query current directory", path, source))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256(data: &[u8]) -> String {
|
||||||
|
common::sha256(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod limit_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::android::LimitExceeded;
|
||||||
|
|
||||||
|
fn config() -> Module9bConfig {
|
||||||
|
Module9bConfig {
|
||||||
|
header_seed: 0,
|
||||||
|
container_seed: 0,
|
||||||
|
aes_key: [0; 32],
|
||||||
|
skip_aes: true,
|
||||||
|
schedule_offset: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tight(entries: usize, depth: usize) -> ResourceLimits {
|
||||||
|
ResourceLimits {
|
||||||
|
max_entry_bytes: 1024,
|
||||||
|
max_total_bytes: 1024,
|
||||||
|
max_entries: entries,
|
||||||
|
max_depth: depth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One direct record whose payload is `child`.
|
||||||
|
///
|
||||||
|
/// The command is `stream_id + 0x10`, which is the id a nested stream uses
|
||||||
|
/// for its interpreter. The payload begins immediately after the single
|
||||||
|
/// descriptor, so parsing stops at that boundary.
|
||||||
|
fn direct_stream(stream_id: u32, child: &[u8]) -> Vec<u8> {
|
||||||
|
let key = stream_id.wrapping_mul(0x9d32_3cd7);
|
||||||
|
let shift = stream_id & 7;
|
||||||
|
let base = (key >> shift)
|
||||||
|
.wrapping_add(0x5e72_7d74)
|
||||||
|
.wrapping_add(key.wrapping_shl(stream_id & 0xb))
|
||||||
|
.wrapping_add(0xf71e_3005);
|
||||||
|
// record_state = plain1 + base, and plain1 is chosen so that state is
|
||||||
|
// zero. That keeps the record cipher's index mask zero as well.
|
||||||
|
let plain1 = 0_u32.wrapping_sub(base);
|
||||||
|
let cipher0 =
|
||||||
|
unmultiply(1_u32 ^ 0xebe8_1dba_u32.wrapping_add(base)).wrapping_sub(0xcbf0_c1d8);
|
||||||
|
let cipher1 = unmultiply(plain1 ^ 0xebe8_1dba_u32.wrapping_mul(5).wrapping_add(base))
|
||||||
|
.wrapping_sub(cipher0);
|
||||||
|
let mut stream = Vec::new();
|
||||||
|
stream.extend_from_slice(&cipher0.to_le_bytes());
|
||||||
|
stream.extend_from_slice(&cipher1.to_le_bytes());
|
||||||
|
// `command - 0x10` is the interpreter id a nested stream looks up.
|
||||||
|
// Using `stream_id + 0x10` makes a lone record direct, because that
|
||||||
|
// interpreter was not decoded from this same stream.
|
||||||
|
let command = stream_id.wrapping_add(0x10);
|
||||||
|
let words = [
|
||||||
|
command,
|
||||||
|
super::super::stream::DIRECT_FLAG,
|
||||||
|
(8 + super::super::stream::RECORD_SIZE) as u32,
|
||||||
|
child.len() as u32,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
command,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
stream.extend_from_slice(&encrypt_record(&words));
|
||||||
|
stream.extend_from_slice(child);
|
||||||
|
stream
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encrypt_record(words: &[u32]) -> Vec<u8> {
|
||||||
|
// Inverse of `decrypt_record` at record state zero: replay its
|
||||||
|
// feedback from each ciphertext word, then undo the field multiply.
|
||||||
|
let mut feedback = 0xf02f_7685_u32;
|
||||||
|
let mut accumulator = 0x7993_4cf6_u32;
|
||||||
|
let mut raw = vec![0_u8; super::super::stream::RECORD_SIZE];
|
||||||
|
for (word_index, plain) in words.iter().copied().enumerate() {
|
||||||
|
feedback = feedback.wrapping_mul(feedback);
|
||||||
|
let mixed = plain
|
||||||
|
.wrapping_sub(accumulator)
|
||||||
|
.wrapping_add(0 >> ((word_index * 4 + 3) & 5));
|
||||||
|
let cipher = unmultiply(mixed) ^ (feedback >> 3);
|
||||||
|
raw[word_index * 4..word_index * 4 + 4].copy_from_slice(&cipher.to_le_bytes());
|
||||||
|
accumulator = accumulator.wrapping_add(0xe64d_33d8);
|
||||||
|
feedback = cipher;
|
||||||
|
}
|
||||||
|
raw
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unmultiply(encoded: u32) -> u32 {
|
||||||
|
let mut multiplier = 0xdc49_cd06_u32;
|
||||||
|
let mut value = encoded;
|
||||||
|
let mut result = 0_u32;
|
||||||
|
while multiplier != 0 {
|
||||||
|
if multiplier & 1 != 0 {
|
||||||
|
result ^= value;
|
||||||
|
}
|
||||||
|
let carry = value >> 31;
|
||||||
|
value = value.wrapping_shl(1);
|
||||||
|
if carry != 0 {
|
||||||
|
value ^= 0x5793_57eb;
|
||||||
|
}
|
||||||
|
multiplier >>= 1;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extractor() -> Extractor {
|
||||||
|
Extractor {
|
||||||
|
output_dir: PathBuf::from("."),
|
||||||
|
streams: Vec::new(),
|
||||||
|
artifacts: Vec::new(),
|
||||||
|
registry: BTreeMap::new(),
|
||||||
|
seen_streams: HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn budget(entries: usize, depth: usize) -> DecodeBudget {
|
||||||
|
let limits = tight(entries, depth);
|
||||||
|
DecodeBudget {
|
||||||
|
limits,
|
||||||
|
bytes: ByteBudget::new(&limits),
|
||||||
|
records: EntryBudget::new(&limits),
|
||||||
|
config: config(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_budget_rejects_one_past_the_ceiling_and_allows_the_boundary() {
|
||||||
|
let stream = direct_stream(0xe2, &[0]);
|
||||||
|
let error = extractor()
|
||||||
|
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget(0, 32))
|
||||||
|
.expect_err("one record is already past a zero-record ceiling");
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
error,
|
||||||
|
Error::Limit(LimitExceeded::Entries {
|
||||||
|
requested: 1,
|
||||||
|
limit: 0
|
||||||
|
})
|
||||||
|
),
|
||||||
|
"unexpected error: {error}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let directory = tempfile::tempdir().expect("temporary output directory");
|
||||||
|
let mut at_ceiling = extractor();
|
||||||
|
at_ceiling.output_dir = directory.path().to_path_buf();
|
||||||
|
at_ceiling
|
||||||
|
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget(1, 0))
|
||||||
|
.expect("one record at depth zero is the boundary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nesting_one_past_the_ceiling_is_rejected_before_the_child_is_decoded() {
|
||||||
|
let stream = direct_stream(0xe2, &[0]);
|
||||||
|
let directory = tempfile::tempdir().expect("temporary output directory");
|
||||||
|
let mut at_ceiling = extractor();
|
||||||
|
at_ceiling.output_dir = directory.path().to_path_buf();
|
||||||
|
at_ceiling
|
||||||
|
.extract_stream(&stream, 0xe2, 32, None, None, &mut budget(1, 32))
|
||||||
|
.expect("depth 32 is the nesting boundary");
|
||||||
|
|
||||||
|
let error = extractor()
|
||||||
|
.extract_stream(&stream, 0xe2, 33, None, None, &mut budget(1, 32))
|
||||||
|
.expect_err("depth 33 is rejected before the stream is parsed");
|
||||||
|
match &error {
|
||||||
|
Error::Limit(limit) => assert_eq!(
|
||||||
|
limit,
|
||||||
|
&LimitExceeded::Depth {
|
||||||
|
requested: 33,
|
||||||
|
limit: 32
|
||||||
|
}
|
||||||
|
),
|
||||||
|
other => panic!("unexpected error: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decoded_payloads_share_one_task_budget() {
|
||||||
|
let stream = direct_stream(0xe2, &[0]);
|
||||||
|
let directory = tempfile::tempdir().expect("temporary output directory");
|
||||||
|
let mut budget = budget(1, 0);
|
||||||
|
budget.limits.max_total_bytes = 1;
|
||||||
|
budget.bytes = ByteBudget::new(&budget.limits);
|
||||||
|
// A direct record has no decoded container, so it does not charge the
|
||||||
|
// byte budget. Seed the only allowed byte to prove the budget is the
|
||||||
|
// one the caller passed in and is not reset per stream.
|
||||||
|
budget.bytes.charge(1).unwrap();
|
||||||
|
let mut extractor = extractor();
|
||||||
|
extractor.output_dir = directory.path().to_path_buf();
|
||||||
|
extractor
|
||||||
|
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(budget.bytes.used(), 1);
|
||||||
|
assert!(budget.bytes.charge(1024).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use senbei_crypto::android::Module9bConfig;
|
||||||
|
|
||||||
|
use super::error::Error;
|
||||||
|
use super::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
||||||
|
use crate::android::ResourceLimits;
|
||||||
|
|
||||||
|
/// Return whether `data` has a supported protected AArch64 IL2CPP layout.
|
||||||
|
///
|
||||||
|
/// This uses the default ceilings. A size past those ceilings is reported as
|
||||||
|
/// not protected, matching a scan that has not opted into a custom limit.
|
||||||
|
/// [`probe_libil2cpp_with_limits`] returns that limit error instead.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_protected_libil2cpp(data: &[u8]) -> bool {
|
||||||
|
probe_libil2cpp_with_limits(data, &ResourceLimits::default()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe one image under explicit ceilings.
|
||||||
|
///
|
||||||
|
/// A parse or configuration mismatch is `Ok(false)`: the image is not a
|
||||||
|
/// supported target. [`Error::Limit`] is returned as-is, because the declared
|
||||||
|
/// Stage 1 payload was large enough that classifying it would require the
|
||||||
|
/// allocation the ceiling exists to prevent. The payload copy is capped by
|
||||||
|
/// `min(max_entry_bytes, max_total_bytes)` before it is reserved.
|
||||||
|
pub fn probe_libil2cpp_with_limits(data: &[u8], limits: &ResourceLimits) -> Result<bool, Error> {
|
||||||
|
let stage1 = match stage1::inspect(
|
||||||
|
data,
|
||||||
|
Path::new("<probe>"),
|
||||||
|
DEFAULT_OUTER_SIZE,
|
||||||
|
DEFAULT_CIPHER_CONSTANT,
|
||||||
|
Some(limits.max_entry_bytes.min(limits.max_total_bytes)),
|
||||||
|
) {
|
||||||
|
Ok(stage1) => stage1,
|
||||||
|
Err(Error::Limit(limit)) => return Err(Error::Limit(limit)),
|
||||||
|
Err(_) => return Ok(false),
|
||||||
|
};
|
||||||
|
Ok(Module9bConfig::parse_embedded(&stage1.plaintext).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Stage 1 header words, encrypted with the default cipher constant.
|
||||||
|
fn encrypt_header(words: [u32; 8]) -> [u8; 0x20] {
|
||||||
|
let key = words[0];
|
||||||
|
let mut raw = [0_u8; 0x20];
|
||||||
|
raw[..4].copy_from_slice(&key.to_le_bytes());
|
||||||
|
for (index, value) in words.into_iter().enumerate().skip(1) {
|
||||||
|
let index = index as u32;
|
||||||
|
let word = (value ^ DEFAULT_CIPHER_CONSTANT.wrapping_mul(index + 1))
|
||||||
|
.wrapping_sub((index + 3).wrapping_mul(key));
|
||||||
|
raw[index as usize * 4..index as usize * 4 + 4].copy_from_slice(&word.to_le_bytes());
|
||||||
|
}
|
||||||
|
raw
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(data: &mut [u8], offset: usize, bytes: &[u8]) {
|
||||||
|
data[offset..offset + bytes.len()].copy_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal AArch64 ELF whose SHT_LOUSER payload declares `payload_size`.
|
||||||
|
///
|
||||||
|
/// The declared payload is not present in the file. Inspection rejects that
|
||||||
|
/// size before copying it when the ceiling is smaller.
|
||||||
|
fn elf_with_payload_size(payload_size: u32) -> Vec<u8> {
|
||||||
|
let outer = DEFAULT_OUTER_SIZE;
|
||||||
|
let section_file_size = outer + 0x1000;
|
||||||
|
let section_offset = 64_usize;
|
||||||
|
let shoff = section_offset + section_file_size;
|
||||||
|
// Offsets into `names`: dynsym 1, dynstr 9, gnu.version 17,
|
||||||
|
// gnu.version_r 30, hash 44, private 50, shstrtab 59.
|
||||||
|
let names =
|
||||||
|
b"\0.dynsym\0.dynstr\0.gnu.version\0.gnu.version_r\0.hash\0.private\0.shstrtab\0";
|
||||||
|
let strtab = shoff + 64 * 8;
|
||||||
|
let mut data = vec![0_u8; strtab + names.len()];
|
||||||
|
put(&mut data, 0, b"\x7fELF\x02\x01\x01");
|
||||||
|
put(&mut data, 16, &3_u16.to_le_bytes());
|
||||||
|
put(&mut data, 18, &183_u16.to_le_bytes());
|
||||||
|
put(&mut data, 20, &1_u32.to_le_bytes());
|
||||||
|
put(&mut data, 40, &(shoff as u64).to_le_bytes());
|
||||||
|
put(&mut data, 52, &64_u16.to_le_bytes());
|
||||||
|
put(&mut data, 58, &64_u16.to_le_bytes());
|
||||||
|
put(&mut data, 60, &8_u16.to_le_bytes());
|
||||||
|
put(&mut data, 62, &7_u16.to_le_bytes());
|
||||||
|
let header = encrypt_header([1, 0, 0x20, payload_size, 0, 0, payload_size, payload_size]);
|
||||||
|
put(&mut data, section_offset + outer, &header);
|
||||||
|
put(&mut data, strtab, names);
|
||||||
|
let sections: [(u32, u32, u64, u64); 8] = [
|
||||||
|
(0, 0, 0, 0),
|
||||||
|
(1, 11, 0, 0),
|
||||||
|
(9, 3, 0, 0),
|
||||||
|
(17, 0x6fff_fff0, 0, 0),
|
||||||
|
(30, 0x6fff_fffe, 0, 0),
|
||||||
|
(45, 5, 0, 0),
|
||||||
|
(
|
||||||
|
51,
|
||||||
|
0x8000_0000,
|
||||||
|
section_offset as u64,
|
||||||
|
(outer + 0x1000 + payload_size as usize) as u64,
|
||||||
|
),
|
||||||
|
(60, 3, strtab as u64, names.len() as u64),
|
||||||
|
];
|
||||||
|
for (index, (name, kind, offset, size)) in sections.into_iter().enumerate() {
|
||||||
|
let at = shoff + index * 64;
|
||||||
|
put(&mut data, at, &name.to_le_bytes());
|
||||||
|
put(&mut data, at + 4, &kind.to_le_bytes());
|
||||||
|
put(&mut data, at + 24, &offset.to_le_bytes());
|
||||||
|
put(&mut data, at + 32, &size.to_le_bytes());
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_stage1_payload_is_a_limit_error_not_a_clean_miss() {
|
||||||
|
let image = elf_with_payload_size(8);
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
max_entry_bytes: 4,
|
||||||
|
max_total_bytes: 4,
|
||||||
|
..ResourceLimits::default()
|
||||||
|
};
|
||||||
|
let error = probe_libil2cpp_with_limits(&image, &limits);
|
||||||
|
assert!(
|
||||||
|
matches!(error, Err(Error::Limit(_))),
|
||||||
|
"unexpected probe result: {error:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!probe_libil2cpp_with_limits(&[], &limits).expect("an empty image is not protected")
|
||||||
|
);
|
||||||
|
assert!(!is_protected_libil2cpp(&[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Stage1Report {
|
||||||
|
pub section_index: usize,
|
||||||
|
pub section_type: u32,
|
||||||
|
pub section_offset: usize,
|
||||||
|
pub section_size: usize,
|
||||||
|
pub outer_size: usize,
|
||||||
|
pub header_offset: usize,
|
||||||
|
pub header_key: u32,
|
||||||
|
pub payload_offset: u32,
|
||||||
|
pub payload_size: u32,
|
||||||
|
pub payload_key: u32,
|
||||||
|
pub entry_offset: u32,
|
||||||
|
pub protect_size: u32,
|
||||||
|
pub stage2_file_offset: usize,
|
||||||
|
pub stage2_size: usize,
|
||||||
|
pub stage2_sha256: String,
|
||||||
|
pub remaining_file_offset: usize,
|
||||||
|
pub remaining_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DecoderReport {
|
||||||
|
pub kind: String,
|
||||||
|
pub interpreter_id: Option<u32>,
|
||||||
|
pub header_seed: u32,
|
||||||
|
pub container_seed: u32,
|
||||||
|
pub schedule_offset: usize,
|
||||||
|
pub aes_key_sha256: String,
|
||||||
|
pub skip_aes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ArtifactReport {
|
||||||
|
pub kind: String,
|
||||||
|
pub path: String,
|
||||||
|
pub size: usize,
|
||||||
|
pub sha256: String,
|
||||||
|
pub depth: usize,
|
||||||
|
pub stream_id: u32,
|
||||||
|
pub record_index: Option<usize>,
|
||||||
|
pub command_id: Option<u32>,
|
||||||
|
pub classification: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct RecordReport {
|
||||||
|
pub index: usize,
|
||||||
|
pub command_id: u32,
|
||||||
|
pub flags: u32,
|
||||||
|
pub image_offset: u32,
|
||||||
|
pub image_size: u32,
|
||||||
|
pub metadata_offset: u32,
|
||||||
|
pub metadata_size: u32,
|
||||||
|
pub id_copy: u32,
|
||||||
|
pub entry_offset: u32,
|
||||||
|
pub init_offset: u32,
|
||||||
|
pub direct: bool,
|
||||||
|
pub extraction_status: String,
|
||||||
|
pub image: Option<ArtifactReport>,
|
||||||
|
pub metadata: Option<ArtifactReport>,
|
||||||
|
pub nested_stream_id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct StreamParent {
|
||||||
|
pub stream_id: u32,
|
||||||
|
pub record_index: usize,
|
||||||
|
pub command_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct StreamReport {
|
||||||
|
pub depth: usize,
|
||||||
|
pub stream_id: u32,
|
||||||
|
pub parent: Option<StreamParent>,
|
||||||
|
pub source_file_offset: Option<usize>,
|
||||||
|
pub available_size: usize,
|
||||||
|
pub descriptor_table_size: usize,
|
||||||
|
pub encrypted_header_words: [u32; 2],
|
||||||
|
pub decrypted_header_words: [u32; 2],
|
||||||
|
pub record_state: u32,
|
||||||
|
pub sha256: String,
|
||||||
|
pub decoder: DecoderReport,
|
||||||
|
pub records: Vec<RecordReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ModuleRegistryEntry {
|
||||||
|
pub command_id: u32,
|
||||||
|
pub size: usize,
|
||||||
|
pub sha256: String,
|
||||||
|
pub depth: usize,
|
||||||
|
pub record_index: usize,
|
||||||
|
pub image_path: String,
|
||||||
|
pub metadata_path: Option<String>,
|
||||||
|
pub init_offset: u32,
|
||||||
|
pub entry_offset: u32,
|
||||||
|
pub classification: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine-readable output of one complete static Stage 2 extraction.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ExtractionReport {
|
||||||
|
pub format_version: u32,
|
||||||
|
pub protected_elf: String,
|
||||||
|
pub output_dir: String,
|
||||||
|
pub stage1: Stage1Report,
|
||||||
|
pub streams: Vec<StreamReport>,
|
||||||
|
pub artifacts: Vec<ArtifactReport>,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
pub module_registry: Vec<ModuleRegistryEntry>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use senbei_elf::{AARCH64_MACHINE, Error as ElfError, parse};
|
||||||
|
|
||||||
|
use super::error::{Error, Result, invalid};
|
||||||
|
|
||||||
|
pub(crate) use senbei_elf::SHT_LOUSER;
|
||||||
|
pub const DEFAULT_CIPHER_CONSTANT: u32 = 0xbf20_165d;
|
||||||
|
pub const DEFAULT_OUTER_SIZE: usize = 0x23c;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct Stage1Header {
|
||||||
|
pub key: u32,
|
||||||
|
pub reserved: u32,
|
||||||
|
pub payload_offset: u32,
|
||||||
|
pub payload_size: u32,
|
||||||
|
pub payload_key: u32,
|
||||||
|
pub entry_offset: u32,
|
||||||
|
pub protect_size: u32,
|
||||||
|
pub size_copy: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Stage1Result {
|
||||||
|
pub section_index: usize,
|
||||||
|
pub section_offset: usize,
|
||||||
|
pub section_size: usize,
|
||||||
|
pub header_offset: usize,
|
||||||
|
pub payload_file_offset: usize,
|
||||||
|
pub remaining_file_offset: usize,
|
||||||
|
pub remaining_size: usize,
|
||||||
|
pub header: Stage1Header,
|
||||||
|
pub plaintext: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn inspect(
|
||||||
|
data: &[u8],
|
||||||
|
path: &Path,
|
||||||
|
outer_size: usize,
|
||||||
|
cipher_constant: u32,
|
||||||
|
max_payload_bytes: Option<u64>,
|
||||||
|
) -> Result<Stage1Result> {
|
||||||
|
let elf = parse(data).map_err(|source: ElfError| Error::Elf {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
if elf.header.e_machine != AARCH64_MACHINE {
|
||||||
|
return invalid(format!(
|
||||||
|
"expected AArch64 ELF (machine 0x{AARCH64_MACHINE:X}), got 0x{:X}",
|
||||||
|
elf.header.e_machine
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let matches = elf
|
||||||
|
.section_headers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, section)| section.sh_type == SHT_LOUSER)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if matches.len() != 1 {
|
||||||
|
return invalid(format!(
|
||||||
|
"expected exactly one SHT_LOUSER section, found {}",
|
||||||
|
matches.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for wanted in senbei_elf::PROBE_SECTION_NAMES {
|
||||||
|
if !elf.section_headers.iter().any(|section| {
|
||||||
|
elf.shdr_strtab
|
||||||
|
.get_at(section.sh_name)
|
||||||
|
.is_some_and(|name| name == wanted)
|
||||||
|
}) {
|
||||||
|
return invalid(format!("protected ELF lacks required section {wanted}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let has_symbol_hash = senbei_elf::SYMBOL_HASH_SECTION_NAMES.iter().any(|wanted| {
|
||||||
|
elf.section_headers.iter().any(|section| {
|
||||||
|
elf.shdr_strtab
|
||||||
|
.get_at(section.sh_name)
|
||||||
|
.is_some_and(|name| name == *wanted)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if !has_symbol_hash {
|
||||||
|
return invalid("protected ELF lacks a .gnu.hash or .hash symbol-hash section");
|
||||||
|
}
|
||||||
|
let (section_index, section) = matches[0];
|
||||||
|
let section_offset = usize::try_from(section.sh_offset)
|
||||||
|
.map_err(|_| Error::Invalid("SHT_LOUSER offset exceeds usize".to_owned()))?;
|
||||||
|
let section_size = usize::try_from(section.sh_size)
|
||||||
|
.map_err(|_| Error::Invalid("SHT_LOUSER size exceeds usize".to_owned()))?;
|
||||||
|
let section_end = section_offset
|
||||||
|
.checked_add(section_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("SHT_LOUSER range overflows usize".to_owned()))?;
|
||||||
|
if section_end > data.len() {
|
||||||
|
return invalid("SHT_LOUSER range extends beyond the input file");
|
||||||
|
}
|
||||||
|
let header_relative = outer_size;
|
||||||
|
if outer_size
|
||||||
|
.checked_add(0x1000)
|
||||||
|
.is_none_or(|end| end > section_size)
|
||||||
|
{
|
||||||
|
return invalid("Stage 1 outer header leaves no complete parameter area");
|
||||||
|
}
|
||||||
|
let header_offset = section_offset
|
||||||
|
.checked_add(header_relative)
|
||||||
|
.ok_or_else(|| Error::Invalid("Stage 1 header offset overflow".to_owned()))?;
|
||||||
|
let header_raw = bytes(data, header_offset, 0x1000)?;
|
||||||
|
let header = decrypt_header(header_raw, cipher_constant)?;
|
||||||
|
if header.reserved != 0 {
|
||||||
|
return invalid(format!(
|
||||||
|
"Stage 1 header reserved word is nonzero: 0x{:x}",
|
||||||
|
header.reserved
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if header.size_copy != header.payload_size {
|
||||||
|
return invalid(format!(
|
||||||
|
"Stage 1 payload size copy 0x{:x} != size 0x{:x}",
|
||||||
|
header.size_copy, header.payload_size
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let private_size = section_size - outer_size;
|
||||||
|
let payload_offset = usize::try_from(header.payload_offset)
|
||||||
|
.map_err(|_| Error::Invalid("Stage 1 payload offset exceeds usize".to_owned()))?;
|
||||||
|
let payload_size = usize::try_from(header.payload_size)
|
||||||
|
.map_err(|_| Error::Invalid("Stage 1 payload size exceeds usize".to_owned()))?;
|
||||||
|
let payload_end = payload_offset
|
||||||
|
.checked_add(payload_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("Stage 1 payload range overflow".to_owned()))?;
|
||||||
|
if payload_offset < 0x20 || payload_end > private_size {
|
||||||
|
return invalid(format!(
|
||||||
|
"Stage 1 payload range 0x{payload_offset:x}..0x{payload_end:x} exceeds private size 0x{private_size:x}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if payload_size == 0 || payload_size % 4 != 0 {
|
||||||
|
return invalid(format!(
|
||||||
|
"Stage 1 payload size must be nonzero and word aligned: 0x{payload_size:x}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(limit) = max_payload_bytes {
|
||||||
|
let declared = u64::try_from(payload_size)
|
||||||
|
.map_err(|_| Error::Invalid("Stage 1 payload size exceeds u64".to_owned()))?;
|
||||||
|
crate::android::ResourceLimits {
|
||||||
|
max_entry_bytes: limit,
|
||||||
|
..crate::android::ResourceLimits::default()
|
||||||
|
}
|
||||||
|
.check_entry_bytes(declared)?;
|
||||||
|
}
|
||||||
|
let entry_offset = usize::try_from(header.entry_offset)
|
||||||
|
.map_err(|_| Error::Invalid("Stage 1 entry offset exceeds usize".to_owned()))?;
|
||||||
|
if entry_offset >= payload_size {
|
||||||
|
return invalid("Stage 1 entry offset is outside the payload");
|
||||||
|
}
|
||||||
|
let protect_size = usize::try_from(header.protect_size)
|
||||||
|
.map_err(|_| Error::Invalid("Stage 1 protect size exceeds usize".to_owned()))?;
|
||||||
|
if protect_size > payload_size {
|
||||||
|
return invalid("Stage 1 mprotect length exceeds the payload");
|
||||||
|
}
|
||||||
|
let payload_file_offset = header_offset
|
||||||
|
.checked_add(payload_offset)
|
||||||
|
.ok_or_else(|| Error::Invalid("Stage 1 payload file offset overflow".to_owned()))?;
|
||||||
|
let encrypted = bytes(data, payload_file_offset, payload_size)?;
|
||||||
|
let plaintext = decrypt_words(encrypted, header.payload_key, cipher_constant)?;
|
||||||
|
let aligned_payload_end = (payload_end + 3) & !3;
|
||||||
|
let remaining_relative = aligned_payload_end;
|
||||||
|
if remaining_relative > private_size {
|
||||||
|
return invalid("aligned Stage 2 cursor exceeds SHT_LOUSER");
|
||||||
|
}
|
||||||
|
let remaining_file_offset = section_offset
|
||||||
|
.checked_add(outer_size)
|
||||||
|
.and_then(|value| value.checked_add(remaining_relative))
|
||||||
|
.ok_or_else(|| Error::Invalid("Stage 2 stream offset overflow".to_owned()))?;
|
||||||
|
Ok(Stage1Result {
|
||||||
|
section_index,
|
||||||
|
section_offset,
|
||||||
|
section_size,
|
||||||
|
header_offset,
|
||||||
|
payload_file_offset,
|
||||||
|
remaining_file_offset,
|
||||||
|
remaining_size: private_size - remaining_relative,
|
||||||
|
header,
|
||||||
|
plaintext,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt_header(raw: &[u8], constant: u32) -> Result<Stage1Header> {
|
||||||
|
let key = read_u32(raw, 0)?;
|
||||||
|
let mut decoded = decrypt_words(&raw[..0x20], key, constant)?;
|
||||||
|
decoded[..4].copy_from_slice(&key.to_le_bytes());
|
||||||
|
Ok(Stage1Header {
|
||||||
|
key,
|
||||||
|
reserved: read_u32(&decoded, 4)?,
|
||||||
|
payload_offset: read_u32(&decoded, 8)?,
|
||||||
|
payload_size: read_u32(&decoded, 12)?,
|
||||||
|
payload_key: read_u32(&decoded, 16)?,
|
||||||
|
entry_offset: read_u32(&decoded, 20)?,
|
||||||
|
protect_size: read_u32(&decoded, 24)?,
|
||||||
|
size_copy: read_u32(&decoded, 28)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt_words(ciphertext: &[u8], key: u32, constant: u32) -> Result<Vec<u8>> {
|
||||||
|
if !ciphertext.len().is_multiple_of(4) {
|
||||||
|
return invalid("Stage 1 word cipher input is not 4-byte aligned");
|
||||||
|
}
|
||||||
|
let mut plaintext = Vec::new();
|
||||||
|
plaintext
|
||||||
|
.try_reserve_exact(ciphertext.len())
|
||||||
|
.map_err(|_| Error::Invalid("Stage 1 plaintext reservation failed".to_owned()))?;
|
||||||
|
plaintext.extend_from_slice(ciphertext);
|
||||||
|
for (index, chunk) in plaintext.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||||
|
let index = u32::try_from(index)
|
||||||
|
.map_err(|_| Error::Invalid("Stage 1 word index exceeds u32".to_owned()))?;
|
||||||
|
let mut word = u32::from_le_bytes(*chunk);
|
||||||
|
word = word.wrapping_add(index.wrapping_add(3).wrapping_mul(key));
|
||||||
|
word ^= constant.wrapping_mul(index.wrapping_add(1));
|
||||||
|
chunk.copy_from_slice(&word.to_le_bytes());
|
||||||
|
}
|
||||||
|
Ok(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> {
|
||||||
|
let end = offset
|
||||||
|
.checked_add(size)
|
||||||
|
.ok_or_else(|| Error::Invalid("byte range overflow".to_owned()))?;
|
||||||
|
data.get(offset..end).ok_or_else(|| {
|
||||||
|
Error::Invalid(format!(
|
||||||
|
"byte range 0x{offset:x}..0x{end:x} is outside the input"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||||
|
let bytes = bytes(data, offset, 4)?;
|
||||||
|
Ok(u32::from_le_bytes(bytes.try_into().map_err(|_| {
|
||||||
|
Error::Invalid("invalid u32 byte range".to_owned())
|
||||||
|
})?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage1_word_transform_round_trips() {
|
||||||
|
let key = 0x1234_5678;
|
||||||
|
let constant = DEFAULT_CIPHER_CONSTANT;
|
||||||
|
let plain = [0x1122_3344_u32, 0xaabb_ccdd, 0x0102_0304];
|
||||||
|
let mut cipher = Vec::new();
|
||||||
|
for (index, value) in plain.into_iter().enumerate() {
|
||||||
|
let index = index as u32;
|
||||||
|
let word = (value ^ constant.wrapping_mul(index + 1))
|
||||||
|
.wrapping_sub((index + 3).wrapping_mul(key));
|
||||||
|
cipher.extend_from_slice(&word.to_le_bytes());
|
||||||
|
}
|
||||||
|
let decoded = decrypt_words(&cipher, key, constant).unwrap();
|
||||||
|
let expected = plain
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(u32::to_le_bytes)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(decoded, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
use senbei_crypto::android::gf32_mul_fixed;
|
||||||
|
|
||||||
|
use super::error::{Error, Result, invalid};
|
||||||
|
|
||||||
|
pub(crate) const RECORD_SIZE: usize = 0x5c;
|
||||||
|
pub(crate) const DIRECT_FLAG: u32 = 2;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct Record {
|
||||||
|
pub index: usize,
|
||||||
|
pub command_id: u32,
|
||||||
|
pub flags: u32,
|
||||||
|
pub image_offset: u32,
|
||||||
|
pub image_size: u32,
|
||||||
|
pub metadata_offset: u32,
|
||||||
|
pub metadata_size: u32,
|
||||||
|
pub id_copy: u32,
|
||||||
|
pub entry_offset: u32,
|
||||||
|
pub init_offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record {
|
||||||
|
pub(crate) fn direct(self) -> bool {
|
||||||
|
self.flags & DIRECT_FLAG != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) struct StreamHeader {
|
||||||
|
pub encrypted_words: [u32; 2],
|
||||||
|
pub decrypted_words: [u32; 2],
|
||||||
|
pub record_state: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_record_stream(
|
||||||
|
stream: &[u8],
|
||||||
|
stream_id: u32,
|
||||||
|
) -> Result<(StreamHeader, Vec<Record>, usize)> {
|
||||||
|
if stream.len() < 8 {
|
||||||
|
return invalid(format!(
|
||||||
|
"stream 0x{stream_id:02X} is shorter than its 8-byte header"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let cipher0 = read_u32(stream, 0)?;
|
||||||
|
let cipher1 = read_u32(stream, 4)?;
|
||||||
|
let key = stream_id.wrapping_mul(0x9d32_3cd7);
|
||||||
|
let shift = stream_id & 7;
|
||||||
|
let base = (key >> shift)
|
||||||
|
.wrapping_add(0x5e72_7d74)
|
||||||
|
.wrapping_add(key.wrapping_shl(stream_id & 0xb))
|
||||||
|
.wrapping_add(0xf71e_3005);
|
||||||
|
let plain0 =
|
||||||
|
gf32_mul_fixed(cipher0.wrapping_add(0xcbf0_c1d8)) ^ 0xeb_e81dba_u32.wrapping_add(base);
|
||||||
|
let plain1 = gf32_mul_fixed(cipher1.wrapping_add(cipher0))
|
||||||
|
^ 0xeb_e81dba_u32.wrapping_mul(5).wrapping_add(base);
|
||||||
|
let header = StreamHeader {
|
||||||
|
encrypted_words: [cipher0, cipher1],
|
||||||
|
decrypted_words: [plain0, plain1],
|
||||||
|
record_state: plain1.wrapping_add(base),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut records = Vec::new();
|
||||||
|
let mut first_payload = stream.len();
|
||||||
|
for index in 0..256_usize {
|
||||||
|
let start =
|
||||||
|
8_usize
|
||||||
|
.checked_add(index.checked_mul(RECORD_SIZE).ok_or_else(|| {
|
||||||
|
Error::Invalid("record descriptor offset overflow".to_owned())
|
||||||
|
})?)
|
||||||
|
.ok_or_else(|| Error::Invalid("record descriptor offset overflow".to_owned()))?;
|
||||||
|
let end = start
|
||||||
|
.checked_add(RECORD_SIZE)
|
||||||
|
.ok_or_else(|| Error::Invalid("record descriptor end overflow".to_owned()))?;
|
||||||
|
if end > stream.len() {
|
||||||
|
return invalid(format!(
|
||||||
|
"stream 0x{stream_id:02X} descriptor table is truncated at record {index}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let record = decrypt_record(&stream[start..end], index, header.record_state)?;
|
||||||
|
if record.id_copy != 0 && record.command_id != record.id_copy {
|
||||||
|
return invalid(format!(
|
||||||
|
"stream 0x{stream_id:02X} record {index} command/id mismatch: 0x{:X} != 0x{:X}",
|
||||||
|
record.command_id, record.id_copy
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (offset, size) in [
|
||||||
|
(record.image_offset, record.image_size),
|
||||||
|
(record.metadata_offset, record.metadata_size),
|
||||||
|
] {
|
||||||
|
if offset != 0 && size != 0 {
|
||||||
|
let offset = usize::try_from(offset).map_err(|_| {
|
||||||
|
Error::Invalid(format!(
|
||||||
|
"stream 0x{stream_id:02X} record {index} payload offset exceeds usize"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if offset >= stream.len() {
|
||||||
|
return invalid(format!(
|
||||||
|
"stream 0x{stream_id:02X} record {index} payload offset 0x{offset:x} exceeds stream 0x{:x}",
|
||||||
|
stream.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
first_payload = first_payload.min(offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
records.push(record);
|
||||||
|
if end == first_payload {
|
||||||
|
return Ok((header, records, first_payload));
|
||||||
|
}
|
||||||
|
if end > first_payload {
|
||||||
|
return invalid(format!(
|
||||||
|
"stream 0x{stream_id:02X} descriptor table crosses first payload at 0x{first_payload:x}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invalid(format!(
|
||||||
|
"stream 0x{stream_id:02X} has no descriptor boundary in 256 records"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt_record(raw: &[u8], index: usize, state: u32) -> Result<Record> {
|
||||||
|
if raw.len() != RECORD_SIZE {
|
||||||
|
return invalid(format!(
|
||||||
|
"record {index} has size 0x{:x}, expected 0x{RECORD_SIZE:x}",
|
||||||
|
raw.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let product = state.wrapping_add(0x96f6_0b71).wrapping_mul(state);
|
||||||
|
let index_mask = product.wrapping_shl(((index + 1) & 3) as u32);
|
||||||
|
let mix = state.wrapping_mul(0x06a5_5bcc).wrapping_add(product);
|
||||||
|
let mut accumulator = 0x7993_4cf6_u32;
|
||||||
|
let mut feedback = 0xf02f_7685_u32;
|
||||||
|
let mut words = [0_u32; RECORD_SIZE / 4];
|
||||||
|
for (word_index, chunk) in raw.as_chunks::<4>().0.iter().enumerate() {
|
||||||
|
feedback = feedback.wrapping_mul(feedback);
|
||||||
|
let cipher = u32::from_le_bytes(*chunk);
|
||||||
|
let mut value = gf32_mul_fixed(cipher ^ (feedback >> 3)) ^ index_mask;
|
||||||
|
value = value.wrapping_add(accumulator).wrapping_add(state);
|
||||||
|
value = value.wrapping_sub(mix >> ((word_index * 4 + 3) & 5));
|
||||||
|
words[word_index] = value;
|
||||||
|
accumulator = accumulator.wrapping_add(0xe64d_33d8);
|
||||||
|
feedback = cipher;
|
||||||
|
}
|
||||||
|
Ok(Record {
|
||||||
|
index,
|
||||||
|
command_id: words[0],
|
||||||
|
flags: words[1],
|
||||||
|
image_offset: words[2],
|
||||||
|
image_size: words[3],
|
||||||
|
metadata_offset: words[4],
|
||||||
|
metadata_size: words[5],
|
||||||
|
id_copy: words[6],
|
||||||
|
entry_offset: words[7],
|
||||||
|
init_offset: words[8],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||||
|
let bytes = data.get(offset..offset + 4).ok_or_else(|| {
|
||||||
|
Error::Invalid(format!("record header range 0x{offset:x} is out of bounds"))
|
||||||
|
})?;
|
||||||
|
Ok(u32::from_le_bytes(bytes.try_into().map_err(|_| {
|
||||||
|
Error::Invalid("invalid record u32 range".to_owned())
|
||||||
|
})?))
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
//! Shared Android resource ceilings for extraction, restoration, and archives.
|
||||||
|
//!
|
||||||
|
//! These bounds reject an attacker-controlled size before the corresponding
|
||||||
|
//! allocation or file growth. They are per-allocation and per-task accounting
|
||||||
|
//! caps, not a measurement of peak resident memory: decoder scratch, Huffman
|
||||||
|
//! tables, and mapped inputs can exist at the same time.
|
||||||
|
|
||||||
|
/// Default ceiling for one ZIP entry, one decoder output, one writer source or
|
||||||
|
/// destination, one artifact read, and the restored output image.
|
||||||
|
pub const DEFAULT_MAX_ENTRY_BYTES: u64 = 2 << 30;
|
||||||
|
/// Default ceiling for the sum of retained or temporary bytes charged by one
|
||||||
|
/// extraction, restoration, or archive task.
|
||||||
|
pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 8 << 30;
|
||||||
|
/// Default ceiling for ZIP entries and decoded stage-2 records in one task.
|
||||||
|
pub const DEFAULT_MAX_ENTRIES: usize = 100_000;
|
||||||
|
/// Default nesting ceiling. Depth `0` is the outermost container or stream, and
|
||||||
|
/// a depth equal to this value is still allowed.
|
||||||
|
pub const DEFAULT_MAX_DEPTH: usize = 32;
|
||||||
|
|
||||||
|
/// Overridable ceilings shared by Android extraction, restoration, and I/O.
|
||||||
|
///
|
||||||
|
/// Archive code uses the same type: `max_entry_bytes` bounds one streamed ZIP
|
||||||
|
/// entry, `max_total_bytes` bounds the sum of those entries, `max_entries`
|
||||||
|
/// bounds the entry count including nested archives, and `max_depth` bounds
|
||||||
|
/// archive nesting.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ResourceLimits {
|
||||||
|
pub max_entry_bytes: u64,
|
||||||
|
pub max_total_bytes: u64,
|
||||||
|
pub max_entries: usize,
|
||||||
|
pub max_depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ResourceLimits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_entry_bytes: DEFAULT_MAX_ENTRY_BYTES,
|
||||||
|
max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
|
||||||
|
max_entries: DEFAULT_MAX_ENTRIES,
|
||||||
|
max_depth: DEFAULT_MAX_DEPTH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResourceLimits {
|
||||||
|
/// Numeric output ceiling passed into the crypto crate.
|
||||||
|
#[must_use]
|
||||||
|
pub fn output_limit(self) -> senbei_crypto::android::OutputLimit {
|
||||||
|
senbei_crypto::android::OutputLimit {
|
||||||
|
max_output_bytes: self.max_entry_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject one allocation, entry, or output before it is reserved.
|
||||||
|
pub fn check_entry_bytes(self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
|
||||||
|
if bytes > self.max_entry_bytes {
|
||||||
|
return Err(LimitExceeded::Entry {
|
||||||
|
requested: bytes,
|
||||||
|
limit: self.max_entry_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject a declared entry or record count before those records are stored.
|
||||||
|
pub fn check_entries(self, count: usize) -> std::result::Result<(), LimitExceeded> {
|
||||||
|
if count > self.max_entries {
|
||||||
|
return Err(LimitExceeded::Entries {
|
||||||
|
requested: count,
|
||||||
|
limit: self.max_entries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject a nested container or stream whose depth exceeds the ceiling.
|
||||||
|
///
|
||||||
|
/// `depth` is zero for the outermost item. `max_depth` itself is accepted.
|
||||||
|
pub fn check_depth(self, depth: usize) -> std::result::Result<(), LimitExceeded> {
|
||||||
|
if depth > self.max_depth {
|
||||||
|
return Err(LimitExceeded::Depth {
|
||||||
|
requested: depth,
|
||||||
|
limit: self.max_depth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A size or count that would exceed a [`ResourceLimits`] ceiling.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum LimitExceeded {
|
||||||
|
/// One entry or allocation is larger than `max_entry_bytes`.
|
||||||
|
#[error("resource limit: entry of {requested} bytes exceeds the {limit}-byte ceiling")]
|
||||||
|
Entry { requested: u64, limit: u64 },
|
||||||
|
/// Charging more bytes would pass `max_total_bytes`.
|
||||||
|
#[error(
|
||||||
|
"resource limit: task total of {requested} bytes exceeds the {limit}-byte ceiling (adding {adding} bytes)"
|
||||||
|
)]
|
||||||
|
Total {
|
||||||
|
requested: u64,
|
||||||
|
adding: u64,
|
||||||
|
limit: u64,
|
||||||
|
},
|
||||||
|
/// An entry or record count passed `max_entries`.
|
||||||
|
#[error("resource limit: {requested} entries exceed the ceiling of {limit}")]
|
||||||
|
Entries { requested: usize, limit: usize },
|
||||||
|
/// Nesting passed `max_depth`.
|
||||||
|
#[error("resource limit: nesting depth {requested} exceeds the ceiling of {limit}")]
|
||||||
|
Depth { requested: usize, limit: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running sum of retained or temporary bytes for one task.
|
||||||
|
///
|
||||||
|
/// Each call starts its own budget at zero. Charging checks the single-entry
|
||||||
|
/// ceiling and the task total, but it does not account for buffers that were
|
||||||
|
/// never charged, so it is not a peak-RSS budget.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ByteBudget {
|
||||||
|
limits: ResourceLimits,
|
||||||
|
used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ByteBudget {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(limits: &ResourceLimits) -> Self {
|
||||||
|
Self {
|
||||||
|
limits: *limits,
|
||||||
|
used: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn used(&self) -> u64 {
|
||||||
|
self.used
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject `bytes` against the single-entry ceiling without charging it.
|
||||||
|
pub fn check_entry(&self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
|
||||||
|
self.limits.check_entry_bytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge `bytes` against both the single-entry ceiling and the task total.
|
||||||
|
pub fn charge(&mut self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
|
||||||
|
self.check_entry(bytes)?;
|
||||||
|
self.charge_total(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge `bytes` against the task total only.
|
||||||
|
///
|
||||||
|
/// Use this for a sum of pieces that were each already checked against the
|
||||||
|
/// single-entry ceiling, such as several decoded records in one stream.
|
||||||
|
pub fn charge_total(&mut self, bytes: u64) -> std::result::Result<(), LimitExceeded> {
|
||||||
|
let requested = self
|
||||||
|
.used
|
||||||
|
.checked_add(bytes)
|
||||||
|
.filter(|total| *total <= self.limits.max_total_bytes);
|
||||||
|
match requested {
|
||||||
|
Some(total) => {
|
||||||
|
self.used = total;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(LimitExceeded::Total {
|
||||||
|
requested: self.used.saturating_add(bytes),
|
||||||
|
adding: bytes,
|
||||||
|
limit: self.limits.max_total_bytes,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running count of entries or decoded records for one task.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EntryBudget {
|
||||||
|
limits: ResourceLimits,
|
||||||
|
used: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntryBudget {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(limits: &ResourceLimits) -> Self {
|
||||||
|
Self {
|
||||||
|
limits: *limits,
|
||||||
|
used: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn used(&self) -> usize {
|
||||||
|
self.used
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge `count` additional entries against `max_entries`.
|
||||||
|
pub fn charge(&mut self, count: usize) -> std::result::Result<(), LimitExceeded> {
|
||||||
|
let requested = self
|
||||||
|
.used
|
||||||
|
.checked_add(count)
|
||||||
|
.filter(|total| *total <= self.limits.max_entries);
|
||||||
|
match requested {
|
||||||
|
Some(total) => {
|
||||||
|
self.used = total;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(LimitExceeded::Entries {
|
||||||
|
requested: self.used.saturating_add(count),
|
||||||
|
limit: self.limits.max_entries,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_match_the_documented_ceilings() {
|
||||||
|
let limits = ResourceLimits::default();
|
||||||
|
assert_eq!(limits.max_entry_bytes, 2 << 30);
|
||||||
|
assert_eq!(limits.max_total_bytes, 8 << 30);
|
||||||
|
assert_eq!(limits.max_entries, 100_000);
|
||||||
|
assert_eq!(limits.max_depth, 32);
|
||||||
|
assert!(limits.check_entry_bytes(limits.max_entry_bytes).is_ok());
|
||||||
|
assert!(limits.check_entries(limits.max_entries).is_ok());
|
||||||
|
assert!(limits.check_depth(limits.max_depth).is_ok());
|
||||||
|
assert!(
|
||||||
|
limits
|
||||||
|
.check_entry_bytes(limits.max_entry_bytes + 1)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(limits.check_depth(limits.max_depth + 1).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_budget_accepts_the_boundary_and_rejects_the_next_byte() {
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
max_entry_bytes: 4,
|
||||||
|
max_total_bytes: 8,
|
||||||
|
max_entries: 2,
|
||||||
|
max_depth: 1,
|
||||||
|
};
|
||||||
|
let mut budget = ByteBudget::new(&limits);
|
||||||
|
budget.charge(4).unwrap();
|
||||||
|
budget.charge_total(4).unwrap();
|
||||||
|
assert_eq!(budget.used(), 8);
|
||||||
|
assert!(matches!(
|
||||||
|
budget.charge(5),
|
||||||
|
Err(LimitExceeded::Entry { requested: 5, .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
budget.charge_total(1),
|
||||||
|
Err(LimitExceeded::Total { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(budget.used(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entry_budget_rejects_the_count_past_the_ceiling() {
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
max_entries: 2,
|
||||||
|
..ResourceLimits::default()
|
||||||
|
};
|
||||||
|
let mut budget = EntryBudget::new(&limits);
|
||||||
|
budget.charge(2).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
budget.charge(1),
|
||||||
|
Err(LimitExceeded::Entries {
|
||||||
|
requested: 3,
|
||||||
|
limit: 2
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! Android AArch64 extraction and ELF restoration.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
mod extract;
|
||||||
|
mod limits;
|
||||||
|
mod restore;
|
||||||
|
|
||||||
|
pub use extract::{
|
||||||
|
DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, Error as ExtractionError, ExtractOptions,
|
||||||
|
ExtractionReport, extract_stage2, extract_stage2_with_limits, is_protected_libil2cpp,
|
||||||
|
probe_libil2cpp_with_limits,
|
||||||
|
};
|
||||||
|
pub use limits::{
|
||||||
|
ByteBudget, DEFAULT_MAX_DEPTH, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
|
||||||
|
DEFAULT_MAX_TOTAL_BYTES, EntryBudget, LimitExceeded, ResourceLimits,
|
||||||
|
};
|
||||||
|
pub use restore::{
|
||||||
|
Error as RestoreError, RestoreOptions, RestoreReport, restore_libil2cpp,
|
||||||
|
restore_libil2cpp_with_limits,
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::error::{Error, Result, invalid};
|
||||||
|
|
||||||
|
const REQUIRED_IDS: [u32; 3] = [0x9b, 0x9d, 0x9e];
|
||||||
|
const OPTIONAL_IDS: [u32; 2] = [0x96, 0x98];
|
||||||
|
|
||||||
|
fn wanted_id(command_id: u32) -> bool {
|
||||||
|
REQUIRED_IDS.contains(&command_id) || OPTIONAL_IDS.contains(&command_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct Artifact {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub size: u64,
|
||||||
|
pub entry_offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact>> {
|
||||||
|
let text = std::fs::read_to_string(index_path)
|
||||||
|
.map_err(|error| Error::io("read module index", index_path, error))?;
|
||||||
|
let document: Value = serde_json::from_str(&text)?;
|
||||||
|
let root = index_path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
let mut result = BTreeMap::new();
|
||||||
|
|
||||||
|
if let Some(items) = document.get("module_registry").and_then(Value::as_array) {
|
||||||
|
for item in items {
|
||||||
|
let Some(command_id) = item.get("command_id").and_then(Value::as_u64) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let command_id = u32::try_from(command_id)
|
||||||
|
.map_err(|_| Error::Invalid("module command ID exceeds u32".to_owned()))?;
|
||||||
|
if !wanted_id(command_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(path) = item.get("image_path").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let size = item
|
||||||
|
.get("size")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.ok_or_else(|| Error::Invalid(format!("module 0x{command_id:02X} lacks size")))?;
|
||||||
|
result.insert(
|
||||||
|
command_id,
|
||||||
|
Artifact {
|
||||||
|
path: root.join(path),
|
||||||
|
size,
|
||||||
|
entry_offset: item
|
||||||
|
.get("entry_offset")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.and_then(|value| u32::try_from(value).ok()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(streams) = document.get("streams").and_then(Value::as_array) {
|
||||||
|
for stream in streams {
|
||||||
|
let Some(records) = stream.get("records").and_then(Value::as_array) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for record in records {
|
||||||
|
let Some(command_id) = record.get("command_id").and_then(Value::as_u64) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let command_id = u32::try_from(command_id)
|
||||||
|
.map_err(|_| Error::Invalid("record command ID exceeds u32".to_owned()))?;
|
||||||
|
if !wanted_id(command_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(image) = record.get("image") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(path) = image.get("path").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let size = image.get("size").and_then(Value::as_u64).ok_or_else(|| {
|
||||||
|
Error::Invalid(format!("record 0x{command_id:02X} lacks image size"))
|
||||||
|
})?;
|
||||||
|
result.insert(
|
||||||
|
command_id,
|
||||||
|
Artifact {
|
||||||
|
path: root.join(path),
|
||||||
|
size,
|
||||||
|
entry_offset: record
|
||||||
|
.get("entry_offset")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.and_then(|value| u32::try_from(value).ok()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let missing = REQUIRED_IDS
|
||||||
|
.iter()
|
||||||
|
.filter(|id| !result.contains_key(id))
|
||||||
|
.map(|id| format!("0x{id:02X}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return invalid(format!(
|
||||||
|
"module index lacks required IDs: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (&command_id, artifact) in &result {
|
||||||
|
let metadata = std::fs::metadata(&artifact.path)
|
||||||
|
.map_err(|error| Error::io("inspect artifact", &artifact.path, error))?;
|
||||||
|
if !metadata.is_file() || metadata.len() != artifact.size {
|
||||||
|
return invalid(format!(
|
||||||
|
"invalid artifact for module 0x{command_id:02X}: {}",
|
||||||
|
artifact.path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
//! Evaluate the callback selector reached from the handoff module entry.
|
||||||
|
//!
|
||||||
|
//! This is a bounded interpreter for the selector's integer/control-flow
|
||||||
|
//! instructions, not a native module loader. Only stack writes are permitted;
|
||||||
|
//! image calls are recorded, never executed. Unknown instructions or memory
|
||||||
|
//! accesses reject a candidate. Table offsets, ordering, flags and the group
|
||||||
|
//! fallback decision consequently come from the module's own code.
|
||||||
|
|
||||||
|
use std::collections::{BTreeSet, HashSet};
|
||||||
|
|
||||||
|
use senbei_elf::{ElfLayout, read_u32, slice_u64};
|
||||||
|
|
||||||
|
use super::error::{Result, invalid};
|
||||||
|
use super::pipeline::{aarch64_adrp_target, virtual_address_to_file_offset};
|
||||||
|
|
||||||
|
const RUNTIME: u64 = 1 << 32;
|
||||||
|
const IMAGE: u64 = 2 << 32;
|
||||||
|
const STACK: u64 = 3 << 32;
|
||||||
|
const STACK_SIZE: usize = 0x10000;
|
||||||
|
const RETURN: u64 = u64::MAX - 3;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "constructors_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
fn signed(value: u32, bits: u32) -> i64 {
|
||||||
|
i64::from((value << (32 - bits)) as i32 >> (32 - bits))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn branch_target(pc: u64, instruction: u32, bits: u32, shift: u32) -> Option<u64> {
|
||||||
|
pc.checked_add_signed(signed((instruction >> shift) & ((1 << bits) - 1), bits) * 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow direct control flow from the descriptor entry, without inspecting
|
||||||
|
/// unrelated code or searching the data image for plausible tables.
|
||||||
|
fn selector_functions(module: &[u8], entry: u32) -> Result<BTreeSet<u64>> {
|
||||||
|
let mut functions = vec![u64::from(entry)];
|
||||||
|
let mut visited_functions = HashSet::new();
|
||||||
|
let mut selectors = BTreeSet::new();
|
||||||
|
let mut budget = 100_000_usize;
|
||||||
|
while let Some(function) = functions.pop() {
|
||||||
|
if !visited_functions.insert(function) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut pending = vec![function];
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
while let Some(pc) = pending.pop() {
|
||||||
|
if !visited.insert(pc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if budget == 0 {
|
||||||
|
return invalid("constructor module control flow exceeds analysis budget");
|
||||||
|
}
|
||||||
|
budget -= 1;
|
||||||
|
let Some(instruction) = usize::try_from(pc)
|
||||||
|
.ok()
|
||||||
|
.filter(|offset| offset.is_multiple_of(4))
|
||||||
|
.and_then(|offset| read_u32(module, offset).ok())
|
||||||
|
else {
|
||||||
|
return invalid("constructor module branches outside its image");
|
||||||
|
};
|
||||||
|
if instruction & 0x7c00_0000 == 0x1400_0000 {
|
||||||
|
let Some(target) = branch_target(pc, instruction, 26, 0) else {
|
||||||
|
return invalid("constructor module branch address overflows");
|
||||||
|
};
|
||||||
|
if instruction & 0x8000_0000 != 0 {
|
||||||
|
functions.push(target);
|
||||||
|
pending.push(pc + 4);
|
||||||
|
} else {
|
||||||
|
pending.push(target);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let conditional_bits = if instruction & 0xff00_0010 == 0x5400_0000
|
||||||
|
|| instruction & 0x7e00_0000 == 0x3400_0000
|
||||||
|
{
|
||||||
|
Some(19)
|
||||||
|
} else if instruction & 0x7e00_0000 == 0x3600_0000 {
|
||||||
|
Some(14)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(bits) = conditional_bits {
|
||||||
|
let Some(target) = branch_target(pc, instruction, bits, 5) else {
|
||||||
|
return invalid("constructor module conditional branch overflows");
|
||||||
|
};
|
||||||
|
pending.push(target);
|
||||||
|
}
|
||||||
|
if instruction & 0xffff_fc1f == 0xd63f_0000 {
|
||||||
|
selectors.insert(function);
|
||||||
|
} else if instruction & 0xffff_fc1f == 0xd61f_0000
|
||||||
|
|| instruction & 0xffff_fc1f == 0xd65f_0000
|
||||||
|
|| instruction == 0
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pending.push(pc + 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(selectors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask(bits: u32) -> u64 {
|
||||||
|
u64::MAX >> (64 - bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logical_immediate(instruction: u32, width: u32) -> Option<u64> {
|
||||||
|
let n = (instruction >> 22) & 1;
|
||||||
|
let imms = (instruction >> 10) & 63;
|
||||||
|
let immr = (instruction >> 16) & 63;
|
||||||
|
let encoded = (n << 6) | (!imms & 63);
|
||||||
|
let len = 31_u32.checked_sub(encoded.leading_zeros())?;
|
||||||
|
if len < 1 || (width == 32 && n != 0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let size = 1 << len;
|
||||||
|
let ones = (imms & (size - 1)) + 1;
|
||||||
|
if ones == size || size > width {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rotate = immr & (size - 1);
|
||||||
|
let value = mask(ones);
|
||||||
|
let element = if rotate == 0 {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
((value >> rotate) | (value << (size - rotate))) & mask(size)
|
||||||
|
};
|
||||||
|
let mut result = 0;
|
||||||
|
for position in (0..width).step_by(size as usize) {
|
||||||
|
result |= element << position;
|
||||||
|
}
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Selector<'a> {
|
||||||
|
module: &'a [u8],
|
||||||
|
image: &'a [u8],
|
||||||
|
layout: &'a ElfLayout,
|
||||||
|
registers: [Option<u64>; 31],
|
||||||
|
sp: u64,
|
||||||
|
stack: Vec<Option<u8>>,
|
||||||
|
flags: Option<(bool, bool, bool, bool)>,
|
||||||
|
read_image_base: bool,
|
||||||
|
calls: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Selector<'_> {
|
||||||
|
fn reg(&self, index: u32, sp: bool) -> Option<u64> {
|
||||||
|
if index == 31 {
|
||||||
|
Some(if sp { self.sp } else { 0 })
|
||||||
|
} else {
|
||||||
|
self.registers[index as usize]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(&mut self, index: u32, value: u64, width: u32, sp: bool) {
|
||||||
|
let value = value & mask(width);
|
||||||
|
if index != 31 {
|
||||||
|
self.registers[index as usize] = Some(value);
|
||||||
|
} else if sp {
|
||||||
|
self.sp = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&mut self, address: u64, size: usize) -> Option<u64> {
|
||||||
|
if address == RUNTIME + 8 && size == 8 {
|
||||||
|
self.read_image_base = true;
|
||||||
|
return Some(IMAGE);
|
||||||
|
}
|
||||||
|
let bytes = if address >= STACK {
|
||||||
|
let start = usize::try_from(address - STACK).ok()?;
|
||||||
|
self.stack
|
||||||
|
.get(start..start.checked_add(size)?)?
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.collect::<Option<Vec<_>>>()?
|
||||||
|
} else if address >= IMAGE {
|
||||||
|
let offset = virtual_address_to_file_offset(self.layout, address - IMAGE, size as u64)?;
|
||||||
|
self.image.get(offset..offset.checked_add(size)?)?.to_vec()
|
||||||
|
} else {
|
||||||
|
slice_u64(self.module, address, size as u64).ok()?.to_vec()
|
||||||
|
};
|
||||||
|
Some(bytes.iter().enumerate().fold(0, |value, (index, byte)| {
|
||||||
|
value | (u64::from(*byte) << (index * 8))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&mut self, address: u64, size: usize, value: u64) -> Option<()> {
|
||||||
|
let offset = usize::try_from(address.checked_sub(STACK)?).ok()?;
|
||||||
|
for (index, byte) in self
|
||||||
|
.stack
|
||||||
|
.get_mut(offset..offset.checked_add(size)?)?
|
||||||
|
.iter_mut()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
*byte = Some((value >> (index * 8)) as u8);
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn condition(&self, condition: u32) -> Option<bool> {
|
||||||
|
let (negative, zero, carry, overflow) = self.flags?;
|
||||||
|
let matched = match condition >> 1 {
|
||||||
|
0 => zero,
|
||||||
|
1 => carry,
|
||||||
|
2 => negative,
|
||||||
|
3 => overflow,
|
||||||
|
4 => carry && !zero,
|
||||||
|
5 => negative == overflow,
|
||||||
|
6 => negative == overflow && !zero,
|
||||||
|
7 => return Some(true),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(matched ^ (condition & 1 != 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(mut self, start: u64, max_calls: usize) -> Option<Vec<u64>> {
|
||||||
|
let mut pc = start;
|
||||||
|
let budget = max_calls
|
||||||
|
.checked_add(1)?
|
||||||
|
.checked_mul(256)?
|
||||||
|
.checked_add(1024)?
|
||||||
|
.min(1_000_000);
|
||||||
|
for _ in 0..budget {
|
||||||
|
if !pc.is_multiple_of(4) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let instruction = read_u32(self.module, usize::try_from(pc).ok()?).ok()?;
|
||||||
|
let mut next = pc.checked_add(4)?;
|
||||||
|
let rd = instruction & 31;
|
||||||
|
let rn = (instruction >> 5) & 31;
|
||||||
|
let width = if instruction >> 31 == 0 { 32 } else { 64 };
|
||||||
|
if instruction & 0xffff_fc1f == 0xd65f_0000 {
|
||||||
|
return (self.reg(rn, false)? == RETURN
|
||||||
|
&& self.sp == STACK + STACK_SIZE as u64
|
||||||
|
&& self.read_image_base
|
||||||
|
&& !self.calls.is_empty()
|
||||||
|
&& self.reg(0, false)? == 1)
|
||||||
|
.then_some(self.calls);
|
||||||
|
} else if instruction & 0xffff_fc1f == 0xd63f_0000 {
|
||||||
|
let address = self.reg(rn, false)?.checked_sub(IMAGE)?;
|
||||||
|
if self.calls.len() >= max_calls || !executable_address(self.layout, address) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.calls.push(address);
|
||||||
|
self.registers[..19].fill(None);
|
||||||
|
self.registers[30] = Some(next);
|
||||||
|
self.flags = None;
|
||||||
|
} else if instruction & 0xfc00_0000 == 0x1400_0000 {
|
||||||
|
next = branch_target(pc, instruction, 26, 0)?;
|
||||||
|
} else if instruction & 0xff00_0010 == 0x5400_0000 {
|
||||||
|
if self.condition(instruction & 15)? {
|
||||||
|
next = branch_target(pc, instruction, 19, 5)?;
|
||||||
|
}
|
||||||
|
} else if instruction & 0x7e00_0000 == 0x3400_0000 {
|
||||||
|
let nonzero = self.reg(rd, false)? & mask(width) != 0;
|
||||||
|
if nonzero == (instruction & (1 << 24) != 0) {
|
||||||
|
next = branch_target(pc, instruction, 19, 5)?;
|
||||||
|
}
|
||||||
|
} else if instruction & 0x9f00_0000 == 0x9000_0000 {
|
||||||
|
self.set(rd, aarch64_adrp_target(instruction, pc)?, 64, false);
|
||||||
|
} else if instruction & 0x1f80_0000 == 0x1280_0000 {
|
||||||
|
let shift = ((instruction >> 21) & 3) * 16;
|
||||||
|
if shift >= width {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let immediate = u64::from((instruction >> 5) & 0xffff) << shift;
|
||||||
|
let value = match (instruction >> 29) & 3 {
|
||||||
|
0 => !immediate,
|
||||||
|
2 => immediate,
|
||||||
|
3 => (self.reg(rd, false)? & !(0xffff_u64 << shift)) | immediate,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
self.set(rd, value, width, false);
|
||||||
|
} else if instruction & 0x1f00_0000 == 0x1100_0000
|
||||||
|
|| instruction & 0x1f20_0000 == 0x0b00_0000
|
||||||
|
{
|
||||||
|
let immediate = instruction & 0x1f00_0000 == 0x1100_0000;
|
||||||
|
let left = self.reg(rn, immediate)? & mask(width);
|
||||||
|
let right = if immediate {
|
||||||
|
u64::from((instruction >> 10) & 0xfff)
|
||||||
|
<< (if instruction & (1 << 22) != 0 { 12 } else { 0 })
|
||||||
|
} else {
|
||||||
|
if (instruction >> 22) & 3 != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let shift = (instruction >> 10) & 63;
|
||||||
|
if shift >= width {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(self.reg((instruction >> 16) & 31, false)? << shift) & mask(width)
|
||||||
|
};
|
||||||
|
let subtract = instruction & (1 << 30) != 0;
|
||||||
|
let set_flags = instruction & (1 << 29) != 0;
|
||||||
|
let value = if subtract {
|
||||||
|
left.wrapping_sub(right)
|
||||||
|
} else {
|
||||||
|
left.wrapping_add(right)
|
||||||
|
} & mask(width);
|
||||||
|
if set_flags {
|
||||||
|
let sign = 1_u64 << (width - 1);
|
||||||
|
let carry = if subtract {
|
||||||
|
left >= right
|
||||||
|
} else {
|
||||||
|
u128::from(left) + u128::from(right) > u128::from(mask(width))
|
||||||
|
};
|
||||||
|
let overflow = if subtract {
|
||||||
|
(left ^ right) & (left ^ value) & sign != 0
|
||||||
|
} else {
|
||||||
|
!(left ^ right) & (left ^ value) & sign != 0
|
||||||
|
};
|
||||||
|
self.flags = Some((value & sign != 0, value == 0, carry, overflow));
|
||||||
|
}
|
||||||
|
self.set(rd, value, width, immediate && !set_flags);
|
||||||
|
} else if instruction & 0x1f80_0000 == 0x1200_0000
|
||||||
|
|| instruction & 0x1f20_0000 == 0x0a00_0000
|
||||||
|
{
|
||||||
|
let left = self.reg(rn, false)?;
|
||||||
|
let right = if instruction & 0x1f80_0000 == 0x1200_0000 {
|
||||||
|
logical_immediate(instruction, width)?
|
||||||
|
} else {
|
||||||
|
if (instruction >> 22) & 3 != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let shift = (instruction >> 10) & 63;
|
||||||
|
if shift >= width {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.reg((instruction >> 16) & 31, false)? << shift
|
||||||
|
};
|
||||||
|
let value = match (instruction >> 29) & 3 {
|
||||||
|
0 => left & right,
|
||||||
|
1 => left | right,
|
||||||
|
2 => left ^ right,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
self.set(rd, value, width, false);
|
||||||
|
} else if instruction & 0x7f80_0000 == 0x5300_0000 {
|
||||||
|
let rotate = (instruction >> 16) & 63;
|
||||||
|
let end = (instruction >> 10) & 63;
|
||||||
|
if rotate >= width || end >= width || (instruction >> 22) & 1 != width / 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let source = self.reg(rn, false)? & mask(width);
|
||||||
|
let value = if rotate <= end {
|
||||||
|
(source >> rotate) & mask(end - rotate + 1)
|
||||||
|
} else {
|
||||||
|
(source & mask(end + 1)) << (width - rotate)
|
||||||
|
};
|
||||||
|
self.set(rd, value, width, false);
|
||||||
|
} else if instruction & 0x3b00_0000 == 0x3900_0000 && instruction & (1 << 26) == 0 {
|
||||||
|
let size = 1_usize << (instruction >> 30);
|
||||||
|
let address = self
|
||||||
|
.reg(rn, true)?
|
||||||
|
.checked_add(u64::from((instruction >> 10) & 0xfff) * size as u64)?;
|
||||||
|
match (instruction >> 22) & 3 {
|
||||||
|
0 => self.write(address, size, self.reg(rd, false)?)?,
|
||||||
|
1 => {
|
||||||
|
let value = self.read(address, size)?;
|
||||||
|
self.set(rd, value, if size == 8 { 64 } else { 32 }, false);
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
pc = next;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn executable_address(layout: &ElfLayout, address: u64) -> bool {
|
||||||
|
address.is_multiple_of(4)
|
||||||
|
&& layout.program_headers.iter().any(|segment| {
|
||||||
|
segment.flags & 1 != 0
|
||||||
|
&& address >= segment.virtual_address
|
||||||
|
&& address
|
||||||
|
.checked_add(4)
|
||||||
|
.zip(segment.virtual_address.checked_add(segment.file_size))
|
||||||
|
.is_some_and(|(end, limit)| end <= limit)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn callbacks(
|
||||||
|
module: &[u8],
|
||||||
|
entry: u32,
|
||||||
|
image: &[u8],
|
||||||
|
layout: &ElfLayout,
|
||||||
|
max_calls: usize,
|
||||||
|
) -> Result<Vec<u64>> {
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
for function in selector_functions(module, entry)? {
|
||||||
|
let mut registers = [None; 31];
|
||||||
|
registers[0] = Some(RUNTIME);
|
||||||
|
registers[30] = Some(RETURN);
|
||||||
|
let selector = Selector {
|
||||||
|
module,
|
||||||
|
image,
|
||||||
|
layout,
|
||||||
|
registers,
|
||||||
|
sp: STACK + STACK_SIZE as u64,
|
||||||
|
stack: vec![None; STACK_SIZE],
|
||||||
|
flags: None,
|
||||||
|
read_image_base: false,
|
||||||
|
calls: Vec::new(),
|
||||||
|
};
|
||||||
|
if let Some(calls) = selector.run(function, max_calls) {
|
||||||
|
matches.push(calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match matches.len() {
|
||||||
|
1 => Ok(matches.pop().unwrap()),
|
||||||
|
count => invalid(format!(
|
||||||
|
"constructor module entry reaches {count} validated callback selectors"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
use super::*;
|
||||||
|
use senbei_elf::LoadSegment;
|
||||||
|
|
||||||
|
fn fixture() -> (Vec<u8>, Vec<u8>, ElfLayout) {
|
||||||
|
let mut module = vec![0; 0x2000];
|
||||||
|
// Synthetic selector: check every primary, then call either all primary
|
||||||
|
// or all fallback offsets. Bit 1 disables a record's instruction check;
|
||||||
|
// null primaries are skipped. The entry reaches it through a direct call.
|
||||||
|
let code: [u32; 41] = [
|
||||||
|
0xd10043ff, // sub sp, sp, #16
|
||||||
|
0xf90003fe, // str x30, [sp]
|
||||||
|
0xf9400413, // ldr x19, [x0, #8]
|
||||||
|
0xb0000014, // adrp x20, table page
|
||||||
|
0x91200294, // add x20, x20, #0x800
|
||||||
|
0xb9400295, // ldr w21, [x20] (count)
|
||||||
|
0x91002294, // add x20, x20, #8 (records)
|
||||||
|
0x52800016, // mov w22, #0 (fallback)
|
||||||
|
0x52800017, // mov w23, #0 (index)
|
||||||
|
0x6b1502ff, // cmp w23, w21
|
||||||
|
0x540001e2, // b.hs checked
|
||||||
|
0x8b171298, // add x24, x20, x23, lsl #4
|
||||||
|
0xb9400300, // ldr w0, [x24]
|
||||||
|
0x34000140, // cbz w0, check_next
|
||||||
|
0xb9400f01, // ldr w1, [x24, #12]
|
||||||
|
0x121f0021, // and w1, w1, #2
|
||||||
|
0x350000e1, // cbnz w1, check_next
|
||||||
|
0x8b000260, // add x0, x19, x0
|
||||||
|
0xb9400000, // ldr w0, [x0]
|
||||||
|
0xb9400b01, // ldr w1, [x24, #8]
|
||||||
|
0x6b01001f, // cmp w0, w1
|
||||||
|
0x54000040, // b.eq check_next
|
||||||
|
0x52800036, // mov w22, #1
|
||||||
|
0x110006f7, // add w23, w23, #1
|
||||||
|
0x17fffff1, // b check
|
||||||
|
0x52800017, // mov w23, #0
|
||||||
|
0x6b1502ff, // cmp w23, w21
|
||||||
|
0x54000142, // b.hs done
|
||||||
|
0x8b171298, // add x24, x20, x23, lsl #4
|
||||||
|
0xb9400300, // ldr w0, [x24]
|
||||||
|
0x340000a0, // cbz w0, invoke_next
|
||||||
|
0x34000056, // cbz w22, primary
|
||||||
|
0xb9400700, // ldr w0, [x24, #4]
|
||||||
|
0x8b000260, // add x0, x19, x0
|
||||||
|
0xd63f0000, // blr x0
|
||||||
|
0x110006f7, // add w23, w23, #1
|
||||||
|
0x17fffff6, // b invoke
|
||||||
|
0x52800020, // mov w0, #1
|
||||||
|
0xf94003fe, // ldr x30, [sp]
|
||||||
|
0x910043ff, // add sp, sp, #16
|
||||||
|
0xd65f03c0, // ret
|
||||||
|
];
|
||||||
|
put(&mut module, 0x40, 0x9400_0030);
|
||||||
|
put(&mut module, 0x44, 0xd65f_03c0);
|
||||||
|
for (index, instruction) in code.into_iter().enumerate() {
|
||||||
|
put(&mut module, 0x100 + index * 4, instruction);
|
||||||
|
}
|
||||||
|
put(&mut module, 0x1800, 2);
|
||||||
|
for (index, record) in [[0x4400, 0x4500, 0x1234, 0], [0x4200, 0x4520, 0x5678, 0]]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
for (field, word) in record.into_iter().enumerate() {
|
||||||
|
put(&mut module, 0x1808 + index * 16 + field * 4, word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut image = vec![0; 0x1000];
|
||||||
|
put(&mut image, 0x400, 0x1234);
|
||||||
|
put(&mut image, 0x200, 0x5678);
|
||||||
|
let layout = ElfLayout {
|
||||||
|
entrypoint: 0,
|
||||||
|
program_header_offset: 0,
|
||||||
|
program_header_size: 0x38,
|
||||||
|
program_header_count: 1,
|
||||||
|
program_headers: vec![LoadSegment {
|
||||||
|
offset: 0,
|
||||||
|
virtual_address: 0x4000,
|
||||||
|
file_size: 0x1000,
|
||||||
|
memory_size: 0x1000,
|
||||||
|
flags: 5,
|
||||||
|
alignment: 0x1000,
|
||||||
|
}],
|
||||||
|
section_headers: Vec::new(),
|
||||||
|
section_name_index: 0,
|
||||||
|
private_section_index: 0,
|
||||||
|
};
|
||||||
|
(module, image, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(data: &mut [u8], offset: usize, word: u32) {
|
||||||
|
data[offset..offset + 4].copy_from_slice(&word.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_order_comes_from_execution_even_after_module_relocation() {
|
||||||
|
let (module, image, layout) = fixture();
|
||||||
|
assert_eq!(
|
||||||
|
callbacks(&module, 0x40, &image, &layout, 2).unwrap(),
|
||||||
|
[0x4400, 0x4200]
|
||||||
|
);
|
||||||
|
let mut relocated = vec![0; 0x5000];
|
||||||
|
relocated.extend_from_slice(&module);
|
||||||
|
// Unreachable data and code cannot become another table candidate.
|
||||||
|
relocated[..module.len()].copy_from_slice(&module);
|
||||||
|
assert_eq!(
|
||||||
|
callbacks(&relocated, 0x5040, &image, &layout, 2).unwrap(),
|
||||||
|
[0x4400, 0x4200]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_instruction_mismatch_selects_the_entire_fallback_group() {
|
||||||
|
let (module, mut image, layout) = fixture();
|
||||||
|
put(&mut image, 0x200, 0);
|
||||||
|
assert_eq!(
|
||||||
|
callbacks(&module, 0x40, &image, &layout, 2).unwrap(),
|
||||||
|
[0x4500, 0x4520]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_flags_null_records_and_single_entries_follow_the_code() {
|
||||||
|
let (mut module, mut image, layout) = fixture();
|
||||||
|
put(&mut image, 0x200, 0);
|
||||||
|
put(&mut module, 0x1824, 2);
|
||||||
|
assert_eq!(
|
||||||
|
callbacks(&module, 0x40, &image, &layout, 2).unwrap(),
|
||||||
|
[0x4400, 0x4200]
|
||||||
|
);
|
||||||
|
put(&mut module, 0x1818, 0);
|
||||||
|
assert_eq!(
|
||||||
|
callbacks(&module, 0x40, &image, &layout, 2).unwrap(),
|
||||||
|
[0x4400]
|
||||||
|
);
|
||||||
|
put(&mut module, 0x1800, 1);
|
||||||
|
assert_eq!(
|
||||||
|
callbacks(&module, 0x40, &image, &layout, 1).unwrap(),
|
||||||
|
[0x4400]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_code_invalid_memory_and_excess_calls_are_rejected() {
|
||||||
|
let (module, image, layout) = fixture();
|
||||||
|
assert!(callbacks(&module, 0x40, &image, &layout, 1).is_err());
|
||||||
|
for (offset, instruction) in [
|
||||||
|
(0x100, 0xd400_0001), // system call
|
||||||
|
(0x108, 0xf900_0413), // attempted write outside the stack
|
||||||
|
(0x150, 0x1400_0000), // nonterminating comparison loop
|
||||||
|
(0x1808, 0x9000), // primary is outside the image
|
||||||
|
(0x181c, 0x9000), // fallback is outside the image
|
||||||
|
(0x1800, u32::MAX), // count exceeds the module/table bounds
|
||||||
|
] {
|
||||||
|
let mut broken = module.clone();
|
||||||
|
put(&mut broken, offset, instruction);
|
||||||
|
let mut mismatched = image.clone();
|
||||||
|
if offset == 0x181c {
|
||||||
|
put(&mut mismatched, 0x200, 0);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
callbacks(&broken, 0x40, &mismatched, &layout, 2).is_err(),
|
||||||
|
"offset {offset:x}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(callbacks(&module[..0x1820], 0x40, &image, &layout, 2).is_err());
|
||||||
|
assert!(callbacks(&module, 0xffff_ffff, &image, &layout, 2).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_reachable_selectors_are_not_silently_chosen() {
|
||||||
|
let (mut module, image, layout) = fixture();
|
||||||
|
module.copy_within(0x100..0x1a4, 0x300);
|
||||||
|
put(&mut module, 0x44, 0x9400_00af); // BL 0x300
|
||||||
|
put(&mut module, 0x48, 0xd65f_03c0);
|
||||||
|
assert!(
|
||||||
|
callbacks(&module, 0x40, &image, &layout, 2)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("2 validated")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// ELF restoration failure.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("{action} `{path}`: {source}")]
|
||||||
|
Io {
|
||||||
|
action: &'static str,
|
||||||
|
path: PathBuf,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
#[error("cannot parse module index: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Crypto(#[from] senbei_crypto::android::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Elf(#[from] senbei_elf::Error),
|
||||||
|
#[error("{0}")]
|
||||||
|
Invalid(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Limit(#[from] crate::android::LimitExceeded),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
pub(crate) fn io(action: &'static str, path: &Path, source: std::io::Error) -> Self {
|
||||||
|
Self::Io {
|
||||||
|
action,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
pub(crate) fn invalid<T>(message: impl Into<String>) -> Result<T> {
|
||||||
|
Err(Error::Invalid(message.into()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
mod artifact;
|
||||||
|
mod constructors;
|
||||||
|
mod error;
|
||||||
|
mod pipeline;
|
||||||
|
|
||||||
|
pub use error::Error;
|
||||||
|
pub use pipeline::{
|
||||||
|
RestoreOptions, RestoreReport, restore_libil2cpp, restore_libil2cpp_with_limits,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
|||||||
|
//! Platform-specific unpacking engines.
|
||||||
|
|
||||||
|
pub mod android;
|
||||||
|
mod panic_capture;
|
||||||
|
pub mod windows;
|
||||||
|
|
||||||
|
pub use panic_capture::catch_unwind_quiet;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if let Ok(value) = std::env::var("SENBEI_THREADS")
|
||||||
|
&& let Ok(count) = value.trim().parse::<usize>()
|
||||||
|
&& count >= 1
|
||||||
|
{
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
std::thread::available_parallelism()
|
||||||
|
.map(|count| count.get())
|
||||||
|
.unwrap_or(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
//! Thread-scoped panic capture shared by the engines and native orchestration.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct PanicCapture(Arc<Mutex<Option<PanicDetails>>>);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct PanicDetails {
|
||||||
|
pub message: String,
|
||||||
|
pub file: String,
|
||||||
|
pub line: u32,
|
||||||
|
pub column: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static ACTIVE_PANIC_CAPTURE: RefCell<Option<PanicCapture>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PanicCaptureGuard(Option<PanicCapture>);
|
||||||
|
|
||||||
|
impl Drop for PanicCaptureGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
ACTIVE_PANIC_CAPTURE.with(|slot| {
|
||||||
|
slot.replace(self.0.take());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PanicCapture {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self(Arc::new(Mutex::new(None)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&self, info: &std::panic::PanicHookInfo<'_>) {
|
||||||
|
let location = info.location();
|
||||||
|
let details = PanicDetails {
|
||||||
|
message: panic_message(info.payload()),
|
||||||
|
file: location
|
||||||
|
.map(|value| value.file().to_owned())
|
||||||
|
.unwrap_or_else(|| "<unknown>".to_owned()),
|
||||||
|
line: location.map_or(0, std::panic::Location::line),
|
||||||
|
column: location.map_or(0, std::panic::Location::column),
|
||||||
|
};
|
||||||
|
let mut captured = self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if captured.is_none() {
|
||||||
|
*captured = Some(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn details(&self, payload: &(dyn std::any::Any + Send)) -> PanicDetails {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| PanicDetails {
|
||||||
|
message: panic_message(payload),
|
||||||
|
file: "<unknown>".to_owned(),
|
||||||
|
line: 0,
|
||||||
|
column: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn merge_from(&self, other: &Self) {
|
||||||
|
let details = other
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.clone();
|
||||||
|
let Some(details) = details else { return };
|
||||||
|
let mut captured = self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if captured.is_none() {
|
||||||
|
*captured = Some(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||||
|
if let Some(message) = payload.downcast_ref::<&str>() {
|
||||||
|
(*message).to_owned()
|
||||||
|
} else if let Some(message) = payload.downcast_ref::<String>() {
|
||||||
|
message.clone()
|
||||||
|
} else {
|
||||||
|
"non-string panic payload".to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_hook() {
|
||||||
|
static INSTALL: std::sync::Once = std::sync::Once::new();
|
||||||
|
INSTALL.call_once(|| {
|
||||||
|
let previous = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let capture = ACTIVE_PANIC_CAPTURE
|
||||||
|
.try_with(|slot| slot.try_borrow().ok().and_then(|slot| slot.clone()))
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
if let Some(capture) = capture {
|
||||||
|
capture.record(info);
|
||||||
|
// WebAssembly unwinding is unavailable: retain the browser's
|
||||||
|
// diagnostic hook before the unrecoverable trap.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
previous(info);
|
||||||
|
} else {
|
||||||
|
previous(info);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn current_panic_capture() -> Option<PanicCapture> {
|
||||||
|
ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_panic_capture<R>(capture: Option<PanicCapture>, f: impl FnOnce() -> R) -> R {
|
||||||
|
let previous = ACTIVE_PANIC_CAPTURE.with(|slot| slot.replace(capture));
|
||||||
|
let _guard = PanicCaptureGuard(previous);
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catch a native per-file panic without printing the process panic hook.
|
||||||
|
///
|
||||||
|
/// Suppression applies only to this thread and scope. The process hook is
|
||||||
|
/// installed once, chains to the host hook outside captures, and is never
|
||||||
|
/// replaced or restored by individual jobs. Nested engine captures and their
|
||||||
|
/// worker-thread diagnostics continue to work independently.
|
||||||
|
///
|
||||||
|
/// Allocation aborts and WebAssembly traps cannot be caught.
|
||||||
|
pub fn catch_unwind_quiet<R>(f: impl FnOnce() -> R) -> std::thread::Result<R> {
|
||||||
|
install_hook();
|
||||||
|
with_panic_capture(Some(PanicCapture::new()), || {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_captures_restore_the_outer_scope() {
|
||||||
|
install_hook();
|
||||||
|
let outer = PanicCapture::new();
|
||||||
|
with_panic_capture(Some(outer.clone()), || {
|
||||||
|
assert!(catch_unwind_quiet(|| panic!("inner panic")).is_err());
|
||||||
|
let payload = std::panic::catch_unwind(|| panic!("outer panic")).unwrap_err();
|
||||||
|
assert_eq!(outer.details(payload.as_ref()).message, "outer panic");
|
||||||
|
});
|
||||||
|
assert!(current_panic_capture().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captures_are_thread_local_and_unwind_safe() {
|
||||||
|
install_hook();
|
||||||
|
let workers: Vec<_> = (0..8)
|
||||||
|
.map(|index| {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let capture = PanicCapture::new();
|
||||||
|
let payload = with_panic_capture(Some(capture.clone()), || {
|
||||||
|
std::panic::catch_unwind(|| panic!("worker {index}"))
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(current_panic_capture().is_none());
|
||||||
|
let details = capture.details(payload.as_ref());
|
||||||
|
assert_eq!(details.message, format!("worker {index}"));
|
||||||
|
assert!(details.file.ends_with("panic_capture.rs"));
|
||||||
|
assert!(details.line > 0);
|
||||||
|
assert!(details.column > 0);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for worker in workers {
|
||||||
|
worker.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -91,6 +91,16 @@ impl std::fmt::Display for DescriptorTable {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub enum UnpackError {
|
pub enum UnpackError {
|
||||||
|
#[error(
|
||||||
|
"Companion missing? This loader expects a matching <input>._ payload. Keep it beside the input file (or add both files in the browser) and try again."
|
||||||
|
)]
|
||||||
|
CompanionMissing,
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"Companion doesn't match: the supplied ._ payload is too short or its header differs from the loader's. Use the companion from the same build; either file may be damaged."
|
||||||
|
)]
|
||||||
|
CompanionMismatch,
|
||||||
|
|
||||||
#[error("input too short (need at least {required} bytes, got {actual})")]
|
#[error("input too short (need at least {required} bytes, got {actual})")]
|
||||||
InputTooShort { actual: usize, required: usize },
|
InputTooShort { actual: usize, required: usize },
|
||||||
|
|
||||||
@@ -149,6 +159,13 @@ pub enum UnpackError {
|
|||||||
buffer_len: usize,
|
buffer_len: usize,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("managed stub {region} restoration failed: {source}")]
|
||||||
|
ManagedStubRestoreFailed {
|
||||||
|
region: &'static str,
|
||||||
|
#[source]
|
||||||
|
source: senbei_pe::Error,
|
||||||
|
},
|
||||||
|
|
||||||
#[error(
|
#[error(
|
||||||
"EXE checksum descriptor at 0x{descriptor:08X} points outside input (offset {offset}, size {size}, input length {image_len})"
|
"EXE checksum descriptor at 0x{descriptor:08X} points outside input (offset {offset}, size {size}, input length {image_len})"
|
||||||
)]
|
)]
|
||||||
@@ -207,6 +224,9 @@ pub enum UnpackError {
|
|||||||
exe: Box<UnpackError>,
|
exe: Box<UnpackError>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("structurally invalid layout candidate: {}", issues.join("; "))]
|
||||||
|
InvalidLayoutCandidate { issues: Vec<String> },
|
||||||
|
|
||||||
#[error(
|
#[error(
|
||||||
"PE32 second-stage range is invalid (offset {offset}, size {size}, image length {image_len})"
|
"PE32 second-stage range is invalid (offset {offset}, size {size}, image length {image_len})"
|
||||||
)]
|
)]
|
||||||
File renamed without changes.
@@ -15,23 +15,12 @@ pub fn unpack(input: &[u8]) -> Result<Vec<u8>, UnpackError> {
|
|||||||
/// Used by the new-layout managed (CLR) metadata restore to locate the COR20
|
/// Used by the new-layout managed (CLR) metadata restore to locate the COR20
|
||||||
/// header and BSJB MetaData stream in the original protected file.
|
/// header and BSJB MetaData stream in the original protected file.
|
||||||
fn prot_rva_to_off(file_data: &[u8], pe_header: u32, rva: u32) -> Option<u32> {
|
fn prot_rva_to_off(file_data: &[u8], pe_header: u32, rva: u32) -> Option<u32> {
|
||||||
let nsec = get_u16(file_data, pe_header + 6) as u32;
|
let headers = senbei_pe::parse(file_data).ok()?;
|
||||||
let opt = get_u16(file_data, pe_header + 20) as u32;
|
if headers.pe_offset != pe_header as usize {
|
||||||
let tab = pe_header + 24 + opt;
|
|
||||||
for i in 0..nsec {
|
|
||||||
let s = tab + i * 40;
|
|
||||||
if (s as usize + 24) > file_data.len() {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let va = get_u32(file_data, s + 12);
|
let offset = senbei_pe::rva_to_offset(file_data, headers, rva).ok()?;
|
||||||
let vs = get_u32(file_data, s + 8);
|
u32::try_from(offset).ok()
|
||||||
let rsz = get_u32(file_data, s + 16);
|
|
||||||
let rp = get_u32(file_data, s + 20);
|
|
||||||
if va <= rva && rva < va + vs.max(rsz) {
|
|
||||||
return Some(rp + (rva - va));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unpack_v(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError> {
|
pub fn unpack_v(input: &[u8], verbose: bool) -> Result<Vec<u8>, UnpackError> {
|
||||||
@@ -115,6 +104,45 @@ impl<'a> Unpacker<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_payload(&mut self) -> Option<u32> {
|
||||||
|
self.decrypt_size = self.info[6].wrapping_sub(self.info[3]).wrapping_add(8192);
|
||||||
|
self.decrypt_data2();
|
||||||
|
let src_start = self.info[4]
|
||||||
|
.wrapping_add(4096)
|
||||||
|
.wrapping_add(self.decrypt_size) as usize;
|
||||||
|
let dst_start = self.info[3].wrapping_add(self.decrypt_size) as usize;
|
||||||
|
let len = self.info[5].wrapping_sub(self.decrypt_size) as usize;
|
||||||
|
self.decompressed[dst_start..dst_start + len]
|
||||||
|
.copy_from_slice(&self.file_data[src_start..src_start + len]);
|
||||||
|
|
||||||
|
write_u32(&mut self.decompressed, self.info[3], 4096);
|
||||||
|
self.decompressed[..4096].copy_from_slice(&self.file_data[..4096]);
|
||||||
|
let pe = get_u32(self.file_data, 60);
|
||||||
|
let is_pe32 = get_u16(self.file_data, pe.wrapping_add(24)) == 0x10B;
|
||||||
|
let mut table = if is_pe32 {
|
||||||
|
self.find_pe32_table()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
// Older PE32 loaders encrypt the entire payload; the length also seeds
|
||||||
|
// the cipher, so the prefix-size key cannot decode even their shell.
|
||||||
|
if is_pe32 && self.info[5] > self.decrypt_size && table.is_none() {
|
||||||
|
let prefix_size = self.decrypt_size;
|
||||||
|
let start = self.info[3] as usize;
|
||||||
|
let end = start + self.info[5] as usize;
|
||||||
|
let backup = self.decompressed[start..end].to_vec();
|
||||||
|
self.decrypt_size = self.info[5];
|
||||||
|
self.decrypt_data2();
|
||||||
|
write_u32(&mut self.decompressed, self.info[3], 4096);
|
||||||
|
table = self.find_pe32_table();
|
||||||
|
if table.is_none() {
|
||||||
|
self.decompressed[start..end].copy_from_slice(&backup);
|
||||||
|
self.decrypt_size = prefix_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
// Strategy (a): delegate to primitives::decrypt_data3
|
// Strategy (a): delegate to primitives::decrypt_data3
|
||||||
fn decrypt_data3(&mut self, pos: u32, key: u32, shift: u32) {
|
fn decrypt_data3(&mut self, pos: u32, key: u32, shift: u32) {
|
||||||
primitives::decrypt_data3(&mut self.decompressed, pos, key, shift);
|
primitives::decrypt_data3(&mut self.decompressed, pos, key, shift);
|
||||||
@@ -362,28 +390,18 @@ impl<'a> Unpacker<'a> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
u.decompressed = vec![0u8; size_of_image as usize];
|
u.decompressed = vec![0u8; size_of_image as usize];
|
||||||
u.decrypt_size = u.info[6].wrapping_sub(u.info[3]).wrapping_add(8192);
|
|
||||||
if verbose {
|
if verbose {
|
||||||
println!("[2/9] Decrypting payload...");
|
println!("[2/9] Decrypting payload...");
|
||||||
}
|
}
|
||||||
u.decrypt_data2();
|
let pe32_table = u.load_payload();
|
||||||
|
|
||||||
{
|
|
||||||
let src_start = u.info[4].wrapping_add(4096).wrapping_add(u.decrypt_size) as usize;
|
|
||||||
let dst_start = u.info[3].wrapping_add(u.decrypt_size) as usize;
|
|
||||||
let len = u.info[5].wrapping_sub(u.decrypt_size) as usize;
|
|
||||||
u.decompressed[dst_start..dst_start + len]
|
|
||||||
.copy_from_slice(&u.file_data[src_start..src_start + len]);
|
|
||||||
}
|
|
||||||
write_u32(&mut u.decompressed, u.info[3], 4096);
|
|
||||||
u.decompressed[..4096].copy_from_slice(&u.file_data[..4096]);
|
|
||||||
|
|
||||||
// PE32 (32-bit) images use an entirely different config layout and
|
// PE32 (32-bit) images use an entirely different config layout and
|
||||||
// final-output transform than PE32+ (64-bit). Dispatch here, after the
|
// final-output transform than PE32+ (64-bit). Dispatch here, after the
|
||||||
// shared header/payload setup (the PE32 branch follows the common
|
// shared header/payload setup (the PE32 branch follows the common
|
||||||
// Stage 1/2 work). pe_magic at pe_off+24: 0x10B = PE32, 0x20B = PE32+.
|
// Stage 1/2 work). pe_magic at pe_off+24: 0x10B = PE32, 0x20B = PE32+.
|
||||||
if get_u16(u.file_data, pe_off.wrapping_add(24)) == 0x10B {
|
if get_u16(u.file_data, pe_off.wrapping_add(24)) == 0x10B {
|
||||||
return u.run_pe32(pe_off, verbose);
|
let table = pe32_table.ok_or(UnpackError::Pe32TblNotFound)?;
|
||||||
|
return u.run_pe32(pe_off, table, verbose);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The config block layout in the decrypted region varies between Crackproof
|
// The config block layout in the decrypted region varies between Crackproof
|
||||||
@@ -2003,6 +2021,146 @@ impl<'a> Unpacker<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod payload_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn check_payload(full: bool, valid_table: bool) {
|
||||||
|
let info = [
|
||||||
|
0x1234_5678,
|
||||||
|
0x4E4E_4F4B,
|
||||||
|
0,
|
||||||
|
0x2000,
|
||||||
|
0x200,
|
||||||
|
0x7000,
|
||||||
|
0x2400,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
let mut plain = vec![0u8; 0x10000];
|
||||||
|
let table = 0x2800;
|
||||||
|
let (shell_field, header_field) = if full { (0x78, 0x50) } else { (0x88, 0x58) };
|
||||||
|
write_u32(&mut plain, table + shell_field, info[6]);
|
||||||
|
write_u32(&mut plain, table + shell_field + 4, 0x1800);
|
||||||
|
write_u32(&mut plain, table + header_field, 0x80);
|
||||||
|
write_u32(
|
||||||
|
&mut plain,
|
||||||
|
table + header_field + 4,
|
||||||
|
if valid_table { 0x58 } else { 0 },
|
||||||
|
);
|
||||||
|
let ss_size = if full { 0xAEC } else { 0xBC0 };
|
||||||
|
write_u32(&mut plain, table + shell_field + 0x10, 0x6000);
|
||||||
|
write_u32(&mut plain, table + shell_field + 0x14, ss_size);
|
||||||
|
write_u32(&mut plain, table + shell_field + 0x20, info[6]);
|
||||||
|
write_u32(&mut plain, table + shell_field + 0x24, 0x1800);
|
||||||
|
write_u32(&mut plain, table + 0x40, 0x9ABC_DEF0);
|
||||||
|
let third_pair = 0x6000 + if full { 0xAB4 } else { 0xB8C };
|
||||||
|
write_u32(&mut plain, third_pair, 0x8000);
|
||||||
|
write_u32(&mut plain, third_pair + 4, 0x20);
|
||||||
|
if full {
|
||||||
|
// This earlier table has plausible descriptors but a wrong stage key.
|
||||||
|
for (field, value) in [
|
||||||
|
(0x40, 0x1122_3344),
|
||||||
|
(0x50, 0x80),
|
||||||
|
(0x54, 0x58),
|
||||||
|
(0x78, info[6]),
|
||||||
|
(0x7C, 0x1800),
|
||||||
|
(0x88, 0x7000),
|
||||||
|
(0x8C, 0xAEC),
|
||||||
|
(0x98, info[6]),
|
||||||
|
(0x9C, 0x1800),
|
||||||
|
] {
|
||||||
|
write_u32(&mut plain, 0x2500 + field, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_u32(&mut plain, 0x8100, 0xAABB_CCDD);
|
||||||
|
let encrypted_size = if full {
|
||||||
|
info[5]
|
||||||
|
} else {
|
||||||
|
info[6] - info[3] + 8192
|
||||||
|
};
|
||||||
|
let src = 4096 + info[4];
|
||||||
|
let mut input = vec![0u8; (src + info[5]) as usize];
|
||||||
|
write_u32(&mut input, 0x3C, 0x80);
|
||||||
|
write_u16(&mut input, 0x98, 0x10B);
|
||||||
|
plain[..4096].copy_from_slice(&input[..4096]);
|
||||||
|
let mut key = primitives::calculate_checksum(&plain, table + header_field)
|
||||||
|
^ primitives::calculate_checksum(&plain, table + shell_field + 0x20)
|
||||||
|
^ get_u32(&plain, table + 0x40);
|
||||||
|
for i in 0..ss_size / 4 {
|
||||||
|
let word = get_u32(&plain, 0x6000 + i * 4);
|
||||||
|
write_u32(
|
||||||
|
&mut plain,
|
||||||
|
0x6000 + i * 4,
|
||||||
|
word.wrapping_add(i).rotate_left(21) ^ key,
|
||||||
|
);
|
||||||
|
key = key.wrapping_add(i);
|
||||||
|
}
|
||||||
|
let mut key = info[0].wrapping_add(!encrypted_size);
|
||||||
|
for i in 0..info[5] / 4 {
|
||||||
|
let word = get_u32(&plain, info[3] + i * 4);
|
||||||
|
let cell = if i * 4 < encrypted_size {
|
||||||
|
word ^ key
|
||||||
|
} else {
|
||||||
|
word
|
||||||
|
};
|
||||||
|
write_u32(&mut input, src + i * 4, cell);
|
||||||
|
key = i.wrapping_mul(i) ^ key.wrapping_add(cell).wrapping_add(i);
|
||||||
|
}
|
||||||
|
let mut unpacker = Unpacker {
|
||||||
|
file_data: &input,
|
||||||
|
decompressed: vec![0u8; plain.len()],
|
||||||
|
info,
|
||||||
|
key_offsets: [0; 4],
|
||||||
|
decrypt_size: 0,
|
||||||
|
};
|
||||||
|
let selected = unpacker.load_payload();
|
||||||
|
assert_eq!(selected, valid_table.then_some(table));
|
||||||
|
if !valid_table {
|
||||||
|
let prefix_size = info[6] - info[3] + 8192;
|
||||||
|
assert_eq!(unpacker.decrypt_size, prefix_size);
|
||||||
|
assert_eq!(
|
||||||
|
get_u32(&unpacker.decompressed, info[3] + prefix_size - 4),
|
||||||
|
{
|
||||||
|
let mut key = info[0].wrapping_add(!prefix_size);
|
||||||
|
let mut word = 0;
|
||||||
|
for i in 0..prefix_size / 4 {
|
||||||
|
let cell = get_u32(&input, src + i * 4);
|
||||||
|
word = key ^ cell;
|
||||||
|
key = i.wrapping_mul(i) ^ key.wrapping_add(cell).wrapping_add(i);
|
||||||
|
}
|
||||||
|
word
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&unpacker.decompressed
|
||||||
|
[(info[3] + prefix_size) as usize..(info[3] + info[5]) as usize],
|
||||||
|
&input[(src + prefix_size) as usize..(src + info[5]) as usize]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert_eq!(unpacker.decrypt_size, encrypted_size);
|
||||||
|
assert_eq!(
|
||||||
|
&unpacker.decompressed[info[3] as usize + 4..(info[3] + info[5]) as usize],
|
||||||
|
&plain[info[3] as usize + 4..(info[3] + info[5]) as usize]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pe32_full_payload_encryption_is_trial_validated() {
|
||||||
|
check_payload(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pe32_prefix_payload_encryption_preserves_plaintext_tail() {
|
||||||
|
check_payload(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pe32_invalid_full_payload_trial_restores_prefix() {
|
||||||
|
check_payload(true, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod error_tests {
|
mod error_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
+341
-117
@@ -1,11 +1,175 @@
|
|||||||
use super::super::super::layout;
|
use super::super::super::layout;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn stage_key_rounds(data: &[u8], table: u32, slots: usize) -> Result<u32, UnpackError> {
|
||||||
|
let offset = table as usize;
|
||||||
|
let size = slots.saturating_mul(16);
|
||||||
|
let descriptors = offset
|
||||||
|
.checked_add(size)
|
||||||
|
.and_then(|end| data.get(offset..end))
|
||||||
|
.ok_or(UnpackError::BufferRangeOutOfBounds {
|
||||||
|
operation: BufferOperation::Read,
|
||||||
|
offset,
|
||||||
|
size,
|
||||||
|
buffer_len: data.len(),
|
||||||
|
})?;
|
||||||
|
// The loader stops at the first empty helper, even if later slots are nonempty.
|
||||||
|
Ok(descriptors
|
||||||
|
.as_chunks::<16>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.take_while(|descriptor| get_u32(descriptor.as_slice(), 4) > 4)
|
||||||
|
.count() as u32)
|
||||||
|
}
|
||||||
|
|
||||||
impl<'a> Unpacker<'a> {
|
impl<'a> Unpacker<'a> {
|
||||||
|
pub(super) fn find_pe32_table(&mut self) -> Option<u32> {
|
||||||
|
let pe_off = get_u32(self.file_data, 60);
|
||||||
|
let header = self.decompressed[..4096].to_vec();
|
||||||
|
for tbl in layout::find_tbl_pe32_candidates(&self.decompressed, &self.info) {
|
||||||
|
let compact = get_u32(&self.decompressed, tbl + 0x78) == self.info[6];
|
||||||
|
let pair = tbl + if compact { 0x88 } else { 0x98 };
|
||||||
|
let start = get_u32(&self.decompressed, pair) as usize;
|
||||||
|
let size = get_u32(&self.decompressed, pair + 4) as usize;
|
||||||
|
let Some(end) = start
|
||||||
|
.checked_add(size)
|
||||||
|
.filter(|&end| end <= self.decompressed.len())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let ciphertext = self.decompressed[start..end].to_vec();
|
||||||
|
let valid = self.decrypt_pe32_second_stage(tbl, pe_off).is_ok();
|
||||||
|
self.decompressed[start..end].copy_from_slice(&ciphertext);
|
||||||
|
self.decompressed[..4096].copy_from_slice(&header);
|
||||||
|
if valid {
|
||||||
|
return Some(tbl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt_pe32_second_stage(&mut self, tbl: u32, pe_off: u32) -> Result<u32, UnpackError> {
|
||||||
|
let compact = get_u32(&self.decompressed, tbl.wrapping_add(0x78)) == self.info[6];
|
||||||
|
let header_cs_field = if compact { 0x50 } else { 0x58 };
|
||||||
|
let stage_field = if compact { 0x88 } else { 0x98 };
|
||||||
|
let resource_field = if compact { 0xB0 } else { 0xC8 };
|
||||||
|
if (pe_off as usize)
|
||||||
|
.checked_add(0xB8)
|
||||||
|
.is_none_or(|end| end > 0x1000)
|
||||||
|
{
|
||||||
|
return Err(UnpackError::Pe32RelocationDataNotFound);
|
||||||
|
}
|
||||||
|
let import_rva = get_u32(&self.decompressed, tbl + stage_field + 0x24);
|
||||||
|
let resource_rva = get_u32(&self.decompressed, tbl + resource_field);
|
||||||
|
let resource_size = get_u32(&self.decompressed, tbl + resource_field + 4);
|
||||||
|
write_u32(&mut self.decompressed, pe_off + 0x80, import_rva);
|
||||||
|
write_u32(&mut self.decompressed, pe_off + 0x88, resource_rva);
|
||||||
|
write_u32(&mut self.decompressed, pe_off + 0x8C, resource_size);
|
||||||
|
write_u32(&mut self.decompressed, pe_off + 0xB0, 0);
|
||||||
|
write_u32(&mut self.decompressed, pe_off + 0xB4, 0);
|
||||||
|
|
||||||
|
let cs = tbl + stage_field + 0x10;
|
||||||
|
let cs_start = get_u32(&self.decompressed, cs) as usize;
|
||||||
|
let cs_size = get_u32(&self.decompressed, cs + 4) as usize;
|
||||||
|
if cs_size == 0
|
||||||
|
|| cs_start
|
||||||
|
.checked_add(cs_size)
|
||||||
|
.is_none_or(|end| end > self.decompressed.len())
|
||||||
|
{
|
||||||
|
return Err(UnpackError::Pe32RelocationDataNotFound);
|
||||||
|
}
|
||||||
|
let first_stage_cs = self.calculate_checksum(cs);
|
||||||
|
let second_stage_key = get_u32(&self.decompressed, tbl + 0x40);
|
||||||
|
let ss_pair = tbl + stage_field;
|
||||||
|
let ss = get_u32(&self.decompressed, ss_pair);
|
||||||
|
let ss_size = get_u32(&self.decompressed, ss_pair + 4);
|
||||||
|
let ss_shift = ss_size.wrapping_sub(if compact { 0xAEC } else { 0xBC0 });
|
||||||
|
let third_pair_off = (if compact { 0xAB4u32 } else { 0xB8C }).wrapping_add(ss_shift);
|
||||||
|
let ss_lo = ss as usize;
|
||||||
|
let ss_hi = ss_lo.saturating_add(ss_size as usize);
|
||||||
|
if ss_hi > self.decompressed.len() || third_pair_off.saturating_add(8) > ss_size {
|
||||||
|
return Err(UnpackError::Pe32SecondStageRangeInvalid {
|
||||||
|
offset: ss,
|
||||||
|
size: ss_size,
|
||||||
|
image_len: self.decompressed.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let ss_ct = self.decompressed[ss_lo..ss_hi].to_vec();
|
||||||
|
// Header checksums refer to the original PE header. Compact EXEs and
|
||||||
|
// native DLLs can carry a packer-added BaseReloc entry: trial both keys.
|
||||||
|
for zero_reloc in [false, true] {
|
||||||
|
if zero_reloc {
|
||||||
|
write_u32(&mut self.decompressed, pe_off + 0xA0, 0);
|
||||||
|
write_u32(&mut self.decompressed, pe_off + 0xA4, 0);
|
||||||
|
}
|
||||||
|
let mut hcs_addr = tbl + header_cs_field;
|
||||||
|
let mut header_checksum = 0;
|
||||||
|
loop {
|
||||||
|
if hcs_addr + 8 > tbl + stage_field - 0x10 {
|
||||||
|
return Err(UnpackError::Pe32RelocationDataNotFound);
|
||||||
|
}
|
||||||
|
let size = get_u32(&self.decompressed, hcs_addr + 4) as usize;
|
||||||
|
if size == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let start = get_u32(&self.decompressed, hcs_addr) as usize;
|
||||||
|
if start.checked_add(size).is_none_or(|end| end > 0x1000) {
|
||||||
|
return Err(UnpackError::Pe32RelocationDataNotFound);
|
||||||
|
}
|
||||||
|
header_checksum ^= self.calculate_checksum(hcs_addr);
|
||||||
|
hcs_addr += 8;
|
||||||
|
}
|
||||||
|
self.decompressed[ss_lo..ss_hi].copy_from_slice(&ss_ct);
|
||||||
|
self.decrypt_data3(
|
||||||
|
ss_pair,
|
||||||
|
header_checksum ^ first_stage_cs ^ second_stage_key,
|
||||||
|
21,
|
||||||
|
);
|
||||||
|
let pair = ss + third_pair_off;
|
||||||
|
let off = get_u32(&self.decompressed, pair) as usize;
|
||||||
|
let size = get_u32(&self.decompressed, pair + 4) as usize;
|
||||||
|
if off > 0x1000
|
||||||
|
&& size >= 4
|
||||||
|
&& off
|
||||||
|
.checked_add(size)
|
||||||
|
.is_some_and(|end| end <= self.decompressed.len())
|
||||||
|
{
|
||||||
|
return Ok(header_checksum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(UnpackError::Pe32RelocationDataNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_pe32_metadata(&mut self, compact: bool) -> (u32, [u8; 128]) {
|
||||||
|
let base = self.info[3];
|
||||||
|
let layout_b = get_u32(&self.decompressed, base.wrapping_add(0x10)) > 0x10000;
|
||||||
|
let (offset, size, ep_offset, dirs_offset) = if layout_b {
|
||||||
|
// Compact metadata has an extra 16-byte prefix before the EP/dirs.
|
||||||
|
let shift = if compact { 0x10 } else { 0 };
|
||||||
|
(0x10, 0x290, 0x20 + shift, 0x30 + shift)
|
||||||
|
} else {
|
||||||
|
(0x40, 144, 0x40, 0x50)
|
||||||
|
};
|
||||||
|
let start = base.wrapping_add(offset) as usize;
|
||||||
|
let backup = self.decompressed[start..start + size].to_vec();
|
||||||
|
self.decrypt_data5(base.wrapping_add(offset), size as u32);
|
||||||
|
let ep = get_u32(&self.decompressed, base.wrapping_add(ep_offset));
|
||||||
|
let mut dirs = [0u8; 128];
|
||||||
|
let dirs_start = base.wrapping_add(dirs_offset) as usize;
|
||||||
|
dirs.copy_from_slice(&self.decompressed[dirs_start..dirs_start + 128]);
|
||||||
|
self.decompressed[start..start + size].copy_from_slice(&backup);
|
||||||
|
(ep, dirs)
|
||||||
|
}
|
||||||
|
|
||||||
/// PE32 (32-bit) unpack pipeline. The shared Stage 1/2 setup (info decrypt,
|
/// PE32 (32-bit) unpack pipeline. The shared Stage 1/2 setup (info decrypt,
|
||||||
/// payload decrypt, raw copy, header restore) has already run in `run()`
|
/// payload decrypt, raw copy, header restore) has already run in `run()`
|
||||||
/// before dispatch; this takes over from "Locating shell offsets".
|
/// before dispatch; this takes over from "Locating shell offsets".
|
||||||
pub(super) fn run_pe32(&mut self, pe_off: u32, verbose: bool) -> Result<Vec<u8>, UnpackError> {
|
pub(super) fn run_pe32(
|
||||||
|
&mut self,
|
||||||
|
pe_off: u32,
|
||||||
|
tbl: u32,
|
||||||
|
verbose: bool,
|
||||||
|
) -> Result<Vec<u8>, UnpackError> {
|
||||||
let info = self.info;
|
let info = self.info;
|
||||||
let info3 = info[3];
|
let info3 = info[3];
|
||||||
|
|
||||||
@@ -22,94 +186,21 @@ impl<'a> Unpacker<'a> {
|
|||||||
key
|
key
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Locate tbl in shell ----
|
// ---- Validated shell table ----
|
||||||
let tbl =
|
let compact = get_u32(&self.decompressed, tbl.wrapping_add(0x78)) == info[6];
|
||||||
layout::find_tbl_pe32(&self.decompressed, &info).ok_or(UnpackError::Pe32TblNotFound)?;
|
let stage_field = if compact { 0x88 } else { 0x98 };
|
||||||
if verbose {
|
if verbose {
|
||||||
println!("[3/9] Locating config layout (PE32)...");
|
println!("[3/9] Locating config layout (PE32)...");
|
||||||
println!(" tbl = 0x{:X}", tbl);
|
println!(" tbl = 0x{:X}, compact = {}", tbl, compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- PE header restore ----
|
|
||||||
let val_bc = get_u32(&self.decompressed, tbl.wrapping_add(0xBC));
|
|
||||||
let val_c8 = get_u32(&self.decompressed, tbl.wrapping_add(0xC8));
|
|
||||||
let val_cc = get_u32(&self.decompressed, tbl.wrapping_add(0xCC));
|
|
||||||
write_u32(&mut self.decompressed, pe_off.wrapping_add(0x80), val_bc);
|
|
||||||
write_u32(&mut self.decompressed, pe_off.wrapping_add(0x88), val_c8);
|
|
||||||
write_u32(&mut self.decompressed, pe_off.wrapping_add(0x8C), val_cc);
|
|
||||||
write_u32(&mut self.decompressed, pe_off.wrapping_add(0xB0), 0);
|
|
||||||
write_u32(&mut self.decompressed, pe_off.wrapping_add(0xB4), 0);
|
|
||||||
|
|
||||||
// ---- Header-independent checksum inputs ----
|
|
||||||
let first_stage_cs = self.calculate_checksum(tbl.wrapping_add(0xA8));
|
|
||||||
let second_stage_key = get_u32(&self.decompressed, tbl.wrapping_add(0x40));
|
|
||||||
|
|
||||||
// ---- Stage 3: SecondStage ----
|
// ---- Stage 3: SecondStage ----
|
||||||
//
|
let ss_pair = tbl.wrapping_add(stage_field);
|
||||||
// ss_key = headerChecksum ^ firstStageCS ^ secondStageKey, where the
|
|
||||||
// header checksum (a XOR of crc32(region)^size over the sub-regions at
|
|
||||||
// tbl+0x58) is taken over the *original* pre-pack PE header. For EXEs the
|
|
||||||
// import/resource restore above reconstructs that header exactly. Native
|
|
||||||
// DLLs additionally carry a packer-added BaseReloc data-directory entry
|
|
||||||
// (dir 5) that was absent from the checksummed original, so the header
|
|
||||||
// checksum only matches once that entry is treated as zero. EXEs have no
|
|
||||||
// dir-5 entry, so zeroing it is a no-op for them.
|
|
||||||
//
|
|
||||||
// Rather than branch on EXE-vs-DLL, try the header as-is and, on failure,
|
|
||||||
// with the BaseReloc entry zeroed; keep whichever ss_key decrypts a
|
|
||||||
// SecondStage whose ThirdStage (off,size) pair lands inside the image.
|
|
||||||
// This uses the same shift/key trial-and-validate the later stages
|
|
||||||
// already use, and keeps EXE output byte-identical (the as-is variant
|
|
||||||
// wins first).
|
|
||||||
let ss_pair = tbl.wrapping_add(0x98);
|
|
||||||
let ss = get_u32(&self.decompressed, ss_pair);
|
let ss = get_u32(&self.decompressed, ss_pair);
|
||||||
let ss_size = get_u32(&self.decompressed, ss_pair.wrapping_add(4));
|
let ss_size = get_u32(&self.decompressed, ss_pair.wrapping_add(4));
|
||||||
let ss_shift = ss_size.wrapping_sub(0xBC0);
|
let ss_shift = ss_size.wrapping_sub(if compact { 0xAEC } else { 0xBC0 });
|
||||||
// Back up the SecondStage ciphertext so a failed trial can be retried.
|
let third_pair_off = (if compact { 0xAB4u32 } else { 0xB8C }).wrapping_add(ss_shift);
|
||||||
let ss_lo = ss as usize;
|
let header_checksum = self.decrypt_pe32_second_stage(tbl, pe_off)?;
|
||||||
let ss_hi = ss_lo.wrapping_add(ss_size as usize);
|
|
||||||
if ss_hi < ss_lo || ss_hi > self.decompressed.len() {
|
|
||||||
return Err(UnpackError::Pe32SecondStageRangeInvalid {
|
|
||||||
offset: ss,
|
|
||||||
size: ss_size,
|
|
||||||
image_len: self.decompressed.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let ss_ct: Vec<u8> = self.decompressed[ss_lo..ss_hi].to_vec();
|
|
||||||
// PE32 data dir 5 (BaseReloc) = optional_header(pe+24) + 0x60 + 5*8 = pe+0xA0.
|
|
||||||
let reloc_dir = pe_off.wrapping_add(0xA0);
|
|
||||||
let len = self.decompressed.len() as u64;
|
|
||||||
let pair_off = 0xB8Cu32.wrapping_add(ss_shift);
|
|
||||||
let mut found = false;
|
|
||||||
// Holds the winning variant's header checksum; the later stages
|
|
||||||
// (Forth/Fifth/Seven/Eighth) reuse it as a key component.
|
|
||||||
let mut header_checksum: u32 = 0;
|
|
||||||
for zero_reloc in [false, true] {
|
|
||||||
if zero_reloc {
|
|
||||||
write_u32(&mut self.decompressed, reloc_dir, 0);
|
|
||||||
write_u32(&mut self.decompressed, reloc_dir.wrapping_add(4), 0);
|
|
||||||
}
|
|
||||||
let mut hcs_addr = tbl.wrapping_add(0x58);
|
|
||||||
header_checksum = 0;
|
|
||||||
while get_u32(&self.decompressed, hcs_addr.wrapping_add(4)) != 0 {
|
|
||||||
header_checksum ^= self.calculate_checksum(hcs_addr);
|
|
||||||
hcs_addr = hcs_addr.wrapping_add(8);
|
|
||||||
}
|
|
||||||
let ss_key = header_checksum ^ first_stage_cs ^ second_stage_key;
|
|
||||||
self.decompressed[ss_lo..ss_hi].copy_from_slice(&ss_ct);
|
|
||||||
self.decrypt_data3(ss_pair, ss_key, 21);
|
|
||||||
// Validate: the ThirdStage (off,size) pair must reference the image.
|
|
||||||
let pair = ss.wrapping_add(pair_off);
|
|
||||||
let off = get_u32(&self.decompressed, pair) as u64;
|
|
||||||
let sz = get_u32(&self.decompressed, pair.wrapping_add(4)) as u64;
|
|
||||||
if off > 0x1000 && off < len && sz >= 4 && off.saturating_add(sz) <= len {
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return Err(UnpackError::Pe32RelocationDataNotFound);
|
|
||||||
}
|
|
||||||
if verbose {
|
if verbose {
|
||||||
println!(
|
println!(
|
||||||
" ss = 0x{:08X}, size = 0x{:X}, shift = 0x{:X}",
|
" ss = 0x{:08X}, size = 0x{:X}, shift = 0x{:X}",
|
||||||
@@ -118,13 +209,12 @@ impl<'a> Unpacker<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- PE32 fixed offsets ----
|
// ---- PE32 fixed offsets ----
|
||||||
let third_key_off = 0x968u32.wrapping_add(ss_shift);
|
let third_key_off = (if compact { 0x950u32 } else { 0x968 }).wrapping_add(ss_shift);
|
||||||
let forth_key_off = 0x964u32.wrapping_add(ss_shift);
|
let forth_key_off = (if compact { 0x94Cu32 } else { 0x964 }).wrapping_add(ss_shift);
|
||||||
let cs_base_off = 0x96Cu32.wrapping_add(ss_shift);
|
let cs_base_off = (if compact { 0x954u32 } else { 0x96C }).wrapping_add(ss_shift);
|
||||||
let dp_base_off = 0xA9Cu32.wrapping_add(ss_shift);
|
let dp_base_off = (if compact { 0x9C4u32 } else { 0xA9C }).wrapping_add(ss_shift);
|
||||||
|
|
||||||
// ---- Stage 4: ThirdStage (brute-force the rotate shift) ----
|
// ---- Stage 4: ThirdStage (brute-force the rotate shift) ----
|
||||||
let third_pair_off = 0xB8Cu32.wrapping_add(ss_shift);
|
|
||||||
let key = get_u32(&self.decompressed, ss.wrapping_add(third_key_off));
|
let key = get_u32(&self.decompressed, ss.wrapping_add(third_key_off));
|
||||||
let pair_addr = ss.wrapping_add(third_pair_off);
|
let pair_addr = ss.wrapping_add(third_pair_off);
|
||||||
let ts_addr = get_u32(&self.decompressed, pair_addr);
|
let ts_addr = get_u32(&self.decompressed, pair_addr);
|
||||||
@@ -217,18 +307,22 @@ impl<'a> Unpacker<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Checksum addresses ----
|
// ---- Checksum addresses ----
|
||||||
let second_stage_cs_addr = tbl.wrapping_add(0xB0);
|
let second_stage_cs_addr = tbl.wrapping_add(stage_field + 0x18);
|
||||||
let forth_stage_cs_addr = ss.wrapping_add(cs_base_off);
|
let forth_stage_cs_addr = ss.wrapping_add(cs_base_off);
|
||||||
let fifth_stage_cs_addr = ss.wrapping_add(cs_base_off).wrapping_add(0x08);
|
let fifth_stage_cs_addr = ss.wrapping_add(cs_base_off).wrapping_add(0x08);
|
||||||
let seven_stage_cs_addr = ss.wrapping_add(cs_base_off).wrapping_add(0x10);
|
let seven_stage_cs_addr = ss.wrapping_add(cs_base_off).wrapping_add(0x10);
|
||||||
|
|
||||||
// ---- ForthStage ----
|
// ---- ForthStage ----
|
||||||
let second_stage_cs = self.calculate_checksum(second_stage_cs_addr);
|
let second_stage_cs = self.calculate_checksum(second_stage_cs_addr);
|
||||||
|
let dp_base = ss.wrapping_add(dp_base_off);
|
||||||
|
let forth_key_rounds = stage_key_rounds(&self.decompressed, dp_base, 4)?;
|
||||||
let forth_stage_key = advance_key(
|
let forth_stage_key = advance_key(
|
||||||
get_u32(&self.decompressed, ss.wrapping_add(forth_key_off)),
|
get_u32(&self.decompressed, ss.wrapping_add(forth_key_off)),
|
||||||
4,
|
forth_key_rounds,
|
||||||
);
|
);
|
||||||
let dp_base = ss.wrapping_add(dp_base_off);
|
if verbose {
|
||||||
|
println!(" fourth-stage key rounds = {forth_key_rounds}");
|
||||||
|
}
|
||||||
let forth_addr = dp_base.wrapping_add(0x40);
|
let forth_addr = dp_base.wrapping_add(0x40);
|
||||||
let fk = header_checksum ^ second_stage_cs ^ forth_stage_key;
|
let fk = header_checksum ^ second_stage_cs ^ forth_stage_key;
|
||||||
if let Err(reason) = self.decrypt_and_decompress_data(forth_addr, fk, None) {
|
if let Err(reason) = self.decrypt_and_decompress_data(forth_addr, fk, None) {
|
||||||
@@ -273,7 +367,9 @@ impl<'a> Unpacker<'a> {
|
|||||||
);
|
);
|
||||||
let seven_key = !get_u32(
|
let seven_key = !get_u32(
|
||||||
&self.decompressed,
|
&self.decompressed,
|
||||||
cs1_addr.wrapping_add(cs1_size).wrapping_sub(0x10),
|
cs1_addr
|
||||||
|
.wrapping_add(cs1_size)
|
||||||
|
.wrapping_sub(if compact { 0xC } else { 0x10 }),
|
||||||
);
|
);
|
||||||
let fk7 = header_checksum ^ fifth_cs ^ seven_key;
|
let fk7 = header_checksum ^ fifth_cs ^ seven_key;
|
||||||
if let Err(reason) = self.decrypt_and_decompress_data(seven_addr, fk7, None) {
|
if let Err(reason) = self.decrypt_and_decompress_data(seven_addr, fk7, None) {
|
||||||
@@ -313,6 +409,11 @@ impl<'a> Unpacker<'a> {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let seven_cs = self.calculate_checksum(seven_stage_cs_addr);
|
let seven_cs = self.calculate_checksum(seven_stage_cs_addr);
|
||||||
|
let eighth_key_rounds =
|
||||||
|
stage_key_rounds(&self.decompressed, dp_base.wrapping_add(0x80), 4)?;
|
||||||
|
if verbose {
|
||||||
|
println!(" eighth-stage key rounds = {eighth_key_rounds}");
|
||||||
|
}
|
||||||
let eighth_addr = dp_base.wrapping_add(0xC0);
|
let eighth_addr = dp_base.wrapping_add(0xC0);
|
||||||
let eighth_dsz = get_u32(&self.decompressed, eighth_addr.wrapping_add(12));
|
let eighth_dsz = get_u32(&self.decompressed, eighth_addr.wrapping_add(12));
|
||||||
let eighth_src = get_u32(&self.decompressed, eighth_addr);
|
let eighth_src = get_u32(&self.decompressed, eighth_addr);
|
||||||
@@ -381,7 +482,7 @@ impl<'a> Unpacker<'a> {
|
|||||||
self.decompressed[eighth_addr as usize..(eighth_addr + 16) as usize]
|
self.decompressed[eighth_addr as usize..(eighth_addr + 16) as usize]
|
||||||
.copy_from_slice(&eighth_pair_bak);
|
.copy_from_slice(&eighth_pair_bak);
|
||||||
let raw = get_u32(&self.decompressed, seven_start_actual.wrapping_add(ek_off));
|
let raw = get_u32(&self.decompressed, seven_start_actual.wrapping_add(ek_off));
|
||||||
let test_key = advance_key(raw, 3);
|
let test_key = advance_key(raw, eighth_key_rounds);
|
||||||
let fk8 = header_checksum ^ fifth_cs ^ seven_cs ^ test_key;
|
let fk8 = header_checksum ^ fifth_cs ^ seven_cs ^ test_key;
|
||||||
let result = primitives::decrypt_and_decompress_data(
|
let result = primitives::decrypt_and_decompress_data(
|
||||||
&mut self.decompressed,
|
&mut self.decompressed,
|
||||||
@@ -445,7 +546,7 @@ impl<'a> Unpacker<'a> {
|
|||||||
let mut best: Option<(u32 /*dist*/, u32 /*off*/)> = None;
|
let mut best: Option<(u32 /*dist*/, u32 /*off*/)> = None;
|
||||||
let mut o = 0u32;
|
let mut o = 0u32;
|
||||||
let dlen = self.decompressed.len() as u32;
|
let dlen = self.decompressed.len() as u32;
|
||||||
while o + 8 <= eighth_dsz.saturating_sub(0x4B4u32.saturating_sub(0x30)) {
|
while o + 8 <= eighth_dsz {
|
||||||
let fc = get_u32(&self.decompressed, eighth_start.wrapping_add(o));
|
let fc = get_u32(&self.decompressed, eighth_start.wrapping_add(o));
|
||||||
let sz = get_u32(&self.decompressed, eighth_start.wrapping_add(o + 4));
|
let sz = get_u32(&self.decompressed, eighth_start.wrapping_add(o + 4));
|
||||||
if fc > info3
|
if fc > info3
|
||||||
@@ -454,8 +555,9 @@ impl<'a> Unpacker<'a> {
|
|||||||
&& (0x10..=0x200).contains(&sz)
|
&& (0x10..=0x200).contains(&sz)
|
||||||
&& (sz & 0xF) == 0
|
&& (sz & 0xF) == 0
|
||||||
{
|
{
|
||||||
// Cluster base must leave room for the +0x4B4 LFSR slot
|
// Compact stages place the decryptor closer to this
|
||||||
// (even if the exact LFSR is later adjusted by scan).
|
// cluster. Only the config fields must fit here; the
|
||||||
|
// actual LFSR location is trial-validated below.
|
||||||
if o >= 0x30 {
|
if o >= 0x30 {
|
||||||
let base = o - 0x30;
|
let base = o - 0x30;
|
||||||
if base.wrapping_add(0x4C) <= eighth_dsz {
|
if base.wrapping_add(0x4C) <= eighth_dsz {
|
||||||
@@ -609,28 +711,7 @@ impl<'a> Unpacker<'a> {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
// ---- PE32 metadata: EP and data dirs from info[3] ----
|
// ---- PE32 metadata: EP and data dirs from info[3] ----
|
||||||
let test_val = get_u32(&self.decompressed, info3.wrapping_add(0x10));
|
let (metadata_ep, metadata_dirs) = self.read_pe32_metadata(compact);
|
||||||
let metadata_ep: u32;
|
|
||||||
let mut metadata_dirs = [0u8; 128];
|
|
||||||
if test_val > 0x10000 {
|
|
||||||
// Layout B
|
|
||||||
let s = info3.wrapping_add(0x10) as usize;
|
|
||||||
let backup_meta = self.decompressed[s..s + 0x290].to_vec();
|
|
||||||
self.decrypt_data5(info3.wrapping_add(0x10), 0x290);
|
|
||||||
metadata_ep = get_u32(&self.decompressed, info3.wrapping_add(0x20));
|
|
||||||
let d = info3.wrapping_add(0x30) as usize;
|
|
||||||
metadata_dirs.copy_from_slice(&self.decompressed[d..d + 128]);
|
|
||||||
self.decompressed[s..s + 0x290].copy_from_slice(&backup_meta);
|
|
||||||
} else {
|
|
||||||
// Layout A
|
|
||||||
let s = info3.wrapping_add(0x40) as usize;
|
|
||||||
let backup_meta = self.decompressed[s..s + 144].to_vec();
|
|
||||||
self.decrypt_data5(info3.wrapping_add(0x40), 144);
|
|
||||||
metadata_ep = get_u32(&self.decompressed, info3.wrapping_add(0x40));
|
|
||||||
let d = info3.wrapping_add(0x50) as usize;
|
|
||||||
metadata_dirs.copy_from_slice(&self.decompressed[d..d + 128]);
|
|
||||||
self.decompressed[s..s + 144].copy_from_slice(&backup_meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Zero-out list (runs BEFORE decompression) ----
|
// ---- Zero-out list (runs BEFORE decompression) ----
|
||||||
let zero_list_addr = eighth_start.wrapping_add(off_zero_list);
|
let zero_list_addr = eighth_start.wrapping_add(off_zero_list);
|
||||||
@@ -792,8 +873,16 @@ impl<'a> Unpacker<'a> {
|
|||||||
let d = export_va as usize;
|
let d = export_va as usize;
|
||||||
let s = export_file_off as usize;
|
let s = export_file_off as usize;
|
||||||
let n = export_size as usize;
|
let n = export_size as usize;
|
||||||
|
// Spliced companions may omit the stub's plaintext exports;
|
||||||
|
// the I/O adapter overlays those from the original stub later.
|
||||||
|
if s.checked_add(n)
|
||||||
|
.is_some_and(|end| end <= self.file_data.len())
|
||||||
|
&& d.checked_add(n)
|
||||||
|
.is_some_and(|end| end <= self.decompressed.len())
|
||||||
|
{
|
||||||
self.decompressed[d..d + n].copy_from_slice(&self.file_data[s..s + n]);
|
self.decompressed[d..d + n].copy_from_slice(&self.file_data[s..s + n]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- .text decrypt with decrypt_data8 (PE32 auto-detected formula) ----
|
// ---- .text decrypt with decrypt_data8 (PE32 auto-detected formula) ----
|
||||||
// `select_dd8_formula_pe32` returns None when `.text` was not packer-dd8-
|
// `select_dd8_formula_pe32` returns None when `.text` was not packer-dd8-
|
||||||
@@ -945,6 +1034,58 @@ impl<'a> Unpacker<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Re-arm TLS field base relocations (PE32 DLL) ----
|
||||||
|
// The packer neutralizes the four relocations covering the TLS
|
||||||
|
// directory's VA fields (Start/EndAddressOfRawData, AddressOfIndex,
|
||||||
|
// AddressOfCallBacks) by demoting them to IMAGE_REL_BASED_ABSOLUTE
|
||||||
|
// padding, because its own loader fixes TLS up by hand. Restored
|
||||||
|
// verbatim, a DLL mapped off its preferred base keeps stale VAs in its
|
||||||
|
// TLS directory, and the OS loader faults in LdrpAllocateTlsEntry when
|
||||||
|
// it writes the TLS slot index through the unrelocated AddressOfIndex.
|
||||||
|
// Promote those entries back to IMAGE_REL_BASED_HIGHLOW. EXE output
|
||||||
|
// is untouched: its BaseReloc directory is zeroed above, and its
|
||||||
|
// goldens must stay byte-identical.
|
||||||
|
if is_dll && tls_dir_rva > 0 {
|
||||||
|
let reloc_rva = get_u32(&self.decompressed, exe_pe.wrapping_add(0xA0));
|
||||||
|
let reloc_size = get_u32(&self.decompressed, exe_pe.wrapping_add(0xA4));
|
||||||
|
let reloc_end = reloc_rva.wrapping_add(reloc_size);
|
||||||
|
let dlen = self.decompressed.len() as u32;
|
||||||
|
if reloc_rva > 0 && reloc_size >= 8 && reloc_end <= dlen {
|
||||||
|
let mut rearmed = 0u32;
|
||||||
|
let mut block = reloc_rva;
|
||||||
|
while block.wrapping_add(8) <= reloc_end {
|
||||||
|
let page = get_u32(&self.decompressed, block);
|
||||||
|
let block_size = get_u32(&self.decompressed, block.wrapping_add(4));
|
||||||
|
if block_size < 8
|
||||||
|
|| !block_size.is_multiple_of(2)
|
||||||
|
|| block.wrapping_add(block_size) > reloc_end
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let entries = (block_size - 8) / 2;
|
||||||
|
for i in 0..entries {
|
||||||
|
let entry_off = block.wrapping_add(8).wrapping_add(i.wrapping_mul(2));
|
||||||
|
let entry = get_u16(&self.decompressed, entry_off);
|
||||||
|
let entry_rva = page.wrapping_add((entry & 0x0FFF) as u32);
|
||||||
|
// IMAGE_REL_BASED_ABSOLUTE with a real offset is
|
||||||
|
// neutralized, not padding (padding keeps offset 0).
|
||||||
|
if entry >> 12 == 0
|
||||||
|
&& entry & 0x0FFF != 0
|
||||||
|
&& entry_rva >= tls_dir_rva
|
||||||
|
&& entry_rva < tls_dir_rva.wrapping_add(16)
|
||||||
|
{
|
||||||
|
write_u16(&mut self.decompressed, entry_off, (entry | 0x3000) as u32);
|
||||||
|
rearmed = rearmed.wrapping_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
block = block.wrapping_add(block_size);
|
||||||
|
}
|
||||||
|
if verbose && rearmed > 0 {
|
||||||
|
println!(" re-armed {} TLS field relocations", rearmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Import table (PE32, 4-byte thunks) ----
|
// ---- Import table (PE32, 4-byte thunks) ----
|
||||||
if verbose {
|
if verbose {
|
||||||
println!("[8/9] Decrypting import strings (PE32)...");
|
println!("[8/9] Decrypting import strings (PE32)...");
|
||||||
@@ -1061,3 +1202,86 @@ impl<'a> Unpacker<'a> {
|
|||||||
Ok(compact)
|
Ok(compact)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn check_metadata(compact: bool, layout_b: bool) {
|
||||||
|
let base = 0x1000u32;
|
||||||
|
let (offset, size, ep_offset, dirs_offset) = if layout_b {
|
||||||
|
if compact {
|
||||||
|
(0x10, 0x290, 0x30, 0x40)
|
||||||
|
} else {
|
||||||
|
(0x10, 0x290, 0x20, 0x30)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(0x40, 144, 0x40, 0x50)
|
||||||
|
};
|
||||||
|
let mut data = vec![0u8; 0x2000];
|
||||||
|
let ep = 0x123456;
|
||||||
|
let dirs = std::array::from_fn::<_, 128, _>(|i| i as u8 ^ 0x5A);
|
||||||
|
write_u32(&mut data, base + ep_offset, ep);
|
||||||
|
data[(base + dirs_offset) as usize..(base + dirs_offset + 128) as usize]
|
||||||
|
.copy_from_slice(&dirs);
|
||||||
|
for i in 0..size {
|
||||||
|
let key = (base + offset + i) as u8;
|
||||||
|
let byte = &mut data[(base + offset + i) as usize];
|
||||||
|
let mixed = (byte.rotate_right(2) ^ key).rotate_right(2) ^ key.wrapping_add(1);
|
||||||
|
*byte = mixed.rotate_right(2);
|
||||||
|
}
|
||||||
|
assert_eq!(get_u32(&data, base + 0x10) > 0x10000, layout_b);
|
||||||
|
let backup = data.clone();
|
||||||
|
let mut unpacker = Unpacker {
|
||||||
|
file_data: &[],
|
||||||
|
decompressed: data,
|
||||||
|
info: [0, 0, 0, base, 0, 0, 0, 0],
|
||||||
|
key_offsets: [0; 4],
|
||||||
|
decrypt_size: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(unpacker.read_pe32_metadata(compact), (ep, dirs));
|
||||||
|
assert_eq!(
|
||||||
|
unpacker.decompressed, backup,
|
||||||
|
"metadata reads must be nondestructive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pe32_compact_metadata_restores_entry_point_and_directories() {
|
||||||
|
check_metadata(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pe32_standard_metadata_preserves_both_layouts() {
|
||||||
|
check_metadata(false, false);
|
||||||
|
check_metadata(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_key_rounds_follow_active_descriptor_prefix() {
|
||||||
|
for (sizes, expected) in [
|
||||||
|
([0, 0x45, 0x45, 0x45], 0),
|
||||||
|
([0x45, 4, 0x45, 0x45], 1),
|
||||||
|
([0x45, 0x45, 0x45, 3], 3),
|
||||||
|
([0x45, 0x45, 0x45, 0x45], 4),
|
||||||
|
] {
|
||||||
|
let mut data = [0u8; 80];
|
||||||
|
for (index, size) in sizes.into_iter().enumerate() {
|
||||||
|
write_u32(&mut data, 16 + index as u32 * 16 + 4, size);
|
||||||
|
}
|
||||||
|
assert_eq!(stage_key_rounds(&data, 16, 4).unwrap(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stage_key_rounds_reject_truncated_tables() {
|
||||||
|
assert!(matches!(
|
||||||
|
stage_key_rounds(&[0u8; 63], 0, 4),
|
||||||
|
Err(UnpackError::BufferRangeOutOfBounds { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
stage_key_rounds(&[0u8; 64], u32::MAX, 4),
|
||||||
|
Err(UnpackError::BufferRangeOutOfBounds { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,44 @@
|
|||||||
//!
|
//!
|
||||||
//! All reads are bounds-checked; the check never panics on any input.
|
//! All reads are bounds-checked; the check never panics on any input.
|
||||||
|
|
||||||
|
/// How serious one [`IntegrityReport`] defect is for choosing a DLL layout.
|
||||||
|
///
|
||||||
|
/// This does not change what [`check`] puts in [`IntegrityReport::issues`].
|
||||||
|
/// Callers that surface suspect output still read that list. Layout selection
|
||||||
|
/// uses [`IntegrityFinding::severity`] so a warning (the image may still be the
|
||||||
|
/// right layout) is not treated as "try the other pipeline".
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum IntegritySeverity {
|
||||||
|
/// The loader or runtime rejects the image before executing it, or a
|
||||||
|
/// directory the header claims to own does not resolve inside the file.
|
||||||
|
/// A DLL layout that reports one of these is the wrong layout.
|
||||||
|
Fatal,
|
||||||
|
/// The image is a PE, but a recoverable region looks unrestored. Real
|
||||||
|
/// outputs can carry these and still be the layout that must be kept
|
||||||
|
/// (a native DLL with no base relocations, an entry stub left as zeros
|
||||||
|
/// or `int3`).
|
||||||
|
Warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One defect from [`check`], with the severity layout selection needs.
|
||||||
|
///
|
||||||
|
/// `message` is the same string stored in [`IntegrityReport::issues`]. Severity
|
||||||
|
/// is decided while the check still knows which rule fired, not by reading the
|
||||||
|
/// message back.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct IntegrityFinding {
|
||||||
|
pub severity: IntegritySeverity,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a static integrity check over an unpacked image.
|
/// Result of a static integrity check over an unpacked image.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct IntegrityReport {
|
pub struct IntegrityReport {
|
||||||
/// Each entry describes one detected defect. Empty means no defect found.
|
/// Each entry describes one detected defect. Empty means no defect found.
|
||||||
|
/// Order and wording match [`IntegrityReport::findings`].
|
||||||
pub issues: Vec<String>,
|
pub issues: Vec<String>,
|
||||||
|
/// The same defects as `issues`, each tagged fatal or warning.
|
||||||
|
pub findings: Vec<IntegrityFinding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntegrityReport {
|
impl IntegrityReport {
|
||||||
@@ -25,6 +58,23 @@ impl IntegrityReport {
|
|||||||
pub fn ok(&self) -> bool {
|
pub fn ok(&self) -> bool {
|
||||||
self.issues.is_empty()
|
self.issues.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when every defect is a [`IntegritySeverity::Warning`].
|
||||||
|
///
|
||||||
|
/// An empty report is not fatal. Layout selection uses this to keep a DLL
|
||||||
|
/// image whose only defects are warnings, and to reject one the loader
|
||||||
|
/// cannot map.
|
||||||
|
pub fn has_fatal(&self) -> bool {
|
||||||
|
self.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| finding.severity == IntegritySeverity::Fatal)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, severity: IntegritySeverity, message: impl Into<String>) {
|
||||||
|
let message = message.into();
|
||||||
|
self.issues.push(message.clone());
|
||||||
|
self.findings.push(IntegrityFinding { severity, message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `checked_add`, not `+`: `usize` is 32-bit on wasm32, so a header-derived
|
// `checked_add`, not `+`: `usize` is 32-bit on wasm32, so a header-derived
|
||||||
@@ -42,47 +92,26 @@ fn rd_u32(d: &[u8], off: u32) -> Option<u32> {
|
|||||||
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A parsed section-table entry (only the fields we translate against).
|
/// PE format section data used by the integrity policy.
|
||||||
struct Section {
|
type Section = senbei_pe::Section;
|
||||||
va: u32,
|
|
||||||
vsize: u32,
|
|
||||||
raw_ptr: u32,
|
|
||||||
raw_size: u32,
|
|
||||||
chars: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk the output's own section table and translate an RVA to a file offset.
|
/// Walk the output's own section table and translate an RVA to a file offset.
|
||||||
/// Works for both memory-image output (raw_ptr == va) and compacted disk
|
/// Works for both memory-image output (raw_ptr == va) and compacted disk
|
||||||
/// output (real raw pointers), because it consults whatever the output declares.
|
/// output (real raw pointers), because it consults whatever the output declares.
|
||||||
/// Returns the offset only if the translated range `[off, off+need)` lies inside
|
/// Returns the offset only if the translated range `[off, off+need)` lies inside
|
||||||
/// the file.
|
/// the file.
|
||||||
fn rva_to_off(secs: &[Section], file_len: usize, rva: u32, need: u32) -> Option<u32> {
|
fn rva_to_off(data: &[u8], headers: senbei_pe::Headers, rva: u32, need: u32) -> Option<u32> {
|
||||||
for s in secs {
|
let offset = u32::try_from(senbei_pe::rva_to_offset(data, headers, rva).ok()?).ok()?;
|
||||||
// The mapped span is the larger of virtual and raw size, so an RVA that
|
let end = offset.checked_add(need)?;
|
||||||
// falls in the virtual tail of a section still resolves.
|
(usize::try_from(end).ok()? <= data.len()).then_some(offset)
|
||||||
let span = s.vsize.max(s.raw_size);
|
|
||||||
if span == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if rva >= s.va && rva < s.va.wrapping_add(span) {
|
|
||||||
let delta = rva - s.va;
|
|
||||||
let off = s.raw_ptr.checked_add(delta)?;
|
|
||||||
let end = off.checked_add(need)?;
|
|
||||||
if (end as usize) <= file_len {
|
|
||||||
return Some(off);
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_executable_rva(secs: &[Section], rva: u32) -> bool {
|
fn is_executable_rva(secs: &[Section], rva: u32) -> bool {
|
||||||
secs.iter().any(|section| {
|
secs.iter().any(|section| {
|
||||||
let span = section.vsize.max(section.raw_size);
|
let span = section.virtual_size.max(section.raw_size);
|
||||||
rva >= section.va
|
rva >= section.virtual_address
|
||||||
&& rva < section.va.wrapping_add(span)
|
&& rva < section.virtual_address.wrapping_add(span)
|
||||||
&& (section.chars & 0x2000_0000) != 0
|
&& (section.characteristics & 0x2000_0000) != 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +142,12 @@ fn check_common_entry_branches(
|
|||||||
.ok()
|
.ok()
|
||||||
.is_some_and(|rva| is_executable_rva(secs, rva));
|
.is_some_and(|rva| is_executable_rva(secs, rva));
|
||||||
if !valid {
|
if !valid {
|
||||||
report.issues.push(format!(
|
report.push(
|
||||||
|
IntegritySeverity::Fatal,
|
||||||
|
format!(
|
||||||
"entry point {name} target 0x{target:X} is outside executable sections (DD8 selection is likely wrong)"
|
"entry point {name} target 0x{target:X} is outside executable sections (DD8 selection is likely wrong)"
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,35 +160,39 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
|
|
||||||
// --- DOS + PE headers ---------------------------------------------------
|
// --- DOS + PE headers ---------------------------------------------------
|
||||||
if rd_u16(out, 0) != Some(0x5A4D) {
|
if rd_u16(out, 0) != Some(0x5A4D) {
|
||||||
r.issues.push("missing 'MZ' DOS signature".into());
|
r.push(IntegritySeverity::Fatal, "missing 'MZ' DOS signature");
|
||||||
return r; // nothing else is meaningful
|
return r; // nothing else is meaningful
|
||||||
}
|
}
|
||||||
let pe_off = match rd_u32(out, 0x3C) {
|
let pe_off = match rd_u32(out, 0x3C) {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => {
|
||||||
r.issues.push("truncated DOS header (no e_lfanew)".into());
|
r.push(
|
||||||
|
IntegritySeverity::Fatal,
|
||||||
|
"truncated DOS header (no e_lfanew)",
|
||||||
|
);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if rd_u32(out, pe_off) != Some(0x0000_4550) {
|
if rd_u32(out, pe_off) != Some(0x0000_4550) {
|
||||||
r.issues
|
r.push(
|
||||||
.push(format!("missing 'PE\\0\\0' signature at 0x{pe_off:X}"));
|
IntegritySeverity::Fatal,
|
||||||
|
format!("missing 'PE\\0\\0' signature at 0x{pe_off:X}"),
|
||||||
|
);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
let num_sections = match rd_u16(out, pe_off.wrapping_add(6)) {
|
let num_sections = match rd_u16(out, pe_off.wrapping_add(6)) {
|
||||||
Some(v) => v as u32,
|
Some(v) => v as u32,
|
||||||
None => {
|
None => {
|
||||||
r.issues.push("truncated COFF header".into());
|
r.push(IntegritySeverity::Fatal, "truncated COFF header");
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let opt_hdr_size = rd_u16(out, pe_off.wrapping_add(20)).unwrap_or(0) as u32;
|
|
||||||
let opt = pe_off.wrapping_add(24);
|
let opt = pe_off.wrapping_add(24);
|
||||||
let magic = match rd_u16(out, opt) {
|
let magic = match rd_u16(out, opt) {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => {
|
||||||
r.issues.push("truncated optional header".into());
|
r.push(IntegritySeverity::Fatal, "truncated optional header");
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -164,59 +200,66 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
0x20B => true,
|
0x20B => true,
|
||||||
0x10B => false,
|
0x10B => false,
|
||||||
other => {
|
other => {
|
||||||
r.issues
|
r.push(
|
||||||
.push(format!("bad optional-header magic 0x{other:X}"));
|
IntegritySeverity::Fatal,
|
||||||
|
format!("bad optional-header magic 0x{other:X}"),
|
||||||
|
);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if num_sections == 0 || num_sections > 96 {
|
if num_sections == 0 || num_sections > 96 {
|
||||||
r.issues
|
r.push(
|
||||||
.push(format!("implausible section count {num_sections}"));
|
IntegritySeverity::Fatal,
|
||||||
|
format!("implausible section count {num_sections}"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let size_of_image = rd_u32(out, pe_off.wrapping_add(80)).unwrap_or(0);
|
let size_of_image = rd_u32(out, pe_off.wrapping_add(80)).unwrap_or(0);
|
||||||
if size_of_image == 0 {
|
if size_of_image == 0 {
|
||||||
r.issues.push("SizeOfImage is zero".into());
|
r.push(IntegritySeverity::Fatal, "SizeOfImage is zero");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Section table ------------------------------------------------------
|
// --- Section table ------------------------------------------------------
|
||||||
let sec_table = opt.wrapping_add(opt_hdr_size);
|
let headers = match senbei_pe::parse(out) {
|
||||||
let mut secs: Vec<Section> = Vec::new();
|
Ok(headers) => headers,
|
||||||
for i in 0..num_sections {
|
Err(_) => {
|
||||||
let base = sec_table.wrapping_add(i * 40);
|
r.push(
|
||||||
// If the table runs past EOF the image is structurally broken.
|
IntegritySeverity::Fatal,
|
||||||
let (vsize, va, raw_size, raw_ptr, chars) = match (
|
"section table extends past end of file",
|
||||||
rd_u32(out, base.wrapping_add(8)),
|
);
|
||||||
rd_u32(out, base.wrapping_add(12)),
|
|
||||||
rd_u32(out, base.wrapping_add(16)),
|
|
||||||
rd_u32(out, base.wrapping_add(20)),
|
|
||||||
rd_u32(out, base.wrapping_add(36)),
|
|
||||||
) {
|
|
||||||
(Some(a), Some(b), Some(c), Some(d), Some(e)) => (a, b, c, d, e),
|
|
||||||
_ => {
|
|
||||||
r.issues
|
|
||||||
.push("section table extends past end of file".into());
|
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Raw data must lie within the file for compacted (disk-layout) output.
|
let parsed_sections = match senbei_pe::sections(out, headers) {
|
||||||
if raw_size != 0 {
|
Ok(sections) => sections,
|
||||||
let end = raw_ptr.wrapping_add(raw_size) as usize;
|
Err(_) => {
|
||||||
|
r.push(
|
||||||
|
IntegritySeverity::Fatal,
|
||||||
|
"section table extends past end of file",
|
||||||
|
);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let secs: Vec<Section> = parsed_sections
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, section)| {
|
||||||
|
if section.raw_size != 0 {
|
||||||
|
let end = section.raw_offset.wrapping_add(section.raw_size) as usize;
|
||||||
if end > file_len {
|
if end > file_len {
|
||||||
r.issues.push(format!(
|
r.push(
|
||||||
"section #{i} raw data [0x{raw_ptr:X}..0x{end:X}] exceeds file size 0x{file_len:X}"
|
IntegritySeverity::Fatal,
|
||||||
));
|
format!(
|
||||||
|
"section #{i} raw data [0x{:X}..0x{end:X}] exceeds file size 0x{file_len:X}",
|
||||||
|
section.raw_offset
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
secs.push(Section {
|
section
|
||||||
va,
|
})
|
||||||
vsize,
|
.collect();
|
||||||
raw_ptr,
|
|
||||||
raw_size,
|
|
||||||
chars,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Managed (CLR) detection ------------------------------------------
|
// --- Managed (CLR) detection ------------------------------------------
|
||||||
// The COR20 (CLR) data directory, when present and non-zero, marks a managed
|
// The COR20 (CLR) data directory, when present and non-zero, marks a managed
|
||||||
@@ -248,9 +291,13 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
if is_dll && !is_managed {
|
if is_dll && !is_managed {
|
||||||
let reloc_rva = rd_u32(out, dd_base.wrapping_add(5 * 8)).unwrap_or(0);
|
let reloc_rva = rd_u32(out, dd_base.wrapping_add(5 * 8)).unwrap_or(0);
|
||||||
if reloc_rva == 0 {
|
if reloc_rva == 0 {
|
||||||
r.issues.push(
|
// Warning, not fatal: a native DLL can ship without base relocations
|
||||||
"native DLL has no base relocation table (DD[5] is zero) — will crash when loaded at a non-preferred base"
|
// (`IMAGE_FILE_RELOCS_STRIPPED`, or a module that is only ever loaded
|
||||||
.into(),
|
// at its preferred base). Rejecting the layout here would fall back
|
||||||
|
// to the other pipeline and change bytes that previously unpacked.
|
||||||
|
r.push(
|
||||||
|
IntegritySeverity::Warning,
|
||||||
|
"native DLL has no base relocation table (DD[5] is zero) — will crash when loaded at a non-preferred base",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,38 +310,51 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
if ep == 0 {
|
if ep == 0 {
|
||||||
// A DLL may legitimately have no entry point; an EXE never does.
|
// A DLL may legitimately have no entry point; an EXE never does.
|
||||||
if !is_dll {
|
if !is_dll {
|
||||||
r.issues.push("entry point RVA is zero".into());
|
r.push(IntegritySeverity::Fatal, "entry point RVA is zero");
|
||||||
}
|
}
|
||||||
} else if !is_managed {
|
} else if !is_managed {
|
||||||
match rva_to_off(&secs, file_len, ep, 16) {
|
match rva_to_off(out, headers, ep, 16) {
|
||||||
None => {
|
None => {
|
||||||
r.issues.push(format!(
|
r.push(
|
||||||
"entry point RVA 0x{ep:X} does not map into any section"
|
IntegritySeverity::Fatal,
|
||||||
));
|
format!("entry point RVA 0x{ep:X} does not map into any section"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Some(off) => {
|
Some(off) => {
|
||||||
let stub = &out[off as usize..off as usize + 16];
|
let stub = &out[off as usize..off as usize + 16];
|
||||||
if stub.iter().all(|&b| b == 0) {
|
if stub.iter().all(|&b| b == 0) {
|
||||||
r.issues.push(format!(
|
// Warning: the entry maps and is executable, but its bytes
|
||||||
"entry point at RVA 0x{ep:X} is all zeros (stub not recovered)"
|
// were not recovered. That is the suspect signal for an
|
||||||
));
|
// already-chosen layout, not proof another layout is the
|
||||||
|
// right one.
|
||||||
|
r.push(
|
||||||
|
IntegritySeverity::Warning,
|
||||||
|
format!("entry point at RVA 0x{ep:X} is all zeros (stub not recovered)"),
|
||||||
|
);
|
||||||
} else if stub.iter().all(|&b| b == 0xCC) {
|
} else if stub.iter().all(|&b| b == 0xCC) {
|
||||||
// 16 bytes of int3 padding where the entry stub should be:
|
// 16 bytes of int3 padding where the entry stub should be:
|
||||||
// the stub region was never recovered, the loader walks
|
// the stub region was never recovered, the loader walks
|
||||||
// straight into a debug-break wall.
|
// straight into a debug-break wall. Same severity as a
|
||||||
r.issues.push(format!(
|
// zeroed stub: the image is mapped, the bytes look unrestored.
|
||||||
|
r.push(
|
||||||
|
IntegritySeverity::Warning,
|
||||||
|
format!(
|
||||||
"entry point at RVA 0x{ep:X} is all int3 padding (stub not recovered)"
|
"entry point at RVA 0x{ep:X} is all int3 padding (stub not recovered)"
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// The entry must live in an executable section.
|
// The entry must live in an executable section.
|
||||||
let exec = secs.iter().any(|s| {
|
let exec = secs.iter().any(|s| {
|
||||||
let span = s.vsize.max(s.raw_size);
|
let span = s.virtual_size.max(s.raw_size);
|
||||||
ep >= s.va && ep < s.va.wrapping_add(span) && (s.chars & 0x2000_0000) != 0
|
ep >= s.virtual_address
|
||||||
|
&& ep < s.virtual_address.wrapping_add(span)
|
||||||
|
&& (s.characteristics & 0x2000_0000) != 0
|
||||||
});
|
});
|
||||||
if !exec {
|
if !exec {
|
||||||
r.issues.push(format!(
|
r.push(
|
||||||
"entry point RVA 0x{ep:X} is not in an executable section"
|
IntegritySeverity::Fatal,
|
||||||
));
|
format!("entry point RVA 0x{ep:X} is not in an executable section"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(entry_stub) = out.get(off as usize..off as usize + 18) {
|
if let Some(entry_stub) = out.get(off as usize..off as usize + 18) {
|
||||||
check_common_entry_branches(entry_stub, ep, &secs, &mut r);
|
check_common_entry_branches(entry_stub, ep, &secs, &mut r);
|
||||||
@@ -316,10 +376,11 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
if !is_managed {
|
if !is_managed {
|
||||||
let imp_rva = rd_u32(out, dd_base.wrapping_add(8)).unwrap_or(0);
|
let imp_rva = rd_u32(out, dd_base.wrapping_add(8)).unwrap_or(0);
|
||||||
if imp_rva != 0 {
|
if imp_rva != 0 {
|
||||||
match rva_to_off(&secs, file_len, imp_rva, 20) {
|
match rva_to_off(out, headers, imp_rva, 20) {
|
||||||
None => r.issues.push(format!(
|
None => r.push(
|
||||||
"import directory RVA 0x{imp_rva:X} does not map into any section"
|
IntegritySeverity::Fatal,
|
||||||
)),
|
format!("import directory RVA 0x{imp_rva:X} does not map into any section"),
|
||||||
|
),
|
||||||
Some(desc_off) => {
|
Some(desc_off) => {
|
||||||
// 256 descriptors is far beyond any real import table; the
|
// 256 descriptors is far beyond any real import table; the
|
||||||
// cap keeps a corrupt, never-null table from walking on.
|
// cap keeps a corrupt, never-null table from walking on.
|
||||||
@@ -331,15 +392,25 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
if name_rva == 0 {
|
if name_rva == 0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
match rva_to_off(&secs, file_len, name_rva, 1) {
|
match rva_to_off(out, headers, name_rva, 1) {
|
||||||
None => r.issues.push(format!(
|
None => r.push(
|
||||||
|
IntegritySeverity::Fatal,
|
||||||
|
format!(
|
||||||
"import descriptor {i} DLL name RVA 0x{name_rva:X} does not map into any section"
|
"import descriptor {i} DLL name RVA 0x{name_rva:X} does not map into any section"
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
Some(noff) => {
|
Some(noff) => {
|
||||||
if !looks_like_dll_name(out, noff) {
|
if !looks_like_dll_name(out, noff) {
|
||||||
r.issues.push(format!(
|
// Warning: the name is inside the image, so
|
||||||
|
// the directory is structurally present.
|
||||||
|
// Unreadable bytes are the "left encrypted"
|
||||||
|
// suspect signal, not an unmapped PE.
|
||||||
|
r.push(
|
||||||
|
IntegritySeverity::Warning,
|
||||||
|
format!(
|
||||||
"import descriptor {i} DLL name at RVA 0x{name_rva:X} is not readable ASCII (imports left encrypted?)"
|
"import descriptor {i} DLL name at RVA 0x{name_rva:X} is not readable ASCII (imports left encrypted?)"
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,30 +430,40 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
// still refuses to load. Validate: COR20 cb == 0x48, and the MetaData stream
|
// still refuses to load. Validate: COR20 cb == 0x48, and the MetaData stream
|
||||||
// begins with the "BSJB" signature.
|
// begins with the "BSJB" signature.
|
||||||
if is_managed {
|
if is_managed {
|
||||||
match rva_to_off(&secs, file_len, clr_rva, 0x48) {
|
match rva_to_off(out, headers, clr_rva, 0x48) {
|
||||||
None => r.issues.push(format!(
|
None => r.push(
|
||||||
"CLR (COR20) directory RVA 0x{clr_rva:X} does not map into any section"
|
IntegritySeverity::Fatal,
|
||||||
)),
|
format!("CLR (COR20) directory RVA 0x{clr_rva:X} does not map into any section"),
|
||||||
|
),
|
||||||
Some(coff) => {
|
Some(coff) => {
|
||||||
let cb = rd_u32(out, coff).unwrap_or(0);
|
let cb = rd_u32(out, coff).unwrap_or(0);
|
||||||
if cb != 0x48 {
|
if cb != 0x48 {
|
||||||
r.issues.push(format!(
|
r.push(
|
||||||
|
IntegritySeverity::Fatal,
|
||||||
|
format!(
|
||||||
"COR20 header at RVA 0x{clr_rva:X} has cb 0x{cb:X} (expected 0x48) — CLR header corrupt"
|
"COR20 header at RVA 0x{clr_rva:X} has cb 0x{cb:X} (expected 0x48) — CLR header corrupt"
|
||||||
));
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// MetaData RVA/size live at COR20 + 0x08 / + 0x0C.
|
// MetaData RVA/size live at COR20 + 0x08 / + 0x0C.
|
||||||
let md_rva = rd_u32(out, coff.wrapping_add(8)).unwrap_or(0);
|
let md_rva = rd_u32(out, coff.wrapping_add(8)).unwrap_or(0);
|
||||||
if md_rva != 0 {
|
if md_rva != 0 {
|
||||||
match rva_to_off(&secs, file_len, md_rva, 4) {
|
match rva_to_off(out, headers, md_rva, 4) {
|
||||||
None => r.issues.push(format!(
|
None => r.push(
|
||||||
|
IntegritySeverity::Fatal,
|
||||||
|
format!(
|
||||||
"CLR MetaData RVA 0x{md_rva:X} does not map into any section"
|
"CLR MetaData RVA 0x{md_rva:X} does not map into any section"
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
Some(moff) => {
|
Some(moff) => {
|
||||||
let sig = out.get(moff as usize..moff as usize + 4);
|
let sig = out.get(moff as usize..moff as usize + 4);
|
||||||
if sig != Some(b"BSJB") {
|
if sig != Some(b"BSJB") {
|
||||||
r.issues.push(format!(
|
r.push(
|
||||||
|
IntegritySeverity::Fatal,
|
||||||
|
format!(
|
||||||
"CLR MetaData at RVA 0x{md_rva:X} lacks 'BSJB' signature (metadata corrupt — managed image will not load)"
|
"CLR MetaData at RVA 0x{md_rva:X} lacks 'BSJB' signature (metadata corrupt — managed image will not load)"
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,11 +497,11 @@ mod tests {
|
|||||||
|
|
||||||
fn executable_text() -> Vec<Section> {
|
fn executable_text() -> Vec<Section> {
|
||||||
vec![Section {
|
vec![Section {
|
||||||
va: 0x1000,
|
virtual_address: 0x1000,
|
||||||
vsize: 0x4000,
|
virtual_size: 0x4000,
|
||||||
raw_ptr: 0x1000,
|
raw_offset: 0x1000,
|
||||||
raw_size: 0x4000,
|
raw_size: 0x4000,
|
||||||
chars: 0x6000_0020,
|
characteristics: 0x6000_0020,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,8 +6,9 @@ mod image;
|
|||||||
|
|
||||||
pub(super) use dd8::{select_dd8_formula_pe32, select_dd8_shift};
|
pub(super) use dd8::{select_dd8_formula_pe32, select_dd8_shift};
|
||||||
pub(super) use discovery::{
|
pub(super) use discovery::{
|
||||||
discover_eighth_slots, find_bytecode_offset, find_lfsr_block, find_str_pos, find_tbl_pe32,
|
discover_eighth_slots, find_bytecode_offset, find_lfsr_block, find_str_pos,
|
||||||
find_v_after_pad, find_v4_offset, get_string_to_null, section_name, trial_decrypt5_u32,
|
find_tbl_pe32_candidates, find_v_after_pad, find_v4_offset, get_string_to_null, section_name,
|
||||||
|
trial_decrypt5_u32,
|
||||||
};
|
};
|
||||||
pub(super) use image::{
|
pub(super) use image::{
|
||||||
compact_memory_image_to_pe, move_pe32_imports_to_kmiat, pe32_imports_already_match_idata_layout,
|
compact_memory_image_to_pe, move_pe32_imports_to_kmiat, pe32_imports_already_match_idata_layout,
|
||||||
File renamed without changes.
+95
-27
@@ -212,37 +212,54 @@ pub fn section_name(data: &[u8], offset: u32) -> String {
|
|||||||
// PE32 (32-bit) helpers
|
// PE32 (32-bit) helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// PE32 shell-table locator. Walks the shell region (`info[6]`) for a dword
|
/// PE32 shell-table locator. The compact layout moves the shell descriptor
|
||||||
/// equal to `info[6]` followed by a plausible shell size, returning the table
|
/// from +0x88 to +0x78 and the header-checksum list from +0x58 to +0x50.
|
||||||
/// base (`candidate = off - 0x88`) when `candidate+0x58` holds a valid pointer.
|
/// Return structurally plausible candidates; the pipeline must trial-decrypt
|
||||||
pub fn find_tbl_pe32(data: &[u8], info: &[u32; 8]) -> Option<u32> {
|
/// their second stages before selecting a table.
|
||||||
let shell = info[6];
|
pub fn find_tbl_pe32_candidates(data: &[u8], info: &[u32; 8]) -> Vec<u32> {
|
||||||
if (data.len() as u64) < 0x100 {
|
let mut tables = Vec::new();
|
||||||
return None;
|
let shell = info[6] as usize;
|
||||||
|
let hi = shell.saturating_add(0x3000).min(data.len());
|
||||||
|
for off in (shell..hi.saturating_sub(8)).step_by(4) {
|
||||||
|
if get_u32(data, off as u32) != info[6] {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
let hi = (shell as u64)
|
let shell_size = get_u32(data, off as u32 + 4) as usize;
|
||||||
.saturating_add(0x3000)
|
if !(0x1001..0x100000).contains(&shell_size)
|
||||||
.min(data.len() as u64 - 0x100) as u32;
|
|| shell
|
||||||
let mut off = shell;
|
.checked_add(shell_size)
|
||||||
while off < hi {
|
.is_none_or(|end| end > data.len())
|
||||||
if off as usize + 8 <= data.len() {
|
{
|
||||||
let candidate = off.wrapping_sub(0x88);
|
continue;
|
||||||
if candidate >= shell && get_u32(data, off) == info[6] {
|
}
|
||||||
let shell_size_val = get_u32(data, off.wrapping_add(4));
|
for (shell_field, header_field) in [(0x88, 0x58), (0x78, 0x50)] {
|
||||||
if shell_size_val > 0x1000 && shell_size_val < 0x100000 {
|
let Some(candidate) = off.checked_sub(shell_field).filter(|&base| base >= shell) else {
|
||||||
let v58_off = candidate.wrapping_add(0x58);
|
continue;
|
||||||
if (v58_off as usize + 4) <= data.len() {
|
};
|
||||||
let v58 = get_u32(data, v58_off);
|
let Some(table) = data.get(candidate..candidate + 0xD0) else {
|
||||||
if v58 > 0 && (v58 as usize) < data.len() {
|
continue;
|
||||||
return Some(candidate);
|
};
|
||||||
|
let header = get_u32(table, header_field) as usize;
|
||||||
|
let header_size = get_u32(table, header_field + 4) as usize;
|
||||||
|
let stage = get_u32(table, shell_field as u32 + 0x10) as usize;
|
||||||
|
let stage_size = get_u32(table, shell_field as u32 + 0x14) as usize;
|
||||||
|
if header > 0
|
||||||
|
&& header_size > 0
|
||||||
|
&& header
|
||||||
|
.checked_add(header_size)
|
||||||
|
.is_some_and(|end| end <= 0x1000)
|
||||||
|
&& stage >= info[3] as usize
|
||||||
|
&& stage_size >= 4
|
||||||
|
&& stage
|
||||||
|
.checked_add(stage_size)
|
||||||
|
.is_some_and(|end| end <= data.len())
|
||||||
|
&& !tables.contains(&(candidate as u32))
|
||||||
|
{
|
||||||
|
tables.push(candidate as u32);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
tables
|
||||||
}
|
|
||||||
off = off.wrapping_add(4);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate an LFSR-encrypted bytecode block (decrypt_data6 form) in a region.
|
/// Locate an LFSR-encrypted bytecode block (decrypt_data6 form) in a region.
|
||||||
@@ -449,6 +466,57 @@ pub fn discover_eighth_slots(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn pe32_table_fixture(compact: bool) -> (Vec<u8>, [u32; 8], u32) {
|
||||||
|
let mut data = vec![0u8; 0x10000];
|
||||||
|
let info = [0, 0, 0, 0x2000, 0, 0x7000, 0x2400, 0];
|
||||||
|
let table = 0x2800;
|
||||||
|
let (shell, header) = if compact { (0x78, 0x50) } else { (0x88, 0x58) };
|
||||||
|
for (field, value) in [
|
||||||
|
(header, 0x80),
|
||||||
|
(header + 4, 0x58),
|
||||||
|
(shell, info[6]),
|
||||||
|
(shell + 4, 0x1800),
|
||||||
|
(shell + 0x10, 0x6000),
|
||||||
|
(shell + 0x14, 0xAEC),
|
||||||
|
] {
|
||||||
|
senbei_crypto::primitives::write_u32(&mut data, table + field, value);
|
||||||
|
}
|
||||||
|
(data, info, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pe32_table_supports_compact_header_descriptors() {
|
||||||
|
for compact in [false, true] {
|
||||||
|
let (data, info, table) = pe32_table_fixture(compact);
|
||||||
|
assert_eq!(find_tbl_pe32_candidates(&data, &info), vec![table]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pe32_table_rejects_a_shell_reference_without_valid_descriptors() {
|
||||||
|
let (mut data, info, table) = pe32_table_fixture(true);
|
||||||
|
for (field, value) in [(0x58, 0x80), (0x5C, 0x58), (0x88, info[6]), (0x8C, 0x1800)] {
|
||||||
|
senbei_crypto::primitives::write_u32(&mut data, 0x2500 + field, value);
|
||||||
|
}
|
||||||
|
assert_eq!(find_tbl_pe32_candidates(&data, &info), vec![table]);
|
||||||
|
senbei_crypto::primitives::write_u32(&mut data, table + 0x54, 0);
|
||||||
|
assert_eq!(find_tbl_pe32_candidates(&data, &info), Vec::<u32>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pe32_table_rejects_truncated_and_out_of_range_regions() {
|
||||||
|
let (mut data, mut info, table) = pe32_table_fixture(true);
|
||||||
|
assert_eq!(
|
||||||
|
find_tbl_pe32_candidates(&data[..0x28C0], &info),
|
||||||
|
Vec::<u32>::new()
|
||||||
|
);
|
||||||
|
senbei_crypto::primitives::write_u32(&mut data, table + 0x50, u32::MAX - 15);
|
||||||
|
assert_eq!(find_tbl_pe32_candidates(&data, &info), Vec::<u32>::new());
|
||||||
|
info[6] = u32::MAX;
|
||||||
|
assert_eq!(find_tbl_pe32_candidates(&data, &info), Vec::<u32>::new());
|
||||||
|
assert_eq!(find_tbl_pe32_candidates(&[], &info), Vec::<u32>::new());
|
||||||
|
}
|
||||||
|
|
||||||
/// Task 4.1 regression: build a synthetic buffer whose valid bytecode block
|
/// Task 4.1 regression: build a synthetic buffer whose valid bytecode block
|
||||||
/// sits PAST `len` but within `len*2`. Assert that the smaller window misses
|
/// sits PAST `len` but within `len*2`. Assert that the smaller window misses
|
||||||
/// it and the doubled window finds it.
|
/// it and the doubled window finds it.
|
||||||
@@ -331,6 +331,13 @@ pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>
|
|||||||
let opt_hdr = pe_header.wrapping_add(24);
|
let opt_hdr = pe_header.wrapping_add(24);
|
||||||
let sec_table = opt_hdr.wrapping_add(opt_hdr_size);
|
let sec_table = opt_hdr.wrapping_add(opt_hdr_size);
|
||||||
let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32;
|
let num_sections = get_u16(data, pe_header.wrapping_add(6)) as u32;
|
||||||
|
let export_dir = opt_hdr.wrapping_add(if get_u16(data, opt_hdr) == 0x20B {
|
||||||
|
112
|
||||||
|
} else {
|
||||||
|
96
|
||||||
|
});
|
||||||
|
let export_rva = get_u32(data, export_dir);
|
||||||
|
let export_size = get_u32(data, export_dir.wrapping_add(4));
|
||||||
|
|
||||||
struct SecLayout {
|
struct SecLayout {
|
||||||
sec_off: u32,
|
sec_off: u32,
|
||||||
@@ -365,11 +372,21 @@ pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let meaningful = if last_nonzero >= 0 {
|
let mut meaningful = if last_nonzero >= 0 {
|
||||||
(last_nonzero + 1) as u32
|
(last_nonzero + 1) as u32
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
// Companion exports are overlaid after compaction. Keep their whole
|
||||||
|
// range file-backed even when the payload leaves it zero-filled.
|
||||||
|
if export_size != 0
|
||||||
|
&& let Some(end) = export_rva
|
||||||
|
.checked_sub(va)
|
||||||
|
.and_then(|delta| delta.checked_add(export_size))
|
||||||
|
&& end as usize <= section_data.len()
|
||||||
|
{
|
||||||
|
meaningful = meaningful.max(end);
|
||||||
|
}
|
||||||
let mut raw_size = if meaningful != 0 {
|
let mut raw_size = if meaningful != 0 {
|
||||||
align_up_u32(meaningful, FILE_ALIGNMENT)
|
align_up_u32(meaningful, FILE_ALIGNMENT)
|
||||||
} else {
|
} else {
|
||||||
@@ -433,6 +450,45 @@ pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_keeps_export_space_without_changing_the_next_section() {
|
||||||
|
for (rva, size, expected_raw_size) in [
|
||||||
|
(0, 0, 0x200),
|
||||||
|
(0x1280, 0x181, 0x600),
|
||||||
|
(0x1f00, 0x200, 0x200),
|
||||||
|
(0xffff_fff0, 0x40, 0x200),
|
||||||
|
] {
|
||||||
|
let mut data = vec![0; 0x3000];
|
||||||
|
data[..2].copy_from_slice(b"MZ");
|
||||||
|
data[0x80..0x84].copy_from_slice(b"PE\0\0");
|
||||||
|
write_u16(&mut data, 0x86, 2);
|
||||||
|
write_u16(&mut data, 0x94, 0xe0);
|
||||||
|
write_u16(&mut data, 0x98, 0x10b);
|
||||||
|
for (offset, value) in [
|
||||||
|
(0x3c, 0x80),
|
||||||
|
(0xf8, rva),
|
||||||
|
(0xfc, size),
|
||||||
|
(0x180, 0x1000),
|
||||||
|
(0x184, 0x1000),
|
||||||
|
(0x1a8, 0x1000),
|
||||||
|
(0x1ac, 0x2000),
|
||||||
|
] {
|
||||||
|
write_u32(&mut data, offset, value);
|
||||||
|
}
|
||||||
|
data[0x1000] = 0xaa;
|
||||||
|
data[0x2000..0x200c].copy_from_slice(b"next section");
|
||||||
|
let out = compact_memory_image_to_pe(&data, 0x80).unwrap();
|
||||||
|
assert_eq!(get_u32(&out, 0x188), expected_raw_size);
|
||||||
|
let headers = senbei_pe::parse(&out).unwrap();
|
||||||
|
let next = senbei_pe::rva_range(&out, headers, 0x2000, 12).unwrap();
|
||||||
|
assert_eq!(&out[next], b"next section");
|
||||||
|
if rva == 0x1280 {
|
||||||
|
let exports = senbei_pe::rva_range(&out, headers, rva, size).unwrap();
|
||||||
|
assert!(out[exports].iter().all(|&byte| byte == 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Review regression: a zero last-section VA (corrupt section table) must
|
/// Review regression: a zero last-section VA (corrupt section table) must
|
||||||
/// bail instead of building .kmiat at RVA 0 — the old code zeroed
|
/// bail instead of building .kmiat at RVA 0 — the old code zeroed
|
||||||
/// `[0, 0x7000)`, wiping the DOS/PE headers, and returned the broken image
|
/// `[0, 0x7000)`, wiping the DOS/PE headers, and returned the broken image
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
//! PE detection, unpacking, and structural validation.
|
||||||
|
|
||||||
|
pub mod dll;
|
||||||
|
mod error;
|
||||||
|
pub mod exe;
|
||||||
|
pub mod integrity;
|
||||||
|
mod layout;
|
||||||
|
pub(crate) mod parallel;
|
||||||
|
|
||||||
|
use senbei_crypto::primitives;
|
||||||
|
|
||||||
|
pub(crate) use crate::panic_capture::{PanicCapture, current_panic_capture, with_panic_capture};
|
||||||
|
|
||||||
|
pub use crate::thread_cap;
|
||||||
|
pub use dll::{unpack_dll, unpack_dll_v};
|
||||||
|
pub use error::*;
|
||||||
|
pub use exe::{unpack as unpack_exe, unpack_v as unpack_exe_v};
|
||||||
|
pub use integrity::{
|
||||||
|
IntegrityFinding, IntegrityReport, IntegritySeverity, check as check_integrity,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer
|
||||||
|
/// for. Guards against a corrupt/crafted header requesting a multi-gigabyte
|
||||||
|
/// (or, as a sign-extended negative `i32`, multi-exabyte) allocation, which
|
||||||
|
/// would abort the process — an abort that `catch_unpack` below cannot trap.
|
||||||
|
/// Real protected binaries are far below this.
|
||||||
|
pub(crate) const MAX_IMAGE_SIZE: u64 = senbei_crypto::MAX_IMAGE_SIZE;
|
||||||
|
|
||||||
|
/// Run an unpack pipeline, converting any internal panic into a clean
|
||||||
|
/// [`UnpackError::InternalPanic`] so the public API stays panic-free on any input
|
||||||
|
/// (truncated/garbled files chase offsets out of bounds). The panic location and
|
||||||
|
/// payload are captured for diagnostics without printing a backtrace to stderr.
|
||||||
|
///
|
||||||
|
/// Note: allocation *failures* abort the process and are NOT caught here; size
|
||||||
|
/// requests are bounds-checked against [`MAX_IMAGE_SIZE`] before allocating.
|
||||||
|
pub(crate) fn catch_unpack<F>(f: F) -> Result<Vec<u8>, UnpackError>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
||||||
|
{
|
||||||
|
crate::panic_capture::install_hook();
|
||||||
|
let capture = PanicCapture::new();
|
||||||
|
let r = with_panic_capture(Some(capture.clone()), || {
|
||||||
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
|
||||||
|
});
|
||||||
|
match r {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(payload) => {
|
||||||
|
let details = capture.details(payload.as_ref());
|
||||||
|
Err(UnpackError::InternalPanic {
|
||||||
|
message: details.message,
|
||||||
|
file: details.file,
|
||||||
|
line: details.line,
|
||||||
|
column: details.column,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crackproof header magic stored in `keys[1]`/`info[1]`.
|
||||||
|
pub(crate) const MAGIC_KONN: u32 = 0x4E4E4F4B; // b"KONN" little-endian (= 1313754955)
|
||||||
|
|
||||||
|
/// True if `magic` is the Crackproof magic this unpacker supports.
|
||||||
|
pub(crate) fn is_supported_magic(magic: u32) -> bool {
|
||||||
|
magic == MAGIC_KONN
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Kind {
|
||||||
|
NativeExe,
|
||||||
|
ManagedExe,
|
||||||
|
NativeDll,
|
||||||
|
ManagedDll,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Detected {
|
||||||
|
pub kind: Kind,
|
||||||
|
pub magic: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content-based detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Derive the 8-element Crackproof key table from the header at offset 4096.
|
||||||
|
/// Returns `None` if the input is too short or doesn't have a valid PE signature.
|
||||||
|
fn key_table(input: &[u8]) -> Option<[u32; 8]> {
|
||||||
|
// Need at least 4128 bytes: the key-table loop below reads dwords up to
|
||||||
|
// offset 4124 (bytes 4124..4127). Guarding only `< 4096` would let a
|
||||||
|
// 4096..4127-byte PE (e.g. a 4 KiB stub) panic in `get_u32`.
|
||||||
|
if input.len() < 4128 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Validate the PE signature with checked arithmetic so a crafted offset
|
||||||
|
// cannot wrap the bounds check on a narrower target.
|
||||||
|
let e_lfanew = primitives::get_u32(input, 0x3C);
|
||||||
|
let pe_start = e_lfanew as usize;
|
||||||
|
if pe_start.checked_add(4).is_none_or(|end| end > input.len()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if &input[pe_start..pe_start + 4] != b"PE\0\0" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Derive 8 keys per the Crackproof header-key formula.
|
||||||
|
let mut keys = [0u32; 8];
|
||||||
|
keys[0] = primitives::get_u32(input, 4096);
|
||||||
|
let mut k = keys[0];
|
||||||
|
for i in 0u32..7 {
|
||||||
|
let cell = primitives::get_u32(input, 4100u32.wrapping_add(i.wrapping_mul(4)));
|
||||||
|
keys[(i + 1) as usize] = k ^ cell;
|
||||||
|
k = i.wrapping_mul(i) ^ (k.wrapping_add(cell).wrapping_sub(i));
|
||||||
|
}
|
||||||
|
Some(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect whether `input` is a Crackproof-protected binary and classify it.
|
||||||
|
/// Returns `None` if the magic doesn't match.
|
||||||
|
///
|
||||||
|
/// Routing: `keys[1]` must be the Crackproof magic (`KONN`).
|
||||||
|
/// The PE IMAGE_FILE_DLL characteristic distinguishes EXE vs DLL;
|
||||||
|
/// the CLR data-directory RVA distinguishes managed from native for both.
|
||||||
|
pub fn detect(input: &[u8]) -> Option<Detected> {
|
||||||
|
let keys = key_table(input)?;
|
||||||
|
let magic = keys[1];
|
||||||
|
// Anything whose magic doesn't match is left untouched rather than
|
||||||
|
// detected-then-errored, honoring the "anything that doesn't match is
|
||||||
|
// left untouched" contract.
|
||||||
|
if !is_supported_magic(magic) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Use the PE DLL characteristic to distinguish EXE from DLL.
|
||||||
|
// IMAGE_FILE_HEADER.Characteristics is at peOff+4+18; bit 0x2000 = IMAGE_FILE_DLL.
|
||||||
|
let pe_off = primitives::get_u32(input, 0x3C);
|
||||||
|
let chars_offset = pe_off.wrapping_add(4).wrapping_add(18);
|
||||||
|
if (chars_offset as usize)
|
||||||
|
.checked_add(2)
|
||||||
|
.is_none_or(|end| end > input.len())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let chars =
|
||||||
|
(input[chars_offset as usize] as u16) | ((input[chars_offset as usize + 1] as u16) << 8);
|
||||||
|
let is_dll = (chars & 0x2000) != 0;
|
||||||
|
// Managed vs native via the CLR data-directory RVA.
|
||||||
|
// peOff + 24 = start of optional header. The data directories start at a
|
||||||
|
// magic-dependent offset within it: PE32 (0x10B) at +96, PE32+ (0x20B) at
|
||||||
|
// +112. Using the PE32+ offset on a PE32 image reads the wrong dword and
|
||||||
|
// can mis-flag a native image as managed.
|
||||||
|
//
|
||||||
|
// `get_u16`/`get_u32` index unchecked, so every read past the already-
|
||||||
|
// checked Characteristics word must be bounds-checked first: a truncated
|
||||||
|
// file (e.g. `e_lfanew` pointing at len-24) would otherwise panic here,
|
||||||
|
// and this detector runs on the folder scan threads where a panic aborts
|
||||||
|
// the whole run.
|
||||||
|
let opt_magic_off = pe_off.wrapping_add(24) as usize;
|
||||||
|
let b = input.get(opt_magic_off..opt_magic_off.checked_add(2)?)?;
|
||||||
|
let opt_magic = u16::from_le_bytes([b[0], b[1]]);
|
||||||
|
let dd_off: u32 = if opt_magic == 0x20B { 112 } else { 96 };
|
||||||
|
// + 14*8 = IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
|
||||||
|
let clr_rva_offset = pe_off
|
||||||
|
.wrapping_add(24)
|
||||||
|
.wrapping_add(dd_off)
|
||||||
|
.wrapping_add(14u32.wrapping_mul(8));
|
||||||
|
if (clr_rva_offset as usize)
|
||||||
|
.checked_add(4)
|
||||||
|
.is_none_or(|end| end > input.len())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let clr_rva = primitives::get_u32(input, clr_rva_offset);
|
||||||
|
let kind = match (is_dll, clr_rva != 0) {
|
||||||
|
(false, false) => Kind::NativeExe,
|
||||||
|
(false, true) => Kind::ManagedExe,
|
||||||
|
(true, false) => Kind::NativeDll,
|
||||||
|
(true, true) => Kind::ManagedDll,
|
||||||
|
};
|
||||||
|
Some(Detected { kind, magic })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the file type and dispatch to the matching pipeline.
|
||||||
|
/// Returns the detected `Kind` together with the unpacked image bytes.
|
||||||
|
pub fn unpack_auto(input: &[u8]) -> Result<(Kind, Vec<u8>), UnpackError> {
|
||||||
|
unpack_auto_v(input, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`unpack_auto`], but prints detailed `[N/9]` unpack-step progress to
|
||||||
|
/// stdout when `verbose` is true. Output bytes are identical regardless.
|
||||||
|
pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec<u8>), UnpackError> {
|
||||||
|
let detected = detect(input).ok_or(UnpackError::NotCrackproof)?;
|
||||||
|
let out = match detected.kind {
|
||||||
|
Kind::NativeExe | Kind::ManagedExe => unpack_exe_v(input, verbose)?,
|
||||||
|
Kind::NativeDll | Kind::ManagedDll => select_dll_layout(
|
||||||
|
|| dll::unpack_dll_v(input, verbose),
|
||||||
|
|| exe::unpack_v(input, verbose),
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
Ok((detected.kind, out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick between the older DLL layout and the EXE-style shell layout.
|
||||||
|
///
|
||||||
|
/// `Ok` from a pipeline is not proof it chose the right layout: a wrong layout
|
||||||
|
/// can emit a PE-shaped image the loader would reject. Each successful image is
|
||||||
|
/// judged with [`dll_layout_invalid`]. The older DLL layout is kept whenever it
|
||||||
|
/// is structurally acceptable, including when its only defects are ordinary
|
||||||
|
/// warnings (a missing base-relocation directory is one of those). The EXE-style
|
||||||
|
/// layout runs only when the DLL layout errors or is structurally invalid.
|
||||||
|
///
|
||||||
|
/// The two closures exist so tests can inject synthetic images without running
|
||||||
|
/// either real pipeline. Production passes `dll::unpack_dll_v` and `exe::unpack_v`.
|
||||||
|
fn select_dll_layout<D, E>(dll_layout: D, exe_layout: E) -> Result<Vec<u8>, UnpackError>
|
||||||
|
where
|
||||||
|
D: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
||||||
|
E: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
||||||
|
{
|
||||||
|
match validate_dll_layout(dll_layout()) {
|
||||||
|
Ok(output) => Ok(output),
|
||||||
|
Err(dll) => {
|
||||||
|
validate_dll_layout(exe_layout()).map_err(|exe| UnpackError::PipelineFallbackFailed {
|
||||||
|
dll: Box::new(dll),
|
||||||
|
exe: Box::new(exe),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_dll_layout(output: Result<Vec<u8>, UnpackError>) -> Result<Vec<u8>, UnpackError> {
|
||||||
|
let output = output?;
|
||||||
|
if let Some(report) = dll_layout_invalid(&output) {
|
||||||
|
return Err(UnpackError::InvalidLayoutCandidate {
|
||||||
|
issues: report
|
||||||
|
.findings
|
||||||
|
.into_iter()
|
||||||
|
.filter(|finding| finding.severity == integrity::IntegritySeverity::Fatal)
|
||||||
|
.map(|finding| finding.message)
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structural defects that mean a DLL layout candidate must not be accepted.
|
||||||
|
///
|
||||||
|
/// Returns `None` when [`integrity::check`] reports nothing fatal. Warning
|
||||||
|
/// findings stay on the image: a native DLL with no base-relocation directory,
|
||||||
|
/// a zeroed or `int3` entry stub, or an import name that is mapped but not
|
||||||
|
/// printable ASCII. Those are the existing suspect conditions. A fatal finding
|
||||||
|
/// is one the loader or CLR rejects before that — the image is not a PE, a
|
||||||
|
/// claimed directory or entry point does not map, a section's raw range runs
|
||||||
|
/// past the file, or managed metadata has no COR20/BSJB signature.
|
||||||
|
///
|
||||||
|
/// Severity comes from [`IntegrityReport::has_fatal`], which the checker sets
|
||||||
|
/// when it records each finding. This does not re-read issue text.
|
||||||
|
fn dll_layout_invalid(image: &[u8]) -> Option<IntegrityReport> {
|
||||||
|
let report = integrity::check(image);
|
||||||
|
report.has_fatal().then_some(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caught_panic_reports_location_and_message() {
|
||||||
|
let error = catch_unpack(|| -> Result<Vec<u8>, UnpackError> {
|
||||||
|
panic!("test panic");
|
||||||
|
})
|
||||||
|
.expect_err("panic must become an error");
|
||||||
|
let UnpackError::InternalPanic {
|
||||||
|
message,
|
||||||
|
file,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
} = error
|
||||||
|
else {
|
||||||
|
panic!("unexpected error: {error}");
|
||||||
|
};
|
||||||
|
assert_eq!(message, "test panic");
|
||||||
|
assert!(
|
||||||
|
file.ends_with("senbei-engine/src/windows/mod.rs")
|
||||||
|
|| file.ends_with("senbei-engine\\src\\windows\\mod.rs")
|
||||||
|
);
|
||||||
|
assert!(line > 0);
|
||||||
|
assert!(column > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_panic_keeps_the_worker_source_location() {
|
||||||
|
let error = catch_unpack(|| -> Result<Vec<u8>, UnpackError> {
|
||||||
|
let capture = current_panic_capture();
|
||||||
|
let result = std::thread::spawn(move || {
|
||||||
|
with_panic_capture(capture, || panic!("worker panic"));
|
||||||
|
})
|
||||||
|
.join();
|
||||||
|
if let Err(payload) = result {
|
||||||
|
std::panic::resume_unwind(payload);
|
||||||
|
}
|
||||||
|
Ok(Vec::new())
|
||||||
|
})
|
||||||
|
.expect_err("worker panic must become an error");
|
||||||
|
let UnpackError::InternalPanic {
|
||||||
|
message,
|
||||||
|
file,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
} = error
|
||||||
|
else {
|
||||||
|
panic!("unexpected error: {error}");
|
||||||
|
};
|
||||||
|
assert_eq!(message, "worker panic");
|
||||||
|
assert!(
|
||||||
|
file.ends_with("senbei-engine/src/windows/mod.rs")
|
||||||
|
|| file.ends_with("senbei-engine\\src\\windows\\mod.rs")
|
||||||
|
);
|
||||||
|
assert!(line > 0);
|
||||||
|
assert!(column > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod dll_layout_selection {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn pipeline_err(stage: &'static str) -> UnpackError {
|
||||||
|
UnpackError::InputTooShort {
|
||||||
|
actual: stage.len(),
|
||||||
|
required: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal PE32+ image large enough for `integrity::check` to walk headers,
|
||||||
|
/// one section, and (when asked) an import name or a COR20 header.
|
||||||
|
struct SyntheticPe {
|
||||||
|
dll: bool,
|
||||||
|
size_of_image: u32,
|
||||||
|
entry_rva: u32,
|
||||||
|
entry_bytes: Vec<u8>,
|
||||||
|
executable: bool,
|
||||||
|
reloc: bool,
|
||||||
|
import_name: Option<&'static str>,
|
||||||
|
import_unmapped: bool,
|
||||||
|
managed: bool,
|
||||||
|
cor20_cb: u32,
|
||||||
|
metadata_sig: &'static [u8],
|
||||||
|
section_count: u16,
|
||||||
|
raw_past_eof: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SyntheticPe {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
dll: true,
|
||||||
|
size_of_image: 0x2000,
|
||||||
|
entry_rva: 0x1000,
|
||||||
|
entry_bytes: vec![0xC3],
|
||||||
|
executable: true,
|
||||||
|
reloc: true,
|
||||||
|
import_name: None,
|
||||||
|
import_unmapped: false,
|
||||||
|
managed: false,
|
||||||
|
cor20_cb: 0x48,
|
||||||
|
metadata_sig: b"BSJB",
|
||||||
|
section_count: 1,
|
||||||
|
raw_past_eof: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyntheticPe {
|
||||||
|
fn build(&self) -> Vec<u8> {
|
||||||
|
let optional_size: u16 = 0xF0;
|
||||||
|
let pe = 0x80usize;
|
||||||
|
let section = pe + 24 + optional_size as usize;
|
||||||
|
let file_size = if self.raw_past_eof { 0x200 } else { 0x500 };
|
||||||
|
let mut data = vec![0u8; file_size];
|
||||||
|
data[..2].copy_from_slice(b"MZ");
|
||||||
|
data[0x3C..0x40].copy_from_slice(&(pe as u32).to_le_bytes());
|
||||||
|
data[pe..pe + 4].copy_from_slice(b"PE\0\0");
|
||||||
|
data[pe + 6..pe + 8].copy_from_slice(&self.section_count.to_le_bytes());
|
||||||
|
let chars: u16 = if self.dll { 0x2000 } else { 0 };
|
||||||
|
data[pe + 22..pe + 24].copy_from_slice(&chars.to_le_bytes());
|
||||||
|
data[pe + 20..pe + 22].copy_from_slice(&optional_size.to_le_bytes());
|
||||||
|
data[pe + 24..pe + 26].copy_from_slice(&0x20Bu16.to_le_bytes());
|
||||||
|
data[pe + 24 + 16..pe + 24 + 20].copy_from_slice(&self.entry_rva.to_le_bytes());
|
||||||
|
data[pe + 24 + 56..pe + 24 + 60].copy_from_slice(&self.size_of_image.to_le_bytes());
|
||||||
|
// NumberOfRvaAndSizes must cover the directories we publish.
|
||||||
|
data[pe + 24 + 108..pe + 24 + 112].copy_from_slice(&16u32.to_le_bytes());
|
||||||
|
let dd = pe + 24 + 112;
|
||||||
|
if self.reloc && !self.managed {
|
||||||
|
data[dd + 5 * 8..dd + 5 * 8 + 4].copy_from_slice(&0x1800u32.to_le_bytes());
|
||||||
|
data[dd + 5 * 8 + 4..dd + 5 * 8 + 8].copy_from_slice(&8u32.to_le_bytes());
|
||||||
|
}
|
||||||
|
if self.import_name.is_some() || self.import_unmapped {
|
||||||
|
data[dd + 8..dd + 12].copy_from_slice(&0x1100u32.to_le_bytes());
|
||||||
|
data[dd + 12..dd + 16].copy_from_slice(&40u32.to_le_bytes());
|
||||||
|
}
|
||||||
|
if self.managed {
|
||||||
|
data[dd + 14 * 8..dd + 14 * 8 + 4].copy_from_slice(&0x1400u32.to_le_bytes());
|
||||||
|
data[dd + 14 * 8 + 4..dd + 14 * 8 + 8].copy_from_slice(&0x48u32.to_le_bytes());
|
||||||
|
}
|
||||||
|
if self.section_count > 0 && section + 40 <= data.len() {
|
||||||
|
let characteristics: u32 = if self.executable {
|
||||||
|
0x6000_0020
|
||||||
|
} else {
|
||||||
|
0x4000_0040
|
||||||
|
};
|
||||||
|
let raw_size: u32 = if self.raw_past_eof { 0x1000 } else { 0x300 };
|
||||||
|
for (offset, value) in [
|
||||||
|
(8usize, 0x1000u32),
|
||||||
|
(12, 0x1000),
|
||||||
|
(16, raw_size),
|
||||||
|
(20, 0x200),
|
||||||
|
(36, characteristics),
|
||||||
|
] {
|
||||||
|
data[section + offset..section + offset + 4]
|
||||||
|
.copy_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.entry_rva != 0 && !self.entry_bytes.is_empty() {
|
||||||
|
let at = 0x200 + (self.entry_rva.saturating_sub(0x1000)) as usize;
|
||||||
|
if let Some(slot) = data.get_mut(at..at + self.entry_bytes.len()) {
|
||||||
|
slot.copy_from_slice(&self.entry_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.import_name.is_some() || self.import_unmapped {
|
||||||
|
// Descriptor at RVA 0x1100 -> file 0x300. The name has to sit
|
||||||
|
// *before* that descriptor: bytes after it are the next
|
||||||
|
// descriptor, and a name like "kernel32.dll" would be walked
|
||||||
|
// as one. Mapped name RVA 0x1010 -> file 0x210, which is past
|
||||||
|
// the 16-byte entry stub and still inside the section.
|
||||||
|
let name_rva: u32 = if self.import_unmapped { 0x9000 } else { 0x1010 };
|
||||||
|
if data.len() >= 0x300 + 20 {
|
||||||
|
data[0x30C..0x310].copy_from_slice(&name_rva.to_le_bytes());
|
||||||
|
}
|
||||||
|
if let Some(name) = self.import_name.filter(|_| !self.import_unmapped) {
|
||||||
|
let name_at = 0x210;
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
if let Some(slot) = data.get_mut(name_at..name_at + bytes.len()) {
|
||||||
|
slot.copy_from_slice(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.managed {
|
||||||
|
let cor = 0x200 + (0x1400 - 0x1000);
|
||||||
|
if data.len() >= cor + 0x48 {
|
||||||
|
data[cor..cor + 4].copy_from_slice(&self.cor20_cb.to_le_bytes());
|
||||||
|
let md_rva = 0x1500u32;
|
||||||
|
data[cor + 8..cor + 12].copy_from_slice(&md_rva.to_le_bytes());
|
||||||
|
data[cor + 12..cor + 16].copy_from_slice(&4u32.to_le_bytes());
|
||||||
|
let md = 0x200 + (0x1500 - 0x1000);
|
||||||
|
let sig = self.metadata_sig;
|
||||||
|
if let Some(slot) = data.get_mut(md..md + sig.len()) {
|
||||||
|
slot.copy_from_slice(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_native_dll() -> Vec<u8> {
|
||||||
|
SyntheticPe::default().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_dll_layout_falls_back_to_valid_exe_layout() {
|
||||||
|
let dll_image = SyntheticPe {
|
||||||
|
entry_rva: 0x9000,
|
||||||
|
..SyntheticPe::default()
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
let exe_image = valid_native_dll();
|
||||||
|
let selected = select_dll_layout(|| Ok(dll_image.clone()), || Ok(exe_image.clone()))
|
||||||
|
.expect("valid EXE-layout image must be accepted");
|
||||||
|
assert_eq!(selected, exe_image);
|
||||||
|
assert!(dll_layout_invalid(&dll_image).is_some());
|
||||||
|
assert!(dll_layout_invalid(&exe_image).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structurally_valid_dll_layout_is_kept_without_calling_exe() {
|
||||||
|
let dll_image = valid_native_dll();
|
||||||
|
let selected = select_dll_layout(
|
||||||
|
|| Ok(dll_image.clone()),
|
||||||
|
|| panic!("EXE layout must not run when the DLL layout is valid"),
|
||||||
|
)
|
||||||
|
.expect("DLL layout");
|
||||||
|
assert_eq!(selected, dll_image);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dll_warning_is_not_fatal_and_blocks_exe_fallback() {
|
||||||
|
let warned = SyntheticPe {
|
||||||
|
reloc: false,
|
||||||
|
..SyntheticPe::default()
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
let report = integrity::check(&warned);
|
||||||
|
assert!(!report.ok(), "fixture must actually warn, got {report:?}");
|
||||||
|
assert!(
|
||||||
|
dll_layout_invalid(&warned).is_none(),
|
||||||
|
"a missing relocation directory is a warning, not a rejected layout"
|
||||||
|
);
|
||||||
|
let selected = select_dll_layout(
|
||||||
|
|| Ok(warned.clone()),
|
||||||
|
|| panic!("warnings must not trigger the EXE layout"),
|
||||||
|
)
|
||||||
|
.expect("warned DLL layout");
|
||||||
|
assert_eq!(selected, warned);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zeroed_entry_stub_is_a_warning_not_a_rejected_layout() {
|
||||||
|
let warned = SyntheticPe {
|
||||||
|
entry_bytes: vec![0; 16],
|
||||||
|
..SyntheticPe::default()
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
let report = integrity::check(&warned);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.issues
|
||||||
|
.iter()
|
||||||
|
.any(|issue| issue.contains("all zeros"))
|
||||||
|
);
|
||||||
|
assert!(dll_layout_invalid(&warned).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_layouts_structurally_invalid_reports_both_candidates() {
|
||||||
|
let invalid = SyntheticPe {
|
||||||
|
entry_rva: 0x9000,
|
||||||
|
..SyntheticPe::default()
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
let error = select_dll_layout(|| Ok(invalid.clone()), || Ok(invalid.clone()))
|
||||||
|
.expect_err("neither layout is acceptable");
|
||||||
|
match error {
|
||||||
|
UnpackError::PipelineFallbackFailed { dll, exe } => {
|
||||||
|
let dll = dll.to_string();
|
||||||
|
let exe = exe.to_string();
|
||||||
|
assert!(dll.contains("structurally invalid"), "{dll}");
|
||||||
|
assert!(exe.contains("structurally invalid"), "{exe}");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dll_error_then_exe_error_keeps_pipeline_diagnostics() {
|
||||||
|
let error = select_dll_layout(
|
||||||
|
|| Err(pipeline_err("dll")),
|
||||||
|
|| Err(pipeline_err("exe-layout")),
|
||||||
|
)
|
||||||
|
.expect_err("both pipelines failed");
|
||||||
|
match error {
|
||||||
|
UnpackError::PipelineFallbackFailed { dll, exe } => {
|
||||||
|
let dll = dll.to_string();
|
||||||
|
let exe = exe.to_string();
|
||||||
|
assert!(dll.contains("got 3"), "{dll}");
|
||||||
|
assert!(exe.contains("got 10"), "{exe}");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dll_error_then_invalid_exe_image_names_both_failures() {
|
||||||
|
let invalid = SyntheticPe {
|
||||||
|
raw_past_eof: true,
|
||||||
|
..SyntheticPe::default()
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
assert!(dll_layout_invalid(&invalid).is_some());
|
||||||
|
let error = select_dll_layout(|| Err(pipeline_err("dll")), || Ok(invalid))
|
||||||
|
.expect_err("EXE image is not a usable fallback");
|
||||||
|
match error {
|
||||||
|
UnpackError::PipelineFallbackFailed { dll, exe } => {
|
||||||
|
let dll = dll.to_string();
|
||||||
|
let exe = exe.to_string();
|
||||||
|
assert!(dll.contains("need at least 1"), "{dll}");
|
||||||
|
assert!(exe.contains("structurally invalid"), "{exe}");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unmapped_import_and_bad_metadata_are_fatal() {
|
||||||
|
let imports = SyntheticPe {
|
||||||
|
import_unmapped: true,
|
||||||
|
import_name: Some("unused"),
|
||||||
|
..SyntheticPe::default()
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
assert!(dll_layout_invalid(&imports).is_some());
|
||||||
|
|
||||||
|
let metadata = SyntheticPe {
|
||||||
|
managed: true,
|
||||||
|
reloc: false,
|
||||||
|
metadata_sig: b"XXXX",
|
||||||
|
..SyntheticPe::default()
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
assert!(dll_layout_invalid(&metadata).is_some());
|
||||||
|
|
||||||
|
let readable = SyntheticPe {
|
||||||
|
import_name: Some("kernel32.dll"),
|
||||||
|
..SyntheticPe::default()
|
||||||
|
}
|
||||||
|
.build();
|
||||||
|
assert!(
|
||||||
|
dll_layout_invalid(&readable).is_none(),
|
||||||
|
"{:?}",
|
||||||
|
integrity::check(&readable).issues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,20 +19,6 @@
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
/// Worker-thread cap. `SENBEI_THREADS` overrides it (`1` forces the sequential
|
|
||||||
/// path); otherwise the host's available parallelism; otherwise 1.
|
|
||||||
pub fn thread_cap() -> usize {
|
|
||||||
if let Ok(v) = std::env::var("SENBEI_THREADS")
|
|
||||||
&& let Ok(n) = v.trim().parse::<usize>()
|
|
||||||
&& n >= 1
|
|
||||||
{
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
std::thread::available_parallelism()
|
|
||||||
.map(|n| n.get())
|
|
||||||
.unwrap_or(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run `f(i, span_base, span)` for every block `i`, fanning out across worker
|
/// Run `f(i, span_base, span)` for every block `i`, fanning out across worker
|
||||||
/// threads when the spans are disjoint and worthwhile, else sequentially.
|
/// threads when the spans are disjoint and worthwhile, else sequentially.
|
||||||
///
|
///
|
||||||
@@ -102,7 +88,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cap = thread_cap();
|
let cap = crate::thread_cap();
|
||||||
let per = min_per_thread.max(1);
|
let per = min_per_thread.max(1);
|
||||||
let workers = if cap > 1 && n >= per.saturating_mul(2) {
|
let workers = if cap > 1 && n >= per.saturating_mul(2) {
|
||||||
cap.min(n / per)
|
cap.min(n / per)
|
||||||
@@ -7,11 +7,18 @@ description = "Filesystem, scanning, logging, and CLI orchestration for Senbei"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
senbei-crypto.workspace = true
|
||||||
indicatif.workspace = true
|
indicatif.workspace = true
|
||||||
|
memmap2.workspace = true
|
||||||
owo-colors.workspace = true
|
owo-colors.workspace = true
|
||||||
senbei-metadata.workspace = true
|
senbei-engine.workspace = true
|
||||||
|
senbei-elf.workspace = true
|
||||||
senbei-pe.workspace = true
|
senbei-pe.workspace = true
|
||||||
|
senbei-metadata.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
walkdir.workspace = true
|
walkdir.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows.workspace = true
|
windows.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
use senbei_engine::android::ResourceLimits;
|
||||||
|
use zip::ZipArchive;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct ArchiveInfo {
|
||||||
|
entries: usize,
|
||||||
|
directory_start: u64,
|
||||||
|
directory_bytes: u64,
|
||||||
|
end_record: u64,
|
||||||
|
zip64_record: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ZIP library may retry an older end record when metadata is malformed.
|
||||||
|
/// During index construction, permit only the end records we preflighted and
|
||||||
|
/// keep central-header reads inside their declared region. Once indexing ends,
|
||||||
|
/// normal entry reads regain unrestricted seeking on the same file handle.
|
||||||
|
pub(super) struct ArchiveReader {
|
||||||
|
file: File,
|
||||||
|
info: ArchiveInfo,
|
||||||
|
indexing: Arc<AtomicBool>,
|
||||||
|
directory_started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_metadata(message: &'static str) -> std::io::Error {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidData, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for ArchiveReader {
|
||||||
|
fn seek(&mut self, from: SeekFrom) -> std::io::Result<u64> {
|
||||||
|
let position = self.file.seek(from)?;
|
||||||
|
if !self.indexing.load(Ordering::Relaxed) {
|
||||||
|
return Ok(position);
|
||||||
|
}
|
||||||
|
if matches!(from, SeekFrom::Start(_)) {
|
||||||
|
let mut signature = [0_u8; 4];
|
||||||
|
let read = self.file.read(&mut signature)?;
|
||||||
|
self.file.seek(SeekFrom::Start(position))?;
|
||||||
|
if read == 4
|
||||||
|
&& ((signature == *b"PK\x05\x06" && position != self.info.end_record)
|
||||||
|
|| (signature == *b"PK\x06\x06" && Some(position) != self.info.zip64_record))
|
||||||
|
{
|
||||||
|
return Err(invalid_metadata(
|
||||||
|
"ZIP parser attempted an unverified end record",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if position == self.info.directory_start {
|
||||||
|
self.directory_started = true;
|
||||||
|
}
|
||||||
|
Ok(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for ArchiveReader {
|
||||||
|
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
if buffer.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
if !self.indexing.load(Ordering::Relaxed) || !self.directory_started {
|
||||||
|
return self.file.read(buffer);
|
||||||
|
}
|
||||||
|
let position = self.file.stream_position()?;
|
||||||
|
let directory_end = self.info.directory_start + self.info.directory_bytes;
|
||||||
|
if position >= self.info.directory_start && position < directory_end {
|
||||||
|
let count = (directory_end - position).min(buffer.len() as u64) as usize;
|
||||||
|
return self.file.read(&mut buffer[..count]);
|
||||||
|
}
|
||||||
|
let trailer = self.info.zip64_record.unwrap_or(self.info.end_record);
|
||||||
|
if position >= trailer {
|
||||||
|
return self.file.read(buffer);
|
||||||
|
}
|
||||||
|
Err(invalid_metadata(
|
||||||
|
"ZIP indexing crossed the verified metadata region",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cumulative extraction work for a run, including nested APKs. The budget is
|
||||||
|
/// charged for actual bytes read, even if a later write or restore fails.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct ArchiveBudget {
|
||||||
|
max_entry_bytes: u64,
|
||||||
|
max_total_bytes: u64,
|
||||||
|
max_entries: usize,
|
||||||
|
bytes: u64,
|
||||||
|
entries: usize,
|
||||||
|
archives: HashMap<PathBuf, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ArchiveBudget {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::from_limits(&ResourceLimits::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArchiveBudget {
|
||||||
|
pub(super) fn from_limits(limits: &ResourceLimits) -> Self {
|
||||||
|
Self::new(
|
||||||
|
limits.max_entry_bytes,
|
||||||
|
limits.max_total_bytes,
|
||||||
|
limits.max_entries,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(max_entry_bytes: u64, max_total_bytes: u64, max_entries: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
max_entry_bytes,
|
||||||
|
max_total_bytes,
|
||||||
|
max_entries,
|
||||||
|
bytes: 0,
|
||||||
|
entries: 0,
|
||||||
|
archives: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn open_archive(&mut self, path: &Path) -> Result<ZipArchive<ArchiveReader>> {
|
||||||
|
let mut file = File::open(path).with_context(|| format!("open `{}`", path.display()))?;
|
||||||
|
let info = inspect_archive(
|
||||||
|
&mut file,
|
||||||
|
self.max_entries,
|
||||||
|
self.max_entry_bytes
|
||||||
|
.min(self.max_total_bytes.saturating_sub(self.bytes)),
|
||||||
|
)
|
||||||
|
.with_context(|| format!("check package `{}`", path.display()))?;
|
||||||
|
let count = info.entries;
|
||||||
|
let trailer = info.zip64_record.unwrap_or(info.end_record);
|
||||||
|
if info
|
||||||
|
.directory_start
|
||||||
|
.checked_add(info.directory_bytes)
|
||||||
|
.is_none_or(|end| end > trailer)
|
||||||
|
|| (count as u64)
|
||||||
|
.checked_mul(46)
|
||||||
|
.is_none_or(|minimum| minimum > info.directory_bytes)
|
||||||
|
{
|
||||||
|
bail!("ZIP central directory range is inconsistent with its end record");
|
||||||
|
}
|
||||||
|
let previous = self.archives.get(path).copied().unwrap_or(0);
|
||||||
|
if count > previous {
|
||||||
|
self.register_entries(count - previous)?;
|
||||||
|
}
|
||||||
|
self.archives
|
||||||
|
.insert(path.to_path_buf(), count.max(previous));
|
||||||
|
let indexing = Arc::new(AtomicBool::new(true));
|
||||||
|
let reader = ArchiveReader {
|
||||||
|
file,
|
||||||
|
info,
|
||||||
|
indexing: indexing.clone(),
|
||||||
|
directory_started: false,
|
||||||
|
};
|
||||||
|
let archive = ZipArchive::with_config(
|
||||||
|
zip::read::Config {
|
||||||
|
archive_offset: zip::read::ArchiveOffset::Known(0),
|
||||||
|
},
|
||||||
|
reader,
|
||||||
|
)
|
||||||
|
.with_context(|| format!("read package `{}`", path.display()))?;
|
||||||
|
indexing.store(false, Ordering::Relaxed);
|
||||||
|
if archive.len() > count {
|
||||||
|
bail!("package central directory contains more entries than its end record");
|
||||||
|
}
|
||||||
|
Ok(archive)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_probe(
|
||||||
|
&mut self,
|
||||||
|
input: &mut impl Read,
|
||||||
|
size: u64,
|
||||||
|
maximum: usize,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
if size > self.max_entry_bytes {
|
||||||
|
bail!(
|
||||||
|
"entry `{label}` exceeds extraction limit {}",
|
||||||
|
self.max_entry_bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let length = size.min(maximum as u64);
|
||||||
|
if length > self.max_total_bytes.saturating_sub(self.bytes) {
|
||||||
|
bail!("manifest `{label}` exceeds the remaining extraction byte limit");
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes
|
||||||
|
.try_reserve_exact(length as usize)
|
||||||
|
.context("reserve manifest probe")?;
|
||||||
|
self.copy_entry(&mut input.take(length), &mut bytes, length, label)?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn register_entries(&mut self, entries: usize) -> Result<()> {
|
||||||
|
let total = self
|
||||||
|
.entries
|
||||||
|
.checked_add(entries)
|
||||||
|
.context("archive entry count overflow")?;
|
||||||
|
if total > self.max_entries {
|
||||||
|
bail!(
|
||||||
|
"archive entry limit exceeded: {total} > {}",
|
||||||
|
self.max_entries
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.entries = total;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn copy_entry(
|
||||||
|
&mut self,
|
||||||
|
input: &mut impl Read,
|
||||||
|
output: &mut impl Write,
|
||||||
|
declared_size: u64,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let remaining = self.max_total_bytes.saturating_sub(self.bytes);
|
||||||
|
let allowance = self.max_entry_bytes.min(remaining);
|
||||||
|
if declared_size > allowance {
|
||||||
|
bail!(
|
||||||
|
"entry `{label}` declares {declared_size} bytes, exceeding extraction limit {allowance}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The advertised size is a stricter ceiling when it is smaller than
|
||||||
|
// the budget. A dishonest header must not buy extra decompression work.
|
||||||
|
let limit = declared_size;
|
||||||
|
let mut buffer = [0_u8; 64 * 1024];
|
||||||
|
let mut written = 0_u64;
|
||||||
|
loop {
|
||||||
|
// Read at most one byte beyond the allowance to detect a dishonest
|
||||||
|
// size header without decompressing the rest of the entry.
|
||||||
|
let read_limit = (limit - written).saturating_add(1).min(buffer.len() as u64) as usize;
|
||||||
|
let read = input.read(&mut buffer[..read_limit])?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let next = written
|
||||||
|
.checked_add(read as u64)
|
||||||
|
.context("decompressed entry size overflow")?;
|
||||||
|
if next > limit {
|
||||||
|
bail!("entry `{label}` exceeds extraction limit {limit} while decompressing");
|
||||||
|
}
|
||||||
|
self.bytes = self
|
||||||
|
.bytes
|
||||||
|
.checked_add(read as u64)
|
||||||
|
.context("extraction byte count overflow")?;
|
||||||
|
output.write_all(&buffer[..read])?;
|
||||||
|
written = next;
|
||||||
|
}
|
||||||
|
if written != declared_size {
|
||||||
|
bail!("entry `{label}` decompressed to {written} bytes, expected {declared_size}");
|
||||||
|
}
|
||||||
|
Ok(written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn word(bytes: &[u8], offset: usize) -> u16 {
|
||||||
|
u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dword(bytes: &[u8], offset: usize) -> u32 {
|
||||||
|
u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qword(bytes: &[u8], offset: usize) -> u64 {
|
||||||
|
u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read only the bounded end records before asking the ZIP library to allocate
|
||||||
|
/// its central-directory index. Both ordinary ZIP and ZIP64 are supported.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn preflight_archive(
|
||||||
|
file: &mut File,
|
||||||
|
max_entries: usize,
|
||||||
|
max_directory_bytes: u64,
|
||||||
|
) -> Result<usize> {
|
||||||
|
Ok(inspect_archive(file, max_entries, max_directory_bytes)?.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_archive(
|
||||||
|
file: &mut File,
|
||||||
|
max_entries: usize,
|
||||||
|
max_directory_bytes: u64,
|
||||||
|
) -> Result<ArchiveInfo> {
|
||||||
|
let length = file.seek(SeekFrom::End(0))?;
|
||||||
|
let tail_length = length.min(22 + u16::MAX as u64) as usize;
|
||||||
|
let mut tail = vec![0_u8; tail_length];
|
||||||
|
file.seek(SeekFrom::End(-(tail_length as i64)))?;
|
||||||
|
file.read_exact(&mut tail)?;
|
||||||
|
let end = (0..=tail.len().saturating_sub(22))
|
||||||
|
.rev()
|
||||||
|
.find(|&offset| {
|
||||||
|
tail.get(offset..offset + 4) == Some(b"PK\x05\x06")
|
||||||
|
&& offset + 22 <= tail.len()
|
||||||
|
&& offset + 22 + word(&tail, offset + 20) as usize == tail.len()
|
||||||
|
})
|
||||||
|
.context("ZIP end record is missing or truncated")?;
|
||||||
|
let record = &tail[end..end + 22];
|
||||||
|
if word(record, 4) != 0 || word(record, 6) != 0 {
|
||||||
|
bail!("multi-disk ZIP packages are unsupported");
|
||||||
|
}
|
||||||
|
let end_record = length - tail_length as u64 + end as u64;
|
||||||
|
let mut zip64_record = None;
|
||||||
|
let mut directory_start = u64::from(dword(record, 16));
|
||||||
|
let mut entries = u64::from(word(record, 10));
|
||||||
|
let mut directory_bytes = u64::from(dword(record, 12));
|
||||||
|
if entries == u16::MAX as u64
|
||||||
|
|| directory_bytes == u32::MAX as u64
|
||||||
|
|| dword(record, 16) == u32::MAX
|
||||||
|
{
|
||||||
|
let absolute_end = length - tail_length as u64 + end as u64;
|
||||||
|
let locator_offset = absolute_end
|
||||||
|
.checked_sub(20)
|
||||||
|
.context("ZIP64 locator is missing")?;
|
||||||
|
file.seek(SeekFrom::Start(locator_offset))?;
|
||||||
|
let mut locator = [0_u8; 20];
|
||||||
|
file.read_exact(&mut locator)?;
|
||||||
|
if &locator[..4] != b"PK\x06\x07" || dword(&locator, 4) != 0 || dword(&locator, 16) != 1 {
|
||||||
|
bail!("invalid ZIP64 locator");
|
||||||
|
}
|
||||||
|
let record_offset = qword(&locator, 8);
|
||||||
|
if record_offset
|
||||||
|
.checked_add(56)
|
||||||
|
.is_none_or(|end| end > locator_offset)
|
||||||
|
{
|
||||||
|
bail!("ZIP64 end record is outside the package");
|
||||||
|
}
|
||||||
|
file.seek(SeekFrom::Start(record_offset))?;
|
||||||
|
let mut record = [0_u8; 56];
|
||||||
|
file.read_exact(&mut record)?;
|
||||||
|
if &record[..4] != b"PK\x06\x06"
|
||||||
|
|| qword(&record, 4) < 44
|
||||||
|
|| record_offset
|
||||||
|
.checked_add(12)
|
||||||
|
.and_then(|start| start.checked_add(qword(&record, 4)))
|
||||||
|
.is_none_or(|end| end > locator_offset)
|
||||||
|
|| dword(&record, 16) != 0
|
||||||
|
|| dword(&record, 20) != 0
|
||||||
|
|| qword(&record, 24) != qword(&record, 32)
|
||||||
|
{
|
||||||
|
bail!("invalid ZIP64 end record");
|
||||||
|
}
|
||||||
|
if qword(&record, 4) > max_directory_bytes {
|
||||||
|
bail!("ZIP64 metadata exceeds the directory memory limit");
|
||||||
|
}
|
||||||
|
zip64_record = Some(record_offset);
|
||||||
|
directory_start = qword(&record, 48);
|
||||||
|
entries = qword(&record, 32);
|
||||||
|
directory_bytes = qword(&record, 40);
|
||||||
|
} else if word(record, 8) != word(record, 10) {
|
||||||
|
bail!("inconsistent ZIP entry counts");
|
||||||
|
}
|
||||||
|
if entries > max_entries as u64 {
|
||||||
|
bail!("archive entry limit exceeded: {entries} > {max_entries}");
|
||||||
|
}
|
||||||
|
if directory_bytes > max_directory_bytes {
|
||||||
|
bail!("ZIP directory size {directory_bytes} exceeds memory limit {max_directory_bytes}");
|
||||||
|
}
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
|
Ok(ArchiveInfo {
|
||||||
|
entries: usize::try_from(entries).context("ZIP entry count exceeds address space")?,
|
||||||
|
directory_start,
|
||||||
|
directory_bytes,
|
||||||
|
end_record,
|
||||||
|
zip64_record,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entry_and_aggregate_limits_allow_exact_boundaries() {
|
||||||
|
let mut budget = ArchiveBudget::new(4, 6, 3);
|
||||||
|
budget.register_entries(3).unwrap();
|
||||||
|
assert!(budget.register_entries(1).is_err());
|
||||||
|
let mut output = Vec::new();
|
||||||
|
budget
|
||||||
|
.copy_entry(&mut Cursor::new(b"abcd"), &mut output, 4, "first")
|
||||||
|
.unwrap();
|
||||||
|
budget
|
||||||
|
.copy_entry(&mut Cursor::new(b"ef"), &mut output, 2, "second")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(output, b"abcdef");
|
||||||
|
assert!(
|
||||||
|
budget
|
||||||
|
.copy_entry(&mut Cursor::new(b"g"), &mut output, 1, "third")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(output, b"abcdef");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declared_and_actual_sizes_are_both_limited() {
|
||||||
|
let mut budget = ArchiveBudget::new(4, 10, 10);
|
||||||
|
let mut output = Vec::new();
|
||||||
|
assert!(
|
||||||
|
budget
|
||||||
|
.copy_entry(&mut Cursor::new(b"abcde"), &mut output, 5, "large header")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(output.is_empty());
|
||||||
|
assert!(
|
||||||
|
budget
|
||||||
|
.copy_entry(&mut Cursor::new(b"abcde"), &mut output, 4, "lying header")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(output.is_empty());
|
||||||
|
assert!(
|
||||||
|
budget
|
||||||
|
.copy_entry(&mut Cursor::new(b"abc"), &mut output, 4, "short entry")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_writes_still_consume_the_read_budget() {
|
||||||
|
struct FailingWriter;
|
||||||
|
impl Write for FailingWriter {
|
||||||
|
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||||
|
Err(std::io::Error::other("blocked"))
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut budget = ArchiveBudget::new(4, 4, 10);
|
||||||
|
assert!(
|
||||||
|
budget
|
||||||
|
.copy_entry(&mut Cursor::new(b"abcd"), &mut FailingWriter, 4, "first")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
budget
|
||||||
|
.copy_entry(&mut Cursor::new(b"x"), &mut Vec::new(), 1, "retry")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end_record(entries: u16, directory_bytes: u32) -> [u8; 22] {
|
||||||
|
let mut record = [0_u8; 22];
|
||||||
|
record[..4].copy_from_slice(b"PK\x05\x06");
|
||||||
|
record[8..10].copy_from_slice(&entries.to_le_bytes());
|
||||||
|
record[10..12].copy_from_slice(&entries.to_le_bytes());
|
||||||
|
record[12..16].copy_from_slice(&directory_bytes.to_le_bytes());
|
||||||
|
record
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preflight_rejects_large_counts_and_directory_before_indexing() {
|
||||||
|
let mut file = tempfile::tempfile().unwrap();
|
||||||
|
file.write_all(&end_record(50, 1000)).unwrap();
|
||||||
|
assert!(preflight_archive(&mut file, 49, 1000).is_err());
|
||||||
|
assert!(preflight_archive(&mut file, 50, 999).is_err());
|
||||||
|
assert_eq!(preflight_archive(&mut file, 50, 1000).unwrap(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preflight_bounds_zip64_counts() {
|
||||||
|
let mut bytes = [0_u8; 76];
|
||||||
|
bytes[..4].copy_from_slice(b"PK\x06\x06");
|
||||||
|
bytes[4..12].copy_from_slice(&44_u64.to_le_bytes());
|
||||||
|
bytes[24..32].copy_from_slice(&100_001_u64.to_le_bytes());
|
||||||
|
bytes[32..40].copy_from_slice(&100_001_u64.to_le_bytes());
|
||||||
|
bytes[56..60].copy_from_slice(b"PK\x06\x07");
|
||||||
|
bytes[72..76].copy_from_slice(&1_u32.to_le_bytes());
|
||||||
|
let mut file = tempfile::tempfile().unwrap();
|
||||||
|
file.write_all(&bytes).unwrap();
|
||||||
|
file.write_all(&end_record(u16::MAX, u32::MAX)).unwrap();
|
||||||
|
assert!(preflight_archive(&mut file, 100_000, 1024).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
preflight_archive(&mut file, 100_001, 1024).unwrap(),
|
||||||
|
100_001
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reopening_an_archive_does_not_charge_its_entries_twice() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let first = temp.path().join("first.apk");
|
||||||
|
let second = temp.path().join("second.apk");
|
||||||
|
for path in [&first, &second] {
|
||||||
|
let mut writer = zip::ZipWriter::new(File::create(path).unwrap());
|
||||||
|
writer
|
||||||
|
.start_file("lib/test.so", zip::write::SimpleFileOptions::default())
|
||||||
|
.unwrap();
|
||||||
|
writer.write_all(b"payload").unwrap();
|
||||||
|
writer.finish().unwrap();
|
||||||
|
}
|
||||||
|
let mut budget = ArchiveBudget::new(1024, 4096, 1);
|
||||||
|
assert_eq!(budget.open_archive(&first).unwrap().len(), 1);
|
||||||
|
assert_eq!(budget.open_archive(&first).unwrap().len(), 1);
|
||||||
|
assert!(budget.open_archive(&second).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_cannot_retry_an_unverified_older_end_record() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("misleading.apk");
|
||||||
|
let mut writer = zip::ZipWriter::new(File::create(&path).unwrap());
|
||||||
|
for name in ["one.so", "two.so"] {
|
||||||
|
writer
|
||||||
|
.start_file(name, zip::write::SimpleFileOptions::default())
|
||||||
|
.unwrap();
|
||||||
|
writer.write_all(b"payload").unwrap();
|
||||||
|
}
|
||||||
|
let mut file = writer.finish().unwrap();
|
||||||
|
// An invalid last record advertises a small archive. Without guarding
|
||||||
|
// fallback, the ZIP parser retries the older, larger directory.
|
||||||
|
file.seek(SeekFrom::End(0)).unwrap();
|
||||||
|
file.write_all(&end_record(1, 46)).unwrap();
|
||||||
|
drop(file);
|
||||||
|
let mut budget = ArchiveBudget::new(1024, 4096, 1);
|
||||||
|
assert!(budget.open_archive(&path).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_indexing_respects_the_remaining_shared_budget() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("app.apk");
|
||||||
|
let mut writer = zip::ZipWriter::new(File::create(&path).unwrap());
|
||||||
|
writer
|
||||||
|
.start_file("lib/test.so", zip::write::SimpleFileOptions::default())
|
||||||
|
.unwrap();
|
||||||
|
writer.write_all(b"payload").unwrap();
|
||||||
|
writer.finish().unwrap();
|
||||||
|
let mut budget = ArchiveBudget::new(1024, 1024, 10);
|
||||||
|
budget
|
||||||
|
.copy_entry(
|
||||||
|
&mut Cursor::new(vec![0; 1024]),
|
||||||
|
&mut std::io::sink(),
|
||||||
|
1024,
|
||||||
|
"earlier",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(budget.open_archive(&path).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_probe_shares_the_extraction_budget() {
|
||||||
|
let mut budget = ArchiveBudget::new(10, 6, 10);
|
||||||
|
assert_eq!(
|
||||||
|
budget
|
||||||
|
.read_probe(&mut Cursor::new(b"abcdefghi"), 9, 4, "manifest")
|
||||||
|
.unwrap(),
|
||||||
|
b"abcd"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
budget
|
||||||
|
.copy_entry(&mut Cursor::new(b"xyz"), &mut Vec::new(), 3, "library")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
budget
|
||||||
|
.read_probe(&mut Cursor::new(b"abc"), 3, 3, "next manifest")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preflight_rejects_truncated_records() {
|
||||||
|
for bytes in [&b""[..], &b"PK\x05\x06"[..], &b"not an archive"[..]] {
|
||||||
|
let mut file = tempfile::tempfile().unwrap();
|
||||||
|
file.write_all(bytes).unwrap();
|
||||||
|
assert!(preflight_archive(&mut file, 100, 1024).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
//! Application-scoped module `0x0C` context for Android metadata recovery.
|
||||||
|
//!
|
||||||
|
//! One decoded module belongs to one application. Split APKs of that application
|
||||||
|
//! share it; unrelated games under the same scan do not. A metadata blob is
|
||||||
|
//! restored only after its application context is unambiguous, and the identity
|
||||||
|
//! of a successful restore includes that context: the same encrypted bytes can
|
||||||
|
//! decode differently under two module profiles.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Where a decoded module `0x0C` image was read from.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct ModuleSource {
|
||||||
|
/// Protected library this module was recovered from (package label or path).
|
||||||
|
pub label: String,
|
||||||
|
/// Stage-2 image name inside that library, when the restorer reported one.
|
||||||
|
pub image: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One application's collected module `0x0C` images.
|
||||||
|
///
|
||||||
|
/// Distinct byte profiles are kept. Duplicate libraries populate the same
|
||||||
|
/// context instead of replacing it, so a later game cannot win by being last.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub(crate) struct ApplicationModules {
|
||||||
|
profiles: BTreeMap<String, Vec<u8>>,
|
||||||
|
sources: Vec<ModuleSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicationModules {
|
||||||
|
pub(crate) fn record(&mut self, module: &[u8], source: ModuleSource) {
|
||||||
|
let identity = crate::android::content_identity(module);
|
||||||
|
self.profiles
|
||||||
|
.entry(identity)
|
||||||
|
.or_insert_with(|| module.to_vec());
|
||||||
|
self.sources.push(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_empty(&self) -> bool {
|
||||||
|
self.profiles.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single module image, or `None` when this application recovered none
|
||||||
|
/// or more than one distinct profile.
|
||||||
|
pub(crate) fn unambiguous_module(&self) -> Option<&[u8]> {
|
||||||
|
let mut values = self.profiles.values();
|
||||||
|
let first = values.next()?;
|
||||||
|
values.next().is_none().then_some(first.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn profile_count(&self) -> usize {
|
||||||
|
self.profiles.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sources(&self) -> &[ModuleSource] {
|
||||||
|
&self.sources
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a metadata blob cannot be paired with exactly one module `0x0C`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ContextAmbiguity {
|
||||||
|
/// No library in this application yielded a module.
|
||||||
|
Missing,
|
||||||
|
/// Libraries in this application yielded more than one distinct module.
|
||||||
|
Conflicting { profiles: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run-scoped cache of restored libraries and application module contexts.
|
||||||
|
///
|
||||||
|
/// The public package API still takes a content-identity set. This cache is
|
||||||
|
/// internal: a library hit reuses only its recovered module context, while a
|
||||||
|
/// metadata hit is keyed by recovery context as well as the encrypted bytes.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct AndroidRunCache {
|
||||||
|
pub(super) limits: senbei_engine::android::ResourceLimits,
|
||||||
|
pub(super) archive: super::archive_limits::ArchiveBudget,
|
||||||
|
apps: HashMap<AppKey, ApplicationModules>,
|
||||||
|
/// Successfully restored protected libraries, keyed by encrypted content.
|
||||||
|
libraries: HashMap<String, CachedLibrary>,
|
||||||
|
/// Successfully handled metadata, keyed by encrypted content and context.
|
||||||
|
metadata: HashMap<MetadataCacheKey, CachedMetadata>,
|
||||||
|
/// Content identities already restored before this cache existed (the public
|
||||||
|
/// `HashSet` contract). They skip a repeat of the same bytes but carry no
|
||||||
|
/// module image, so they never become another application's profile.
|
||||||
|
prior_success: std::collections::HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
struct MetadataCacheKey {
|
||||||
|
content: String,
|
||||||
|
context: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct CachedLibrary {
|
||||||
|
/// Module `0x0C` from a library whose restore, including any embedded
|
||||||
|
/// metadata write, succeeded. The restored image is not kept: a duplicate
|
||||||
|
/// writes nothing.
|
||||||
|
pub method_index_module: Option<Vec<u8>>,
|
||||||
|
pub module_image: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) enum CachedMetadata {
|
||||||
|
/// Successfully written package metadata. A duplicate does not write again.
|
||||||
|
Restored { remapped: usize },
|
||||||
|
/// The blob was already canonical under this context.
|
||||||
|
Unchanged,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidRunCache {
|
||||||
|
pub(super) fn with_limits(limits: &senbei_engine::android::ResourceLimits) -> Self {
|
||||||
|
Self {
|
||||||
|
limits: *limits,
|
||||||
|
archive: super::archive_limits::ArchiveBudget::from_limits(limits),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn modules_mut(&mut self, key: &AppKey) -> &mut ApplicationModules {
|
||||||
|
self.apps.entry(key.clone()).or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn modules(&self, key: &AppKey) -> Option<&ApplicationModules> {
|
||||||
|
self.apps.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn library(&self, content: &str) -> Option<&CachedLibrary> {
|
||||||
|
self.libraries.get(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert_prior_success(&mut self, content: String) {
|
||||||
|
self.prior_success.insert(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_prior_success(&self, content: &str) -> bool {
|
||||||
|
self.prior_success.contains(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content identities safe to publish back to the public successful-only set:
|
||||||
|
/// prior successes plus libraries and metadata handled successfully here.
|
||||||
|
/// Metadata identities are included because the public set still dedups a
|
||||||
|
/// repeated blob inside one package call; cross-application recovery uses
|
||||||
|
/// the context-keyed cache instead of this set.
|
||||||
|
pub(crate) fn successful_content_identities(&self) -> Vec<String> {
|
||||||
|
let mut identities: Vec<String> = self.prior_success.iter().cloned().collect();
|
||||||
|
identities.extend(self.libraries.keys().cloned());
|
||||||
|
identities.extend(self.metadata.keys().map(|key| key.content.clone()));
|
||||||
|
identities.sort();
|
||||||
|
identities.dedup();
|
||||||
|
identities
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert_library(&mut self, content: String, cached: CachedLibrary) {
|
||||||
|
self.libraries.entry(content).or_insert(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn metadata(&self, content: &str, context: &str) -> Option<&CachedMetadata> {
|
||||||
|
self.metadata.get(&MetadataCacheKey {
|
||||||
|
content: content.to_owned(),
|
||||||
|
context: context.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn insert_metadata(
|
||||||
|
&mut self,
|
||||||
|
content: String,
|
||||||
|
context: String,
|
||||||
|
cached: CachedMetadata,
|
||||||
|
) {
|
||||||
|
self.metadata
|
||||||
|
.entry(MetadataCacheKey { content, context })
|
||||||
|
.or_insert(cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity of one Android application inside a folder run or one package set.
|
||||||
|
///
|
||||||
|
/// Constructed only from manifest package name **and** version, or the
|
||||||
|
/// conservative extracted `lib/<abi>/` + `assets/` boundary. The same package
|
||||||
|
/// name in a different version, or in a different bundle / loose tree, is a
|
||||||
|
/// different application. Scan-root names and ZIP entry names are never used
|
||||||
|
/// as a guess.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) enum AppKey {
|
||||||
|
/// Manifest `package` plus `versionCode` (or `versionName` when the code is
|
||||||
|
/// absent). `package_set` is the directory of the package file, or the
|
||||||
|
/// bundle file itself, so the same name and version elsewhere do not merge.
|
||||||
|
Manifest {
|
||||||
|
package: String,
|
||||||
|
version: String,
|
||||||
|
package_set: PathBuf,
|
||||||
|
},
|
||||||
|
/// Parent of a real `lib/<abi>/` or `assets/` boundary.
|
||||||
|
Extracted(PathBuf),
|
||||||
|
/// No manifest and no extracted boundary. Isolated so it cannot see another
|
||||||
|
/// application's modules. Not shared with any other unidentifiable input.
|
||||||
|
Unidentifiable { source: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A metadata or library input assigned to one application context.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct ScopedInput {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub key: AppKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group loose Android targets by application without crossing unrelated games.
|
||||||
|
///
|
||||||
|
/// A `lib/<abi>/` library and an `assets/` metadata blob share a context only
|
||||||
|
/// when both sit directly under the same application root. Anything else is
|
||||||
|
/// isolated. Order of `libraries` and `metadata` does not matter.
|
||||||
|
pub(crate) fn group_loose_inputs(libraries: &[PathBuf], metadata: &[PathBuf]) -> Vec<ScopedInput> {
|
||||||
|
let mut scoped = Vec::with_capacity(libraries.len() + metadata.len());
|
||||||
|
for path in libraries {
|
||||||
|
scoped.push(ScopedInput {
|
||||||
|
key: loose_library_key(path),
|
||||||
|
path: path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for path in metadata {
|
||||||
|
scoped.push(ScopedInput {
|
||||||
|
key: loose_metadata_key(path),
|
||||||
|
path: path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scoped.sort_by(|left, right| left.path.cmp(&right.path));
|
||||||
|
scoped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loose_library_key(path: &Path) -> AppKey {
|
||||||
|
extracted_app_root(path).map_or_else(
|
||||||
|
|| AppKey::Unidentifiable {
|
||||||
|
source: path.to_string_lossy().into_owned(),
|
||||||
|
},
|
||||||
|
AppKey::Extracted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loose_metadata_key(path: &Path) -> AppKey {
|
||||||
|
// `assets/bin/Data/Managed/Metadata/global-metadata.dat` and the shorter
|
||||||
|
// `assets/bin/Data/Managed/global-metadata.dat` both end at `assets/`.
|
||||||
|
assets_app_root(path).map_or_else(
|
||||||
|
|| AppKey::Unidentifiable {
|
||||||
|
source: path.to_string_lossy().into_owned(),
|
||||||
|
},
|
||||||
|
AppKey::Extracted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application root when `path` is `<root>/lib/<abi>/<library>.so`.
|
||||||
|
fn extracted_app_root(path: &Path) -> Option<PathBuf> {
|
||||||
|
let mut components: Vec<Component<'_>> = path.components().collect();
|
||||||
|
let file = components.pop()?;
|
||||||
|
let abi = components.pop()?;
|
||||||
|
let lib = components.pop()?;
|
||||||
|
if !component_is(file, path.file_name()?.to_str()?) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Compare components, not `Path::parent` display. A path joined from mixed
|
||||||
|
// separators still yields `lib` and `<abi>` as two normal components.
|
||||||
|
if !component_is(lib, "lib") || !matches!(abi, Component::Normal(_)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let root: PathBuf = components.into_iter().collect();
|
||||||
|
Some(if root.as_os_str().is_empty() {
|
||||||
|
PathBuf::from(".")
|
||||||
|
} else {
|
||||||
|
root
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application root when `path` is `<root>/assets/**/global-metadata.dat`.
|
||||||
|
fn assets_app_root(path: &Path) -> Option<PathBuf> {
|
||||||
|
let mut components: Vec<Component<'_>> = path.components().collect();
|
||||||
|
let name = components.pop()?;
|
||||||
|
if !component_is(name, crate::METADATA_FILE_NAME) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let assets_at = components
|
||||||
|
.iter()
|
||||||
|
.rposition(|component| component_is(*component, "assets"))?;
|
||||||
|
// `assets/global-metadata.dat` and `assets/**/global-metadata.dat` both
|
||||||
|
// belong to the directory that contains `assets`.
|
||||||
|
let root: PathBuf = components[..assets_at].iter().collect();
|
||||||
|
Some(if root.as_os_str().is_empty() {
|
||||||
|
PathBuf::from(".")
|
||||||
|
} else {
|
||||||
|
root
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_eq(path: &Path, expected: &str) -> bool {
|
||||||
|
// The final component must be exactly `expected`. `file_name` on a directory
|
||||||
|
// path is that component; requiring the whole path to equal it would reject
|
||||||
|
// every application root that is not a single path segment.
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_is(component: Component<'_>, expected: &str) -> bool {
|
||||||
|
match component {
|
||||||
|
Component::Normal(name) => name
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case(expected)),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One ZIP whose central directory was listed while naming an application.
|
||||||
|
///
|
||||||
|
/// `reopen_key` is the on-disk package (or extracted nested APK) to open again
|
||||||
|
/// when entries are restored. The resource budget charges this archive's
|
||||||
|
/// physical entry count on the first inventory pass only; reopening it for
|
||||||
|
/// entry extraction is not a second package.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct InventoriedArchive {
|
||||||
|
pub reopen_key: PathBuf,
|
||||||
|
/// Central-directory index of `AndroidManifest.xml`, when one was present.
|
||||||
|
/// `None` means the manifest entry was absent and nothing was read.
|
||||||
|
pub manifest_index: Option<usize>,
|
||||||
|
/// Central-directory entry count observed by this inventory pass.
|
||||||
|
pub entry_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inventory of one package (or nested split) that can name its application.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PackageInventory {
|
||||||
|
pub key: AppKey,
|
||||||
|
/// True when `key` came from a parsed manifest `package` and version.
|
||||||
|
/// Distinguishes a named package from an isolated one for a later report.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub manifest_named: bool,
|
||||||
|
/// Reopen handle for this archive. Entry extraction uses the already-open
|
||||||
|
/// handle; this records the central directory that must be charged once.
|
||||||
|
pub archive: InventoriedArchive,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manifest-first application identity for one ZIP package.
|
||||||
|
///
|
||||||
|
/// Reads only a bounded root `AndroidManifest.xml` entry, supporting plain and
|
||||||
|
/// compiled XML. A missing, truncated, or unrecognized manifest isolates the
|
||||||
|
/// package instead of guessing from the ZIP filename. `package_set` is the
|
||||||
|
/// bundle or directory that owns this set of
|
||||||
|
/// splits — the same package name outside that boundary is a different app.
|
||||||
|
///
|
||||||
|
/// The returned [`InventoriedArchive`] is the reopen handle. Callers restore
|
||||||
|
/// entries from `archive.reopen_key` and must not treat that second open as a
|
||||||
|
/// newly discovered package.
|
||||||
|
pub(crate) fn inventory_manifest_package<R: std::io::Read + std::io::Seek>(
|
||||||
|
archive: &mut zip::ZipArchive<R>,
|
||||||
|
reopen_key: &Path,
|
||||||
|
package_set: &Path,
|
||||||
|
isolate_as: &str,
|
||||||
|
budget: &mut super::archive_limits::ArchiveBudget,
|
||||||
|
) -> Result<PackageInventory> {
|
||||||
|
let entry_count = archive.len();
|
||||||
|
let mut manifest_index = None;
|
||||||
|
for index in 0..entry_count {
|
||||||
|
let entry = archive.by_index(index)?;
|
||||||
|
if entry.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = entry.enclosed_name() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Root manifest only. Nested paths and every other entry stay unread.
|
||||||
|
if name.components().count() == 1 && component_eq(&name, "AndroidManifest.xml") {
|
||||||
|
manifest_index = Some(index);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(index) = manifest_index else {
|
||||||
|
return Ok(PackageInventory {
|
||||||
|
key: AppKey::Unidentifiable {
|
||||||
|
source: isolate_as.to_owned(),
|
||||||
|
},
|
||||||
|
manifest_named: false,
|
||||||
|
archive: InventoriedArchive {
|
||||||
|
reopen_key: reopen_key.to_path_buf(),
|
||||||
|
manifest_index: None,
|
||||||
|
entry_count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let mut entry = archive.by_index(index)?;
|
||||||
|
// The package and version attributes are in the root `<manifest` start tag.
|
||||||
|
// Bound and charge this probe against the shared extraction budget. It
|
||||||
|
// never opens assets or other entries. A manifest larger than the bound is
|
||||||
|
// truncated and produces no identity rather than a partial guess.
|
||||||
|
const MANIFEST_PROBE: usize = 4 * 1024 * 1024;
|
||||||
|
let size = entry.size();
|
||||||
|
let buffer = budget.read_probe(&mut entry, size, MANIFEST_PROBE, "AndroidManifest.xml")?;
|
||||||
|
let truncated = size > MANIFEST_PROBE as u64;
|
||||||
|
let named = super::manifest::identity(&buffer, truncated);
|
||||||
|
Ok(PackageInventory {
|
||||||
|
key: match &named {
|
||||||
|
Some((package, version)) => AppKey::Manifest {
|
||||||
|
package: package.clone(),
|
||||||
|
version: version.clone(),
|
||||||
|
package_set: package_set.to_path_buf(),
|
||||||
|
},
|
||||||
|
None => AppKey::Unidentifiable {
|
||||||
|
source: isolate_as.to_owned(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manifest_named: named.is_some(),
|
||||||
|
archive: InventoriedArchive {
|
||||||
|
reopen_key: reopen_key.to_path_buf(),
|
||||||
|
manifest_index: Some(index),
|
||||||
|
entry_count,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovery-context identity mixed into metadata dedup.
|
||||||
|
///
|
||||||
|
/// Same encrypted bytes under two modules (or under "no module" versus a module)
|
||||||
|
/// must not collapse to one cached output.
|
||||||
|
pub(crate) fn metadata_context_identity(module: Option<&[u8]>) -> String {
|
||||||
|
match module {
|
||||||
|
Some(module) => format!("module:{}", crate::android::content_identity(module)),
|
||||||
|
None => "module:none".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the module a metadata blob may use, refusing an ambiguous context
|
||||||
|
/// before any output is written.
|
||||||
|
pub(crate) fn binding_module(
|
||||||
|
modules: Option<&ApplicationModules>,
|
||||||
|
) -> std::result::Result<Option<&[u8]>, ContextAmbiguity> {
|
||||||
|
let Some(modules) = modules else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if modules.is_empty() {
|
||||||
|
return Err(ContextAmbiguity::Missing);
|
||||||
|
}
|
||||||
|
match modules.unambiguous_module() {
|
||||||
|
Some(module) => Ok(Some(module)),
|
||||||
|
None => Err(ContextAmbiguity::Conflicting {
|
||||||
|
profiles: modules.profile_count(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provenance recorded on a metadata outcome: every library that contributed
|
||||||
|
/// the module actually used, or the reason no module was bound.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub(crate) struct MetadataProvenance {
|
||||||
|
pub sources: Vec<ModuleSource>,
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn provenance_for(
|
||||||
|
modules: Option<&ApplicationModules>,
|
||||||
|
ambiguity: Option<&ContextAmbiguity>,
|
||||||
|
) -> MetadataProvenance {
|
||||||
|
match ambiguity {
|
||||||
|
Some(ContextAmbiguity::Missing) => MetadataProvenance {
|
||||||
|
sources: modules
|
||||||
|
.map(|modules| modules.sources().to_vec())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
note: Some("no module 0x0C in this application".to_owned()),
|
||||||
|
},
|
||||||
|
Some(ContextAmbiguity::Conflicting { profiles }) => MetadataProvenance {
|
||||||
|
sources: modules
|
||||||
|
.map(|modules| modules.sources().to_vec())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
note: Some(format!(
|
||||||
|
"ambiguous module 0x0C binding ({profiles} distinct profiles)"
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
None => MetadataProvenance {
|
||||||
|
sources: modules
|
||||||
|
.map(|modules| modules.sources().to_vec())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
note: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a library restore contributes to an application context.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct RestoredLibrary {
|
||||||
|
pub embedded_metadata: Option<Vec<u8>>,
|
||||||
|
pub method_index_module: Option<Vec<u8>>,
|
||||||
|
pub module_image: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Injectable library restore. Tests supply a fake so application-context
|
||||||
|
/// behaviour does not need a protected ELF fixture. Production calls the
|
||||||
|
/// stage-2 extractor.
|
||||||
|
pub(crate) trait LibraryRestorer {
|
||||||
|
fn restore(&mut self, input: &Path, dest: &Path, verbose: bool) -> Result<RestoredLibrary>;
|
||||||
|
|
||||||
|
/// Bytes that should be treated as a protected library without the ELF
|
||||||
|
/// probe. Production returns `None`; tests return the synthetic payload so
|
||||||
|
/// a package entry can reach the restorer.
|
||||||
|
fn protected_override(&mut self, _input: &Path) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production restorer: stage-2 extract, ELF rebuild, embedded metadata unwrap.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct EngineRestorer(pub(super) senbei_engine::android::ResourceLimits);
|
||||||
|
|
||||||
|
impl LibraryRestorer for EngineRestorer {
|
||||||
|
fn restore(&mut self, input: &Path, dest: &Path, verbose: bool) -> Result<RestoredLibrary> {
|
||||||
|
crate::android::restore_so_with_engine_limits(input, dest, verbose, &self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
//! Bounded identity of one Android manifest.
|
||||||
|
//!
|
||||||
|
//! The only result is `(package, version)`. Version is `android:versionCode`,
|
||||||
|
//! with `android:versionCodeMajor` prefixed when it is present and non-zero.
|
||||||
|
//! `android:versionName` is not an identity. A truncated or corrupt manifest
|
||||||
|
//! returns `None` rather than a partial guess.
|
||||||
|
//!
|
||||||
|
//! Chunk layout follows `androidfw/ResourceTypes.h`: a start element is a
|
||||||
|
//! `ResXMLTree_node` (`headerSize` is normally 16) followed by `ResXMLTree_attrExt`
|
||||||
|
//! at that header size. Attribute records begin at `headerSize + attributeStart`.
|
||||||
|
//! `Res_value::dataType` is the byte immediately before the data word.
|
||||||
|
|
||||||
|
/// `(package, version)` when the root element is `manifest` and carries a
|
||||||
|
/// package plus a version code. Truncated probes never supply a binding.
|
||||||
|
pub(super) fn identity(bytes: &[u8], truncated: bool) -> Option<(String, String)> {
|
||||||
|
if truncated {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if bytes.len() >= 8 && u16::from_le_bytes([bytes[0], bytes[1]]) == 0x0003 {
|
||||||
|
return binary_identity(bytes);
|
||||||
|
}
|
||||||
|
plain_identity(bytes, truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_STRING: u8 = 0x03;
|
||||||
|
const TYPE_INT_DEC: u8 = 0x10;
|
||||||
|
const TYPE_INT_HEX: u8 = 0x11;
|
||||||
|
const VERSION_CODE: u32 = 0x0101_021b;
|
||||||
|
const VERSION_CODE_MAJOR: u32 = 0x0101_0576;
|
||||||
|
const NO_STRING: u32 = 0xffff_ffff;
|
||||||
|
|
||||||
|
fn binary_identity(bytes: &[u8]) -> Option<(String, String)> {
|
||||||
|
if bytes.len() < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let file_size = u32::from_le_bytes(bytes.get(4..8)?.try_into().ok()?) as usize;
|
||||||
|
if file_size < 8 || file_size > bytes.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = bytes.get(..file_size)?;
|
||||||
|
let mut offset = read_u16(bytes, 2)? as usize;
|
||||||
|
if offset < 8 || offset > bytes.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut pool: Option<StringPool<'_>> = None;
|
||||||
|
let mut resource_map = Vec::new();
|
||||||
|
while offset + 8 <= bytes.len() {
|
||||||
|
let kind = read_u16(bytes, offset)?;
|
||||||
|
let header = read_u16(bytes, offset + 2)? as usize;
|
||||||
|
let size = read_u32(bytes, offset + 4)? as usize;
|
||||||
|
if header < 8 || size < header || offset.checked_add(size)? > bytes.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let chunk = bytes.get(offset..offset + size)?;
|
||||||
|
match kind {
|
||||||
|
0x0001 => pool = Some(StringPool::parse(chunk)?),
|
||||||
|
0x0180 => resource_map = resource_map_of(chunk)?,
|
||||||
|
0x0102 => return start_element_identity(chunk, pool.as_ref()?, &resource_map),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
offset += size;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_element_identity(
|
||||||
|
chunk: &[u8],
|
||||||
|
pool: &StringPool<'_>,
|
||||||
|
resource_map: &[u32],
|
||||||
|
) -> Option<(String, String)> {
|
||||||
|
let header = read_u16(chunk, 2)? as usize;
|
||||||
|
// ResXMLTree_attrExt sits headerSize bytes after the chunk start.
|
||||||
|
if header < 16 || chunk.len() < header.checked_add(20)? {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ext = header;
|
||||||
|
let name = read_u32(chunk, ext + 4)?;
|
||||||
|
if pool.get(name)?.as_str() != "manifest" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let attribute_start = read_u16(chunk, ext + 8)? as usize;
|
||||||
|
let attribute_size = read_u16(chunk, ext + 10)? as usize;
|
||||||
|
let attribute_count = read_u16(chunk, ext + 12)? as usize;
|
||||||
|
if attribute_size < 20 || attribute_start < 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let base = header.checked_add(attribute_start)?;
|
||||||
|
let bytes = attribute_count.checked_mul(attribute_size)?;
|
||||||
|
if base.checked_add(bytes)? > chunk.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut package = None;
|
||||||
|
let mut version_code = None;
|
||||||
|
let mut version_major = None;
|
||||||
|
for index in 0..attribute_count {
|
||||||
|
let at = base + index * attribute_size;
|
||||||
|
let record = chunk.get(at..at + 20)?;
|
||||||
|
let namespace = read_u32(record, 0)?;
|
||||||
|
let name = read_u32(record, 4)?;
|
||||||
|
let raw = read_u32(record, 8)?;
|
||||||
|
// Res_value: size u16, res0 u8, dataType u8, data u32.
|
||||||
|
let data_type = *record.get(15)?;
|
||||||
|
let data = read_u32(record, 16)?;
|
||||||
|
let name_text = pool.get(name)?;
|
||||||
|
let resource = resource_map.get(name as usize).copied();
|
||||||
|
// `package` is the un-namespaced attribute. A resource id is never
|
||||||
|
// used for it: `0x01010003` is `android:name`, not the package.
|
||||||
|
if namespace == NO_STRING && name_text == "package" {
|
||||||
|
package = Some(attribute_text(pool, raw, data_type, data)?);
|
||||||
|
} else if name_text == "versionCode" || resource == Some(VERSION_CODE) {
|
||||||
|
version_code = Some(version_number(pool, raw, data_type, data)?);
|
||||||
|
} else if name_text == "versionCodeMajor" || resource == Some(VERSION_CODE_MAJOR) {
|
||||||
|
version_major = Some(version_number(pool, raw, data_type, data)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish_identity(package?, version_code, version_major)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attribute_text(pool: &StringPool<'_>, raw: u32, data_type: u8, data: u32) -> Option<String> {
|
||||||
|
if data_type == TYPE_STRING {
|
||||||
|
return pool.get(data);
|
||||||
|
}
|
||||||
|
if raw != NO_STRING {
|
||||||
|
return pool.get(raw);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_number(pool: &StringPool<'_>, raw: u32, data_type: u8, data: u32) -> Option<String> {
|
||||||
|
match data_type {
|
||||||
|
TYPE_INT_DEC | TYPE_INT_HEX => Some(data.to_string()),
|
||||||
|
TYPE_STRING => normalize_version(&pool.get(data)?),
|
||||||
|
_ if raw != NO_STRING => normalize_version(&pool.get(raw)?),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_identity(
|
||||||
|
package: String,
|
||||||
|
version_code: Option<String>,
|
||||||
|
version_major: Option<String>,
|
||||||
|
) -> Option<(String, String)> {
|
||||||
|
if !is_package_token(&package) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let code = version_code?;
|
||||||
|
let version = match version_major {
|
||||||
|
Some(major) if major != "0" => format!("{major}.{code}"),
|
||||||
|
_ => code,
|
||||||
|
};
|
||||||
|
if !is_version_token(&version) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((package, version))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StringPool<'a> {
|
||||||
|
bytes: &'a [u8],
|
||||||
|
offsets: Vec<u32>,
|
||||||
|
strings_start: usize,
|
||||||
|
utf8: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StringPool<'a> {
|
||||||
|
fn parse(chunk: &'a [u8]) -> Option<Self> {
|
||||||
|
if chunk.len() < 28 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let count = read_u32(chunk, 8)? as usize;
|
||||||
|
let flags = read_u32(chunk, 16)?;
|
||||||
|
let strings_start = read_u32(chunk, 20)? as usize;
|
||||||
|
// Offsets begin after the 28-byte pool header. A count whose offset
|
||||||
|
// table does not fit is corrupt. There is no fixed cap on the count:
|
||||||
|
// the bound is that the table fits and later decoding stays within
|
||||||
|
// twice the chunk.
|
||||||
|
let table = 28_usize.checked_add(count.checked_mul(4)?)?;
|
||||||
|
if table > chunk.len() || strings_start < table || strings_start > chunk.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
offsets.try_reserve_exact(count).ok()?;
|
||||||
|
for index in 0..count {
|
||||||
|
offsets.push(read_u32(chunk, 28 + index * 4)?);
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
bytes: chunk,
|
||||||
|
offsets,
|
||||||
|
strings_start,
|
||||||
|
utf8: flags & (1 << 8) != 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, index: u32) -> Option<String> {
|
||||||
|
if index == NO_STRING {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let start = self
|
||||||
|
.strings_start
|
||||||
|
.checked_add(*self.offsets.get(index as usize)? as usize)?;
|
||||||
|
let text = if self.utf8 {
|
||||||
|
utf8_at(self.bytes, start)?.to_owned()
|
||||||
|
} else {
|
||||||
|
utf16_at(self.bytes, start)?
|
||||||
|
};
|
||||||
|
// One looked-up string cannot be larger than the pool that holds it.
|
||||||
|
if text.len() > self.bytes.len().saturating_mul(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf8_at(bytes: &[u8], mut offset: usize) -> Option<&str> {
|
||||||
|
let (_, next) = utf8_len(bytes, offset)?;
|
||||||
|
offset = next;
|
||||||
|
let (byte_len, next) = utf8_len(bytes, offset)?;
|
||||||
|
offset = next;
|
||||||
|
let end = offset.checked_add(byte_len)?;
|
||||||
|
let text = bytes.get(offset..end)?;
|
||||||
|
// The character after the bytes is a NUL terminator.
|
||||||
|
if *bytes.get(end)? != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
std::str::from_utf8(text).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf8_len(bytes: &[u8], offset: usize) -> Option<(usize, usize)> {
|
||||||
|
let first = *bytes.get(offset)?;
|
||||||
|
if first & 0x80 == 0 {
|
||||||
|
Some((first as usize, offset + 1))
|
||||||
|
} else {
|
||||||
|
let second = *bytes.get(offset + 1)? as usize;
|
||||||
|
Some((((first as usize & 0x7f) << 8) | second, offset + 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf16_at(bytes: &[u8], offset: usize) -> Option<String> {
|
||||||
|
let first = read_u16(bytes, offset)?;
|
||||||
|
let (len, data_at) = if first & 0x8000 == 0 {
|
||||||
|
(first as usize, offset + 2)
|
||||||
|
} else {
|
||||||
|
let second = read_u16(bytes, offset + 2)? as usize;
|
||||||
|
((((first as usize) & 0x7fff) << 16) | second, offset + 4)
|
||||||
|
};
|
||||||
|
let end = data_at.checked_add(len.checked_mul(2)?)?;
|
||||||
|
if read_u16(bytes, end)? != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let raw = bytes.get(data_at..end)?;
|
||||||
|
let units = raw.as_chunks::<2>().0;
|
||||||
|
// Avoid allocating a UTF-16 copy larger than the chunk that holds it.
|
||||||
|
if units.len().saturating_mul(2) > bytes.len().saturating_mul(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let owned: Vec<u16> = units.iter().map(|pair| u16::from_le_bytes(*pair)).collect();
|
||||||
|
String::from_utf16(&owned).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resource_map_of(chunk: &[u8]) -> Option<Vec<u32>> {
|
||||||
|
let body = chunk.get(8..)?;
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
ids.try_reserve_exact(body.len() / 4).ok()?;
|
||||||
|
for item in body.as_chunks::<4>().0 {
|
||||||
|
ids.push(u32::from_le_bytes(*item));
|
||||||
|
}
|
||||||
|
Some(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plain_identity(bytes: &[u8], truncated: bool) -> Option<(String, String)> {
|
||||||
|
let text = std::str::from_utf8(bytes).ok()?;
|
||||||
|
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||||
|
let rest = skip_preamble(text)?;
|
||||||
|
if !rest.starts_with("<manifest") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut cursor = &rest["<manifest".len()..];
|
||||||
|
if cursor.starts_with(|ch: char| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut package = None;
|
||||||
|
let mut version_code = None;
|
||||||
|
let mut version_major = None;
|
||||||
|
let mut closed = false;
|
||||||
|
loop {
|
||||||
|
cursor = cursor.trim_start();
|
||||||
|
if cursor.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if cursor.starts_with('>') || cursor.starts_with("/>") {
|
||||||
|
closed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if cursor.starts_with("<!--") {
|
||||||
|
let close = cursor.find("-->")?;
|
||||||
|
cursor = &cursor[close + 3..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name_end = cursor
|
||||||
|
.find(|ch: char| ch.is_whitespace() || ch == '=')
|
||||||
|
.unwrap_or(cursor.len());
|
||||||
|
let name = &cursor[..name_end];
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
cursor = cursor[name_end..].trim_start();
|
||||||
|
if !cursor.starts_with('=') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
cursor = cursor[1..].trim_start();
|
||||||
|
let quote = cursor.chars().next()?;
|
||||||
|
if quote != '"' && quote != '\'' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
cursor = &cursor[quote.len_utf8()..];
|
||||||
|
let value_end = cursor.find(quote)?;
|
||||||
|
let value = &cursor[..value_end];
|
||||||
|
cursor = &cursor[value_end + quote.len_utf8()..];
|
||||||
|
match name {
|
||||||
|
"package" => package = Some(value.to_owned()),
|
||||||
|
"android:versionCode" => version_code = normalize_version(value),
|
||||||
|
"android:versionCodeMajor" => version_major = normalize_version(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !closed || truncated {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
finish_identity(package?, version_code, version_major)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip a BOM (already removed), XML declarations, and comments. A comment
|
||||||
|
/// that contains `<manifest` is not the root.
|
||||||
|
fn skip_preamble(mut text: &str) -> Option<&str> {
|
||||||
|
loop {
|
||||||
|
text = text.trim_start();
|
||||||
|
if text.starts_with("<?") {
|
||||||
|
let close = text.find("?>")?;
|
||||||
|
text = &text[close + 2..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if text.starts_with("<!--") {
|
||||||
|
let close = text.find("-->")?;
|
||||||
|
text = &text[close + 3..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_version(value: &str) -> Option<String> {
|
||||||
|
if value.starts_with(['-', '+']) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let number = if let Some(hex) = value
|
||||||
|
.strip_prefix("0x")
|
||||||
|
.or_else(|| value.strip_prefix("0X"))
|
||||||
|
{
|
||||||
|
u32::from_str_radix(hex, 16).ok()?
|
||||||
|
} else {
|
||||||
|
value.parse::<u32>().ok()?
|
||||||
|
};
|
||||||
|
Some(number.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_package_token(value: &str) -> bool {
|
||||||
|
!value.is_empty()
|
||||||
|
&& value
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_version_token(value: &str) -> bool {
|
||||||
|
!value.is_empty()
|
||||||
|
&& value.len() <= 80
|
||||||
|
&& value
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_digit() || matches!(ch, '.' | '-' | 'x' | 'a'..='f'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
|
||||||
|
Some(u16::from_le_bytes(
|
||||||
|
bytes.get(offset..offset + 2)?.try_into().ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||||
|
Some(u32::from_le_bytes(
|
||||||
|
bytes.get(offset..offset + 4)?.try_into().ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn plain(body: &str) -> Vec<u8> {
|
||||||
|
body.as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_requires_version_code_and_skips_comments() {
|
||||||
|
let text = plain(
|
||||||
|
"<?xml version=\"1.0\"?>\n<!-- <manifest package=\"fake.comment\" android:versionCode=\"9\"/> -->\n<manifest package='example.game' android:versionName=\"same\" android:versionCode=\"12\" android:versionCodeMajor=\"3\">\n</manifest>\n",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
identity(&text, false),
|
||||||
|
Some(("example.game".to_owned(), "3.12".to_owned()))
|
||||||
|
);
|
||||||
|
let named = plain("<manifest package=\"example.game\" android:versionName=\"1\"/>");
|
||||||
|
assert_eq!(identity(&named, false), None);
|
||||||
|
let substring = plain("<notmanifest package=\"example.game\" android:versionCode=\"1\"/>");
|
||||||
|
assert_eq!(identity(&substring, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_truncated_root_is_not_an_identity() {
|
||||||
|
let cut = plain("<manifest package=\"example.game\" android:versionCode=\"4\"");
|
||||||
|
assert_eq!(identity(&cut, false), None);
|
||||||
|
assert_eq!(identity(&cut, true), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_hex_version_normalizes() {
|
||||||
|
let text = plain("<manifest package=\"example.game\" android:versionCode=\"0x10\"/>");
|
||||||
|
assert_eq!(
|
||||||
|
identity(&text, false),
|
||||||
|
Some(("example.game".to_owned(), "16".to_owned()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_typed_package_and_nonzero_major() {
|
||||||
|
// rawValue is 0xFFFFFFFF. The package exists only as a TYPE_STRING
|
||||||
|
// data index, and versionCodeMajor is a real non-zero integer.
|
||||||
|
let bytes = axml_with_data(
|
||||||
|
"manifest",
|
||||||
|
&[
|
||||||
|
("package", NO_STRING, "example.typed", TYPE_STRING, 0),
|
||||||
|
("versionCode", NO_STRING, "", TYPE_INT_DEC, 7),
|
||||||
|
("versionCodeMajor", NO_STRING, "", TYPE_INT_DEC, 2),
|
||||||
|
],
|
||||||
|
Encoding::Utf8,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
identity(&bytes, false),
|
||||||
|
Some(("example.typed".to_owned(), "2.7".to_owned()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_utf16_and_hex_version() {
|
||||||
|
let bytes = axml_with_data(
|
||||||
|
"manifest",
|
||||||
|
&[
|
||||||
|
("package", NO_STRING, "example.utf16", 0, 0),
|
||||||
|
("versionCode", NO_STRING, "", TYPE_INT_HEX, 0x20),
|
||||||
|
],
|
||||||
|
Encoding::Utf16,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
identity(&bytes, false),
|
||||||
|
Some(("example.utf16".to_owned(), "32".to_owned()))
|
||||||
|
);
|
||||||
|
let _ = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_rejects_other_root_and_android_name_resource() {
|
||||||
|
let other = axml(
|
||||||
|
"application",
|
||||||
|
&[
|
||||||
|
attr(NO_STRING, "package", "example.nope", 0, ""),
|
||||||
|
attr(NO_STRING, "versionCode", "1", 0, ""),
|
||||||
|
],
|
||||||
|
Encoding::Utf8,
|
||||||
|
);
|
||||||
|
assert_eq!(identity(&other, false), None);
|
||||||
|
// Resource id 0x01010003 is android:name. It must not become the package
|
||||||
|
// even when its string is a package-looking value.
|
||||||
|
let named = axml_resources(
|
||||||
|
"manifest",
|
||||||
|
&[("name", 0x0101_0003, "example.fake", TYPE_STRING)],
|
||||||
|
&[],
|
||||||
|
Encoding::Utf8,
|
||||||
|
);
|
||||||
|
assert_eq!(identity(&named, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_truncated_and_corrupt_ranges() {
|
||||||
|
let bytes = axml(
|
||||||
|
"manifest",
|
||||||
|
&[
|
||||||
|
attr(NO_STRING, "package", "example.cut", 0, ""),
|
||||||
|
attr(NO_STRING, "versionCode", "1", 0, ""),
|
||||||
|
],
|
||||||
|
Encoding::Utf8,
|
||||||
|
);
|
||||||
|
let mut short = bytes.clone();
|
||||||
|
short.truncate(20);
|
||||||
|
assert_eq!(identity(&short, true), None);
|
||||||
|
let mut bad = bytes.clone();
|
||||||
|
bad[4..8].copy_from_slice(&4_u32.to_le_bytes());
|
||||||
|
assert_eq!(identity(&bad, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_major_is_omitted() {
|
||||||
|
let bytes = axml_with_data(
|
||||||
|
"manifest",
|
||||||
|
&[
|
||||||
|
("package", NO_STRING, "example.zero", TYPE_STRING, 0),
|
||||||
|
("versionCode", NO_STRING, "", TYPE_INT_DEC, 9),
|
||||||
|
("versionCodeMajor", NO_STRING, "", TYPE_INT_DEC, 0),
|
||||||
|
],
|
||||||
|
Encoding::Utf8,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
identity(&bytes, false),
|
||||||
|
Some(("example.zero".to_owned(), "9".to_owned()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BuiltAttr {
|
||||||
|
name: &'static str,
|
||||||
|
raw: u32,
|
||||||
|
raw_text: &'static str,
|
||||||
|
data_type: u8,
|
||||||
|
data_text: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attr(
|
||||||
|
_namespace: u32,
|
||||||
|
name: &'static str,
|
||||||
|
raw_text: &'static str,
|
||||||
|
data_type: u8,
|
||||||
|
data_text: &'static str,
|
||||||
|
) -> BuiltAttr {
|
||||||
|
BuiltAttr {
|
||||||
|
name,
|
||||||
|
raw: if raw_text.is_empty() { NO_STRING } else { 0 },
|
||||||
|
raw_text,
|
||||||
|
data_type,
|
||||||
|
data_text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Encoding {
|
||||||
|
Utf8,
|
||||||
|
Utf16,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn axml(root: &str, attributes: &[BuiltAttr], encoding: Encoding) -> Vec<u8> {
|
||||||
|
let specs: Vec<(&str, u32, &str, u8, u32)> = attributes
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
let data = if item.data_type == TYPE_INT_DEC || item.data_type == TYPE_INT_HEX {
|
||||||
|
item.data_text.parse().unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
(item.name, item.raw, item.raw_text, item.data_type, data)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
axml_with_data(root, &specs, encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn axml_with_data(
|
||||||
|
root: &str,
|
||||||
|
attributes: &[(&str, u32, &str, u8, u32)],
|
||||||
|
encoding: Encoding,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
axml_resources(root, &[], attributes, encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn axml_resources(
|
||||||
|
root: &str,
|
||||||
|
named_resources: &[(&str, u32, &str, u8)],
|
||||||
|
attributes: &[(&str, u32, &str, u8, u32)],
|
||||||
|
encoding: Encoding,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut strings = vec![root.to_owned()];
|
||||||
|
let mut specs: Vec<(u32, u32, u32, u8, u32)> = Vec::new();
|
||||||
|
for (name, raw, raw_text, data_type, data) in attributes {
|
||||||
|
let name_index = push_string(&mut strings, name);
|
||||||
|
let raw_index = if raw_text.is_empty() {
|
||||||
|
*raw
|
||||||
|
} else {
|
||||||
|
push_string(&mut strings, raw_text)
|
||||||
|
};
|
||||||
|
let data_index = if *data_type == TYPE_STRING {
|
||||||
|
if raw_text.is_empty() {
|
||||||
|
*data
|
||||||
|
} else {
|
||||||
|
push_string(&mut strings, raw_text)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*data
|
||||||
|
};
|
||||||
|
specs.push((NO_STRING, name_index, raw_index, *data_type, data_index));
|
||||||
|
}
|
||||||
|
for (name, _resource, text, data_type) in named_resources {
|
||||||
|
let name_index = push_string(&mut strings, name);
|
||||||
|
let value_index = push_string(&mut strings, text);
|
||||||
|
specs.push((NO_STRING, name_index, NO_STRING, *data_type, value_index));
|
||||||
|
}
|
||||||
|
let resources: Vec<u32> = {
|
||||||
|
let mut ids = vec![0_u32; strings.len()];
|
||||||
|
for (name, resource, _, _) in named_resources {
|
||||||
|
if let Some(index) = strings.iter().position(|item| item == name) {
|
||||||
|
ids[index] = *resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids
|
||||||
|
};
|
||||||
|
let pool = encode_pool(&strings, encoding);
|
||||||
|
let mut map = Vec::new();
|
||||||
|
map.extend(u16::to_le_bytes(0x0180));
|
||||||
|
map.extend(u16::to_le_bytes(8));
|
||||||
|
map.extend(u32::to_le_bytes((8 + resources.len() * 4) as u32));
|
||||||
|
for id in &resources {
|
||||||
|
map.extend(u32::to_le_bytes(*id));
|
||||||
|
}
|
||||||
|
let mut element = Vec::new();
|
||||||
|
element.extend(u16::to_le_bytes(0x0102));
|
||||||
|
element.extend(u16::to_le_bytes(16));
|
||||||
|
let attribute_bytes = specs.len() * 20;
|
||||||
|
let size = 16 + 20 + attribute_bytes;
|
||||||
|
element.extend(u32::to_le_bytes(size as u32));
|
||||||
|
element.extend(u32::to_le_bytes(1)); // line
|
||||||
|
element.extend(u32::to_le_bytes(NO_STRING)); // comment
|
||||||
|
element.extend(u32::to_le_bytes(NO_STRING)); // namespace
|
||||||
|
element.extend(u32::to_le_bytes(0)); // name = root
|
||||||
|
element.extend(u16::to_le_bytes(20)); // attributeStart from attrExt
|
||||||
|
element.extend(u16::to_le_bytes(20));
|
||||||
|
element.extend(u16::to_le_bytes(specs.len() as u16));
|
||||||
|
element.extend(u16::to_le_bytes(0));
|
||||||
|
element.extend(u16::to_le_bytes(0));
|
||||||
|
element.extend(u16::to_le_bytes(0));
|
||||||
|
for (namespace, name, raw, data_type, data) in specs {
|
||||||
|
element.extend(u32::to_le_bytes(namespace));
|
||||||
|
element.extend(u32::to_le_bytes(name));
|
||||||
|
element.extend(u32::to_le_bytes(raw));
|
||||||
|
element.extend(u16::to_le_bytes(8));
|
||||||
|
element.push(0);
|
||||||
|
element.push(data_type);
|
||||||
|
element.extend(u32::to_le_bytes(data));
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
bytes.extend(u16::to_le_bytes(0x0003));
|
||||||
|
bytes.extend(u16::to_le_bytes(8));
|
||||||
|
bytes.extend(u32::to_le_bytes(
|
||||||
|
(8 + pool.len() + map.len() + element.len()) as u32,
|
||||||
|
));
|
||||||
|
bytes.extend(pool);
|
||||||
|
bytes.extend(map);
|
||||||
|
bytes.extend(element);
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_string(strings: &mut Vec<String>, value: &str) -> u32 {
|
||||||
|
if let Some(index) = strings.iter().position(|item| item == value) {
|
||||||
|
return index as u32;
|
||||||
|
}
|
||||||
|
strings.push(value.to_owned());
|
||||||
|
(strings.len() - 1) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_pool(strings: &[String], encoding: Encoding) -> Vec<u8> {
|
||||||
|
let mut data = Vec::new();
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
for value in strings {
|
||||||
|
offsets.push(data.len() as u32);
|
||||||
|
match encoding {
|
||||||
|
Encoding::Utf8 => {
|
||||||
|
data.push(value.chars().count() as u8);
|
||||||
|
data.push(value.len() as u8);
|
||||||
|
data.extend(value.as_bytes());
|
||||||
|
data.push(0);
|
||||||
|
}
|
||||||
|
Encoding::Utf16 => {
|
||||||
|
let units: Vec<u16> = value.encode_utf16().collect();
|
||||||
|
data.extend(u16::to_le_bytes(units.len() as u16));
|
||||||
|
for unit in units {
|
||||||
|
data.extend(u16::to_le_bytes(unit));
|
||||||
|
}
|
||||||
|
data.extend(u16::to_le_bytes(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut chunk = Vec::new();
|
||||||
|
chunk.extend(u16::to_le_bytes(0x0001));
|
||||||
|
chunk.extend(u16::to_le_bytes(28));
|
||||||
|
let size = 28 + strings.len() * 4 + data.len();
|
||||||
|
chunk.extend(u32::to_le_bytes(size as u32));
|
||||||
|
chunk.extend(u32::to_le_bytes(strings.len() as u32));
|
||||||
|
chunk.extend(u32::to_le_bytes(0));
|
||||||
|
let flags: u32 = match encoding {
|
||||||
|
Encoding::Utf8 => 1 << 8,
|
||||||
|
Encoding::Utf16 => 0,
|
||||||
|
};
|
||||||
|
chunk.extend(u32::to_le_bytes(flags));
|
||||||
|
chunk.extend(u32::to_le_bytes((28 + strings.len() * 4) as u32));
|
||||||
|
chunk.extend(u32::to_le_bytes(0));
|
||||||
|
for offset in offsets {
|
||||||
|
chunk.extend(u32::to_le_bytes(offset));
|
||||||
|
}
|
||||||
|
chunk.extend(data);
|
||||||
|
chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,131 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
use zip::write::SimpleFileOptions;
|
||||||
|
|
||||||
|
fn package(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||||
|
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
for (name, bytes) in entries {
|
||||||
|
writer
|
||||||
|
.start_file(
|
||||||
|
*name,
|
||||||
|
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
writer.write_all(bytes).unwrap();
|
||||||
|
}
|
||||||
|
writer.finish().unwrap().into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compressed_entry_limit_leaves_no_restored_output() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let input = temp.path().join("app.apk");
|
||||||
|
std::fs::write(&input, package(&[("lib/test.so", &vec![0; 4096])])).unwrap();
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
max_entry_bytes: 1024,
|
||||||
|
..ResourceLimits::default()
|
||||||
|
};
|
||||||
|
let out = temp.path().join("out");
|
||||||
|
let error = restore_package_with_limits(
|
||||||
|
&input,
|
||||||
|
Path::new("app.apk"),
|
||||||
|
&out,
|
||||||
|
&mut HashSet::new(),
|
||||||
|
false,
|
||||||
|
&limits,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(format!("{error:#}").contains("extraction limit"));
|
||||||
|
assert!(!out.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archive_bytes_are_cumulative_across_packages_in_a_folder_run() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let first = temp.path().join("first.apk");
|
||||||
|
let second = temp.path().join("second.apk");
|
||||||
|
let bytes = package(&[("lib/test.so", &vec![0; 400])]);
|
||||||
|
std::fs::write(&first, &bytes).unwrap();
|
||||||
|
std::fs::write(&second, &bytes).unwrap();
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
max_entry_bytes: 1024,
|
||||||
|
max_total_bytes: 600,
|
||||||
|
..ResourceLimits::default()
|
||||||
|
};
|
||||||
|
let mut cache = run_cache_with_limits(&limits);
|
||||||
|
let out = temp.path().join("out");
|
||||||
|
let first =
|
||||||
|
restore_package_cached(&first, Path::new("first.apk"), &out, &mut cache, false).unwrap();
|
||||||
|
assert!(matches!(first[0].status, EntryStatus::NotTarget));
|
||||||
|
let error = restore_package_cached(&second, Path::new("second.apk"), &out, &mut cache, false)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(format!("{error:#}").contains("extraction limit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_entries_share_the_outer_archive_count_budget() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let input = temp.path().join("bundle.apks");
|
||||||
|
let nested = package(&[("lib/test.so", b"not an ELF")]);
|
||||||
|
std::fs::write(&input, package(&[("base.apk", &nested)])).unwrap();
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
max_entries: 1,
|
||||||
|
..ResourceLimits::default()
|
||||||
|
};
|
||||||
|
let error = restore_package_with_limits(
|
||||||
|
&input,
|
||||||
|
Path::new("bundle.apks"),
|
||||||
|
&temp.path().join("out"),
|
||||||
|
&mut HashSet::new(),
|
||||||
|
false,
|
||||||
|
&limits,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(format!("{error:#}").contains("entry limit"), "{error:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_depth_refuses_nested_apks_before_extracting_them() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let input = temp.path().join("bundle.apks");
|
||||||
|
std::fs::write(&input, package(&[("base.apk", b"not even a ZIP")])).unwrap();
|
||||||
|
let limits = ResourceLimits {
|
||||||
|
max_depth: 0,
|
||||||
|
..ResourceLimits::default()
|
||||||
|
};
|
||||||
|
let error = restore_package_with_limits(
|
||||||
|
&input,
|
||||||
|
Path::new("bundle.apks"),
|
||||||
|
&temp.path().join("out"),
|
||||||
|
&mut HashSet::new(),
|
||||||
|
false,
|
||||||
|
&limits,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
format!("{error:#}").contains("nesting depth 1"),
|
||||||
|
"{error:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_entry_extraction_removes_its_partial_temporary() {
|
||||||
|
let bytes = package(&[("lib/test.so", &vec![0; 4096])]);
|
||||||
|
let mut archive = ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
let mut budget = archive_limits::ArchiveBudget::from_limits(&ResourceLimits {
|
||||||
|
max_entry_bytes: 1024,
|
||||||
|
..ResourceLimits::default()
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
extract_entry(
|
||||||
|
&mut archive,
|
||||||
|
0,
|
||||||
|
&workspace,
|
||||||
|
Path::new("app.apk::lib/test.so"),
|
||||||
|
&mut budget
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read_dir(workspace.path()).unwrap().count(), 0);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,203 @@
|
|||||||
|
//! Shared atomic filesystem writes for native orchestration.
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tempfile::Builder;
|
||||||
|
|
||||||
|
/// Write `bytes` through a uniquely named temporary file in `dest`'s directory
|
||||||
|
/// and replace `dest` only after the complete write succeeds.
|
||||||
|
///
|
||||||
|
/// The temporary name is random, so concurrent writers of the same destination
|
||||||
|
/// do not share one sibling file. An existing `dest` is replaced. On any
|
||||||
|
/// failure the temporary file is removed and `dest` is left unchanged.
|
||||||
|
pub(crate) fn write_atomic(dest: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
let parent = dest
|
||||||
|
.parent()
|
||||||
|
.filter(|parent| !parent.as_os_str().is_empty());
|
||||||
|
let parent = parent.unwrap_or_else(|| Path::new("."));
|
||||||
|
// Same directory as `dest`: the final replace must stay on one filesystem.
|
||||||
|
// The temporary is private to this call and is removed if persist is not
|
||||||
|
// reached, or if persist fails.
|
||||||
|
let mut temporary = Builder::new()
|
||||||
|
.prefix(".senbei-")
|
||||||
|
.suffix(".tmp")
|
||||||
|
.tempfile_in(parent)?;
|
||||||
|
temporary.write_all(bytes)?;
|
||||||
|
temporary.flush()?;
|
||||||
|
temporary.as_file().sync_all()?;
|
||||||
|
// `persist` replaces an existing file and, on failure, hands the temporary
|
||||||
|
// back. Drop deletes that file, so a failed replace cannot leave `dest`
|
||||||
|
// half-written. Windows can refuse the replace while another handle still
|
||||||
|
// has `dest` open (the file is mapped, or a concurrent replace has not
|
||||||
|
// dropped its handle yet). Retry these refusals briefly; a persistent
|
||||||
|
// permission or sharing failure still leaves the old destination intact.
|
||||||
|
const ATTEMPTS: u32 = 8;
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
match temporary.persist(dest) {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(error) if replace_in_progress(&error.error) && attempt + 1 < ATTEMPTS => {
|
||||||
|
thread::sleep(Duration::from_millis(1 << attempt));
|
||||||
|
attempt += 1;
|
||||||
|
temporary = error.file;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A replace refused because the destination is momentarily in use.
|
||||||
|
///
|
||||||
|
/// `ERROR_ACCESS_DENIED` (5) is what Windows returns when `MoveFileExW` cannot
|
||||||
|
/// replace a file another handle still has open. `ERROR_SHARING_VIOLATION`
|
||||||
|
/// (32) is the same condition reported through the sharing check. These codes
|
||||||
|
/// can also mean a permanent denial, so retries are bounded and never remove
|
||||||
|
/// the existing destination to force replacement.
|
||||||
|
fn replace_in_progress(error: &io::Error) -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
matches!(error.raw_os_error(), Some(5 | 32))
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = error;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::{Arc, Barrier};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn stray_names(dir: &Path) -> Vec<String> {
|
||||||
|
fs::read_dir(dir)
|
||||||
|
.expect("read destination directory")
|
||||||
|
.map(|entry| {
|
||||||
|
entry
|
||||||
|
.expect("directory entry")
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
.filter(|name| name.starts_with(".senbei-") || name.ends_with(".senbei-tmp"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_new_file_and_leaves_no_temporary() {
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir");
|
||||||
|
let dest = dir.path().join("out.bin");
|
||||||
|
|
||||||
|
write_atomic(&dest, b"payload").expect("write");
|
||||||
|
|
||||||
|
assert_eq!(fs::read(&dest).unwrap(), b"payload");
|
||||||
|
assert!(stray_names(dir.path()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overwrites_existing_destination() {
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir");
|
||||||
|
let dest = dir.path().join("out.bin");
|
||||||
|
fs::write(&dest, b"previous").expect("seed");
|
||||||
|
|
||||||
|
write_atomic(&dest, b"replacement").expect("overwrite");
|
||||||
|
|
||||||
|
assert_eq!(fs::read(&dest).unwrap(), b"replacement");
|
||||||
|
assert!(stray_names(dir.path()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_leaves_existing_destination_and_no_temporary() {
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir");
|
||||||
|
let dest = dir.path().join("out.bin");
|
||||||
|
fs::create_dir(&dest).expect("destination directory");
|
||||||
|
|
||||||
|
let error = write_atomic(&dest, b"replacement").expect_err("cannot replace a directory");
|
||||||
|
|
||||||
|
assert_ne!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||||
|
assert!(dest.is_dir());
|
||||||
|
assert!(stray_names(dir.path()).is_empty());
|
||||||
|
assert!(
|
||||||
|
stray_names(&dest).is_empty(),
|
||||||
|
"temporary file was left inside the destination"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_parent_writes_nothing() {
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir");
|
||||||
|
let parent = dir.path().join("missing");
|
||||||
|
let dest = parent.join("out.bin");
|
||||||
|
|
||||||
|
let error = write_atomic(&dest, b"payload").expect_err("missing parent");
|
||||||
|
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::NotFound);
|
||||||
|
assert!(!parent.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_writers_leave_one_complete_output() {
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir");
|
||||||
|
let dest = Arc::new(dir.path().join("out.bin"));
|
||||||
|
let barrier = Arc::new(Barrier::new(8));
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for id in 0..8u8 {
|
||||||
|
let dest = Arc::clone(&dest);
|
||||||
|
let barrier = Arc::clone(&barrier);
|
||||||
|
handles.push(thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
let payload = vec![id; 64 * 1024];
|
||||||
|
write_atomic(&dest, &payload).expect("concurrent write");
|
||||||
|
id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let ids: Vec<u8> = handles
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| handle.join().expect("writer thread"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let written = fs::read(dest.as_path()).expect("read destination");
|
||||||
|
let writer = written[0];
|
||||||
|
assert!(ids.contains(&writer));
|
||||||
|
assert!(written.iter().all(|byte| *byte == writer));
|
||||||
|
assert_eq!(written.len(), 64 * 1024);
|
||||||
|
assert!(stray_names(dir.path()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distinct_temporary_names_survive_a_failed_replace() {
|
||||||
|
// Two writers no longer share `<dest>.senbei-tmp`. Each temporary is
|
||||||
|
// removed when its own replace fails, including when the destination
|
||||||
|
// cannot be replaced at all.
|
||||||
|
let dir = tempfile::tempdir().expect("temp dir");
|
||||||
|
let dest = dir.path().join("out.bin");
|
||||||
|
fs::create_dir(&dest).expect("destination directory");
|
||||||
|
let barrier = Arc::new(Barrier::new(2));
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for payload in [b"first".as_slice(), b"second".as_slice()] {
|
||||||
|
let dest = dest.clone();
|
||||||
|
let barrier = Arc::clone(&barrier);
|
||||||
|
let payload = payload.to_vec();
|
||||||
|
handles.push(thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
write_atomic(&dest, &payload)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for handle in handles {
|
||||||
|
handle
|
||||||
|
.join()
|
||||||
|
.expect("writer thread")
|
||||||
|
.expect_err("directory destination");
|
||||||
|
}
|
||||||
|
assert!(dest.is_dir());
|
||||||
|
assert!(stray_names(dir.path()).is_empty());
|
||||||
|
assert!(stray_names(&dest).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+497
-562
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,13 @@
|
|||||||
//! Filesystem and command-line orchestration.
|
//! Filesystem and command-line orchestration.
|
||||||
|
|
||||||
|
/// File name of an IL2CPP metadata blob shared by both platform scanners.
|
||||||
|
pub const METADATA_FILE_NAME: &str = "global-metadata.dat";
|
||||||
|
|
||||||
|
pub mod android;
|
||||||
|
mod atomic;
|
||||||
pub mod job;
|
pub mod job;
|
||||||
pub mod logfile;
|
pub mod logfile;
|
||||||
pub mod pause;
|
pub mod pause;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod windows;
|
||||||
+176
-187
@@ -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,85 +25,10 @@ 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
|
fn is_metadata_name(path: &Path) -> bool {
|
||||||
/// image or an il2cpp metadata blob.
|
path.file_name()
|
||||||
///
|
.and_then(|name| name.to_str())
|
||||||
/// This is deliberately a **deny**-list, not an executable allow-list: unknown
|
.is_some_and(|name| name.eq_ignore_ascii_case(crate::METADATA_FILE_NAME))
|
||||||
/// extensions are still probed. Extensionless files are handled separately by
|
|
||||||
/// [`denied_name`] because asset stores commonly contain tens of thousands of
|
|
||||||
/// extensionless chunks; exhaustive probing remains available through
|
|
||||||
/// `--scan-all`.
|
|
||||||
///
|
|
||||||
/// Set `SENBEI_SCAN_ALL=1` (or pass `--scan-all`) to probe every file regardless.
|
|
||||||
const DENY_EXT: &[&str] = &[
|
|
||||||
// Unity and other engine asset containers
|
|
||||||
"ab",
|
|
||||||
"bundle",
|
|
||||||
"unity3d",
|
|
||||||
"manifest",
|
|
||||||
"resource",
|
|
||||||
"ress",
|
|
||||||
"assets",
|
|
||||||
"sharedassets",
|
|
||||||
// audio / video / image / font
|
|
||||||
"acb",
|
|
||||||
"awb",
|
|
||||||
"usm",
|
|
||||||
"wav",
|
|
||||||
"ogg",
|
|
||||||
"mp3",
|
|
||||||
"mp4",
|
|
||||||
"avi",
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"bmp",
|
|
||||||
"gif",
|
|
||||||
"tga",
|
|
||||||
"dds",
|
|
||||||
"svg",
|
|
||||||
"ttf",
|
|
||||||
"otf",
|
|
||||||
// text, markup, config, logs
|
|
||||||
"xml",
|
|
||||||
"json",
|
|
||||||
"txt",
|
|
||||||
"csv",
|
|
||||||
"md",
|
|
||||||
"toml",
|
|
||||||
"ini",
|
|
||||||
"yml",
|
|
||||||
"yaml",
|
|
||||||
"log",
|
|
||||||
"html",
|
|
||||||
"htm",
|
|
||||||
"css",
|
|
||||||
"aspx",
|
|
||||||
"browser",
|
|
||||||
"config",
|
|
||||||
"sig",
|
|
||||||
"map",
|
|
||||||
"pdb",
|
|
||||||
// rhythm-game chart/score data
|
|
||||||
"ma2",
|
|
||||||
"sr",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Whether `path` can be skipped from its name alone. Extensionless files and
|
|
||||||
/// 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 {
|
|
||||||
let Some(ext) = path.extension() else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let Some(ext) = ext.to_str() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
// Extensions are ASCII in practice; compare case-insensitively without
|
|
||||||
// allocating for the overwhelmingly common non-match.
|
|
||||||
DENY_EXT
|
|
||||||
.iter()
|
|
||||||
.any(|d| d.len() == ext.len() && d.eq_ignore_ascii_case(ext))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content classification of a single file.
|
/// Content classification of a single file.
|
||||||
@@ -115,6 +40,25 @@ enum Class {
|
|||||||
Crackproof,
|
Crackproof,
|
||||||
/// An il2cpp `global-metadata.dat` (de-obfuscation target).
|
/// An il2cpp `global-metadata.dat` (de-obfuscation target).
|
||||||
Metadata,
|
Metadata,
|
||||||
|
/// A protected AArch64 shared library (Android restore target).
|
||||||
|
AndroidSo,
|
||||||
|
/// An Android app package (`.apk`/`.apks`/`.xapk`) — a container whose
|
||||||
|
/// entries are content-probed individually during the Android pass.
|
||||||
|
AndroidPackage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything one [`find_targets_opts`] walk found, plus non-target tallies.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ScanResult {
|
||||||
|
/// Crackproof-protected PE files.
|
||||||
|
pub crackproof: Vec<PathBuf>,
|
||||||
|
/// il2cpp `global-metadata.dat` blobs.
|
||||||
|
pub metadata: Vec<PathBuf>,
|
||||||
|
/// Protected AArch64 shared libraries.
|
||||||
|
pub android_so: Vec<PathBuf>,
|
||||||
|
/// Android app packages (containers restored entry-by-entry).
|
||||||
|
pub android_packages: Vec<PathBuf>,
|
||||||
|
pub stats: ScanStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk `root` recursively (skipping any directory literally named `"unpack"`)
|
/// Walk `root` recursively (skipping any directory literally named `"unpack"`)
|
||||||
@@ -132,21 +76,19 @@ enum Class {
|
|||||||
/// 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 selected 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 because it only
|
||||||
/// pass, no file opens) because it feeds the parallel probe.
|
/// collects names and sizes before 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
|
||||||
/// deterministic.
|
/// deterministic.
|
||||||
pub fn find_targets(root: &Path) -> (Vec<PathBuf>, Vec<PathBuf>, ScanStats) {
|
pub fn find_targets(root: &Path) -> ScanResult {
|
||||||
find_targets_opts(root, scan_all_env())
|
find_targets_opts(root, scan_all_env())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,9 +109,23 @@ pub struct ScanStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [`find_targets`], but with the pre-filter explicitly controlled. When
|
/// [`find_targets`], but with the pre-filter explicitly controlled. When
|
||||||
/// `scan_all` is true every regular file is probed, restoring the exhaustive
|
/// `scan_all` is true selected target names below [`MIN_SIZE`] are also probed.
|
||||||
/// (and on asset-heavy trees, far slower) behavior.
|
pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
||||||
pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<PathBuf>, ScanStats) {
|
find_targets_with_limits(
|
||||||
|
root,
|
||||||
|
scan_all,
|
||||||
|
&senbei_engine::android::ResourceLimits::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`find_targets_opts`], with Android probe allocations bounded by the
|
||||||
|
/// same limits as restoration. An over-limit probe remains a restore candidate
|
||||||
|
/// so the job reports the limit error rather than silently skipping the file.
|
||||||
|
pub fn find_targets_with_limits(
|
||||||
|
root: &Path,
|
||||||
|
scan_all: bool,
|
||||||
|
limits: &senbei_engine::android::ResourceLimits,
|
||||||
|
) -> ScanResult {
|
||||||
// Phase 1: serial traversal collecting regular-file paths only. No file is
|
// Phase 1: serial traversal collecting regular-file paths only. No file is
|
||||||
// opened here; `readdir` is fast relative to the content probe that follows,
|
// opened here; `readdir` is fast relative to the content probe that follows,
|
||||||
// and `entry.metadata()` is served from the directory entry on Windows, so
|
// and `entry.metadata()` is served from the directory entry on Windows, so
|
||||||
@@ -193,7 +149,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<Path
|
|||||||
// Skip reparse-point directories (junctions, symlink-dirs): they point
|
// Skip reparse-point directories (junctions, symlink-dirs): they point
|
||||||
// outside the scanned tree — walking one would silently unpack an
|
// outside the scanned tree — walking one would silently unpack an
|
||||||
// entire foreign tree (e.g. a `samples` junction into the golden corpus).
|
// entire foreign tree (e.g. a `samples` junction into the golden corpus).
|
||||||
!is_reparse_point(e)
|
!crate::windows::is_reparse_point(e)
|
||||||
}) {
|
}) {
|
||||||
let entry = match entry {
|
let entry = match entry {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
@@ -205,12 +161,17 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<Path
|
|||||||
if !entry.file_type().is_file() {
|
if !entry.file_type().is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !scan_all {
|
if crate::windows::is_companion(entry.path()) {
|
||||||
// Name checks come first so extensionless asset chunks never
|
|
||||||
// trigger even an explicit metadata query.
|
|
||||||
if denied_name(entry.path()) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !is_metadata_name(entry.path())
|
||||||
|
&& !crate::windows::is_pe_extension(entry.path())
|
||||||
|
&& !crate::android::is_so_name(entry.path())
|
||||||
|
&& !crate::android::is_package_name(entry.path())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !scan_all {
|
||||||
// Skip on directory metadata alone — never open these.
|
// Skip on directory metadata alone — never open these.
|
||||||
let too_small = entry
|
let too_small = entry
|
||||||
.metadata()
|
.metadata()
|
||||||
@@ -228,10 +189,10 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<Path
|
|||||||
// `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, limits);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let chunk = n.div_ceil(workers);
|
let chunk = n.div_ceil(workers);
|
||||||
@@ -239,51 +200,35 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> (Vec<PathBuf>, Vec<Path
|
|||||||
for (pc, cc) in paths.chunks(chunk).zip(class.chunks_mut(chunk)) {
|
for (pc, cc) in paths.chunks(chunk).zip(class.chunks_mut(chunk)) {
|
||||||
scope.spawn(move || {
|
scope.spawn(move || {
|
||||||
for (p, c) in pc.iter().zip(cc.iter_mut()) {
|
for (p, c) in pc.iter().zip(cc.iter_mut()) {
|
||||||
*c = classify(p);
|
*c = classify(p, limits);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut candidates = Vec::new();
|
let mut result = ScanResult {
|
||||||
let mut metadata = Vec::new();
|
stats,
|
||||||
|
..ScanResult::default()
|
||||||
|
};
|
||||||
for (p, c) in paths.into_iter().zip(class) {
|
for (p, c) in paths.into_iter().zip(class) {
|
||||||
match c {
|
match c {
|
||||||
Some(Class::Crackproof) => candidates.push(p),
|
Some(Class::Crackproof) => result.crackproof.push(p),
|
||||||
Some(Class::Metadata) => metadata.push(p),
|
Some(Class::Metadata) => result.metadata.push(p),
|
||||||
Some(Class::None) => stats.skipped += 1,
|
Some(Class::AndroidSo) => result.android_so.push(p),
|
||||||
|
Some(Class::AndroidPackage) => result.android_packages.push(p),
|
||||||
|
Some(Class::None) => result.stats.skipped += 1,
|
||||||
// Unreadable / panicking probe: NOT skipped — the scan could not
|
// Unreadable / panicking probe: NOT skipped — the scan could not
|
||||||
// classify it, so it may be a target we failed to unpack.
|
// classify it, so it may be a target we failed to unpack.
|
||||||
None => stats.probe_errors += 1,
|
None => result.stats.probe_errors += 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(candidates, metadata, stats)
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if a walked directory entry is a reparse point (junction or symlink).
|
/// Whether the size pre-filter is disabled via `SENBEI_SCAN_ALL`. Any value
|
||||||
///
|
/// other than `0`/empty enables probing small selected target names. It never
|
||||||
/// `DirEntry::file_type` only flags true symlinks; NTFS junctions report as
|
/// expands the platform filename boundary.
|
||||||
/// ordinary directories, so without this check the walker descends into them.
|
|
||||||
/// Off-Windows there are no junctions — symlink dirs are already excluded
|
|
||||||
/// because `follow_links` is off (their `file_type().is_dir()` is false).
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn is_reparse_point(e: &walkdir::DirEntry) -> bool {
|
|
||||||
use std::os::windows::fs::MetadataExt;
|
|
||||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
|
||||||
e.metadata()
|
|
||||||
.map(|m| m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
fn is_reparse_point(_e: &walkdir::DirEntry) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the scan pre-filter is disabled via `SENBEI_SCAN_ALL`. Any value
|
|
||||||
/// other than `0`/empty turns exhaustive scanning on. The `--scan-all` flag is
|
|
||||||
/// ORed with this.
|
|
||||||
pub fn scan_all_env() -> bool {
|
pub fn scan_all_env() -> bool {
|
||||||
match std::env::var("SENBEI_SCAN_ALL") {
|
match std::env::var("SENBEI_SCAN_ALL") {
|
||||||
Ok(v) => !matches!(v.trim(), "" | "0"),
|
Ok(v) => !matches!(v.trim(), "" | "0"),
|
||||||
@@ -291,11 +236,11 @@ 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. Returns `None`
|
/// the detector for that platform. Returns `None` when the file could not be classified at
|
||||||
/// when the file could not be classified at all — an I/O error opening it
|
/// all — an I/O error opening it (locked, permissions) or a panic inside a
|
||||||
/// (locked, permissions) or a panic inside a detector — so the caller counts
|
/// detector — so the caller counts it as a probe error rather than a clean
|
||||||
/// it as a probe error rather than a clean "not a target" skip.
|
/// "not a target" skip.
|
||||||
///
|
///
|
||||||
/// The detector is wrapped in `catch_unwind` because a panic in a scan worker
|
/// The detector is wrapped in `catch_unwind` because a panic in a scan worker
|
||||||
/// thread would otherwise abort the whole folder run (a scoped-thread panic
|
/// thread would otherwise abort the whole folder run (a scoped-thread panic
|
||||||
@@ -304,16 +249,30 @@ pub fn scan_all_env() -> bool {
|
|||||||
///
|
///
|
||||||
/// A Crackproof PE never matches the metadata magic (it is a PE, not a
|
/// A Crackproof PE never matches the metadata magic (it is a PE, not a
|
||||||
/// metadata blob) and vice versa, so the order is immaterial.
|
/// metadata blob) and vice versa, so the order is immaterial.
|
||||||
fn classify(path: &Path) -> Option<Class> {
|
///
|
||||||
|
/// The Android library probe needs more than the prefix: the protection
|
||||||
|
/// payload lives in a section found via the section-header table at the *end*
|
||||||
|
/// of the file, so an ELF64/AArch64 prefix triggers a full-file read. Only
|
||||||
|
/// selected `.so` images pay for it.
|
||||||
|
fn classify(path: &Path, limits: &senbei_engine::android::ResourceLimits) -> 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 crate::android::is_package_name(path) && crate::android::is_app_package(path, &head) {
|
||||||
Class::Crackproof
|
return Class::AndroidPackage;
|
||||||
} else if senbei_metadata::is_metadata(&head) {
|
|
||||||
Class::Metadata
|
|
||||||
} else {
|
|
||||||
Class::None
|
|
||||||
}
|
}
|
||||||
|
if is_metadata_name(path) && senbei_metadata::is_metadata(&head) {
|
||||||
|
return Class::Metadata;
|
||||||
|
}
|
||||||
|
if crate::windows::is_pe_extension(path) && detect(&head).is_some() {
|
||||||
|
return Class::Crackproof;
|
||||||
|
}
|
||||||
|
if crate::android::is_so_name(path)
|
||||||
|
&& crate::android::is_elf64_aarch64(&head)
|
||||||
|
&& crate::android::probe_so_file_with_limits(path, limits).unwrap_or(true)
|
||||||
|
{
|
||||||
|
return Class::AndroidSo;
|
||||||
|
}
|
||||||
|
Class::None
|
||||||
}));
|
}));
|
||||||
r.ok()
|
r.ok()
|
||||||
}
|
}
|
||||||
@@ -332,55 +291,56 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn denies_bulk_asset_extensions_case_insensitively() {
|
fn candidate_names_are_platform_specific() {
|
||||||
for p in ["a.ab", "a.XML", "a.Acb", "a.ma2", "a.manifest", "a.PNG"] {
|
for p in [
|
||||||
assert!(denied_name(Path::new(p)), "{p} should be denied");
|
"daemon.exe",
|
||||||
|
"GameLib.DLL",
|
||||||
|
"libil2cpp.so",
|
||||||
|
"global-metadata.dat",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
is_metadata_name(Path::new(p))
|
||||||
|
|| crate::windows::is_pe_extension(Path::new(p))
|
||||||
|
|| crate::android::is_so_name(Path::new(p)),
|
||||||
|
"{p} should be a candidate"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn denies_extensionless_files() {
|
|
||||||
for p in ["asset", "level0", "0123456789abcdef"] {
|
|
||||||
assert!(denied_name(Path::new(p)), "{p} should be denied");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn never_denies_what_a_target_can_be_named() {
|
|
||||||
// Unknown extensions must still be probed. This keeps the filter a
|
|
||||||
// narrow deny-list rather than an executable-extension allow-list.
|
|
||||||
for p in [
|
for p in [
|
||||||
"app.exe.bak",
|
"app.exe.bak",
|
||||||
"managed.dll.bak",
|
"managed.dll.bak",
|
||||||
"daemon.exe",
|
"libil2cpp.so.bak",
|
||||||
"GameLib.dll",
|
"global-metadata.bin",
|
||||||
"global-metadata.dat",
|
"asset",
|
||||||
"a.so",
|
"a.ab",
|
||||||
"a.bin",
|
|
||||||
] {
|
] {
|
||||||
assert!(!denied_name(Path::new(p)), "{p} must still be probed");
|
assert!(
|
||||||
|
!is_metadata_name(Path::new(p))
|
||||||
|
&& !crate::windows::is_pe_extension(Path::new(p))
|
||||||
|
&& !crate::android::is_so_name(Path::new(p)),
|
||||||
|
"{p} must not be a candidate"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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];
|
||||||
blob[..4].copy_from_slice(&0xFAB1_1BAFu32.to_le_bytes());
|
blob[..4].copy_from_slice(&0xFAB1_1BAFu32.to_le_bytes());
|
||||||
std::fs::write(root.join("metadata"), &blob).unwrap();
|
std::fs::write(root.join("metadata"), &blob).unwrap();
|
||||||
|
|
||||||
let (_, filtered, _) = find_targets_opts(root, false);
|
let filtered = find_targets_opts(root, false);
|
||||||
assert!(filtered.is_empty());
|
assert!(filtered.metadata.is_empty());
|
||||||
|
|
||||||
let (_, exhaustive, _) = find_targets_opts(root, true);
|
let exhaustive = find_targets_opts(root, true);
|
||||||
assert_eq!(exhaustive.len(), 1);
|
assert!(exhaustive.metadata.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file below the Crackproof key-table bound is skipped without being
|
/// A selected file below the Crackproof key-table bound is skipped without
|
||||||
/// opened, but a large non-asset file is still probed.
|
/// being opened, while `scan_all` probes it.
|
||||||
#[test]
|
#[test]
|
||||||
fn prefilter_skips_small_and_denied_files_only() {
|
fn prefilter_skips_small_selected_files_only() {
|
||||||
let td = tempfile::tempdir().unwrap();
|
let td = tempfile::tempdir().unwrap();
|
||||||
let root = td.path();
|
let root = td.path();
|
||||||
std::fs::write(root.join("tiny.dll"), vec![0u8; 100]).unwrap();
|
std::fs::write(root.join("tiny.dll"), vec![0u8; 100]).unwrap();
|
||||||
@@ -388,15 +348,15 @@ mod tests {
|
|||||||
std::fs::write(root.join("plain.dll"), vec![0u8; 100_000]).unwrap();
|
std::fs::write(root.join("plain.dll"), vec![0u8; 100_000]).unwrap();
|
||||||
|
|
||||||
// None of them are Crackproof, so both modes find nothing; the point is
|
// None of them are Crackproof, so both modes find nothing; the point is
|
||||||
// that the filtered walk does not panic and honors `scan_all`.
|
// that only the selected names are considered and `scan_all` controls
|
||||||
let (c, m, _) = find_targets_opts(root, false);
|
// the size floor.
|
||||||
assert!(c.is_empty() && m.is_empty());
|
let scan = find_targets_opts(root, false);
|
||||||
let (c, m, _) = find_targets_opts(root, true);
|
assert!(scan.crackproof.is_empty() && scan.metadata.is_empty());
|
||||||
assert!(c.is_empty() && m.is_empty());
|
let scan = find_targets_opts(root, true);
|
||||||
|
assert!(scan.crackproof.is_empty() && scan.metadata.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An il2cpp metadata blob is found by the filtered scan: `.dat` is not on
|
/// An exact `global-metadata.dat` name is found by the filtered scan.
|
||||||
/// the deny-list and a real one is far above `MIN_SIZE`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finds_metadata_through_the_prefilter() {
|
fn finds_metadata_through_the_prefilter() {
|
||||||
let td = tempfile::tempdir().unwrap();
|
let td = tempfile::tempdir().unwrap();
|
||||||
@@ -407,9 +367,9 @@ mod tests {
|
|||||||
// Same magic but too small to be processable — skipped by the size floor.
|
// Same magic but too small to be processable — skipped by the size floor.
|
||||||
std::fs::write(root.join("stub.dat"), &blob[..64]).unwrap();
|
std::fs::write(root.join("stub.dat"), &blob[..64]).unwrap();
|
||||||
|
|
||||||
let (_, m, _) = find_targets_opts(root, false);
|
let scan = find_targets_opts(root, false);
|
||||||
assert_eq!(m.len(), 1);
|
assert_eq!(scan.metadata.len(), 1);
|
||||||
assert!(m[0].ends_with("global-metadata.dat"));
|
assert!(scan.metadata[0].ends_with("global-metadata.dat"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Review regression: a previous output tree is pruned case-insensitively
|
/// Review regression: a previous output tree is pruned case-insensitively
|
||||||
@@ -428,12 +388,41 @@ mod tests {
|
|||||||
// A big non-target file at the root: probed, then skipped.
|
// A big non-target file at the root: probed, then skipped.
|
||||||
std::fs::write(root.join("plain.dll"), vec![0u8; 100_000]).unwrap();
|
std::fs::write(root.join("plain.dll"), vec![0u8; 100_000]).unwrap();
|
||||||
|
|
||||||
let (c, m, stats) = find_targets_opts(root, false);
|
let scan = find_targets_opts(root, false);
|
||||||
assert!(
|
assert!(
|
||||||
c.is_empty() && m.is_empty(),
|
scan.crackproof.is_empty() && scan.metadata.is_empty(),
|
||||||
"old output tree must be pruned"
|
"old output tree must be pruned"
|
||||||
);
|
);
|
||||||
assert_eq!(stats.skipped, 1, "the probed non-target counts as skipped");
|
assert_eq!(
|
||||||
assert_eq!(stats.walk_errors, 0);
|
scan.stats.skipped, 1,
|
||||||
|
"the probed non-target counts as skipped"
|
||||||
|
);
|
||||||
|
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!(crate::windows::is_companion(&root.join("app.exe._")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_all_keeps_the_platform_name_boundary() {
|
||||||
|
let td = tempfile::tempdir().unwrap();
|
||||||
|
let root = td.path();
|
||||||
|
let mut metadata = vec![0_u8; MIN_SIZE as usize];
|
||||||
|
metadata[..4].copy_from_slice(&0xFAB1_1BAFu32.to_le_bytes());
|
||||||
|
std::fs::write(root.join("renamed.bin"), &metadata).unwrap();
|
||||||
|
std::fs::write(root.join("global-metadata.dat"), &metadata).unwrap();
|
||||||
|
|
||||||
|
let scan = find_targets_opts(root, true);
|
||||||
|
assert_eq!(scan.metadata.len(), 1);
|
||||||
|
assert!(scan.metadata[0].ends_with("global-metadata.dat"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-10
@@ -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.
|
||||||
@@ -19,27 +19,33 @@ pub fn progress(n: u64, quiet: bool) -> ProgressBar {
|
|||||||
|
|
||||||
/// Print a green success line, suspending the progress bar.
|
/// Print a green success line, suspending the progress bar.
|
||||||
pub fn ok(bar: &ProgressBar, quiet: bool, rel: &Path, kind: Kind, dest: &Path) {
|
pub fn ok(bar: &ProgressBar, quiet: bool, rel: &Path, kind: Kind, dest: &Path) {
|
||||||
|
ok_label(
|
||||||
|
bar,
|
||||||
|
quiet,
|
||||||
|
&rel.display().to_string(),
|
||||||
|
&format!("{kind:?}"),
|
||||||
|
dest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a green success line with a free-form kind label (Android targets),
|
||||||
|
/// suspending the progress bar.
|
||||||
|
pub fn ok_label(bar: &ProgressBar, quiet: bool, rel: &str, label: &str, dest: &Path) {
|
||||||
if quiet {
|
if quiet {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let msg = format!(
|
let msg = format!("{} {} {} -> {}", "✓".green(), label, rel, dest.display());
|
||||||
"{} {:?} {} -> {}",
|
|
||||||
"✓".green(),
|
|
||||||
kind,
|
|
||||||
rel.display(),
|
|
||||||
dest.display()
|
|
||||||
);
|
|
||||||
bar.suspend(|| println!("{msg}"));
|
bar.suspend(|| println!("{msg}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Print a green success line for a de-obfuscated il2cpp `global-metadata.dat`,
|
/// Print a green success line for a de-obfuscated il2cpp `global-metadata.dat`,
|
||||||
/// reporting how many method tokens were remapped.
|
/// reporting how many protected method fields were remapped.
|
||||||
pub fn metadata(bar: &ProgressBar, quiet: bool, rel: &Path, remapped: usize, dest: &Path) {
|
pub fn metadata(bar: &ProgressBar, quiet: bool, rel: &Path, remapped: usize, dest: &Path) {
|
||||||
if quiet {
|
if quiet {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"{} metadata {} -> {} ({} method tokens remapped)",
|
"{} metadata {} -> {} ({} method fields remapped)",
|
||||||
"✓".green(),
|
"✓".green(),
|
||||||
rel.display(),
|
rel.display(),
|
||||||
dest.display(),
|
dest.display(),
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
//! Conservative recognition of the loader's external-file declaration.
|
||||||
|
|
||||||
|
use super::{HEADER_OFF, splice_companion};
|
||||||
|
use senbei_engine::UnpackError;
|
||||||
|
|
||||||
|
/// Recognize the observed PE32+ loader entry record, rather than guessing from
|
||||||
|
/// file size, hollow sections, or an unpack failure (all occur in single-file
|
||||||
|
/// builds too). Its last complemented immediate is a suffix appended to the
|
||||||
|
/// module path before CreateFileW; zero means reopen the module itself.
|
||||||
|
fn expects_companion(input: &[u8]) -> Option<()> {
|
||||||
|
senbei_engine::detect(input)?;
|
||||||
|
let pe = senbei_pe::parse(input).ok()?;
|
||||||
|
if !pe.is_pe32_plus
|
||||||
|
|| u16::from_le_bytes(
|
||||||
|
input
|
||||||
|
.get(pe.pe_offset + 4..pe.pe_offset + 6)?
|
||||||
|
.try_into()
|
||||||
|
.ok()?,
|
||||||
|
) != 0x8664
|
||||||
|
|| pe.sections == 0
|
||||||
|
|| pe.size_of_image == 0
|
||||||
|
|| pe.sections_offset < pe.pe_offset + 24 + 0xf0
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let headers_end = pe
|
||||||
|
.sections_offset
|
||||||
|
.checked_add(usize::from(pe.sections) * 40)?;
|
||||||
|
let header_size = u32::from_le_bytes(
|
||||||
|
input
|
||||||
|
.get(pe.pe_offset + 84..pe.pe_offset + 88)?
|
||||||
|
.try_into()
|
||||||
|
.ok()?,
|
||||||
|
) as usize;
|
||||||
|
if header_size < headers_end || header_size > input.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// A truncated section table/raw section is evidence of a broken stub, not
|
||||||
|
// sufficient evidence to diagnose a missing companion.
|
||||||
|
for section in senbei_pe::sections(input, pe).ok()? {
|
||||||
|
if section.raw_size != 0 {
|
||||||
|
let end = section.raw_offset.checked_add(section.raw_size)? as usize;
|
||||||
|
input.get(section.raw_offset as usize..end)?;
|
||||||
|
}
|
||||||
|
if section
|
||||||
|
.virtual_address
|
||||||
|
.checked_add(section.virtual_size.max(section.raw_size))?
|
||||||
|
> pe.size_of_image
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let range = senbei_pe::rva_range(input, pe, pe.entry_rva, 63).ok()?;
|
||||||
|
let entry = &input[range];
|
||||||
|
let dword = |offset: usize| u32::from_le_bytes(entry[offset..offset + 4].try_into().unwrap());
|
||||||
|
// Four call/ret entry thunks must all reach the same, file-backed loader.
|
||||||
|
if entry[0] != 0xe8 || entry[5] != 0xc3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target = i64::from(pe.entry_rva) + 5 + i64::from(dword(1) as i32);
|
||||||
|
let target = u32::try_from(target).ok()?;
|
||||||
|
senbei_pe::rva_range(input, pe, target, 1).ok()?;
|
||||||
|
for (offset, argument) in [(6, 0), (14, 1), (22, 2)] {
|
||||||
|
if entry[offset..offset + 3] != [0x6a, argument, 0xe8]
|
||||||
|
|| entry[offset + 7] != 0xc3
|
||||||
|
|| i64::from(pe.entry_rva) + offset as i64 + 7 + i64::from(dword(offset + 3) as i32)
|
||||||
|
!= i64::from(target)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entry[30..32] != [0x6a, 3]
|
||||||
|
|| [32, 37, 42, 47, 52, 57]
|
||||||
|
.iter()
|
||||||
|
.any(|&offset| entry[offset] != 0x68)
|
||||||
|
|| entry[62] != 0xc3
|
||||||
|
|| dword(48).wrapping_add(pe.entry_rva).wrapping_add(1) != 0
|
||||||
|
|| !dword(53) != HEADER_OFF as u32
|
||||||
|
|| !dword(58) != u32::from_le_bytes(*b"._\0\0")
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn prepare_companion(
|
||||||
|
input: &[u8],
|
||||||
|
companion: Option<&[u8]>,
|
||||||
|
) -> Result<Option<Vec<u8>>, UnpackError> {
|
||||||
|
let spliced = companion.and_then(|payload| splice_companion(input, payload));
|
||||||
|
if spliced.is_none() && expects_companion(input).is_some() {
|
||||||
|
return Err(if companion.is_some() {
|
||||||
|
UnpackError::CompanionMismatch
|
||||||
|
} else {
|
||||||
|
UnpackError::CompanionMissing
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(spliced)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn put32(bytes: &mut [u8], offset: usize, value: u32) {
|
||||||
|
bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generated PE and entry record; no protected binary bytes are embedded.
|
||||||
|
fn stub() -> Vec<u8> {
|
||||||
|
let mut bytes = vec![0; 0x3000];
|
||||||
|
bytes[..2].copy_from_slice(b"MZ");
|
||||||
|
put32(&mut bytes, 0x3c, 0x80);
|
||||||
|
bytes[0x80..0x84].copy_from_slice(b"PE\0\0");
|
||||||
|
bytes[0x84..0x86].copy_from_slice(&0x8664u16.to_le_bytes());
|
||||||
|
bytes[0x86..0x88].copy_from_slice(&1u16.to_le_bytes());
|
||||||
|
bytes[0x94..0x96].copy_from_slice(&0xf0u16.to_le_bytes());
|
||||||
|
bytes[0x98..0x9a].copy_from_slice(&0x20bu16.to_le_bytes());
|
||||||
|
put32(&mut bytes, 0xa8, 0x1100);
|
||||||
|
put32(&mut bytes, 0xd0, 0x4000);
|
||||||
|
put32(&mut bytes, 0xd4, 0x1000);
|
||||||
|
bytes[0x188..0x18d].copy_from_slice(b".text");
|
||||||
|
for (offset, value) in [
|
||||||
|
(0x190, 0x2000),
|
||||||
|
(0x194, 0x1000),
|
||||||
|
(0x198, 0x1000),
|
||||||
|
(0x19c, 0x2000),
|
||||||
|
] {
|
||||||
|
put32(&mut bytes, offset, value);
|
||||||
|
}
|
||||||
|
put32(&mut bytes, HEADER_OFF, 0x12345678);
|
||||||
|
put32(&mut bytes, HEADER_OFF + 4, 0x12345678 ^ 0x4e4e4f4b);
|
||||||
|
let entry = &mut bytes[0x2100..0x213f];
|
||||||
|
entry[0] = 0xe8;
|
||||||
|
put32(entry, 1, (-0x105i32) as u32);
|
||||||
|
entry[5] = 0xc3;
|
||||||
|
for (offset, argument) in [(6, 0), (14, 1), (22, 2)] {
|
||||||
|
entry[offset..offset + 3].copy_from_slice(&[0x6a, argument, 0xe8]);
|
||||||
|
put32(entry, offset + 3, (-(0x100 + offset as i32 + 7)) as u32);
|
||||||
|
entry[offset + 7] = 0xc3;
|
||||||
|
}
|
||||||
|
entry[30..32].copy_from_slice(&[0x6a, 3]);
|
||||||
|
for offset in [32, 37, 42, 47, 52, 57] {
|
||||||
|
entry[offset] = 0x68;
|
||||||
|
}
|
||||||
|
put32(entry, 48, !0x1100);
|
||||||
|
put32(entry, 53, !(HEADER_OFF as u32));
|
||||||
|
put32(entry, 58, !u32::from_le_bytes(*b"._\0\0"));
|
||||||
|
entry[62] = 0xc3;
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_companion_reaches_both_byte_entry_points() {
|
||||||
|
let input = stub();
|
||||||
|
for force in [false, true] {
|
||||||
|
let error = super::super::unpack_bytes_impl(&input, None, force)
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(error, UnpackError::CompanionMissing);
|
||||||
|
assert!(error.to_string().contains("Companion missing?"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provided_companion_is_not_reported_missing() {
|
||||||
|
let input = stub();
|
||||||
|
for payload in [&[][..], &[0; 32][..]] {
|
||||||
|
assert_eq!(
|
||||||
|
prepare_companion(&input, Some(payload)),
|
||||||
|
Err(UnpackError::CompanionMismatch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let payload = &input[HEADER_OFF..];
|
||||||
|
assert_eq!(
|
||||||
|
prepare_companion(&input, Some(payload)).unwrap().unwrap(),
|
||||||
|
input
|
||||||
|
);
|
||||||
|
// A matching but damaged payload keeps its original pipeline error.
|
||||||
|
let error = super::super::unpack_bytes(&input, Some(payload))
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!matches!(
|
||||||
|
error,
|
||||||
|
UnpackError::CompanionMissing | UnpackError::CompanionMismatch
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_file_and_damaged_or_unknown_stubs_are_not_diagnosed_as_missing() {
|
||||||
|
let input = stub();
|
||||||
|
let mut single = input.clone();
|
||||||
|
put32(&mut single, 0x213a, !0);
|
||||||
|
assert!(expects_companion(&single).is_none());
|
||||||
|
assert!(
|
||||||
|
prepare_companion(&single, Some(&[0; 32]))
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
for (offset, value) in [
|
||||||
|
(0, 0),
|
||||||
|
(0x84, 0),
|
||||||
|
(0x98, 0),
|
||||||
|
(0x1004, 0),
|
||||||
|
(0x2101, 0),
|
||||||
|
(0x2109, 0),
|
||||||
|
(0x2130, 0),
|
||||||
|
(0x2135, 0),
|
||||||
|
(0x2139, 0),
|
||||||
|
] {
|
||||||
|
let mut damaged = input.clone();
|
||||||
|
damaged[offset] = value;
|
||||||
|
assert!(expects_companion(&damaged).is_none(), "offset {offset:x}");
|
||||||
|
}
|
||||||
|
for length in [0, 0x1000, 0x101f, 0x2100, input.len() - 1] {
|
||||||
|
assert!(expects_companion(&input[..length]).is_none());
|
||||||
|
}
|
||||||
|
let mut unbacked = input.clone();
|
||||||
|
put32(&mut unbacked, 0x198, 0x100);
|
||||||
|
assert!(expects_companion(&unbacked).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filesystem_reports_expected_path_and_preserves_read_errors() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("module.dll");
|
||||||
|
let payload = dir.path().join("module.dll._");
|
||||||
|
let bytes = stub();
|
||||||
|
std::fs::write(&input, &bytes).unwrap();
|
||||||
|
let error = super::super::read_unpacker_input(&input).err().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
error.downcast_ref(),
|
||||||
|
Some(UnpackError::CompanionMissing)
|
||||||
|
));
|
||||||
|
assert!(format!("{error:#}").contains("module.dll._"));
|
||||||
|
std::fs::write(&payload, [0; 32]).unwrap();
|
||||||
|
let error = super::super::read_unpacker_input(&input).err().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
error.downcast_ref(),
|
||||||
|
Some(UnpackError::CompanionMismatch)
|
||||||
|
));
|
||||||
|
std::fs::write(&payload, &bytes[HEADER_OFF..]).unwrap();
|
||||||
|
let paired = super::super::read_unpacker_input(&input).unwrap();
|
||||||
|
assert_eq!(paired.bytes, bytes);
|
||||||
|
assert_eq!(paired.stub.as_deref(), Some(bytes.as_slice()));
|
||||||
|
std::fs::remove_file(&payload).unwrap();
|
||||||
|
std::fs::create_dir(&payload).unwrap();
|
||||||
|
let error = super::super::read_unpacker_input(&input).err().unwrap();
|
||||||
|
assert!(error.downcast_ref::<std::io::Error>().is_some());
|
||||||
|
assert!(error.downcast_ref::<UnpackError>().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_and_folder_jobs_count_missing_companion_as_error_and_log_hint() {
|
||||||
|
for folder in [false, true] {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let input = dir.path().join("module.exe");
|
||||||
|
let out = dir.path().join("unpack");
|
||||||
|
std::fs::write(&input, stub()).unwrap();
|
||||||
|
let summary = if folder {
|
||||||
|
crate::job::run_folder_v(dir.path(), Some(&out), 2, false, false)
|
||||||
|
} else {
|
||||||
|
crate::job::run_file_v(&input, Some(&out), 2, false, false)
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.errors, 1);
|
||||||
|
assert_eq!(summary.unpacked, 0);
|
||||||
|
assert!(!out.join("module.unpack.exe").exists());
|
||||||
|
let log = std::fs::read_dir(&out)
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.path();
|
||||||
|
let log = std::fs::read_to_string(log).unwrap();
|
||||||
|
assert!(log.contains("Companion missing?"), "{log}");
|
||||||
|
assert!(log.contains("module.exe._"), "{log}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
//! Windows filesystem adapter for PE companion payloads and byte APIs.
|
||||||
|
|
||||||
|
use senbei_engine as unpacker;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::atomic::write_atomic;
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
mod companion;
|
||||||
|
|
||||||
|
pub(crate) fn is_pe_extension(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe") || ext.eq_ignore_ascii_case("dll"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_companion(path: &Path) -> bool {
|
||||||
|
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(stub_name) = name.strip_suffix("._") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
is_pe_extension(Path::new(stub_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether a directory entry is an NTFS reparse point. The scanner keeps
|
||||||
|
/// this host-specific check in the Windows adapter while the traversal itself
|
||||||
|
/// remains platform-neutral.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) fn is_reparse_point(entry: &walkdir::DirEntry) -> bool {
|
||||||
|
use std::os::windows::fs::MetadataExt;
|
||||||
|
|
||||||
|
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||||
|
entry
|
||||||
|
.metadata()
|
||||||
|
.map(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub(crate) fn is_reparse_point(_entry: &walkdir::DirEntry) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crackproof header key table lives at this fixed file offset. For the
|
||||||
|
/// external-companion layout, the companion payload aligns to the stub here.
|
||||||
|
const HEADER_OFF: usize = 4096;
|
||||||
|
|
||||||
|
/// Build the unpacker input for `input`, transparently handling the
|
||||||
|
/// **external-companion** layout used by some il2cpp games.
|
||||||
|
///
|
||||||
|
/// In that layout a protected module is split into a thin on-disk loader stub
|
||||||
|
/// (`Foo.dll`, whose code sections are stripped to one page) plus an encrypted
|
||||||
|
/// `Foo.dll._` companion holding the real payload. The companion is byte-for-byte
|
||||||
|
/// the stub's payload region starting at the Crackproof header (offset 4096), so
|
||||||
|
/// `stub[..4096] ++ companion` reconstructs the ordinary embedded-payload file
|
||||||
|
/// the existing pipelines already unpack. The runtime loader does exactly this:
|
||||||
|
/// it maps `Foo.dll._` and feeds it through the standard Crackproof unpack.
|
||||||
|
///
|
||||||
|
/// The splice fires only when a sibling `<input>._` exists *and* its first 32
|
||||||
|
/// bytes equal the stub's header at offset 4096 — a precise signal that the
|
||||||
|
/// companion is this stub's payload. A recognized external-file declaration
|
||||||
|
/// without a matching payload reports a companion error. Other inputs are
|
||||||
|
/// returned untouched, so normal (embedded-payload) inputs are unaffected.
|
||||||
|
pub(crate) fn read_unpacker_input(input: &Path) -> anyhow::Result<UnpackerInput> {
|
||||||
|
let stub = std::fs::read(input)?;
|
||||||
|
|
||||||
|
// Companion path: append "._" to the full file name (Foo.dll -> Foo.dll._).
|
||||||
|
let companion = match input.file_name() {
|
||||||
|
Some(name) => {
|
||||||
|
let mut n = name.to_os_string();
|
||||||
|
n.push("._");
|
||||||
|
input.with_file_name(n)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Ok(UnpackerInput {
|
||||||
|
bytes: stub,
|
||||||
|
stub: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let comp = match std::fs::read(&companion) {
|
||||||
|
Ok(bytes) => Some(bytes),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(error)
|
||||||
|
.with_context(|| format!("couldn't read companion {}", companion.display()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match companion::prepare_companion(&stub, comp.as_deref())
|
||||||
|
.with_context(|| format!("companion {}", companion.display()))?
|
||||||
|
{
|
||||||
|
// A splice fired: keep the stub so its plaintext export table can be
|
||||||
|
// overlaid onto the unpacked image (the companion does not carry it).
|
||||||
|
Some(spliced) => Ok(UnpackerInput {
|
||||||
|
bytes: spliced,
|
||||||
|
stub: Some(stub),
|
||||||
|
}),
|
||||||
|
None => Ok(UnpackerInput {
|
||||||
|
bytes: stub,
|
||||||
|
stub: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bytes fed to the unpacker, plus the original loader stub when the input
|
||||||
|
/// was reconstructed from an external companion. The stub is retained because
|
||||||
|
/// the crackproof loader rebuilds the PE export table at runtime from data kept
|
||||||
|
/// in the stub — that table is *not* present in the encrypted companion, so the
|
||||||
|
/// unpacked image needs it overlaid from the stub afterwards
|
||||||
|
/// (see [`overlay_exports_from_stub`]).
|
||||||
|
pub(crate) struct UnpackerInput {
|
||||||
|
pub(crate) bytes: Vec<u8>,
|
||||||
|
pub(crate) stub: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overlay the PE export table from the loader `stub` onto the unpacked image
|
||||||
|
/// `out`, for the external-companion layout.
|
||||||
|
///
|
||||||
|
/// In that layout the encrypted companion carries the real `.text`/`il2cpp`
|
||||||
|
/// payload but **not** a usable export directory: the crackproof loader rebuilds
|
||||||
|
/// exports at runtime from the plaintext copy retained in the stub's `.rdata`.
|
||||||
|
/// Statically, the spliced input therefore decrypts to a garbage export
|
||||||
|
/// directory (`NumberOfFunctions` etc. are ciphertext), which makes downstream
|
||||||
|
/// tools (IL2CppDumper, IDA) choke when they parse it. The fix does what the
|
||||||
|
/// loader does: copy the export-directory region byte-for-byte from the stub to
|
||||||
|
/// the same RVA in the unpacked image.
|
||||||
|
///
|
||||||
|
/// No-op (leaves `out` untouched) if there is no export directory, or if the
|
||||||
|
/// region cannot be mapped in either image — so a malformed stub can never
|
||||||
|
/// corrupt an otherwise-good unpack.
|
||||||
|
pub(crate) fn overlay_exports_from_stub(out: &mut [u8], stub: &[u8]) {
|
||||||
|
let (export_rva, export_size) = match pe_export_dir(out) {
|
||||||
|
Some(v) if v.1 != 0 => v,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let dst = match senbei_pe::parse(out)
|
||||||
|
.and_then(|headers| senbei_pe::rva_range(out, headers, export_rva, export_size))
|
||||||
|
{
|
||||||
|
Ok(range) => range,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let src = match senbei_pe::parse(stub)
|
||||||
|
.and_then(|headers| senbei_pe::rva_range(stub, headers, export_rva, export_size))
|
||||||
|
{
|
||||||
|
Ok(range) => range,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
out[dst].copy_from_slice(&stub[src]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the CLR regions retained by an external-companion loader stub.
|
||||||
|
/// Method bodies come from the unpacked payload and must not be overlaid.
|
||||||
|
fn restore_managed_from_stub(out: &mut [u8], stub: &[u8]) -> Result<(), unpacker::UnpackError> {
|
||||||
|
let failure =
|
||||||
|
|region, source| unpacker::UnpackError::ManagedStubRestoreFailed { region, source };
|
||||||
|
let source_headers = senbei_pe::parse(stub).map_err(|e| failure("PE headers", e))?;
|
||||||
|
let (clr_rva, clr_size) = senbei_pe::data_directory(stub, source_headers, 14)
|
||||||
|
.map_err(|e| failure("CLR directory", e))?;
|
||||||
|
if clr_rva == 0 && clr_size == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if clr_rva == 0 || clr_size < 0x48 {
|
||||||
|
return Err(failure("CLR directory", senbei_pe::Error::Invalid));
|
||||||
|
}
|
||||||
|
let destination_headers = senbei_pe::parse(out).map_err(|e| failure("output PE headers", e))?;
|
||||||
|
senbei_pe::data_directory(out, destination_headers, 14)
|
||||||
|
.map_err(|e| failure("output CLR directory", e))?;
|
||||||
|
let cor = senbei_pe::rva_range(stub, source_headers, clr_rva, 0x48)
|
||||||
|
.map_err(|e| failure("COR20 header", e))?;
|
||||||
|
if read_u32(stub, cor.start) != Some(0x48) {
|
||||||
|
return Err(failure("COR20 header", senbei_pe::Error::Invalid));
|
||||||
|
}
|
||||||
|
let range_pair = |rva, size, region| {
|
||||||
|
let source = senbei_pe::rva_range(stub, source_headers, rva, size)
|
||||||
|
.map_err(|e| failure(region, e))?;
|
||||||
|
let destination = senbei_pe::rva_range(out, destination_headers, rva, size)
|
||||||
|
.map_err(|e| failure(region, e))?;
|
||||||
|
Ok::<_, unpacker::UnpackError>((source, destination))
|
||||||
|
};
|
||||||
|
let mut copies = vec![range_pair(clr_rva, 0x48, "COR20 header")?];
|
||||||
|
for (field, region) in [
|
||||||
|
(0x08, "metadata"),
|
||||||
|
(0x18, "resources"),
|
||||||
|
(0x20, "strong-name signature"),
|
||||||
|
(0x28, "code-manager table"),
|
||||||
|
(0x30, "vtable fixups"),
|
||||||
|
(0x38, "export address jumps"),
|
||||||
|
(0x40, "managed native header"),
|
||||||
|
] {
|
||||||
|
let rva = read_u32(stub, cor.start + field)
|
||||||
|
.ok_or_else(|| failure(region, senbei_pe::Error::OutOfBounds))?;
|
||||||
|
let size = read_u32(stub, cor.start + field + 4)
|
||||||
|
.ok_or_else(|| failure(region, senbei_pe::Error::OutOfBounds))?;
|
||||||
|
if field != 0x08 && rva == 0 && size == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if rva == 0 || size == 0 {
|
||||||
|
return Err(failure(region, senbei_pe::Error::Invalid));
|
||||||
|
}
|
||||||
|
let (source, destination) = range_pair(rva, size, region)?;
|
||||||
|
if field == 0x08 && !stub[source.clone()].starts_with(b"BSJB") {
|
||||||
|
return Err(failure(region, senbei_pe::Error::Invalid));
|
||||||
|
}
|
||||||
|
if field == 0x30 {
|
||||||
|
if !size.is_multiple_of(8) {
|
||||||
|
return Err(failure(region, senbei_pe::Error::Invalid));
|
||||||
|
}
|
||||||
|
for fixup in stub[source.clone()].as_chunks::<8>().0 {
|
||||||
|
let slots_rva =
|
||||||
|
u32::from_le_bytes(fixup[..4].try_into().expect("eight-byte fixup"));
|
||||||
|
let count = u16::from_le_bytes([fixup[4], fixup[5]]) as u32;
|
||||||
|
let flags = u16::from_le_bytes([fixup[6], fixup[7]]);
|
||||||
|
let width = match flags & 3 {
|
||||||
|
1 => 4,
|
||||||
|
2 => 8,
|
||||||
|
_ => return Err(failure(region, senbei_pe::Error::Invalid)),
|
||||||
|
};
|
||||||
|
if count != 0 {
|
||||||
|
copies.push(range_pair(slots_rva, count * width, "vtable slots")?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copies.push((source, destination));
|
||||||
|
}
|
||||||
|
// Validate all referenced ranges before changing the output.
|
||||||
|
for (source, destination) in copies {
|
||||||
|
out[destination].copy_from_slice(&stub[source]);
|
||||||
|
}
|
||||||
|
let directory = destination_headers.pe_offset
|
||||||
|
+ 24
|
||||||
|
+ if destination_headers.is_pe32_plus {
|
||||||
|
112
|
||||||
|
} else {
|
||||||
|
96
|
||||||
|
}
|
||||||
|
+ 14 * 8;
|
||||||
|
out[directory..directory + 4].copy_from_slice(&clr_rva.to_le_bytes());
|
||||||
|
out[directory + 4..directory + 8].copy_from_slice(&clr_size.to_le_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the TLS directory from the loader `stub` onto the unpacked image
|
||||||
|
/// `out`, for the external-companion layout.
|
||||||
|
///
|
||||||
|
/// Crackproof strips the whole `IMAGE_TLS_DIRECTORY` from the encrypted payload
|
||||||
|
/// — the data-directory entry, the directory struct, the raw-data template, and
|
||||||
|
/// the base relocations for the struct's four 64-bit pointer fields — and
|
||||||
|
/// re-installs TLS itself from data kept in the stub when it loads the module.
|
||||||
|
/// A statically-unpacked DLL is loaded by the ordinary Windows loader instead,
|
||||||
|
/// which needs a valid TLS directory or it never allocates a TLS slot for the
|
||||||
|
/// module nor writes `_tls_index`. The module's C++ `thread_local` accesses then
|
||||||
|
/// read a garbage TLS slot — observed as a `0xC0000005` deep in IL2CPP type
|
||||||
|
/// resolution (a TypeDef token used as a raw `s_TypeInfoTable` index).
|
||||||
|
///
|
||||||
|
/// The stub retains the full plaintext `.rdata` (only `.text`/`il2cpp` are
|
||||||
|
/// stripped to one page), so the directory struct and its raw-data template are
|
||||||
|
/// copied back byte-for-byte at their RVAs, the data-directory entry is taken
|
||||||
|
/// from the stub header (the unpacked image's was overwritten with the zeroed
|
||||||
|
/// saved-header blob), and four DIR64 relocations are appended to `.reloc`.
|
||||||
|
///
|
||||||
|
/// No-op if the stub declares no TLS directory or if any required region cannot
|
||||||
|
/// be mapped/relocated — so it can never corrupt an otherwise-good unpack.
|
||||||
|
pub(crate) fn restore_tls_from_stub(out: &mut [u8], stub: &[u8]) {
|
||||||
|
let pe = match read_u32(out, 0x3C) {
|
||||||
|
Some(v) => v as usize,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
if out.get(pe..pe + 4) != Some(&b"PE\0\0"[..]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// This restore is PE32+-only: it copies a 40-byte IMAGE_TLS_DIRECTORY64,
|
||||||
|
// converts fields with a 64-bit image base, and appends DIR64 relocs. A
|
||||||
|
// PE32 module needs the 24-byte struct / DIR32 handling (the unpacker core
|
||||||
|
// does that itself — see `restore_pe32_tls_from_stub`), so bail rather than
|
||||||
|
// read the data directories at the wrong (PE32+) offset and write garbage.
|
||||||
|
if read_u16(out, pe + 24) != Some(0x20B) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TLS is data-directory index 9 (PE32+ directories at optional header +112).
|
||||||
|
let tls_dd = match pe.checked_add(24 + 112 + 9 * 8) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
// The genuine entry survives in the stub header; the unpacked image's copy
|
||||||
|
// was clobbered by the (zeroed-TLS) saved-header blob.
|
||||||
|
let (tls_rva, tls_size) = match (read_u32(stub, tls_dd), read_u32(stub, tls_dd + 4)) {
|
||||||
|
(Some(r), Some(s)) if r != 0 && s != 0 => (r, s),
|
||||||
|
_ => return, // module has no TLS — nothing to restore
|
||||||
|
};
|
||||||
|
// Image base (PE32+, optional header +24) converts the struct's absolute VAs
|
||||||
|
// back to RVAs for the raw-data template overlay.
|
||||||
|
let image_base = match read_u64(out, pe + 24 + 24) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Overlay the IMAGE_TLS_DIRECTORY struct from the stub at its RVA.
|
||||||
|
let dst = match rva_to_file_off(out, tls_rva) {
|
||||||
|
Some(o) => o,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let src = match rva_to_file_off(stub, tls_rva) {
|
||||||
|
Some(o) => o,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let n = tls_size as usize;
|
||||||
|
if dst.checked_add(n).is_none_or(|e| e > out.len())
|
||||||
|
|| src.checked_add(n).is_none_or(|e| e > stub.len())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out[dst..dst + n].copy_from_slice(&stub[src..src + n]);
|
||||||
|
|
||||||
|
// 2) Restore the data-directory entry so the loader processes TLS at all.
|
||||||
|
write_u32_at(out, tls_dd, tls_rva);
|
||||||
|
write_u32_at(out, tls_dd + 4, tls_size);
|
||||||
|
|
||||||
|
// 3) Overlay the raw-data template [StartAddressOfRawData, EndAddressOfRawData).
|
||||||
|
if let (Some(start_va), Some(end_va)) = (read_u64(out, dst), read_u64(out, dst + 8))
|
||||||
|
&& end_va > start_va
|
||||||
|
&& start_va >= image_base
|
||||||
|
{
|
||||||
|
let tpl_rva = (start_va - image_base) as u32;
|
||||||
|
let tpl_len = (end_va - start_va) as usize;
|
||||||
|
if let (Some(td), Some(ts)) = (
|
||||||
|
rva_to_file_off(out, tpl_rva),
|
||||||
|
rva_to_file_off(stub, tpl_rva),
|
||||||
|
) && td.checked_add(tpl_len).is_some_and(|e| e <= out.len())
|
||||||
|
&& ts.checked_add(tpl_len).is_some_and(|e| e <= stub.len())
|
||||||
|
{
|
||||||
|
out[td..td + tpl_len].copy_from_slice(&stub[ts..ts + tpl_len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Append DIR64 relocations for the struct's four 64-bit pointer fields
|
||||||
|
// (Start/End/Index/CallBacks at +0/+8/+0x10/+0x18). Without them the
|
||||||
|
// loader would leave preferred-base VAs in a rebased image.
|
||||||
|
add_tls_relocs(out, pe, tls_rva);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a single base-relocation block covering the four 64-bit pointer fields
|
||||||
|
/// of the TLS directory struct at `tls_rva`. The block is written immediately
|
||||||
|
/// after the existing relocation table (which must be free space and in bounds)
|
||||||
|
/// and the BaseReloc directory size is grown to include it. No-op if the table
|
||||||
|
/// is absent, the fields straddle a relocation page, or the slot is not free.
|
||||||
|
fn add_tls_relocs(out: &mut [u8], pe: usize, tls_rva: u32) {
|
||||||
|
let reloc_dd = pe + 24 + 112 + 5 * 8; // BaseReloc = directory index 5
|
||||||
|
let (reloc_rva, reloc_size) = match (read_u32(out, reloc_dd), read_u32(out, reloc_dd + 4)) {
|
||||||
|
(Some(r), Some(s)) if r != 0 => (r, s),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
// All four fields (last at +0x18) must share one 0x1000 relocation page.
|
||||||
|
let page = tls_rva & !0xFFF;
|
||||||
|
if (tls_rva.wrapping_add(0x18)) & !0xFFF != page {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const BLOCK: usize = 8 + 4 * 2; // header + four DIR64 entries
|
||||||
|
let at = match rva_to_file_off(out, reloc_rva.wrapping_add(reloc_size)) {
|
||||||
|
Some(o) => o,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
if at.checked_add(BLOCK).is_none_or(|e| e > out.len()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if out[at..at + BLOCK].iter().any(|&b| b != 0) {
|
||||||
|
return; // refuse to clobber existing data
|
||||||
|
}
|
||||||
|
write_u32_at(out, at, page);
|
||||||
|
write_u32_at(out, at + 4, BLOCK as u32);
|
||||||
|
for (i, off) in [0u32, 8, 0x10, 0x18].iter().enumerate() {
|
||||||
|
let entry = (10u16 << 12) | (((tls_rva.wrapping_add(*off)) & 0xFFF) as u16);
|
||||||
|
let p = at + 8 + i * 2;
|
||||||
|
out[p..p + 2].copy_from_slice(&entry.to_le_bytes());
|
||||||
|
}
|
||||||
|
write_u32_at(out, reloc_dd + 4, reloc_size.wrapping_add(BLOCK as u32));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the Export data-directory (RVA, size) from a PE image, or `None` if the
|
||||||
|
/// headers are too short/invalid to parse.
|
||||||
|
fn pe_export_dir(buf: &[u8]) -> Option<(u32, u32)> {
|
||||||
|
let headers = senbei_pe::parse(buf).ok()?;
|
||||||
|
senbei_pe::data_directory(buf, headers, 0).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an RVA to a file offset using the PE section table. Returns `None` if no
|
||||||
|
/// section contains the RVA or the headers cannot be parsed.
|
||||||
|
fn rva_to_file_off(buf: &[u8], rva: u32) -> Option<usize> {
|
||||||
|
let headers = senbei_pe::parse(buf).ok()?;
|
||||||
|
senbei_pe::rva_to_offset(buf, headers, rva).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(buf: &[u8], off: usize) -> Option<u32> {
|
||||||
|
let b = buf.get(off..off + 4)?;
|
||||||
|
Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u16(buf: &[u8], off: usize) -> Option<u16> {
|
||||||
|
let b = buf.get(off..off + 2)?;
|
||||||
|
Some(u16::from_le_bytes([b[0], b[1]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u64(buf: &[u8], off: usize) -> Option<u64> {
|
||||||
|
let b = buf.get(off..off + 8)?;
|
||||||
|
Some(u64::from_le_bytes([
|
||||||
|
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a little-endian `u32` at `off`, silently doing nothing if out of bounds.
|
||||||
|
fn write_u32_at(buf: &mut [u8], off: usize, val: u32) {
|
||||||
|
if let Some(slot) = buf.get_mut(off..off + 4) {
|
||||||
|
slot.copy_from_slice(&val.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splice a stub and its external-companion payload into the embedded-payload
|
||||||
|
/// form the pipelines expect, or `None` if `comp` is not this stub's payload.
|
||||||
|
///
|
||||||
|
/// The companion is byte-for-byte the stub's payload region from the Crackproof
|
||||||
|
/// header (offset 4096) onward, so the result is `stub[..4096] ++ comp`. The
|
||||||
|
/// splice fires only when the first 32 bytes of `comp` equal the stub's header
|
||||||
|
/// at offset 4096 — a 32-byte match on the key-table/magic region that confirms
|
||||||
|
/// the pairing and leaves ordinary (non-companion) inputs untouched.
|
||||||
|
pub(crate) fn splice_companion(stub: &[u8], comp: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let hdr_end = HEADER_OFF + 32;
|
||||||
|
if stub.len() >= hdr_end && comp.len() >= 32 && stub[HEADER_OFF..hdr_end] == comp[..32] {
|
||||||
|
let mut spliced = Vec::with_capacity(HEADER_OFF + comp.len());
|
||||||
|
spliced.extend_from_slice(&stub[..HEADER_OFF]);
|
||||||
|
spliced.extend_from_slice(comp);
|
||||||
|
return Some(spliced);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect `bytes` and run the right pipeline. Spliced external companions use
|
||||||
|
/// the EXE pipeline directly because that layout is definitionally EXE-style.
|
||||||
|
///
|
||||||
|
/// Routing spliced inputs straight to the EXE pipeline is safe: the
|
||||||
|
/// companion layout is definitionally the EXE-style shell (the runtime
|
||||||
|
/// loader maps the companion and runs the standard shell unpack), so the DLL
|
||||||
|
/// pipeline probe can never be right for it. Output bytes are identical to the
|
||||||
|
/// DLL-first + EXE-fallback route for every input that route handles.
|
||||||
|
pub(crate) fn unpack_spliced_or_auto(
|
||||||
|
bytes: &[u8],
|
||||||
|
spliced: bool,
|
||||||
|
force_exe: bool,
|
||||||
|
verbose: bool,
|
||||||
|
) -> Result<(unpacker::Kind, Vec<u8>), unpacker::UnpackError> {
|
||||||
|
if spliced || force_exe {
|
||||||
|
let detected = unpacker::detect(bytes).ok_or(unpacker::UnpackError::NotCrackproof)?;
|
||||||
|
let out = unpacker::unpack_exe_v(bytes, verbose)?;
|
||||||
|
return Ok((detected.kind, out));
|
||||||
|
}
|
||||||
|
unpacker::unpack_auto_v(bytes, verbose)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack a single file to `dest`. Returns the Kind and integrity report on success.
|
||||||
|
pub fn unpack_one(
|
||||||
|
input: &Path,
|
||||||
|
dest: &Path,
|
||||||
|
) -> anyhow::Result<(unpacker::Kind, unpacker::IntegrityReport)> {
|
||||||
|
unpack_one_v(input, dest, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a byte-level unpack ([`unpack_bytes`]): the image, its detected
|
||||||
|
/// kind, and its integrity report. No file I/O is involved.
|
||||||
|
pub struct UnpackedImage {
|
||||||
|
pub kind: unpacker::Kind,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
pub integrity: unpacker::IntegrityReport,
|
||||||
|
/// True when the input was reconstructed from an external companion (the
|
||||||
|
/// `._` layout), i.e. the export/TLS overlays ran.
|
||||||
|
pub companion: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack in-memory `input` bytes, optionally paired with an external
|
||||||
|
/// companion payload `companion` (the `<input>._` file's contents).
|
||||||
|
///
|
||||||
|
/// This is the in-memory counterpart of [`unpack_one_v`]: splice a matching
|
||||||
|
/// companion, unpack, overlay the export table and TLS directory from the stub,
|
||||||
|
/// then run the static integrity check.
|
||||||
|
pub fn unpack_bytes(
|
||||||
|
input: &[u8],
|
||||||
|
companion: Option<&[u8]>,
|
||||||
|
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||||
|
unpack_bytes_impl(input, companion, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`unpack_bytes`], but forces the EXE pipeline (no DLL-pipeline
|
||||||
|
/// probe). This is the web app's recovery path: the DLL-first probe relies
|
||||||
|
/// on `catch_unwind` to reject EXE-shell-layout DLLs, and panics cannot be
|
||||||
|
/// caught on wasm — the probe traps the whole call. The web app runs each
|
||||||
|
/// unpack in a disposable Web Worker and retries trapped DLLs with this
|
||||||
|
/// entry point, reproducing the CLI's dll-first/exe-fallback routing.
|
||||||
|
pub fn unpack_bytes_force_exe(
|
||||||
|
input: &[u8],
|
||||||
|
companion: Option<&[u8]>,
|
||||||
|
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||||
|
unpack_bytes_impl(input, companion, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unpack_bytes_impl(
|
||||||
|
input: &[u8],
|
||||||
|
companion: Option<&[u8]>,
|
||||||
|
force_exe: bool,
|
||||||
|
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||||
|
let spliced = companion::prepare_companion(input, companion)?;
|
||||||
|
let bytes: &[u8] = spliced.as_deref().unwrap_or(input);
|
||||||
|
let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), force_exe, false)?;
|
||||||
|
if spliced.is_some() {
|
||||||
|
overlay_exports_from_stub(&mut out, input);
|
||||||
|
restore_tls_from_stub(&mut out, input);
|
||||||
|
restore_managed_from_stub(&mut out, input)?;
|
||||||
|
}
|
||||||
|
let integrity = unpacker::check_integrity(&out);
|
||||||
|
Ok(UnpackedImage {
|
||||||
|
kind,
|
||||||
|
bytes: out,
|
||||||
|
integrity,
|
||||||
|
companion: spliced.is_some(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`unpack_one`], but prints detailed `[N/9]` step progress (and a final
|
||||||
|
/// `Write to <dest>` line) to stdout when `verbose` is true.
|
||||||
|
pub fn unpack_one_v(
|
||||||
|
input: &Path,
|
||||||
|
dest: &Path,
|
||||||
|
verbose: bool,
|
||||||
|
) -> anyhow::Result<(unpacker::Kind, unpacker::IntegrityReport)> {
|
||||||
|
let UnpackerInput { bytes, stub } = read_unpacker_input(input)?;
|
||||||
|
let (kind, mut out) = unpack_spliced_or_auto(&bytes, stub.is_some(), false, verbose)?;
|
||||||
|
// External-companion layout: restore the export table from the stub, which
|
||||||
|
// the encrypted companion does not carry (the loader rebuilds it at runtime).
|
||||||
|
if let Some(stub) = stub {
|
||||||
|
overlay_exports_from_stub(&mut out, &stub);
|
||||||
|
// ...and the TLS directory, which Crackproof strips from the payload and
|
||||||
|
// re-installs at runtime; the ordinary loader needs it or thread_local
|
||||||
|
// access crashes (see [`restore_tls_from_stub`]).
|
||||||
|
restore_tls_from_stub(&mut out, &stub);
|
||||||
|
restore_managed_from_stub(&mut out, &stub)?;
|
||||||
|
}
|
||||||
|
let report = unpacker::check_integrity(&out);
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
write_atomic(dest, &out)?;
|
||||||
|
if verbose {
|
||||||
|
println!("Write to {}", dest.display());
|
||||||
|
}
|
||||||
|
Ok((kind, report))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const HEADER_OFF: usize = 4096;
|
||||||
|
|
||||||
|
fn stub_with_header(header: &[u8; 32], extra: usize) -> Vec<u8> {
|
||||||
|
let mut stub = vec![0_u8; HEADER_OFF];
|
||||||
|
stub.extend_from_slice(header);
|
||||||
|
stub.extend_from_slice(&vec![0xAA_u8; extra]);
|
||||||
|
stub
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splices_when_header_matches() {
|
||||||
|
let header = [7_u8; 32];
|
||||||
|
let stub = stub_with_header(&header, 16);
|
||||||
|
let mut companion = header.to_vec();
|
||||||
|
companion.extend_from_slice(&[0x42_u8; 1000]);
|
||||||
|
|
||||||
|
let output = splice_companion(&stub, &companion).expect("should splice");
|
||||||
|
assert_eq!(output.len(), HEADER_OFF + companion.len());
|
||||||
|
assert_eq!(&output[..HEADER_OFF], &stub[..HEADER_OFF]);
|
||||||
|
assert_eq!(&output[HEADER_OFF..], &companion[..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_splice_when_header_differs() {
|
||||||
|
let stub = stub_with_header(&[7_u8; 32], 16);
|
||||||
|
let mut companion = vec![9_u8; 32];
|
||||||
|
companion.extend_from_slice(&[0x42_u8; 1000]);
|
||||||
|
assert!(splice_companion(&stub, &companion).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_splice_when_too_short() {
|
||||||
|
let short_stub = vec![0_u8; HEADER_OFF + 8];
|
||||||
|
let companion = vec![0_u8; 64];
|
||||||
|
assert!(splice_companion(&short_stub, &companion).is_none());
|
||||||
|
|
||||||
|
let stub = stub_with_header(&[1_u8; 32], 0);
|
||||||
|
let short_companion = vec![1_u8; 16];
|
||||||
|
assert!(splice_companion(&stub, &short_companion).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn managed_fixture(is_pe32_plus: bool, raw: usize) -> Vec<u8> {
|
||||||
|
let mut data = vec![0; raw + 0x600];
|
||||||
|
data[..2].copy_from_slice(b"MZ");
|
||||||
|
data[0x80..0x84].copy_from_slice(b"PE\0\0");
|
||||||
|
let optional_size = if is_pe32_plus { 0xf0u16 } else { 0xe0 };
|
||||||
|
let section = 0x98 + optional_size as usize;
|
||||||
|
let dirs = 0x98 + if is_pe32_plus { 112 } else { 96 };
|
||||||
|
for (offset, value) in [
|
||||||
|
(0x86, 1u16),
|
||||||
|
(0x94, optional_size),
|
||||||
|
(0x98, if is_pe32_plus { 0x20b } else { 0x10b }),
|
||||||
|
(raw + 0x204, 2),
|
||||||
|
(raw + 0x206, if is_pe32_plus { 2 } else { 1 }),
|
||||||
|
] {
|
||||||
|
data[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
for (offset, value) in [
|
||||||
|
(0x3c, 0x80u32),
|
||||||
|
(0xd0, 0x3000),
|
||||||
|
(0xd4, 0x400),
|
||||||
|
(dirs + 14 * 8, 0x2010),
|
||||||
|
(dirs + 14 * 8 + 4, 0x48),
|
||||||
|
(section + 8, 0x600),
|
||||||
|
(section + 12, 0x2000),
|
||||||
|
(section + 16, 0x600),
|
||||||
|
(section + 20, raw as u32),
|
||||||
|
(raw + 0x10, 0x48),
|
||||||
|
(raw + 0x18, 0x2100),
|
||||||
|
(raw + 0x1c, 0x20),
|
||||||
|
(raw + 0x28, 0x2180),
|
||||||
|
(raw + 0x2c, 8),
|
||||||
|
(raw + 0x40, 0x2200),
|
||||||
|
(raw + 0x44, 8),
|
||||||
|
(raw + 0x200, 0x2280),
|
||||||
|
(raw + 0x280, 0x0600_0001),
|
||||||
|
] {
|
||||||
|
data[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
data[raw + 0x100..raw + 0x104].copy_from_slice(b"BSJB");
|
||||||
|
data[raw + 0x180..raw + 0x188].copy_from_slice(b"resource");
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_overlay_requires_the_entire_range_to_be_file_backed() {
|
||||||
|
for is_pe32_plus in [false, true] {
|
||||||
|
let dirs = 0x98 + if is_pe32_plus { 112 } else { 96 };
|
||||||
|
let section = 0x98 + if is_pe32_plus { 0xf0 } else { 0xe0 };
|
||||||
|
let mut stub = managed_fixture(is_pe32_plus, 0x600);
|
||||||
|
stub[0x780..0x880].fill(0x5a);
|
||||||
|
for (stub_raw_size, out_raw_size) in
|
||||||
|
[(0x600u32, 0x600u32), (0x200, 0x600), (0x600, 0x200)]
|
||||||
|
{
|
||||||
|
let mut out = managed_fixture(is_pe32_plus, 0x400);
|
||||||
|
out[dirs..dirs + 4].copy_from_slice(&0x2180u32.to_le_bytes());
|
||||||
|
out[dirs + 4..dirs + 8].copy_from_slice(&0x100u32.to_le_bytes());
|
||||||
|
out[section + 16..section + 20].copy_from_slice(&out_raw_size.to_le_bytes());
|
||||||
|
stub[section + 16..section + 20].copy_from_slice(&stub_raw_size.to_le_bytes());
|
||||||
|
let before = out.clone();
|
||||||
|
overlay_exports_from_stub(&mut out, &stub);
|
||||||
|
if stub_raw_size == 0x600 && out_raw_size == 0x600 {
|
||||||
|
assert_eq!(&out[0x580..0x680], &[0x5a; 0x100]);
|
||||||
|
assert_eq!(&out[..0x580], &before[..0x580]);
|
||||||
|
assert_eq!(&out[0x680..], &before[0x680..]);
|
||||||
|
} else {
|
||||||
|
assert_eq!(out, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_companion_restores_rva_mapped_regions_without_overwriting_il() {
|
||||||
|
for is_pe32_plus in [false, true] {
|
||||||
|
let stub = managed_fixture(is_pe32_plus, 0x600);
|
||||||
|
let mut out = managed_fixture(is_pe32_plus, 0x400);
|
||||||
|
out[0x400..].fill(0xcc);
|
||||||
|
restore_managed_from_stub(&mut out, &stub).unwrap();
|
||||||
|
for (offset, size) in [
|
||||||
|
(0x10, 0x48),
|
||||||
|
(0x100, 0x20),
|
||||||
|
(0x180, 8),
|
||||||
|
(0x200, 8),
|
||||||
|
(0x280, if is_pe32_plus { 16 } else { 8 }),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
&out[0x400 + offset..0x400 + offset + size],
|
||||||
|
&stub[0x600 + offset..0x600 + offset + size]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(out[0x700..0x740].iter().all(|&b| b == 0xcc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn managed_companion_rejects_invalid_metadata_and_unbacked_vtable_slots() {
|
||||||
|
for broken_metadata in [true, false] {
|
||||||
|
let mut stub = managed_fixture(false, 0x600);
|
||||||
|
if broken_metadata {
|
||||||
|
stub[0x700..0x704].fill(0);
|
||||||
|
} else {
|
||||||
|
stub[0x800..0x804].copy_from_slice(&0x2600u32.to_le_bytes());
|
||||||
|
}
|
||||||
|
let mut out = managed_fixture(false, 0x400);
|
||||||
|
let before = out.clone();
|
||||||
|
assert!(matches!(
|
||||||
|
restore_managed_from_stub(&mut out, &stub),
|
||||||
|
Err(unpacker::UnpackError::ManagedStubRestoreFailed { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(out, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! Extraction of the embedded-metadata packaging variant.
|
||||||
|
//!
|
||||||
|
//! Some protected il2cpp builds ship no `global-metadata.dat` in the app's
|
||||||
|
//! assets at all. Instead a slim metadata blob (an older header format with
|
||||||
|
//! custom record layouts) is embedded in the protected library's data section
|
||||||
|
//! and wrapped in a per-word XOR layer: a 0x100-byte header whose 64 words each
|
||||||
|
//! carry their own key, followed by exactly 256 segments with one u32 key each
|
||||||
|
//! at irregular boundaries. At runtime the protector's il2cpp-side modules
|
||||||
|
//! regenerate the keys and unwrap the blob in place; the keys are stored
|
||||||
|
//! nowhere in the image.
|
||||||
|
//!
|
||||||
|
//! For the one observed build using this variant the full keystream was
|
||||||
|
//! recovered from a ciphertext/plaintext pair and is embedded in
|
||||||
|
//! [`crate::keystream`]. Extraction is therefore content-gated: the wrapped
|
||||||
|
//! header's first plaintext words are known constants, so a restored image that
|
||||||
|
//! does not contain them (every other build) is skipped cheaply and nothing is
|
||||||
|
//! written.
|
||||||
|
//!
|
||||||
|
//! The unwrapped blob stores its patched sanity/version fields byte-swapped;
|
||||||
|
//! they are rewritten to the standard il2cpp metadata magic and version so the
|
||||||
|
//! output is a well-formed `global-metadata.dat`.
|
||||||
|
|
||||||
|
use super::keystream::{HEADER_KEYS, SEGMENTS};
|
||||||
|
|
||||||
|
/// Standard il2cpp metadata sanity magic written over the patched header.
|
||||||
|
const STANDARD_MAGIC: u32 = 0xfab1_1baf;
|
||||||
|
/// Standard header version matching the blob's record layout.
|
||||||
|
const STANDARD_VERSION: u32 = 24;
|
||||||
|
|
||||||
|
/// Plaintext of the first two wrapped header words (the byte-swapped patched
|
||||||
|
/// sanity/version pair). Also the probe pattern: a restored image contains the
|
||||||
|
/// embedded blob iff `word[0] ^ HEADER_KEYS[0]` and `word[1] ^ HEADER_KEYS[1]`
|
||||||
|
/// equal these constants at some 4-aligned offset.
|
||||||
|
const PROBE_WORDS: [u32; 2] = [0x9732_ca38, 0xbac4_374f];
|
||||||
|
|
||||||
|
/// Size of the wrapped blob: the last segment's end offset.
|
||||||
|
pub fn embedded_metadata_size() -> usize {
|
||||||
|
SEGMENTS[SEGMENTS.len() - 1].0 as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate and unwrap the embedded metadata blob in a restored library image.
|
||||||
|
///
|
||||||
|
/// Returns a standalone, well-formed `global-metadata.dat`, or `None` when the
|
||||||
|
/// image carries no blob wrapped with the known keystream.
|
||||||
|
pub fn extract_embedded_metadata(image: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let total = embedded_metadata_size();
|
||||||
|
let offset = find_wrapped_header(image)?;
|
||||||
|
let blob = image.get(offset..offset.checked_add(total)?)?;
|
||||||
|
|
||||||
|
let mut out = blob.to_vec();
|
||||||
|
for (i, &key) in HEADER_KEYS.iter().enumerate() {
|
||||||
|
xor_word(&mut out, 4 * i, key);
|
||||||
|
}
|
||||||
|
let mut pos = 0x100_usize;
|
||||||
|
for &(end, key) in &SEGMENTS {
|
||||||
|
let end = end as usize;
|
||||||
|
let mut o = pos;
|
||||||
|
while o + 4 <= end {
|
||||||
|
xor_word(&mut out, o, key);
|
||||||
|
o += 4;
|
||||||
|
}
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
out[0..4].copy_from_slice(&STANDARD_MAGIC.to_le_bytes());
|
||||||
|
out[4..8].copy_from_slice(&STANDARD_VERSION.to_le_bytes());
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan `image` for the wrapped header probe pattern (4-aligned).
|
||||||
|
fn find_wrapped_header(image: &[u8]) -> Option<usize> {
|
||||||
|
let mut off = 0;
|
||||||
|
while off + 8 <= image.len() {
|
||||||
|
let word = u32::from_le_bytes(image[off..off + 4].try_into().ok()?);
|
||||||
|
if word ^ HEADER_KEYS[0] == PROBE_WORDS[0] {
|
||||||
|
let next = u32::from_le_bytes(image[off + 4..off + 8].try_into().ok()?);
|
||||||
|
if next ^ HEADER_KEYS[1] == PROBE_WORDS[1] {
|
||||||
|
return Some(off);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
off += 4;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xor_word(data: &mut [u8], offset: usize, key: u32) {
|
||||||
|
let word = u32::from_le_bytes(data[offset..offset + 4].try_into().expect("word in bounds"));
|
||||||
|
data[offset..offset + 4].copy_from_slice(&(word ^ key).to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Wrap a synthetic blob with the keystream, then unwrap it back.
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_wrapped_blob() {
|
||||||
|
let total = embedded_metadata_size();
|
||||||
|
let mut image = vec![0_u8; total + 0x40];
|
||||||
|
// Plaintext blob: standard probe words, then a ramp.
|
||||||
|
image[0..4].copy_from_slice(&PROBE_WORDS[0].to_le_bytes());
|
||||||
|
image[4..8].copy_from_slice(&PROBE_WORDS[1].to_le_bytes());
|
||||||
|
for o in (8..total).step_by(4) {
|
||||||
|
let v = (o as u32).wrapping_mul(0x9e37_79b1);
|
||||||
|
image[o..o + 4].copy_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
// Wrap with the keystream.
|
||||||
|
for (i, &key) in HEADER_KEYS.iter().enumerate() {
|
||||||
|
xor_word(&mut image, 4 * i, key);
|
||||||
|
}
|
||||||
|
let mut pos = 0x100_usize;
|
||||||
|
for &(end, key) in &SEGMENTS {
|
||||||
|
let mut o = pos;
|
||||||
|
while o + 4 <= end as usize {
|
||||||
|
xor_word(&mut image, o, key);
|
||||||
|
o += 4;
|
||||||
|
}
|
||||||
|
pos = end as usize;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = extract_embedded_metadata(&image).expect("blob found");
|
||||||
|
assert_eq!(out.len(), total);
|
||||||
|
// Header rewritten to the standard magic/version…
|
||||||
|
assert_eq!(&out[0..4], &STANDARD_MAGIC.to_le_bytes());
|
||||||
|
assert_eq!(&out[4..8], &STANDARD_VERSION.to_le_bytes());
|
||||||
|
// …and the body round-trips.
|
||||||
|
for o in (8..total).step_by(4) {
|
||||||
|
let v = (o as u32).wrapping_mul(0x9e37_79b1);
|
||||||
|
assert_eq!(&out[o..o + 4], &v.to_le_bytes(), "word at {o:#x}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_blob_in_plain_data() {
|
||||||
|
let image = vec![0xAB_u8; 0x1000];
|
||||||
|
assert!(extract_embedded_metadata(&image).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/// Per-word XOR keystream for the embedded-metadata packaging variant,
|
||||||
|
/// recovered from a ciphertext/plaintext pair of one observed build.
|
||||||
|
/// Key derivation for future builds is untraced; other builds simply do
|
||||||
|
/// not match the header probe and are left untouched.
|
||||||
|
pub(crate) const HEADER_KEYS: [u32; 64] = [
|
||||||
|
0x39184c70, 0xd901afd4, 0x19b98815, 0x132906ed, 0x663e8ace, 0x299b1952, 0xe5404ab8, 0xd93b331c,
|
||||||
|
0xb67d3761, 0x42da9259, 0xc29c7a59, 0x17cb841c, 0xd0bcb9c6, 0x21db779b, 0x43874deb, 0x89bf697b,
|
||||||
|
0x0b7f97b4, 0xbe1c59f7, 0xc653ad92, 0x8cdf4336, 0x5e0b6b68, 0x1bd4d668, 0x7250ed61, 0x31a36491,
|
||||||
|
0xaf144dcd, 0xc1e387d0, 0x9d6df5b7, 0x78514f32, 0xc2648cbf, 0x3b8272a5, 0xd2053679, 0x4b18af77,
|
||||||
|
0x71b9ebdd, 0x0094daaa, 0xf3adfed8, 0xc0d082bc, 0xae5e523c, 0xa8dec0be, 0x090a7784, 0x2c0483d6,
|
||||||
|
0x95f0e8f7, 0x234de6d4, 0xa7464527, 0x3b1c531d, 0xc2b31d82, 0xe1c60be0, 0x3d65a0c2, 0x2ea7d77a,
|
||||||
|
0x4ababadb, 0xce484b16, 0x59ab3f99, 0x10a9a463, 0x70e2f78a, 0x0ed71c9c, 0xf8996b2e, 0xff637928,
|
||||||
|
0xf413313d, 0x77c57bf9, 0xdab41dba, 0x0cd2ccbc, 0x3b2fbde3, 0x0b19b14d, 0xd2645dbc, 0x318113d4,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// (segment end offset, segment key) pairs; offsets relative to blob start.
|
||||||
|
pub(crate) const SEGMENTS: [(u32, u32); 256] = [
|
||||||
|
(0x3915c, 0xbb5dda1a),
|
||||||
|
(0x736b4, 0x906dbe0f),
|
||||||
|
(0xaedc4, 0x1e4ca8bd),
|
||||||
|
(0xc506c, 0xe603cb21),
|
||||||
|
(0xdfe34, 0x93bec702),
|
||||||
|
(0xe8f10, 0x9a9f429f),
|
||||||
|
(0x139088, 0x125a8b3f),
|
||||||
|
(0x20bd30, 0x69a6395f),
|
||||||
|
(0x20c548, 0xca807b9a),
|
||||||
|
(0x20c774, 0x8b8880c4),
|
||||||
|
(0x300518, 0x48087852),
|
||||||
|
(0x36c07c, 0x32aa7b5b),
|
||||||
|
(0x448b7c, 0x2e668589),
|
||||||
|
(0x4592b0, 0x292e07d9),
|
||||||
|
(0x45d374, 0x83b0a0ef),
|
||||||
|
(0x474520, 0x8983245d),
|
||||||
|
(0x47a1d4, 0xefb941b7),
|
||||||
|
(0x4bdc74, 0x7c3b3458),
|
||||||
|
(0x4c34c8, 0xee0a87b3),
|
||||||
|
(0x4f1068, 0xf6a2069f),
|
||||||
|
(0x51601c, 0x2e83612b),
|
||||||
|
(0x549d40, 0xb413a58f),
|
||||||
|
(0x56a714, 0x95596da3),
|
||||||
|
(0x573c98, 0x68513e8d),
|
||||||
|
(0x59d058, 0xc4ff5f9a),
|
||||||
|
(0x5c4b34, 0x249ed022),
|
||||||
|
(0x5f19bc, 0xc27272d3),
|
||||||
|
(0x5f47c8, 0xd73aa37b),
|
||||||
|
(0x627df4, 0x002334ba),
|
||||||
|
(0x648f54, 0x868bb6c9),
|
||||||
|
(0x6718a4, 0x17ff0ef4),
|
||||||
|
(0x6a88b8, 0x22cfbc5f),
|
||||||
|
(0x742dbc, 0x152072dd),
|
||||||
|
(0x75603c, 0xbd31be45),
|
||||||
|
(0x783238, 0x45911d6a),
|
||||||
|
(0x7b94d8, 0x6f281add),
|
||||||
|
(0x800060, 0xcf58c8d0),
|
||||||
|
(0x819b50, 0xb40f0276),
|
||||||
|
(0x82dea4, 0x1a1a8402),
|
||||||
|
(0x880210, 0xf2c0824a),
|
||||||
|
(0x8e4f08, 0x86c9ba90),
|
||||||
|
(0x8ea544, 0x0e928544),
|
||||||
|
(0x931454, 0xc3fa017b),
|
||||||
|
(0x94eb70, 0x1dbe612a),
|
||||||
|
(0x95993c, 0x902498fe),
|
||||||
|
(0x98d2b0, 0xb7760451),
|
||||||
|
(0x992034, 0x711cddfc),
|
||||||
|
(0x9e14a0, 0x8bd95e64),
|
||||||
|
(0xa1d2d0, 0xbfdce920),
|
||||||
|
(0xa21ed8, 0x90cf0372),
|
||||||
|
(0xa4d2b8, 0x91e88c9c),
|
||||||
|
(0xa76f8c, 0x9c721e61),
|
||||||
|
(0xac5ba4, 0xbda16e3e),
|
||||||
|
(0xaf070c, 0xe02b6799),
|
||||||
|
(0xaf3a78, 0x32953b4e),
|
||||||
|
(0xb32510, 0x47ea48db),
|
||||||
|
(0xb46550, 0x1443e512),
|
||||||
|
(0xb54998, 0x9e123a75),
|
||||||
|
(0xb5e24c, 0xe11a8efd),
|
||||||
|
(0xb625cc, 0x3facfbf4),
|
||||||
|
(0xb66ec8, 0x76c0c452),
|
||||||
|
(0xb67ad0, 0x4de4ed6c),
|
||||||
|
(0xb7ba5c, 0xe622d97a),
|
||||||
|
(0xb85a90, 0x6f564f8b),
|
||||||
|
(0xbff8b8, 0x3e25d671),
|
||||||
|
(0xc03e50, 0x3563fc2b),
|
||||||
|
(0xc6e958, 0xda8bc3b0),
|
||||||
|
(0xc87f7c, 0x5a9d2269),
|
||||||
|
(0xcb36a4, 0x0ab420cc),
|
||||||
|
(0xcbe4d0, 0x9bbb091e),
|
||||||
|
(0xccd7dc, 0x9e4fd577),
|
||||||
|
(0xd078c0, 0x4b655ae1),
|
||||||
|
(0xd275dc, 0x5ca2a2f4),
|
||||||
|
(0xd2c840, 0xdb437f0d),
|
||||||
|
(0xd3296c, 0x66487f75),
|
||||||
|
(0xd7cbe8, 0xf5427945),
|
||||||
|
(0xd8e0a0, 0x9a65bdb6),
|
||||||
|
(0xda2ed4, 0x46dea4b3),
|
||||||
|
(0xda6f1c, 0xb9916a02),
|
||||||
|
(0xdee9ac, 0x18800a5c),
|
||||||
|
(0xe3673c, 0x4afab3cd),
|
||||||
|
(0xe65420, 0x52e80204),
|
||||||
|
(0xe861f8, 0x639a02d7),
|
||||||
|
(0xeb61c0, 0x21077eba),
|
||||||
|
(0xed51dc, 0x17be91d8),
|
||||||
|
(0xf048a4, 0xd30cc8cb),
|
||||||
|
(0xf3b274, 0xdfb43f3f),
|
||||||
|
(0xf76ac8, 0x63a8b363),
|
||||||
|
(0xf84b64, 0x16508a16),
|
||||||
|
(0xf8bb2c, 0x22ce110d),
|
||||||
|
(0xfb3390, 0xf09a4eb2),
|
||||||
|
(0xff07bc, 0xd2bb0e2c),
|
||||||
|
(0x102d32c, 0xb424012c),
|
||||||
|
(0x10795b0, 0x07338bb9),
|
||||||
|
(0x108d65c, 0x5d68f86e),
|
||||||
|
(0x10ce528, 0x1826c952),
|
||||||
|
(0x10d3528, 0xa7473860),
|
||||||
|
(0x10dca58, 0x92435967),
|
||||||
|
(0x1115c78, 0x061200f4),
|
||||||
|
(0x1171098, 0x94f538a1),
|
||||||
|
(0x117ebf0, 0xd8731d88),
|
||||||
|
(0x1186638, 0x4381b3f9),
|
||||||
|
(0x118afdc, 0xf25ff376),
|
||||||
|
(0x11ee3b4, 0x29605488),
|
||||||
|
(0x11f182c, 0x04367932),
|
||||||
|
(0x11f41dc, 0xaeaccadd),
|
||||||
|
(0x11ff0f4, 0x7c4d358e),
|
||||||
|
(0x120caac, 0xacbc8412),
|
||||||
|
(0x12437cc, 0x3e0ac7e9),
|
||||||
|
(0x124edf8, 0x06f523fd),
|
||||||
|
(0x1263ba0, 0x0a1b9763),
|
||||||
|
(0x12943f0, 0x24a86ba4),
|
||||||
|
(0x12d9230, 0x0cd82e2e),
|
||||||
|
(0x12fd9b4, 0xf3903fb9),
|
||||||
|
(0x135a198, 0x2887f4a3),
|
||||||
|
(0x1366180, 0x9f0d7ca5),
|
||||||
|
(0x13680a4, 0x61e9a459),
|
||||||
|
(0x13a1b44, 0xe61623a4),
|
||||||
|
(0x13a860c, 0xdc44c798),
|
||||||
|
(0x13c024c, 0xc90f7be6),
|
||||||
|
(0x1475c00, 0xc2f338b3),
|
||||||
|
(0x1480aa8, 0xb7b0609e),
|
||||||
|
(0x14fb82c, 0x748e3939),
|
||||||
|
(0x1511184, 0x98426fcf),
|
||||||
|
(0x153c144, 0x1a452d5d),
|
||||||
|
(0x1547838, 0x7dd360e9),
|
||||||
|
(0x15565d4, 0x1d8f093b),
|
||||||
|
(0x156e298, 0x102a1524),
|
||||||
|
(0x159df70, 0xe42613f7),
|
||||||
|
(0x15a13d0, 0xafc5fdc6),
|
||||||
|
(0x15e7f24, 0x84fcd342),
|
||||||
|
(0x15f0878, 0x55038958),
|
||||||
|
(0x1614210, 0xe0602ae4),
|
||||||
|
(0x1631b3c, 0xce2765f6),
|
||||||
|
(0x164eb70, 0xf772dac5),
|
||||||
|
(0x1688b68, 0x5f1a72c9),
|
||||||
|
(0x16d5f8c, 0x7c77747d),
|
||||||
|
(0x16e76dc, 0xac0e16fb),
|
||||||
|
(0x1726374, 0x4a1e7fd7),
|
||||||
|
(0x173455c, 0x870856b4),
|
||||||
|
(0x17697d8, 0xbb2f0a5c),
|
||||||
|
(0x176ed60, 0xc937b386),
|
||||||
|
(0x1784fa8, 0x5e676ab2),
|
||||||
|
(0x17ae3a0, 0xdbf662a1),
|
||||||
|
(0x1866c7c, 0x4e3f1a7d),
|
||||||
|
(0x186b844, 0xe30fce60),
|
||||||
|
(0x18b507c, 0xdfc73c88),
|
||||||
|
(0x18c2f64, 0xb7ee08e0),
|
||||||
|
(0x18c8010, 0xd1471a25),
|
||||||
|
(0x18de290, 0x292e6310),
|
||||||
|
(0x19140d0, 0x9f346f05),
|
||||||
|
(0x192c590, 0xf1eb61bf),
|
||||||
|
(0x194fca0, 0x8888b1df),
|
||||||
|
(0x1959d34, 0x92b89d15),
|
||||||
|
(0x196c0c4, 0x5e152de5),
|
||||||
|
(0x19a5710, 0x866e7bfa),
|
||||||
|
(0x19abfd4, 0x3084ae26),
|
||||||
|
(0x19b1550, 0x0581836f),
|
||||||
|
(0x19b7214, 0xeefc34eb),
|
||||||
|
(0x19c523c, 0xc980335d),
|
||||||
|
(0x19dc4c0, 0x019084e6),
|
||||||
|
(0x19dfb8c, 0xdb1a21a7),
|
||||||
|
(0x19fbf3c, 0xec84cc17),
|
||||||
|
(0x1a29b18, 0xcb31da7d),
|
||||||
|
(0x1a4c670, 0xc5fe570e),
|
||||||
|
(0x1a97024, 0xbbd80964),
|
||||||
|
(0x1ac33bc, 0xe186586d),
|
||||||
|
(0x1acd124, 0x1e413252),
|
||||||
|
(0x1ad9bac, 0x48fc4c75),
|
||||||
|
(0x1b1b728, 0x8071d7a5),
|
||||||
|
(0x1b31d78, 0x9d958013),
|
||||||
|
(0x1badb24, 0x2f236951),
|
||||||
|
(0x1bccc00, 0x7023c620),
|
||||||
|
(0x1bdab2c, 0x88b1e4b8),
|
||||||
|
(0x1c000dc, 0x9e43291a),
|
||||||
|
(0x1c9f0cc, 0x27a7d592),
|
||||||
|
(0x1cd1328, 0x9c0bcc88),
|
||||||
|
(0x1cd79a0, 0x63e0ed75),
|
||||||
|
(0x1d0e484, 0xf51a0d3d),
|
||||||
|
(0x1d17b10, 0xbfd2a7ac),
|
||||||
|
(0x1d930c4, 0xf6b9e877),
|
||||||
|
(0x1db115c, 0xf3eb7e37),
|
||||||
|
(0x1df16b4, 0x682326ff),
|
||||||
|
(0x1e389c0, 0xea11f566),
|
||||||
|
(0x1eb7e48, 0x3dc5fa76),
|
||||||
|
(0x1ec38fc, 0x296ffc1d),
|
||||||
|
(0x1ee87a0, 0x1b9f7fd4),
|
||||||
|
(0x1f19f88, 0x78972e8f),
|
||||||
|
(0x1f33a0c, 0x390c2deb),
|
||||||
|
(0x1f4e0fc, 0xe05e8c6b),
|
||||||
|
(0x1f5a718, 0x367432ae),
|
||||||
|
(0x1f61dcc, 0x7063e58a),
|
||||||
|
(0x1f85878, 0x21c00cea),
|
||||||
|
(0x1fc043c, 0x2676aaaa),
|
||||||
|
(0x1ffdb94, 0xc270eb02),
|
||||||
|
(0x202a618, 0x3a98aed2),
|
||||||
|
(0x2037b34, 0x115d5afc),
|
||||||
|
(0x203d92c, 0x11bced76),
|
||||||
|
(0x203da14, 0xf2628105),
|
||||||
|
(0x2066014, 0x97f32700),
|
||||||
|
(0x208a908, 0xa68e2f71),
|
||||||
|
(0x20ab8ac, 0x1daa2a78),
|
||||||
|
(0x20ba504, 0x73919ef6),
|
||||||
|
(0x20e71e0, 0x0b3fd1d3),
|
||||||
|
(0x2102278, 0x6c123def),
|
||||||
|
(0x21166dc, 0xee354161),
|
||||||
|
(0x2126478, 0x299493f4),
|
||||||
|
(0x2137090, 0x05ae2007),
|
||||||
|
(0x2148270, 0x34b52663),
|
||||||
|
(0x21482ac, 0xe381b5b6),
|
||||||
|
(0x21813b8, 0x94244de1),
|
||||||
|
(0x21a41e8, 0x02c38df5),
|
||||||
|
(0x21a8c4c, 0xf72700dd),
|
||||||
|
(0x21abbac, 0x34c2e7b5),
|
||||||
|
(0x21bcb24, 0x442739ad),
|
||||||
|
(0x21cbe84, 0x6e40d22c),
|
||||||
|
(0x21e2798, 0xdbf774d0),
|
||||||
|
(0x21f892c, 0xe90f1e0c),
|
||||||
|
(0x222beec, 0xa27f27f3),
|
||||||
|
(0x22394f4, 0x7f999a4f),
|
||||||
|
(0x22437ec, 0xf12d28f8),
|
||||||
|
(0x22480b0, 0xf58f3a7d),
|
||||||
|
(0x2261a0c, 0x89b28301),
|
||||||
|
(0x22a76c8, 0x1fe501e2),
|
||||||
|
(0x22b2018, 0xf079db5f),
|
||||||
|
(0x22cc610, 0xaf7d17b7),
|
||||||
|
(0x22cd4f8, 0x71c010cb),
|
||||||
|
(0x22d016c, 0x4a8daed0),
|
||||||
|
(0x22e1c04, 0xe1201aca),
|
||||||
|
(0x22f9994, 0xf3f0e4ee),
|
||||||
|
(0x2384f5c, 0x6b8a5eb1),
|
||||||
|
(0x23d5ecc, 0x5298a9c4),
|
||||||
|
(0x23e15b0, 0xc7bf0afb),
|
||||||
|
(0x23e248c, 0x2d67fecf),
|
||||||
|
(0x2407898, 0x4eef422a),
|
||||||
|
(0x241695c, 0x33ba9ce8),
|
||||||
|
(0x243e8b0, 0x833c1d2c),
|
||||||
|
(0x2460b64, 0x819c96ee),
|
||||||
|
(0x247caec, 0x0ebccbd6),
|
||||||
|
(0x24832b4, 0xf789d4b6),
|
||||||
|
(0x24938b8, 0x9f63baeb),
|
||||||
|
(0x24a7c64, 0x3384e552),
|
||||||
|
(0x24bce94, 0x7bfec208),
|
||||||
|
(0x24bd8f4, 0x9b5260cc),
|
||||||
|
(0x24ce8ec, 0xaf854888),
|
||||||
|
(0x24e741c, 0xda82f062),
|
||||||
|
(0x254401c, 0xbb1a5d5a),
|
||||||
|
(0x25a6a64, 0x24d202d3),
|
||||||
|
(0x2617878, 0x6ac71e5f),
|
||||||
|
(0x2617df0, 0x0e76bd90),
|
||||||
|
(0x268e9b8, 0x874d931c),
|
||||||
|
(0x26a8848, 0xefefd680),
|
||||||
|
(0x26e4f10, 0xfc2799f7),
|
||||||
|
(0x26e93d8, 0x1930ad55),
|
||||||
|
(0x26eea84, 0xfa2f742f),
|
||||||
|
(0x2701f30, 0xed9b92c4),
|
||||||
|
];
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
//! Restoration of IL2CPP v24.1 `Il2CppMethodDefinition.methodIndex` values.
|
||||||
|
//!
|
||||||
|
//! CrackProof's v24.1 Android module rewrites the global method-index permutation
|
||||||
|
//! at runtime. The transform parameters are carried by the decoded module `0x0C`,
|
||||||
|
//! so the restore derives them from that module instead of hard-coding a
|
||||||
|
//! game-specific table.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::common::MAGIC;
|
||||||
|
|
||||||
|
const RAW_VERSION_24: u32 = 24;
|
||||||
|
const HDR_METHODS: usize = 0x30;
|
||||||
|
const V24_1_METHOD_STRIDE: usize = 0x34;
|
||||||
|
const V24_1_METHOD_INDEX_OFFSET: usize = 0x14;
|
||||||
|
const V24_1_SELECTOR: u32 = 1;
|
||||||
|
const EXCEPTION_COUNT: usize = 256;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct MethodIndexReport {
|
||||||
|
pub version: u32,
|
||||||
|
pub methods: usize,
|
||||||
|
pub active_methods: usize,
|
||||||
|
pub changed_indices: usize,
|
||||||
|
pub exception_count: usize,
|
||||||
|
pub seed: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum MethodIndexError {
|
||||||
|
#[error("not an IL2CPP global-metadata.dat")]
|
||||||
|
NotMetadata,
|
||||||
|
#[error("unsupported metadata version {0}")]
|
||||||
|
UnsupportedVersion(u32),
|
||||||
|
#[error("malformed v24.1 metadata: {0}")]
|
||||||
|
Malformed(String),
|
||||||
|
#[error("v24.1 method-index profile was not found in module 0x0C")]
|
||||||
|
ProfileNotFound,
|
||||||
|
#[error("multiple v24.1 method-index profiles matched module 0x0C")]
|
||||||
|
AmbiguousProfile,
|
||||||
|
#[error("v24.1 method-index restoration failed validation: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, MethodIndexError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Profile {
|
||||||
|
seed: u32,
|
||||||
|
exception_values: [u32; EXCEPTION_COUNT],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> {
|
||||||
|
let end = offset
|
||||||
|
.checked_add(size)
|
||||||
|
.ok_or_else(|| MethodIndexError::Malformed("byte range overflow".to_owned()))?;
|
||||||
|
data.get(offset..end).ok_or_else(|| {
|
||||||
|
MethodIndexError::Malformed(format!(
|
||||||
|
"byte range 0x{offset:x}..0x{end:x} is out of bounds"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||||
|
let value: [u8; 4] = bytes(data, offset, 4)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| MethodIndexError::Malformed("invalid u32 range".to_owned()))?;
|
||||||
|
Ok(u32::from_le_bytes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i32(data: &[u8], offset: usize) -> Result<i32> {
|
||||||
|
let value: [u8; 4] = bytes(data, offset, 4)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| MethodIndexError::Malformed("invalid i32 range".to_owned()))?;
|
||||||
|
Ok(i32::from_le_bytes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_i32(data: &mut [u8], offset: usize, value: i32) -> Result<()> {
|
||||||
|
let destination = data.get_mut(offset..offset + 4).ok_or_else(|| {
|
||||||
|
MethodIndexError::Malformed("method-index write is out of bounds".to_owned())
|
||||||
|
})?;
|
||||||
|
destination.copy_from_slice(&value.to_le_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_table(data: &[u8]) -> Result<(usize, usize)> {
|
||||||
|
if read_u32(data, 0)? != MAGIC {
|
||||||
|
return Err(MethodIndexError::NotMetadata);
|
||||||
|
}
|
||||||
|
let version = read_u32(data, 4)?;
|
||||||
|
if version != RAW_VERSION_24 {
|
||||||
|
return Err(MethodIndexError::UnsupportedVersion(version));
|
||||||
|
}
|
||||||
|
let offset = read_u32(data, HDR_METHODS)? as usize;
|
||||||
|
let size = read_u32(data, HDR_METHODS + 4)? as usize;
|
||||||
|
bytes(data, offset, size)?;
|
||||||
|
if !size.is_multiple_of(V24_1_METHOD_STRIDE) {
|
||||||
|
return Err(MethodIndexError::Malformed(format!(
|
||||||
|
"method table size 0x{size:x} is not divisible by v24.1 stride 0x{V24_1_METHOD_STRIDE:x}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok((offset, size / V24_1_METHOD_STRIDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_indices(data: &[u8]) -> Result<(usize, Vec<Option<u32>>)> {
|
||||||
|
let (offset, count) = method_table(data)?;
|
||||||
|
let mut indices = Vec::with_capacity(count);
|
||||||
|
for method in 0..count {
|
||||||
|
let value = read_i32(
|
||||||
|
data,
|
||||||
|
offset + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET,
|
||||||
|
)?;
|
||||||
|
indices.push((value >= 0).then_some(value as u32));
|
||||||
|
}
|
||||||
|
Ok((offset, indices))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_complete_permutation(indices: &[Option<u32>]) -> bool {
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
if active_count == 0 || active_count > u32::MAX as usize {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut seen = vec![false; active_count];
|
||||||
|
for value in indices.iter().flatten().copied() {
|
||||||
|
let Ok(index) = usize::try_from(value) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if index >= active_count || seen[index] {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen[index] = true;
|
||||||
|
}
|
||||||
|
seen.into_iter().all(|value| value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permutation_key(seed: u32, count: u32) -> Result<u32> {
|
||||||
|
if count < 2 {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"method-index permutation requires at least two active methods".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if count > u32::MAX / 2 {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"active method count is too large for the permutation mirror".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let half = count / 2;
|
||||||
|
if half == 0 {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"method-index permutation has a zero divisor".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(seed % half + count / 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permutation_round(mut value: u32, count: u32, key: u32) -> u32 {
|
||||||
|
let mirror = count * 2 - 1;
|
||||||
|
if value & 1 != 0 {
|
||||||
|
value = mirror - value;
|
||||||
|
}
|
||||||
|
value >>= 1;
|
||||||
|
if value >= count {
|
||||||
|
value = mirror - value;
|
||||||
|
}
|
||||||
|
let adjusted = i64::from(value) - i64::from(key);
|
||||||
|
if adjusted < 0 {
|
||||||
|
(adjusted + i64::from(count)) as u32
|
||||||
|
} else {
|
||||||
|
adjusted as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_index(mut value: u32, count: u32, seed: u32) -> Result<u32> {
|
||||||
|
let key = permutation_key(seed, count)?;
|
||||||
|
for _ in 0..5 {
|
||||||
|
value = permutation_round(value, count, key);
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transformed_values(indices: &[Option<u32>], seed: u32) -> Result<Vec<Option<u32>>> {
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
let count = u32::try_from(active_count)
|
||||||
|
.map_err(|_| MethodIndexError::Validation("active method count exceeds u32".to_owned()))?;
|
||||||
|
indices
|
||||||
|
.iter()
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.map(|value| transform_index(value, count, seed))
|
||||||
|
.transpose()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_values(indices: &[Option<u32>]) -> Option<Vec<u32>> {
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
let mut seen = vec![false; active_count];
|
||||||
|
for value in indices.iter().flatten().copied() {
|
||||||
|
let index = usize::try_from(value).ok()?;
|
||||||
|
if index >= active_count {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
seen[index] = true;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
seen.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, present)| (!present).then_some(index as u32))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_exception_table(
|
||||||
|
module: &[u8],
|
||||||
|
count: u32,
|
||||||
|
missing: &[u32],
|
||||||
|
) -> Option<[u32; EXCEPTION_COUNT]> {
|
||||||
|
if missing.len() != EXCEPTION_COUNT || count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut wanted = vec![false; count as usize];
|
||||||
|
for &value in missing {
|
||||||
|
let slot = wanted.get_mut(value as usize)?;
|
||||||
|
*slot = true;
|
||||||
|
}
|
||||||
|
let table_bytes = EXCEPTION_COUNT * 4;
|
||||||
|
for offset in (8..=module.len().checked_sub(table_bytes)?).step_by(4) {
|
||||||
|
if read_u32(module, offset - 8).ok()? != V24_1_SELECTOR
|
||||||
|
|| read_u32(module, offset - 4).ok()? != count - 1
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut values = [0_u32; EXCEPTION_COUNT];
|
||||||
|
let mut seen = vec![false; count as usize];
|
||||||
|
let mut matches = true;
|
||||||
|
for (index, slot) in values.iter_mut().enumerate() {
|
||||||
|
let value = read_u32(module, offset + index * 4).ok()?;
|
||||||
|
let Some(is_wanted) = wanted.get(value as usize) else {
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if !*is_wanted || seen[value as usize] {
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen[value as usize] = true;
|
||||||
|
*slot = value;
|
||||||
|
}
|
||||||
|
if matches {
|
||||||
|
return Some(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_candidates(module: &[u8], indices: &[Option<u32>]) -> Result<Vec<Profile>> {
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
let count = u32::try_from(active_count)
|
||||||
|
.map_err(|_| MethodIndexError::Validation("active method count exceeds u32".to_owned()))?;
|
||||||
|
if count <= EXCEPTION_COUNT as u32 {
|
||||||
|
return Err(MethodIndexError::Validation(format!(
|
||||||
|
"active method count {count} is too small for the v24.1 exception table"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut profiles = Vec::new();
|
||||||
|
for offset in (0..module.len().saturating_sub(8)).step_by(4) {
|
||||||
|
let Ok(version) = read_u32(module, offset + 4) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if version != RAW_VERSION_24 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let seed = read_u32(module, offset)?;
|
||||||
|
let transformed = transformed_values(indices, seed)?;
|
||||||
|
let Some(missing) = missing_values(&transformed) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(exception_values) = find_exception_table(module, count, &missing) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut candidate = transformed;
|
||||||
|
let mut exception = exception_values.iter().copied();
|
||||||
|
for value in candidate.iter_mut().flatten() {
|
||||||
|
if let Some(replacement) = exception.next() {
|
||||||
|
*value = replacement;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exception.next().is_none() && is_complete_permutation(&candidate) {
|
||||||
|
profiles.push(Profile {
|
||||||
|
seed,
|
||||||
|
exception_values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profiles.sort_by_key(|profile| profile.seed);
|
||||||
|
profiles.dedup();
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the v24.1 `methodIndex` permutation using the decoded CrackProof
|
||||||
|
/// module `0x0C` that accompanied the protected library.
|
||||||
|
///
|
||||||
|
/// The profile is accepted only when the module supplies a seed plus a
|
||||||
|
/// 256-entry exception table that turns the restored non-negative indices into
|
||||||
|
/// the exact permutation `0..active_method_count`. This makes a wrong module or
|
||||||
|
/// incompatible v24 sub-layout fail without mutating the metadata.
|
||||||
|
pub fn restore_method_indices_v24_1(
|
||||||
|
data: &[u8],
|
||||||
|
module_0c: &[u8],
|
||||||
|
) -> Result<(Vec<u8>, MethodIndexReport)> {
|
||||||
|
let (method_offset, indices) = method_indices(data)?;
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
if is_complete_permutation(&indices) {
|
||||||
|
return Ok((
|
||||||
|
data.to_vec(),
|
||||||
|
MethodIndexReport {
|
||||||
|
version: RAW_VERSION_24,
|
||||||
|
methods: indices.len(),
|
||||||
|
active_methods: active_count,
|
||||||
|
changed_indices: 0,
|
||||||
|
exception_count: 0,
|
||||||
|
seed: "clean".to_owned(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let profiles = profile_candidates(module_0c, &indices)?;
|
||||||
|
let profile = match profiles.as_slice() {
|
||||||
|
[] => return Err(MethodIndexError::ProfileNotFound),
|
||||||
|
[profile] => profile,
|
||||||
|
_ => return Err(MethodIndexError::AmbiguousProfile),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut restored = transformed_values(&indices, profile.seed)?;
|
||||||
|
let mut exception = profile.exception_values.iter().copied();
|
||||||
|
for value in restored.iter_mut().flatten() {
|
||||||
|
if let Some(replacement) = exception.next() {
|
||||||
|
*value = replacement;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exception.next().is_some() {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"metadata has fewer active methods than the exception table".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !is_complete_permutation(&restored) {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"restored methodIndex values are not a complete permutation".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = data.to_vec();
|
||||||
|
let mut changed_indices = 0_usize;
|
||||||
|
for (method, value) in restored.iter().enumerate() {
|
||||||
|
let Some(value) = value else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let offset = method_offset + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET;
|
||||||
|
let old = read_i32(data, offset)?;
|
||||||
|
let new = i32::try_from(*value).map_err(|_| {
|
||||||
|
MethodIndexError::Validation("restored methodIndex exceeds i32".to_owned())
|
||||||
|
})?;
|
||||||
|
if old != new {
|
||||||
|
write_i32(&mut output, offset, new)?;
|
||||||
|
changed_indices += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
output,
|
||||||
|
MethodIndexReport {
|
||||||
|
version: RAW_VERSION_24,
|
||||||
|
methods: indices.len(),
|
||||||
|
active_methods: active_count,
|
||||||
|
changed_indices,
|
||||||
|
exception_count: EXCEPTION_COUNT,
|
||||||
|
seed: format!("0x{:08X}", profile.seed),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn inverse_map(count: u32, seed: u32) -> Vec<u32> {
|
||||||
|
let mut inverse = vec![u32::MAX; count as usize];
|
||||||
|
for value in 0..count {
|
||||||
|
let transformed = transform_index(value, count, seed).unwrap();
|
||||||
|
inverse[transformed as usize] = value;
|
||||||
|
}
|
||||||
|
assert!(inverse.iter().all(|value| *value != u32::MAX));
|
||||||
|
inverse
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let active_count = 512_u32;
|
||||||
|
let seed = 0x7770_0dcc;
|
||||||
|
let inverse = inverse_map(active_count, seed);
|
||||||
|
let header_size = 0x100usize;
|
||||||
|
let method_size = active_count as usize * V24_1_METHOD_STRIDE;
|
||||||
|
let mut metadata = vec![0_u8; header_size + method_size];
|
||||||
|
metadata[0..4].copy_from_slice(&MAGIC.to_le_bytes());
|
||||||
|
metadata[4..8].copy_from_slice(&RAW_VERSION_24.to_le_bytes());
|
||||||
|
metadata[HDR_METHODS..HDR_METHODS + 4].copy_from_slice(&(header_size as u32).to_le_bytes());
|
||||||
|
metadata[HDR_METHODS + 4..HDR_METHODS + 8]
|
||||||
|
.copy_from_slice(&(method_size as u32).to_le_bytes());
|
||||||
|
|
||||||
|
// Before the exception patch the first half duplicates the second half,
|
||||||
|
// leaving 0..255 missing. The module's 256-entry table fills exactly
|
||||||
|
// those first active methods after the five-round transform.
|
||||||
|
for method in 0..active_count as usize {
|
||||||
|
let transformed = 256 + (method % 256) as u32;
|
||||||
|
let protected = inverse[transformed as usize];
|
||||||
|
let offset = header_size + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET;
|
||||||
|
metadata[offset..offset + 4].copy_from_slice(&(protected as i32).to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut module = vec![0_u8; 0x1000];
|
||||||
|
module[0x100..0x104].copy_from_slice(&seed.to_le_bytes());
|
||||||
|
module[0x104..0x108].copy_from_slice(&RAW_VERSION_24.to_le_bytes());
|
||||||
|
module[0x3f8..0x3fc].copy_from_slice(&V24_1_SELECTOR.to_le_bytes());
|
||||||
|
module[0x3fc..0x400].copy_from_slice(&(active_count - 1).to_le_bytes());
|
||||||
|
for value in 0..EXCEPTION_COUNT as u32 {
|
||||||
|
let offset = 0x400 + value as usize * 4;
|
||||||
|
module[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
(metadata, module)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restores_v24_1_method_index_permutation() {
|
||||||
|
let (metadata, module) = fixture();
|
||||||
|
let (restored, report) = restore_method_indices_v24_1(&metadata, &module).unwrap();
|
||||||
|
assert_eq!(report.active_methods, 512);
|
||||||
|
assert_eq!(report.exception_count, 256);
|
||||||
|
assert_eq!(report.seed, "0x77700DCC");
|
||||||
|
let (offset, indices) = method_indices(&restored).unwrap();
|
||||||
|
assert_eq!(offset, 0x100);
|
||||||
|
assert!(is_complete_permutation(&indices));
|
||||||
|
for (method, value) in indices.into_iter().enumerate() {
|
||||||
|
assert_eq!(value, Some(method as u32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_v24_1_metadata_is_idempotent() {
|
||||||
|
let (metadata, module) = fixture();
|
||||||
|
let (restored, _) = restore_method_indices_v24_1(&metadata, &module).unwrap();
|
||||||
|
let (again, report) = restore_method_indices_v24_1(&restored, &module).unwrap();
|
||||||
|
assert_eq!(again, restored);
|
||||||
|
assert_eq!(report.changed_indices, 0);
|
||||||
|
assert_eq!(report.seed, "clean");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unrelated_module() {
|
||||||
|
let (metadata, _) = fixture();
|
||||||
|
let error = restore_method_indices_v24_1(&metadata, &[0_u8; 0x1000]).unwrap_err();
|
||||||
|
assert_eq!(error, MethodIndexError::ProfileNotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
|||||||
|
//! Static IL2CPP metadata restoration interfaces.
|
||||||
|
|
||||||
|
mod embedded;
|
||||||
|
mod keystream;
|
||||||
|
mod method_indices;
|
||||||
|
mod method_tokens;
|
||||||
|
|
||||||
|
pub use embedded::{embedded_metadata_size, extract_embedded_metadata};
|
||||||
|
pub use method_indices::{MethodIndexError, MethodIndexReport, restore_method_indices_v24_1};
|
||||||
|
pub use method_tokens::{
|
||||||
|
DEFAULT_METHOD_TOKEN_SEED, Error, ImageKeyDiscovery, Report, SeedDiscoveryReport,
|
||||||
|
discover_method_token_seeds, restore_method_tokens,
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//! Shared IL2CPP metadata header primitives.
|
||||||
|
|
||||||
|
/// IL2CPP global-metadata sanity magic.
|
||||||
|
pub(crate) const MAGIC: u32 = 0xFAB1_1BAF;
|
||||||
|
|
||||||
|
/// Cheap check used by both platform scanners before opening a full metadata
|
||||||
|
/// file.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_metadata(data: &[u8]) -> bool {
|
||||||
|
data.get(0..4)
|
||||||
|
.is_some_and(|bytes| u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) == MAGIC)
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
//! Unity il2cpp metadata de-obfuscation.
|
//! Unity il2cpp metadata restoration.
|
||||||
|
|
||||||
mod metadata;
|
pub mod android;
|
||||||
|
mod common;
|
||||||
|
mod structural;
|
||||||
|
pub mod windows;
|
||||||
|
|
||||||
pub use metadata::*;
|
pub use common::is_metadata;
|
||||||
|
pub use structural::*;
|
||||||
@@ -27,8 +27,7 @@
|
|||||||
//! metadata (its tokens already equal `local_index + 1`), so it is safe to run on
|
//! metadata (its tokens already equal `local_index + 1`), so it is safe to run on
|
||||||
//! any il2cpp game — `remapped == 0` then reports that nothing changed.
|
//! any il2cpp game — `remapped == 0` then reports that nothing changed.
|
||||||
|
|
||||||
/// il2cpp `global-metadata.dat` sanity magic (`Il2CppGlobalMetadataHeader.sanity`).
|
use crate::common::MAGIC;
|
||||||
const MAGIC: u32 = 0xFAB1_1BAF;
|
|
||||||
|
|
||||||
/// Metadata format version this de-obfuscator understands. The struct strides
|
/// Metadata format version this de-obfuscator understands. The struct strides
|
||||||
/// and header field offsets below are specific to it; other versions are left
|
/// and header field offsets below are specific to it; other versions are left
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
//! Compatibility namespace for the shared structural metadata transform.
|
||||||
|
|
||||||
|
pub use crate::structural::*;
|
||||||
@@ -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
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
//! PE detection, unpacking, and structural validation.
|
|
||||||
|
|
||||||
pub mod dll;
|
|
||||||
mod error;
|
|
||||||
pub mod exe;
|
|
||||||
pub mod integrity;
|
|
||||||
mod layout;
|
|
||||||
pub(crate) mod parallel;
|
|
||||||
|
|
||||||
use senbei_crypto::primitives;
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
pub use dll::{unpack_dll, unpack_dll_v};
|
|
||||||
pub use error::*;
|
|
||||||
pub use exe::{unpack as unpack_exe, unpack_v as unpack_exe_v};
|
|
||||||
pub use integrity::{IntegrityReport, check as check_integrity};
|
|
||||||
pub use parallel::thread_cap;
|
|
||||||
|
|
||||||
/// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer
|
|
||||||
/// for. Guards against a corrupt/crafted header requesting a multi-gigabyte
|
|
||||||
/// (or, as a sign-extended negative `i32`, multi-exabyte) allocation, which
|
|
||||||
/// would abort the process — an abort that `catch_unpack` below cannot trap.
|
|
||||||
/// Real protected binaries are far below this.
|
|
||||||
pub(crate) const MAX_IMAGE_SIZE: u64 = senbei_crypto::MAX_IMAGE_SIZE;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) struct PanicCapture(Arc<Mutex<Option<PanicDetails>>>);
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct PanicDetails {
|
|
||||||
message: String,
|
|
||||||
file: String,
|
|
||||||
line: u32,
|
|
||||||
column: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
thread_local! {
|
|
||||||
static ACTIVE_PANIC_CAPTURE: RefCell<Option<PanicCapture>> = const { RefCell::new(None) };
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PanicCaptureGuard(Option<PanicCapture>);
|
|
||||||
|
|
||||||
impl Drop for PanicCaptureGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
ACTIVE_PANIC_CAPTURE.with(|slot| {
|
|
||||||
slot.replace(self.0.take());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PanicCapture {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self(Arc::new(Mutex::new(None)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record(&self, info: &std::panic::PanicHookInfo<'_>) {
|
|
||||||
let location = info.location();
|
|
||||||
let details = PanicDetails {
|
|
||||||
message: panic_message(info.payload()),
|
|
||||||
file: location
|
|
||||||
.map(|value| value.file().to_owned())
|
|
||||||
.unwrap_or_else(|| "<unknown>".to_owned()),
|
|
||||||
line: location.map_or(0, std::panic::Location::line),
|
|
||||||
column: location.map_or(0, std::panic::Location::column),
|
|
||||||
};
|
|
||||||
let mut captured = self
|
|
||||||
.0
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
if captured.is_none() {
|
|
||||||
*captured = Some(details);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn into_error(self, payload: &(dyn std::any::Any + Send)) -> UnpackError {
|
|
||||||
let details = self
|
|
||||||
.0
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| PanicDetails {
|
|
||||||
message: panic_message(payload),
|
|
||||||
file: "<unknown>".to_owned(),
|
|
||||||
line: 0,
|
|
||||||
column: 0,
|
|
||||||
});
|
|
||||||
UnpackError::InternalPanic {
|
|
||||||
message: details.message,
|
|
||||||
file: details.file,
|
|
||||||
line: details.line,
|
|
||||||
column: details.column,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge_from(&self, other: &Self) {
|
|
||||||
let details = other
|
|
||||||
.0
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.clone();
|
|
||||||
let Some(details) = details else { return };
|
|
||||||
let mut captured = self
|
|
||||||
.0
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
if captured.is_none() {
|
|
||||||
*captured = Some(details);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
|
||||||
if let Some(message) = payload.downcast_ref::<&str>() {
|
|
||||||
(*message).to_owned()
|
|
||||||
} else if let Some(message) = payload.downcast_ref::<String>() {
|
|
||||||
message.clone()
|
|
||||||
} else {
|
|
||||||
"non-string panic payload".to_owned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install_panic_capture_hook() {
|
|
||||||
static INSTALL: std::sync::Once = std::sync::Once::new();
|
|
||||||
INSTALL.call_once(|| {
|
|
||||||
let previous = std::panic::take_hook();
|
|
||||||
std::panic::set_hook(Box::new(move |info| {
|
|
||||||
let capture = ACTIVE_PANIC_CAPTURE
|
|
||||||
.try_with(|slot| slot.borrow().clone())
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
if let Some(capture) = capture {
|
|
||||||
capture.record(info);
|
|
||||||
} else {
|
|
||||||
previous(info);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn current_panic_capture() -> Option<PanicCapture> {
|
|
||||||
ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn with_panic_capture<R>(capture: Option<PanicCapture>, f: impl FnOnce() -> R) -> R {
|
|
||||||
let previous = ACTIVE_PANIC_CAPTURE.with(|slot| slot.replace(capture));
|
|
||||||
let _guard = PanicCaptureGuard(previous);
|
|
||||||
f()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run an unpack pipeline, converting any internal panic into a clean
|
|
||||||
/// [`UnpackError::InternalPanic`] so the public API stays panic-free on any input
|
|
||||||
/// (truncated/garbled files chase offsets out of bounds). The panic location and
|
|
||||||
/// payload are captured for diagnostics without printing a backtrace to stderr.
|
|
||||||
///
|
|
||||||
/// Note: allocation *failures* abort the process and are NOT caught here; size
|
|
||||||
/// requests are bounds-checked against [`MAX_IMAGE_SIZE`] before allocating.
|
|
||||||
pub(crate) fn catch_unpack<F>(f: F) -> Result<Vec<u8>, UnpackError>
|
|
||||||
where
|
|
||||||
F: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
|
||||||
{
|
|
||||||
install_panic_capture_hook();
|
|
||||||
let capture = PanicCapture::new();
|
|
||||||
let r = with_panic_capture(Some(capture.clone()), || {
|
|
||||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
|
|
||||||
});
|
|
||||||
match r {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(payload) => Err(capture.into_error(payload.as_ref())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Crackproof header magic stored in `keys[1]`/`info[1]`.
|
|
||||||
pub(crate) const MAGIC_KONN: u32 = 0x4E4E4F4B; // b"KONN" little-endian (= 1313754955)
|
|
||||||
|
|
||||||
/// True if `magic` is the Crackproof magic this unpacker supports.
|
|
||||||
pub(crate) fn is_supported_magic(magic: u32) -> bool {
|
|
||||||
magic == MAGIC_KONN
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Kind {
|
|
||||||
NativeExe,
|
|
||||||
ManagedExe,
|
|
||||||
NativeDll,
|
|
||||||
ManagedDll,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct Detected {
|
|
||||||
pub kind: Kind,
|
|
||||||
pub magic: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Content-based detection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Derive the 8-element Crackproof key table from the header at offset 4096.
|
|
||||||
/// Returns `None` if the input is too short or doesn't have a valid PE signature.
|
|
||||||
fn key_table(input: &[u8]) -> Option<[u32; 8]> {
|
|
||||||
// Need at least 4128 bytes: the key-table loop below reads dwords up to
|
|
||||||
// offset 4124 (bytes 4124..4127). Guarding only `< 4096` would let a
|
|
||||||
// 4096..4127-byte PE (e.g. a 4 KiB stub) panic in `get_u32`.
|
|
||||||
if input.len() < 4128 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Validate the PE signature with checked arithmetic so a crafted offset
|
|
||||||
// cannot wrap the bounds check on a narrower target.
|
|
||||||
let e_lfanew = primitives::get_u32(input, 0x3C);
|
|
||||||
let pe_start = e_lfanew as usize;
|
|
||||||
if pe_start.checked_add(4).is_none_or(|end| end > input.len()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if &input[pe_start..pe_start + 4] != b"PE\0\0" {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Derive 8 keys per the Crackproof header-key formula.
|
|
||||||
let mut keys = [0u32; 8];
|
|
||||||
keys[0] = primitives::get_u32(input, 4096);
|
|
||||||
let mut k = keys[0];
|
|
||||||
for i in 0u32..7 {
|
|
||||||
let cell = primitives::get_u32(input, 4100u32.wrapping_add(i.wrapping_mul(4)));
|
|
||||||
keys[(i + 1) as usize] = k ^ cell;
|
|
||||||
k = i.wrapping_mul(i) ^ (k.wrapping_add(cell).wrapping_sub(i));
|
|
||||||
}
|
|
||||||
Some(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect whether `input` is a Crackproof-protected binary and classify it.
|
|
||||||
/// Returns `None` if the magic doesn't match.
|
|
||||||
///
|
|
||||||
/// Routing: `keys[1]` must be the Crackproof magic (`KONN`).
|
|
||||||
/// The PE IMAGE_FILE_DLL characteristic distinguishes EXE vs DLL;
|
|
||||||
/// the CLR data-directory RVA distinguishes managed from native for both.
|
|
||||||
pub fn detect(input: &[u8]) -> Option<Detected> {
|
|
||||||
let keys = key_table(input)?;
|
|
||||||
let magic = keys[1];
|
|
||||||
// Anything whose magic doesn't match is left untouched rather than
|
|
||||||
// detected-then-errored, honoring the "anything that doesn't match is
|
|
||||||
// left untouched" contract.
|
|
||||||
if !is_supported_magic(magic) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Use the PE DLL characteristic to distinguish EXE from DLL.
|
|
||||||
// IMAGE_FILE_HEADER.Characteristics is at peOff+4+18; bit 0x2000 = IMAGE_FILE_DLL.
|
|
||||||
let pe_off = primitives::get_u32(input, 0x3C);
|
|
||||||
let chars_offset = pe_off.wrapping_add(4).wrapping_add(18);
|
|
||||||
if (chars_offset as usize)
|
|
||||||
.checked_add(2)
|
|
||||||
.is_none_or(|end| end > input.len())
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let chars =
|
|
||||||
(input[chars_offset as usize] as u16) | ((input[chars_offset as usize + 1] as u16) << 8);
|
|
||||||
let is_dll = (chars & 0x2000) != 0;
|
|
||||||
// Managed vs native via the CLR data-directory RVA.
|
|
||||||
// peOff + 24 = start of optional header. The data directories start at a
|
|
||||||
// magic-dependent offset within it: PE32 (0x10B) at +96, PE32+ (0x20B) at
|
|
||||||
// +112. Using the PE32+ offset on a PE32 image reads the wrong dword and
|
|
||||||
// can mis-flag a native image as managed.
|
|
||||||
//
|
|
||||||
// `get_u16`/`get_u32` index unchecked, so every read past the already-
|
|
||||||
// checked Characteristics word must be bounds-checked first: a truncated
|
|
||||||
// file (e.g. `e_lfanew` pointing at len-24) would otherwise panic here,
|
|
||||||
// and this detector runs on the folder scan threads where a panic aborts
|
|
||||||
// the whole run.
|
|
||||||
let opt_magic_off = pe_off.wrapping_add(24) as usize;
|
|
||||||
let b = input.get(opt_magic_off..opt_magic_off.checked_add(2)?)?;
|
|
||||||
let opt_magic = u16::from_le_bytes([b[0], b[1]]);
|
|
||||||
let dd_off: u32 = if opt_magic == 0x20B { 112 } else { 96 };
|
|
||||||
// + 14*8 = IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
|
|
||||||
let clr_rva_offset = pe_off
|
|
||||||
.wrapping_add(24)
|
|
||||||
.wrapping_add(dd_off)
|
|
||||||
.wrapping_add(14u32.wrapping_mul(8));
|
|
||||||
if (clr_rva_offset as usize)
|
|
||||||
.checked_add(4)
|
|
||||||
.is_none_or(|end| end > input.len())
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let clr_rva = primitives::get_u32(input, clr_rva_offset);
|
|
||||||
let kind = match (is_dll, clr_rva != 0) {
|
|
||||||
(false, false) => Kind::NativeExe,
|
|
||||||
(false, true) => Kind::ManagedExe,
|
|
||||||
(true, false) => Kind::NativeDll,
|
|
||||||
(true, true) => Kind::ManagedDll,
|
|
||||||
};
|
|
||||||
Some(Detected { kind, magic })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect the file type and dispatch to the matching pipeline.
|
|
||||||
/// Returns the detected `Kind` together with the unpacked image bytes.
|
|
||||||
pub fn unpack_auto(input: &[u8]) -> Result<(Kind, Vec<u8>), UnpackError> {
|
|
||||||
unpack_auto_v(input, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [`unpack_auto`], but prints detailed `[N/9]` unpack-step progress to
|
|
||||||
/// stdout when `verbose` is true. Output bytes are identical regardless.
|
|
||||||
pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec<u8>), UnpackError> {
|
|
||||||
let detected = detect(input).ok_or(UnpackError::NotCrackproof)?;
|
|
||||||
let out = match detected.kind {
|
|
||||||
Kind::NativeExe | Kind::ManagedExe => unpack_exe_v(input, verbose)?,
|
|
||||||
Kind::NativeDll | Kind::ManagedDll => {
|
|
||||||
// Two Crackproof DLL layouts exist. The older one (the byte-identical
|
|
||||||
// DLL goldens) follows the pipeline in `dll.rs`. Newer builds protect
|
|
||||||
// DLLs with the EXE-style shell layout instead — `dll::unpack_dll`
|
|
||||||
// cannot parse them and errors. Try the DLL pipeline first; on
|
|
||||||
// failure, fall back to the EXE pipeline, which handles the new
|
|
||||||
// layout (including managed-DLL CLR metadata restore). The DLL-first
|
|
||||||
// order keeps the old-layout goldens byte-identical (the EXE
|
|
||||||
// pipeline "succeeds" on them but with different bytes).
|
|
||||||
match dll::unpack_dll_v(input, verbose) {
|
|
||||||
Ok(out) => out,
|
|
||||||
Err(dll_err) => match exe::unpack_v(input, verbose) {
|
|
||||||
Ok(out) => out,
|
|
||||||
Err(exe_err) => {
|
|
||||||
return Err(UnpackError::PipelineFallbackFailed {
|
|
||||||
dll: Box::new(dll_err),
|
|
||||||
exe: Box::new(exe_err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok((detected.kind, out))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn caught_panic_reports_location_and_message() {
|
|
||||||
let error = catch_unpack(|| -> Result<Vec<u8>, UnpackError> {
|
|
||||||
panic!("test panic");
|
|
||||||
})
|
|
||||||
.expect_err("panic must become an error");
|
|
||||||
let UnpackError::InternalPanic {
|
|
||||||
message,
|
|
||||||
file,
|
|
||||||
line,
|
|
||||||
column,
|
|
||||||
} = error
|
|
||||||
else {
|
|
||||||
panic!("unexpected error: {error}");
|
|
||||||
};
|
|
||||||
assert_eq!(message, "test panic");
|
|
||||||
assert!(
|
|
||||||
file.ends_with("senbei-pe/src/engine/mod.rs")
|
|
||||||
|| file.ends_with("senbei-pe\\src\\engine\\mod.rs")
|
|
||||||
);
|
|
||||||
assert!(line > 0);
|
|
||||||
assert!(column > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worker_panic_keeps_the_worker_source_location() {
|
|
||||||
let error = catch_unpack(|| -> Result<Vec<u8>, UnpackError> {
|
|
||||||
let capture = current_panic_capture();
|
|
||||||
let result = std::thread::spawn(move || {
|
|
||||||
with_panic_capture(capture, || panic!("worker panic"));
|
|
||||||
})
|
|
||||||
.join();
|
|
||||||
if let Err(payload) = result {
|
|
||||||
std::panic::resume_unwind(payload);
|
|
||||||
}
|
|
||||||
Ok(Vec::new())
|
|
||||||
})
|
|
||||||
.expect_err("worker panic must become an error");
|
|
||||||
let UnpackError::InternalPanic {
|
|
||||||
message,
|
|
||||||
file,
|
|
||||||
line,
|
|
||||||
column,
|
|
||||||
} = error
|
|
||||||
else {
|
|
||||||
panic!("unexpected error: {error}");
|
|
||||||
};
|
|
||||||
assert_eq!(message, "worker panic");
|
|
||||||
assert!(
|
|
||||||
file.ends_with("senbei-pe/src/engine/mod.rs")
|
|
||||||
|| file.ends_with("senbei-pe\\src\\engine\\mod.rs")
|
|
||||||
);
|
|
||||||
assert!(line > 0);
|
|
||||||
assert!(column > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+231
-3
@@ -1,5 +1,233 @@
|
|||||||
//! 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one PE data-directory entry as `(RVA, size)`.
|
||||||
|
pub fn data_directory(data: &[u8], headers: Headers, index: u16) -> Result<(u32, u32)> {
|
||||||
|
let directory_base = headers
|
||||||
|
.pe_offset
|
||||||
|
.checked_add(24)
|
||||||
|
.and_then(|offset| offset.checked_add(if headers.is_pe32_plus { 112 } else { 96 }))
|
||||||
|
.ok_or(Error::OutOfBounds)?;
|
||||||
|
let offset = directory_base
|
||||||
|
.checked_add(
|
||||||
|
usize::from(index)
|
||||||
|
.checked_mul(8)
|
||||||
|
.ok_or(Error::OutOfBounds)?,
|
||||||
|
)
|
||||||
|
.ok_or(Error::OutOfBounds)?;
|
||||||
|
Ok((read_u32(data, offset)?, read_u32(data, offset + 4)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the COFF characteristics bit field.
|
||||||
|
pub fn characteristics(data: &[u8], headers: Headers) -> Result<u16> {
|
||||||
|
read_u16(
|
||||||
|
data,
|
||||||
|
headers
|
||||||
|
.pe_offset
|
||||||
|
.checked_add(22)
|
||||||
|
.ok_or(Error::OutOfBounds)?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a complete RVA range backed by file bytes in the headers or one section.
|
||||||
|
/// Unlike a virtual mapping, this rejects a section's zero-filled tail.
|
||||||
|
pub fn rva_range(
|
||||||
|
data: &[u8],
|
||||||
|
headers: Headers,
|
||||||
|
rva: u32,
|
||||||
|
size: u32,
|
||||||
|
) -> Result<std::ops::Range<usize>> {
|
||||||
|
let header_size = read_u32(data, headers.pe_offset + 24 + 60)?;
|
||||||
|
let offset = if rva < header_size && size <= header_size - rva {
|
||||||
|
rva
|
||||||
|
} else {
|
||||||
|
sections(data, headers)?
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|section| {
|
||||||
|
let delta = rva.checked_sub(section.virtual_address)?;
|
||||||
|
if delta >= section.raw_size || size > section.raw_size - delta {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
section.raw_offset.checked_add(delta)
|
||||||
|
})
|
||||||
|
.ok_or(Error::OutOfBounds)?
|
||||||
|
} as usize;
|
||||||
|
let end = offset
|
||||||
|
.checked_add(size as usize)
|
||||||
|
.ok_or(Error::OutOfBounds)?;
|
||||||
|
data.get(offset..end).ok_or(Error::OutOfBounds)?;
|
||||||
|
Ok(offset..end)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rva_ranges_require_file_backing_for_every_byte() {
|
||||||
|
let mut data = [0u8; 0x400];
|
||||||
|
let headers = Headers {
|
||||||
|
pe_offset: 0x40,
|
||||||
|
is_pe32_plus: false,
|
||||||
|
image_base: 0,
|
||||||
|
size_of_image: 0x2000,
|
||||||
|
entry_rva: 0x1000,
|
||||||
|
sections_offset: 0x100,
|
||||||
|
sections: 1,
|
||||||
|
};
|
||||||
|
for (offset, value) in [
|
||||||
|
(0x94, 0x200u32),
|
||||||
|
(0x108, 0x100),
|
||||||
|
(0x10c, 0x1000),
|
||||||
|
(0x110, 0x80),
|
||||||
|
(0x114, 0x200),
|
||||||
|
] {
|
||||||
|
data[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
assert_eq!(rva_range(&data, headers, 0x1000, 0x80), Ok(0x200..0x280));
|
||||||
|
assert_eq!(rva_range(&data, headers, 0x100, 0x100), Ok(0x100..0x200));
|
||||||
|
for (rva, size) in [(0x1070, 0x20), (0x1080, 1), (0x1f0, 0x20), (u32::MAX, 4)] {
|
||||||
|
assert_eq!(
|
||||||
|
rva_range(&data, headers, rva, size),
|
||||||
|
Err(Error::OutOfBounds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+471
-40
@@ -2,12 +2,38 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
"cpubits",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -16,15 +42,31 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console"
|
name = "console"
|
||||||
version = "0.16.4"
|
version = "0.16.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
|
checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encode_unicode",
|
"encode_unicode",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -42,12 +84,93 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpubits"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encode_unicode"
|
name = "encode_unicode"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
|
dependencies = [
|
||||||
|
"zlib-rs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
@@ -72,6 +195,55 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "goblin"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "17582616a7718cca54cec18e534a76c7c4aec11a8b9a85695712f262fd15a4c8"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"plain",
|
||||||
|
"scroll",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indicatif"
|
name = "indicatif"
|
||||||
version = "0.18.6"
|
version = "0.18.6"
|
||||||
@@ -86,10 +258,25 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "inout"
|
||||||
version = "0.3.104"
|
version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -98,9 +285,48 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.191"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memmap2"
|
||||||
|
version = "0.9.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
@@ -120,6 +346,12 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "plain"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -144,6 +376,25 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -160,49 +411,163 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-crypto"
|
name = "scroll"
|
||||||
version = "1.1.0"
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"scroll_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scroll_derive"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "senbei-crypto"
|
||||||
|
version = "1.3.4"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "senbei-elf"
|
||||||
|
version = "1.3.4"
|
||||||
|
dependencies = [
|
||||||
|
"goblin",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "senbei-engine"
|
||||||
|
version = "1.3.4"
|
||||||
|
dependencies = [
|
||||||
|
"memmap2",
|
||||||
|
"senbei-crypto",
|
||||||
|
"senbei-elf",
|
||||||
|
"senbei-pe",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-io"
|
name = "senbei-io"
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"libc",
|
"libc",
|
||||||
|
"memmap2",
|
||||||
"owo-colors",
|
"owo-colors",
|
||||||
|
"senbei-crypto",
|
||||||
|
"senbei-elf",
|
||||||
|
"senbei-engine",
|
||||||
"senbei-metadata",
|
"senbei-metadata",
|
||||||
"senbei-pe",
|
"senbei-pe",
|
||||||
|
"sha2",
|
||||||
|
"tempfile",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows",
|
"windows",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-metadata"
|
name = "senbei-metadata"
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-pe"
|
name = "senbei-pe"
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"senbei-crypto",
|
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-wasm"
|
name = "senbei-wasm"
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.154"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -222,9 +587,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.4"
|
version = "3.0.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -232,36 +597,61 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "tempfile"
|
||||||
version = "2.0.20"
|
version = "3.27.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.20"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "typed-path"
|
||||||
version = "1.0.24"
|
version = "0.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unit-prefix"
|
name = "unit-prefix"
|
||||||
@@ -281,12 +671,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.127"
|
version = "0.2.130"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"wasm-bindgen-macro",
|
"wasm-bindgen-macro",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
@@ -294,9 +683,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.127"
|
version = "0.2.130"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -304,22 +693,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.127"
|
version = "0.2.130"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.9",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.127"
|
version = "0.2.130"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -461,3 +850,45 @@ checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "9.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"flate2",
|
||||||
|
"getrandom",
|
||||||
|
"indexmap",
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"nix",
|
||||||
|
"typed-path",
|
||||||
|
"windows-sys",
|
||||||
|
"zopfli",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zlib-rs"
|
||||||
|
version = "0.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "senbei-wasm"
|
name = "senbei-wasm"
|
||||||
version = "1.1.0"
|
version = "1.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
rust-version = "1.99.1"
|
||||||
description = "WebAssembly bindings for senbei (browser frontend assets live in web/)"
|
description = "WebAssembly bindings for senbei (browser frontend assets live in web/)"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|
||||||
@@ -11,7 +12,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"
|
||||||
|
|
||||||
|
|||||||
+7
-10
@@ -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.
|
||||||
@@ -130,10 +130,7 @@ pub fn detect(input: &[u8]) -> Option<String> {
|
|||||||
/// is none). Throws a string error when the input is not a supported
|
/// is none). Throws a string error when the input is not a supported
|
||||||
/// Crackproof file or is corrupt.
|
/// Crackproof file or is corrupt.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn unpack_file(
|
pub fn unpack_file(input: &[u8], companion: Option<Vec<u8>>) -> Result<UnpackResult, JsError> {
|
||||||
input: &[u8],
|
|
||||||
companion: Option<Vec<u8>>,
|
|
||||||
) -> Result<UnpackResult, JsError> {
|
|
||||||
let r = senbei_io::job::unpack_bytes(input, companion.as_deref())
|
let r = senbei_io::job::unpack_bytes(input, companion.as_deref())
|
||||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
Ok(UnpackResult {
|
Ok(UnpackResult {
|
||||||
|
|||||||
+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/)
|
|
||||||
```
|
|
||||||
+31
-3
@@ -50,6 +50,22 @@ const KIND_LABEL = {
|
|||||||
'native-dll': 'protected native DLL',
|
'native-dll': 'protected native DLL',
|
||||||
'managed-dll': 'protected managed DLL',
|
'managed-dll': 'protected managed DLL',
|
||||||
metadata: 'il2cpp metadata',
|
metadata: 'il2cpp metadata',
|
||||||
|
'android-package':
|
||||||
|
'Android app package — the web build cannot unpack these yet; use the senbei CLI',
|
||||||
|
'android-so':
|
||||||
|
'Android AArch64 library — the web build cannot unpack these yet; use the senbei CLI',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Android targets are recognized by extension so the row can explain the
|
||||||
|
// situation instead of reporting a protected file as unrecognized: the
|
||||||
|
// Android pipeline is filesystem orchestration (senbei-io) with no wasm
|
||||||
|
// build, so these files need the CLI. The pseudo-kinds are labels only —
|
||||||
|
// they never reach the worker.
|
||||||
|
const ANDROID_KIND = {
|
||||||
|
apk: 'android-package',
|
||||||
|
apks: 'android-package',
|
||||||
|
xapk: 'android-package',
|
||||||
|
so: 'android-so',
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPANION_SVG =
|
const COMPANION_SVG =
|
||||||
@@ -91,8 +107,13 @@ async function stageFiles(list) {
|
|||||||
// the PE header fields); read a small slice, not the whole file.
|
// the PE header fields); read a small slice, not the whole file.
|
||||||
const head = new Uint8Array(await file.slice(0, 65536).arrayBuffer());
|
const head = new Uint8Array(await file.slice(0, 65536).arrayBuffer());
|
||||||
// Companions are ciphertext fragments; detect() only makes sense on the
|
// Companions are ciphertext fragments; detect() only makes sense on the
|
||||||
// base module, so skip it for `._` files.
|
// base module, so skip it for `._` files. Android targets short-circuit
|
||||||
const kind = file.name.endsWith('._') ? undefined : detect(head);
|
// detect() as well: an ELF/zip never classifies as a protected PE, and
|
||||||
|
// the row must carry the android pseudo-kind for its status line.
|
||||||
|
const ext = file.name.slice(file.name.lastIndexOf('.') + 1).toLowerCase();
|
||||||
|
const kind = file.name.endsWith('._')
|
||||||
|
? undefined
|
||||||
|
: (ANDROID_KIND[ext] ?? detect(head));
|
||||||
const old = files.get(file.name);
|
const old = files.get(file.name);
|
||||||
files.set(file.name, {
|
files.set(file.name, {
|
||||||
file,
|
file,
|
||||||
@@ -284,7 +305,10 @@ function render() {
|
|||||||
|
|
||||||
const unpackable = [...files].some(
|
const unpackable = [...files].some(
|
||||||
([name, e]) =>
|
([name, e]) =>
|
||||||
!name.endsWith('._') && e.kind !== undefined && e.state === 'staged',
|
!name.endsWith('._') &&
|
||||||
|
e.kind !== undefined &&
|
||||||
|
!e.kind.startsWith('android-') &&
|
||||||
|
e.state === 'staged',
|
||||||
);
|
);
|
||||||
unpackBtn.disabled = !unpackable;
|
unpackBtn.disabled = !unpackable;
|
||||||
actions.hidden = files.size === 0;
|
actions.hidden = files.size === 0;
|
||||||
@@ -364,6 +388,10 @@ unpackBtn.addEventListener('click', async () => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android rows are informational only (no wasm pipeline); their staged
|
||||||
|
// status line already says to use the CLI.
|
||||||
|
if (entry.kind.startsWith('android-')) continue;
|
||||||
|
|
||||||
entry.state = 'working';
|
entry.state = 'working';
|
||||||
render();
|
render();
|
||||||
try {
|
try {
|
||||||
|
|||||||
+3
-1
@@ -56,7 +56,9 @@
|
|||||||
<p class="hint">
|
<p class="hint">
|
||||||
Protected <code>.exe</code> / <code>.dll</code> modules, optional
|
Protected <code>.exe</code> / <code>.dll</code> modules, optional
|
||||||
<code>._</code> companions, or an il2cpp
|
<code>._</code> companions, or an il2cpp
|
||||||
<code>global-metadata.dat</code>.
|
<code>global-metadata.dat</code>. Android packages
|
||||||
|
(<code>.apk</code> / <code>.apks</code> / <code>.xapk</code>) and
|
||||||
|
<code>.so</code> libraries are recognized but need the CLI.
|
||||||
</p>
|
</p>
|
||||||
<input type="file" id="picker" multiple hidden>
|
<input type="file" id="picker" multiple hidden>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -333,6 +333,26 @@ footer {
|
|||||||
}
|
}
|
||||||
footer a { color: var(--dim); }
|
footer a { color: var(--dim); }
|
||||||
|
|
||||||
|
/* --- narrow screens --- */
|
||||||
|
|
||||||
|
/* On a narrow viewport the status column (flex: 1, right-aligned) is
|
||||||
|
squeezed by a long file name into a one-word-per-line vertical strip.
|
||||||
|
Stack the row instead: name + icons on the first line, the status on its
|
||||||
|
own full-width line below, left-aligned. */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
main { padding: 1.5rem 0.9rem 2.5rem; }
|
||||||
|
|
||||||
|
#dropzone { padding: 1.75rem 1rem; }
|
||||||
|
|
||||||
|
.file .row { flex-wrap: wrap; }
|
||||||
|
.file .name { flex: 1; min-width: 0; }
|
||||||
|
.file .status {
|
||||||
|
order: 6; /* after the download/remove icons */
|
||||||
|
flex-basis: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* --- animations --- */
|
/* --- animations --- */
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
|
|||||||
Reference in new issue
Block a user