mirror of
https://github.com/Momoko-Ayase/Senbei.git
synced 2026-11-04 10:31:29 -05:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0961d23538 | ||
|
|
b59d97c625 | ||
|
|
a380031567 | ||
|
|
22dd92982e | ||
|
|
303aaa6283 | ||
|
|
312a70b7c6 | ||
|
|
db2729737f | ||
|
|
3baf986a84 | ||
|
|
d16f1c9394 | ||
|
|
f5896b3428 | ||
|
|
dfff21604a | ||
|
|
366a19c2a1 | ||
|
|
ba1dacc0b2 | ||
|
|
31a0c4139c | ||
|
|
ea92d26d28 | ||
|
|
6e233bbf18 |
No files matched your search
@@ -28,7 +28,7 @@ The format crates and PE engine remain free of filesystem I/O. Native Android ex
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
Generated
+93
-70
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.3"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
@@ -21,9 +21,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -42,9 +42,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
@@ -58,9 +64,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.4"
|
||||
version = "0.16.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
|
||||
checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
@@ -91,9 +97,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
version = "1.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
@@ -126,9 +132,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
@@ -186,8 +192,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -209,18 +217,18 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.1"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
@@ -241,9 +249,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
@@ -256,9 +264,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
version = "0.3.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -267,9 +275,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
@@ -279,9 +287,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
@@ -291,13 +299,25 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -354,9 +374,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
@@ -397,12 +417,12 @@ checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "senbei-cli"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"senbei-engine",
|
||||
"senbei-io",
|
||||
@@ -413,7 +433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-crypto"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"thiserror",
|
||||
@@ -421,7 +441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-elf"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"goblin",
|
||||
"thiserror",
|
||||
@@ -429,7 +449,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-engine"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"memmap2",
|
||||
"senbei-crypto",
|
||||
@@ -444,7 +464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-io"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indicatif",
|
||||
@@ -465,7 +485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-metadata"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror",
|
||||
@@ -473,7 +493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-pe"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
@@ -505,14 +525,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -523,9 +543,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
@@ -557,9 +577,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -581,29 +601,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
@@ -613,15 +633,15 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "unit-prefix"
|
||||
@@ -641,12 +661,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
@@ -654,9 +673,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -664,22 +683,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -824,23 +843,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
version = "9.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom",
|
||||
"indexmap",
|
||||
"libc",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
@@ -850,9 +873,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@ exclude = ["senbei-wasm"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.98.1"
|
||||
rust-version = "1.99.1"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -39,7 +39,7 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_Console",
|
||||
"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-elf = { path = "senbei-elf" }
|
||||
senbei-engine = { path = "senbei-engine" }
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "1.98.1"
|
||||
channel = "1.99.1"
|
||||
# CI invokes rustfmt/clippy through the rustup shim with no setup action, so
|
||||
# the pinned toolchain must declare its components here — the runner images
|
||||
# only preinstall them for their default toolchain.
|
||||
|
||||
+178
-5
@@ -1,3 +1,4 @@
|
||||
use senbei_engine::android::ResourceLimits;
|
||||
use senbei_io::{job, pause, scan};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -10,6 +11,8 @@ fn main() -> std::process::ExitCode {
|
||||
let mut no_log = false;
|
||||
let mut verbose = false;
|
||||
let mut scan_all = false;
|
||||
let mut strict_integrity = false;
|
||||
let mut limits = ResourceLimits::default();
|
||||
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
@@ -26,6 +29,14 @@ fn main() -> std::process::ExitCode {
|
||||
"--no-pause" => no_pause = true,
|
||||
"--no-log" => no_log = 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() {
|
||||
Some(v) if !v.starts_with('-') => out = Some(v),
|
||||
_ => {
|
||||
@@ -60,16 +71,17 @@ fn main() -> std::process::ExitCode {
|
||||
let p = Path::new(&p);
|
||||
let out_path = out.as_deref().map(Path::new);
|
||||
let result = if p.is_dir() {
|
||||
job::run_folder_opts(
|
||||
job::run_folder_with_limits(
|
||||
p,
|
||||
out_path,
|
||||
quiet,
|
||||
verbose,
|
||||
no_log,
|
||||
scan_all || scan::scan_all_env(),
|
||||
&limits,
|
||||
)
|
||||
} 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 {
|
||||
Ok(summary) => {
|
||||
@@ -77,7 +89,7 @@ fn main() -> std::process::ExitCode {
|
||||
println!("{}", summary.line());
|
||||
println!("done in {} ms", summary.duration_ms);
|
||||
}
|
||||
if summary.errors > 0 { 1 } else { 0 }
|
||||
exit_code(&summary, strict_integrity)
|
||||
}
|
||||
Err(error) => {
|
||||
if quiet < 2 {
|
||||
@@ -90,12 +102,56 @@ fn main() -> std::process::ExitCode {
|
||||
};
|
||||
|
||||
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() {
|
||||
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!(
|
||||
" 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\
|
||||
\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;
|
||||
|
||||
pub use protector::{
|
||||
ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, ProtectedDescriptor,
|
||||
decode_container, gf32_mul_fixed, transform_segment,
|
||||
ContainerHeader, EncodedSegment, Error, HuffmanLzDecoder, Module9bConfig, OutputLimit,
|
||||
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>;
|
||||
|
||||
/// 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> {
|
||||
Err(Error::Invalid(message.into()))
|
||||
}
|
||||
@@ -417,7 +441,24 @@ impl HuffmanLzDecoder {
|
||||
|
||||
/// Decode one compressed writer payload to its exact expected size.
|
||||
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 bit_buffer = 0_u64;
|
||||
let mut available = 0_u8;
|
||||
@@ -548,7 +589,11 @@ pub fn transform_segment(
|
||||
aes_key: &[u8; 32],
|
||||
decrypt_aes: bool,
|
||||
) -> 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 left = 0xe34e_ac63_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.
|
||||
///
|
||||
/// 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(
|
||||
data: &[u8],
|
||||
config: &Module9bConfig,
|
||||
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>> {
|
||||
let header = ContainerHeader::parse(data, 0, config.container_seed)?;
|
||||
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}"
|
||||
));
|
||||
}
|
||||
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 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() {
|
||||
let start = header
|
||||
@@ -614,6 +690,9 @@ pub fn decode_container(
|
||||
.checked_add(encoded.offset as usize)
|
||||
.ok_or_else(|| Error::Invalid("encoded segment start overflow".to_owned()))?;
|
||||
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(
|
||||
encoded_data,
|
||||
config.container_seed,
|
||||
@@ -662,18 +741,28 @@ pub fn decode_container(
|
||||
));
|
||||
}
|
||||
let source = &transformed[data_cursor..encoded_end];
|
||||
let decoded = if encoded_size == output_size {
|
||||
None
|
||||
} else {
|
||||
Some(decoder.decode(source, output_size)?)
|
||||
};
|
||||
let decoded = decoded.as_deref().unwrap_or(source);
|
||||
if let Some(limit) = limit {
|
||||
limit.check(encoded_size, "writer source")?;
|
||||
limit.check(output_size, "writer destination")?;
|
||||
}
|
||||
let target = base_offset
|
||||
.checked_add(output_offset)
|
||||
.ok_or_else(|| Error::Invalid("writer target offset overflow".to_owned()))?;
|
||||
let target_end = target
|
||||
.checked_add(decoded.len())
|
||||
.checked_add(output_size)
|
||||
.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(|| {
|
||||
Error::Invalid(format!(
|
||||
"segment {segment_index} writer {writer_index} target is out of range"
|
||||
@@ -709,4 +798,262 @@ mod tests {
|
||||
fn descriptor_rejects_truncated_input() {
|
||||
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;
|
||||
}
|
||||
@@ -44,6 +44,8 @@ pub enum Error {
|
||||
},
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
#[error(transparent)]
|
||||
Limit(#[from] crate::android::LimitExceeded),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
|
||||
@@ -6,7 +6,7 @@ mod stage1;
|
||||
mod stream;
|
||||
|
||||
pub use error::Error;
|
||||
pub use pipeline::{ExtractOptions, extract_stage2};
|
||||
pub use probe::is_protected_libil2cpp;
|
||||
pub use pipeline::{ExtractOptions, extract_stage2, extract_stage2_with_limits};
|
||||
pub use probe::{is_protected_libil2cpp, probe_libil2cpp_with_limits};
|
||||
pub use report::ExtractionReport;
|
||||
pub use stage1::{DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
||||
@@ -3,7 +3,9 @@ use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
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 super::super::common;
|
||||
@@ -61,6 +63,13 @@ struct ArtifactSpec<'a> {
|
||||
classification: &'a str,
|
||||
}
|
||||
|
||||
struct DecodeBudget {
|
||||
limits: ResourceLimits,
|
||||
bytes: ByteBudget,
|
||||
records: EntryBudget,
|
||||
config: Module9bConfig,
|
||||
}
|
||||
|
||||
struct Extractor {
|
||||
output_dir: PathBuf,
|
||||
streams: Vec<StreamReport>,
|
||||
@@ -70,6 +79,20 @@ struct Extractor {
|
||||
}
|
||||
|
||||
pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
|
||||
extract_stage2_with_limits(options, &ResourceLimits::default())
|
||||
}
|
||||
|
||||
/// [`extract_stage2`] with explicit ceilings for decoded records and nesting.
|
||||
///
|
||||
/// `limits.max_entries` counts decoded stage-2 records. `limits.max_depth`
|
||||
/// counts nested interpreter streams, with the bootstrap stream at depth 0.
|
||||
/// `limits.max_entry_bytes` bounds each decoded container and its writers, and
|
||||
/// `limits.max_total_bytes` sums those decoded payloads for this call. Neither
|
||||
/// budget is a peak resident-memory measurement.
|
||||
pub fn extract_stage2_with_limits(
|
||||
options: &ExtractOptions,
|
||||
limits: &ResourceLimits,
|
||||
) -> Result<ExtractionReport> {
|
||||
let input_path = absolute(&options.input)?;
|
||||
let output_dir = absolute(&options.output_dir)?;
|
||||
if !input_path.is_file() {
|
||||
@@ -98,6 +121,10 @@ pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
|
||||
&input_path,
|
||||
options.outer_size,
|
||||
options.cipher_constant,
|
||||
// Stage 1 plaintext is the first thing this task retains. Its copy
|
||||
// is capped by both the single-entry ceiling and whatever remains of
|
||||
// the task total, which is the whole total at this point.
|
||||
Some(limits.max_entry_bytes.min(limits.max_total_bytes)),
|
||||
)?;
|
||||
if let Some(stage2_output) = &options.stage2_output {
|
||||
write_atomic(&absolute(stage2_output)?, &stage1.plaintext)?;
|
||||
@@ -119,13 +146,22 @@ pub fn extract_stage2(options: &ExtractOptions) -> Result<ExtractionReport> {
|
||||
registry: BTreeMap::new(),
|
||||
seen_streams: HashSet::new(),
|
||||
};
|
||||
let mut budget = DecodeBudget {
|
||||
limits: *limits,
|
||||
bytes: ByteBudget::new(limits),
|
||||
records: EntryBudget::new(limits),
|
||||
config: core_config,
|
||||
};
|
||||
budget
|
||||
.bytes
|
||||
.charge(u64::try_from(stage1.plaintext.len()).unwrap_or(u64::MAX))?;
|
||||
extractor.extract_stream(
|
||||
bootstrap,
|
||||
0xe2,
|
||||
0,
|
||||
None,
|
||||
Some(stage1.remaining_file_offset),
|
||||
core_config,
|
||||
&mut budget,
|
||||
)?;
|
||||
|
||||
let module_registry = extractor
|
||||
@@ -170,8 +206,9 @@ impl Extractor {
|
||||
depth: usize,
|
||||
parent: Option<StreamParent>,
|
||||
source_file_offset: Option<usize>,
|
||||
config: Module9bConfig,
|
||||
budget: &mut DecodeBudget,
|
||||
) -> Result<()> {
|
||||
budget.limits.check_depth(depth)?;
|
||||
let digest = sha256(stream);
|
||||
if !self.seen_streams.insert((stream_id, digest.clone())) {
|
||||
return Ok(());
|
||||
@@ -200,10 +237,15 @@ impl Extractor {
|
||||
"decoded_interpreter"
|
||||
},
|
||||
(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 modules_at_level = BTreeSet::new();
|
||||
|
||||
@@ -213,16 +255,24 @@ impl Extractor {
|
||||
let mut metadata_data = None;
|
||||
|
||||
if !record.direct() && record.image_size != 0 {
|
||||
// Charge the declared size before the decoder reserves it.
|
||||
// A metadata-only record is charged below, on the same rule.
|
||||
budget.bytes.charge(u64::from(record.image_size))?;
|
||||
let image_source = record_tail(stream, record.image_offset)?;
|
||||
let image = decode_container(image_source, &config, record.image_size as usize)
|
||||
.map_err(|source| Error::RecordDecode {
|
||||
depth,
|
||||
stream_id,
|
||||
record_index: record.index,
|
||||
command_id: record.command_id,
|
||||
part: "image decode",
|
||||
source,
|
||||
})?;
|
||||
let image = decode_container_with_limit(
|
||||
image_source,
|
||||
&budget.config,
|
||||
record.image_size as usize,
|
||||
budget.limits.output_limit(),
|
||||
)
|
||||
.map_err(|source| Error::RecordDecode {
|
||||
depth,
|
||||
stream_id,
|
||||
record_index: record.index,
|
||||
command_id: record.command_id,
|
||||
part: "image decode",
|
||||
source,
|
||||
})?;
|
||||
let classification = if record.metadata_size != 0 {
|
||||
"module_image"
|
||||
} else {
|
||||
@@ -243,17 +293,22 @@ impl Extractor {
|
||||
image_data = Some((image, artifact));
|
||||
}
|
||||
if record.metadata_size != 0 {
|
||||
budget.bytes.charge(u64::from(record.metadata_size))?;
|
||||
let metadata_source = record_tail(stream, record.metadata_offset)?;
|
||||
let metadata =
|
||||
decode_container(metadata_source, &config, record.metadata_size as usize)
|
||||
.map_err(|source| Error::RecordDecode {
|
||||
depth,
|
||||
stream_id,
|
||||
record_index: record.index,
|
||||
command_id: record.command_id,
|
||||
part: "metadata decode",
|
||||
source,
|
||||
})?;
|
||||
let metadata = decode_container_with_limit(
|
||||
metadata_source,
|
||||
&budget.config,
|
||||
record.metadata_size as usize,
|
||||
budget.limits.output_limit(),
|
||||
)
|
||||
.map_err(|source| Error::RecordDecode {
|
||||
depth,
|
||||
stream_id,
|
||||
record_index: record.index,
|
||||
command_id: record.command_id,
|
||||
part: "metadata decode",
|
||||
source,
|
||||
})?;
|
||||
let artifact = self.write_artifact(
|
||||
&record,
|
||||
depth,
|
||||
@@ -339,14 +394,17 @@ impl Extractor {
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
budget.limits.check_depth(depth + 1)?;
|
||||
let parent_config = std::mem::replace(&mut budget.config, interpreter_config);
|
||||
self.extract_stream(
|
||||
child_data,
|
||||
next_stream_id,
|
||||
depth + 1,
|
||||
Some(parent),
|
||||
None,
|
||||
interpreter_config,
|
||||
budget,
|
||||
)?;
|
||||
budget.config = parent_config;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -506,3 +564,197 @@ fn absolute(path: &Path) -> Result<PathBuf> {
|
||||
fn sha256(data: &[u8]) -> String {
|
||||
common::sha256(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod limit_tests {
|
||||
use super::*;
|
||||
use crate::android::LimitExceeded;
|
||||
|
||||
fn config() -> Module9bConfig {
|
||||
Module9bConfig {
|
||||
header_seed: 0,
|
||||
container_seed: 0,
|
||||
aes_key: [0; 32],
|
||||
skip_aes: true,
|
||||
schedule_offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn tight(entries: usize, depth: usize) -> ResourceLimits {
|
||||
ResourceLimits {
|
||||
max_entry_bytes: 1024,
|
||||
max_total_bytes: 1024,
|
||||
max_entries: entries,
|
||||
max_depth: depth,
|
||||
}
|
||||
}
|
||||
|
||||
/// One direct record whose payload is `child`.
|
||||
///
|
||||
/// The command is `stream_id + 0x10`, which is the id a nested stream uses
|
||||
/// for its interpreter. The payload begins immediately after the single
|
||||
/// descriptor, so parsing stops at that boundary.
|
||||
fn direct_stream(stream_id: u32, child: &[u8]) -> Vec<u8> {
|
||||
let key = stream_id.wrapping_mul(0x9d32_3cd7);
|
||||
let shift = stream_id & 7;
|
||||
let base = (key >> shift)
|
||||
.wrapping_add(0x5e72_7d74)
|
||||
.wrapping_add(key.wrapping_shl(stream_id & 0xb))
|
||||
.wrapping_add(0xf71e_3005);
|
||||
// record_state = plain1 + base, and plain1 is chosen so that state is
|
||||
// zero. That keeps the record cipher's index mask zero as well.
|
||||
let plain1 = 0_u32.wrapping_sub(base);
|
||||
let cipher0 =
|
||||
unmultiply(1_u32 ^ 0xebe8_1dba_u32.wrapping_add(base)).wrapping_sub(0xcbf0_c1d8);
|
||||
let cipher1 = unmultiply(plain1 ^ 0xebe8_1dba_u32.wrapping_mul(5).wrapping_add(base))
|
||||
.wrapping_sub(cipher0);
|
||||
let mut stream = Vec::new();
|
||||
stream.extend_from_slice(&cipher0.to_le_bytes());
|
||||
stream.extend_from_slice(&cipher1.to_le_bytes());
|
||||
// `command - 0x10` is the interpreter id a nested stream looks up.
|
||||
// Using `stream_id + 0x10` makes a lone record direct, because that
|
||||
// interpreter was not decoded from this same stream.
|
||||
let command = stream_id.wrapping_add(0x10);
|
||||
let words = [
|
||||
command,
|
||||
super::super::stream::DIRECT_FLAG,
|
||||
(8 + super::super::stream::RECORD_SIZE) as u32,
|
||||
child.len() as u32,
|
||||
0,
|
||||
0,
|
||||
command,
|
||||
0,
|
||||
0,
|
||||
];
|
||||
stream.extend_from_slice(&encrypt_record(&words));
|
||||
stream.extend_from_slice(child);
|
||||
stream
|
||||
}
|
||||
|
||||
fn encrypt_record(words: &[u32]) -> Vec<u8> {
|
||||
// Inverse of `decrypt_record` at record state zero: replay its
|
||||
// feedback from each ciphertext word, then undo the field multiply.
|
||||
let mut feedback = 0xf02f_7685_u32;
|
||||
let mut accumulator = 0x7993_4cf6_u32;
|
||||
let mut raw = vec![0_u8; super::super::stream::RECORD_SIZE];
|
||||
for (word_index, plain) in words.iter().copied().enumerate() {
|
||||
feedback = feedback.wrapping_mul(feedback);
|
||||
let mixed = plain
|
||||
.wrapping_sub(accumulator)
|
||||
.wrapping_add(0 >> ((word_index * 4 + 3) & 5));
|
||||
let cipher = unmultiply(mixed) ^ (feedback >> 3);
|
||||
raw[word_index * 4..word_index * 4 + 4].copy_from_slice(&cipher.to_le_bytes());
|
||||
accumulator = accumulator.wrapping_add(0xe64d_33d8);
|
||||
feedback = cipher;
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
fn unmultiply(encoded: u32) -> u32 {
|
||||
let mut multiplier = 0xdc49_cd06_u32;
|
||||
let mut value = encoded;
|
||||
let mut result = 0_u32;
|
||||
while multiplier != 0 {
|
||||
if multiplier & 1 != 0 {
|
||||
result ^= value;
|
||||
}
|
||||
let carry = value >> 31;
|
||||
value = value.wrapping_shl(1);
|
||||
if carry != 0 {
|
||||
value ^= 0x5793_57eb;
|
||||
}
|
||||
multiplier >>= 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn extractor() -> Extractor {
|
||||
Extractor {
|
||||
output_dir: PathBuf::from("."),
|
||||
streams: Vec::new(),
|
||||
artifacts: Vec::new(),
|
||||
registry: BTreeMap::new(),
|
||||
seen_streams: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn budget(entries: usize, depth: usize) -> DecodeBudget {
|
||||
let limits = tight(entries, depth);
|
||||
DecodeBudget {
|
||||
limits,
|
||||
bytes: ByteBudget::new(&limits),
|
||||
records: EntryBudget::new(&limits),
|
||||
config: config(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_budget_rejects_one_past_the_ceiling_and_allows_the_boundary() {
|
||||
let stream = direct_stream(0xe2, &[0]);
|
||||
let error = extractor()
|
||||
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget(0, 32))
|
||||
.expect_err("one record is already past a zero-record ceiling");
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
Error::Limit(LimitExceeded::Entries {
|
||||
requested: 1,
|
||||
limit: 0
|
||||
})
|
||||
),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
|
||||
let directory = tempfile::tempdir().expect("temporary output directory");
|
||||
let mut at_ceiling = extractor();
|
||||
at_ceiling.output_dir = directory.path().to_path_buf();
|
||||
at_ceiling
|
||||
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget(1, 0))
|
||||
.expect("one record at depth zero is the boundary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_one_past_the_ceiling_is_rejected_before_the_child_is_decoded() {
|
||||
let stream = direct_stream(0xe2, &[0]);
|
||||
let directory = tempfile::tempdir().expect("temporary output directory");
|
||||
let mut at_ceiling = extractor();
|
||||
at_ceiling.output_dir = directory.path().to_path_buf();
|
||||
at_ceiling
|
||||
.extract_stream(&stream, 0xe2, 32, None, None, &mut budget(1, 32))
|
||||
.expect("depth 32 is the nesting boundary");
|
||||
|
||||
let error = extractor()
|
||||
.extract_stream(&stream, 0xe2, 33, None, None, &mut budget(1, 32))
|
||||
.expect_err("depth 33 is rejected before the stream is parsed");
|
||||
match &error {
|
||||
Error::Limit(limit) => assert_eq!(
|
||||
limit,
|
||||
&LimitExceeded::Depth {
|
||||
requested: 33,
|
||||
limit: 32
|
||||
}
|
||||
),
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_payloads_share_one_task_budget() {
|
||||
let stream = direct_stream(0xe2, &[0]);
|
||||
let directory = tempfile::tempdir().expect("temporary output directory");
|
||||
let mut budget = budget(1, 0);
|
||||
budget.limits.max_total_bytes = 1;
|
||||
budget.bytes = ByteBudget::new(&budget.limits);
|
||||
// A direct record has no decoded container, so it does not charge the
|
||||
// byte budget. Seed the only allowed byte to prove the budget is the
|
||||
// one the caller passed in and is not reset per stream.
|
||||
budget.bytes.charge(1).unwrap();
|
||||
let mut extractor = extractor();
|
||||
extractor.output_dir = directory.path().to_path_buf();
|
||||
extractor
|
||||
.extract_stream(&stream, 0xe2, 0, None, None, &mut budget)
|
||||
.unwrap();
|
||||
assert_eq!(budget.bytes.used(), 1);
|
||||
assert!(budget.bytes.charge(1024).is_err());
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,132 @@ use std::path::Path;
|
||||
|
||||
use senbei_crypto::android::Module9bConfig;
|
||||
|
||||
use super::error::Error;
|
||||
use super::stage1::{self, DEFAULT_CIPHER_CONSTANT, DEFAULT_OUTER_SIZE};
|
||||
use crate::android::ResourceLimits;
|
||||
|
||||
/// Return whether `data` has a supported protected AArch64 IL2CPP layout.
|
||||
///
|
||||
/// This uses the default ceilings. A size past those ceilings is reported as
|
||||
/// not protected, matching a scan that has not opted into a custom limit.
|
||||
/// [`probe_libil2cpp_with_limits`] returns that limit error instead.
|
||||
#[must_use]
|
||||
pub fn is_protected_libil2cpp(data: &[u8]) -> bool {
|
||||
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,
|
||||
Path::new("<probe>"),
|
||||
DEFAULT_OUTER_SIZE,
|
||||
DEFAULT_CIPHER_CONSTANT,
|
||||
) else {
|
||||
return false;
|
||||
Some(limits.max_entry_bytes.min(limits.max_total_bytes)),
|
||||
) {
|
||||
Ok(stage1) => stage1,
|
||||
Err(Error::Limit(limit)) => return Err(Error::Limit(limit)),
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
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,
|
||||
outer_size: usize,
|
||||
cipher_constant: u32,
|
||||
max_payload_bytes: Option<u64>,
|
||||
) -> Result<Stage1Result> {
|
||||
let elf = parse(data).map_err(|source: ElfError| Error::Elf {
|
||||
path: path.to_path_buf(),
|
||||
@@ -133,6 +134,15 @@ pub(crate) fn inspect(
|
||||
"Stage 1 payload size must be nonzero and word aligned: 0x{payload_size:x}"
|
||||
));
|
||||
}
|
||||
if let Some(limit) = max_payload_bytes {
|
||||
let declared = u64::try_from(payload_size)
|
||||
.map_err(|_| Error::Invalid("Stage 1 payload size exceeds u64".to_owned()))?;
|
||||
crate::android::ResourceLimits {
|
||||
max_entry_bytes: limit,
|
||||
..crate::android::ResourceLimits::default()
|
||||
}
|
||||
.check_entry_bytes(declared)?;
|
||||
}
|
||||
let entry_offset = usize::try_from(header.entry_offset)
|
||||
.map_err(|_| Error::Invalid("Stage 1 entry offset exceeds usize".to_owned()))?;
|
||||
if entry_offset >= payload_size {
|
||||
@@ -190,7 +200,11 @@ fn decrypt_words(ciphertext: &[u8], key: u32, constant: u32) -> Result<Vec<u8>>
|
||||
if !ciphertext.len().is_multiple_of(4) {
|
||||
return invalid("Stage 1 word cipher input is not 4-byte aligned");
|
||||
}
|
||||
let mut plaintext = 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() {
|
||||
let index = u32::try_from(index)
|
||||
.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 extract;
|
||||
mod limits;
|
||||
mod restore;
|
||||
|
||||
pub use extract::{
|
||||
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};
|
||||
@@ -16,6 +16,7 @@ fn wanted_id(command_id: u32) -> bool {
|
||||
pub(crate) struct Artifact {
|
||||
pub path: PathBuf,
|
||||
pub size: u64,
|
||||
pub entry_offset: Option<u32>,
|
||||
}
|
||||
|
||||
pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact>> {
|
||||
@@ -47,6 +48,10 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
|
||||
Artifact {
|
||||
path: root.join(path),
|
||||
size,
|
||||
entry_offset: item
|
||||
.get("entry_offset")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok()),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -79,6 +84,10 @@ pub(crate) fn load_artifacts(index_path: &Path) -> Result<BTreeMap<u32, Artifact
|
||||
Artifact {
|
||||
path: root.join(path),
|
||||
size,
|
||||
entry_offset: record
|
||||
.get("entry_offset")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
#[error(transparent)]
|
||||
Limit(#[from] crate::android::LimitExceeded),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
mod artifact;
|
||||
mod constructors;
|
||||
mod error;
|
||||
mod pipeline;
|
||||
|
||||
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.
|
||||
|
||||
pub mod android;
|
||||
mod panic_capture;
|
||||
pub mod windows;
|
||||
|
||||
pub use panic_capture::catch_unwind_quiet;
|
||||
|
||||
pub use windows::{
|
||||
Detected, IntegrityReport, Kind, UnpackError, check_integrity, detect, unpack_auto,
|
||||
unpack_auto_v, unpack_dll, unpack_dll_v, unpack_exe, unpack_exe_v,
|
||||
|
||||
@@ -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)]
|
||||
#[non_exhaustive]
|
||||
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})")]
|
||||
InputTooShort { actual: usize, required: usize },
|
||||
|
||||
@@ -214,6 +224,9 @@ pub enum UnpackError {
|
||||
exe: Box<UnpackError>,
|
||||
},
|
||||
|
||||
#[error("structurally invalid layout candidate: {}", issues.join("; "))]
|
||||
InvalidLayoutCandidate { issues: Vec<String> },
|
||||
|
||||
#[error(
|
||||
"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
|
||||
fn decrypt_data3(&mut self, pos: u32, key: u32, shift: u32) {
|
||||
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.decrypt_size = u.info[6].wrapping_sub(u.info[3]).wrapping_add(8192);
|
||||
if verbose {
|
||||
println!("[2/9] Decrypting payload...");
|
||||
}
|
||||
u.decrypt_data2();
|
||||
|
||||
{
|
||||
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]);
|
||||
let pe32_table = u.load_payload();
|
||||
|
||||
// PE32 (32-bit) images use an entirely different config layout and
|
||||
// final-output transform than PE32+ (64-bit). Dispatch here, after the
|
||||
// shared header/payload setup (the PE32 branch follows the common
|
||||
// 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 {
|
||||
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
|
||||
@@ -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)]
|
||||
mod error_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -23,10 +23,153 @@ fn stage_key_rounds(data: &[u8], table: u32, slots: usize) -> Result<u32, Unpack
|
||||
}
|
||||
|
||||
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,
|
||||
/// payload decrypt, raw copy, header restore) has already run in `run()`
|
||||
/// 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 info3 = info[3];
|
||||
|
||||
@@ -43,94 +186,21 @@ impl<'a> Unpacker<'a> {
|
||||
key
|
||||
};
|
||||
|
||||
// ---- Locate tbl in shell ----
|
||||
let tbl =
|
||||
layout::find_tbl_pe32(&self.decompressed, &info).ok_or(UnpackError::Pe32TblNotFound)?;
|
||||
// ---- Validated shell table ----
|
||||
let compact = get_u32(&self.decompressed, tbl.wrapping_add(0x78)) == info[6];
|
||||
let stage_field = if compact { 0x88 } else { 0x98 };
|
||||
if verbose {
|
||||
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 ----
|
||||
//
|
||||
// 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_pair = tbl.wrapping_add(stage_field);
|
||||
let ss = get_u32(&self.decompressed, ss_pair);
|
||||
let ss_size = get_u32(&self.decompressed, ss_pair.wrapping_add(4));
|
||||
let ss_shift = ss_size.wrapping_sub(0xBC0);
|
||||
// Back up the SecondStage ciphertext so a failed trial can be retried.
|
||||
let ss_lo = ss as usize;
|
||||
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);
|
||||
}
|
||||
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 header_checksum = self.decrypt_pe32_second_stage(tbl, pe_off)?;
|
||||
if verbose {
|
||||
println!(
|
||||
" ss = 0x{:08X}, size = 0x{:X}, shift = 0x{:X}",
|
||||
@@ -139,13 +209,12 @@ impl<'a> Unpacker<'a> {
|
||||
}
|
||||
|
||||
// ---- PE32 fixed offsets ----
|
||||
let third_key_off = 0x968u32.wrapping_add(ss_shift);
|
||||
let forth_key_off = 0x964u32.wrapping_add(ss_shift);
|
||||
let cs_base_off = 0x96Cu32.wrapping_add(ss_shift);
|
||||
let dp_base_off = 0xA9Cu32.wrapping_add(ss_shift);
|
||||
let third_key_off = (if compact { 0x950u32 } else { 0x968 }).wrapping_add(ss_shift);
|
||||
let forth_key_off = (if compact { 0x94Cu32 } else { 0x964 }).wrapping_add(ss_shift);
|
||||
let cs_base_off = (if compact { 0x954u32 } else { 0x96C }).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) ----
|
||||
let third_pair_off = 0xB8Cu32.wrapping_add(ss_shift);
|
||||
let key = get_u32(&self.decompressed, ss.wrapping_add(third_key_off));
|
||||
let pair_addr = ss.wrapping_add(third_pair_off);
|
||||
let ts_addr = get_u32(&self.decompressed, pair_addr);
|
||||
@@ -238,7 +307,7 @@ impl<'a> Unpacker<'a> {
|
||||
}
|
||||
|
||||
// ---- 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 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);
|
||||
@@ -298,7 +367,9 @@ impl<'a> Unpacker<'a> {
|
||||
);
|
||||
let seven_key = !get_u32(
|
||||
&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;
|
||||
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] ----
|
||||
let test_val = get_u32(&self.decompressed, info3.wrapping_add(0x10));
|
||||
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);
|
||||
}
|
||||
let (metadata_ep, metadata_dirs) = self.read_pe32_metadata(compact);
|
||||
|
||||
// ---- Zero-out list (runs BEFORE decompression) ----
|
||||
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 s = export_file_off 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) ----
|
||||
@@ -1149,6 +1207,56 @@ impl<'a> Unpacker<'a> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn check_metadata(compact: bool, layout_b: bool) {
|
||||
let base = 0x1000u32;
|
||||
let (offset, size, ep_offset, dirs_offset) = if layout_b {
|
||||
if compact {
|
||||
(0x10, 0x290, 0x30, 0x40)
|
||||
} else {
|
||||
(0x10, 0x290, 0x20, 0x30)
|
||||
}
|
||||
} else {
|
||||
(0x40, 144, 0x40, 0x50)
|
||||
};
|
||||
let mut data = vec![0u8; 0x2000];
|
||||
let ep = 0x123456;
|
||||
let dirs = std::array::from_fn::<_, 128, _>(|i| i as u8 ^ 0x5A);
|
||||
write_u32(&mut data, base + ep_offset, ep);
|
||||
data[(base + dirs_offset) as usize..(base + dirs_offset + 128) as usize]
|
||||
.copy_from_slice(&dirs);
|
||||
for i in 0..size {
|
||||
let key = (base + offset + i) as u8;
|
||||
let byte = &mut data[(base + offset + i) as usize];
|
||||
let mixed = (byte.rotate_right(2) ^ key).rotate_right(2) ^ key.wrapping_add(1);
|
||||
*byte = mixed.rotate_right(2);
|
||||
}
|
||||
assert_eq!(get_u32(&data, base + 0x10) > 0x10000, layout_b);
|
||||
let backup = data.clone();
|
||||
let mut unpacker = Unpacker {
|
||||
file_data: &[],
|
||||
decompressed: data,
|
||||
info: [0, 0, 0, base, 0, 0, 0, 0],
|
||||
key_offsets: [0; 4],
|
||||
decrypt_size: 0,
|
||||
};
|
||||
assert_eq!(unpacker.read_pe32_metadata(compact), (ep, dirs));
|
||||
assert_eq!(
|
||||
unpacker.decompressed, backup,
|
||||
"metadata reads must be nondestructive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pe32_compact_metadata_restores_entry_point_and_directories() {
|
||||
check_metadata(true, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pe32_standard_metadata_preserves_both_layouts() {
|
||||
check_metadata(false, false);
|
||||
check_metadata(false, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_key_rounds_follow_active_descriptor_prefix() {
|
||||
for (sizes, expected) in [
|
||||
|
||||
@@ -13,11 +13,44 @@
|
||||
//!
|
||||
//! 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.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IntegrityReport {
|
||||
/// Each entry describes one detected defect. Empty means no defect found.
|
||||
/// Order and wording match [`IntegrityReport::findings`].
|
||||
pub issues: Vec<String>,
|
||||
/// The same defects as `issues`, each tagged fatal or warning.
|
||||
pub findings: Vec<IntegrityFinding>,
|
||||
}
|
||||
|
||||
impl IntegrityReport {
|
||||
@@ -25,6 +58,23 @@ impl IntegrityReport {
|
||||
pub fn ok(&self) -> bool {
|
||||
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
|
||||
@@ -92,9 +142,12 @@ fn check_common_entry_branches(
|
||||
.ok()
|
||||
.is_some_and(|rva| is_executable_rva(secs, rva));
|
||||
if !valid {
|
||||
report.issues.push(format!(
|
||||
"entry point {name} target 0x{target:X} is outside executable sections (DD8 selection is likely wrong)"
|
||||
));
|
||||
report.push(
|
||||
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 ---------------------------------------------------
|
||||
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
|
||||
}
|
||||
let pe_off = match rd_u32(out, 0x3C) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
r.issues.push("truncated DOS header (no e_lfanew)".into());
|
||||
r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
"truncated DOS header (no e_lfanew)",
|
||||
);
|
||||
return r;
|
||||
}
|
||||
};
|
||||
if rd_u32(out, pe_off) != Some(0x0000_4550) {
|
||||
r.issues
|
||||
.push(format!("missing 'PE\\0\\0' signature at 0x{pe_off:X}"));
|
||||
r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!("missing 'PE\\0\\0' signature at 0x{pe_off:X}"),
|
||||
);
|
||||
return r;
|
||||
}
|
||||
|
||||
let num_sections = match rd_u16(out, pe_off.wrapping_add(6)) {
|
||||
Some(v) => v as u32,
|
||||
None => {
|
||||
r.issues.push("truncated COFF header".into());
|
||||
r.push(IntegritySeverity::Fatal, "truncated COFF header");
|
||||
return r;
|
||||
}
|
||||
};
|
||||
@@ -134,7 +192,7 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
||||
let magic = match rd_u16(out, opt) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
r.issues.push("truncated optional header".into());
|
||||
r.push(IntegritySeverity::Fatal, "truncated optional header");
|
||||
return r;
|
||||
}
|
||||
};
|
||||
@@ -142,36 +200,44 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
||||
0x20B => true,
|
||||
0x10B => false,
|
||||
other => {
|
||||
r.issues
|
||||
.push(format!("bad optional-header magic 0x{other:X}"));
|
||||
r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!("bad optional-header magic 0x{other:X}"),
|
||||
);
|
||||
return r;
|
||||
}
|
||||
};
|
||||
|
||||
if num_sections == 0 || num_sections > 96 {
|
||||
r.issues
|
||||
.push(format!("implausible section count {num_sections}"));
|
||||
r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!("implausible section count {num_sections}"),
|
||||
);
|
||||
}
|
||||
|
||||
let size_of_image = rd_u32(out, pe_off.wrapping_add(80)).unwrap_or(0);
|
||||
if size_of_image == 0 {
|
||||
r.issues.push("SizeOfImage is zero".into());
|
||||
r.push(IntegritySeverity::Fatal, "SizeOfImage is zero");
|
||||
}
|
||||
|
||||
// --- Section table ------------------------------------------------------
|
||||
let headers = match senbei_pe::parse(out) {
|
||||
Ok(headers) => headers,
|
||||
Err(_) => {
|
||||
r.issues
|
||||
.push("section table extends past end of file".into());
|
||||
r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
"section table extends past end of file",
|
||||
);
|
||||
return r;
|
||||
}
|
||||
};
|
||||
let parsed_sections = match senbei_pe::sections(out, headers) {
|
||||
Ok(sections) => sections,
|
||||
Err(_) => {
|
||||
r.issues
|
||||
.push("section table extends past end of file".into());
|
||||
r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
"section table extends past end of file",
|
||||
);
|
||||
return r;
|
||||
}
|
||||
};
|
||||
@@ -182,10 +248,13 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
||||
if section.raw_size != 0 {
|
||||
let end = section.raw_offset.wrapping_add(section.raw_size) as usize;
|
||||
if end > file_len {
|
||||
r.issues.push(format!(
|
||||
"section #{i} raw data [0x{:X}..0x{end:X}] exceeds file size 0x{file_len:X}",
|
||||
section.raw_offset
|
||||
));
|
||||
r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!(
|
||||
"section #{i} raw data [0x{:X}..0x{end:X}] exceeds file size 0x{file_len:X}",
|
||||
section.raw_offset
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
section
|
||||
@@ -222,9 +291,13 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
||||
if is_dll && !is_managed {
|
||||
let reloc_rva = rd_u32(out, dd_base.wrapping_add(5 * 8)).unwrap_or(0);
|
||||
if reloc_rva == 0 {
|
||||
r.issues.push(
|
||||
"native DLL has no base relocation table (DD[5] is zero) — will crash when loaded at a non-preferred base"
|
||||
.into(),
|
||||
// Warning, not fatal: a native DLL can ship without base relocations
|
||||
// (`IMAGE_FILE_RELOCS_STRIPPED`, or a module that is only ever loaded
|
||||
// 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 {
|
||||
// A DLL may legitimately have no entry point; an EXE never does.
|
||||
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 {
|
||||
match rva_to_off(out, headers, ep, 16) {
|
||||
None => {
|
||||
r.issues.push(format!(
|
||||
"entry point RVA 0x{ep:X} does not map into any section"
|
||||
));
|
||||
r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!("entry point RVA 0x{ep:X} does not map into any section"),
|
||||
);
|
||||
}
|
||||
Some(off) => {
|
||||
let stub = &out[off as usize..off as usize + 16];
|
||||
if stub.iter().all(|&b| b == 0) {
|
||||
r.issues.push(format!(
|
||||
"entry point at RVA 0x{ep:X} is all zeros (stub not recovered)"
|
||||
));
|
||||
// Warning: the entry maps and is executable, but its bytes
|
||||
// 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) {
|
||||
// 16 bytes of int3 padding where the entry stub should be:
|
||||
// the stub region was never recovered, the loader walks
|
||||
// straight into a debug-break wall.
|
||||
r.issues.push(format!(
|
||||
"entry point at RVA 0x{ep:X} is all int3 padding (stub not recovered)"
|
||||
));
|
||||
// straight into a debug-break wall. Same severity as a
|
||||
// zeroed stub: the image is mapped, the bytes look unrestored.
|
||||
r.push(
|
||||
IntegritySeverity::Warning,
|
||||
format!(
|
||||
"entry point at RVA 0x{ep:X} is all int3 padding (stub not recovered)"
|
||||
),
|
||||
);
|
||||
}
|
||||
// The entry must live in an executable section.
|
||||
let exec = secs.iter().any(|s| {
|
||||
@@ -268,9 +351,10 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
||||
&& (s.characteristics & 0x2000_0000) != 0
|
||||
});
|
||||
if !exec {
|
||||
r.issues.push(format!(
|
||||
"entry point RVA 0x{ep:X} is not in an executable section"
|
||||
));
|
||||
r.push(
|
||||
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) {
|
||||
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);
|
||||
if imp_rva != 0 {
|
||||
match rva_to_off(out, headers, imp_rva, 20) {
|
||||
None => r.issues.push(format!(
|
||||
"import directory RVA 0x{imp_rva:X} does not map into any section"
|
||||
)),
|
||||
None => r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!("import directory RVA 0x{imp_rva:X} does not map into any section"),
|
||||
),
|
||||
Some(desc_off) => {
|
||||
// 256 descriptors is far beyond any real import table; the
|
||||
// cap keeps a corrupt, never-null table from walking on.
|
||||
@@ -308,14 +393,24 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
||||
break;
|
||||
}
|
||||
match rva_to_off(out, headers, name_rva, 1) {
|
||||
None => r.issues.push(format!(
|
||||
"import descriptor {i} DLL name RVA 0x{name_rva:X} does not map into any section"
|
||||
)),
|
||||
None => r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!(
|
||||
"import descriptor {i} DLL name RVA 0x{name_rva:X} does not map into any section"
|
||||
),
|
||||
),
|
||||
Some(noff) => {
|
||||
if !looks_like_dll_name(out, noff) {
|
||||
r.issues.push(format!(
|
||||
"import descriptor {i} DLL name at RVA 0x{name_rva:X} is not readable ASCII (imports left encrypted?)"
|
||||
));
|
||||
// Warning: the name is inside the image, so
|
||||
// the directory is structurally present.
|
||||
// Unreadable bytes are the "left encrypted"
|
||||
// suspect signal, not an unmapped PE.
|
||||
r.push(
|
||||
IntegritySeverity::Warning,
|
||||
format!(
|
||||
"import descriptor {i} DLL name at RVA 0x{name_rva:X} is not readable ASCII (imports left encrypted?)"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,29 +431,39 @@ pub fn check(out: &[u8]) -> IntegrityReport {
|
||||
// begins with the "BSJB" signature.
|
||||
if is_managed {
|
||||
match rva_to_off(out, headers, clr_rva, 0x48) {
|
||||
None => r.issues.push(format!(
|
||||
"CLR (COR20) directory RVA 0x{clr_rva:X} does not map into any section"
|
||||
)),
|
||||
None => r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!("CLR (COR20) directory RVA 0x{clr_rva:X} does not map into any section"),
|
||||
),
|
||||
Some(coff) => {
|
||||
let cb = rd_u32(out, coff).unwrap_or(0);
|
||||
if cb != 0x48 {
|
||||
r.issues.push(format!(
|
||||
"COR20 header at RVA 0x{clr_rva:X} has cb 0x{cb:X} (expected 0x48) — CLR header corrupt"
|
||||
));
|
||||
r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!(
|
||||
"COR20 header at RVA 0x{clr_rva:X} has cb 0x{cb:X} (expected 0x48) — CLR header corrupt"
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// MetaData RVA/size live at COR20 + 0x08 / + 0x0C.
|
||||
let md_rva = rd_u32(out, coff.wrapping_add(8)).unwrap_or(0);
|
||||
if md_rva != 0 {
|
||||
match rva_to_off(out, headers, md_rva, 4) {
|
||||
None => r.issues.push(format!(
|
||||
"CLR MetaData RVA 0x{md_rva:X} does not map into any section"
|
||||
)),
|
||||
None => r.push(
|
||||
IntegritySeverity::Fatal,
|
||||
format!(
|
||||
"CLR MetaData RVA 0x{md_rva:X} does not map into any section"
|
||||
),
|
||||
),
|
||||
Some(moff) => {
|
||||
let sig = out.get(moff as usize..moff as usize + 4);
|
||||
if sig != Some(b"BSJB") {
|
||||
r.issues.push(format!(
|
||||
"CLR MetaData at RVA 0x{md_rva:X} lacks 'BSJB' signature (metadata corrupt — managed image will not load)"
|
||||
));
|
||||
r.push(
|
||||
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 discovery::{
|
||||
discover_eighth_slots, find_bytecode_offset, find_lfsr_block, find_str_pos, find_tbl_pe32,
|
||||
find_v_after_pad, find_v4_offset, get_string_to_null, section_name, trial_decrypt5_u32,
|
||||
discover_eighth_slots, find_bytecode_offset, find_lfsr_block, find_str_pos,
|
||||
find_tbl_pe32_candidates, find_v_after_pad, find_v4_offset, get_string_to_null, section_name,
|
||||
trial_decrypt5_u32,
|
||||
};
|
||||
pub(super) use image::{
|
||||
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 shell-table locator. Walks the shell region (`info[6]`) for a dword
|
||||
/// equal to `info[6]` followed by a plausible shell size, returning the table
|
||||
/// base (`candidate = off - 0x88`) when `candidate+0x58` holds a valid pointer.
|
||||
pub fn find_tbl_pe32(data: &[u8], info: &[u32; 8]) -> Option<u32> {
|
||||
let shell = info[6];
|
||||
if (data.len() as u64) < 0x100 {
|
||||
return None;
|
||||
}
|
||||
let hi = (shell as u64)
|
||||
.saturating_add(0x3000)
|
||||
.min(data.len() as u64 - 0x100) as u32;
|
||||
let mut off = shell;
|
||||
while off < hi {
|
||||
if off as usize + 8 <= data.len() {
|
||||
let candidate = off.wrapping_sub(0x88);
|
||||
if candidate >= shell && get_u32(data, off) == info[6] {
|
||||
let shell_size_val = get_u32(data, off.wrapping_add(4));
|
||||
if shell_size_val > 0x1000 && shell_size_val < 0x100000 {
|
||||
let v58_off = candidate.wrapping_add(0x58);
|
||||
if (v58_off as usize + 4) <= data.len() {
|
||||
let v58 = get_u32(data, v58_off);
|
||||
if v58 > 0 && (v58 as usize) < data.len() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// PE32 shell-table locator. The compact layout moves the shell descriptor
|
||||
/// from +0x88 to +0x78 and the header-checksum list from +0x58 to +0x50.
|
||||
/// Return structurally plausible candidates; the pipeline must trial-decrypt
|
||||
/// their second stages before selecting a table.
|
||||
pub fn find_tbl_pe32_candidates(data: &[u8], info: &[u32; 8]) -> Vec<u32> {
|
||||
let mut tables = Vec::new();
|
||||
let shell = info[6] as usize;
|
||||
let hi = shell.saturating_add(0x3000).min(data.len());
|
||||
for off in (shell..hi.saturating_sub(8)).step_by(4) {
|
||||
if get_u32(data, off as u32) != info[6] {
|
||||
continue;
|
||||
}
|
||||
let shell_size = get_u32(data, off as u32 + 4) as usize;
|
||||
if !(0x1001..0x100000).contains(&shell_size)
|
||||
|| shell
|
||||
.checked_add(shell_size)
|
||||
.is_none_or(|end| end > data.len())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (shell_field, header_field) in [(0x88, 0x58), (0x78, 0x50)] {
|
||||
let Some(candidate) = off.checked_sub(shell_field).filter(|&base| base >= shell) else {
|
||||
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.
|
||||
@@ -449,6 +466,57 @@ pub fn discover_eighth_slots(
|
||||
mod tests {
|
||||
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
|
||||
/// sits PAST `len` but within `len*2`. Assert that the smaller window misses
|
||||
/// 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 sec_table = opt_hdr.wrapping_add(opt_hdr_size);
|
||||
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 {
|
||||
sec_off: u32,
|
||||
@@ -365,11 +372,21 @@ pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>
|
||||
break;
|
||||
}
|
||||
}
|
||||
let meaningful = if last_nonzero >= 0 {
|
||||
let mut meaningful = if last_nonzero >= 0 {
|
||||
(last_nonzero + 1) as u32
|
||||
} else {
|
||||
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 {
|
||||
align_up_u32(meaningful, FILE_ALIGNMENT)
|
||||
} else {
|
||||
@@ -433,6 +450,45 @@ pub fn compact_memory_image_to_pe(data: &[u8], pe_header: u32) -> Option<Vec<u8>
|
||||
mod tests {
|
||||
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
|
||||
/// bail instead of building .kmiat at RVA 0 — the old code zeroed
|
||||
/// `[0, 0x7000)`, wiping the DOS/PE headers, and returned the broken image
|
||||
|
||||
+378
-151
@@ -8,14 +8,16 @@ mod layout;
|
||||
pub(crate) mod parallel;
|
||||
|
||||
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 dll::{unpack_dll, unpack_dll_v};
|
||||
pub use error::*;
|
||||
pub use exe::{unpack as unpack_exe, unpack_v as unpack_exe_v};
|
||||
pub use integrity::{IntegrityReport, check as check_integrity};
|
||||
pub use integrity::{
|
||||
IntegrityFinding, IntegrityReport, IntegritySeverity, check as check_integrity,
|
||||
};
|
||||
|
||||
/// Maximum plausible PE `SizeOfImage` we are willing to allocate a zero buffer
|
||||
/// for. Guards against a corrupt/crafted header requesting a multi-gigabyte
|
||||
@@ -24,130 +26,6 @@ pub use integrity::{IntegrityReport, check as check_integrity};
|
||||
/// Real protected binaries are far below this.
|
||||
pub(crate) const MAX_IMAGE_SIZE: u64 = senbei_crypto::MAX_IMAGE_SIZE;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PanicCapture(Arc<Mutex<Option<PanicDetails>>>);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PanicDetails {
|
||||
message: String,
|
||||
file: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIVE_PANIC_CAPTURE: RefCell<Option<PanicCapture>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct PanicCaptureGuard(Option<PanicCapture>);
|
||||
|
||||
impl Drop for PanicCaptureGuard {
|
||||
fn drop(&mut self) {
|
||||
ACTIVE_PANIC_CAPTURE.with(|slot| {
|
||||
slot.replace(self.0.take());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PanicCapture {
|
||||
fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(None)))
|
||||
}
|
||||
|
||||
fn record(&self, info: &std::panic::PanicHookInfo<'_>) {
|
||||
let location = info.location();
|
||||
let details = PanicDetails {
|
||||
message: panic_message(info.payload()),
|
||||
file: location
|
||||
.map(|value| value.file().to_owned())
|
||||
.unwrap_or_else(|| "<unknown>".to_owned()),
|
||||
line: location.map_or(0, std::panic::Location::line),
|
||||
column: location.map_or(0, std::panic::Location::column),
|
||||
};
|
||||
let mut captured = self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if captured.is_none() {
|
||||
*captured = Some(details);
|
||||
}
|
||||
}
|
||||
|
||||
fn into_error(self, payload: &(dyn std::any::Any + Send)) -> UnpackError {
|
||||
let details = self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
.unwrap_or_else(|| PanicDetails {
|
||||
message: panic_message(payload),
|
||||
file: "<unknown>".to_owned(),
|
||||
line: 0,
|
||||
column: 0,
|
||||
});
|
||||
UnpackError::InternalPanic {
|
||||
message: details.message,
|
||||
file: details.file,
|
||||
line: details.line,
|
||||
column: details.column,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_from(&self, other: &Self) {
|
||||
let details = other
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
let Some(details) = details else { return };
|
||||
let mut captured = self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if captured.is_none() {
|
||||
*captured = Some(details);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
if let Some(message) = payload.downcast_ref::<&str>() {
|
||||
(*message).to_owned()
|
||||
} else if let Some(message) = payload.downcast_ref::<String>() {
|
||||
message.clone()
|
||||
} else {
|
||||
"non-string panic payload".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn install_panic_capture_hook() {
|
||||
static INSTALL: std::sync::Once = std::sync::Once::new();
|
||||
INSTALL.call_once(|| {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let capture = ACTIVE_PANIC_CAPTURE
|
||||
.try_with(|slot| slot.borrow().clone())
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(capture) = capture {
|
||||
capture.record(info);
|
||||
} else {
|
||||
previous(info);
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn current_panic_capture() -> Option<PanicCapture> {
|
||||
ACTIVE_PANIC_CAPTURE.with(|slot| slot.borrow().clone())
|
||||
}
|
||||
|
||||
pub(crate) fn with_panic_capture<R>(capture: Option<PanicCapture>, f: impl FnOnce() -> R) -> R {
|
||||
let previous = ACTIVE_PANIC_CAPTURE.with(|slot| slot.replace(capture));
|
||||
let _guard = PanicCaptureGuard(previous);
|
||||
f()
|
||||
}
|
||||
|
||||
/// Run an unpack pipeline, converting any internal panic into a clean
|
||||
/// [`UnpackError::InternalPanic`] so the public API stays panic-free on any input
|
||||
/// (truncated/garbled files chase offsets out of bounds). The panic location and
|
||||
@@ -159,14 +37,22 @@ pub(crate) fn catch_unpack<F>(f: F) -> Result<Vec<u8>, UnpackError>
|
||||
where
|
||||
F: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
||||
{
|
||||
install_panic_capture_hook();
|
||||
crate::panic_capture::install_hook();
|
||||
let capture = PanicCapture::new();
|
||||
let r = with_panic_capture(Some(capture.clone()), || {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
|
||||
});
|
||||
match r {
|
||||
Ok(result) => result,
|
||||
Err(payload) => 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 out = match detected.kind {
|
||||
Kind::NativeExe | Kind::ManagedExe => unpack_exe_v(input, verbose)?,
|
||||
Kind::NativeDll | Kind::ManagedDll => {
|
||||
// Two Crackproof DLL layouts exist. The older one (the byte-identical
|
||||
// DLL goldens) follows the pipeline in `dll.rs`. Newer builds protect
|
||||
// DLLs with the EXE-style shell layout instead — `dll::unpack_dll`
|
||||
// cannot parse them and errors. Try the DLL pipeline first; on
|
||||
// failure, fall back to the EXE pipeline, which handles the new
|
||||
// layout (including managed-DLL CLR metadata restore). The DLL-first
|
||||
// order keeps the old-layout goldens byte-identical (the EXE
|
||||
// pipeline "succeeds" on them but with different bytes).
|
||||
match dll::unpack_dll_v(input, verbose) {
|
||||
Ok(out) => out,
|
||||
Err(dll_err) => match exe::unpack_v(input, verbose) {
|
||||
Ok(out) => out,
|
||||
Err(exe_err) => {
|
||||
return Err(UnpackError::PipelineFallbackFailed {
|
||||
dll: Box::new(dll_err),
|
||||
exe: Box::new(exe_err),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Kind::NativeDll | Kind::ManagedDll => select_dll_layout(
|
||||
|| dll::unpack_dll_v(input, verbose),
|
||||
|| exe::unpack_v(input, verbose),
|
||||
)?,
|
||||
};
|
||||
Ok((detected.kind, out))
|
||||
}
|
||||
|
||||
/// Pick between the older DLL layout and the EXE-style shell layout.
|
||||
///
|
||||
/// `Ok` from a pipeline is not proof it chose the right layout: a wrong layout
|
||||
/// can emit a PE-shaped image the loader would reject. Each successful image is
|
||||
/// judged with [`dll_layout_invalid`]. The older DLL layout is kept whenever it
|
||||
/// is structurally acceptable, including when its only defects are ordinary
|
||||
/// warnings (a missing base-relocation directory is one of those). The EXE-style
|
||||
/// layout runs only when the DLL layout errors or is structurally invalid.
|
||||
///
|
||||
/// The two closures exist so tests can inject synthetic images without running
|
||||
/// either real pipeline. Production passes `dll::unpack_dll_v` and `exe::unpack_v`.
|
||||
fn select_dll_layout<D, E>(dll_layout: D, exe_layout: E) -> Result<Vec<u8>, UnpackError>
|
||||
where
|
||||
D: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
||||
E: FnOnce() -> Result<Vec<u8>, UnpackError>,
|
||||
{
|
||||
match validate_dll_layout(dll_layout()) {
|
||||
Ok(output) => Ok(output),
|
||||
Err(dll) => {
|
||||
validate_dll_layout(exe_layout()).map_err(|exe| UnpackError::PipelineFallbackFailed {
|
||||
dll: Box::new(dll),
|
||||
exe: Box::new(exe),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_dll_layout(output: Result<Vec<u8>, UnpackError>) -> Result<Vec<u8>, UnpackError> {
|
||||
let output = output?;
|
||||
if let Some(report) = dll_layout_invalid(&output) {
|
||||
return Err(UnpackError::InvalidLayoutCandidate {
|
||||
issues: report
|
||||
.findings
|
||||
.into_iter()
|
||||
.filter(|finding| finding.severity == integrity::IntegritySeverity::Fatal)
|
||||
.map(|finding| finding.message)
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Structural defects that mean a DLL layout candidate must not be accepted.
|
||||
///
|
||||
/// Returns `None` when [`integrity::check`] reports nothing fatal. Warning
|
||||
/// findings stay on the image: a native DLL with no base-relocation directory,
|
||||
/// a zeroed or `int3` entry stub, or an import name that is mapped but not
|
||||
/// printable ASCII. Those are the existing suspect conditions. A fatal finding
|
||||
/// is one the loader or CLR rejects before that — the image is not a PE, a
|
||||
/// claimed directory or entry point does not map, a section's raw range runs
|
||||
/// past the file, or managed metadata has no COR20/BSJB signature.
|
||||
///
|
||||
/// Severity comes from [`IntegrityReport::has_fatal`], which the checker sets
|
||||
/// when it records each finding. This does not re-read issue text.
|
||||
fn dll_layout_invalid(image: &[u8]) -> Option<IntegrityReport> {
|
||||
let report = integrity::check(image);
|
||||
report.has_fatal().then_some(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -389,3 +316,303 @@ mod tests {
|
||||
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
|
||||
}
|
||||
}
|
||||
+813
-168
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.
|
||||
|
||||
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
|
||||
/// the complete write succeeds.
|
||||
pub(crate) fn write_atomic(dest: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut temporary_name = dest.as_os_str().to_os_string();
|
||||
temporary_name.push(".senbei-tmp");
|
||||
let temporary = PathBuf::from(temporary_name);
|
||||
let result = std::fs::write(&temporary, bytes).and_then(|()| std::fs::rename(&temporary, dest));
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&temporary);
|
||||
use tempfile::Builder;
|
||||
|
||||
/// Write `bytes` through a uniquely named temporary file in `dest`'s directory
|
||||
/// and replace `dest` only after the complete write succeeds.
|
||||
///
|
||||
/// The temporary name is random, so concurrent writers of the same destination
|
||||
/// do not share one sibling file. An existing `dest` is replaced. On any
|
||||
/// failure the temporary file is removed and `dest` is left unchanged.
|
||||
pub(crate) fn write_atomic(dest: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let parent = dest
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty());
|
||||
let parent = parent.unwrap_or_else(|| Path::new("."));
|
||||
// Same directory as `dest`: the final replace must stay on one filesystem.
|
||||
// The temporary is private to this call and is removed if persist is not
|
||||
// reached, or if persist fails.
|
||||
let mut temporary = Builder::new()
|
||||
.prefix(".senbei-")
|
||||
.suffix(".tmp")
|
||||
.tempfile_in(parent)?;
|
||||
temporary.write_all(bytes)?;
|
||||
temporary.flush()?;
|
||||
temporary.as_file().sync_all()?;
|
||||
// `persist` replaces an existing file and, on failure, hands the temporary
|
||||
// back. Drop deletes that file, so a failed replace cannot leave `dest`
|
||||
// half-written. Windows can refuse the replace while another handle still
|
||||
// has `dest` open (the file is mapped, or a concurrent replace has not
|
||||
// dropped its handle yet). Retry these refusals briefly; a persistent
|
||||
// permission or sharing failure still leaves the old destination intact.
|
||||
const ATTEMPTS: u32 = 8;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
match temporary.persist(dest) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(error) if replace_in_progress(&error.error) && attempt + 1 < ATTEMPTS => {
|
||||
thread::sleep(Duration::from_millis(1 << attempt));
|
||||
attempt += 1;
|
||||
temporary = error.file;
|
||||
}
|
||||
Err(error) => return Err(error.error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A replace refused because the destination is momentarily in use.
|
||||
///
|
||||
/// `ERROR_ACCESS_DENIED` (5) is what Windows returns when `MoveFileExW` cannot
|
||||
/// replace a file another handle still has open. `ERROR_SHARING_VIOLATION`
|
||||
/// (32) is the same condition reported through the sharing check. These codes
|
||||
/// can also mean a permanent denial, so retries are bounded and never remove
|
||||
/// the existing destination to force replacement.
|
||||
fn replace_in_progress(error: &io::Error) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
matches!(error.raw_os_error(), Some(5 | 32))
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = error;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn stray_names(dir: &Path) -> Vec<String> {
|
||||
fs::read_dir(dir)
|
||||
.expect("read destination directory")
|
||||
.map(|entry| {
|
||||
entry
|
||||
.expect("directory entry")
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.filter(|name| name.starts_with(".senbei-") || name.ends_with(".senbei-tmp"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_new_file_and_leaves_no_temporary() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let dest = dir.path().join("out.bin");
|
||||
|
||||
write_atomic(&dest, b"payload").expect("write");
|
||||
|
||||
assert_eq!(fs::read(&dest).unwrap(), b"payload");
|
||||
assert!(stray_names(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrites_existing_destination() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let dest = dir.path().join("out.bin");
|
||||
fs::write(&dest, b"previous").expect("seed");
|
||||
|
||||
write_atomic(&dest, b"replacement").expect("overwrite");
|
||||
|
||||
assert_eq!(fs::read(&dest).unwrap(), b"replacement");
|
||||
assert!(stray_names(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_leaves_existing_destination_and_no_temporary() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let dest = dir.path().join("out.bin");
|
||||
fs::create_dir(&dest).expect("destination directory");
|
||||
|
||||
let error = write_atomic(&dest, b"replacement").expect_err("cannot replace a directory");
|
||||
|
||||
assert_ne!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert!(dest.is_dir());
|
||||
assert!(stray_names(dir.path()).is_empty());
|
||||
assert!(
|
||||
stray_names(&dest).is_empty(),
|
||||
"temporary file was left inside the destination"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parent_writes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let parent = dir.path().join("missing");
|
||||
let dest = parent.join("out.bin");
|
||||
|
||||
let error = write_atomic(&dest, b"payload").expect_err("missing parent");
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::NotFound);
|
||||
assert!(!parent.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_writers_leave_one_complete_output() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let dest = Arc::new(dir.path().join("out.bin"));
|
||||
let barrier = Arc::new(Barrier::new(8));
|
||||
let mut handles = Vec::new();
|
||||
for id in 0..8u8 {
|
||||
let dest = Arc::clone(&dest);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
handles.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
let payload = vec![id; 64 * 1024];
|
||||
write_atomic(&dest, &payload).expect("concurrent write");
|
||||
id
|
||||
}));
|
||||
}
|
||||
let ids: Vec<u8> = handles
|
||||
.into_iter()
|
||||
.map(|handle| handle.join().expect("writer thread"))
|
||||
.collect();
|
||||
|
||||
let written = fs::read(dest.as_path()).expect("read destination");
|
||||
let writer = written[0];
|
||||
assert!(ids.contains(&writer));
|
||||
assert!(written.iter().all(|byte| *byte == writer));
|
||||
assert_eq!(written.len(), 64 * 1024);
|
||||
assert!(stray_names(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_temporary_names_survive_a_failed_replace() {
|
||||
// Two writers no longer share `<dest>.senbei-tmp`. Each temporary is
|
||||
// removed when its own replace fails, including when the destination
|
||||
// cannot be replaced at all.
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let dest = dir.path().join("out.bin");
|
||||
fs::create_dir(&dest).expect("destination directory");
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let mut handles = Vec::new();
|
||||
for payload in [b"first".as_slice(), b"second".as_slice()] {
|
||||
let dest = dest.clone();
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let payload = payload.to_vec();
|
||||
handles.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
write_atomic(&dest, &payload)
|
||||
}));
|
||||
}
|
||||
for handle in handles {
|
||||
handle
|
||||
.join()
|
||||
.expect("writer thread")
|
||||
.expect_err("directory destination");
|
||||
}
|
||||
assert!(dest.is_dir());
|
||||
assert!(stray_names(dir.path()).is_empty());
|
||||
assert!(stray_names(&dest).is_empty());
|
||||
}
|
||||
result
|
||||
}
|
||||
+231
-123
@@ -95,6 +95,27 @@ pub fn run_folder_opts(
|
||||
verbose: bool,
|
||||
no_log: 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> {
|
||||
let t0 = std::time::Instant::now();
|
||||
let out_root = out_dir
|
||||
@@ -119,7 +140,7 @@ pub fn run_folder_opts(
|
||||
// [`crate::scan::find_targets_opts`]). Files the free directory metadata
|
||||
// already rules out are never opened — on asset-heavy trees the per-file
|
||||
// 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 metas = scan.metadata.as_slice();
|
||||
let scan_stats = &scan.stats;
|
||||
@@ -165,10 +186,6 @@ pub fn run_folder_opts(
|
||||
..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 {
|
||||
let rel = rel_in_tree(root, input);
|
||||
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.
|
||||
let input_owned = input.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)
|
||||
}));
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(Ok((kind, report))) => {
|
||||
@@ -217,54 +234,62 @@ pub fn run_folder_opts(
|
||||
bar.inc(1);
|
||||
}
|
||||
|
||||
// Android pass: protected AArch64 libraries and app packages. Loose `.so`
|
||||
// files restore first so the cross-source dedup keeps them over a copy
|
||||
// inside a package (loose beats `.apk` beats `.apks`/`.xapk` bundle).
|
||||
let mut android_seen = std::collections::HashSet::new();
|
||||
let mut android_method_index_module = None;
|
||||
// Hashing a protected library costs a full read, so only pay it when a
|
||||
// duplicate source can actually exist in this run.
|
||||
let android_dedup = scan.android_so.len() > 1 || !scan.android_packages.is_empty();
|
||||
for input in &scan.android_so {
|
||||
let rel = rel_in_tree(root, input);
|
||||
// Android pass. Loose libraries restore before packages so an identical
|
||||
// library keeps the loose destination (loose beats `.apk` beats bundle).
|
||||
// Module 0x0C stays inside the application that produced it: an extracted
|
||||
// `lib/<abi>/` tree shares with that tree's `assets/` metadata only, and a
|
||||
// package shares only with splits of the same package name and version.
|
||||
let mut android_cache = crate::android::run_cache_with_limits(limits);
|
||||
let loose = crate::android::loose_scopes(&scan.android_so, metas);
|
||||
for scoped in loose.iter().filter(|scoped| {
|
||||
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));
|
||||
// Unreadable here is fine: the restore reports the same error.
|
||||
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 input_owned = scoped.path.clone();
|
||||
let dest_owned = dest.clone();
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
crate::android::restore_so_file_with_method_index_module(
|
||||
let key = scoped.key.clone();
|
||||
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_method_index_module,
|
||||
&mut android_cache,
|
||||
)
|
||||
}));
|
||||
});
|
||||
match result {
|
||||
Ok(Ok(embedded)) => {
|
||||
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)"));
|
||||
Ok(Ok(outcome)) => match outcome.status {
|
||||
crate::android::EntryStatus::Duplicate => {
|
||||
s.skipped += 1;
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("SKIP {rel:?}: duplicate of an earlier target"));
|
||||
}
|
||||
}
|
||||
match write_embedded_metadata(embedded, &dest) {
|
||||
Ok(Some(meta_dest)) => {
|
||||
crate::android::EntryStatus::Failed(e) => {
|
||||
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;
|
||||
crate::ui::ok_label(
|
||||
&bar,
|
||||
@@ -277,16 +302,11 @@ pub fn run_folder_opts(
|
||||
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)) => {
|
||||
s.errors += 1;
|
||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||
@@ -308,81 +328,133 @@ pub fn run_folder_opts(
|
||||
}
|
||||
bar.inc(1);
|
||||
}
|
||||
for package in &scan.android_packages {
|
||||
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 mut seen_taken = std::mem::take(&mut android_seen);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let outcomes = crate::android::restore_package_with_method_index_module(
|
||||
&package_owned,
|
||||
&rel_owned,
|
||||
&out_root_owned,
|
||||
&mut seen_taken,
|
||||
verbose_steps,
|
||||
&mut android_method_index_module,
|
||||
);
|
||||
(outcomes, seen_taken)
|
||||
}));
|
||||
match result {
|
||||
Ok((Ok(outcomes), seen_back)) => {
|
||||
android_seen = seen_back;
|
||||
apply_package_outcomes(outcomes, &mut s, &bar, suppress_file_lines, &log);
|
||||
}
|
||||
Ok((Err(e), seen_back)) => {
|
||||
android_seen = seen_back;
|
||||
s.errors += 1;
|
||||
crate::ui::err(&bar, suppress_file_lines, &rel, &e);
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!("ERR {rel:?}: {e:#}"));
|
||||
// Loose sibling APKs that name the same package and version are one split
|
||||
// set. Their libraries run before their metadata because each package
|
||||
// restores its own libraries first and the shared cache collects modules
|
||||
// across the group. Bundles stay one package each.
|
||||
let package_groups =
|
||||
crate::android::loose_apk_groups_cached(&scan.android_packages, &mut android_cache);
|
||||
for group in &package_groups {
|
||||
if group.len() > 1 {
|
||||
s.packages += group.len();
|
||||
let group_owned = group.clone();
|
||||
let root_owned = root.to_path_buf();
|
||||
let out_root_owned = out_root.clone();
|
||||
let result = senbei_engine::catch_unwind_quiet(|| {
|
||||
crate::android::restore_apk_group(
|
||||
&group_owned,
|
||||
&root_owned,
|
||||
&out_root_owned,
|
||||
&mut android_cache,
|
||||
verbose_steps,
|
||||
)
|
||||
});
|
||||
let rel = rel_in_tree(root, &group[0]);
|
||||
match result {
|
||||
Ok(Ok(outcomes)) => {
|
||||
apply_package_outcomes(outcomes, &mut s, &bar, suppress_file_lines, &log);
|
||||
}
|
||||
}
|
||||
Err(panic) => {
|
||||
// The dedup set may be in an unknown state after a panic; a
|
||||
// re-scan costs a duplicate restore at worst, never corruption.
|
||||
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)
|
||||
));
|
||||
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) => {
|
||||
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(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
|
||||
// tokens in `global-metadata.dat`; de-obfuscate any we find so the unpacked
|
||||
// il2cpp game assembly resolves methods instead of indexing its per-module
|
||||
// tables out of bounds (see [`senbei_metadata`]). This is additive to the
|
||||
// Crackproof module unpack above — the metadata blob is not itself a
|
||||
// Crackproof file.
|
||||
for meta in metas.iter() {
|
||||
let rel = rel_in_tree(root, meta);
|
||||
// il2cpp metadata pass. A loose blob is paired only with the application
|
||||
// assigned when the tree was grouped. v24.1 with a missing or conflicting
|
||||
// module writes nothing. Other versions still restore when the application
|
||||
// has no conflicting module. The paired libraries are recorded on the log
|
||||
// line.
|
||||
for scoped in loose
|
||||
.iter()
|
||||
.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 meta_owned = meta.clone();
|
||||
let meta_owned = scoped.path.clone();
|
||||
let dest_owned = dest.clone();
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
deobfuscate_metadata_to_with_module(
|
||||
let key = scoped.key.clone();
|
||||
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,
|
||||
android_method_index_module.as_deref(),
|
||||
&mut android_cache,
|
||||
)
|
||||
}));
|
||||
});
|
||||
match result {
|
||||
Ok(Ok(report)) if report.remapped > 0 => {
|
||||
s.metadata += 1;
|
||||
crate::ui::metadata(&bar, suppress_file_lines, &rel, report.remapped, &dest);
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!(
|
||||
"META {rel:?} -> {dest:?}: v{} remapped {} method fields",
|
||||
report.version, report.remapped
|
||||
"META {rel:?} -> {dest:?}: v{} remapped {} method fields{}",
|
||||
report.version,
|
||||
report.remapped,
|
||||
provenance_suffix(&report.provenance)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -391,8 +463,9 @@ pub fn run_folder_opts(
|
||||
Ok(Ok(report)) => {
|
||||
if let Some(log) = &log {
|
||||
log.step(&format!(
|
||||
"META {rel:?}: v{} already de-obfuscated",
|
||||
report.version
|
||||
"META {rel:?}: v{} already de-obfuscated{}",
|
||||
report.version,
|
||||
provenance_suffix(&report.provenance)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -443,9 +516,6 @@ pub fn run_folder_opts(
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the original panic hook.
|
||||
std::panic::set_hook(default_hook);
|
||||
|
||||
bar.finish_and_clear();
|
||||
s.duration_ms = t0.elapsed().as_millis();
|
||||
if let Some(log) = &log {
|
||||
@@ -466,6 +536,25 @@ pub fn run_file_v(
|
||||
quiet: u8,
|
||||
verbose: 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> {
|
||||
let t0 = std::time::Instant::now();
|
||||
let out_root = out_dir
|
||||
@@ -508,8 +597,8 @@ pub fn run_file_v(
|
||||
// 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
|
||||
// handled entry-by-entry. Anything else falls through to the PE pipeline.
|
||||
let is_android_so =
|
||||
crate::android::is_elf64_aarch64(&prefix) && crate::android::is_protected_so_file(input);
|
||||
let is_android_so = crate::android::is_elf64_aarch64(&prefix)
|
||||
&& 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);
|
||||
|
||||
if is_meta {
|
||||
@@ -555,7 +644,12 @@ pub fn run_file_v(
|
||||
}
|
||||
}
|
||||
} 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) => {
|
||||
s.unpacked = 1;
|
||||
if let Some(log) = &log {
|
||||
@@ -604,12 +698,13 @@ pub fn run_file_v(
|
||||
s.packages = 1;
|
||||
let rel = PathBuf::from(input.file_name().unwrap_or_default());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
match crate::android::restore_package(
|
||||
match crate::android::restore_package_with_limits(
|
||||
input,
|
||||
&rel,
|
||||
&out_root,
|
||||
&mut seen,
|
||||
verbose && quiet == 0,
|
||||
limits,
|
||||
) {
|
||||
Ok(outcomes) => {
|
||||
let bar = crate::ui::progress(0, true);
|
||||
@@ -706,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.
|
||||
fn panic_payload(panic: &(dyn std::any::Any + Send)) -> String {
|
||||
if let Some(s) = panic.downcast_ref::<&str>() {
|
||||
@@ -829,7 +932,12 @@ fn apply_package_outcomes(
|
||||
}
|
||||
}
|
||||
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 => {
|
||||
|
||||
+19
-4
@@ -111,6 +111,21 @@ pub struct ScanStats {
|
||||
/// [`find_targets`], but with the pre-filter explicitly controlled. When
|
||||
/// `scan_all` is true selected target names below [`MIN_SIZE`] are also probed.
|
||||
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
|
||||
// opened here; `readdir` is fast relative to the content probe that follows,
|
||||
// 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));
|
||||
if workers <= 1 {
|
||||
for (p, c) in paths.iter().zip(class.iter_mut()) {
|
||||
*c = classify(p);
|
||||
*c = classify(p, limits);
|
||||
}
|
||||
} else {
|
||||
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)) {
|
||||
scope.spawn(move || {
|
||||
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*
|
||||
/// of the file, so an ELF64/AArch64 prefix triggers a full-file read. Only
|
||||
/// 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 r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
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)
|
||||
&& 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;
|
||||
}
|
||||
|
||||
@@ -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 crate::atomic::write_atomic;
|
||||
use anyhow::Context;
|
||||
|
||||
mod companion;
|
||||
|
||||
pub(crate) fn is_pe_extension(path: &Path) -> bool {
|
||||
path.extension()
|
||||
@@ -57,9 +60,10 @@ const HEADER_OFF: usize = 4096;
|
||||
///
|
||||
/// The splice fires only when a sibling `<input>._` exists *and* its first 32
|
||||
/// bytes equal the stub's header at offset 4096 — a precise signal that the
|
||||
/// companion is this stub's payload. Otherwise the file is returned untouched,
|
||||
/// so normal (embedded-payload) inputs are unaffected.
|
||||
pub(crate) fn read_unpacker_input(input: &Path) -> std::io::Result<UnpackerInput> {
|
||||
/// companion is this stub's payload. A recognized external-file declaration
|
||||
/// without a matching payload reports a companion error. Other inputs are
|
||||
/// returned untouched, so normal (embedded-payload) inputs are unaffected.
|
||||
pub(crate) fn read_unpacker_input(input: &Path) -> anyhow::Result<UnpackerInput> {
|
||||
let stub = std::fs::read(input)?;
|
||||
|
||||
// Companion path: append "._" to the full file name (Foo.dll -> Foo.dll._).
|
||||
@@ -76,14 +80,17 @@ pub(crate) fn read_unpacker_input(input: &Path) -> std::io::Result<UnpackerInput
|
||||
});
|
||||
}
|
||||
};
|
||||
if !companion.is_file() {
|
||||
return Ok(UnpackerInput {
|
||||
bytes: stub,
|
||||
stub: None,
|
||||
});
|
||||
}
|
||||
let comp = std::fs::read(&companion)?;
|
||||
match splice_companion(&stub, &comp) {
|
||||
let comp = match std::fs::read(&companion) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.with_context(|| format!("couldn't read companion {}", companion.display()));
|
||||
}
|
||||
};
|
||||
match companion::prepare_companion(&stub, comp.as_deref())
|
||||
.with_context(|| format!("companion {}", companion.display()))?
|
||||
{
|
||||
// A splice fired: keep the stub so its plaintext export table can be
|
||||
// overlaid onto the unpacked image (the companion does not carry it).
|
||||
Some(spliced) => Ok(UnpackerInput {
|
||||
@@ -128,18 +135,19 @@ pub(crate) fn overlay_exports_from_stub(out: &mut [u8], stub: &[u8]) {
|
||||
Some(v) if v.1 != 0 => v,
|
||||
_ => return,
|
||||
};
|
||||
let dst = match rva_to_file_off(out, export_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
let dst = match senbei_pe::parse(out)
|
||||
.and_then(|headers| senbei_pe::rva_range(out, headers, export_rva, export_size))
|
||||
{
|
||||
Ok(range) => range,
|
||||
Err(_) => return,
|
||||
};
|
||||
let src = match rva_to_file_off(stub, export_rva) {
|
||||
Some(o) => o,
|
||||
None => return,
|
||||
let src = match senbei_pe::parse(stub)
|
||||
.and_then(|headers| senbei_pe::rva_range(stub, headers, export_rva, export_size))
|
||||
{
|
||||
Ok(range) => range,
|
||||
Err(_) => return,
|
||||
};
|
||||
let n = export_size as usize;
|
||||
if dst + n <= out.len() && src + n <= stub.len() {
|
||||
out[dst..dst + n].copy_from_slice(&stub[src..src + n]);
|
||||
}
|
||||
out[dst].copy_from_slice(&stub[src]);
|
||||
}
|
||||
|
||||
/// Restore the CLR regions retained by an external-companion loader stub.
|
||||
@@ -498,7 +506,7 @@ fn unpack_bytes_impl(
|
||||
companion: Option<&[u8]>,
|
||||
force_exe: bool,
|
||||
) -> 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 (kind, mut out) = unpack_spliced_or_auto(bytes, spliced.is_some(), force_exe, false)?;
|
||||
if spliced.is_some() {
|
||||
@@ -633,6 +641,34 @@ mod tests {
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_overlay_requires_the_entire_range_to_be_file_backed() {
|
||||
for is_pe32_plus in [false, true] {
|
||||
let dirs = 0x98 + if is_pe32_plus { 112 } else { 96 };
|
||||
let section = 0x98 + if is_pe32_plus { 0xf0 } else { 0xe0 };
|
||||
let mut stub = managed_fixture(is_pe32_plus, 0x600);
|
||||
stub[0x780..0x880].fill(0x5a);
|
||||
for (stub_raw_size, out_raw_size) in
|
||||
[(0x600u32, 0x600u32), (0x200, 0x600), (0x600, 0x200)]
|
||||
{
|
||||
let mut out = managed_fixture(is_pe32_plus, 0x400);
|
||||
out[dirs..dirs + 4].copy_from_slice(&0x2180u32.to_le_bytes());
|
||||
out[dirs + 4..dirs + 8].copy_from_slice(&0x100u32.to_le_bytes());
|
||||
out[section + 16..section + 20].copy_from_slice(&out_raw_size.to_le_bytes());
|
||||
stub[section + 16..section + 20].copy_from_slice(&stub_raw_size.to_le_bytes());
|
||||
let before = out.clone();
|
||||
overlay_exports_from_stub(&mut out, &stub);
|
||||
if stub_raw_size == 0x600 && out_raw_size == 0x600 {
|
||||
assert_eq!(&out[0x580..0x680], &[0x5a; 0x100]);
|
||||
assert_eq!(&out[..0x580], &before[..0x580]);
|
||||
assert_eq!(&out[0x680..], &before[0x680..]);
|
||||
} else {
|
||||
assert_eq!(out, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_companion_restores_rva_mapped_regions_without_overwriting_il() {
|
||||
for is_pe32_plus in [false, true] {
|
||||
|
||||
Generated
+93
-70
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.3"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
|
||||
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
@@ -21,9 +21,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -42,9 +42,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
@@ -58,9 +64,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.4"
|
||||
version = "0.16.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
|
||||
checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
@@ -101,9 +107,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
version = "1.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
@@ -136,9 +142,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
@@ -196,8 +202,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -219,18 +227,18 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.1"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
@@ -251,9 +259,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
@@ -266,9 +274,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
version = "0.3.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -277,9 +285,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
@@ -289,9 +297,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
@@ -301,13 +309,25 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -364,9 +384,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
version = "1.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
@@ -407,12 +427,12 @@ checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "senbei-crypto"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"thiserror",
|
||||
@@ -420,7 +440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-elf"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"goblin",
|
||||
"thiserror",
|
||||
@@ -428,7 +448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-engine"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"memmap2",
|
||||
"senbei-crypto",
|
||||
@@ -443,7 +463,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-io"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indicatif",
|
||||
@@ -464,7 +484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-metadata"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror",
|
||||
@@ -472,14 +492,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "senbei-pe"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "senbei-wasm"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"senbei-engine",
|
||||
@@ -515,14 +535,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -533,9 +553,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
@@ -567,9 +587,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -591,29 +611,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
@@ -623,15 +643,15 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
|
||||
|
||||
[[package]]
|
||||
name = "unit-prefix"
|
||||
@@ -651,12 +671,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
@@ -664,9 +683,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -674,22 +693,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
version = "0.2.130"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -834,23 +853,27 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.6.0"
|
||||
version = "9.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom",
|
||||
"indexmap",
|
||||
"libc",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
@@ -860,9 +883,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "senbei-wasm"
|
||||
version = "1.3.2"
|
||||
version = "1.3.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.98.1"
|
||||
rust-version = "1.99.1"
|
||||
description = "WebAssembly bindings for senbei (browser frontend assets live in web/)"
|
||||
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
|
||||
/// Crackproof file or is corrupt.
|
||||
#[wasm_bindgen]
|
||||
pub fn unpack_file(
|
||||
input: &[u8],
|
||||
companion: Option<Vec<u8>>,
|
||||
) -> Result<UnpackResult, JsError> {
|
||||
pub fn unpack_file(input: &[u8], companion: Option<Vec<u8>>) -> Result<UnpackResult, JsError> {
|
||||
let r = senbei_io::job::unpack_bytes(input, companion.as_deref())
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(UnpackResult {
|
||||
|
||||
Reference in new issue
Block a user