mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-11-04 10:31:29 -05:00
Compare commits
33
Commits
512a627066
...
v.1.3.4
| 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 |
No files matched your search
@@ -6,7 +6,7 @@ Guidance for contributors working in this repository.
|
|||||||
|
|
||||||
Senbei is a static unpacker for protected PE files and Android AArch64 shared libraries. The workspace contains `senbei-cli`, `senbei-crypto`, `senbei-elf`, `senbei-engine`, `senbei-io`, `senbei-metadata`, and `senbei-pe`; `senbei-wasm` is a separate crate for the browser frontend.
|
Senbei is a static unpacker for protected PE files and Android AArch64 shared libraries. The workspace contains `senbei-cli`, `senbei-crypto`, `senbei-elf`, `senbei-engine`, `senbei-io`, `senbei-metadata`, and `senbei-pe`; `senbei-wasm` is a separate crate for the browser frontend.
|
||||||
|
|
||||||
Read `docs/design.md` before changing architecture or pipeline boundaries.
|
Read the [Senbei design notes](https://xn--ri8h.gitbook.io/crackproof-research/senbei) before changing architecture or pipeline boundaries.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ The format crates and PE engine remain free of filesystem I/O. Native Android ex
|
|||||||
|
|
||||||
## Hard Rules
|
## Hard Rules
|
||||||
|
|
||||||
- Outputs must be byte-identical to the available golden corpus.
|
- 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.)
|
||||||
- Layout heuristics must trial and validate every candidate before accepting it.
|
- Layout heuristics must trial and validate every candidate before accepting it.
|
||||||
- Deterministic parallel and sequential paths must produce identical bytes.
|
- Deterministic parallel and sequential paths must produce identical bytes.
|
||||||
- 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.
|
- 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.
|
||||||
@@ -37,4 +37,4 @@ The format crates and PE engine remain free of filesystem I/O. Native Android ex
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Use one line for each normal Markdown paragraph. Keep code blocks, table rows, and list items structurally separate. Update `docs/usage.md` when CLI behavior changes.
|
Use one line for each normal Markdown paragraph. Keep code blocks, table rows, and list items structurally separate. Product documentation lives at <https://xn--ri8h.gitbook.io/crackproof-research/senbei>; update that site (not this repository) when CLI behavior changes.
|
||||||
Generated
+93
-70
@@ -4,9 +4,9 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aes"
|
name = "aes"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher",
|
"cipher",
|
||||||
"cpubits",
|
"cpubits",
|
||||||
@@ -21,9 +21,9 @@ 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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
@@ -42,9 +42,15 @@ 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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
@@ -58,9 +64,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[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",
|
||||||
@@ -91,9 +97,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.1"
|
version = "1.5.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
@@ -126,9 +132,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
@@ -186,8 +192,10 @@ 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]]
|
[[package]]
|
||||||
@@ -209,18 +217,18 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.14"
|
version = "0.4.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.1"
|
version = "2.14.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown",
|
"hashbrown",
|
||||||
@@ -241,9 +249,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.2.2"
|
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 = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
@@ -256,9 +264,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.104"
|
version = "0.3.107"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -267,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"
|
||||||
@@ -279,9 +287,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.34"
|
version = "0.4.35"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
@@ -291,13 +299,25 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
version = "0.9.11"
|
version = "0.9.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"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"
|
||||||
@@ -354,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",
|
||||||
@@ -397,12 +417,12 @@ checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-cli"
|
name = "senbei-cli"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"senbei-engine",
|
"senbei-engine",
|
||||||
"senbei-io",
|
"senbei-io",
|
||||||
@@ -413,7 +433,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-crypto"
|
name = "senbei-crypto"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -421,7 +441,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-elf"
|
name = "senbei-elf"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"goblin",
|
"goblin",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -429,7 +449,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-engine"
|
name = "senbei-engine"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"senbei-crypto",
|
"senbei-crypto",
|
||||||
@@ -444,7 +464,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-io"
|
name = "senbei-io"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
@@ -465,7 +485,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-metadata"
|
name = "senbei-metadata"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -473,7 +493,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-pe"
|
name = "senbei-pe"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
@@ -505,14 +525,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.151"
|
version = "1.0.154"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -523,9 +543,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.11.0"
|
version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures",
|
||||||
@@ -557,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",
|
||||||
@@ -581,29 +601,29 @@ 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 = "typed-path"
|
name = "typed-path"
|
||||||
version = "0.12.3"
|
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 = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
@@ -613,15 +633,15 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
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"
|
||||||
@@ -641,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",
|
||||||
@@ -654,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",
|
||||||
@@ -664,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",
|
||||||
]
|
]
|
||||||
@@ -824,23 +843,27 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "8.6.0"
|
version = "9.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"getrandom",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
|
"libc",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
"nix",
|
||||||
"typed-path",
|
"typed-path",
|
||||||
|
"windows-sys",
|
||||||
"zopfli",
|
"zopfli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.7"
|
version = "0.6.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
@@ -850,9 +873,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zopfli"
|
name = "zopfli"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
|
|||||||
+3
-3
@@ -15,9 +15,9 @@ exclude = ["senbei-wasm"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.98.1"
|
rust-version = "1.99.1"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
@@ -39,7 +39,7 @@ windows = { version = "0.62", features = [
|
|||||||
"Win32_System_Console",
|
"Win32_System_Console",
|
||||||
"Win32_System_SystemInformation",
|
"Win32_System_SystemInformation",
|
||||||
] }
|
] }
|
||||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
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-elf = { path = "senbei-elf" }
|
||||||
senbei-engine = { path = "senbei-engine" }
|
senbei-engine = { path = "senbei-engine" }
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
# Senbei
|
# Senbei
|
||||||
|
|
||||||
A static unpacker for Crackproof-protected 64-bit and 32-bit PE files and protected Android AArch64 shared libraries. Point it at a file, an app package, or a folder and it writes decrypted copies without launching the protected program.
|
A static unpacker for CrackProof-protected Windows PE files and Android AArch64 shared libraries.
|
||||||
|
|
||||||
Senbei reads protected input bytes and replays the unpacking algorithm statically. The command-line tool adds filesystem scanning, progress reporting, and logs; `senbei-wasm` provides the browser binding.
|
> _"Crackproof"? It's senbei (煎餅 — rice cracker). Cracks itself._
|
||||||
|
|
||||||
## Crates
|
## Usage
|
||||||
|
|
||||||
The workspace contains eight crates: `senbei-cli`, `senbei-crypto`, `senbei-io`, `senbei-metadata`, `senbei-pe`, `senbei-elf`, `senbei-engine`, and `senbei-wasm`.
|
|
||||||
|
|
||||||
`senbei-pe` and `senbei-elf` contain validated format parsing, address mapping, and ELF dynamic-table helpers. Protection-specific code is in `senbei-engine/src/windows/` and `senbei-engine/src/android/`. Platform-specific crypto is grouped under `senbei-crypto/src/windows/` and `senbei-crypto/src/android/`; metadata code shared by both platforms stays at the `senbei-metadata` root, with seeded Android code under `src/android/`.
|
|
||||||
|
|
||||||
## Supported Inputs
|
|
||||||
|
|
||||||
- Protected Windows `.exe` and `.dll` files, including external `<name>.exe._` and `<name>.dll._` payloads.
|
|
||||||
- `global-metadata.dat` files with supported method-token layouts.
|
|
||||||
- Protected Android `.so` files and Android `.apk`, `.apks`, and `.xapk` packages.
|
|
||||||
|
|
||||||
Windows scanning probes only `.exe`, `.dll`, and `global-metadata.dat`; companion payloads are consumed through their matching stub and are not counted as skipped files. Android scanning probes only `.so` and `global-metadata.dat`. Android packages are inspected from their ZIP manifests and only matching `.so` and metadata entries are extracted.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```cmd
|
```cmd
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@@ -27,30 +13,20 @@ senbei game.apk
|
|||||||
senbei "C:\Games\MyGame"
|
senbei "C:\Games\MyGame"
|
||||||
```
|
```
|
||||||
|
|
||||||
Outputs are written below an `unpack` directory unless `--out` is supplied. Every restored PE or ELF image passes a structural validation step before it is reported as successful.
|
Outputs are written below an `unpack` directory unless `--out` is supplied.
|
||||||
|
|
||||||
## Tests
|
Full documentation: <https://xn--ri8h.gitbook.io/crackproof-research/senbei>
|
||||||
|
|
||||||
```cmd
|
## Legal notice and intended use
|
||||||
cargo test --release --workspace
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo fmt --all -- --check
|
|
||||||
```
|
|
||||||
|
|
||||||
The local `test/` corpus can be passed to the CLI for real sample verification. The tracked `samples/` corpus is optional and remains user-managed.
|
**Read this before using Senbei.**
|
||||||
|
|
||||||
## Web Build
|
- Senbei is a research and interoperability tool. It exists to enable lawful reverse engineering, security research, preservation, and interoperability with software you already legitimately possess.
|
||||||
|
- **Only process binaries you own or are explicitly authorized to analyze.** Depending on your jurisdiction and license agreements, circumventing technological protection measures may be restricted (for example under DMCA §1201 in the United States, which contains exemptions for security research and interoperability). It is your responsibility to ensure your use is lawful.
|
||||||
```cmd
|
- Senbei does not bypass any access control for you: it performs a purely static transformation of a file already on your disk. It derives everything it needs from the input file itself, contains no vendor code, and distributes no cracks or copyrighted content. (One Android packaging variant's embedded metadata layer is unwrapped with an XOR keystream recovered from a ciphertext/plaintext pair during analysis of a single build; that keystream is research output shipped with the unpacker, not a vendor-distributed key, and builds it doesn't match are left alone.)
|
||||||
cd senbei-wasm
|
- Senbei does not enable online play, license fraud, or cheating, and must not be used to redistribute decrypted binaries. Do not upload outputs anywhere.
|
||||||
wasm-pack build --target web --release --out-dir ../web/pkg
|
- The authors provide this software "as is", without warranty of any kind, and accept no liability for misuse. See [LICENSE](LICENSE) (AGPL-3.0).
|
||||||
```
|
- "Crackproof" is a trademark of its respective owner; this project is not affiliated with or endorsed by the protection vendor or any software publisher. Names are used for identification only.
|
||||||
|
|
||||||
The generated package is written to the ignored `web/pkg/` directory and can be served with any static HTTP server.
|
|
||||||
|
|
||||||
## Legal Notice
|
|
||||||
|
|
||||||
Use Senbei only for software you own or are authorized to analyze. The project is intended for lawful reverse engineering, security research, preservation, and interoperability.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
# Design
|
|
||||||
|
|
||||||
Senbei is a fully static unpacker. It reads protected bytes, replays the protection algorithm, validates the result, and writes a recovered image without launching or attaching to the protected program.
|
|
||||||
|
|
||||||
## Crate Layout
|
|
||||||
|
|
||||||
The workspace is organized into eight crates. `senbei-cli` is the command-line entry point, `senbei-io` owns filesystem orchestration, `senbei-wasm` provides browser bindings, `senbei-pe` and `senbei-elf` provide basic format parsing, `senbei-crypto` provides shared primitives, `senbei-metadata` restores metadata, and `senbei-engine` owns protection-specific pipelines.
|
|
||||||
|
|
||||||
Single-platform source stays directly under `src/`. Multi-platform crates keep platform code below `src/windows/` and `src/android/`, with shared code directly below `src/`.
|
|
||||||
|
|
||||||
```text
|
|
||||||
senbei-cli/src/main.rs
|
|
||||||
senbei-crypto/src/
|
|
||||||
senbei-crypto/src/android/
|
|
||||||
senbei-crypto/src/windows/
|
|
||||||
senbei-elf/src/
|
|
||||||
senbei-engine/src/windows/
|
|
||||||
senbei-engine/src/android/
|
|
||||||
senbei-io/src/
|
|
||||||
senbei-io/src/android/
|
|
||||||
senbei-io/src/windows/
|
|
||||||
senbei-metadata/src/
|
|
||||||
senbei-metadata/src/windows/
|
|
||||||
senbei-metadata/src/android/
|
|
||||||
senbei-pe/src/
|
|
||||||
senbei-wasm/src/
|
|
||||||
```
|
|
||||||
|
|
||||||
`senbei-pe` and `senbei-elf` own validated format models, address mapping, and ELF dynamic hash helpers. They do not depend on the unpacking engines, filesystem code, or platform protection logic.
|
|
||||||
|
|
||||||
## Windows Engine
|
|
||||||
|
|
||||||
`senbei-engine/src/windows/` contains PE detection, layout discovery, EXE and DLL restoration, deterministic block parallelism, and structural integrity checks. Candidate layouts are trial-decrypted and validated before an output is accepted.
|
|
||||||
|
|
||||||
External companion inputs are reconstructed as `stub[..4096]` followed by the matching `._` payload. The stub's export, TLS, and declared CLR regions are overlaid after unpacking because those regions are not present in the encrypted companion. Managed restoration follows the COR20 directory and referenced metadata, resources, and vtable fixups through each file's RVA mapping, preserving the decrypted method bodies.
|
|
||||||
|
|
||||||
## Android Engine
|
|
||||||
|
|
||||||
`senbei-engine/src/android/extract/` decrypts the stage-1 header and stage-2 record streams and writes a temporary module workspace. `senbei-engine/src/android/restore/` applies decoded image and fixup containers to the hollowed ELF and rebuilds dynamic-linker tables. Both phases validate bounds and table placement before writing output.
|
|
||||||
|
|
||||||
Windows protection primitives are in `senbei-crypto/src/windows/`, while Android protection primitives are in `senbei-crypto/src/android/`. Android seeded metadata restoration is in `senbei-metadata/src/android/`; the structural MethodDef transform is shared at the metadata crate root because both platform paths use it.
|
|
||||||
|
|
||||||
Android ELF dynamic tables are located from the input section table and its actual file ranges. When the original gap is too small, restoration adds a validated read-only `PT_LOAD` after the existing load image and updates the dynamic tags; it never overwrites an adjacent section or emits a partial image.
|
|
||||||
|
|
||||||
## Scanning and Packages
|
|
||||||
|
|
||||||
Folder scanning uses platform target names to avoid opening bulk assets: Windows candidates are `.exe`, `.dll`, and `global-metadata.dat`; Android candidates are `.so` and `global-metadata.dat`. The shared walker is in `senbei-io/src/scan.rs`; platform name filters and PE companion byte adaptation are in `senbei-io/src/windows/`, and Android package adaptation is in `senbei-io/src/android/`. A Windows `.exe._` or `.dll._` companion is auxiliary input for its sibling stub and is excluded from the skipped count.
|
|
||||||
|
|
||||||
APK, APKS, and XAPK files are containers. Senbei reads their ZIP manifests first, follows nested APK entries when necessary, and extracts only `.so` and exact `global-metadata.dat` entries. Extraction streams directly to temporary files, so compressed and decompressed copies are not held in memory together.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Every heuristic layout uses trial-and-validate. A candidate that fails structural checks, checksums, or table bounds is rejected and the next candidate is tried. A failed restore is reported as an error rather than emitting a silently damaged binary.
|
|
||||||
|
|
||||||
The PE integrity check verifies headers, section ranges, entry-point mapping, import names, relocation requirements, and managed metadata signatures. Android restoration validates ELF ranges, decoded container sizes, fixup bounds, and rebuilt dynamic tables.
|
|
||||||
|
|
||||||
## WebAssembly
|
|
||||||
|
|
||||||
The browser binding depends on `senbei-engine` through the I/O byte API. Native filesystem and Android package orchestration remain outside the browser workflow. Each browser unpack runs in a disposable worker because WebAssembly cannot recover from a caught panic in the same way as native code.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Development
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
Rust 1.98.1 is required and pinned in `rust-toolchain.toml`. Build the CLI with `cargo build --release`; the binary is written to `target/release/senbei.exe` on Windows.
|
|
||||||
|
|
||||||
The workspace crates are portable where their APIs are pure. The browser binding is outside the workspace and is checked with `cargo check --manifest-path senbei-wasm/Cargo.toml` or built with `wasm-pack`.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
cargo test --release --workspace
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
cargo fmt --all -- --check
|
|
||||||
```
|
|
||||||
|
|
||||||
The tracked test suite is safe without protected samples. The optional local `samples/` corpus is user-managed and the ignored `test/` folder can be used for real Windows and Android runs.
|
|
||||||
|
|
||||||
For an Android package, use one command at a time because a protected `.so` can be hundreds of megabytes. APK, APKS, and XAPK tests read the ZIP manifest first and extract only `.so` and `global-metadata.dat` entries.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
- `DD8_SHIFT` overrides the PE page-XOR shift; `99` skips that stage.
|
|
||||||
- `SEL_DIAG` prints PE layout-selector diagnostics.
|
|
||||||
- `SENBEI_THREADS` caps deterministic block fan-out; `1` forces the sequential reference path.
|
|
||||||
- `SENBEI_SCAN_ALL` enables probing selected target names below the size floor; it never enables arbitrary filenames.
|
|
||||||
- `SENBEI_ANDROID_SAMPLES` overrides the Android sample corpus location.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
Format crates stay free of filesystem I/O and protection-specific logic. Windows engine code lives below `senbei-engine/src/windows/`, Android engine code below `senbei-engine/src/android/`, and shared code stays directly under each crate's `src/`.
|
|
||||||
|
|
||||||
Layout heuristics must trial and validate every candidate. A failed validation is an error or a fall-through, never a silently accepted offset.
|
|
||||||
|
|
||||||
Outputs must remain byte-identical against the available golden corpus. Run the full workspace tests after changing a pipeline or a metadata layout.
|
|
||||||
|
|
||||||
Folder scanning uses explicit target names to avoid opening bulk assets. External `.exe._` and `.dll._` files are auxiliary data for their sibling stubs and are not independent scan targets.
|
|
||||||
|
|
||||||
## Repository Layout
|
|
||||||
|
|
||||||
```text
|
|
||||||
senbei-cli/ command-line binary and integration tests
|
|
||||||
senbei-crypto/ Windows and Android crypto primitives
|
|
||||||
senbei-elf/ ELF parsing, mapping, and dynamic-table helpers
|
|
||||||
senbei-engine/ Windows and Android unpacking engines
|
|
||||||
senbei-io/ filesystem, package, scanning, and platform adapters
|
|
||||||
senbei-metadata/ shared, Windows, and Android metadata restoration
|
|
||||||
senbei-pe/ PE parsing, data directories, and RVA mapping
|
|
||||||
senbei-wasm/ browser bindings and its own lockfile
|
|
||||||
web/ static browser frontend
|
|
||||||
samples/ optional local corpus
|
|
||||||
```
|
|
||||||
|
|
||||||
## Web Build
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
cd senbei-wasm
|
|
||||||
wasm-pack build --target web --release --out-dir ../web/pkg
|
|
||||||
```
|
|
||||||
|
|
||||||
Serve `web/` with a static HTTP server after the build. The browser never uploads input files.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Usage
|
|
||||||
|
|
||||||
```text
|
|
||||||
senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Single File
|
|
||||||
|
|
||||||
The output is written below `<parent>/unpack/` with `.unpack` inserted before the extension. `--out DIR` changes both the output and log directory.
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
senbei app.exe
|
|
||||||
senbei app.exe --out C:\out
|
|
||||||
```
|
|
||||||
|
|
||||||
For `global-metadata.dat`, Senbei writes `global-metadata.unpack.dat` only when method tokens change. Unsupported metadata versions remain untouched and are reported as skipped.
|
|
||||||
|
|
||||||
## Android Targets
|
|
||||||
|
|
||||||
Protected `.so` files are restored from their encrypted payload sections and written as `libil2cpp.unpack.so` or the corresponding input name. APK, APKS, and XAPK files are treated as containers: their manifests are read first, nested APKs are followed when necessary, and only `.so` and exact `global-metadata.dat` entries are extracted.
|
|
||||||
|
|
||||||
If a restored library contains embedded metadata, the unwrapped blob is written beside it as `global-metadata.unpack.dat`. Identical loose and package entries are restored once, preferring the loose file.
|
|
||||||
|
|
||||||
## Folder Mode
|
|
||||||
|
|
||||||
Folder mode walks recursively, skips directories named `unpack`, and mirrors recognized outputs below `<root>/unpack/` or `--out DIR`. Windows candidates are `.exe`, `.dll`, and `global-metadata.dat`; Android candidates are `.so` and `global-metadata.dat`. A matching `.exe._` or `.dll._` payload is consumed by its stub and is excluded from the skipped count.
|
|
||||||
|
|
||||||
Managed DLL companions retain CLR metadata and related runtime tables in the original DLL. Both the DLL and its matching `._` file must be available; Senbei restores the declared CLR regions from the DLL while retaining method bodies decrypted from the companion. Invalid or missing referenced regions are reported as errors.
|
|
||||||
|
|
||||||
The summary has the form `12 unpacked · 3 skipped · 0 errors · 1 suspect · 2 metadata`; the package count is appended when packages were opened. Each file is isolated so one failed target does not stop the folder run.
|
|
||||||
|
|
||||||
## Integrity Check
|
|
||||||
|
|
||||||
PE outputs are checked for valid headers, section ranges, entry-point mapping, readable import names, relocation requirements, and managed metadata signatures. Android outputs are validated during ELF restoration, including decoded container sizes, fixup bounds, and rebuilt dynamic tables.
|
|
||||||
|
|
||||||
A clean report is not a proof of correctness, but a non-clean report is a reliable broken-output signal. Suspect PE files are still written and counted separately.
|
|
||||||
|
|
||||||
## Flags
|
|
||||||
|
|
||||||
| Flag | Behavior |
|
|
||||||
| --- | --- |
|
|
||||||
| `--out DIR` | Write outputs and logs below `DIR`. |
|
|
||||||
| `-v`, `--verbose` | Print per-stage progress. |
|
|
||||||
| `-q`, `--quiet` | Hide progress and per-file lines; repeat to suppress all standard output. |
|
|
||||||
| `--no-log` | Do not write a run log. |
|
|
||||||
| `--scan-all` | Probe every selected target-name candidate, including files below the size floor. |
|
|
||||||
| `--no-pause` | Disable the Explorer-friendly Windows exit prompt. |
|
|
||||||
| `-V`, `--version` | Print the version and exit. |
|
|
||||||
| `-h`, `--help` | Show usage. |
|
|
||||||
|
|
||||||
## Exit Codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `0` | The requested restore completed without errors. |
|
|
||||||
| `1` | A target failed, a scan probe was unreadable, or a single-file restore errored. |
|
|
||||||
| `2` | The command line was invalid. |
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.98.1"
|
channel = "1.99.1"
|
||||||
# CI invokes rustfmt/clippy through the rustup shim with no setup action, so
|
# CI invokes rustfmt/clippy through the rustup shim with no setup action, so
|
||||||
# the pinned toolchain must declare its components here — the runner images
|
# the pinned toolchain must declare its components here — the runner images
|
||||||
# only preinstall them for their default toolchain.
|
# only preinstall them for their default toolchain.
|
||||||
|
|||||||
+178
-5
@@ -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,16 +71,17 @@ 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) => {
|
||||||
@@ -77,7 +89,7 @@ fn main() -> std::process::ExitCode {
|
|||||||
println!("{}", summary.line());
|
println!("{}", summary.line());
|
||||||
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 {
|
||||||
@@ -90,12 +102,56 @@ 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!(
|
||||||
" input a Crackproof PE (.exe/.dll), an il2cpp global-metadata.dat,\n\
|
" input a Crackproof PE (.exe/.dll), an il2cpp global-metadata.dat,\n\
|
||||||
@@ -107,4 +163,121 @@ fn print_help() {
|
|||||||
" --scan-all probe selected .exe/.dll/.so/metadata names below the\n\
|
" --scan-all probe selected .exe/.dll/.so/metadata names below the\n\
|
||||||
\x20 size floor; other filenames remain excluded."
|
\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,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"));
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
mod protector;
|
mod protector;
|
||||||
|
|
||||||
pub use protector::{
|
pub use protector::{
|
||||||
ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor,
|
ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, OutputLimit,
|
||||||
decode_container, gf32_mul_fixed, transform_segment,
|
ProtectedDescriptor, decode_container, decode_container_with_limit, gf32_mul_fixed,
|
||||||
|
transform_segment,
|
||||||
};
|
};
|
||||||
@@ -14,6 +14,30 @@ pub enum Error {
|
|||||||
|
|
||||||
type Result<T> = std::result::Result<T, Error>;
|
type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// Ceiling for one decoded container or compressed writer.
|
||||||
|
///
|
||||||
|
/// `decode_container` stays unlimited so existing callers keep their behavior.
|
||||||
|
/// Extraction and restoration pass the shared engine entry ceiling here and
|
||||||
|
/// reject the declared size before the output buffer is reserved.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct OutputLimit {
|
||||||
|
pub max_output_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OutputLimit {
|
||||||
|
fn check(self, bytes: usize, what: &str) -> Result<()> {
|
||||||
|
let requested = u64::try_from(bytes)
|
||||||
|
.map_err(|_| Error::Invalid(format!("{what} length exceeds u64")))?;
|
||||||
|
if requested > self.max_output_bytes {
|
||||||
|
return invalid(format!(
|
||||||
|
"{what} of {requested} bytes exceeds the {limit}-byte output ceiling",
|
||||||
|
limit = self.max_output_bytes
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn invalid<T>(message: impl Into<String>) -> Result<T> {
|
fn invalid<T>(message: impl Into<String>) -> Result<T> {
|
||||||
Err(Error::Invalid(message.into()))
|
Err(Error::Invalid(message.into()))
|
||||||
}
|
}
|
||||||
@@ -417,7 +441,24 @@ impl HuffmanLzDecoder {
|
|||||||
|
|
||||||
/// Decode one compressed writer payload to its exact expected size.
|
/// Decode one compressed writer payload to its exact expected size.
|
||||||
pub fn decode(&self, source: &[u8], output_size: usize) -> Result<Vec<u8>> {
|
pub fn decode(&self, source: &[u8], output_size: usize) -> Result<Vec<u8>> {
|
||||||
let mut output = vec![0_u8; output_size];
|
self.decode_limited(source, output_size, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one writer, rejecting `output_size` before its buffer is reserved.
|
||||||
|
pub fn decode_limited(
|
||||||
|
&self,
|
||||||
|
source: &[u8],
|
||||||
|
output_size: usize,
|
||||||
|
limit: Option<OutputLimit>,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
limit.check(output_size, "compressed writer output")?;
|
||||||
|
}
|
||||||
|
let mut output = Vec::new();
|
||||||
|
output.try_reserve_exact(output_size).map_err(|_| {
|
||||||
|
Error::Invalid("compressed writer output reservation failed".to_owned())
|
||||||
|
})?;
|
||||||
|
output.resize(output_size, 0);
|
||||||
let mut source_pos = 0_usize;
|
let mut source_pos = 0_usize;
|
||||||
let mut bit_buffer = 0_u64;
|
let mut bit_buffer = 0_u64;
|
||||||
let mut available = 0_u8;
|
let mut available = 0_u8;
|
||||||
@@ -548,7 +589,11 @@ pub fn transform_segment(
|
|||||||
aes_key: &[u8; 32],
|
aes_key: &[u8; 32],
|
||||||
decrypt_aes: bool,
|
decrypt_aes: bool,
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
let mut transformed = data.to_vec();
|
let mut transformed = Vec::new();
|
||||||
|
transformed
|
||||||
|
.try_reserve_exact(data.len())
|
||||||
|
.map_err(|_| Error::Invalid("segment reservation failed".to_owned()))?;
|
||||||
|
transformed.extend_from_slice(data);
|
||||||
let mut state = seed;
|
let mut state = seed;
|
||||||
let mut left = 0xe34e_ac63_u32;
|
let mut left = 0xe34e_ac63_u32;
|
||||||
let mut right = 0x07b4_8238_u32;
|
let mut right = 0x07b4_8238_u32;
|
||||||
@@ -590,11 +635,31 @@ pub fn transform_segment(
|
|||||||
/// Decode one complete protector container into its flat output buffer.
|
/// Decode one complete protector container into its flat output buffer.
|
||||||
///
|
///
|
||||||
/// This is the static equivalent of the decoder entrypoint embedded in Stage
|
/// This is the static equivalent of the decoder entrypoint embedded in Stage
|
||||||
/// 2 and in each nested interpreter module.
|
/// 2 and in each nested interpreter module. The declared output size is not
|
||||||
|
/// capped; [`decode_container_with_limit`] applies the shared entry ceiling.
|
||||||
pub fn decode_container(
|
pub fn decode_container(
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
config: &Module9bConfig,
|
config: &Module9bConfig,
|
||||||
expected_size: usize,
|
expected_size: usize,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
decode_container_inner(data, config, expected_size, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`decode_container`] with a ceiling checked before the output is reserved.
|
||||||
|
pub fn decode_container_with_limit(
|
||||||
|
data: &[u8],
|
||||||
|
config: &Module9bConfig,
|
||||||
|
expected_size: usize,
|
||||||
|
limit: OutputLimit,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
decode_container_inner(data, config, expected_size, Some(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_container_inner(
|
||||||
|
data: &[u8],
|
||||||
|
config: &Module9bConfig,
|
||||||
|
expected_size: usize,
|
||||||
|
limit: Option<OutputLimit>,
|
||||||
) -> Result<Vec<u8>> {
|
) -> Result<Vec<u8>> {
|
||||||
let header = ContainerHeader::parse(data, 0, config.container_seed)?;
|
let header = ContainerHeader::parse(data, 0, config.container_seed)?;
|
||||||
let header_size = usize::try_from(header.output_size)
|
let header_size = usize::try_from(header.output_size)
|
||||||
@@ -604,9 +669,20 @@ pub fn decode_container(
|
|||||||
"container output size 0x{header_size:x} != expected 0x{expected_size:x}"
|
"container output size 0x{header_size:x} != expected 0x{expected_size:x}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
// Both declared sizes are rejected before the flat output exists, so a
|
||||||
|
// hostile container header cannot force the allocation by disagreeing
|
||||||
|
// with the record that pointed at it.
|
||||||
|
limit.check(header_size, "container output")?;
|
||||||
|
limit.check(expected_size, "container output")?;
|
||||||
|
}
|
||||||
let decoder = HuffmanLzDecoder::new(&header.tree)?;
|
let decoder = HuffmanLzDecoder::new(&header.tree)?;
|
||||||
let decrypt_aes = !(config.skip_aes || header.skip_aes);
|
let decrypt_aes = !(config.skip_aes || header.skip_aes);
|
||||||
let mut output = vec![0_u8; expected_size];
|
let mut output = Vec::new();
|
||||||
|
output
|
||||||
|
.try_reserve_exact(expected_size)
|
||||||
|
.map_err(|_| Error::Invalid("container output reservation failed".to_owned()))?;
|
||||||
|
output.resize(expected_size, 0);
|
||||||
|
|
||||||
for (segment_index, encoded) in header.segments.iter().enumerate() {
|
for (segment_index, encoded) in header.segments.iter().enumerate() {
|
||||||
let start = header
|
let start = header
|
||||||
@@ -614,6 +690,9 @@ pub fn decode_container(
|
|||||||
.checked_add(encoded.offset as usize)
|
.checked_add(encoded.offset as usize)
|
||||||
.ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?;
|
.ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?;
|
||||||
let encoded_data = range(data, start, encoded.size as usize)?;
|
let encoded_data = range(data, start, encoded.size as usize)?;
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
limit.check(encoded.size as usize, "encoded segment")?;
|
||||||
|
}
|
||||||
let transformed = transform_segment(
|
let transformed = transform_segment(
|
||||||
encoded_data,
|
encoded_data,
|
||||||
config.container_seed,
|
config.container_seed,
|
||||||
@@ -662,18 +741,28 @@ pub fn decode_container(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let source = &transformed[data_cursor..encoded_end];
|
let source = &transformed[data_cursor..encoded_end];
|
||||||
let decoded = if encoded_size == output_size {
|
if let Some(limit) = limit {
|
||||||
None
|
limit.check(encoded_size, "writer source")?;
|
||||||
} else {
|
limit.check(output_size, "writer destination")?;
|
||||||
Some(decoder.decode(source, output_size)?)
|
}
|
||||||
};
|
|
||||||
let decoded = decoded.as_deref().unwrap_or(source);
|
|
||||||
let target = base_offset
|
let target = base_offset
|
||||||
.checked_add(output_offset)
|
.checked_add(output_offset)
|
||||||
.ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?;
|
.ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?;
|
||||||
let target_end = target
|
let target_end = target
|
||||||
.checked_add(decoded.len())
|
.checked_add(output_size)
|
||||||
.ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?;
|
.ok_or_else(|| Error::Invalid("writer target end overflow".to_owned()))?;
|
||||||
|
if target_end > output.len() {
|
||||||
|
return invalid(format!(
|
||||||
|
"segment {segment_index} writer {writer_index} target 0x{target:x}..0x{target_end:x} is outside the 0x{:x}-byte container",
|
||||||
|
output.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let decoded = if encoded_size == output_size {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(decoder.decode_limited(source, output_size, limit)?)
|
||||||
|
};
|
||||||
|
let decoded = decoded.as_deref().unwrap_or(source);
|
||||||
let destination = output.get_mut(target..target_end).ok_or_else(|| {
|
let destination = output.get_mut(target..target_end).ok_or_else(|| {
|
||||||
Error::Invalid(format!(
|
Error::Invalid(format!(
|
||||||
"segment {segment_index} writer {writer_index} target is out of range"
|
"segment {segment_index} writer {writer_index} target is out of range"
|
||||||
@@ -709,4 +798,262 @@ mod tests {
|
|||||||
fn descriptor_rejects_truncated_input() {
|
fn descriptor_rejects_truncated_input() {
|
||||||
assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err());
|
assert!(ProtectedDescriptor::decrypt(&[0_u8; 16], 1).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A container whose declared output is past the ceiling.
|
||||||
|
///
|
||||||
|
/// Parsing succeeds. Decoding must fail on that declared size before the
|
||||||
|
/// flat output is reserved, so the failure mentions the output ceiling and
|
||||||
|
/// not a later segment or writer error. The exact ceiling is allowed
|
||||||
|
/// through that check.
|
||||||
|
#[test]
|
||||||
|
fn oversized_container_is_rejected_before_its_output_is_reserved() {
|
||||||
|
let config = empty_config();
|
||||||
|
let over = synthetic_container(&config, 5, None);
|
||||||
|
let error = decode_container_with_limit(
|
||||||
|
&over,
|
||||||
|
&config,
|
||||||
|
5,
|
||||||
|
OutputLimit {
|
||||||
|
max_output_bytes: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("five-byte output is over the four-byte ceiling");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("container output"),
|
||||||
|
"unexpected error: {error}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let boundary = synthetic_container(&config, 4, None);
|
||||||
|
let error = decode_container_with_limit(
|
||||||
|
&boundary,
|
||||||
|
&config,
|
||||||
|
4,
|
||||||
|
OutputLimit {
|
||||||
|
max_output_bytes: 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("the boundary container has no usable segment body");
|
||||||
|
assert!(
|
||||||
|
!error.to_string().contains("output ceiling"),
|
||||||
|
"the boundary size must pass the ceiling: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The container itself is within the ceiling. Its writer declares a
|
||||||
|
/// decoded length past that ceiling and must be rejected before Huffman
|
||||||
|
/// decoding reserves a buffer of that length.
|
||||||
|
#[test]
|
||||||
|
fn oversized_writer_is_rejected_before_decompression() {
|
||||||
|
let config = empty_config();
|
||||||
|
let data = synthetic_container(&config, 8, Some((0, 128)));
|
||||||
|
let error = decode_container_with_limit(
|
||||||
|
&data,
|
||||||
|
&config,
|
||||||
|
8,
|
||||||
|
OutputLimit {
|
||||||
|
// The segment body is 36 bytes, under this ceiling. The writer
|
||||||
|
// declares 128 decoded bytes, which is over it.
|
||||||
|
max_output_bytes: 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("writer destination exceeds the container ceiling");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("writer destination"),
|
||||||
|
"unexpected error: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The declared writer length is within the entry ceiling and would be a
|
||||||
|
/// legal allocation on its own. It still starts past the flat container,
|
||||||
|
/// so the range check rejects it before Huffman decoding.
|
||||||
|
#[test]
|
||||||
|
fn writer_inside_the_entry_cap_but_outside_the_container_is_rejected_first() {
|
||||||
|
let config = empty_config();
|
||||||
|
let data = synthetic_container(&config, 8, Some((8, 4)));
|
||||||
|
let error = decode_container_with_limit(
|
||||||
|
&data,
|
||||||
|
&config,
|
||||||
|
8,
|
||||||
|
OutputLimit {
|
||||||
|
max_output_bytes: 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("target 8..12 is outside an 8-byte container");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("outside"),
|
||||||
|
"unexpected error: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-byte writer that ends exactly at the container length is in range.
|
||||||
|
/// It is uncompressed, so success does not depend on a Huffman payload.
|
||||||
|
#[test]
|
||||||
|
fn writer_ending_at_the_container_length_is_accepted() {
|
||||||
|
let config = empty_config();
|
||||||
|
let data = synthetic_container(&config, 8, Some((7, 1)));
|
||||||
|
let decoded = decode_container_with_limit(
|
||||||
|
&data,
|
||||||
|
&config,
|
||||||
|
8,
|
||||||
|
OutputLimit {
|
||||||
|
max_output_bytes: 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("a writer ending at the container length is in range");
|
||||||
|
assert_eq!(decoded.len(), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_config() -> Module9bConfig {
|
||||||
|
Module9bConfig {
|
||||||
|
header_seed: 0,
|
||||||
|
container_seed: 0,
|
||||||
|
aes_key: [0; 32],
|
||||||
|
skip_aes: true,
|
||||||
|
schedule_offset: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A container with one segment.
|
||||||
|
///
|
||||||
|
/// `writer_output` replaces the segment body with a transformed writer
|
||||||
|
/// record declaring that decoded length. `None` leaves a four-byte body
|
||||||
|
/// that is only there so the header parses.
|
||||||
|
fn synthetic_container(
|
||||||
|
config: &Module9bConfig,
|
||||||
|
output_size: u32,
|
||||||
|
writer_output: Option<(u32, u32)>,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let seed = config.container_seed;
|
||||||
|
let seed_square = seed.wrapping_mul(seed);
|
||||||
|
let state = seed_square.wrapping_shr(17) ^ seed_square.wrapping_shl(11);
|
||||||
|
let raw0 = gf32_unmultiply(
|
||||||
|
output_size
|
||||||
|
.wrapping_sub(0xa21d_fb3a_u32.wrapping_shl(state & 7))
|
||||||
|
.wrapping_sub(state.wrapping_mul(0xf87b_337c)),
|
||||||
|
);
|
||||||
|
// One segment, skip_aes = 1.
|
||||||
|
let flag_word = 1_u32 | (1_u32 << 8);
|
||||||
|
let raw1 = gf32_unmultiply(
|
||||||
|
flag_word
|
||||||
|
^ state
|
||||||
|
.wrapping_add(0xbd19_c63c)
|
||||||
|
.wrapping_add(0x416e_2af2_u32.wrapping_shr(state & 0x0d)),
|
||||||
|
);
|
||||||
|
let mut tree = vec![0_u8; 256 * 3];
|
||||||
|
tree[1] = 0x80;
|
||||||
|
tree[2] = 1;
|
||||||
|
let tree_state = state.wrapping_add(0xf1cb_5b81).wrapping_mul(state);
|
||||||
|
let tree_delta = tree_state.wrapping_sub(0x23b3_2203_u32.wrapping_mul(state));
|
||||||
|
// Stored tree bytes are decrypted a word at a time and then adjusted a
|
||||||
|
// byte at a time, so the byte adjustment has to be undone first.
|
||||||
|
for (index, byte) in tree.iter_mut().enumerate() {
|
||||||
|
let shift = (index & 0x1b) as u32;
|
||||||
|
let left = gf32_mul_fixed(tree_state.wrapping_shl(shift));
|
||||||
|
let right = tree_delta.wrapping_shr((index & 0x17) as u32);
|
||||||
|
let adjustment = left.wrapping_sub(right).wrapping_shr((index & 0x1f) as u32);
|
||||||
|
*byte = byte.wrapping_sub(adjustment as u8);
|
||||||
|
}
|
||||||
|
let raw2 = gf32_unmultiply(
|
||||||
|
(tree.len() as u32)
|
||||||
|
.wrapping_add(state ^ 0x3b2b_f538)
|
||||||
|
.wrapping_sub(0x643a_3a3b_u32.wrapping_shl(state & 0x0b)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut header = Vec::new();
|
||||||
|
header.extend_from_slice(&raw0.to_le_bytes());
|
||||||
|
header.extend_from_slice(&raw1.to_le_bytes());
|
||||||
|
header.extend_from_slice(&raw2.to_le_bytes());
|
||||||
|
let mut stored_tree = tree;
|
||||||
|
for offset in (0..stored_tree.len() & !3).step_by(4) {
|
||||||
|
let value = u32::from_le_bytes(stored_tree[offset..offset + 4].try_into().unwrap());
|
||||||
|
stored_tree[offset..offset + 4].copy_from_slice(&gf32_unmultiply(value).to_le_bytes());
|
||||||
|
}
|
||||||
|
header.extend_from_slice(&stored_tree);
|
||||||
|
|
||||||
|
let body = match writer_output {
|
||||||
|
Some((writer_offset, writer_output)) => {
|
||||||
|
writer_body(config, writer_offset, writer_output)
|
||||||
|
}
|
||||||
|
None => vec![0_u8; 4],
|
||||||
|
};
|
||||||
|
let segment_offset = (header.len() + 8) as u32;
|
||||||
|
let table_state = state.wrapping_add(0xb31f_451c).wrapping_mul(state);
|
||||||
|
let table_xor = table_state.wrapping_shl(3);
|
||||||
|
let table_add = table_state.wrapping_sub(0x822f_e82d_u32.wrapping_mul(state));
|
||||||
|
for (index, plain) in [segment_offset, body.len() as u32].into_iter().enumerate() {
|
||||||
|
let adjustment = table_add.wrapping_shr((((index * 4) & 7) + 5) as u32);
|
||||||
|
// decode is `gf(encoded ^ xor) + adjustment`, so the stored word
|
||||||
|
// is `ungf(plain - adjustment) ^ xor`.
|
||||||
|
let encoded = gf32_unmultiply(plain.wrapping_sub(adjustment)) ^ table_xor;
|
||||||
|
header.extend_from_slice(&encoded.to_le_bytes());
|
||||||
|
}
|
||||||
|
header.extend_from_slice(&body);
|
||||||
|
header
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Segment body that `transform_segment` turns into one writer record.
|
||||||
|
fn writer_body(config: &Module9bConfig, writer_offset: u32, writer_output: u32) -> Vec<u8> {
|
||||||
|
let mut plain = vec![0_u8; 36];
|
||||||
|
plain[..4].copy_from_slice(&0_u32.to_le_bytes());
|
||||||
|
plain[4..8].copy_from_slice(&1_u32.to_le_bytes());
|
||||||
|
plain[8..12].copy_from_slice(&16_u32.to_le_bytes());
|
||||||
|
plain[12..16].copy_from_slice(&32_u32.to_le_bytes());
|
||||||
|
plain[16..20].copy_from_slice(&writer_offset.to_le_bytes());
|
||||||
|
plain[20..24].copy_from_slice(&writer_output.to_le_bytes());
|
||||||
|
plain[24..28].copy_from_slice(&1_u32.to_le_bytes());
|
||||||
|
inverse_transform_segment(&plain, config.container_seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invert [`transform_segment`] for `decrypt_aes = false`.
|
||||||
|
///
|
||||||
|
/// The forward routine adds and xors a state that does not depend on the
|
||||||
|
/// ciphertext, so replaying that state and undoing the arithmetic recovers
|
||||||
|
/// the input that decodes to `plain`.
|
||||||
|
fn inverse_transform_segment(plain: &[u8], seed: u32) -> Vec<u8> {
|
||||||
|
let mut encoded = plain.to_vec();
|
||||||
|
let mut state = seed;
|
||||||
|
let mut left = 0xe34e_ac63_u32;
|
||||||
|
let mut right = 0x07b4_8238_u32;
|
||||||
|
for (index, chunk) in encoded.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||||
|
let index32 = index as u32;
|
||||||
|
left = state
|
||||||
|
.wrapping_add(0x72f6_fcbe)
|
||||||
|
.wrapping_add(left.wrapping_add(0x4f8b_1bca).wrapping_mul(left))
|
||||||
|
.wrapping_shr(index32.wrapping_mul(index32) & 0x0f);
|
||||||
|
right = state
|
||||||
|
.wrapping_sub(0x71b6_a98d)
|
||||||
|
.wrapping_add(right.wrapping_sub(0x1605_a81c).wrapping_mul(right))
|
||||||
|
.wrapping_shl(index32 & 7);
|
||||||
|
state = left ^ right;
|
||||||
|
let mut value = u32::from_le_bytes(*chunk);
|
||||||
|
value ^= state;
|
||||||
|
value = value.wrapping_add(state);
|
||||||
|
value ^= 0xaf57_f7fb_u32.wrapping_mul(index32 & 3);
|
||||||
|
value = value.wrapping_sub(0xb43b_9baf_u32.wrapping_mul(index32 & 0x0d));
|
||||||
|
chunk.copy_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gf32_unmultiply(encoded: u32) -> u32 {
|
||||||
|
// Multiply by the inverse of the fixed element 0x94511dd2.
|
||||||
|
let mut multiplier = GF32_INVERSE;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `gf32_mul_fixed(GF32_INVERSE) == 1`, so this undoes that multiply.
|
||||||
|
const GF32_INVERSE: u32 = 0xdc49_cd06;
|
||||||
}
|
}
|
||||||
+362
-33
@@ -5,6 +5,8 @@ pub const SHT_STRTAB: u32 = 3;
|
|||||||
pub const SHT_LOUSER: u32 = 0x8000_0000;
|
pub const SHT_LOUSER: u32 = 0x8000_0000;
|
||||||
pub const SHF_ALLOC: u64 = 2;
|
pub const SHF_ALLOC: u64 = 2;
|
||||||
const PT_LOAD: u32 = 1;
|
const PT_LOAD: u32 = 1;
|
||||||
|
const PT_NOTE: u32 = 4;
|
||||||
|
const PT_PHDR: u32 = 6;
|
||||||
pub const PF_R: u32 = 4;
|
pub const PF_R: u32 = 4;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -220,7 +222,89 @@ impl ElfLayout {
|
|||||||
Ok(alignment)
|
Ok(alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn append_load_segment(&self, output: &mut [u8], segment: LoadSegment) -> Result<Self> {
|
pub fn additional_program_header_reservation(&self) -> Result<usize> {
|
||||||
|
if self.program_header_size != 0x38 {
|
||||||
|
return invalid("unexpected ELF program header size");
|
||||||
|
}
|
||||||
|
let new_count = self
|
||||||
|
.program_header_count
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?;
|
||||||
|
let header_offset = checked_index(
|
||||||
|
self.program_header_offset,
|
||||||
|
self.program_header_count,
|
||||||
|
self.program_header_size,
|
||||||
|
)?;
|
||||||
|
let header_end = header_offset
|
||||||
|
.checked_add(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?;
|
||||||
|
let first_file_section = self
|
||||||
|
.section_headers
|
||||||
|
.iter()
|
||||||
|
.filter(|section| section.section_type != SHT_NOBITS && section.size != 0)
|
||||||
|
.map(|section| section.offset)
|
||||||
|
.min();
|
||||||
|
if first_file_section.is_none_or(|offset| header_end as u64 <= offset) {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
new_count
|
||||||
|
.checked_mul(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("relocated program header size overflow".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_program_header_type(&self, data: &[u8], program_type: u32) -> Result<bool> {
|
||||||
|
for index in 0..self.program_header_count {
|
||||||
|
let offset =
|
||||||
|
checked_index(self.program_header_offset, index, self.program_header_size)?;
|
||||||
|
if read_u32(data, offset)? == program_type {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reusable_note_program_header(&self, data: &[u8]) -> Result<Option<usize>> {
|
||||||
|
let mut note_count = 0_usize;
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for index in 0..self.program_header_count {
|
||||||
|
let offset =
|
||||||
|
checked_index(self.program_header_offset, index, self.program_header_size)?;
|
||||||
|
if read_u32(data, offset)? != PT_NOTE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
note_count += 1;
|
||||||
|
let file_offset = read_u64(data, offset + 8)?;
|
||||||
|
let file_size = read_u64(data, offset + 0x20)?;
|
||||||
|
let file_end = file_offset
|
||||||
|
.checked_add(file_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_NOTE file range overflow".to_owned()))?;
|
||||||
|
let contained = self.program_headers.iter().any(|segment| {
|
||||||
|
segment
|
||||||
|
.offset
|
||||||
|
.checked_add(segment.file_size)
|
||||||
|
.is_some_and(|segment_end| {
|
||||||
|
segment.offset <= file_offset && file_end <= segment_end
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if contained {
|
||||||
|
candidates.push((file_offset, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if note_count < 2 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(candidates
|
||||||
|
.into_iter()
|
||||||
|
.max_by_key(|(file_offset, _)| *file_offset)
|
||||||
|
.map(|(_, index)| index))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_load_segment(
|
||||||
|
&self,
|
||||||
|
output: &mut [u8],
|
||||||
|
segment: LoadSegment,
|
||||||
|
reserved_program_header_bytes: usize,
|
||||||
|
) -> Result<Self> {
|
||||||
if self.program_header_size != 0x38 {
|
if self.program_header_size != 0x38 {
|
||||||
return invalid("unexpected ELF program header size");
|
return invalid("unexpected ELF program header size");
|
||||||
}
|
}
|
||||||
@@ -274,23 +358,11 @@ impl ElfLayout {
|
|||||||
.ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?;
|
.ok_or_else(|| Error::Invalid("program header count overflow".to_owned()))?;
|
||||||
let new_count_u16 = u16::try_from(new_count)
|
let new_count_u16 = u16::try_from(new_count)
|
||||||
.map_err(|_| Error::Invalid("program header count exceeds u16".to_owned()))?;
|
.map_err(|_| Error::Invalid("program header count exceeds u16".to_owned()))?;
|
||||||
let header_offset = checked_index(
|
let reservation = self.additional_program_header_reservation()?;
|
||||||
self.program_header_offset,
|
if reserved_program_header_bytes != reservation {
|
||||||
self.program_header_count,
|
return invalid(format!(
|
||||||
self.program_header_size,
|
"program-header reservation mismatch: expected 0x{reservation:x}, got 0x{reserved_program_header_bytes:x}"
|
||||||
)?;
|
));
|
||||||
let header_end = header_offset
|
|
||||||
.checked_add(self.program_header_size)
|
|
||||||
.ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?;
|
|
||||||
slice(output, header_offset, self.program_header_size)?;
|
|
||||||
let first_file_section = self
|
|
||||||
.section_headers
|
|
||||||
.iter()
|
|
||||||
.filter(|section| section.section_type != SHT_NOBITS && section.size != 0)
|
|
||||||
.map(|section| section.offset)
|
|
||||||
.min();
|
|
||||||
if first_file_section.is_some_and(|offset| header_end as u64 > offset) {
|
|
||||||
return invalid("no space for an additional program header");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut header = [0_u8; 0x38];
|
let mut header = [0_u8; 0x38];
|
||||||
@@ -302,17 +374,95 @@ impl ElfLayout {
|
|||||||
header[0x20..0x28].copy_from_slice(&segment.file_size.to_le_bytes());
|
header[0x20..0x28].copy_from_slice(&segment.file_size.to_le_bytes());
|
||||||
header[0x28..0x30].copy_from_slice(&segment.memory_size.to_le_bytes());
|
header[0x28..0x30].copy_from_slice(&segment.memory_size.to_le_bytes());
|
||||||
header[0x30..0x38].copy_from_slice(&segment.alignment.to_le_bytes());
|
header[0x30..0x38].copy_from_slice(&segment.alignment.to_le_bytes());
|
||||||
output
|
|
||||||
.get_mut(header_offset..header_end)
|
let old_table_size = self
|
||||||
.ok_or_else(|| Error::Invalid("new program header exceeds output".to_owned()))?
|
.program_header_count
|
||||||
.copy_from_slice(&header);
|
.checked_mul(self.program_header_size)
|
||||||
output
|
.ok_or_else(|| Error::Invalid("program header table size overflow".to_owned()))?;
|
||||||
.get_mut(0x38..0x3a)
|
let new_table_size = new_count
|
||||||
.ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))?
|
.checked_mul(self.program_header_size)
|
||||||
.copy_from_slice(&new_count_u16.to_le_bytes());
|
.ok_or_else(|| Error::Invalid("program header table size overflow".to_owned()))?;
|
||||||
|
let mut updated_program_header_offset = self.program_header_offset;
|
||||||
|
let mut updated_program_header_count = new_count;
|
||||||
|
if reservation == 0 {
|
||||||
|
let header_offset = checked_index(
|
||||||
|
self.program_header_offset,
|
||||||
|
self.program_header_count,
|
||||||
|
self.program_header_size,
|
||||||
|
)?;
|
||||||
|
let header_end = header_offset
|
||||||
|
.checked_add(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("new program header range overflow".to_owned()))?;
|
||||||
|
output
|
||||||
|
.get_mut(header_offset..header_end)
|
||||||
|
.ok_or_else(|| Error::Invalid("new program header exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&header);
|
||||||
|
update_pt_phdr(
|
||||||
|
output,
|
||||||
|
self.program_header_offset,
|
||||||
|
self.program_header_count,
|
||||||
|
self.program_header_size,
|
||||||
|
None,
|
||||||
|
new_table_size as u64,
|
||||||
|
)?;
|
||||||
|
} else if let Some(note_index) = self.reusable_note_program_header(output)? {
|
||||||
|
let header_offset = checked_index(
|
||||||
|
self.program_header_offset,
|
||||||
|
note_index,
|
||||||
|
self.program_header_size,
|
||||||
|
)?;
|
||||||
|
let header_end = header_offset
|
||||||
|
.checked_add(self.program_header_size)
|
||||||
|
.ok_or_else(|| Error::Invalid("reused program header range overflow".to_owned()))?;
|
||||||
|
output
|
||||||
|
.get_mut(header_offset..header_end)
|
||||||
|
.ok_or_else(|| Error::Invalid("reused program header exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&header);
|
||||||
|
updated_program_header_count = self.program_header_count;
|
||||||
|
} else {
|
||||||
|
if !self.has_program_header_type(output, PT_PHDR)? {
|
||||||
|
return invalid(
|
||||||
|
"cannot safely relocate program headers without PT_PHDR or a redundant PT_NOTE",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if reservation != new_table_size {
|
||||||
|
return invalid("relocated program-header reservation has unexpected size");
|
||||||
|
}
|
||||||
|
if segment.file_size < reservation as u64 {
|
||||||
|
return invalid("new PT_LOAD is too small for relocated program headers");
|
||||||
|
}
|
||||||
|
let relocated = usize_from_u64(segment.offset, "relocated program header offset")?;
|
||||||
|
let relocated_end = relocated.checked_add(new_table_size).ok_or_else(|| {
|
||||||
|
Error::Invalid("relocated program header range overflow".to_owned())
|
||||||
|
})?;
|
||||||
|
slice(output, relocated, new_table_size)?;
|
||||||
|
let old_table = slice(output, self.program_header_offset, old_table_size)?.to_vec();
|
||||||
|
output[relocated..relocated + old_table_size].copy_from_slice(&old_table);
|
||||||
|
output[relocated + old_table_size..relocated_end].copy_from_slice(&header);
|
||||||
|
output
|
||||||
|
.get_mut(0x20..0x28)
|
||||||
|
.ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))?
|
||||||
|
.copy_from_slice(&segment.offset.to_le_bytes());
|
||||||
|
update_pt_phdr(
|
||||||
|
output,
|
||||||
|
relocated,
|
||||||
|
self.program_header_count,
|
||||||
|
self.program_header_size,
|
||||||
|
Some((segment.offset, segment.virtual_address)),
|
||||||
|
new_table_size as u64,
|
||||||
|
)?;
|
||||||
|
updated_program_header_offset = relocated;
|
||||||
|
}
|
||||||
|
if updated_program_header_count == new_count {
|
||||||
|
output
|
||||||
|
.get_mut(0x38..0x3a)
|
||||||
|
.ok_or_else(|| Error::Invalid("ELF header is truncated".to_owned()))?
|
||||||
|
.copy_from_slice(&new_count_u16.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
let mut updated = self.clone();
|
let mut updated = self.clone();
|
||||||
updated.program_header_count = new_count;
|
updated.program_header_offset = updated_program_header_offset;
|
||||||
|
updated.program_header_count = updated_program_header_count;
|
||||||
updated.program_headers.push(segment);
|
updated.program_headers.push(segment);
|
||||||
Ok(updated)
|
Ok(updated)
|
||||||
}
|
}
|
||||||
@@ -394,6 +544,47 @@ impl ElfLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_pt_phdr(
|
||||||
|
output: &mut [u8],
|
||||||
|
table_offset: usize,
|
||||||
|
old_count: usize,
|
||||||
|
entry_size: usize,
|
||||||
|
relocated: Option<(u64, u64)>,
|
||||||
|
table_size: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
for index in 0..old_count {
|
||||||
|
let offset = checked_index(table_offset, index, entry_size)?;
|
||||||
|
if read_u32(output, offset)? != PT_PHDR {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((file_offset, virtual_address)) = relocated {
|
||||||
|
output
|
||||||
|
.get_mut(offset + 8..offset + 0x10)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_PHDR file offset exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&file_offset.to_le_bytes());
|
||||||
|
output
|
||||||
|
.get_mut(offset + 0x10..offset + 0x18)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_PHDR virtual address exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&virtual_address.to_le_bytes());
|
||||||
|
output
|
||||||
|
.get_mut(offset + 0x18..offset + 0x20)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::Invalid("PT_PHDR physical address exceeds output".to_owned())
|
||||||
|
})?
|
||||||
|
.copy_from_slice(&virtual_address.to_le_bytes());
|
||||||
|
}
|
||||||
|
output
|
||||||
|
.get_mut(offset + 0x20..offset + 0x28)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_PHDR file size exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&table_size.to_le_bytes());
|
||||||
|
output
|
||||||
|
.get_mut(offset + 0x28..offset + 0x30)
|
||||||
|
.ok_or_else(|| Error::Invalid("PT_PHDR memory size exceeds output".to_owned()))?
|
||||||
|
.copy_from_slice(&table_size.to_le_bytes());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn slice(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> {
|
pub fn slice(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> {
|
||||||
let end = offset
|
let end = offset
|
||||||
.checked_add(size)
|
.checked_add(size)
|
||||||
@@ -585,6 +776,7 @@ mod tests {
|
|||||||
flags: PF_R,
|
flags: PF_R,
|
||||||
alignment: 0x1000,
|
alignment: 0x1000,
|
||||||
},
|
},
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.expect("append segment");
|
.expect("append segment");
|
||||||
assert_eq!(updated.program_header_count, 1);
|
assert_eq!(updated.program_header_count, 1);
|
||||||
@@ -594,7 +786,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn append_load_segment_rejects_program_header_overlap() {
|
fn append_load_segment_fails_closed_when_no_safe_phdr_slot() {
|
||||||
let mut elf_layout = ElfLayout {
|
let mut elf_layout = ElfLayout {
|
||||||
entrypoint: 0,
|
entrypoint: 0,
|
||||||
program_header_offset: 0,
|
program_header_offset: 0,
|
||||||
@@ -617,20 +809,157 @@ mod tests {
|
|||||||
alignment: 1,
|
alignment: 1,
|
||||||
entry_size: 0,
|
entry_size: 0,
|
||||||
});
|
});
|
||||||
let mut output = vec![0_u8; 0x100];
|
let reservation = elf_layout
|
||||||
|
.additional_program_header_reservation()
|
||||||
|
.expect("reservation");
|
||||||
|
assert_eq!(reservation, 0x38);
|
||||||
|
let mut output = vec![0_u8; 0x200];
|
||||||
let error = elf_layout
|
let error = elf_layout
|
||||||
.append_load_segment(
|
.append_load_segment(
|
||||||
&mut output,
|
&mut output,
|
||||||
LoadSegment {
|
LoadSegment {
|
||||||
offset: 0x80,
|
offset: 0x80,
|
||||||
virtual_address: 0x1080,
|
virtual_address: 0x1080,
|
||||||
file_size: 0x20,
|
file_size: 0x80,
|
||||||
memory_size: 0x20,
|
memory_size: 0x80,
|
||||||
flags: PF_R,
|
flags: PF_R,
|
||||||
alignment: 0x1000,
|
alignment: 0x1000,
|
||||||
},
|
},
|
||||||
|
reservation,
|
||||||
)
|
)
|
||||||
.expect_err("overlapping program header");
|
.expect_err("unsafe relocation must fail");
|
||||||
assert!(error.to_string().contains("additional program header"));
|
assert!(error.to_string().contains("cannot safely relocate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_load_segment_reuses_redundant_note_when_no_slack() {
|
||||||
|
let mut elf_layout = ElfLayout {
|
||||||
|
entrypoint: 0,
|
||||||
|
program_header_offset: 0x40,
|
||||||
|
program_header_size: 0x38,
|
||||||
|
program_header_count: 3,
|
||||||
|
program_headers: vec![LoadSegment {
|
||||||
|
offset: 0,
|
||||||
|
virtual_address: 0,
|
||||||
|
file_size: 0x400,
|
||||||
|
memory_size: 0x400,
|
||||||
|
flags: PF_R,
|
||||||
|
alignment: 0x1000,
|
||||||
|
}],
|
||||||
|
section_headers: Vec::new(),
|
||||||
|
section_name_index: 0,
|
||||||
|
private_section_index: usize::MAX,
|
||||||
|
};
|
||||||
|
elf_layout.section_headers.push(SectionHeader {
|
||||||
|
name: 0,
|
||||||
|
section_type: 1,
|
||||||
|
flags: 0,
|
||||||
|
address: 0,
|
||||||
|
offset: 0xe8,
|
||||||
|
size: 1,
|
||||||
|
link: 0,
|
||||||
|
info: 0,
|
||||||
|
alignment: 1,
|
||||||
|
entry_size: 0,
|
||||||
|
});
|
||||||
|
let mut output = vec![0_u8; 0x2000];
|
||||||
|
output[0x38..0x3a].copy_from_slice(&3_u16.to_le_bytes());
|
||||||
|
output[0x40..0x44].copy_from_slice(&PT_LOAD.to_le_bytes());
|
||||||
|
output[0x78..0x7c].copy_from_slice(&PT_NOTE.to_le_bytes());
|
||||||
|
output[0x80..0x88].copy_from_slice(&0x100_u64.to_le_bytes());
|
||||||
|
output[0x98..0xa0].copy_from_slice(&0x20_u64.to_le_bytes());
|
||||||
|
output[0xb0..0xb4].copy_from_slice(&PT_NOTE.to_le_bytes());
|
||||||
|
output[0xb8..0xc0].copy_from_slice(&0x200_u64.to_le_bytes());
|
||||||
|
output[0xd0..0xd8].copy_from_slice(&0x20_u64.to_le_bytes());
|
||||||
|
|
||||||
|
let reservation = elf_layout
|
||||||
|
.additional_program_header_reservation()
|
||||||
|
.expect("reservation");
|
||||||
|
assert_eq!(reservation, 0xe0);
|
||||||
|
let updated = elf_layout
|
||||||
|
.append_load_segment(
|
||||||
|
&mut output,
|
||||||
|
LoadSegment {
|
||||||
|
offset: 0x1000,
|
||||||
|
virtual_address: 0x2000,
|
||||||
|
file_size: 0x100,
|
||||||
|
memory_size: 0x100,
|
||||||
|
flags: PF_R,
|
||||||
|
alignment: 0x1000,
|
||||||
|
},
|
||||||
|
reservation,
|
||||||
|
)
|
||||||
|
.expect("reuse redundant PT_NOTE");
|
||||||
|
|
||||||
|
assert_eq!(updated.program_header_offset, 0x40);
|
||||||
|
assert_eq!(updated.program_header_count, 3);
|
||||||
|
assert_eq!(updated.program_headers.len(), 2);
|
||||||
|
assert_eq!(&output[0x38..0x3a], &3_u16.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x78..0x7c], &PT_NOTE.to_le_bytes());
|
||||||
|
assert_eq!(&output[0xb0..0xb4], &PT_LOAD.to_le_bytes());
|
||||||
|
assert_eq!(&output[0xb8..0xc0], &0x1000_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0xc0..0xc8], &0x2000_u64.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relocated_program_headers_update_pt_phdr() {
|
||||||
|
let mut elf_layout = ElfLayout {
|
||||||
|
entrypoint: 0,
|
||||||
|
program_header_offset: 0x40,
|
||||||
|
program_header_size: 0x38,
|
||||||
|
program_header_count: 1,
|
||||||
|
program_headers: Vec::new(),
|
||||||
|
section_headers: Vec::new(),
|
||||||
|
section_name_index: 0,
|
||||||
|
private_section_index: usize::MAX,
|
||||||
|
};
|
||||||
|
elf_layout.section_headers.push(SectionHeader {
|
||||||
|
name: 0,
|
||||||
|
section_type: 1,
|
||||||
|
flags: 0,
|
||||||
|
address: 0,
|
||||||
|
offset: 0x78,
|
||||||
|
size: 1,
|
||||||
|
link: 0,
|
||||||
|
info: 0,
|
||||||
|
alignment: 1,
|
||||||
|
entry_size: 0,
|
||||||
|
});
|
||||||
|
let mut output = vec![0_u8; 0x2000];
|
||||||
|
output[0x20..0x28].copy_from_slice(&0x40_u64.to_le_bytes());
|
||||||
|
output[0x40..0x44].copy_from_slice(&PT_PHDR.to_le_bytes());
|
||||||
|
output[0x48..0x50].copy_from_slice(&0x40_u64.to_le_bytes());
|
||||||
|
output[0x50..0x58].copy_from_slice(&0x40_u64.to_le_bytes());
|
||||||
|
output[0x58..0x60].copy_from_slice(&0x40_u64.to_le_bytes());
|
||||||
|
output[0x60..0x68].copy_from_slice(&0x38_u64.to_le_bytes());
|
||||||
|
output[0x68..0x70].copy_from_slice(&0x38_u64.to_le_bytes());
|
||||||
|
|
||||||
|
let reservation = elf_layout
|
||||||
|
.additional_program_header_reservation()
|
||||||
|
.expect("reservation");
|
||||||
|
assert_eq!(reservation, 0x70);
|
||||||
|
let updated = elf_layout
|
||||||
|
.append_load_segment(
|
||||||
|
&mut output,
|
||||||
|
LoadSegment {
|
||||||
|
offset: 0x1000,
|
||||||
|
virtual_address: 0x2000,
|
||||||
|
file_size: 0x100,
|
||||||
|
memory_size: 0x100,
|
||||||
|
flags: PF_R,
|
||||||
|
alignment: 0x1000,
|
||||||
|
},
|
||||||
|
reservation,
|
||||||
|
)
|
||||||
|
.expect("relocate program headers");
|
||||||
|
|
||||||
|
assert_eq!(updated.program_header_offset, 0x1000);
|
||||||
|
assert_eq!(&output[0x20..0x28], &0x1000_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1000..0x1004], &PT_PHDR.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1008..0x1010], &0x1000_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1010..0x1018], &0x2000_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1020..0x1028], &0x70_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1028..0x1030], &0x70_u64.to_le_bytes());
|
||||||
|
assert_eq!(&output[0x1038..0x103c], &PT_LOAD.to_le_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-10
@@ -15,26 +15,23 @@ pub use layout::{
|
|||||||
/// ELF machine identifier for AArch64.
|
/// ELF machine identifier for AArch64.
|
||||||
pub const AARCH64_MACHINE: u16 = EM_AARCH64;
|
pub const AARCH64_MACHINE: u16 = EM_AARCH64;
|
||||||
|
|
||||||
/// Dynamic sections required by the restored AArch64 loader image.
|
/// Dynamic sections required by every restored AArch64 loader image.
|
||||||
pub const DYNAMIC_SECTION_NAMES: [&str; 8] = [
|
pub const DYNAMIC_SECTION_NAMES: [&str; 7] = [
|
||||||
".dynsym",
|
".dynsym",
|
||||||
".gnu.version",
|
".gnu.version",
|
||||||
".gnu.version_r",
|
".gnu.version_r",
|
||||||
".gnu.hash",
|
|
||||||
".dynstr",
|
".dynstr",
|
||||||
".rela.dyn",
|
".rela.dyn",
|
||||||
".rela.plt",
|
".rela.plt",
|
||||||
".dynamic",
|
".dynamic",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Supported dynamic symbol-hash sections. At least one must be present.
|
||||||
|
pub const SYMBOL_HASH_SECTION_NAMES: [&str; 2] = [".gnu.hash", ".hash"];
|
||||||
|
|
||||||
/// Dynamic sections needed to identify a protected image before extraction.
|
/// Dynamic sections needed to identify a protected image before extraction.
|
||||||
pub const PROBE_SECTION_NAMES: [&str; 5] = [
|
/// Hash tables are checked separately because either GNU or SysV hashing is valid.
|
||||||
".dynsym",
|
pub const PROBE_SECTION_NAMES: [&str; 4] = [".dynsym", ".dynstr", ".gnu.version", ".gnu.version_r"];
|
||||||
".dynstr",
|
|
||||||
".gnu.hash",
|
|
||||||
".gnu.version",
|
|
||||||
".gnu.version_r",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// ELF64 dynamic table record sizes.
|
/// ELF64 dynamic table record sizes.
|
||||||
pub const ELF64_SYMBOL_SIZE: usize = 0x18;
|
pub const ELF64_SYMBOL_SIZE: usize = 0x18;
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ pub enum Error {
|
|||||||
},
|
},
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Invalid(String),
|
Invalid(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Limit(#[from] crate::android::LimitExceeded),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error {
|
impl Error {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ mod stage1;
|
|||||||
mod stream;
|
mod stream;
|
||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use pipeline::{ExtractOptions, extract_stage2};
|
pub use pipeline::{ExtractOptions, extract_stage2, extract_stage2_with_limits};
|
||||||
pub use probe::is_protected_libil2cpp;
|
pub use probe::{is_protected_libil2cpp, probe_libil2cpp_with_limits};
|
||||||
pub use report::ExtractionReport;
|
pub use report::ExtractionReport;
|
||||||
pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
||||||
@@ -3,7 +3,9 @@ use std::fs::File;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use memmap2::MmapOptions;
|
use memmap2::MmapOptions;
|
||||||
use senbei_crypto::android::{Module9bConfig, decode_container};
|
use senbei_crypto::android::{Module9bConfig, decode_container_with_limit};
|
||||||
|
|
||||||
|
use crate::android::{ByteBudget, EntryBudget, ResourceLimits};
|
||||||
use serde_json::to_vec_pretty;
|
use serde_json::to_vec_pretty;
|
||||||
|
|
||||||
use super::super::common;
|
use super::super::common;
|
||||||
@@ -61,6 +63,13 @@ struct ArtifactSpec<'a> {
|
|||||||
classification: &'a str,
|
classification: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DecodeBudget {
|
||||||
|
limits: ResourceLimits,
|
||||||
|
bytes: ByteBudget,
|
||||||
|
records: EntryBudget,
|
||||||
|
config: Module9bConfig,
|
||||||
|
}
|
||||||
|
|
||||||
struct Extractor {
|
struct Extractor {
|
||||||
output_dir: PathBuf,
|
output_dir: PathBuf,
|
||||||
streams: Vec<StreamReport>,
|
streams: Vec<StreamReport>,
|
||||||
@@ -70,6 +79,20 @@ struct Extractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
|
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 input_path = absolute(&options.input)?;
|
||||||
let output_dir = absolute(&options.output_dir)?;
|
let output_dir = absolute(&options.output_dir)?;
|
||||||
if !input_path.is_file() {
|
if !input_path.is_file() {
|
||||||
@@ -98,6 +121,10 @@ pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
|
|||||||
&input_path,
|
&input_path,
|
||||||
options.outer_size,
|
options.outer_size,
|
||||||
options.cipher_constant,
|
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 {
|
if let Some(stage2_output) = &options.stage2_output {
|
||||||
write_atomic(&absolute(stage2_output)?, &stage1.plaintext)?;
|
write_atomic(&absolute(stage2_output)?, &stage1.plaintext)?;
|
||||||
@@ -119,13 +146,22 @@ pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
|
|||||||
registry: BTreeMap::new(),
|
registry: BTreeMap::new(),
|
||||||
seen_streams: HashSet::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(
|
extractor.extract_stream(
|
||||||
bootstrap,
|
bootstrap,
|
||||||
0xe2,
|
0xe2,
|
||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
Some(stage1.remaining_file_offset),
|
Some(stage1.remaining_file_offset),
|
||||||
core_config,
|
&mut budget,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let module_registry = extractor
|
let module_registry = extractor
|
||||||
@@ -170,8 +206,9 @@ impl Extractor {
|
|||||||
depth: usize,
|
depth: usize,
|
||||||
parent: Option<StreamParent>,
|
parent: Option<StreamParent>,
|
||||||
source_file_offset: Option<usize>,
|
source_file_offset: Option<usize>,
|
||||||
config: Module9bConfig,
|
budget: &mut DecodeBudget,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
budget.limits.check_depth(depth)?;
|
||||||
let digest = sha256(stream);
|
let digest = sha256(stream);
|
||||||
if !self.seen_streams.insert((stream_id, digest.clone())) {
|
if !self.seen_streams.insert((stream_id, digest.clone())) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -200,10 +237,15 @@ impl Extractor {
|
|||||||
"decoded_interpreter"
|
"decoded_interpreter"
|
||||||
},
|
},
|
||||||
(depth != 0).then_some(stream_id),
|
(depth != 0).then_some(stream_id),
|
||||||
&config,
|
&budget.config,
|
||||||
),
|
),
|
||||||
records: Vec::with_capacity(records.len()),
|
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 direct_records = Vec::new();
|
||||||
let mut modules_at_level = BTreeSet::new();
|
let mut modules_at_level = BTreeSet::new();
|
||||||
|
|
||||||
@@ -213,16 +255,24 @@ impl Extractor {
|
|||||||
let mut metadata_data = None;
|
let mut metadata_data = None;
|
||||||
|
|
||||||
if !record.direct() && record.image_size != 0 {
|
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_source = record_tail(stream, record.image_offset)?;
|
||||||
let image = decode_container(image_source, &config, record.image_size as usize)
|
let image = decode_container_with_limit(
|
||||||
.map_err(|source| Error::RecordDecode {
|
image_source,
|
||||||
depth,
|
&budget.config,
|
||||||
stream_id,
|
record.image_size as usize,
|
||||||
record_index: record.index,
|
budget.limits.output_limit(),
|
||||||
command_id: record.command_id,
|
)
|
||||||
part: "image decode",
|
.map_err(|source| Error::RecordDecode {
|
||||||
source,
|
depth,
|
||||||
})?;
|
stream_id,
|
||||||
|
record_index: record.index,
|
||||||
|
command_id: record.command_id,
|
||||||
|
part: "image decode",
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
let classification = if record.metadata_size != 0 {
|
let classification = if record.metadata_size != 0 {
|
||||||
"module_image"
|
"module_image"
|
||||||
} else {
|
} else {
|
||||||
@@ -243,17 +293,22 @@ impl Extractor {
|
|||||||
image_data = Some((image, artifact));
|
image_data = Some((image, artifact));
|
||||||
}
|
}
|
||||||
if record.metadata_size != 0 {
|
if record.metadata_size != 0 {
|
||||||
|
budget.bytes.charge(u64::from(record.metadata_size))?;
|
||||||
let metadata_source = record_tail(stream, record.metadata_offset)?;
|
let metadata_source = record_tail(stream, record.metadata_offset)?;
|
||||||
let metadata =
|
let metadata = decode_container_with_limit(
|
||||||
decode_container(metadata_source, &config, record.metadata_size as usize)
|
metadata_source,
|
||||||
.map_err(|source| Error::RecordDecode {
|
&budget.config,
|
||||||
depth,
|
record.metadata_size as usize,
|
||||||
stream_id,
|
budget.limits.output_limit(),
|
||||||
record_index: record.index,
|
)
|
||||||
command_id: record.command_id,
|
.map_err(|source| Error::RecordDecode {
|
||||||
part: "metadata decode",
|
depth,
|
||||||
source,
|
stream_id,
|
||||||
})?;
|
record_index: record.index,
|
||||||
|
command_id: record.command_id,
|
||||||
|
part: "metadata decode",
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
let artifact = self.write_artifact(
|
let artifact = self.write_artifact(
|
||||||
&record,
|
&record,
|
||||||
depth,
|
depth,
|
||||||
@@ -339,14 +394,17 @@ impl Extractor {
|
|||||||
source,
|
source,
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
budget.limits.check_depth(depth + 1)?;
|
||||||
|
let parent_config = std::mem::replace(&mut budget.config, interpreter_config);
|
||||||
self.extract_stream(
|
self.extract_stream(
|
||||||
child_data,
|
child_data,
|
||||||
next_stream_id,
|
next_stream_id,
|
||||||
depth + 1,
|
depth + 1,
|
||||||
Some(parent),
|
Some(parent),
|
||||||
None,
|
None,
|
||||||
interpreter_config,
|
budget,
|
||||||
)?;
|
)?;
|
||||||
|
budget.config = parent_config;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -506,3 +564,197 @@ fn absolute(path: &Path) -> Result<PathBuf> {
|
|||||||
fn sha256(data: &[u8]) -> String {
|
fn sha256(data: &[u8]) -> String {
|
||||||
common::sha256(data)
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,18 +2,132 @@ use std::path::Path;
|
|||||||
|
|
||||||
use senbei_crypto::android::Module9bConfig;
|
use senbei_crypto::android::Module9bConfig;
|
||||||
|
|
||||||
|
use super::error::Error;
|
||||||
use super::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
use super::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
||||||
|
use crate::android::ResourceLimits;
|
||||||
|
|
||||||
/// Return whether `data` has a supported protected AArch64 IL2CPP layout.
|
/// 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]
|
#[must_use]
|
||||||
pub fn is_protected_libil2cpp(data: &[u8]) -> bool {
|
pub fn is_protected_libil2cpp(data: &[u8]) -> bool {
|
||||||
let Ok(stage1) = stage1::inspect(
|
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,
|
data,
|
||||||
Path::new("<probe>"),
|
Path::new("<probe>"),
|
||||||
DEFAULT_OUTER_SIZE,
|
DEFAULT_OUTER_SIZE,
|
||||||
DEFAULT_CIPHER_CONSTANT,
|
DEFAULT_CIPHER_CONSTANT,
|
||||||
) else {
|
Some(limits.max_entry_bytes.min(limits.max_total_bytes)),
|
||||||
return false;
|
) {
|
||||||
|
Ok(stage1) => stage1,
|
||||||
|
Err(Error::Limit(limit)) => return Err(Error::Limit(limit)),
|
||||||
|
Err(_) => return Ok(false),
|
||||||
};
|
};
|
||||||
Module9bConfig::parse_embedded(&stage1.plaintext).is_ok()
|
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(&[]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,7 @@ pub(crate) fn inspect(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
outer_size: usize,
|
outer_size: usize,
|
||||||
cipher_constant: u32,
|
cipher_constant: u32,
|
||||||
|
max_payload_bytes: Option<u64>,
|
||||||
) -> Result<Stage1Result> {
|
) -> Result<Stage1Result> {
|
||||||
let elf = parse(data).map_err(|source: ElfError| Error::Elf {
|
let elf = parse(data).map_err(|source: ElfError| Error::Elf {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
@@ -70,6 +71,16 @@ pub(crate) fn inspect(
|
|||||||
return invalid(format!("protected ELF lacks required section {wanted}"));
|
return invalid(format!("protected ELF lacks required section {wanted}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let has_symbol_hash = senbei_elf::SYMBOL_HASH_SECTION_NAMES.iter().any(|wanted| {
|
||||||
|
elf.section_headers.iter().any(|section| {
|
||||||
|
elf.shdr_strtab
|
||||||
|
.get_at(section.sh_name)
|
||||||
|
.is_some_and(|name| name == *wanted)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if !has_symbol_hash {
|
||||||
|
return invalid("protected ELF lacks a .gnu.hash or .hash symbol-hash section");
|
||||||
|
}
|
||||||
let (section_index, section) = matches[0];
|
let (section_index, section) = matches[0];
|
||||||
let section_offset = usize::try_from(section.sh_offset)
|
let section_offset = usize::try_from(section.sh_offset)
|
||||||
.map_err(|_| Error::Invalid("SHT_LOUSER offset exceeds usize".to_owned()))?;
|
.map_err(|_| Error::Invalid("SHT_LOUSER offset exceeds usize".to_owned()))?;
|
||||||
@@ -123,6 +134,15 @@ pub(crate) fn inspect(
|
|||||||
"Stage 1 payload size must be nonzero and word aligned: 0x{payload_size:x}"
|
"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)
|
let entry_offset = usize::try_from(header.entry_offset)
|
||||||
.map_err(|_| Error::Invalid("Stage 1 entry offset exceeds usize".to_owned()))?;
|
.map_err(|_| Error::Invalid("Stage 1 entry offset exceeds usize".to_owned()))?;
|
||||||
if entry_offset >= payload_size {
|
if entry_offset >= payload_size {
|
||||||
@@ -180,7 +200,11 @@ fn decrypt_words(ciphertext: &[u8], key: u32, constant: u32) -> Result<Vec<u8>>
|
|||||||
if !ciphertext.len().is_multiple_of(4) {
|
if !ciphertext.len().is_multiple_of(4) {
|
||||||
return invalid("Stage 1 word cipher input is not 4-byte aligned");
|
return invalid("Stage 1 word cipher input is not 4-byte aligned");
|
||||||
}
|
}
|
||||||
let mut plaintext = ciphertext.to_vec();
|
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() {
|
for (index, chunk) in plaintext.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||||
let index = u32::try_from(index)
|
let index = u32::try_from(index)
|
||||||
.map_err(|_| Error::Invalid("Stage 1 word index exceeds u32".to_owned()))?;
|
.map_err(|_| Error::Invalid("Stage 1 word index exceeds u32".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
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
mod extract;
|
mod extract;
|
||||||
|
mod limits;
|
||||||
mod restore;
|
mod restore;
|
||||||
|
|
||||||
pub use extract::{
|
pub use extract::{
|
||||||
DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, Error as ExtractionError, ExtractOptions,
|
DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE, Error as ExtractionError, ExtractOptions,
|
||||||
ExtractionReport, extract_stage2, is_protected_libil2cpp,
|
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,
|
||||||
};
|
};
|
||||||
pub use restore::{Error as RestoreError, RestoreOptions, RestoreReport, restore_libil2cpp};
|
|
||||||
@@ -6,11 +6,17 @@ use serde_json::Value;
|
|||||||
use super::error::{Error, Result, invalid};
|
use super::error::{Error, Result, invalid};
|
||||||
|
|
||||||
const REQUIRED_IDS: [u32; 3] = [0x9b, 0x9d, 0x9e];
|
const REQUIRED_IDS: [u32; 3] = [0x9b, 0x9d, 0x9e];
|
||||||
|
const OPTIONAL_IDS: [u32; 2] = [0x96, 0x98];
|
||||||
|
|
||||||
|
fn wanted_id(command_id: u32) -> bool {
|
||||||
|
REQUIRED_IDS.contains(&command_id) || OPTIONAL_IDS.contains(&command_id)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct Artifact {
|
pub(crate) struct Artifact {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
|
pub entry_offset: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact>> {
|
pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact>> {
|
||||||
@@ -27,7 +33,7 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
|
|||||||
};
|
};
|
||||||
let command_id = u32::try_from(command_id)
|
let command_id = u32::try_from(command_id)
|
||||||
.map_err(|_| Error::Invalid("module command ID exceeds u32".to_owned()))?;
|
.map_err(|_| Error::Invalid("module command ID exceeds u32".to_owned()))?;
|
||||||
if !REQUIRED_IDS.contains(&command_id) {
|
if !wanted_id(command_id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(path) = item.get("image_path").and_then(Value::as_str) else {
|
let Some(path) = item.get("image_path").and_then(Value::as_str) else {
|
||||||
@@ -42,6 +48,10 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
|
|||||||
Artifact {
|
Artifact {
|
||||||
path: root.join(path),
|
path: root.join(path),
|
||||||
size,
|
size,
|
||||||
|
entry_offset: item
|
||||||
|
.get("entry_offset")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.and_then(|value| u32::try_from(value).ok()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,7 +67,7 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
|
|||||||
};
|
};
|
||||||
let command_id = u32::try_from(command_id)
|
let command_id = u32::try_from(command_id)
|
||||||
.map_err(|_| Error::Invalid("record command ID exceeds u32".to_owned()))?;
|
.map_err(|_| Error::Invalid("record command ID exceeds u32".to_owned()))?;
|
||||||
if !REQUIRED_IDS.contains(&command_id) {
|
if !wanted_id(command_id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(image) = record.get("image") else {
|
let Some(image) = record.get("image") else {
|
||||||
@@ -74,6 +84,10 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
|
|||||||
Artifact {
|
Artifact {
|
||||||
path: root.join(path),
|
path: root.join(path),
|
||||||
size,
|
size,
|
||||||
|
entry_offset: record
|
||||||
|
.get("entry_offset")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.and_then(|value| u32::try_from(value).ok()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ pub enum Error {
|
|||||||
Elf(#[from] senbei_elf::Error),
|
Elf(#[from] senbei_elf::Error),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Invalid(String),
|
Invalid(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Limit(#[from] crate::android::LimitExceeded),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error {
|
impl Error {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
mod artifact;
|
mod artifact;
|
||||||
|
mod constructors;
|
||||||
mod error;
|
mod error;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
pub use pipeline::{RestoreOptions, RestoreReport, restore_libil2cpp};
|
pub use pipeline::{
|
||||||
|
RestoreOptions, RestoreReport, restore_libil2cpp, restore_libil2cpp_with_limits,
|
||||||
|
};
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,11 @@
|
|||||||
//! Platform-specific unpacking engines.
|
//! Platform-specific unpacking engines.
|
||||||
|
|
||||||
pub mod android;
|
pub mod android;
|
||||||
|
mod panic_capture;
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|
||||||
|
pub use panic_capture::catch_unwind_quiet;
|
||||||
|
|
||||||
pub use windows::{
|
pub use windows::{
|
||||||
Detected, IntegrityReport, Kind, UnpackError, check_integrity, detect, unpack_auto,
|
Detected, IntegrityReport, Kind, UnpackError, check_integrity, detect, unpack_auto,
|
||||||
unpack_auto_v, unpack_dll, unpack_dll_v, unpack_exe, unpack_exe_v,
|
unpack_auto_v, unpack_dll, unpack_dll_v, unpack_exe, unpack_exe_v,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|
||||||
@@ -214,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})"
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -104,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);
|
||||||
@@ -351,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
|
||||||
@@ -1992,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::*;
|
||||||
|
|||||||
@@ -23,10 +23,153 @@ fn stage_key_rounds(data: &[u8], table: u32, slots: usize) -> Result<u32, Unpack
|
|||||||
}
|
}
|
||||||
|
|
||||||
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];
|
||||||
|
|
||||||
@@ -43,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}",
|
||||||
@@ -139,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);
|
||||||
@@ -238,7 +307,7 @@ 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);
|
||||||
@@ -298,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) {
|
||||||
@@ -640,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);
|
||||||
@@ -823,7 +873,15 @@ impl<'a> Unpacker<'a> {
|
|||||||
let d = export_va as usize;
|
let d = export_va as usize;
|
||||||
let s = export_file_off as usize;
|
let s = export_file_off as usize;
|
||||||
let n = export_size as usize;
|
let n = export_size as usize;
|
||||||
self.decompressed[d..d + n].copy_from_slice(&self.file_data[s..s + n]);
|
// Spliced companions may omit the stub's plaintext exports;
|
||||||
|
// the I/O adapter overlays those from the original stub later.
|
||||||
|
if s.checked_add(n)
|
||||||
|
.is_some_and(|end| end <= self.file_data.len())
|
||||||
|
&& d.checked_add(n)
|
||||||
|
.is_some_and(|end| end <= self.decompressed.len())
|
||||||
|
{
|
||||||
|
self.decompressed[d..d + n].copy_from_slice(&self.file_data[s..s + n]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- .text decrypt with decrypt_data8 (PE32 auto-detected formula) ----
|
// ---- .text decrypt with decrypt_data8 (PE32 auto-detected formula) ----
|
||||||
@@ -976,6 +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)...");
|
||||||
@@ -1097,6 +1207,56 @@ impl<'a> Unpacker<'a> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn stage_key_rounds_follow_active_descriptor_prefix() {
|
fn stage_key_rounds_follow_active_descriptor_prefix() {
|
||||||
for (sizes, expected) in [
|
for (sizes, expected) in [
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -92,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(
|
||||||
"entry point {name} target 0x{target:X} is outside executable sections (DD8 selection is likely wrong)"
|
IntegritySeverity::Fatal,
|
||||||
));
|
format!(
|
||||||
|
"entry point {name} target 0x{target:X} is outside executable sections (DD8 selection is likely wrong)"
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,26 +160,31 @@ 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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -134,7 +192,7 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -142,36 +200,44 @@ 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 headers = match senbei_pe::parse(out) {
|
let headers = match senbei_pe::parse(out) {
|
||||||
Ok(headers) => headers,
|
Ok(headers) => headers,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
r.issues
|
r.push(
|
||||||
.push("section table extends past end of file".into());
|
IntegritySeverity::Fatal,
|
||||||
|
"section table extends past end of file",
|
||||||
|
);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let parsed_sections = match senbei_pe::sections(out, headers) {
|
let parsed_sections = match senbei_pe::sections(out, headers) {
|
||||||
Ok(sections) => sections,
|
Ok(sections) => sections,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
r.issues
|
r.push(
|
||||||
.push("section table extends past end of file".into());
|
IntegritySeverity::Fatal,
|
||||||
|
"section table extends past end of file",
|
||||||
|
);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -182,10 +248,13 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
if section.raw_size != 0 {
|
if section.raw_size != 0 {
|
||||||
let end = section.raw_offset.wrapping_add(section.raw_size) as usize;
|
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{:X}..0x{end:X}] exceeds file size 0x{file_len:X}",
|
IntegritySeverity::Fatal,
|
||||||
section.raw_offset
|
format!(
|
||||||
));
|
"section #{i} raw data [0x{:X}..0x{end:X}] exceeds file size 0x{file_len:X}",
|
||||||
|
section.raw_offset
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
section
|
section
|
||||||
@@ -222,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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,28 +310,38 @@ 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(out, headers, 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.
|
||||||
"entry point at RVA 0x{ep:X} is all int3 padding (stub not recovered)"
|
r.push(
|
||||||
));
|
IntegritySeverity::Warning,
|
||||||
|
format!(
|
||||||
|
"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| {
|
||||||
@@ -268,9 +351,10 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
&& (s.characteristics & 0x2000_0000) != 0
|
&& (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);
|
||||||
@@ -293,9 +377,10 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
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(out, headers, 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.
|
||||||
@@ -308,14 +393,24 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
match rva_to_off(out, headers, name_rva, 1) {
|
match rva_to_off(out, headers, name_rva, 1) {
|
||||||
None => r.issues.push(format!(
|
None => r.push(
|
||||||
"import descriptor {i} DLL name RVA 0x{name_rva:X} does not map into any section"
|
IntegritySeverity::Fatal,
|
||||||
)),
|
format!(
|
||||||
|
"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
|
||||||
"import descriptor {i} DLL name at RVA 0x{name_rva:X} is not readable ASCII (imports left encrypted?)"
|
// 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?)"
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,29 +431,39 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
|||||||
// begins with the "BSJB" signature.
|
// begins with the "BSJB" signature.
|
||||||
if is_managed {
|
if is_managed {
|
||||||
match rva_to_off(out, headers, 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(
|
||||||
"COR20 header at RVA 0x{clr_rva:X} has cb 0x{cb:X} (expected 0x48) — CLR header corrupt"
|
IntegritySeverity::Fatal,
|
||||||
));
|
format!(
|
||||||
|
"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(out, headers, md_rva, 4) {
|
match rva_to_off(out, headers, md_rva, 4) {
|
||||||
None => r.issues.push(format!(
|
None => r.push(
|
||||||
"CLR MetaData RVA 0x{md_rva:X} does not map into any section"
|
IntegritySeverity::Fatal,
|
||||||
)),
|
format!(
|
||||||
|
"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(
|
||||||
"CLR MetaData at RVA 0x{md_rva:X} lacks 'BSJB' signature (metadata corrupt — managed image will not load)"
|
IntegritySeverity::Fatal,
|
||||||
));
|
format!(
|
||||||
|
"CLR MetaData at RVA 0x{md_rva:X} lacks 'BSJB' signature (metadata corrupt — managed image will not load)"
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
let hi = (shell as u64)
|
for off in (shell..hi.saturating_sub(8)).step_by(4) {
|
||||||
.saturating_add(0x3000)
|
if get_u32(data, off as u32) != info[6] {
|
||||||
.min(data.len() as u64 - 0x100) as u32;
|
continue;
|
||||||
let mut off = shell;
|
}
|
||||||
while off < hi {
|
let shell_size = get_u32(data, off as u32 + 4) as usize;
|
||||||
if off as usize + 8 <= data.len() {
|
if !(0x1001..0x100000).contains(&shell_size)
|
||||||
let candidate = off.wrapping_sub(0x88);
|
|| shell
|
||||||
if candidate >= shell && get_u32(data, off) == info[6] {
|
.checked_add(shell_size)
|
||||||
let shell_size_val = get_u32(data, off.wrapping_add(4));
|
.is_none_or(|end| end > data.len())
|
||||||
if shell_size_val > 0x1000 && shell_size_val < 0x100000 {
|
{
|
||||||
let v58_off = candidate.wrapping_add(0x58);
|
continue;
|
||||||
if (v58_off as usize + 4) <= data.len() {
|
}
|
||||||
let v58 = get_u32(data, v58_off);
|
for (shell_field, header_field) in [(0x88, 0x58), (0x78, 0x50)] {
|
||||||
if v58 > 0 && (v58 as usize) < data.len() {
|
let Some(candidate) = off.checked_sub(shell_field).filter(|&base| base >= shell) else {
|
||||||
return Some(candidate);
|
continue;
|
||||||
}
|
};
|
||||||
}
|
let Some(table) = data.get(candidate..candidate + 0xD0) else {
|
||||||
}
|
continue;
|
||||||
|
};
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
off = off.wrapping_add(4);
|
|
||||||
}
|
}
|
||||||
None
|
tables
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|||||||
+378
-151
@@ -8,14 +8,16 @@ mod layout;
|
|||||||
pub(crate) mod parallel;
|
pub(crate) mod parallel;
|
||||||
|
|
||||||
use senbei_crypto::primitives;
|
use senbei_crypto::primitives;
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::sync::{Arc, Mutex};
|
pub(crate) use crate::panic_capture::{PanicCapture, current_panic_capture, with_panic_capture};
|
||||||
|
|
||||||
pub use crate::thread_cap;
|
pub use crate::thread_cap;
|
||||||
pub use dll::{unpack_dll, unpack_dll_v};
|
pub use dll::{unpack_dll, unpack_dll_v};
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
pub use exe::{unpack as unpack_exe, unpack_v as unpack_exe_v};
|
pub use exe::{unpack as unpack_exe, unpack_v as unpack_exe_v};
|
||||||
pub use integrity::{IntegrityReport, check as check_integrity};
|
pub use integrity::{
|
||||||
|
IntegrityFinding, IntegrityReport, IntegritySeverity, check as check_integrity,
|
||||||
|
};
|
||||||
|
|
||||||
/// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer
|
/// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer
|
||||||
/// for. Guards against a corrupt/crafted header requesting a multi-gigabyte
|
/// for. Guards against a corrupt/crafted header requesting a multi-gigabyte
|
||||||
@@ -24,130 +26,6 @@ pub use integrity::{IntegrityReport, check as check_integrity};
|
|||||||
/// Real protected binaries are far below this.
|
/// Real protected binaries are far below this.
|
||||||
pub(crate) const MAX_IMAGE_SIZE: u64 = senbei_crypto::MAX_IMAGE_SIZE;
|
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
|
/// Run an unpack pipeline, converting any internal panic into a clean
|
||||||
/// [`UnpackError::InternalPanic`] so the public API stays panic-free on any input
|
/// [`UnpackError::InternalPanic`] so the public API stays panic-free on any input
|
||||||
/// (truncated/garbled files chase offsets out of bounds). The panic location and
|
/// (truncated/garbled files chase offsets out of bounds). The panic location and
|
||||||
@@ -159,14 +37,22 @@ pub(crate) fn catch_unpack<F>(f: F) -> Result<Vec<u8>, UnpackError>
|
|||||||
where
|
where
|
||||||
F: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
F: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
||||||
{
|
{
|
||||||
install_panic_capture_hook();
|
crate::panic_capture::install_hook();
|
||||||
let capture = PanicCapture::new();
|
let capture = PanicCapture::new();
|
||||||
let r = with_panic_capture(Some(capture.clone()), || {
|
let r = with_panic_capture(Some(capture.clone()), || {
|
||||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
|
||||||
});
|
});
|
||||||
match r {
|
match r {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(payload) => Err(capture.into_error(payload.as_ref())),
|
Err(payload) => {
|
||||||
|
let details = capture.details(payload.as_ref());
|
||||||
|
Err(UnpackError::InternalPanic {
|
||||||
|
message: details.message,
|
||||||
|
file: details.file,
|
||||||
|
line: details.line,
|
||||||
|
column: details.column,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,32 +189,73 @@ pub fn unpack_auto_v(input: &[u8], verbose: bool) -> Result<(Kind, Vec<u8>), Unp
|
|||||||
let detected = detect(input).ok_or(UnpackError::NotCrackproof)?;
|
let detected = detect(input).ok_or(UnpackError::NotCrackproof)?;
|
||||||
let out = match detected.kind {
|
let out = match detected.kind {
|
||||||
Kind::NativeExe | Kind::ManagedExe => unpack_exe_v(input, verbose)?,
|
Kind::NativeExe | Kind::ManagedExe => unpack_exe_v(input, verbose)?,
|
||||||
Kind::NativeDll | Kind::ManagedDll => {
|
Kind::NativeDll | Kind::ManagedDll => select_dll_layout(
|
||||||
// Two Crackproof DLL layouts exist. The older one (the byte-identical
|
|| dll::unpack_dll_v(input, verbose),
|
||||||
// DLL goldens) follows the pipeline in `dll.rs`. Newer builds protect
|
|| exe::unpack_v(input, verbose),
|
||||||
// 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))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -389,3 +316,303 @@ mod tests {
|
|||||||
assert!(column > 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+909
-122
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
+198
-11
@@ -1,16 +1,203 @@
|
|||||||
//! Shared atomic filesystem writes for native orchestration.
|
//! Shared atomic filesystem writes for native orchestration.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::io::{self, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Write `bytes` through a sibling temporary file and replace `dest` only after
|
use tempfile::Builder;
|
||||||
/// the complete write succeeds.
|
|
||||||
pub(crate) fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
/// Write `bytes` through a uniquely named temporary file in `dest`'s directory
|
||||||
let mut temporary_name = dest.as_os_str().to_os_string();
|
/// and replace `dest` only after the complete write succeeds.
|
||||||
temporary_name.push(".senbei-tmp");
|
///
|
||||||
let temporary = PathBuf::from(temporary_name);
|
/// The temporary name is random, so concurrent writers of the same destination
|
||||||
let result = std::fs::write(&temporary, bytes).and_then(|()| std::fs::rename(&temporary, dest));
|
/// do not share one sibling file. An existing `dest` is replaced. On any
|
||||||
if result.is_err() {
|
/// failure the temporary file is removed and `dest` is left unchanged.
|
||||||
let _ = std::fs::remove_file(&temporary);
|
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());
|
||||||
}
|
}
|
||||||
result
|
|
||||||
}
|
}
|
||||||
+254
-124
@@ -15,7 +15,7 @@ pub struct Summary {
|
|||||||
/// — likely to crash at runtime (e.g. 0xC0000005). Counted in addition to
|
/// — likely to crash at runtime (e.g. 0xC0000005). Counted in addition to
|
||||||
/// `unpacked` (a suspect file is still written).
|
/// `unpacked` (a suspect file is still written).
|
||||||
pub suspect: usize,
|
pub suspect: usize,
|
||||||
/// il2cpp `global-metadata.dat` files de-obfuscated (method tokens remapped),
|
/// il2cpp `global-metadata.dat` files de-obfuscated (protected method fields remapped),
|
||||||
/// including blobs unwrapped from restored Android libraries.
|
/// including blobs unwrapped from restored Android libraries.
|
||||||
pub metadata: usize,
|
pub metadata: usize,
|
||||||
/// Android app packages (`.apk`/`.apks`/`.xapk`) opened and searched.
|
/// Android app packages (`.apk`/`.apks`/`.xapk`) opened and searched.
|
||||||
@@ -95,6 +95,27 @@ pub fn run_folder_opts(
|
|||||||
verbose: bool,
|
verbose: bool,
|
||||||
no_log: bool,
|
no_log: bool,
|
||||||
scan_all: bool,
|
scan_all: bool,
|
||||||
|
) -> anyhow::Result<Summary> {
|
||||||
|
run_folder_with_limits(
|
||||||
|
root,
|
||||||
|
out_dir,
|
||||||
|
quiet,
|
||||||
|
verbose,
|
||||||
|
no_log,
|
||||||
|
scan_all,
|
||||||
|
&senbei_engine::android::ResourceLimits::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folder mode with explicit Android archive and decoder ceilings.
|
||||||
|
pub fn run_folder_with_limits(
|
||||||
|
root: &Path,
|
||||||
|
out_dir: Option<&Path>,
|
||||||
|
quiet: u8,
|
||||||
|
verbose: bool,
|
||||||
|
no_log: bool,
|
||||||
|
scan_all: bool,
|
||||||
|
limits: &senbei_engine::android::ResourceLimits,
|
||||||
) -> anyhow::Result<Summary> {
|
) -> anyhow::Result<Summary> {
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
let out_root = out_dir
|
let out_root = out_dir
|
||||||
@@ -119,7 +140,7 @@ pub fn run_folder_opts(
|
|||||||
// [`crate::scan::find_targets_opts`]). Files the free directory metadata
|
// [`crate::scan::find_targets_opts`]). Files the free directory metadata
|
||||||
// already rules out are never opened — on asset-heavy trees the per-file
|
// already rules out are never opened — on asset-heavy trees the per-file
|
||||||
// open+read latency, not the traversal, is the whole cost.
|
// open+read latency, not the traversal, is the whole cost.
|
||||||
let scan = crate::scan::find_targets_opts(root, scan_all);
|
let scan = crate::scan::find_targets_with_limits(root, scan_all, limits);
|
||||||
let candidates = scan.crackproof.as_slice();
|
let candidates = scan.crackproof.as_slice();
|
||||||
let metas = scan.metadata.as_slice();
|
let metas = scan.metadata.as_slice();
|
||||||
let scan_stats = &scan.stats;
|
let scan_stats = &scan.stats;
|
||||||
@@ -165,10 +186,6 @@ pub fn run_folder_opts(
|
|||||||
..Summary::default()
|
..Summary::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Silence the default panic hook's stderr spew during per-file processing.
|
|
||||||
let default_hook = std::panic::take_hook();
|
|
||||||
std::panic::set_hook(Box::new(|_| {})); // suppress "thread panicked" messages
|
|
||||||
|
|
||||||
for input in candidates {
|
for input in candidates {
|
||||||
let rel = rel_in_tree(root, input);
|
let rel = rel_in_tree(root, input);
|
||||||
let dest = out_root.join(out_name(&rel));
|
let dest = out_root.join(out_name(&rel));
|
||||||
@@ -176,9 +193,9 @@ pub fn run_folder_opts(
|
|||||||
// Wrap in catch_unwind so a single bad file never aborts the folder run.
|
// Wrap in catch_unwind so a single bad file never aborts the folder run.
|
||||||
let input_owned = input.clone();
|
let input_owned = input.clone();
|
||||||
let dest_owned = dest.clone();
|
let dest_owned = dest.clone();
|
||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let result = senbei_engine::catch_unwind_quiet(|| {
|
||||||
unpack_one_v(&input_owned, &dest_owned, verbose_steps)
|
unpack_one_v(&input_owned, &dest_owned, verbose_steps)
|
||||||
}));
|
});
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok((kind, report))) => {
|
Ok(Ok((kind, report))) => {
|
||||||
@@ -217,48 +234,62 @@ pub fn run_folder_opts(
|
|||||||
bar.inc(1);
|
bar.inc(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android pass: protected AArch64 libraries and app packages. Loose `.so`
|
// Android pass. Loose libraries restore before packages so an identical
|
||||||
// files restore first so the cross-source dedup keeps them over a copy
|
// library keeps the loose destination (loose beats `.apk` beats bundle).
|
||||||
// inside a package (loose beats `.apk` beats `.apks`/`.xapk` bundle).
|
// Module 0x0C stays inside the application that produced it: an extracted
|
||||||
let mut android_seen = std::collections::HashSet::new();
|
// `lib/<abi>/` tree shares with that tree's `assets/` metadata only, and a
|
||||||
// Hashing a protected library costs a full read, so only pay it when a
|
// package shares only with splits of the same package name and version.
|
||||||
// duplicate source can actually exist in this run.
|
let mut android_cache = crate::android::run_cache_with_limits(limits);
|
||||||
let android_dedup = scan.android_so.len() > 1 || !scan.android_packages.is_empty();
|
let loose = crate::android::loose_scopes(&scan.android_so, metas);
|
||||||
for input in &scan.android_so {
|
for scoped in loose.iter().filter(|scoped| {
|
||||||
let rel = rel_in_tree(root, input);
|
scan.android_so
|
||||||
|
.iter()
|
||||||
|
.any(|library| library == &scoped.path)
|
||||||
|
}) {
|
||||||
|
let rel = rel_in_tree(root, &scoped.path);
|
||||||
let dest = out_root.join(out_name(&rel));
|
let dest = out_root.join(out_name(&rel));
|
||||||
// Unreadable here is fine: the restore reports the same error.
|
let input_owned = scoped.path.clone();
|
||||||
if android_dedup
|
|
||||||
&& let Ok(identity) = crate::android::file_content_identity(input)
|
|
||||||
&& !android_seen.insert(identity)
|
|
||||||
{
|
|
||||||
s.skipped += 1;
|
|
||||||
if let Some(log) = &log {
|
|
||||||
log.step(&format!("SKIP {rel:?}: duplicate of an earlier target"));
|
|
||||||
}
|
|
||||||
bar.inc(1);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let input_owned = input.clone();
|
|
||||||
let dest_owned = dest.clone();
|
let dest_owned = dest.clone();
|
||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let key = scoped.key.clone();
|
||||||
crate::android::restore_so_file(&input_owned, &dest_owned, verbose_steps)
|
let label = rel.display().to_string();
|
||||||
}));
|
let result = senbei_engine::catch_unwind_quiet(|| {
|
||||||
|
crate::android::restore_loose_library(
|
||||||
|
&input_owned,
|
||||||
|
&dest_owned,
|
||||||
|
&label,
|
||||||
|
&key,
|
||||||
|
verbose_steps,
|
||||||
|
&mut android_cache,
|
||||||
|
)
|
||||||
|
});
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(embedded)) => {
|
Ok(Ok(outcome)) => match outcome.status {
|
||||||
s.unpacked += 1;
|
crate::android::EntryStatus::Duplicate => {
|
||||||
crate::ui::ok_label(
|
s.skipped += 1;
|
||||||
&bar,
|
if let Some(log) = &log {
|
||||||
suppress_file_lines,
|
log.step(&format!("SKIP {rel:?}: duplicate of an earlier target"));
|
||||||
&rel.display().to_string(),
|
}
|
||||||
"So",
|
|
||||||
&dest,
|
|
||||||
);
|
|
||||||
if let Some(log) = &log {
|
|
||||||
log.step(&format!("OK {rel:?} -> {dest:?} (Android SO)"));
|
|
||||||
}
|
}
|
||||||
match write_embedded_metadata(embedded, &dest) {
|
crate::android::EntryStatus::Failed(e) => {
|
||||||
Ok(Some(meta_dest)) => {
|
s.errors += 1;
|
||||||
|
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||||
|
if let Some(log) = &log {
|
||||||
|
log.step(&format!("ERR {rel:?}: {e:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::android::EntryStatus::Restored => {
|
||||||
|
s.unpacked += 1;
|
||||||
|
crate::ui::ok_label(
|
||||||
|
&bar,
|
||||||
|
suppress_file_lines,
|
||||||
|
&rel.display().to_string(),
|
||||||
|
"So",
|
||||||
|
&dest,
|
||||||
|
);
|
||||||
|
if let Some(log) = &log {
|
||||||
|
log.step(&format!("OK {rel:?} -> {dest:?} (Android SO)"));
|
||||||
|
}
|
||||||
|
if let Some(meta_dest) = outcome.embedded {
|
||||||
s.metadata += 1;
|
s.metadata += 1;
|
||||||
crate::ui::ok_label(
|
crate::ui::ok_label(
|
||||||
&bar,
|
&bar,
|
||||||
@@ -271,16 +302,11 @@ pub fn run_folder_opts(
|
|||||||
log.step(&format!("META {rel:?} (embedded) -> {meta_dest:?}"));
|
log.step(&format!("META {rel:?} (embedded) -> {meta_dest:?}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
|
||||||
Err(e) => {
|
|
||||||
s.errors += 1;
|
|
||||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
|
||||||
if let Some(log) = &log {
|
|
||||||
log.step(&format!("ERR {rel:?}: embedded metadata: {e:#}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
crate::android::EntryStatus::NotTarget | crate::android::EntryStatus::Unchanged => {
|
||||||
|
s.skipped += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
s.errors += 1;
|
s.errors += 1;
|
||||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||||
@@ -302,75 +328,133 @@ pub fn run_folder_opts(
|
|||||||
}
|
}
|
||||||
bar.inc(1);
|
bar.inc(1);
|
||||||
}
|
}
|
||||||
for package in &scan.android_packages {
|
// Loose sibling APKs that name the same package and version are one split
|
||||||
let rel = rel_in_tree(root, package);
|
// set. Their libraries run before their metadata because each package
|
||||||
s.packages += 1;
|
// restores its own libraries first and the shared cache collects modules
|
||||||
let package_owned = package.clone();
|
// across the group. Bundles stay one package each.
|
||||||
let rel_owned = rel.clone().into_owned();
|
let package_groups =
|
||||||
let out_root_owned = out_root.clone();
|
crate::android::loose_apk_groups_cached(&scan.android_packages, &mut android_cache);
|
||||||
let mut seen_taken = std::mem::take(&mut android_seen);
|
for group in &package_groups {
|
||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
if group.len() > 1 {
|
||||||
let outcomes = crate::android::restore_package(
|
s.packages += group.len();
|
||||||
&package_owned,
|
let group_owned = group.clone();
|
||||||
&rel_owned,
|
let root_owned = root.to_path_buf();
|
||||||
&out_root_owned,
|
let out_root_owned = out_root.clone();
|
||||||
&mut seen_taken,
|
let result = senbei_engine::catch_unwind_quiet(|| {
|
||||||
verbose_steps,
|
crate::android::restore_apk_group(
|
||||||
);
|
&group_owned,
|
||||||
(outcomes, seen_taken)
|
&root_owned,
|
||||||
}));
|
&out_root_owned,
|
||||||
match result {
|
&mut android_cache,
|
||||||
Ok((Ok(outcomes), seen_back)) => {
|
verbose_steps,
|
||||||
android_seen = seen_back;
|
)
|
||||||
apply_package_outcomes(outcomes, &mut s, &bar, suppress_file_lines, &log);
|
});
|
||||||
}
|
let rel = rel_in_tree(root, &group[0]);
|
||||||
Ok((Err(e), seen_back)) => {
|
match result {
|
||||||
android_seen = seen_back;
|
Ok(Ok(outcomes)) => {
|
||||||
s.errors += 1;
|
apply_package_outcomes(outcomes, &mut s, &bar, suppress_file_lines, &log);
|
||||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
|
||||||
if let Some(log) = &log {
|
|
||||||
log.step(&format!("ERR {rel:?}: {e:#}"));
|
|
||||||
}
|
}
|
||||||
}
|
Ok(Err(e)) => {
|
||||||
Err(panic) => {
|
s.errors += 1;
|
||||||
// The dedup set may be in an unknown state after a panic; a
|
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||||
// re-scan costs a duplicate restore at worst, never corruption.
|
if let Some(log) = &log {
|
||||||
let e = anyhow::anyhow!("unexpected panic: {}", panic_payload(&panic));
|
log.step(&format!("ERR {rel:?}: {e:#}"));
|
||||||
s.errors += 1;
|
}
|
||||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
}
|
||||||
if let Some(log) = &log {
|
Err(panic) => {
|
||||||
log.step(&format!(
|
let e = anyhow::anyhow!("unexpected panic: {}", panic_payload(&panic));
|
||||||
"ERR {rel:?}: panic during package restore: {}",
|
s.errors += 1;
|
||||||
panic_payload(&panic)
|
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||||
));
|
if let Some(log) = &log {
|
||||||
|
log.step(&format!(
|
||||||
|
"ERR {rel:?}: panic during package restore: {}",
|
||||||
|
panic_payload(&panic)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bar.inc(group.len() as u64);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for package in group {
|
||||||
|
let rel = rel_in_tree(root, package);
|
||||||
|
s.packages += 1;
|
||||||
|
let package_owned = package.clone();
|
||||||
|
let rel_owned = rel.clone().into_owned();
|
||||||
|
let out_root_owned = out_root.clone();
|
||||||
|
let result = senbei_engine::catch_unwind_quiet(|| {
|
||||||
|
crate::android::restore_package_cached(
|
||||||
|
&package_owned,
|
||||||
|
&rel_owned,
|
||||||
|
&out_root_owned,
|
||||||
|
&mut android_cache,
|
||||||
|
verbose_steps,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(Ok(outcomes)) => {
|
||||||
|
apply_package_outcomes(outcomes, &mut s, &bar, suppress_file_lines, &log);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
s.errors += 1;
|
||||||
|
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||||
|
if let Some(log) = &log {
|
||||||
|
log.step(&format!("ERR {rel:?}: {e:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(panic) => {
|
||||||
|
// A panic may leave a partial module recorded. Keeping the cache
|
||||||
|
// preserves successful dedup; a conflict still fails closed.
|
||||||
|
let e = anyhow::anyhow!("unexpected panic: {}", panic_payload(&panic));
|
||||||
|
s.errors += 1;
|
||||||
|
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||||
|
if let Some(log) = &log {
|
||||||
|
log.step(&format!(
|
||||||
|
"ERR {rel:?}: panic during package restore: {}",
|
||||||
|
panic_payload(&panic)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bar.inc(1);
|
||||||
}
|
}
|
||||||
bar.inc(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// il2cpp metadata pass. Crackproof's `-GMD` option obfuscates the method
|
// il2cpp metadata pass. A loose blob is paired only with the application
|
||||||
// tokens in `global-metadata.dat`; de-obfuscate any we find so the unpacked
|
// assigned when the tree was grouped. v24.1 with a missing or conflicting
|
||||||
// il2cpp game assembly resolves methods instead of indexing its per-module
|
// module writes nothing. Other versions still restore when the application
|
||||||
// tables out of bounds (see [`senbei_metadata`]). This is additive to the
|
// has no conflicting module. The paired libraries are recorded on the log
|
||||||
// Crackproof module unpack above — the metadata blob is not itself a
|
// line.
|
||||||
// Crackproof file.
|
for scoped in loose
|
||||||
for meta in metas.iter() {
|
.iter()
|
||||||
let rel = rel_in_tree(root, meta);
|
.filter(|scoped| metas.iter().any(|meta| meta == &scoped.path))
|
||||||
|
{
|
||||||
|
let rel = rel_in_tree(root, &scoped.path);
|
||||||
let dest = out_root.join(out_name(&rel));
|
let dest = out_root.join(out_name(&rel));
|
||||||
let meta_owned = meta.clone();
|
let meta_owned = scoped.path.clone();
|
||||||
let dest_owned = dest.clone();
|
let dest_owned = dest.clone();
|
||||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
let key = scoped.key.clone();
|
||||||
deobfuscate_metadata_to(&meta_owned, &dest_owned, verbose_steps)
|
let label = rel.display().to_string();
|
||||||
}));
|
let result = senbei_engine::catch_unwind_quiet(|| {
|
||||||
|
crate::android::restore_loose_metadata(
|
||||||
|
&meta_owned,
|
||||||
|
&dest_owned,
|
||||||
|
&label,
|
||||||
|
&key,
|
||||||
|
verbose_steps,
|
||||||
|
&mut android_cache,
|
||||||
|
)
|
||||||
|
});
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(report)) if report.remapped > 0 => {
|
Ok(Ok(report)) if report.remapped > 0 => {
|
||||||
s.metadata += 1;
|
s.metadata += 1;
|
||||||
crate::ui::metadata(&bar, suppress_file_lines, &rel, report.remapped, &dest);
|
crate::ui::metadata(&bar, suppress_file_lines, &rel, report.remapped, &dest);
|
||||||
if let Some(log) = &log {
|
if let Some(log) = &log {
|
||||||
log.step(&format!(
|
log.step(&format!(
|
||||||
"META {rel:?} -> {dest:?}: v{} remapped {} method tokens",
|
"META {rel:?} -> {dest:?}: v{} remapped {} method fields{}",
|
||||||
report.version, report.remapped
|
report.version,
|
||||||
|
report.remapped,
|
||||||
|
provenance_suffix(&report.provenance)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,8 +463,9 @@ pub fn run_folder_opts(
|
|||||||
Ok(Ok(report)) => {
|
Ok(Ok(report)) => {
|
||||||
if let Some(log) = &log {
|
if let Some(log) = &log {
|
||||||
log.step(&format!(
|
log.step(&format!(
|
||||||
"META {rel:?}: v{} already de-obfuscated",
|
"META {rel:?}: v{} already de-obfuscated{}",
|
||||||
report.version
|
report.version,
|
||||||
|
provenance_suffix(&report.provenance)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,9 +516,6 @@ pub fn run_folder_opts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the original panic hook.
|
|
||||||
std::panic::set_hook(default_hook);
|
|
||||||
|
|
||||||
bar.finish_and_clear();
|
bar.finish_and_clear();
|
||||||
s.duration_ms = t0.elapsed().as_millis();
|
s.duration_ms = t0.elapsed().as_millis();
|
||||||
if let Some(log) = &log {
|
if let Some(log) = &log {
|
||||||
@@ -454,6 +536,25 @@ pub fn run_file_v(
|
|||||||
quiet: u8,
|
quiet: u8,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
no_log: bool,
|
no_log: bool,
|
||||||
|
) -> anyhow::Result<Summary> {
|
||||||
|
run_file_with_limits(
|
||||||
|
input,
|
||||||
|
out_dir,
|
||||||
|
quiet,
|
||||||
|
verbose,
|
||||||
|
no_log,
|
||||||
|
&senbei_engine::android::ResourceLimits::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-file mode with explicit Android archive and decoder ceilings.
|
||||||
|
pub fn run_file_with_limits(
|
||||||
|
input: &Path,
|
||||||
|
out_dir: Option<&Path>,
|
||||||
|
quiet: u8,
|
||||||
|
verbose: bool,
|
||||||
|
no_log: bool,
|
||||||
|
limits: &senbei_engine::android::ResourceLimits,
|
||||||
) -> anyhow::Result<Summary> {
|
) -> anyhow::Result<Summary> {
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
let out_root = out_dir
|
let out_root = out_dir
|
||||||
@@ -496,8 +597,8 @@ pub fn run_file_v(
|
|||||||
// library probe needs the whole file (its payload section is found through
|
// library probe needs the whole file (its payload section is found through
|
||||||
// the section-header table at the end), while a package is a container
|
// the section-header table at the end), while a package is a container
|
||||||
// handled entry-by-entry. Anything else falls through to the PE pipeline.
|
// handled entry-by-entry. Anything else falls through to the PE pipeline.
|
||||||
let is_android_so =
|
let is_android_so = crate::android::is_elf64_aarch64(&prefix)
|
||||||
crate::android::is_elf64_aarch64(&prefix) && crate::android::is_protected_so_file(input);
|
&& crate::android::probe_so_file_with_limits(input, limits).unwrap_or(true);
|
||||||
let is_android_package = !is_android_so && crate::android::is_app_package(input, &prefix);
|
let is_android_package = !is_android_so && crate::android::is_app_package(input, &prefix);
|
||||||
|
|
||||||
if is_meta {
|
if is_meta {
|
||||||
@@ -506,13 +607,13 @@ pub fn run_file_v(
|
|||||||
s.metadata = 1;
|
s.metadata = 1;
|
||||||
if let Some(log) = &log {
|
if let Some(log) = &log {
|
||||||
log.step(&format!(
|
log.step(&format!(
|
||||||
"META {:?} -> {:?}: v{} remapped {} method tokens",
|
"META {:?} -> {:?}: v{} remapped {} method fields",
|
||||||
input, dest, report.version, report.remapped
|
input, dest, report.version, report.remapped
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if quiet == 0 {
|
if quiet == 0 {
|
||||||
println!(
|
println!(
|
||||||
"✓ metadata v{} -> {:?} ({} method tokens remapped)",
|
"✓ metadata v{} -> {:?} ({} method fields remapped)",
|
||||||
report.version, dest, report.remapped
|
report.version, dest, report.remapped
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -543,7 +644,12 @@ pub fn run_file_v(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if is_android_so {
|
} else if is_android_so {
|
||||||
match crate::android::restore_so_file(input, &dest, verbose && quiet == 0) {
|
match crate::android::restore_so_file_with_limits(
|
||||||
|
input,
|
||||||
|
&dest,
|
||||||
|
verbose && quiet == 0,
|
||||||
|
limits,
|
||||||
|
) {
|
||||||
Ok(embedded) => {
|
Ok(embedded) => {
|
||||||
s.unpacked = 1;
|
s.unpacked = 1;
|
||||||
if let Some(log) = &log {
|
if let Some(log) = &log {
|
||||||
@@ -592,12 +698,13 @@ pub fn run_file_v(
|
|||||||
s.packages = 1;
|
s.packages = 1;
|
||||||
let rel = PathBuf::from(input.file_name().unwrap_or_default());
|
let rel = PathBuf::from(input.file_name().unwrap_or_default());
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
match crate::android::restore_package(
|
match crate::android::restore_package_with_limits(
|
||||||
input,
|
input,
|
||||||
&rel,
|
&rel,
|
||||||
&out_root,
|
&out_root,
|
||||||
&mut seen,
|
&mut seen,
|
||||||
verbose && quiet == 0,
|
verbose && quiet == 0,
|
||||||
|
limits,
|
||||||
) {
|
) {
|
||||||
Ok(outcomes) => {
|
Ok(outcomes) => {
|
||||||
let bar = crate::ui::progress(0, true);
|
let bar = crate::ui::progress(0, true);
|
||||||
@@ -694,6 +801,14 @@ pub fn out_name(input: &Path) -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn provenance_suffix(sources: &[String]) -> String {
|
||||||
|
if sources.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("; module 0x0C from {}", sources.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract a printable message from a caught panic payload.
|
/// Extract a printable message from a caught panic payload.
|
||||||
fn panic_payload(panic: &(dyn std::any::Any + Send)) -> String {
|
fn panic_payload(panic: &(dyn std::any::Any + Send)) -> String {
|
||||||
if let Some(s) = panic.downcast_ref::<&str>() {
|
if let Some(s) = panic.downcast_ref::<&str>() {
|
||||||
@@ -735,6 +850,15 @@ pub fn deobfuscate_metadata_to(
|
|||||||
input: &Path,
|
input: &Path,
|
||||||
dest: &Path,
|
dest: &Path,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
|
) -> anyhow::Result<senbei_metadata::Report> {
|
||||||
|
deobfuscate_metadata_to_with_module(input, dest, verbose, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deobfuscate_metadata_to_with_module(
|
||||||
|
input: &Path,
|
||||||
|
dest: &Path,
|
||||||
|
verbose: bool,
|
||||||
|
method_index_module: Option<&[u8]>,
|
||||||
) -> anyhow::Result<senbei_metadata::Report> {
|
) -> anyhow::Result<senbei_metadata::Report> {
|
||||||
let data = std::fs::read(input)?;
|
let data = std::fs::read(input)?;
|
||||||
// The Android seeded-permutation variant is tried first (it validates
|
// The Android seeded-permutation variant is tried first (it validates
|
||||||
@@ -742,8 +866,9 @@ pub fn deobfuscate_metadata_to(
|
|||||||
// Windows path. The [`senbei_metadata::Error`] is preserved in the chain
|
// Windows path. The [`senbei_metadata::Error`] is preserved in the chain
|
||||||
// (rather than stringified) so the folder driver can apply its
|
// (rather than stringified) so the folder driver can apply its
|
||||||
// unsupported-version policy.
|
// unsupported-version policy.
|
||||||
let (out, report) = crate::android::restore_metadata_bytes(&data)
|
let (out, report) =
|
||||||
.map_err(|e| e.context(format!("{input:?}")))?;
|
crate::android::restore_metadata_bytes_with_module(&data, method_index_module)
|
||||||
|
.map_err(|e| e.context(format!("{input:?}")))?;
|
||||||
if report.remapped > 0 {
|
if report.remapped > 0 {
|
||||||
if let Some(parent) = dest.parent() {
|
if let Some(parent) = dest.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
@@ -807,7 +932,12 @@ fn apply_package_outcomes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(log) = log {
|
if let Some(log) = log {
|
||||||
log.step(&format!("OK {} -> {:?}", outcome.label, outcome.dest));
|
log.step(&format!(
|
||||||
|
"OK {} -> {:?}{}",
|
||||||
|
outcome.label,
|
||||||
|
outcome.dest,
|
||||||
|
provenance_suffix(&outcome.provenance)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EntryStatus::Duplicate | EntryStatus::NotTarget | EntryStatus::Unchanged => {
|
EntryStatus::Duplicate | EntryStatus::NotTarget | EntryStatus::Unchanged => {
|
||||||
|
|||||||
+19
-4
@@ -111,6 +111,21 @@ 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 selected target names below [`MIN_SIZE`] are also probed.
|
/// `scan_all` is true selected target names below [`MIN_SIZE`] are also probed.
|
||||||
pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
||||||
|
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
|
||||||
@@ -177,7 +192,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
|||||||
let workers = senbei_engine::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);
|
||||||
@@ -185,7 +200,7 @@ pub fn find_targets_opts(root: &Path, scan_all: bool) -> ScanResult {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -239,7 +254,7 @@ pub fn scan_all_env() -> bool {
|
|||||||
/// payload lives in a section found via the section-header table at the *end*
|
/// 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
|
/// of the file, so an ELF64/AArch64 prefix triggers a full-file read. Only
|
||||||
/// selected `.so` images pay for it.
|
/// selected `.so` images pay for it.
|
||||||
fn classify(path: &Path) -> Option<Class> {
|
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 crate::android::is_package_name(path) && crate::android::is_app_package(path, &head) {
|
if crate::android::is_package_name(path) && crate::android::is_app_package(path, &head) {
|
||||||
@@ -253,7 +268,7 @@ fn classify(path: &Path) -> Option<Class> {
|
|||||||
}
|
}
|
||||||
if crate::android::is_so_name(path)
|
if crate::android::is_so_name(path)
|
||||||
&& crate::android::is_elf64_aarch64(&head)
|
&& crate::android::is_elf64_aarch64(&head)
|
||||||
&& crate::android::is_protected_so_file(path)
|
&& crate::android::probe_so_file_with_limits(path, limits).unwrap_or(true)
|
||||||
{
|
{
|
||||||
return Class::AndroidSo;
|
return Class::AndroidSo;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -39,13 +39,13 @@ pub fn ok_label(bar: &ProgressBar, quiet: bool, rel: &str, label: &str, dest: &P
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Print a green success line for a de-obfuscated il2cpp `global-metadata.dat`,
|
/// Print a green success line for a de-obfuscated il2cpp `global-metadata.dat`,
|
||||||
/// reporting how many method tokens were remapped.
|
/// reporting how many protected method fields were remapped.
|
||||||
pub fn metadata(bar: &ProgressBar, quiet: bool, rel: &Path, remapped: usize, dest: &Path) {
|
pub fn metadata(bar: &ProgressBar, quiet: bool, rel: &Path, remapped: usize, dest: &Path) {
|
||||||
if quiet {
|
if quiet {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"{} metadata {} -> {} ({} method tokens remapped)",
|
"{} metadata {} -> {} ({} method fields remapped)",
|
||||||
"✓".green(),
|
"✓".green(),
|
||||||
rel.display(),
|
rel.display(),
|
||||||
dest.display(),
|
dest.display(),
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ use senbei_engine as unpacker;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::atomic::write_atomic;
|
use crate::atomic::write_atomic;
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
mod companion;
|
||||||
|
|
||||||
pub(crate) fn is_pe_extension(path: &Path) -> bool {
|
pub(crate) fn is_pe_extension(path: &Path) -> bool {
|
||||||
path.extension()
|
path.extension()
|
||||||
@@ -57,9 +60,10 @@ const HEADER_OFF: usize = 4096;
|
|||||||
///
|
///
|
||||||
/// The splice fires only when a sibling `<input>._` exists *and* its first 32
|
/// 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
|
/// bytes equal the stub's header at offset 4096 — a precise signal that the
|
||||||
/// companion is this stub's payload. Otherwise the file is returned untouched,
|
/// companion is this stub's payload. A recognized external-file declaration
|
||||||
/// so normal (embedded-payload) inputs are unaffected.
|
/// without a matching payload reports a companion error. Other inputs are
|
||||||
pub(crate) fn read_unpacker_input(input: &Path) -> std::io::Result<UnpackerInput> {
|
/// 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)?;
|
let stub = std::fs::read(input)?;
|
||||||
|
|
||||||
// Companion path: append "._" to the full file name (Foo.dll -> Foo.dll._).
|
// Companion path: append "._" to the full file name (Foo.dll -> Foo.dll._).
|
||||||
@@ -76,14 +80,17 @@ pub(crate) fn read_unpacker_input(input: &Path) -> std::io::Result<UnpackerInput
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !companion.is_file() {
|
let comp = match std::fs::read(&companion) {
|
||||||
return Ok(UnpackerInput {
|
Ok(bytes) => Some(bytes),
|
||||||
bytes: stub,
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||||
stub: None,
|
Err(error) => {
|
||||||
});
|
return Err(error)
|
||||||
}
|
.with_context(|| format!("couldn't read companion {}", companion.display()));
|
||||||
let comp = std::fs::read(&companion)?;
|
}
|
||||||
match splice_companion(&stub, &comp) {
|
};
|
||||||
|
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
|
// A splice fired: keep the stub so its plaintext export table can be
|
||||||
// overlaid onto the unpacked image (the companion does not carry it).
|
// overlaid onto the unpacked image (the companion does not carry it).
|
||||||
Some(spliced) => Ok(UnpackerInput {
|
Some(spliced) => Ok(UnpackerInput {
|
||||||
@@ -128,18 +135,19 @@ pub(crate) fn overlay_exports_from_stub(out: &mut [u8], stub: &[u8]) {
|
|||||||
Some(v) if v.1 != 0 => v,
|
Some(v) if v.1 != 0 => v,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
let dst = match rva_to_file_off(out, export_rva) {
|
let dst = match senbei_pe::parse(out)
|
||||||
Some(o) => o,
|
.and_then(|headers| senbei_pe::rva_range(out, headers, export_rva, export_size))
|
||||||
None => return,
|
{
|
||||||
|
Ok(range) => range,
|
||||||
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
let src = match rva_to_file_off(stub, export_rva) {
|
let src = match senbei_pe::parse(stub)
|
||||||
Some(o) => o,
|
.and_then(|headers| senbei_pe::rva_range(stub, headers, export_rva, export_size))
|
||||||
None => return,
|
{
|
||||||
|
Ok(range) => range,
|
||||||
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
let n = export_size as usize;
|
out[dst].copy_from_slice(&stub[src]);
|
||||||
if dst + n <= out.len() && src + n <= stub.len() {
|
|
||||||
out[dst..dst + n].copy_from_slice(&stub[src..src + n]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore the CLR regions retained by an external-companion loader stub.
|
/// Restore the CLR regions retained by an external-companion loader stub.
|
||||||
@@ -498,7 +506,7 @@ fn unpack_bytes_impl(
|
|||||||
companion: Option<&[u8]>,
|
companion: Option<&[u8]>,
|
||||||
force_exe: bool,
|
force_exe: bool,
|
||||||
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
) -> Result<UnpackedImage, unpacker::UnpackError> {
|
||||||
let spliced = companion.and_then(|c| splice_companion(input, c));
|
let spliced = companion::prepare_companion(input, companion)?;
|
||||||
let bytes: &[u8] = spliced.as_deref().unwrap_or(input);
|
let bytes: &[u8] = spliced.as_deref().unwrap_or(input);
|
||||||
let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), force_exe, false)?;
|
let (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), force_exe, false)?;
|
||||||
if spliced.is_some() {
|
if spliced.is_some() {
|
||||||
@@ -633,6 +641,34 @@ mod tests {
|
|||||||
data
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_overlay_requires_the_entire_range_to_be_file_backed() {
|
||||||
|
for is_pe32_plus in [false, true] {
|
||||||
|
let dirs = 0x98 + if is_pe32_plus { 112 } else { 96 };
|
||||||
|
let section = 0x98 + if is_pe32_plus { 0xf0 } else { 0xe0 };
|
||||||
|
let mut stub = managed_fixture(is_pe32_plus, 0x600);
|
||||||
|
stub[0x780..0x880].fill(0x5a);
|
||||||
|
for (stub_raw_size, out_raw_size) in
|
||||||
|
[(0x600u32, 0x600u32), (0x200, 0x600), (0x600, 0x200)]
|
||||||
|
{
|
||||||
|
let mut out = managed_fixture(is_pe32_plus, 0x400);
|
||||||
|
out[dirs..dirs + 4].copy_from_slice(&0x2180u32.to_le_bytes());
|
||||||
|
out[dirs + 4..dirs + 8].copy_from_slice(&0x100u32.to_le_bytes());
|
||||||
|
out[section + 16..section + 20].copy_from_slice(&out_raw_size.to_le_bytes());
|
||||||
|
stub[section + 16..section + 20].copy_from_slice(&stub_raw_size.to_le_bytes());
|
||||||
|
let before = out.clone();
|
||||||
|
overlay_exports_from_stub(&mut out, &stub);
|
||||||
|
if stub_raw_size == 0x600 && out_raw_size == 0x600 {
|
||||||
|
assert_eq!(&out[0x580..0x680], &[0x5a; 0x100]);
|
||||||
|
assert_eq!(&out[..0x580], &before[..0x580]);
|
||||||
|
assert_eq!(&out[0x680..], &before[0x680..]);
|
||||||
|
} else {
|
||||||
|
assert_eq!(out, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn managed_companion_restores_rva_mapped_regions_without_overwriting_il() {
|
fn managed_companion_restores_rva_mapped_regions_without_overwriting_il() {
|
||||||
for is_pe32_plus in [false, true] {
|
for is_pe32_plus in [false, true] {
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
//! Restoration of IL2CPP v24.1 `Il2CppMethodDefinition.methodIndex` values.
|
||||||
|
//!
|
||||||
|
//! CrackProof's v24.1 Android module rewrites the global method-index permutation
|
||||||
|
//! at runtime. The transform parameters are carried by the decoded module `0x0C`,
|
||||||
|
//! so the restore derives them from that module instead of hard-coding a
|
||||||
|
//! game-specific table.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::common::MAGIC;
|
||||||
|
|
||||||
|
const RAW_VERSION_24: u32 = 24;
|
||||||
|
const HDR_METHODS: usize = 0x30;
|
||||||
|
const V24_1_METHOD_STRIDE: usize = 0x34;
|
||||||
|
const V24_1_METHOD_INDEX_OFFSET: usize = 0x14;
|
||||||
|
const V24_1_SELECTOR: u32 = 1;
|
||||||
|
const EXCEPTION_COUNT: usize = 256;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct MethodIndexReport {
|
||||||
|
pub version: u32,
|
||||||
|
pub methods: usize,
|
||||||
|
pub active_methods: usize,
|
||||||
|
pub changed_indices: usize,
|
||||||
|
pub exception_count: usize,
|
||||||
|
pub seed: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum MethodIndexError {
|
||||||
|
#[error("not an IL2CPP global-metadata.dat")]
|
||||||
|
NotMetadata,
|
||||||
|
#[error("unsupported metadata version {0}")]
|
||||||
|
UnsupportedVersion(u32),
|
||||||
|
#[error("malformed v24.1 metadata: {0}")]
|
||||||
|
Malformed(String),
|
||||||
|
#[error("v24.1 method-index profile was not found in module 0x0C")]
|
||||||
|
ProfileNotFound,
|
||||||
|
#[error("multiple v24.1 method-index profiles matched module 0x0C")]
|
||||||
|
AmbiguousProfile,
|
||||||
|
#[error("v24.1 method-index restoration failed validation: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, MethodIndexError>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct Profile {
|
||||||
|
seed: u32,
|
||||||
|
exception_values: [u32; EXCEPTION_COUNT],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(data: &[u8], offset: usize, size: usize) -> Result<&[u8]> {
|
||||||
|
let end = offset
|
||||||
|
.checked_add(size)
|
||||||
|
.ok_or_else(|| MethodIndexError::Malformed("byte range overflow".to_owned()))?;
|
||||||
|
data.get(offset..end).ok_or_else(|| {
|
||||||
|
MethodIndexError::Malformed(format!(
|
||||||
|
"byte range 0x{offset:x}..0x{end:x} is out of bounds"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||||
|
let value: [u8; 4] = bytes(data, offset, 4)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| MethodIndexError::Malformed("invalid u32 range".to_owned()))?;
|
||||||
|
Ok(u32::from_le_bytes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i32(data: &[u8], offset: usize) -> Result<i32> {
|
||||||
|
let value: [u8; 4] = bytes(data, offset, 4)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| MethodIndexError::Malformed("invalid i32 range".to_owned()))?;
|
||||||
|
Ok(i32::from_le_bytes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_i32(data: &mut [u8], offset: usize, value: i32) -> Result<()> {
|
||||||
|
let destination = data.get_mut(offset..offset + 4).ok_or_else(|| {
|
||||||
|
MethodIndexError::Malformed("method-index write is out of bounds".to_owned())
|
||||||
|
})?;
|
||||||
|
destination.copy_from_slice(&value.to_le_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_table(data: &[u8]) -> Result<(usize, usize)> {
|
||||||
|
if read_u32(data, 0)? != MAGIC {
|
||||||
|
return Err(MethodIndexError::NotMetadata);
|
||||||
|
}
|
||||||
|
let version = read_u32(data, 4)?;
|
||||||
|
if version != RAW_VERSION_24 {
|
||||||
|
return Err(MethodIndexError::UnsupportedVersion(version));
|
||||||
|
}
|
||||||
|
let offset = read_u32(data, HDR_METHODS)? as usize;
|
||||||
|
let size = read_u32(data, HDR_METHODS + 4)? as usize;
|
||||||
|
bytes(data, offset, size)?;
|
||||||
|
if !size.is_multiple_of(V24_1_METHOD_STRIDE) {
|
||||||
|
return Err(MethodIndexError::Malformed(format!(
|
||||||
|
"method table size 0x{size:x} is not divisible by v24.1 stride 0x{V24_1_METHOD_STRIDE:x}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok((offset, size / V24_1_METHOD_STRIDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_indices(data: &[u8]) -> Result<(usize, Vec<Option<u32>>)> {
|
||||||
|
let (offset, count) = method_table(data)?;
|
||||||
|
let mut indices = Vec::with_capacity(count);
|
||||||
|
for method in 0..count {
|
||||||
|
let value = read_i32(
|
||||||
|
data,
|
||||||
|
offset + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET,
|
||||||
|
)?;
|
||||||
|
indices.push((value >= 0).then_some(value as u32));
|
||||||
|
}
|
||||||
|
Ok((offset, indices))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_complete_permutation(indices: &[Option<u32>]) -> bool {
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
if active_count == 0 || active_count > u32::MAX as usize {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut seen = vec![false; active_count];
|
||||||
|
for value in indices.iter().flatten().copied() {
|
||||||
|
let Ok(index) = usize::try_from(value) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if index >= active_count || seen[index] {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen[index] = true;
|
||||||
|
}
|
||||||
|
seen.into_iter().all(|value| value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permutation_key(seed: u32, count: u32) -> Result<u32> {
|
||||||
|
if count < 2 {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"method-index permutation requires at least two active methods".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if count > u32::MAX / 2 {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"active method count is too large for the permutation mirror".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let half = count / 2;
|
||||||
|
if half == 0 {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"method-index permutation has a zero divisor".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(seed % half + count / 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permutation_round(mut value: u32, count: u32, key: u32) -> u32 {
|
||||||
|
let mirror = count * 2 - 1;
|
||||||
|
if value & 1 != 0 {
|
||||||
|
value = mirror - value;
|
||||||
|
}
|
||||||
|
value >>= 1;
|
||||||
|
if value >= count {
|
||||||
|
value = mirror - value;
|
||||||
|
}
|
||||||
|
let adjusted = i64::from(value) - i64::from(key);
|
||||||
|
if adjusted < 0 {
|
||||||
|
(adjusted + i64::from(count)) as u32
|
||||||
|
} else {
|
||||||
|
adjusted as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_index(mut value: u32, count: u32, seed: u32) -> Result<u32> {
|
||||||
|
let key = permutation_key(seed, count)?;
|
||||||
|
for _ in 0..5 {
|
||||||
|
value = permutation_round(value, count, key);
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transformed_values(indices: &[Option<u32>], seed: u32) -> Result<Vec<Option<u32>>> {
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
let count = u32::try_from(active_count)
|
||||||
|
.map_err(|_| MethodIndexError::Validation("active method count exceeds u32".to_owned()))?;
|
||||||
|
indices
|
||||||
|
.iter()
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.map(|value| transform_index(value, count, seed))
|
||||||
|
.transpose()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_values(indices: &[Option<u32>]) -> Option<Vec<u32>> {
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
let mut seen = vec![false; active_count];
|
||||||
|
for value in indices.iter().flatten().copied() {
|
||||||
|
let index = usize::try_from(value).ok()?;
|
||||||
|
if index >= active_count {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
seen[index] = true;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
seen.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, present)| (!present).then_some(index as u32))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_exception_table(
|
||||||
|
module: &[u8],
|
||||||
|
count: u32,
|
||||||
|
missing: &[u32],
|
||||||
|
) -> Option<[u32; EXCEPTION_COUNT]> {
|
||||||
|
if missing.len() != EXCEPTION_COUNT || count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut wanted = vec![false; count as usize];
|
||||||
|
for &value in missing {
|
||||||
|
let slot = wanted.get_mut(value as usize)?;
|
||||||
|
*slot = true;
|
||||||
|
}
|
||||||
|
let table_bytes = EXCEPTION_COUNT * 4;
|
||||||
|
for offset in (8..=module.len().checked_sub(table_bytes)?).step_by(4) {
|
||||||
|
if read_u32(module, offset - 8).ok()? != V24_1_SELECTOR
|
||||||
|
|| read_u32(module, offset - 4).ok()? != count - 1
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut values = [0_u32; EXCEPTION_COUNT];
|
||||||
|
let mut seen = vec![false; count as usize];
|
||||||
|
let mut matches = true;
|
||||||
|
for (index, slot) in values.iter_mut().enumerate() {
|
||||||
|
let value = read_u32(module, offset + index * 4).ok()?;
|
||||||
|
let Some(is_wanted) = wanted.get(value as usize) else {
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if !*is_wanted || seen[value as usize] {
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen[value as usize] = true;
|
||||||
|
*slot = value;
|
||||||
|
}
|
||||||
|
if matches {
|
||||||
|
return Some(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_candidates(module: &[u8], indices: &[Option<u32>]) -> Result<Vec<Profile>> {
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
let count = u32::try_from(active_count)
|
||||||
|
.map_err(|_| MethodIndexError::Validation("active method count exceeds u32".to_owned()))?;
|
||||||
|
if count <= EXCEPTION_COUNT as u32 {
|
||||||
|
return Err(MethodIndexError::Validation(format!(
|
||||||
|
"active method count {count} is too small for the v24.1 exception table"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut profiles = Vec::new();
|
||||||
|
for offset in (0..module.len().saturating_sub(8)).step_by(4) {
|
||||||
|
let Ok(version) = read_u32(module, offset + 4) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if version != RAW_VERSION_24 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let seed = read_u32(module, offset)?;
|
||||||
|
let transformed = transformed_values(indices, seed)?;
|
||||||
|
let Some(missing) = missing_values(&transformed) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(exception_values) = find_exception_table(module, count, &missing) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut candidate = transformed;
|
||||||
|
let mut exception = exception_values.iter().copied();
|
||||||
|
for value in candidate.iter_mut().flatten() {
|
||||||
|
if let Some(replacement) = exception.next() {
|
||||||
|
*value = replacement;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exception.next().is_none() && is_complete_permutation(&candidate) {
|
||||||
|
profiles.push(Profile {
|
||||||
|
seed,
|
||||||
|
exception_values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profiles.sort_by_key(|profile| profile.seed);
|
||||||
|
profiles.dedup();
|
||||||
|
Ok(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the v24.1 `methodIndex` permutation using the decoded CrackProof
|
||||||
|
/// module `0x0C` that accompanied the protected library.
|
||||||
|
///
|
||||||
|
/// The profile is accepted only when the module supplies a seed plus a
|
||||||
|
/// 256-entry exception table that turns the restored non-negative indices into
|
||||||
|
/// the exact permutation `0..active_method_count`. This makes a wrong module or
|
||||||
|
/// incompatible v24 sub-layout fail without mutating the metadata.
|
||||||
|
pub fn restore_method_indices_v24_1(
|
||||||
|
data: &[u8],
|
||||||
|
module_0c: &[u8],
|
||||||
|
) -> Result<(Vec<u8>, MethodIndexReport)> {
|
||||||
|
let (method_offset, indices) = method_indices(data)?;
|
||||||
|
let active_count = indices.iter().filter(|value| value.is_some()).count();
|
||||||
|
if is_complete_permutation(&indices) {
|
||||||
|
return Ok((
|
||||||
|
data.to_vec(),
|
||||||
|
MethodIndexReport {
|
||||||
|
version: RAW_VERSION_24,
|
||||||
|
methods: indices.len(),
|
||||||
|
active_methods: active_count,
|
||||||
|
changed_indices: 0,
|
||||||
|
exception_count: 0,
|
||||||
|
seed: "clean".to_owned(),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let profiles = profile_candidates(module_0c, &indices)?;
|
||||||
|
let profile = match profiles.as_slice() {
|
||||||
|
[] => return Err(MethodIndexError::ProfileNotFound),
|
||||||
|
[profile] => profile,
|
||||||
|
_ => return Err(MethodIndexError::AmbiguousProfile),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut restored = transformed_values(&indices, profile.seed)?;
|
||||||
|
let mut exception = profile.exception_values.iter().copied();
|
||||||
|
for value in restored.iter_mut().flatten() {
|
||||||
|
if let Some(replacement) = exception.next() {
|
||||||
|
*value = replacement;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if exception.next().is_some() {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"metadata has fewer active methods than the exception table".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !is_complete_permutation(&restored) {
|
||||||
|
return Err(MethodIndexError::Validation(
|
||||||
|
"restored methodIndex values are not a complete permutation".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = data.to_vec();
|
||||||
|
let mut changed_indices = 0_usize;
|
||||||
|
for (method, value) in restored.iter().enumerate() {
|
||||||
|
let Some(value) = value else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let offset = method_offset + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET;
|
||||||
|
let old = read_i32(data, offset)?;
|
||||||
|
let new = i32::try_from(*value).map_err(|_| {
|
||||||
|
MethodIndexError::Validation("restored methodIndex exceeds i32".to_owned())
|
||||||
|
})?;
|
||||||
|
if old != new {
|
||||||
|
write_i32(&mut output, offset, new)?;
|
||||||
|
changed_indices += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
output,
|
||||||
|
MethodIndexReport {
|
||||||
|
version: RAW_VERSION_24,
|
||||||
|
methods: indices.len(),
|
||||||
|
active_methods: active_count,
|
||||||
|
changed_indices,
|
||||||
|
exception_count: EXCEPTION_COUNT,
|
||||||
|
seed: format!("0x{:08X}", profile.seed),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn inverse_map(count: u32, seed: u32) -> Vec<u32> {
|
||||||
|
let mut inverse = vec![u32::MAX; count as usize];
|
||||||
|
for value in 0..count {
|
||||||
|
let transformed = transform_index(value, count, seed).unwrap();
|
||||||
|
inverse[transformed as usize] = value;
|
||||||
|
}
|
||||||
|
assert!(inverse.iter().all(|value| *value != u32::MAX));
|
||||||
|
inverse
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let active_count = 512_u32;
|
||||||
|
let seed = 0x7770_0dcc;
|
||||||
|
let inverse = inverse_map(active_count, seed);
|
||||||
|
let header_size = 0x100usize;
|
||||||
|
let method_size = active_count as usize * V24_1_METHOD_STRIDE;
|
||||||
|
let mut metadata = vec![0_u8; header_size + method_size];
|
||||||
|
metadata[0..4].copy_from_slice(&MAGIC.to_le_bytes());
|
||||||
|
metadata[4..8].copy_from_slice(&RAW_VERSION_24.to_le_bytes());
|
||||||
|
metadata[HDR_METHODS..HDR_METHODS + 4].copy_from_slice(&(header_size as u32).to_le_bytes());
|
||||||
|
metadata[HDR_METHODS + 4..HDR_METHODS + 8]
|
||||||
|
.copy_from_slice(&(method_size as u32).to_le_bytes());
|
||||||
|
|
||||||
|
// Before the exception patch the first half duplicates the second half,
|
||||||
|
// leaving 0..255 missing. The module's 256-entry table fills exactly
|
||||||
|
// those first active methods after the five-round transform.
|
||||||
|
for method in 0..active_count as usize {
|
||||||
|
let transformed = 256 + (method % 256) as u32;
|
||||||
|
let protected = inverse[transformed as usize];
|
||||||
|
let offset = header_size + method * V24_1_METHOD_STRIDE + V24_1_METHOD_INDEX_OFFSET;
|
||||||
|
metadata[offset..offset + 4].copy_from_slice(&(protected as i32).to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut module = vec![0_u8; 0x1000];
|
||||||
|
module[0x100..0x104].copy_from_slice(&seed.to_le_bytes());
|
||||||
|
module[0x104..0x108].copy_from_slice(&RAW_VERSION_24.to_le_bytes());
|
||||||
|
module[0x3f8..0x3fc].copy_from_slice(&V24_1_SELECTOR.to_le_bytes());
|
||||||
|
module[0x3fc..0x400].copy_from_slice(&(active_count - 1).to_le_bytes());
|
||||||
|
for value in 0..EXCEPTION_COUNT as u32 {
|
||||||
|
let offset = 0x400 + value as usize * 4;
|
||||||
|
module[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
(metadata, module)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restores_v24_1_method_index_permutation() {
|
||||||
|
let (metadata, module) = fixture();
|
||||||
|
let (restored, report) = restore_method_indices_v24_1(&metadata, &module).unwrap();
|
||||||
|
assert_eq!(report.active_methods, 512);
|
||||||
|
assert_eq!(report.exception_count, 256);
|
||||||
|
assert_eq!(report.seed, "0x77700DCC");
|
||||||
|
let (offset, indices) = method_indices(&restored).unwrap();
|
||||||
|
assert_eq!(offset, 0x100);
|
||||||
|
assert!(is_complete_permutation(&indices));
|
||||||
|
for (method, value) in indices.into_iter().enumerate() {
|
||||||
|
assert_eq!(value, Some(method as u32));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_v24_1_metadata_is_idempotent() {
|
||||||
|
let (metadata, module) = fixture();
|
||||||
|
let (restored, _) = restore_method_indices_v24_1(&metadata, &module).unwrap();
|
||||||
|
let (again, report) = restore_method_indices_v24_1(&restored, &module).unwrap();
|
||||||
|
assert_eq!(again, restored);
|
||||||
|
assert_eq!(report.changed_indices, 0);
|
||||||
|
assert_eq!(report.seed, "clean");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unrelated_module() {
|
||||||
|
let (metadata, _) = fixture();
|
||||||
|
let error = restore_method_indices_v24_1(&metadata, &[0_u8; 0x1000]).unwrap_err();
|
||||||
|
assert_eq!(error, MethodIndexError::ProfileNotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
mod embedded;
|
mod embedded;
|
||||||
mod keystream;
|
mod keystream;
|
||||||
|
mod method_indices;
|
||||||
mod method_tokens;
|
mod method_tokens;
|
||||||
|
|
||||||
pub use embedded::{embedded_metadata_size, extract_embedded_metadata};
|
pub use embedded::{embedded_metadata_size, extract_embedded_metadata};
|
||||||
|
pub use method_indices::{MethodIndexError, MethodIndexReport, restore_method_indices_v24_1};
|
||||||
pub use method_tokens::{
|
pub use method_tokens::{
|
||||||
DEFAULT_METHOD_TOKEN_SEED, Error, ImageKeyDiscovery, Report, SeedDiscoveryReport,
|
DEFAULT_METHOD_TOKEN_SEED, Error, ImageKeyDiscovery, Report, SeedDiscoveryReport,
|
||||||
discover_method_token_seeds, restore_method_tokens,
|
discover_method_token_seeds, restore_method_tokens,
|
||||||
|
|||||||
Generated
+93
-70
@@ -4,9 +4,9 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aes"
|
name = "aes"
|
||||||
version = "0.9.3"
|
version = "0.9.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cipher",
|
"cipher",
|
||||||
"cpubits",
|
"cpubits",
|
||||||
@@ -21,9 +21,9 @@ 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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
@@ -42,9 +42,15 @@ 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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
@@ -58,9 +64,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[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",
|
||||||
@@ -101,9 +107,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.1"
|
version = "1.5.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
@@ -136,9 +142,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
@@ -196,8 +202,10 @@ 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]]
|
[[package]]
|
||||||
@@ -219,18 +227,18 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.14"
|
version = "0.4.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.1"
|
version = "2.14.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown",
|
"hashbrown",
|
||||||
@@ -251,9 +259,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.2.2"
|
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 = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
@@ -266,9 +274,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.104"
|
version = "0.3.107"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -277,9 +285,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"
|
||||||
@@ -289,9 +297,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.34"
|
version = "0.4.35"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
@@ -301,13 +309,25 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
version = "0.9.11"
|
version = "0.9.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"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"
|
||||||
@@ -364,9 +384,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",
|
||||||
@@ -407,12 +427,12 @@ checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-crypto"
|
name = "senbei-crypto"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -420,7 +440,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-elf"
|
name = "senbei-elf"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"goblin",
|
"goblin",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -428,7 +448,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-engine"
|
name = "senbei-engine"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"senbei-crypto",
|
"senbei-crypto",
|
||||||
@@ -443,7 +463,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-io"
|
name = "senbei-io"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
@@ -464,7 +484,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-metadata"
|
name = "senbei-metadata"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -472,14 +492,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-pe"
|
name = "senbei-pe"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "senbei-wasm"
|
name = "senbei-wasm"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"senbei-engine",
|
"senbei-engine",
|
||||||
@@ -515,14 +535,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.4",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.151"
|
version = "1.0.154"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -533,9 +553,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.11.0"
|
version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures",
|
||||||
@@ -567,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",
|
||||||
@@ -591,29 +611,29 @@ 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 = "typed-path"
|
name = "typed-path"
|
||||||
version = "0.12.3"
|
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 = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
@@ -623,15 +643,15 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
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"
|
||||||
@@ -651,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",
|
||||||
@@ -664,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",
|
||||||
@@ -674,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",
|
||||||
]
|
]
|
||||||
@@ -834,23 +853,27 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "8.6.0"
|
version = "9.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"getrandom",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
|
"libc",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
"nix",
|
||||||
"typed-path",
|
"typed-path",
|
||||||
|
"windows-sys",
|
||||||
"zopfli",
|
"zopfli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.7"
|
version = "0.6.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
@@ -860,9 +883,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zopfli"
|
name = "zopfli"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "senbei-wasm"
|
name = "senbei-wasm"
|
||||||
version = "1.3.0"
|
version = "1.3.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.98.1"
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user