commit 737780597bab4adbdea20126d9897478dcf323e3 Author: Tenshi Date: Sat Oct 31 23:35:41 2026 +0100 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..469e547 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target/ +/dist/ +/tools/ +/android-build-tools/ +/java/ +*.apk +*.keystore +*.bak diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..08e424a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1287 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Hi3-Patcher" +version = "1.0.0" +dependencies = [ + "adler2", + "aes", + "anyhow", + "base64", + "crc32fast", + "crossterm", + "lz4_flex", + "regex", + "rsa", + "serde_json", + "sha1", + "sha2", + "tempfile", + "url", + "windows-sys", + "zip", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lz4_flex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c65ace4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "Hi3-Patcher" +version = "1.0.0" +edition = "2024" +description = "Honkai Impact 3rd Windows and Android patcher" +authors = ["Tenshi"] + +[lib] +name = "hi3_patcher" + +[[bin]] +name = "Hi3-Patcher" +path = "src/main.rs" + +[dependencies] +adler2 = "2" +aes = "0.8" +anyhow = "1" +base64 = "0.22" +crc32fast = "1" +crossterm = "0.29" +lz4_flex = "0.14" +regex = { version = "1", default-features = false, features = ["std", "perf", "unicode-perl"] } +rsa = { version = "0.9", features = ["pem"] } +serde_json = "1" +sha1 = "0.10" +sha2 = "0.10" +tempfile = "3" +url = "2" +zip = { version = "2", default-features = false, features = ["deflate"] } + +[profile.release] +strip = true +opt-level = "z" +lto = "fat" +codegen-units = 1 + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Memory", "Win32_System_Threading"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..87196a9 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Honkai Impact 3 - Patcher + +Patcher for Windows clients and Android APKs. + +> [!WARNING] +> This repository contains AI-generated code. + +| Patch | Platform | Target | Effect | +| --- | --- | --- | --- | +| Dispatch & SDK | Windows, Android | Unity assets, SDK bundle, CN DEX | Rewrites dispatch/SDK URLs to a chosen server | +| RSA | Windows, Android | `UserAssembly.dll`, passport DEX | Replaces the passport login RSA public key | +| ACE | Windows (≤ 8.9) | `BH3.exe` | Removes Anti-Cheat Expert via a local init capture | +| HoYoKProtect | Windows (9.0+) | `UserAssembly.dll` | Forces the client-side MHYP loader off | +| Protection | Android | 4 ELF libs, startup DEX | Removes AnoSDK (overseas) or Mobile ACE/TSS (CN) | + +Unknown client layouts are refused; repeat runs are no-ops where +detectable. Windows patches edit the client in place; Android patches +write a separate signed `_patched.apk`. + +> [!CAUTION] +> Windows clients below 9.0 must apply the ACE Patch first! + +## Build + +Requires [Rust](https://rustup.rs/) (edition 2024). Build on the OS you +run it on: + +```sh +cargo build --release --locked +``` + +Output: `target/release/Hi3-Patcher` (`.exe` on Windows). Android patches +need extra tools at patch time; see [Environment](#environment). + +## Usage + +### Interactive + +```sh +Hi3-Patcher +``` + +Arrow or number keys select, Enter confirms, Esc/`0` goes back. + +### Command line + +``` +Hi3-Patcher [options] + +dispatch --platform --input --server [--https] +rsa --platform --input [--key ] +ace --input [--yes] +hoyok --input +protection --input [--variant ] + +--json Emit one JSON result object as the last stdout line +-h, --help Usage +-V, --version Version +``` + +- Windows input: the game folder or its `BH3.exe`. Android input: an APK. +- `rsa --key` takes a Base64 DER public-key body; omitted (or empty in the + menu) uses the built-in PemukulPaku key. It must match + `Passport:RsaPrivateKey` in the server's `appsettings.json`. +- `ace` launches the client once to capture its initialized state. On + the command line this needs `--yes`; without it, nothing is changed. +- `protection --variant auto` (default) picks AnoSDK or Mobile ACE/TSS + from the libraries in the APK. + +### Exit codes and JSON + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Patch or runtime error | +| 2 | Usage error (unknown command or option, missing or invalid value); nothing is changed | + +With `--json`, the last stdout line is one JSON object; progress lines +come before it. + +```json +{"ok": true, "command": "protection", "output": "/path/App_patched.apk"} +{"ok": false, "error": "no production BH3 SDK URLs found"} +``` + +`output` is the written APK path for Android patches, `null` for Windows. + +### Environment + +Android signing needs Build-Tools 35 (`zipalign`, `lib/apksigner.jar`) +and Java 17+ (`java`, `keytool`). To use existing installs: + +| Variable | Use | +| --- | --- | +| `HI3_ANDROID_TOOLS` | Build-Tools directory | +| `ANDROID_HOME`, `ANDROID_SDK_ROOT` | Scanned for `build-tools/*` | +| `JAVA_HOME` | Preferred Java runtime | + +Otherwise the interactive menu (Platform > Android) offers to download +Build-Tools (~60 MB) and a Temurin JDK 17 (~190 MB) beside the binary, +SHA-256 verified; the command line then finds them there. + +The signing keystore `Hi3-Patcher.keystore` is created beside the input +on first use. Keep it: updates must be signed with the same key. + +## Layout + +``` +src/ + format/ Binary-format primitives (shared, no patch logic) + bytes Bounds-checked LE/BE integer access + pe PE32+ (x86-64) reader + elf ARM64 ELF reader + dex DEX field access, checksum fix-up, CN string growth + unity UnityFS bundles and serialized TextAssets (read, rebuild, resize) + patch/ + region Windows client region identity (ChannelConfig) + dispatch/ Dispatch & SDK URL rewrite (windows, android, cn_android) + rsa/ Passport RSA key replacement (windows, android, cn_android) + ace/ ACE removal (discovery, capture, process, runtime, generate) + hoyokprotect/ MHYP loader switch (discovery) + protection/ AnoSDK + Mobile ACE/TSS, one profile-driven pipeline + android/ APK signing: toolchain discovery/download, sign workflow + util/ Atomic verified file replacement + cli/ Interactive menu, args interface +``` + +## Documentation + +| Document | Contents | +| --- | --- | +| [docs/architecture.md](docs/architecture.md) | Crate structure and shared format primitives | +| [docs/dispatch.md](docs/dispatch.md) | URL rewrite rules and asset containers | +| [docs/rsa.md](docs/rsa.md) | Passport RSA patch points and wrapper | +| [docs/ace.md](docs/ace.md) | ACE discovery, capture and rebuild | +| [docs/hoyokprotect.md](docs/hoyokprotect.md) | MHYP loader location and edit | +| [docs/protection.md](docs/protection.md) | ELF restore, native neutralize, DEX edits | diff --git a/docs/ace.md b/docs/ace.md new file mode 100644 index 0000000..15e2a23 --- /dev/null +++ b/docs/ace.md @@ -0,0 +1,37 @@ +# ACE patch (Windows, clients ≤ 8.9) + +Builds a patched `BH3.exe` from a capture of the clean client's +initialized state. Only `BH3.exe` is written, without a backup. 9.0+ +clients use HoYoKProtect instead. + +## Discovery (`ace/discovery.rs`) + +Read-only; all checks must pass: + +| File | Required layout | +| --- | --- | +| `BH3.exe` | 7 sections all named `.ace*`, 4 KiB section alignment, no TLS directory, entry calls the `BH3Base.dll` ordinal-1 import | +| `BH3_Data/sharedassets0.assets` | `ChannelConfig` resolves to a known region | +| `UnityPlayer.dll` | exports `UnityMain`; three writable decoder globals found via a `.text` code pattern | +| `BH3_Data/Native/UserAssembly.dll` | exports `il2cpp_get_api_table`; executable `.tvm0` section with a virtual tail larger than its raw size | + +## Capture (`ace/capture.rs`, `ace/process.rs`) + +After confirmation, launches the client (Windows directly; Linux via +`umu-run` + Proton) and reads its memory read-only — no injection or +protection tampering. Once the state is stable for 2 s (120 s timeout), +it captures the `.tvm0` section, the three decoder states and the +committed private executable allocations. + +## Normalize and rebuild (`ace/runtime.rs`, `ace/generate.rs`) + +`runtime.rs` checks the captured `.tvm0` against the original after PE +relocations, resolves `FF 25` jump slots against the captured +allocations, and emits a position-independent blob and two thunk tables. +`generate.rs` appends `.bh3code` (the `bootstrap.bin` stub, patched with +the discovered RVAs/sizes per `bootstrap.json`) and `.bh3imp` (imports, +decoder states and the runtime blob), then repoints the entry point. + +A `HI3AUTO1` receipt with SHA-256 hashes of the patched image, +`UnityPlayer.dll` and `UserAssembly.dll` is appended; a repeat run that +validates it is a no-op. Written via `util::replace::verified`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ccdd5b7 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,56 @@ +# Architecture + +`format/` holds reusable binary-format parsing; `patch/` holds the patch +logic. Patch modules depend on `format/`, `android/` and `util/`, never on +each other. + +## format/ + +All reads are bounds-checked; malformed input returns an error instead of +panicking. + +- **bytes** — little/big-endian integer reads and writes, slicing, + power-of-two alignment. +- **pe** — PE32+ (x86-64) reader. Validates headers and section ranges; + provides section lookup, RVA→offset mapping, data directories, export + lookup, and a stricter image-bound check used by ACE and HoYoKProtect. + Patches add their own layout checks on top. +- **elf** — ARM64 shared-object reader. Validates headers and + section/segment ranges; provides sections, segments, `.dynsym` symbols, + RELA entries, and address→file-offset mapping through `PT_LOAD`. +- **dex** — field access, checksum recompute (SHA-1 + Adler-32), string + and `const-string` lookup, and `append_string`, which inserts a + `string_data_item` before the annotations section and relocates every + later offset, map entry and header field. +- **unity** — UnityFS v6 bundles and serialized-file `TextAsset`s. + `rebuild` re-encodes only changed blocks and re-verifies the result. + `replace_script` and `Bundle::replace_node` resize a TextAsset or + bundle node, rewriting object offsets, the header size and the node + directory. Blocks may be uncompressed or LZ4. + +## patch/ + +`region` maps the Windows client's `ChannelConfig` +(`ChannelName`/`BundleIdentifier`) to one of six PC regions +(CN/GLB/JP/KR/TW/SEA) with its SDK key and environment id. + +Single-file Windows writes (RSA, ACE, HoYoKProtect) use +`util::replace::verified`: write a sibling temporary file, flush, read it +back, re-check the target for concurrent changes, persist, re-verify. The +dispatch patch replaces its three files together and rolls back on +failure. + +## android/ + +`toolchain` finds Build-Tools and Java 17+, or downloads them (SHA-256 +verified). `sign::signed_patch` runs a patch's repack, which drops the old +signature entries, into a temporary APK, then zip-aligns it +(`-P 16 -f 4`), signs and verifies it with `apksigner`, and writes +`_patched.apk`. + +## cli/ + +`interactive` is the terminal menu (no arguments). `args` parses +` --flag value` (or `--flag=value`) and validates the whole +command line before running the patch, then reports via exit code and +optional JSON. diff --git a/docs/dispatch.md b/docs/dispatch.md new file mode 100644 index 0000000..1e78c2f --- /dev/null +++ b/docs/dispatch.md @@ -0,0 +1,70 @@ +# Dispatch & SDK patch + +Rewrites client URLs to a chosen HTTP or HTTPS server. The server is a +host or IP with an optional port, without scheme or path. + +## URL rules (`dispatch/mod.rs`) + +- `dispatch` replaces the `"DispatchUrls": [...]` list with + `"/query_dispatch"` entries inside its original byte range, + space-padding the rest. +- `sdk` rewrites the URLs of the production `bh3_*` records in + `server_pc` (`env` `0` for `bh3_cn`, else `2`), padding each record to + its original length. +- `android_urls` rewrites `http(s)`/`ws(s)` URLs and bare hosts on the + official domains (`bh3.com`, `honkaiimpact3.com`, `mihoyo.com`, + `hoyoverse.com`, plus one known IP). Shorter replacements keep the + original length with an `x…@` userinfo prefix; `ws`/`wss` follow the + chosen scheme. + +If a replacement is longer than the original, `dispatch_grow`, `sdk_grow` +or `android_urls_grow` writes it at full length and the asset is +enlarged: `unity::replace_script` rewrites the serialized object table and +header size, and `Bundle::replace_node` re-chunks a UnityFS node and +fixes its directory. The tool then prints a warning that assets were +enlarged. + +## Windows (`dispatch/windows.rs`) + +Writes three files together and rolls back on failure: + +| File | Content patched | +| --- | --- | +| `BH3_Data/sharedassets0.assets` | `ChannelConfig` dispatch URLs; also identifies the region | +| `BH3_Data/resources.assets` | `server_pc` SDK URLs | +| `BH3_Data/StreamingAssets/MiHoYoSDKRes/PC/mihoyo_sdk_res` | the `server_pc` copy inside the SDK bundle | + +Both `server_pc` copies must be byte-equal before patching. + +## Android (`dispatch/android.rs`) + +Patches serialized files, UnityFS bundle nodes and plain text among the +candidate `assets/` entries (`assets/bin/data/*` and known text +extensions, except `package_config.json`, `share_config.json`, `.wmv` +and `.bak`). `.splitN` parts are joined for patching and re-split at the +original chunk size; enlarging can add parts. DEX, native libraries and +`.wmv` are copied unchanged, except for the CN passport URL below. At +least one `ChannelConfig` and one SDK/resource URL are required, otherwise +no APK is written. The result is repacked and signed by `android::sign`. + +## CN passport URL (`dispatch/cn_android.rs`) + +The CN passport login URL is a plaintext string in the DEX that declares +`…account.sdk.utils.RSAUtils`; only that DEX is edited. The server must +use **HTTP** — HTTPS is rejected. + +The stock DEX has exactly one loopback string, `http://127.0.0.1` (an SDK +default), between known neighbors in the sorted `string_ids` table. The +patch: + +1. Appends a `string_data_item` for the chosen origin + (`format::dex::append_string`) and points the loopback string id at + it. +2. Points the `const-string` that loaded the official login URL + (`https://passport-api.mihoyo.com`) at that id, so login goes to the + server. +3. Moves the original loopback `const-string` to the neighboring id to + keep `string_ids` sorted. + +The origin must sort between those neighbors; other addresses are +rejected. Checksums are recomputed. diff --git a/docs/hoyokprotect.md b/docs/hoyokprotect.md new file mode 100644 index 0000000..dd09a4b --- /dev/null +++ b/docs/hoyokprotect.md @@ -0,0 +1,28 @@ +# HoYoKProtect patch (Windows, clients 9.0+) + +Makes the `IsLoadMHYBase` predicate in `BH3_Data/Native/UserAssembly.dll` +return false, turning the client-side MHYP loader off. Only that file is +written; the region is confirmed from `ChannelConfig` first. + +## Discovery (`hoyokprotect/discovery.rs`) + +1. In `.text`, match the encoded `IsLoadMHYBase` string construction by + byte pattern (not a fixed RVA); exactly one match. +2. The exception directory gives the containing function's bounds. It + must start with the known prologue, end with the known boolean-return + epilogue, and contain the match. +3. Exactly two relative `CALL`s must target the function, followed by the + known module-check and initialization byte sequences. + +`il2cpp_get_api_table` must be exported. Unknown or ambiguous results +abort. + +## Edit + +| Location | Original | Replacement | Effect | +| --- | --- | --- | --- | +| First 3 bytes of the function | `55 41 57` | `31 C0 C3` | `xor eax, eax; ret` | + +Discovery re-runs on the edited image to confirm the disabled form, then +the file is written via `util::replace::verified`. An already-disabled +function is left unchanged. diff --git a/docs/protection.md b/docs/protection.md new file mode 100644 index 0000000..84f8ac7 --- /dev/null +++ b/docs/protection.md @@ -0,0 +1,81 @@ +# Android protection removal + +One pipeline removes AnoSDK (overseas) or Mobile ACE/TSS (CN); the +variants differ only in the constants in `protection/profile.rs`, and +`--variant auto` picks one from the libraries present. It restores four +protected ARM64 libraries, neutralizes the SDK startup and native API +calls, drops the protection libraries, and signs a new APK. + +## Variant profiles + +| Field | AnoSDK | Mobile ACE/TSS | +| --- | --- | --- | +| Regions | SEA, GLB, JP, KR, TW | CN | +| Decoder library | `libanort.so` | `libtprt.so` | +| Guard library | `libanogs.so` | `libtersafe2.so` | +| Key record XOR | `0xDF` | `0x49` | +| Decoder symbol | `g_acf_array` | `g_tprt_pfn_array` | +| `Application` class | `com.ano.gshell.AnoApplication` | `com.ace.gshell.AceApplication` | +| SDK class | `com.gamesafe.ano.AnoSdk` | `com.tencent.tp.TssSdk` | +| Native APIs | 6 `AnoSDK*` | 5 `tp2_*` | + +## Identification (`protection/mod.rs`) + +Checks `ChannelConfig` (`ChannelName`/`BundleIdentifier`) against the +variant's regions, confirms both protection libraries are ARM64 ELFs, and +rejects APKs that also contain the other variant's libraries. ARM64 is +the only supported ABI. + +## Restore (`protection/loader.rs`) + +- `permutation` follows the decoder symbol through its routine and GOT + reference to the eight-page permutation (a digit string permuting + `0..8`). +- Each 48-byte protected-section descriptor is matched to an ELF section + by name, RVA, file offset and size. The Base64 key record at descriptor + table `+0x1A4` is XORed with the variant byte: magic `0x20240829`, key + id 1 drives restoration, and the mode at `+0x184` (1 or 2) selects page + treatment. Each restored section must match its recorded CRC-32. + - Large sections: AES-128-CBC over `0x4000` bytes in `0x800`-byte + chunks from `0x2000 - (RVA & 0xFFF)` (key: the value's repeated + lowercase hex; IV `0x02..0x11`, reset per chunk). Mode 1 XORs and + shuffles `0x4000` fragments across eight `0x10000` strides; mode 2 + XORs the remainder. + - Small sections: whole-section XOR. +- `BBMG311DIS12IMP` gives the protected import count (`+16`), `.rela.plt` + RVA (`+24`), entry count (`+28`) and excluded GOT slots (`+32/+36`); + selected import `r_info` values are rotated back into order. + `BBMG311ENC12IAI` gives the constructor array RVA (`+16`) and count + (`+20`). The wrapper `DT_INIT`, the decoder library's `DT_NEEDED` entry + and the APS2/RELA wrapper relocations are cleared, and the + `fread`/`fwrite`/`fclose` GOT imports are restored. The removed + `DT_INIT` RVA is passed to the API helper. + +## Neutralize (`protection/native.rs`) + +In restored `libil2cpp.so`, finds the SDK API names and the guard library +name in `.rodata`, traces ADRP/ADD references to them, and redirects each +resolver `BL` to a 16-byte helper written over the removed `DT_INIT` +initializer. The helper returns a pointer to a function that returns +zero; other P/Invoke resolutions are untouched. Aborts if the initializer +is still active, an API is missing, or the call graph is ambiguous. + +## DEX edits (`protection/dex.rs`) + +Three methods are edited after each call is validated: + +| Method | Edit | +| --- | --- | +| `Application.` | NOP both native library loads (20 bytes) | +| `Application.attachBaseContext` | Replace the invoke + move-result after the superclass call with return/NOPs (8 bytes) | +| SDK class `` | NOP the guard library load (`const-string` 10 bytes, or `const-string/jumbo` 12 bytes); keep the return | + +All 8 `attachBaseContext` bytes are rewritten because leftover operand +words after the `return` would still fail DEX verification. Checksums are +recomputed. + +## Output + +The decoder and guard libraries are dropped; the restored libraries and +edited DEX files are repacked and signed by `android::sign`. Any +mismatched marker, CRC, method or resolver layout aborts the patch. diff --git a/docs/rsa.md b/docs/rsa.md new file mode 100644 index 0000000..694ccd6 --- /dev/null +++ b/docs/rsa.md @@ -0,0 +1,51 @@ +# Passport RSA patch + +Replaces the passport login RSA public key so credentials are encrypted +for the server's `Passport:RsaPrivateKey`. Input: a single-line Base64 DER +`SubjectPublicKeyInfo` body (no PEM markers), 2048-bit, exponent 65537; +empty uses the built-in PemukulPaku key. + +## Windows (`rsa/windows.rs`) + +Target: `BH3_Data/Native/UserAssembly.dll` (x64 PE), after confirming the +region from `ChannelConfig`. + +1. Find the shared XML RSA routine (prefix `CRYPTO`) in the executable + `il2cpp` section and the nearby account/password encryption `CALL` + pair that targets it. A missing or ambiguous match aborts before any + write. +2. Base64-encode the key's modulus (344 characters). +3. Append a `.hi3rsa` code section: an `HI3RSA01` header (the two + original call RVAs and the routine RVA), the wrapper, and the modulus. + Redirect both login `CALL`s into the wrapper; the routine and its + other callers are untouched. +4. Update the section count, code/image sizes and certificate directory + offset (the certificate is kept; its signature no longer matches), + re-verify the image, and write via `util::replace::verified`. + +At runtime the wrapper receives the managed XML key in `RCX`. If the key +is non-null, 505 UTF-16 chars long, and has the `` tags at the +expected offsets, it writes the new modulus at byte offset `0xE2`; in +every case it then calls the original routine. + +A repeat run validates the existing `.hi3rsa` section and redirected +calls and replaces only the modulus. A single known 80-byte `jk98…` +overlay before the certificate is preserved; other overlays are rejected. + +## Android overseas (`rsa/android.rs`) + +`…oversea.sdk.internal.shared.crypto.RSAUtils.getRSA_PUBLIC_KEY` +references three environment keys (398-byte, newline-wrapped 2048-bit) via +`const-string`. All three are replaced in place with the chosen key; +wrapping at 63/64/65 characters keeps the strings distinct and in sort +order. String order is re-validated and checksums are recomputed. + +## Android CN (`rsa/cn_android.rs`) + +`…account.sdk.utils.RSAUtils` holds a 1024-bit key string. A 398-byte +wrapped 2048-bit replacement is appended (`format::dex::append_string`), +the key and its two neighbor string ids are reordered to keep sort order, +and the field, both `const-string` references and both static values are +remapped. Checksums are recomputed; unknown layouts are rejected. + +An APK containing both CN and overseas SDKs is rejected. diff --git a/src/android/mod.rs b/src/android/mod.rs new file mode 100644 index 0000000..bd20b13 --- /dev/null +++ b/src/android/mod.rs @@ -0,0 +1,11 @@ +//! Android APK support: the signing toolchain, the sign/repack workflow, and +//! small shared helpers. Patch byte edits live in the individual patch modules. +pub mod sign; +pub mod toolchain; + +/// True for JAR signature entries that must be dropped before re-signing. +pub fn signature(name: &str) -> bool { + let n = name.to_ascii_uppercase(); + n == "META-INF/MANIFEST.MF" + || (n.starts_with("META-INF/") && [".RSA", ".DSA", ".EC", ".SF"].iter().any(|s| n.ends_with(s))) +} diff --git a/src/android/sign.rs b/src/android/sign.rs new file mode 100644 index 0000000..9d5a458 --- /dev/null +++ b/src/android/sign.rs @@ -0,0 +1,97 @@ +//! Shared APK packaging and signing. Each patch module supplies a `repack` +//! closure that writes the modified entries; this module zip-aligns and signs +//! the result into a separate `_patched.apk`, leaving the input intact. +use super::toolchain::{build_tools, check_java, java_tool}; +use anyhow::{Context, Result, ensure}; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +fn run(command: &mut Command) -> Result<()> { + let output = command.output().context( + "could not start Android signing tool; Java 17+ and Android Build-Tools are required", + )?; + ensure!( + output.status.success(), + "Android signing command failed: {}\n{}\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} + +/// Package the edits produced by `repack`, then align and sign the APK. +pub fn signed_patch(input: &Path, repack: impl FnOnce(&Path, &Path) -> Result<()>) -> Result { + ensure!( + input.is_file() + && input + .extension() + .is_some_and(|s| s.eq_ignore_ascii_case("apk")), + "select an APK file" + ); + let tools = build_tools()?; + let java = check_java()?; + // Java signing tools do not accept Windows verbatim (\\?\) paths. + let input = std::path::absolute(input)?; + let parent = input.parent().unwrap(); + let output = parent.join(format!( + "{}_patched.apk", + input.file_stem().unwrap().to_string_lossy() + )); + ensure!( + !output.exists(), + "output already exists: {}. Move it before patching again.", + output.display() + ); + println!("[OK] APK and tools checked"); + let work = tempfile::tempdir_in(parent)?; + let unsigned = work.path().join("unsigned.apk"); + let aligned = work.path().join("aligned.apk"); + let signed = work.path().join("signed.apk"); + repack(&input, &unsigned)?; + println!("[OK] Patch prepared"); + let key = parent.join("Hi3-Patcher.keystore"); + if !key.exists() { + run(Command::new(java_tool(&java, "keytool")) + .args(["-genkeypair", "-keystore"]) + .arg(&key) + .args([ + "-storepass", "android", "-keypass", "android", "-alias", "patchkey", "-keyalg", + "RSA", "-keysize", "2048", "-validity", "10000", "-dname", + "CN=Tenshi,O=Hi3-Patcher,C=US", + ]))?; + } + let zipalign = tools.join(if cfg!(windows) { + "zipalign.exe" + } else { + "zipalign" + }); + run(Command::new(zipalign) + .args(["-P", "16", "-f", "4"]) + .arg(&unsigned) + .arg(&aligned))?; + let jar = tools.join("lib/apksigner.jar"); + run(Command::new(java_tool(&java, "java")) + .arg("-jar") + .arg(&jar) + .args(["sign", "--ks"]) + .arg(&key) + .args(["--ks-pass", "pass:android", "--key-pass", "pass:android", "--out"]) + .arg(&signed) + .arg(&aligned))?; + run(Command::new(java_tool(&java, "java")) + .arg("-jar") + .arg(jar) + .args(["verify", "--verbose"]) + .arg(&signed))?; + println!("[OK] Signed APK verified"); + let mut final_file = tempfile::NamedTempFile::new_in(parent)?; + std::io::copy(&mut fs::File::open(signed)?, &mut final_file)?; + final_file.as_file().sync_all()?; + final_file.persist_noclobber(&output).map_err(|e| e.error)?; + println!("[OK] Output saved"); + Ok(output) +} diff --git a/src/android/toolchain.rs b/src/android/toolchain.rs new file mode 100644 index 0000000..f0d762f --- /dev/null +++ b/src/android/toolchain.rs @@ -0,0 +1,339 @@ +//! Discovery and optional download of the Android SDK Build-Tools and a Java +//! 17+ runtime used to zip-align and sign patched APKs. +use anyhow::{Context, Result, ensure}; +use sha2::{Digest, Sha256}; +use std::{ + env, fs, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; +use zip::ZipArchive; + +const ALIGN: &str = if cfg!(windows) { + "zipalign.exe" +} else { + "zipalign" +}; + +/// Locate a Build-Tools directory containing `zipalign` and `apksigner.jar`. +pub fn build_tools() -> Result { + let mut candidates = vec![local_build_tools()?]; + if let Some(p) = env::var_os("HI3_ANDROID_TOOLS") { + candidates.push(PathBuf::from(p)); + } + for key in ["ANDROID_HOME", "ANDROID_SDK_ROOT"] { + if let Some(root) = env::var_os(key) { + let path = PathBuf::from(root).join("build-tools"); + if path.is_dir() { + let mut entries = fs::read_dir(path)? + .filter_map(|e| e.ok().map(|v| v.path())) + .collect::>(); + entries.sort(); + entries.reverse(); + candidates.extend(entries); + } + } + } + if let Some(paths) = env::var_os("PATH") { + candidates.extend(env::split_paths(&paths)); + } + let exe = env::current_exe()?; + candidates.push(exe.parent().unwrap().join("tools/android-build-tools")); + candidates + .into_iter() + .find(|p| p.join(ALIGN).is_file() && p.join("lib/apksigner.jar").is_file()) + .context("Android Build-Tools are not installed. Run the Android setup to download them.") +} + +pub fn local_build_tools() -> Result { + Ok(env::current_exe()? + .parent() + .context("patcher directory missing")? + .join("android-build-tools")) +} + +pub fn install_build_tools(destination: &Path) -> Result<()> { + let (platform, hash) = if cfg!(windows) { + ( + "windows", + "5753c679a1b90bcf6fbc9945a2ce39dfb9e74f1df0831a1e1866ae5b594326f0", + ) + } else if cfg!(target_os = "linux") { + ( + "linux", + "bd3a4966912eb8b30ed0d00b0cda6b6543b949d5ffe00bea54c04c81e1561d88", + ) + } else { + anyhow::bail!("Automatic Android tools setup supports Windows and Linux only."); + }; + ensure!( + cfg!(target_arch = "x86_64"), + "Automatic Android tools setup supports x86-64 Windows and Linux only." + ); + ensure!( + !destination.exists(), + "Tools folder already exists but is incomplete: {}. Move it aside, then retry.", + destination.display() + ); + let work = tempfile::tempdir_in(destination.parent().context("tools parent missing")?) + .context("cannot write beside the patcher. Move it to a writable folder and retry.")?; + let archive = work.path().join("tools.zip"); + let total = if cfg!(windows) { + 59_878_107 + } else { + 61_958_799 + }; + download( + &format!("https://dl.google.com/android/repository/build-tools_r35_{platform}.zip"), + &archive, + total, + )?; + verify_download(&archive, hash)?; + let unpacked = work.path().join("unpacked"); + ZipArchive::new(fs::File::open(&archive)?)?.extract(&unpacked)?; + let folder = fs::read_dir(&unpacked)? + .next() + .context("empty Build-Tools archive")?? + .path(); + let align = folder.join(ALIGN); + ensure!( + align.is_file() && folder.join("lib/apksigner.jar").is_file(), + "incomplete Build-Tools archive" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&align, fs::Permissions::from_mode(0o755))?; + } + fs::rename(folder, destination).context("cannot install Android Build-Tools")?; + Ok(()) +} + +pub fn local_java() -> Result { + Ok(env::current_exe()? + .parent() + .context("patcher directory missing")? + .join("java")) +} + +fn java_name(name: &str) -> String { + if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_owned() + } +} + +/// Path to a `java` launcher backed by a supported (17+) runtime with keytool. +pub fn check_java() -> Result { + let mut candidates = Vec::new(); + if let Some(root) = env::var_os("JAVA_HOME") { + candidates.push(PathBuf::from(root).join("bin")); + } + candidates.push(local_java()?.join("bin")); + if let Some(paths) = env::var_os("PATH") { + candidates.extend(env::split_paths(&paths)); + } + candidates + .into_iter() + .find(|bin| supported_java(bin)) + .context("No supported Java found. Android signing requires Java 17+ with keytool.") +} + +/// The `java`/`keytool` executable names under a `bin` directory. +pub fn java_tool(bin: &Path, name: &str) -> PathBuf { + bin.join(java_name(name)) +} + +fn java_major(output: &str) -> Option { + output.lines().find_map(|line| { + let version = line.trim().strip_prefix("java.specification.version = ")?; + version.strip_prefix("1.").unwrap_or(version).parse().ok() + }) +} + +fn supported_java(bin: &Path) -> bool { + if !bin.join(java_name("java")).is_file() || !bin.join(java_name("keytool")).is_file() { + return false; + } + let Ok(output) = Command::new(bin.join(java_name("java"))) + .args(["-XshowSettings:properties", "-version"]) + .output() + else { + return false; + }; + output.status.success() + && java_major(&String::from_utf8_lossy(&output.stderr)).is_some_and(|v| v >= 17) +} + +pub fn install_java(destination: &Path) -> Result<()> { + ensure!( + cfg!(target_arch = "x86_64") && (cfg!(windows) || cfg!(target_os = "linux")), + "Automatic Java setup supports x86-64 Windows and Linux only." + ); + ensure!( + !destination.exists(), + "Java folder already exists but is unusable: {}. Move it aside, then retry.", + destination.display() + ); + let (archive_name, total, hash) = if cfg!(windows) { + ( + "OpenJDK17U-jdk_x64_windows_hotspot_17.0.20.1_1.zip", + 190_817_615, + "e53a79c3c3d86865bd7e787903884331068e71321714ffd44f145785affc7cb0", + ) + } else { + ( + "OpenJDK17U-jdk_x64_linux_hotspot_17.0.20.1_1.tar.gz", + 193_252_603, + "3808d1d15e3ec6bd5b84057fb5d84c33d8a1536a258146bcea2e603fc726e08e", + ) + }; + let work = tempfile::tempdir_in(destination.parent().context("Java parent missing")?) + .context("cannot write beside the patcher. Move it to a writable folder and retry.")?; + let archive = work.path().join(archive_name); + download( + &format!( + "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20.1%2B1/{archive_name}" + ), + &archive, + total, + )?; + verify_download(&archive, hash)?; + let unpacked = work.path().join("unpacked"); + fs::create_dir(&unpacked)?; + if cfg!(windows) { + ZipArchive::new(fs::File::open(&archive)?)?.extract(&unpacked)?; + } else { + let output = Command::new("tar") + .arg("-xzf") + .arg(&archive) + .arg("-C") + .arg(&unpacked) + .output() + .context("cannot extract Java. Install tar, then retry.")?; + ensure!( + output.status.success(), + "Java extraction failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let folder = fs::read_dir(&unpacked)? + .next() + .context("empty Java archive")?? + .path(); + ensure!( + supported_java(&folder.join("bin")), + "downloaded Java cannot run on this system." + ); + fs::rename(folder, destination).context("cannot install portable Java")?; + Ok(()) +} + +fn download(url: &str, archive: &Path, total: u64) -> Result<()> { + let mut downloaded = 0; + let started = Instant::now(); + let mut updated = started; + let mut file = fs::File::create(archive)?; + let errors = tempfile::tempfile()?; + let mut child = Command::new(if cfg!(windows) { "curl.exe" } else { "curl" }) + .args([ + "--fail", + "--location", + "--silent", + "--show-error", + "--proto", + "=https", + "--proto-redir", + "=https", + "--connect-timeout", + "30", + "--max-time", + "600", + ]) + .arg(url) + .stdout(Stdio::piped()) + .stderr(errors.try_clone()?) + .spawn() + .context("cannot start curl. Install curl, then retry the download.")?; + let transfer = (|| -> Result<()> { + draw_download(downloaded, total, started.elapsed())?; + let mut stream = child.stdout.take().context("missing download stream")?; + let mut buffer = [0; 64 * 1024]; + loop { + let count = stream.read(&mut buffer)?; + if count == 0 { + break; + } + file.write_all(&buffer[..count])?; + downloaded += count as u64; + if updated.elapsed() >= Duration::from_millis(250) { + draw_download(downloaded, total, started.elapsed())?; + updated = Instant::now(); + } + } + Ok(()) + })(); + if transfer.is_err() { + let _ = child.kill(); + } + let status = child.wait()?; + if transfer.is_ok() { + draw_download(downloaded, total, started.elapsed())?; + } + println!(); + transfer?; + let mut errors = errors; + use std::io::{Seek, SeekFrom}; + errors.seek(SeekFrom::Start(0))?; + let mut message = String::new(); + errors.read_to_string(&mut message)?; + ensure!(status.success(), "download failed: {}", message.trim()); + ensure!(downloaded == total, "incomplete download"); + drop(file); + Ok(()) +} + +fn download_progress(downloaded: u64, total: u64, elapsed: Duration) -> String { + let fraction = (downloaded as f64 / total as f64).min(1.0); + let filled = (fraction * 20.0) as usize; + let speed = downloaded as f64 / elapsed.as_secs_f64().max(0.001); + let remaining = if downloaded >= total { + "00:00".to_owned() + } else if downloaded == 0 { + "--:--".to_owned() + } else { + let seconds = ((total - downloaded) as f64 / speed).ceil() as u64; + format!("{:02}:{:02}", seconds / 60, seconds % 60) + }; + format!( + "Downloading ... [{}{}] {:3.0}% {:.2} MB/s ETA {remaining}", + "X".repeat(filled), + " ".repeat(20 - filled), + fraction * 100.0, + speed / 1_000_000.0 + ) +} + +fn draw_download(downloaded: u64, total: u64, elapsed: Duration) -> Result<()> { + crossterm::execute!( + std::io::stdout(), + crossterm::cursor::MoveToColumn(0), + crossterm::terminal::Clear(crossterm::terminal::ClearType::CurrentLine), + crossterm::style::Print(download_progress(downloaded, total, elapsed)) + )?; + Ok(()) +} + +fn verify_download(archive: &Path, hash: &str) -> Result<()> { + let mut file = fs::File::open(archive)?; + let mut digest = Sha256::new(); + std::io::copy(&mut file, &mut digest)?; + ensure!( + format!("{:x}", digest.finalize()) == hash, + "download checksum mismatch; nothing installed." + ); + Ok(()) +} diff --git a/src/cli/args.rs b/src/cli/args.rs new file mode 100644 index 0000000..c0138b8 --- /dev/null +++ b/src/cli/args.rs @@ -0,0 +1,329 @@ +//! Non-interactive command-line interface, intended for scripting and for +//! driving the patcher from another application. +//! +//! Contract: the process exit code is authoritative — `0` success, `1` a +//! patch/runtime error, `2` a usage error. The whole command line is +//! validated before any patch work starts. With `--json`, the final line of +//! stdout is a single JSON object (`{"ok":true,"command":...,"output":...}` +//! or `{"ok":false,"error":...}`), usage errors included; human progress is +//! printed before it. +use crate::patch::{ + ace, + dispatch::{Target, android, windows}, + hoyokprotect, protection, rsa, +}; +use anyhow::Result; +use std::{collections::HashMap, path::PathBuf, process::ExitCode}; + +const USAGE: &str = "\ +Honkai Impact 3rd patcher + +Usage: + Hi3-Patcher Launch the interactive menu + Hi3-Patcher [options] + +Commands: + dispatch Redirect dispatch & SDK URLs to a server + --platform --input + --server [--https] + rsa Replace the passport RSA public key + --platform --input [--key ] + ace Remove ACE from a Windows client (<= 8.9); requires a capture + --input [--yes] + hoyok Disable HoYoKProtect in a Windows client (9.0+) + --input + protection Remove Android protection and re-sign the APK + --input [--variant ] + +Options: + --json Emit a single JSON result object as the last stdout line + -h, --help, -V, --version + +Windows --input accepts the game folder or its BH3.exe. +rsa --key defaults to the PemukulPaku public key when omitted."; + +/// Options each command accepts: `(value options, switches)`. Every command +/// also accepts `--json`. +fn accepted(command: &str) -> Option<(&'static [&'static str], &'static [&'static str])> { + Some(match command { + "dispatch" => (&["platform", "input", "server"], &["https"]), + "rsa" => (&["platform", "input", "key"], &[]), + "ace" => (&["input"], &["yes"]), + "hoyok" => (&["input"], &[]), + "protection" => (&["input", "variant"], &[]), + _ => return None, + }) +} + +struct Parsed { + values: HashMap, + switches: Vec, +} + +/// Split `--name value`, `--name=value` and `--switch` tokens, rejecting +/// anything the command does not accept. Errors are usage errors. +fn parse(command: &str, rest: &[String]) -> Result { + let (options, flags) = + accepted(command).ok_or_else(|| format!("unknown command: {command}"))?; + let mut values = HashMap::new(); + let mut switches: Vec = Vec::new(); + let mut it = rest.iter(); + while let Some(token) = it.next() { + let Some(name) = token.strip_prefix("--") else { + return Err(format!("unexpected argument: {token}")); + }; + let (name, inline) = match name.split_once('=') { + Some((name, value)) => (name, Some(value)), + None => (name, None), + }; + if name == "json" || flags.contains(&name) { + if inline.is_some() { + return Err(format!("--{name} does not take a value")); + } + if switches.iter().any(|s| s == name) { + return Err(format!("--{name} given more than once")); + } + switches.push(name.to_owned()); + } else if options.contains(&name) { + let value = match inline { + Some(value) => value.to_owned(), + None => it + .next() + .filter(|value| !value.starts_with("--")) + .ok_or_else(|| format!("--{name} needs a value"))? + .clone(), + }; + if values.insert(name.to_owned(), value).is_some() { + return Err(format!("--{name} given more than once")); + } + } else { + return Err(format!("{command} does not accept --{name}")); + } + } + Ok(Parsed { values, switches }) +} + +impl Parsed { + fn required(&self, name: &str) -> Result<&str, String> { + match self.values.get(name).map(String::as_str) { + None | Some("") => Err(format!("--{name} is required")), + Some(value) => Ok(value), + } + } + fn has(&self, name: &str) -> bool { + self.switches.iter().any(|s| s == name) + } +} + +enum Platform { + Windows, + Android, +} + +enum Variant { + Auto, + Ano, + Tss, +} + +/// A fully validated command, ready to run. +enum Job { + Dispatch { platform: Platform, input: PathBuf, target: Target }, + Rsa { platform: Platform, input: PathBuf, key: String }, + Ace { input: PathBuf, yes: bool }, + Hoyok { input: PathBuf }, + Protection { input: PathBuf, variant: Variant }, +} + +fn parse_platform(args: &Parsed) -> Result { + match args.required("platform")? { + "windows" | "win" => Ok(Platform::Windows), + "android" => Ok(Platform::Android), + other => Err(format!("unknown platform: {other}")), + } +} + +/// Resolve `--input`; a Windows input may be the game folder or its BH3.exe. +fn input_path(args: &Parsed, platform: &Platform) -> Result { + let path = PathBuf::from(args.required("input")?); + Ok(match platform { + Platform::Windows if path.is_dir() => path.join("BH3.exe"), + _ => path, + }) +} + +/// Check a parsed command line per command. Errors are usage errors. +fn job(command: &str, args: &Parsed) -> Result { + Ok(match command { + "dispatch" => { + let platform = parse_platform(args)?; + let input = input_path(args, &platform)?; + let target = Target::new(args.required("server")?, args.has("https")) + .map_err(|error| format!("--server: {error:#}"))?; + Job::Dispatch { platform, input, target } + } + "rsa" => { + let platform = parse_platform(args)?; + let input = input_path(args, &platform)?; + let key = args.values.get("key").cloned().unwrap_or_default(); + rsa::public_body(&key).map_err(|error| format!("--key: {error:#}"))?; + Job::Rsa { platform, input, key } + } + "ace" => Job::Ace { + input: input_path(args, &Platform::Windows)?, + yes: args.has("yes"), + }, + "hoyok" => Job::Hoyok { + input: input_path(args, &Platform::Windows)?, + }, + "protection" => Job::Protection { + input: input_path(args, &Platform::Android)?, + variant: match args.values.get("variant").map(String::as_str) { + None | Some("auto") => Variant::Auto, + Some("ano") => Variant::Ano, + Some("tss") => Variant::Tss, + Some(other) => return Err(format!("unknown variant: {other}")), + }, + }, + other => return Err(format!("unknown command: {other}")), + }) +} + +/// Run a validated job. Returns the output path for Android patches. +fn execute(job: Job) -> Result> { + Ok(match job { + Job::Dispatch { platform: Platform::Windows, input, target } => { + windows::patch(&input, &target)?; + None + } + Job::Dispatch { platform: Platform::Android, input, target } => { + Some(android::patch(&input, &target)?) + } + Job::Rsa { platform: Platform::Windows, input, key } => { + rsa::patch_windows(&input, &key)?; + None + } + Job::Rsa { platform: Platform::Android, input, key } => { + Some(rsa::patch_android(&input, &key)?) + } + Job::Ace { input, yes } => { + ace::patch(&input, |_| Ok(yes))?; + None + } + Job::Hoyok { input } => { + hoyokprotect::patch(&input)?; + None + } + Job::Protection { input, variant } => Some(match variant { + Variant::Auto => protection::auto(&input)?, + Variant::Ano => protection::anosdk(&input)?, + Variant::Tss => protection::mobile_ace_tss(&input)?, + }), + }) +} + +fn report(command: &str, result: Result>, json: bool) -> ExitCode { + match result { + Ok(output) => { + let out = output.as_ref().map(|p| p.display().to_string()); + if json { + println!( + "{}", + serde_json::json!({"ok": true, "command": command, "output": out}) + ); + } else if let Some(path) = out { + println!("Output: {path}"); + } + ExitCode::SUCCESS + } + Err(error) => { + let message = format!("{error:#}"); + if json { + println!("{}", serde_json::json!({"ok": false, "error": message})); + } else { + eprintln!("error: {message}"); + } + ExitCode::FAILURE + } + } +} + +fn usage_error(message: &str, json: bool) -> ExitCode { + if json { + println!("{}", serde_json::json!({"ok": false, "error": message})); + } else { + eprintln!("error: {message}\n\n{USAGE}"); + } + ExitCode::from(2) +} + +/// Entry point for a non-empty argument list (the command and its options). +pub fn run(argv: &[String]) -> ExitCode { + let command = argv[0].as_str(); + let rest = &argv[1..]; + if matches!(command, "-h" | "--help" | "help") + || rest.iter().any(|a| a == "-h" || a == "--help") + { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + if matches!(command, "-V" | "--version" | "version") { + println!("Hi3-Patcher {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } + let json = rest.iter().any(|a| a == "--json"); + match parse(command, rest).and_then(|args| job(command, &args)) { + Ok(job) => report(command, execute(job), json), + Err(message) => usage_error(&message, json), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Validate a command line the way `run` does, without executing it. + fn check(line: &[&str]) -> Result { + let argv: Vec = line.iter().map(|s| s.to_string()).collect(); + parse(&argv[0], &argv[1..]).and_then(|args| job(&argv[0], &args)) + } + + #[test] + fn accepts_valid_command_lines() { + let cases: &[&[&str]] = &[ + &["dispatch", "--platform", "windows", "--input", "x", "--server", "1.1.1.1"], + &["dispatch", "--platform=android", "--input=a.apk", "--server=h:3000", "--https", "--json"], + &["rsa", "--platform", "win", "--input", "x"], + &["rsa", "--platform", "windows", "--input", "x", "--key", ""], + &["ace", "--input", "x", "--yes"], + &["hoyok", "--input", "x"], + &["protection", "--input", "a.apk", "--variant", "tss"], + ]; + for line in cases { + assert!(check(line).is_ok(), "rejected: {line:?}"); + } + } + + #[test] + fn rejects_usage_errors() { + let cases: &[&[&str]] = &[ + &["bogus"], + &["dispatch", "--platform", "windows", "--input", "x"], + &["dispatch", "--platform", "plan9", "--input", "x", "--server", "h"], + &["dispatch", "--platform", "windows", "--input", "x", "--server", "h/p"], + &["dispatch", "stray"], + &["dispatch", "--input"], + &["dispatch", "--input", "--json"], + &["dispatch", "--platform", "windows", "--input", "x", "--server", "h", "--sever", "h"], + &["rsa", "--platform", "windows", "--input", "x", "--key", "abc"], + &["ace", "--input", "x", "--https"], + &["ace", "--input", "a", "--input", "b"], + &["ace", "--input", "x", "--yes=1"], + &["hoyok", "--input", ""], + &["protection", "--input", "a.apk", "--variant", "x"], + ]; + for line in cases { + assert!(check(line).is_err(), "accepted: {line:?}"); + } + } +} diff --git a/src/cli/interactive.rs b/src/cli/interactive.rs new file mode 100644 index 0000000..03ecbcb --- /dev/null +++ b/src/cli/interactive.rs @@ -0,0 +1,435 @@ +//! Interactive terminal front end: arrow-key menus and path prompts. +use crate::{ + android::toolchain, + patch::{ + ace, + dispatch::{Target, android, windows}, + hoyokprotect, protection, rsa, + }, +}; +use anyhow::{Result, bail}; +use crossterm::{ + cursor::{Hide, MoveTo, Show, position}, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + execute, + style::{Color, Print, ResetColor, SetForegroundColor}, + terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use std::{ + io::{self, Write}, + path::PathBuf, +}; + +struct Screen; +impl Drop for Screen { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + let _ = execute!(io::stdout(), Show, LeaveAlternateScreen); + } +} + +struct RawInput; +impl Drop for RawInput { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + let _ = execute!(io::stdout(), Show); + } +} + +fn screen(title: &str) -> Result<()> { + execute!(io::stdout(), MoveTo(0, 0), Clear(ClearType::All))?; + print!("Honkai Impact 3 - Patcher\r\n"); + print!("{:>53}\r\n", concat!("Ver: ", env!("CARGO_PKG_VERSION"))); + print!("=====================================================\r\n{title}\r\n\r\n"); + Ok(()) +} + +// Returns the activated shortcut; arrows only change the highlight. +fn selection(key: KeyEvent, selected: &mut usize, options: &[(char, &str)]) -> Option { + if key.kind == KeyEventKind::Release { + return None; + } + match key.code { + KeyCode::Up => *selected = (*selected + options.len() - 1) % options.len(), + KeyCode::Down => *selected = (*selected + 1) % options.len(), + KeyCode::Enter if key.kind == KeyEventKind::Press => return Some(options[*selected].0), + KeyCode::Esc => return Some('0'), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Some('0'), + KeyCode::Char(c) if key.kind == KeyEventKind::Press && options.iter().any(|o| o.0 == c) => { + return Some(c); + } + _ => {} + } + None +} + +fn menu(title: &str, options: &[(char, &str)]) -> Result { + terminal::enable_raw_mode()?; + let _raw = RawInput; + execute!(io::stdout(), Hide)?; + let mut selected = 0; + loop { + screen(title)?; + io::stdout().flush()?; + let (_, first_row) = position()?; + for (index, (key, label)) in options.iter().enumerate() { + print!("{} {key}. {label}\r\n", if index == selected { ">" } else { " " }); + } + io::stdout().flush()?; + loop { + match event::read()? { + Event::Key(key) => { + let previous = selected; + if let Some(choice) = selection(key, &mut selected, options) { + return Ok(choice); + } + if previous != selected { + execute!( + io::stdout(), + MoveTo(0, first_row + previous as u16), + Print(" "), + MoveTo(0, first_row + selected as u16), + Print(">") + )?; + } + } + Event::Resize(..) => break, + _ => {} + } + } + } +} + +fn prompt(label: &str) -> Result { + print!("{label}"); + io::stdout().flush()?; + let mut value = String::new(); + if io::stdin().read_line(&mut value)? == 0 { + bail!("input closed"); + } + Ok(value.trim().trim_matches('"').trim_matches('\'').to_owned()) +} + +fn prompt_path(label: &str) -> Result> { + print!("{label}"); + io::stdout().flush()?; + let (column, row) = position()?; + terminal::enable_raw_mode()?; + let _raw = RawInput; + let mut value = String::new(); + loop { + match event::read()? { + Event::Key(key) if key.kind != KeyEventKind::Release => match key.code { + KeyCode::Esc => return Ok(None), + KeyCode::Enter => { + print!("\r\n"); + return Ok(Some(PathBuf::from( + value.trim().trim_matches('"').trim_matches('\''), + ))); + } + KeyCode::Backspace => { + value.pop(); + } + KeyCode::Char(c) + if !key.modifiers.contains(KeyModifiers::CONTROL) + || key.modifiers.contains(KeyModifiers::ALT) => + { + value.push(c); + } + _ => continue, + }, + Event::Paste(text) => value.extend(text.chars().filter(|c| !matches!(c, '\r' | '\n'))), + _ => continue, + } + execute!( + io::stdout(), + MoveTo(column, row), + Clear(ClearType::FromCursorDown), + Print(&value) + )?; + } +} + +fn prompt_rsa_key() -> Result { + println!("\nNote: Press Enter for Default used by PemukulPaku"); + print!("Public key: "); + io::stdout().flush()?; + let (column, row) = position()?; + terminal::enable_raw_mode()?; + let _raw = RawInput; + let mut value = String::new(); + loop { + execute!(io::stdout(), MoveTo(column, row), Clear(ClearType::FromCursorDown))?; + if value.is_empty() { + execute!( + io::stdout(), + SetForegroundColor(Color::DarkGrey), + Print(rsa::DEFAULT_BODY), + ResetColor, + MoveTo(column, row) + )?; + } else { + print!("{value}"); + } + io::stdout().flush()?; + match event::read()? { + Event::Key(key) if key.kind != KeyEventKind::Release => match key.code { + KeyCode::Enter => { + execute!(io::stdout(), MoveTo(column, row), Clear(ClearType::FromCursorDown))?; + if value.is_empty() { + execute!( + io::stdout(), + SetForegroundColor(Color::DarkGrey), + Print("(default)"), + ResetColor + )?; + } else { + print!("{value}"); + } + print!("\r\n"); + return Ok(value); + } + KeyCode::Backspace => { + value.pop(); + } + KeyCode::Char(c) + if !key.modifiers.contains(KeyModifiers::CONTROL) + || key.modifiers.contains(KeyModifiers::ALT) => + { + value.push(c); + } + _ => {} + }, + Event::Paste(text) => value.extend(text.chars().filter(|c| !c.is_whitespace())), + _ => {} + } + } +} + +fn prompt_bh3_exe() -> Result> { + println!("Enter full Path to the Game folder."); + println!("Example: D:\\example\\some folder\\BH3_vX.X.0_becb21cb288f\n"); + let Some(path) = prompt_path("Full path to BH3: ")? else { + return Ok(None); + }; + Ok(Some(if path.is_dir() { + path.join("BH3.exe") + } else { + path + })) +} + +fn dispatch_patch(platform: char) -> Result<()> { + let platform_name = if platform == '1' { "Windows" } else { "Android" }; + let section = format!("Platform > {platform_name} > Dispatch & SDK Patch"); + let https = match menu( + &format!( + "{section} / Protocol\r\n\r\nPatch Client to redirect to a different IP or Domain.\r\nNote: Normally it's HTTPS, but pemu uses HTTP for simpler setup\r\nso the client does not need a Cert Patch." + ), + &[('1', "HTTP (default)"), ('2', "HTTPS"), ('0', "Back")], + )? { + '1' => false, + '2' => true, + _ => return Ok(()), + }; + screen(&format!("{section} / Address"))?; + println!("Patch Client to redirect to a different IP or Domain.\n"); + println!("Example: 127.0.0.1 or example.com"); + let target = Target::new(&prompt("Address: ")?, https)?; + screen(&format!("{section} / Client"))?; + let input = if platform == '1' { + prompt_bh3_exe()? + } else { + prompt_path("Full path to APK: ")? + }; + let Some(input) = input else { return Ok(()) }; + screen(&format!("{section} / Patching"))?; + if platform == '1' { + windows::patch(&input, &target)?; + } else { + android::patch(&input, &target)?; + } + prompt("\nPress Enter to return...")?; + Ok(()) +} + +fn setup_android() -> Result { + if toolchain::build_tools().is_err() { + if menu( + "Platform > Android > Setup\r\n\r\nThis patch requires Android SDK Build-Tools 35.0.0 (~60 MB). Download them?", + &[('1', "Yes"), ('2', "No")], + )? != '1' + { + return Ok(false); + } + screen("Platform > Android > Setup / Downloading Build-Tools")?; + toolchain::install_build_tools(&toolchain::local_build_tools()?)?; + } + if toolchain::check_java().is_err() { + if menu( + "Platform > Android > Setup\r\n\r\nThis patch requires Java 17+. Download portable Java (~190 MB)?", + &[('1', "Yes"), ('2', "No")], + )? != '1' + { + return Ok(false); + } + screen("Platform > Android > Setup / Downloading Java")?; + toolchain::install_java(&toolchain::local_java()?)?; + toolchain::check_java()?; + } + Ok(true) +} + +fn patch_options(platform: char) -> &'static [(char, &'static str)] { + if platform == '1' { + &[ + ('1', "Dispatch & SDK Patch"), + ('2', "RSA Patch"), + ('3', "ACE Patch"), + ('4', "HoYoKProtect Patch"), + ('0', "Back"), + ] + } else { + &[ + ('1', "Dispatch & SDK Patch"), + ('2', "RSA Patch"), + ('3', "Mobile ACE/TSS Patch (CN)"), + ('4', "AnoSDK Patch (SEA, GLB, JP, KR, TW)"), + ('0', "Back"), + ] + } +} + +pub fn run() -> Result<()> { + let _screen = Screen; + execute!(io::stdout(), EnterAlternateScreen)?; + loop { + let platform = menu( + "Platform:\r\n\r\nSelect the Client Platform you want to Patch.", + &[ + ('1', "Windows"), + ('2', "Android"), + ('3', "iOS (not implemented yet)"), + ('0', "Exit"), + ], + )?; + if platform == '0' { + return Ok(()); + } + if platform == '3' { + continue; + } + let platform_path = if platform == '1' { + "Platform > Windows" + } else { + "Platform > Android" + }; + if platform == '2' { + match setup_android() { + Ok(true) => {} + Ok(false) => continue, + Err(error) => { + screen("Platform > Android > Setup / Error")?; + println!("{error:#}"); + prompt("\nPress Enter to return...")?; + continue; + } + } + } + loop { + match menu(platform_path, patch_options(platform))? { + '0' => break, + '1' => { + if let Err(error) = dispatch_patch(platform) { + println!("[x] {error:#}"); + prompt("\nPress Enter to return...")?; + } + } + '2' => { + screen(&format!("{platform_path} > RSA Patch"))?; + println!("Replaces the RSA Public Key used by Ma-Passport"); + println!( + "Note: if you use a different RSA Public key make sure it matches the PemukulPaku's appsettings.json Private Key" + ); + if platform == '2' { + println!("Creates a separate signed APK. The original stays unchanged."); + } + let input = if platform == '1' { + prompt_bh3_exe()? + } else { + prompt_path("Full path to APK: ")? + }; + let Some(input) = input else { continue }; + let key = prompt_rsa_key()?; + let result = if platform == '1' { + rsa::patch_windows(&input, &key) + } else { + rsa::patch_android(&input, &key).map(|_| ()) + }; + if let Err(error) = result { + println!("[x] {error:#}"); + } + prompt("\nPress Enter to return...")?; + } + '3' => { + if platform == '2' { + screen(&format!("{platform_path} > Mobile ACE/TSS Patch"))?; + println!("Removes client Protection and creates a separate signed APK."); + let Some(input) = prompt_path("Full path to APK: ")? else { + continue; + }; + if let Err(error) = protection::mobile_ace_tss(&input) { + println!("[x] {error:#}"); + } + } else { + screen(&format!("{platform_path} > ACE Patch"))?; + println!("Removes ACE from the Game Client."); + println!("Note: Only for clients 8.9 and below, 9.0+ clients use HoYoKProtect."); + let Some(input) = prompt_bh3_exe()? else { + continue; + }; + if let Err(error) = ace::patch(&input, |_| { + let choice = menu( + &format!( + "{platform_path} > ACE Patch\r\n\r\nThis patch will start the client to capture initialization." + ), + &[('1', "Yes"), ('2', "No")], + )?; + screen(&format!("{platform_path} > ACE Patch"))?; + Ok(choice == '1') + }) { + println!("[x] {error:#}"); + } + } + prompt("\nPress Enter to return...")?; + } + '4' => { + if platform == '2' { + screen(&format!("{platform_path} > AnoSDK Patch"))?; + println!("Removes client Protection and creates a separate signed APK."); + let Some(input) = prompt_path("Full path to APK: ")? else { + continue; + }; + if let Err(error) = protection::anosdk(&input) { + println!("[x] {error:#}"); + } + } else { + screen(&format!("{platform_path} > HoYoKProtect Patch"))?; + println!("Removes HoYoKProtect from the Game Client."); + let Some(input) = prompt_bh3_exe()? else { + continue; + }; + if let Err(error) = hoyokprotect::patch(&input) { + println!("[x] {error:#}"); + } + } + prompt("\nPress Enter to return...")?; + } + _ => { + screen("Not implemented yet. No files changed.")?; + prompt("Press Enter to return...")?; + } + } + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..a873766 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,20 @@ +//! Front ends: an interactive terminal menu and an argument-driven interface. +//! With no arguments the interactive menu runs; otherwise the first argument +//! selects a command (see [`args`]). +pub mod args; +pub mod interactive; + +use std::process::ExitCode; + +pub fn run() -> ExitCode { + let argv: Vec = std::env::args().skip(1).collect(); + if argv.is_empty() { + if let Err(error) = interactive::run() { + eprintln!("error: {error:#}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS + } else { + args::run(&argv) + } +} diff --git a/src/format/bytes.rs b/src/format/bytes.rs new file mode 100644 index 0000000..ee84ce7 --- /dev/null +++ b/src/format/bytes.rs @@ -0,0 +1,104 @@ +//! Bounds-checked fixed-width integer reads and writes over byte slices. +//! +//! Every accessor validates its range with checked arithmetic, so a malformed +//! or truncated input yields an error instead of a panic. +use anyhow::{Context, Result, ensure}; + +fn range(data: &[u8], at: usize, len: usize) -> Result<&[u8]> { + let end = at.checked_add(len).context("offset overflow")?; + data.get(at..end).context("unexpected end of data") +} + +/// Read `len` bytes at `at`. +pub fn slice(data: &[u8], at: usize, len: usize) -> Result<&[u8]> { + range(data, at, len) +} + +/// Read a fixed-size array at `at`. +pub fn array(data: &[u8], at: usize) -> Result<[u8; N]> { + Ok(range(data, at, N)?.try_into().unwrap()) +} + +pub fn u16(data: &[u8], at: usize) -> Result { + Ok(u16::from_le_bytes(array(data, at)?)) +} + +pub fn u32(data: &[u8], at: usize) -> Result { + Ok(u32::from_le_bytes(array(data, at)?)) +} + +pub fn u64(data: &[u8], at: usize) -> Result { + Ok(u64::from_le_bytes(array(data, at)?)) +} + +pub fn be32(data: &[u8], at: usize) -> Result { + Ok(u32::from_be_bytes(array(data, at)?)) +} + +pub fn be64(data: &[u8], at: usize) -> Result { + Ok(u64::from_be_bytes(array(data, at)?)) +} + +/// `u32` read widened to `usize`. +pub fn u32z(data: &[u8], at: usize) -> Result { + Ok(u32(data, at)? as usize) +} + +/// Write a little-endian `u32` at `at`. +pub fn put_u32(data: &mut [u8], at: usize, value: u32) -> Result<()> { + let end = at.checked_add(4).context("offset overflow")?; + data.get_mut(at..end) + .context("write out of bounds")? + .copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +/// Write a little-endian `u16` at `at`. +pub fn put_u16(data: &mut [u8], at: usize, value: u16) -> Result<()> { + let end = at.checked_add(2).context("offset overflow")?; + data.get_mut(at..end) + .context("write out of bounds")? + .copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +/// Round `value` up to a multiple of `alignment`, which must be a power of two. +pub fn align_up(value: usize, alignment: usize) -> Result { + ensure!(alignment.is_power_of_two(), "alignment is not a power of two"); + value + .checked_add(alignment - 1) + .map(|v| v & !(alignment - 1)) + .context("alignment overflow") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_and_writes_little_endian() { + let mut data = [0u8; 8]; + put_u32(&mut data, 0, 0x1122_3344).unwrap(); + put_u16(&mut data, 4, 0x5566).unwrap(); + assert_eq!(u32(&data, 0).unwrap(), 0x1122_3344); + assert_eq!(u16(&data, 4).unwrap(), 0x5566); + assert_eq!(u32z(&data, 0).unwrap(), 0x1122_3344); + } + + #[test] + fn rejects_out_of_bounds() { + let data = [0u8; 3]; + assert!(u32(&data, 0).is_err()); + assert!(slice(&data, 2, 4).is_err()); + assert!(put_u32(&mut [0u8; 2], 0, 0).is_err()); + } + + #[test] + fn aligns_up_to_power_of_two() { + assert_eq!(align_up(0, 16).unwrap(), 0); + assert_eq!(align_up(1, 16).unwrap(), 16); + assert_eq!(align_up(16, 16).unwrap(), 16); + assert_eq!(align_up(17, 512).unwrap(), 512); + assert!(align_up(1, 24).is_err()); + } +} diff --git a/src/format/dex.rs b/src/format/dex.rs new file mode 100644 index 0000000..2a13aa1 --- /dev/null +++ b/src/format/dex.rs @@ -0,0 +1,216 @@ +//! DEX helpers: little-endian field access, the shared SHA-1/Adler-32 +//! checksum fix-up, and the CN passport string-data growth used by the +//! dispatch and RSA patches to insert a longer string into a DEX file. +use anyhow::{Context, Result, ensure}; +use sha1::{Digest, Sha1}; + +/// Read a 32-bit little-endian word as `usize`. +pub fn word(data: &[u8], at: usize) -> Result { + Ok(u32::from_le_bytes( + data.get(at..at + 4).context("truncated DEX")?.try_into()?, + ) as usize) +} + +/// Write `value` as a 32-bit little-endian word. +pub fn put(data: &mut [u8], at: usize, value: usize) -> Result<()> { + data.get_mut(at..at + 4) + .context("truncated DEX")? + .copy_from_slice(&u32::try_from(value)?.to_le_bytes()); + Ok(()) +} + +/// Recompute the DEX SHA-1 signature and Adler-32 checksum in place. +pub fn recompute_checksums(data: &mut [u8]) -> Result<()> { + let digest = Sha1::digest(&data[32..]); + data[12..32].copy_from_slice(&digest); + put(data, 8, adler2::adler32_slice(&data[12..]) as usize) +} + +/// Bytes of string `index` from the string-id table. +pub fn string(data: &[u8], index: usize) -> Result<&[u8]> { + ensure!(index < word(data, 0x38)?, "invalid CN DEX string index"); + let mut at = word(data, word(data, 0x3c)? + index * 4)?; + loop { + let byte = *data.get(at).context("invalid CN DEX string offset")?; + at += 1; + if byte & 0x80 == 0 { + break; + } + } + let tail = data.get(at..).context("invalid CN DEX string")?; + let len = tail + .iter() + .position(|&b| b == 0) + .context("unterminated CN DEX string")?; + Ok(&tail[..len]) +} + +/// Index of the unique string matching `match_value`, if any. +pub fn find_string(data: &[u8], match_value: impl Fn(&[u8]) -> bool) -> Result> { + let mut found = None; + for index in 0..word(data, 0x38)? { + if match_value(string(data, index)?) { + ensure!(found.is_none(), "ambiguous CN DEX string"); + found = Some(index); + } + } + Ok(found) +} + +/// File offset of the unique `const-string` instruction referencing `index`. +pub fn find_const_string(data: &[u8], index: usize) -> Result { + let index = u16::try_from(index)?.to_le_bytes(); + let mut found = None; + for (at, part) in data.windows(4).enumerate() { + if part[0] == 0x1a && part[2..4] == index { + ensure!(found.is_none(), "ambiguous CN DEX const-string"); + found = Some(at); + } + } + found.context("missing CN DEX const-string") +} + +/// Map section `(size, offset)` for map-list entry type `ty`. +pub fn section(data: &[u8], ty: u16) -> Result<(usize, usize)> { + let map = word(data, 0x34)?; + let mut found = None; + for i in 0..word(data, map)? { + let at = map + 4 + i * 12; + if u16::from_le_bytes(data.get(at..at + 2).context("invalid CN DEX map")?.try_into()?) == ty + { + ensure!(found.is_none(), "duplicate CN DEX map section"); + found = Some((word(data, at + 4)?, word(data, at + 8)?)); + } + } + found.context("missing CN DEX map section") +} + +fn shift(data: &mut [u8], at: usize, insertion: usize, delta: usize) -> Result<()> { + let value = word(data, at)?; + if value >= insertion { + put( + data, + at, + value.checked_add(delta).context("CN DEX offset overflow")?, + )?; + } + Ok(()) +} + +/// Append one ASCII `string_data_item` before annotations and relocate every +/// later offset. Returns the file offset of the inserted string data. +pub fn append_string(data: &mut Vec, value: &[u8]) -> Result { + ensure!( + data.starts_with(b"dex\n") && word(data, 0x20)? == data.len(), + "invalid CN DEX" + ); + ensure!( + value.is_ascii() && !value.contains(&0), + "invalid CN DEX string" + ); + let map = word(data, 0x34)?; + let (strings, string_start) = section(data, 0x2002)?; + let (_, insertion) = section(data, 0x2004)?; + let (sets, mut set_at) = section(data, 0x1003)?; + let (refs, mut ref_at) = section(data, 0x1002)?; + let (dirs, mut dir_at) = section(data, 0x2006)?; + ensure!( + strings >= word(data, 0x38)? + && string_start < insertion + && insertion < set_at + && set_at < ref_at + && ref_at < dir_at + && dir_at < map, + "unsupported CN passport DEX layout" + ); + let mut string_end = string_start; + for _ in 0..strings { + loop { + let byte = *data + .get(string_end) + .context("truncated CN DEX string length")?; + string_end += 1; + if byte & 0x80 == 0 { + break; + } + } + let tail = data + .get(string_end..insertion) + .context("invalid CN DEX string section")?; + string_end += tail + .iter() + .position(|&b| b == 0) + .context("unterminated CN DEX string")? + + 1; + } + ensure!( + string_end <= insertion && insertion - string_end < 4, + "invalid CN DEX string padding" + ); + let old_padding = insertion - string_end; + let mut item = Vec::new(); + let mut length = value.len(); + loop { + let byte = (length & 0x7f) as u8; + length >>= 7; + item.push(if length == 0 { byte } else { byte | 0x80 }); + if length == 0 { + break; + } + } + item.extend_from_slice(value); + item.push(0); + let delta = (item.len() - old_padding + 3) & !3; + item.resize(old_padding + delta, 0); + + for i in 0..word(data, 0x60)? { + let at = word(data, 0x64)? + i * 32; + for field in [20, 24, 28] { + shift(data, at + field, insertion, delta)?; + } + } + for _ in 0..sets { + let count = word(data, set_at)?; + for i in 0..count { + shift(data, set_at + 4 + i * 4, insertion, delta)?; + } + set_at += 4 + count * 4; + } + ensure!(set_at == ref_at, "CN DEX annotation sets changed"); + for _ in 0..refs { + let count = word(data, ref_at)?; + for i in 0..count { + shift(data, ref_at + 4 + i * 4, insertion, delta)?; + } + ref_at += 4 + count * 4; + } + ensure!(ref_at == dir_at, "CN DEX annotation refs changed"); + for _ in 0..dirs { + shift(data, dir_at, insertion, delta)?; + let count = [4, 8, 12] + .into_iter() + .map(|field| word(data, dir_at + field)) + .sum::>()?; + dir_at += 16; + for _ in 0..count { + shift(data, dir_at + 4, insertion, delta)?; + dir_at += 8; + } + } + ensure!(dir_at == map, "CN DEX annotation directories changed"); + for i in 0..word(data, map)? { + let at = map + 4 + i * 12; + let ty = u16::from_le_bytes(data[at..at + 2].try_into()?); + if ty == 0x2002 { + put(data, at + 4, strings + 1)?; + } + shift(data, at + 8, insertion, delta)?; + } + let size = data.len() + delta; + let data_size = word(data, 0x68)? + delta; + put(data, 0x34, map + delta)?; + put(data, 0x20, size)?; + put(data, 0x68, data_size)?; + data.splice(string_end..insertion, item); + Ok(string_end) +} diff --git a/src/format/elf.rs b/src/format/elf.rs new file mode 100644 index 0000000..393ec37 --- /dev/null +++ b/src/format/elf.rs @@ -0,0 +1,212 @@ +//! Minimal ARM64 ELF reader shared by the Android protection-removal code. +//! +//! Covers the section table, program headers, dynamic symbols and RELA +//! tables of a `ET_DYN` AArch64 shared object. Validation matches what the +//! restore and neutralize passes relied on previously. +use crate::format::bytes::{self, slice, u16 as rd_u16, u32, u64 as rd_u64}; +use anyhow::{Context, Result, ensure}; + +/// Narrow a `u64` file size/offset to `usize`. +pub fn offset(value: u64) -> Result { + usize::try_from(value).context("ELF offset overflow") +} + +fn u16z(data: &[u8], at: usize) -> Result { + Ok(rd_u16(data, at)? as usize) +} + +/// NUL-terminated string at `at` within a string table. +pub fn cstr(table: &[u8], at: usize) -> Result<&[u8]> { + let tail = table.get(at..).context("invalid ELF string offset")?; + Ok(&tail[..tail + .iter() + .position(|b| *b == 0) + .context("unterminated ELF string")?]) +} + +pub struct Section<'a> { + pub name: &'a [u8], + pub kind: u32, + pub flags: u64, + pub rva: u64, + pub at: usize, + pub size: usize, + pub link: usize, +} + +pub struct Segment { + pub kind: u32, + pub flags: u32, + pub rva: u64, + pub at: usize, + pub size: usize, +} + +pub struct Symbol<'a> { + pub name: &'a [u8], + pub section: usize, + pub value: u64, +} + +pub struct Elf<'a> { + pub data: &'a [u8], + pub sections: Vec>, + pub segments: Vec, +} + +impl<'a> Elf<'a> { + pub fn new(data: &'a [u8]) -> Result { + ensure!( + data.len() <= 1 << 30 + && slice(data, 0, 7)? == b"\x7fELF\x02\x01\x01" + && rd_u16(data, 16)? == 3 + && rd_u16(data, 18)? == 183, + "expected an ARM64 ELF shared library" + ); + ensure!( + rd_u16(data, 54)? == 56 && rd_u16(data, 58)? == 64, + "unsupported ELF table layout" + ); + let ph = offset(rd_u64(data, 32)?)?; + let pn = u16z(data, 56)?; + let sh = offset(rd_u64(data, 40)?)?; + let sn = u16z(data, 60)?; + ensure!( + pn > 0 && pn <= 4096 && sn > 0 && sn <= 16384, + "invalid ELF table counts" + ); + slice(data, ph, pn * 56)?; + slice(data, sh, sn * 64)?; + let ni = u16z(data, 62)?; + ensure!(ni < sn, "invalid ELF string table index"); + let names = slice( + data, + offset(rd_u64(data, sh + ni * 64 + 24)?)?, + offset(rd_u64(data, sh + ni * 64 + 32)?)?, + )?; + let mut sections = Vec::new(); + for at in (sh..sh + sn * 64).step_by(64) { + let section = Section { + name: cstr(names, u32(data, at)? as usize)?, + kind: u32(data, at + 4)?, + flags: rd_u64(data, at + 8)?, + rva: rd_u64(data, at + 16)?, + at: offset(rd_u64(data, at + 24)?)?, + size: offset(rd_u64(data, at + 32)?)?, + link: u32(data, at + 40)? as usize, + }; + ensure!( + section + .rva + .checked_add(section.size as u64) + .is_some_and(|v| v <= i64::MAX as u64), + "ELF virtual range overflow" + ); + if section.kind != 8 { + slice(data, section.at, section.size)?; + } + sections.push(section); + } + let mut segments = Vec::new(); + for at in (ph..ph + pn * 56).step_by(56) { + let segment = Segment { + kind: u32(data, at)?, + flags: u32(data, at + 4)?, + rva: rd_u64(data, at + 16)?, + at: offset(rd_u64(data, at + 8)?)?, + size: offset(rd_u64(data, at + 32)?)?, + }; + slice(data, segment.at, segment.size)?; + ensure!( + segment + .rva + .checked_add(segment.size as u64) + .is_some_and(|v| v <= i64::MAX as u64), + "ELF segment overflow" + ); + segments.push(segment); + } + Ok(Self { + data, + sections, + segments, + }) + } + + /// The single section with the given name. + pub fn section(&self, name: &[u8]) -> Result<&Section<'a>> { + let mut found = self.sections.iter().filter(|s| s.name == name); + let section = found.next().context("required ELF section missing")?; + ensure!(found.next().is_none(), "duplicate ELF section"); + Ok(section) + } + + /// File offset of `size` bytes mapped at virtual address `rva` within a + /// single `PT_LOAD` segment. + pub fn rva(&self, rva: u64, size: usize) -> Result { + let mut candidates = self.segments.iter().filter(|s| { + s.kind == 1 + && rva >= s.rva + && rva + .checked_sub(s.rva) + .and_then(|v| v.checked_add(size as u64)) + .is_some_and(|v| v <= s.size as u64) + }); + let segment = candidates.next().context("ELF address is not file backed")?; + ensure!(candidates.next().is_none(), "ambiguous ELF address mapping"); + Ok(segment.at + offset(rva - segment.rva)?) + } + + /// Dynamic symbol table (`.dynsym`). + pub fn symbols(&self) -> Result>> { + let table = self.section(b".dynsym")?; + ensure!( + table.kind == 11 && table.size % 24 == 0, + "invalid dynamic symbol table" + ); + let strings = self + .sections + .get(table.link) + .context("invalid symbol string table")?; + let strings = slice(self.data, strings.at, strings.size)?; + (table.at..table.at + table.size) + .step_by(24) + .map(|at| { + Ok(Symbol { + name: cstr(strings, u32(self.data, at)? as usize)?, + section: u16z(self.data, at + 6)?, + value: rd_u64(self.data, at + 8)?, + }) + }) + .collect() + } + + /// File offsets of every entry across all `SHT_RELA` tables. + pub fn relas(&self) -> Result> { + let mut entries = Vec::new(); + for s in self.sections.iter().filter(|s| s.kind == 4) { + ensure!(s.size % 24 == 0, "invalid RELA table length"); + entries.extend((s.at..s.at + s.size).step_by(24)); + } + Ok(entries) + } +} + +/// Offsets of every non-overlapping occurrence of `pattern` in `data`. +pub fn find_all(data: &[u8], pattern: &[u8]) -> Result> { + let escaped = regex::escape(std::str::from_utf8(pattern)?); + Ok(regex::bytes::Regex::new(&escaped)? + .find_iter(data) + .map(|m| m.start()) + .collect()) +} + +/// The single offset of `pattern` in `data`; errors when absent or repeated. +pub fn find_one(data: &[u8], pattern: &[u8]) -> Result { + let found = find_all(data, pattern)?; + ensure!(found.len() == 1, "missing or ambiguous ELF marker"); + Ok(found[0]) +} + +// Re-export for protection code that threads raw byte reads through the ELF. +pub use bytes::{slice as bytes_slice, u16, u32 as u32le, u64 as u64le}; diff --git a/src/format/mod.rs b/src/format/mod.rs new file mode 100644 index 0000000..795a767 --- /dev/null +++ b/src/format/mod.rs @@ -0,0 +1,7 @@ +//! Binary-format primitives shared across patch modules: byte access, PE, +//! ELF, DEX and Unity (`UnityFS` bundle / serialized file) parsing. +pub mod bytes; +pub mod dex; +pub mod elf; +pub mod pe; +pub mod unity; diff --git a/src/format/pe.rs b/src/format/pe.rs new file mode 100644 index 0000000..78abe42 --- /dev/null +++ b/src/format/pe.rs @@ -0,0 +1,142 @@ +//! Minimal PE32+ (x86-64) reader shared by the Windows patch modules. +//! +//! `read` performs only the structural validation common to every consumer; +//! each module adds its own layout checks after parsing. +use crate::format::bytes::{self, u16, u32, u32z}; +use anyhow::{Context, Result, bail, ensure}; + +pub const SECTION_EXECUTE: u32 = 0x2000_0000; +pub const SECTION_WRITE: u32 = 0x8000_0000; + +pub struct Section { + /// File offset of this entry in the 40-byte section header table. + pub header: usize, + pub name: [u8; 8], + pub virtual_size: u32, + pub rva: u32, + pub raw_size: usize, + pub raw: usize, + pub flags: u32, +} + +pub struct Pe<'a> { + pub data: &'a [u8], + /// Offset of the `PE\0\0` signature. + pub nt: usize, + /// Offset of the optional header. + pub opt: usize, + pub opt_size: usize, + /// Offset of the section header table. + pub table: usize, + pub image_size: u32, + pub sections: Vec
, +} + +impl<'a> Pe<'a> { + pub fn read(data: &'a [u8]) -> Result { + ensure!(data.starts_with(b"MZ"), "not a PE file"); + let nt = u32z(data, 0x3c)?; + ensure!( + bytes::slice(data, nt, 6).ok() == Some(b"PE\0\0\x64\x86"), + "expected an x86-64 PE" + ); + let opt = nt + 24; + ensure!(u16(data, opt)? == 0x20b, "expected a PE32+ optional header"); + let opt_size = u16(data, nt + 20)? as usize; + ensure!(opt_size >= 112, "incomplete PE optional header"); + let count = u16(data, nt + 6)? as usize; + ensure!((1..=96).contains(&count), "invalid PE section count"); + let table = opt + opt_size; + let image_size = u32(data, opt + 56)?; + let mut sections = Vec::with_capacity(count); + for i in 0..count { + let header = table + i * 40; + bytes::slice(data, header, 40)?; + let section = Section { + header, + name: bytes::array(data, header)?, + virtual_size: u32(data, header + 8)?, + rva: u32(data, header + 12)?, + raw_size: u32z(data, header + 16)?, + raw: u32z(data, header + 20)?, + flags: u32(data, header + 36)?, + }; + ensure!( + section + .raw + .checked_add(section.raw_size) + .is_some_and(|end| end <= data.len()), + "PE section exceeds file bounds" + ); + sections.push(section); + } + Ok(Self { + data, + nt, + opt, + opt_size, + table, + image_size, + sections, + }) + } + + /// Validate that every section lies within the declared image size. + pub fn require_sections_within_image(&self) -> Result<()> { + for s in &self.sections { + ensure!( + s.rva + .checked_add(s.virtual_size) + .is_some_and(|end| end <= self.image_size), + "PE section exceeds image bounds" + ); + } + Ok(()) + } + + /// The single section with the given 8-byte name. + pub fn section(&self, name: &[u8; 8]) -> Result<&Section> { + let mut found = self.sections.iter().filter(|s| &s.name == name); + let section = found.next().context("required PE section missing")?; + ensure!(found.next().is_none(), "ambiguous PE section"); + Ok(section) + } + + /// File offset of `len` bytes mapped at `rva`, within one section's raw data. + pub fn offset(&self, rva: u32, len: usize) -> Result { + self.sections + .iter() + .find_map(|s| { + let delta = rva.checked_sub(s.rva)? as usize; + (delta.checked_add(len)? <= s.raw_size).then_some(s.raw + delta) + }) + .context("RVA is not mapped to file data") + } + + /// Raw bytes of `len` mapped at `rva`. + pub fn at_rva(&self, rva: u32, len: usize) -> Result<&'a [u8]> { + let at = self.offset(rva, len)?; + Ok(&self.data[at..at + len]) + } + + /// Data directory `index` as `(rva, size)`. + pub fn data_dir(&self, index: usize) -> Result<(u32, u32)> { + let at = self.opt + 112 + index * 8; + Ok((u32(self.data, at)?, u32(self.data, at + 4)?)) + } + + /// Succeeds when the image exports a symbol named `name` (include the NUL). + pub fn require_export(&self, name: &[u8]) -> Result<()> { + let export = self.at_rva(self.data_dir(0)?.0, 40)?; + let count = u32z(export, 24)?; + ensure!(count > 0 && count <= 100_000, "invalid export name count"); + let names = self.at_rva(u32(export, 32)?, count * 4)?; + for index in 0..count { + let rva = u32(names, index * 4)?; + if self.at_rva(rva, name.len()).is_ok_and(|bytes| bytes == name) { + return Ok(()); + } + } + bail!("required export missing: {}", String::from_utf8_lossy(name)); + } +} diff --git a/src/format/unity.rs b/src/format/unity.rs new file mode 100644 index 0000000..ce4a133 --- /dev/null +++ b/src/format/unity.rs @@ -0,0 +1,531 @@ +use anyhow::{Result, bail, ensure}; +use std::ops::Range; + +struct Reader<'a> { + data: &'a [u8], + pos: usize, +} +impl<'a> Reader<'a> { + fn bytes(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self + .pos + .checked_add(n) + .ok_or_else(|| anyhow::anyhow!("Offset overflow"))?; + let v = self + .data + .get(self.pos..end) + .ok_or_else(|| anyhow::anyhow!("Truncated Unity asset"))?; + self.pos = end; + Ok(v) + } + fn be32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.bytes(4)?.try_into()?)) + } + fn be64(&mut self) -> Result { + Ok(u64::from_be_bytes(self.bytes(8)?.try_into()?)) + } + fn le32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.bytes(4)?.try_into()?)) + } + fn cstring(&mut self) -> Result<&'a [u8]> { + let n = self + .data + .get(self.pos..) + .and_then(|s| s.iter().position(|&b| b == 0)) + .ok_or_else(|| anyhow::anyhow!("Unterminated Unity string"))?; + let v = self.bytes(n)?; + self.bytes(1)?; + Ok(v) + } + fn align(&mut self, n: usize) -> Result<()> { + let pad = (n - self.pos % n) % n; + self.bytes(pad)?; + Ok(()) + } +} + +#[derive(Debug)] +pub struct TextAsset { + pub name: String, + pub script: Range, +} + +pub fn text_assets(data: &[u8]) -> Result> { + let mut h = Reader { data, pos: 0 }; + h.be32()?; + let size = h.be32()? as usize; + let version = h.be32()?; + let offset = h.be32()? as usize; + ensure!( + (17..=21).contains(&version), + "Unsupported Unity serialized version {version}" + ); + ensure!( + size == data.len() && offset <= size, + "Invalid Unity serialized file size" + ); + ensure!( + h.bytes(1)?[0] == 0, + "Big-endian Unity assets are unsupported" + ); + h.bytes(3)?; + h.cstring()?; + h.bytes(4)?; + let tree = h.bytes(1)?[0] != 0; + let count = h.le32()? as usize; + ensure!(count <= data.len() / 23, "Invalid Unity type count"); + let mut types = vec![]; + for _ in 0..count { + let class = h.le32()? as i32; + types.push(class); + h.bytes(3)?; + if class == 114 { + h.bytes(16)?; + } + h.bytes(16)?; + if tree { + let nodes = h.le32()? as usize; + let strings = h.le32()? as usize; + let node_size = if version >= 19 { 32 } else { 24 }; + h.bytes( + nodes + .checked_mul(node_size) + .ok_or_else(|| anyhow::anyhow!("Type tree overflow"))?, + )?; + h.bytes(strings)?; + if version >= 21 { + let dependencies = h.le32()? as usize; + h.bytes( + dependencies + .checked_mul(4) + .ok_or_else(|| anyhow::anyhow!("Dependency overflow"))?, + )?; + } + } + } + let count = h.le32()? as usize; + ensure!(count <= data.len() / 20, "Invalid Unity object count"); + let mut result = vec![]; + for _ in 0..count { + h.align(4)?; + h.bytes(8)?; + let start = h.le32()? as usize; + let len = h.le32()? as usize; + let kind = h.le32()? as usize; + ensure!(kind < types.len(), "Invalid object type index"); + let start = offset + .checked_add(start) + .ok_or_else(|| anyhow::anyhow!("Object offset overflow"))?; + let end = start + .checked_add(len) + .ok_or_else(|| anyhow::anyhow!("Object size overflow"))?; + ensure!(end <= size, "Object outside asset"); + if types[kind] != 49 { + continue; + } + let mut r = Reader { + data: &data[..end], + pos: start, + }; + let n = r.le32()? as usize; + let name = std::str::from_utf8(r.bytes(n)?)?.to_owned(); + r.align(4)?; + let n = r.le32()? as usize; + let begin = r.pos; + r.bytes(n)?; + result.push(TextAsset { + name, + script: begin..r.pos, + }); + } + Ok(result) +} + +pub fn script(data: &[u8], name: &str) -> Result> { + let mut found = text_assets(data)?.into_iter().filter(|a| a.name == name); + let asset = found + .next() + .ok_or_else(|| anyhow::anyhow!("Missing TextAsset {name}"))?; + ensure!(found.next().is_none(), "Duplicate TextAsset {name}"); + Ok(asset.script) +} + +/// Replace the named TextAsset's script with `new_script` of any length, +/// rewriting the object table offsets and the header file size so the +/// serialized file stays valid. The script must be the final field of its +/// object, which holds for Unity `TextAsset`s. The script region is always a +/// multiple of 4 bytes, so the size change is 4-aligned and downstream object +/// alignment is preserved. +pub fn replace_script(data: &[u8], name: &str, new_script: &[u8]) -> Result> { + let mut h = Reader { data, pos: 0 }; + h.be32()?; + let size = h.be32()? as usize; + let version = h.be32()?; + let offset = h.be32()? as usize; + ensure!( + (17..=21).contains(&version), + "Unsupported Unity serialized version {version}" + ); + ensure!( + size == data.len() && offset <= size, + "Invalid Unity serialized file size" + ); + ensure!(h.bytes(1)?[0] == 0, "Big-endian Unity assets are unsupported"); + h.bytes(3)?; + h.cstring()?; + h.bytes(4)?; + let tree = h.bytes(1)?[0] != 0; + let count = h.le32()? as usize; + ensure!(count <= data.len() / 23, "Invalid Unity type count"); + let mut types = vec![]; + for _ in 0..count { + let class = h.le32()? as i32; + types.push(class); + h.bytes(3)?; + if class == 114 { + h.bytes(16)?; + } + h.bytes(16)?; + if tree { + let nodes = h.le32()? as usize; + let strings = h.le32()? as usize; + let node_size = if version >= 19 { 32 } else { 24 }; + h.bytes( + nodes + .checked_mul(node_size) + .ok_or_else(|| anyhow::anyhow!("Type tree overflow"))?, + )?; + h.bytes(strings)?; + if version >= 21 { + let dependencies = h.le32()? as usize; + h.bytes( + dependencies + .checked_mul(4) + .ok_or_else(|| anyhow::anyhow!("Dependency overflow"))?, + )?; + } + } + } + let count = h.le32()? as usize; + ensure!(count <= data.len() / 20, "Invalid Unity object count"); + // (start_field_pos, rel_start, len_field_pos, len, kind) + let mut objects = Vec::with_capacity(count); + for _ in 0..count { + h.align(4)?; + h.bytes(8)?; + let start_pos = h.pos; + let rel_start = h.le32()? as usize; + let len_pos = h.pos; + let len = h.le32()? as usize; + let kind = h.le32()? as usize; + ensure!(kind < types.len(), "Invalid object type index"); + objects.push((start_pos, rel_start, len_pos, len, kind)); + } + let mut target = None; + for &(_, rel_start, len_pos, len, kind) in &objects { + if types[kind] != 49 { + continue; + } + let begin = offset + .checked_add(rel_start) + .ok_or_else(|| anyhow::anyhow!("Object offset overflow"))?; + let end = begin + .checked_add(len) + .ok_or_else(|| anyhow::anyhow!("Object size overflow"))?; + ensure!(end <= size, "Object outside asset"); + let mut r = Reader { + data: &data[..end], + pos: begin, + }; + let n = r.le32()? as usize; + if std::str::from_utf8(r.bytes(n)?)? != name { + continue; + } + r.align(4)?; + let script_len_pos = r.pos; + let old_n = r.le32()? as usize; + let script_begin = r.pos; + r.bytes(old_n)?; + r.align(4)?; + ensure!( + r.pos == end, + "Unsupported TextAsset layout: script is not the final field" + ); + ensure!(target.is_none(), "Duplicate TextAsset {name}"); + target = Some((rel_start, len_pos, len, script_len_pos)); + let _ = script_begin; + } + let (target_start, target_len_pos, target_len, script_len_pos) = + target.ok_or_else(|| anyhow::anyhow!("Missing TextAsset {name}"))?; + // Current script region (length prefix + bytes + alignment), already a + // multiple of 4; and the new region, likewise padded to 4. + let target_end = offset + target_start + target_len; + let old_region = target_end - script_len_pos; + let new_region = (4 + new_script.len() + 3) & !3; + let delta = new_region as isize - old_region as isize; + let new_total = (data.len() as isize + delta) as usize; + + let mut out = Vec::with_capacity(new_total); + out.extend_from_slice(&data[..script_len_pos]); + out.extend_from_slice(&u32::try_from(new_script.len())?.to_le_bytes()); + out.extend_from_slice(new_script); + out.resize(script_len_pos + new_region, 0); + out.extend_from_slice(&data[target_end..]); + debug_assert_eq!(out.len(), new_total); + + let apply = |buf: &mut [u8], at: usize, value: usize| -> Result<()> { + buf[at..at + 4].copy_from_slice(&u32::try_from(value)?.to_le_bytes()); + Ok(()) + }; + // Grow the target object and shift every object stored after it. + apply(&mut out, target_len_pos, (target_len as isize + delta) as usize)?; + for &(start_pos, rel_start, ..) in &objects { + if rel_start > target_start { + apply(&mut out, start_pos, (rel_start as isize + delta) as usize)?; + } + } + out[4..8].copy_from_slice(&u32::try_from(new_total)?.to_be_bytes()); + Ok(out) +} + +struct Block { + raw: Range, + logical: Range, + flags: u16, +} +pub struct Bundle { + header: Vec, + info: Vec, + size_offset: usize, + info_offset: usize, + flags: u32, + blocks: Vec, + pub logical: Vec, +} + +fn decode(data: &[u8], size: usize, flags: u32) -> Result> { + ensure!( + size <= 512 * 1024 * 1024, + "Unity block exceeds 512 MiB safety limit" + ); + let out = match flags & 63 { + 0 => data.to_vec(), + 2 | 3 => lz4_flex::block::decompress(data, size)?, + other => bail!("Unsupported Unity compression {other}"), + }; + ensure!(out.len() == size, "Incorrect decompressed block size"); + Ok(out) +} +fn encode(data: &[u8], flags: u32) -> Result> { + match flags & 63 { + 0 => Ok(data.to_vec()), + 2 | 3 => Ok(lz4_flex::block::compress(data)), + other => bail!("Unsupported Unity compression {other}"), + } +} + +impl Bundle { + pub fn read(data: &[u8]) -> Result { + let mut h = Reader { data, pos: 0 }; + ensure!(h.cstring()? == b"UnityFS", "Not a UnityFS bundle"); + ensure!(h.be32()? == 6, "Only UnityFS version 6 is supported"); + h.cstring()?; + h.cstring()?; + let size_offset = h.pos; + ensure!(h.be64()? == data.len() as u64, "UnityFS file size mismatch"); + let info_offset = h.pos; + let packed = h.be32()? as usize; + let unpacked = h.be32()? as usize; + let flags = h.be32()?; + ensure!( + flags & 0x280 == 0, + "Unsupported UnityFS trailing/aligned block-info layout" + ); + let header = data[..h.pos].to_vec(); + let info = decode(h.bytes(packed)?, unpacked, flags)?; + let mut r = Reader { + data: &info, + pos: 16, + }; + let count = r.be32()? as usize; + ensure!(count <= info.len() / 10, "Invalid UnityFS block count"); + let mut logical = vec![]; + let mut blocks = vec![]; + for _ in 0..count { + let size = r.be32()? as usize; + let packed = r.be32()? as usize; + let flags = u16::from_be_bytes(r.bytes(2)?.try_into()?); + let start = h.pos; + let bytes = decode(h.bytes(packed)?, size, flags as u32)?; + let begin = logical.len(); + ensure!( + begin + .checked_add(size) + .is_some_and(|n| n <= 1024 * 1024 * 1024), + "Bundle exceeds 1 GiB safety limit" + ); + logical.extend(bytes); + blocks.push(Block { + raw: start..h.pos, + logical: begin..logical.len(), + flags, + }); + } + ensure!(h.pos == data.len(), "Unexpected UnityFS trailing data"); + Ok(Self { + header, + info, + size_offset, + info_offset, + flags, + blocks, + logical, + }) + } + pub fn rebuild(&self, original: &[u8], patched: &[u8]) -> Result> { + ensure!( + patched.len() == self.logical.len(), + "Cannot resize logical SDK data" + ); + let mut info = self.info.clone(); + let mut chunks = Vec::new(); + for (i, block) in self.blocks.iter().enumerate() { + let chunk = if patched[block.logical.clone()] == self.logical[block.logical.clone()] { + original[block.raw.clone()].to_vec() + } else { + encode(&patched[block.logical.clone()], block.flags as u32)? + }; + info[24 + i * 10..28 + i * 10] + .copy_from_slice(&u32::try_from(chunk.len())?.to_be_bytes()); + chunks.push(chunk); + } + let info = encode(&info, self.flags)?; + let mut result = self.header.clone(); + result[self.info_offset..self.info_offset + 4] + .copy_from_slice(&u32::try_from(info.len())?.to_be_bytes()); + result.extend(info); + for chunk in chunks { + result.extend(chunk); + } + let size = result.len() as u64; + result[self.size_offset..self.size_offset + 8].copy_from_slice(&size.to_be_bytes()); + ensure!( + Self::read(&result)?.logical == patched, + "UnityFS rebuild verification failed" + ); + Ok(result) + } + // Node offsets locate serialized files inside the bundle's logical data. + pub fn nodes(&self) -> Result>> { + let mut r = Reader { + data: &self.info, + pos: 20 + self.blocks.len() * 10, + }; + let count = r.be32()? as usize; + ensure!(count <= self.info.len() / 21, "Invalid UnityFS node count"); + let mut nodes = vec![]; + for _ in 0..count { + let start = usize::try_from(r.be64()?)?; + let size = usize::try_from(r.be64()?)?; + r.be32()?; + r.cstring()?; + let end = start + .checked_add(size) + .ok_or_else(|| anyhow::anyhow!("Node overflow"))?; + ensure!(end <= self.logical.len(), "Node outside bundle"); + nodes.push(start..end); + } + Ok(nodes) + } + + /// Replace node `index`'s bytes with `new_node` of any length, re-chunking + /// and re-encoding the archive and fixing the node directory. Used to grow + /// a serialized file inside the bundle when a patched URL no longer fits. + pub fn replace_node(&self, index: usize, new_node: &[u8]) -> Result> { + let nodes = self.nodes()?; + let node = nodes + .get(index) + .ok_or_else(|| anyhow::anyhow!("Node index out of range"))? + .clone(); + let delta = new_node.len() as isize - (node.end - node.start) as isize; + let mut logical = Vec::with_capacity((self.logical.len() as isize + delta) as usize); + logical.extend_from_slice(&self.logical[..node.start]); + logical.extend_from_slice(new_node); + logical.extend_from_slice(&self.logical[node.end..]); + + ensure!(!self.blocks.is_empty(), "Bundle has no blocks"); + let flags = self.blocks[0].flags; + ensure!( + self.blocks.iter().all(|b| b.flags == flags), + "Mixed block flags unsupported for resize" + ); + let chunk = self.blocks[0].logical.len(); + ensure!( + chunk > 0 + && self.blocks[..self.blocks.len() - 1] + .iter() + .all(|b| b.logical.len() == chunk), + "Non-uniform block sizing unsupported for resize" + ); + + let mut block_table = Vec::new(); + let mut chunks: Vec> = Vec::new(); + for piece in logical.chunks(chunk) { + let encoded = encode(piece, flags as u32)?; + block_table.extend_from_slice(&u32::try_from(piece.len())?.to_be_bytes()); + block_table.extend_from_slice(&u32::try_from(encoded.len())?.to_be_bytes()); + block_table.extend_from_slice(&flags.to_be_bytes()); + chunks.push(encoded); + } + + let mut r = Reader { + data: &self.info, + pos: 20 + self.blocks.len() * 10, + }; + let node_count = r.be32()? as usize; + let mut directory = Vec::new(); + directory.extend_from_slice(&u32::try_from(node_count)?.to_be_bytes()); + for i in 0..node_count { + let off = r.be64()?; + let sz = r.be64()?; + let nflags = r.be32()?; + let path = r.cstring()?; + let (off, sz) = if i == index { + (off, (sz as isize + delta) as u64) + } else if off >= node.end as u64 { + ((off as isize + delta) as u64, sz) + } else { + (off, sz) + }; + directory.extend_from_slice(&off.to_be_bytes()); + directory.extend_from_slice(&sz.to_be_bytes()); + directory.extend_from_slice(&nflags.to_be_bytes()); + directory.extend_from_slice(path); + directory.push(0); + } + + let mut info = Vec::new(); + info.extend_from_slice(&self.info[..16]); + info.extend_from_slice(&u32::try_from(chunks.len())?.to_be_bytes()); + info.extend_from_slice(&block_table); + info.extend_from_slice(&directory); + + let encoded_info = encode(&info, self.flags)?; + let mut result = self.header.clone(); + result[self.info_offset..self.info_offset + 4] + .copy_from_slice(&u32::try_from(encoded_info.len())?.to_be_bytes()); + result[self.info_offset + 4..self.info_offset + 8] + .copy_from_slice(&u32::try_from(info.len())?.to_be_bytes()); + result.extend_from_slice(&encoded_info); + for c in &chunks { + result.extend_from_slice(c); + } + let total = result.len() as u64; + result[self.size_offset..self.size_offset + 8].copy_from_slice(&total.to_be_bytes()); + ensure!( + Self::read(&result)?.logical == logical, + "UnityFS resize verification failed" + ); + Ok(result) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1d47673 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +//! Honkai Impact 3rd client patcher: redirect, passport RSA and protection +//! removal for Windows and Android clients. +//! +//! - [`format`] — shared binary-format primitives (bytes, PE, ELF, DEX, Unity). +//! - [`patch`] — the individual patch modules. +//! - [`android`] — APK signing toolchain and workflow. +//! - [`cli`] — interactive and argument-driven front ends. +pub mod android; +pub mod cli; +pub mod format; +pub mod patch; +pub mod util; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c88cacd --- /dev/null +++ b/src/main.rs @@ -0,0 +1,5 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + hi3_patcher::cli::run() +} diff --git a/src/patch/ace/bootstrap.bin b/src/patch/ace/bootstrap.bin new file mode 100644 index 0000000..e5a21f5 Binary files /dev/null and b/src/patch/ace/bootstrap.bin differ diff --git a/src/patch/ace/bootstrap.json b/src/patch/ace/bootstrap.json new file mode 100644 index 0000000..23659e2 --- /dev/null +++ b/src/patch/ace/bootstrap.json @@ -0,0 +1,70 @@ +{ + "tail_rva": { + "baseline": 305885184, + "offsets": [ + 107, + 155, + 183, + 284 + ] + }, + "tail_size": { + "baseline": 330480, + "offsets": [ + 85, + 112, + 167, + 289 + ] + }, + "code_size": { + "baseline": 348160, + "offsets": [ + 40, + 90, + 332 + ] + }, + "asset0": { + "baseline": 27827976, + "offsets": [ + 443 + ] + }, + "asset1": { + "baseline": 27827984, + "offsets": [ + 457 + ] + }, + "asset2": { + "baseline": 27827992, + "offsets": [ + 471 + ] + }, + "table0_2": { + "baseline": 678640, + "offsets": [ + 198 + ] + }, + "table0_3": { + "baseline": 12719, + "offsets": [ + 203 + ] + }, + "table1_2": { + "baseline": 729516, + "offsets": [ + 245 + ] + }, + "table1_3": { + "baseline": 12711, + "offsets": [ + 250 + ] + } +} diff --git a/src/patch/ace/capture.rs b/src/patch/ace/capture.rs new file mode 100644 index 0000000..2452324 --- /dev/null +++ b/src/patch/ace/capture.rs @@ -0,0 +1,268 @@ +use super::{ + discovery::Layout, + process::Process, + runtime::{self, Runtime, Snapshot}, +}; +use anyhow::{Context, Result, bail, ensure}; +use std::{ + fs, + path::Path, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +fn snapshot(process: &Process, layout: &Layout) -> Result { + let (unity, base) = process.modules()?; + let section = process.read( + base + u64::from(layout.vm_section_rva), + layout.vm_raw_size + layout.vm_tail_size as usize, + )?; + ensure!( + section[layout.vm_raw_size..].iter().any(|b| *b != 0), + "Generated state is not initialized" + ); + let mut assets = Vec::new(); + for (index, (&rva, size)) in layout + .decoder_globals + .iter() + .zip([0xe50, 0x40, 0x400]) + .enumerate() + { + let address = + u64::from_le_bytes(process.read(unity + u64::from(rva), 8)?.try_into().unwrap()); + ensure!(address != 0, "Decoder state is not initialized"); + let mut data = process.read(address, size)?; + if index == 0 { + for offset in [0, 16, 24, 32, 40, 48] { + let value = u64::from_le_bytes(data[offset..offset + 8].try_into()?); + ensure!( + value >= unity && value - unity < u64::from(layout.unity_image_size), + "Invalid decoder pointer" + ); + data[offset..offset + 8].copy_from_slice(&(value - unity).to_le_bytes()); + } + } + assets.push(data); + } + Ok(Snapshot { + base, + section, + allocations: process.allocations()?, + assets, + }) +} + +struct Game { + child: Child, + stopped: bool, + #[cfg(target_os = "linux")] + root: std::path::PathBuf, + #[cfg(target_os = "linux")] + selected: Option<(u32, String)>, + _work: tempfile::TempDir, +} + +#[cfg(target_os = "linux")] +fn identity(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let fields: Vec<_> = stat.rsplit_once(") ")?.1.split_whitespace().collect(); + if fields.first() == Some(&"Z") { + return None; + } + fields.get(19).map(|value| (*value).to_owned()) +} + +fn launch_error(error: std::io::Error) -> anyhow::Error { + if cfg!(windows) && error.raw_os_error() == Some(740) { + return anyhow::Error::new(error) + .context("No administrator privileges. Please restart the patcher as administrator."); + } + anyhow::Error::new(error).context(if cfg!(windows) { + "Cannot launch the Windows client" + } else { + "Cannot launch client. Linux capture requires umu-run and a working Proton installation (PROTONPATH)." + }) +} + +impl Game { + fn start(exe: &Path) -> Result { + let root = exe.parent().context("Missing client directory")?; + let work = tempfile::tempdir()?; + let log = fs::File::create(work.path().join("launch.log"))?; + #[cfg(windows)] + let mut command = Command::new(exe); + #[cfg(target_os = "linux")] + let mut command = { + use std::os::unix::process::CommandExt; + for proc in fs::read_dir("/proc")? { + let proc = proc?.path(); + ensure!( + proc.join("cwd").canonicalize().ok().as_deref() != Some(root) + || fs::read_to_string(proc.join("comm")) + .unwrap_or_default() + .trim() + != "BH3.exe", + "Close the existing client before capture" + ); + } + let mut command = Command::new("umu-run"); + command + .arg(exe) + .env("WINEPREFIX", work.path().join("pfx")) + .env("GAMEID", "0") + .process_group(0); + command + }; + let child = command + .current_dir(root) + .stdin(Stdio::null()) + .stdout(log.try_clone()?) + .stderr(log) + .spawn() + .map_err(launch_error)?; + Ok(Self { + child, + stopped: false, + #[cfg(target_os = "linux")] + root: root.to_owned(), + #[cfg(target_os = "linux")] + selected: None, + _work: work, + }) + } + + fn process(&mut self) -> Result { + #[cfg(windows)] + { + Process::open(self.child.id()) + } + #[cfg(target_os = "linux")] + { + if let Some((pid, ref start)) = self.selected { + ensure!( + identity(pid).as_ref() == Some(start), + "Captured client exited" + ); + return Process::open(pid); + } + let mut candidates = Vec::new(); + for proc in fs::read_dir("/proc")? { + let proc = proc?.path(); + if proc.join("cwd").canonicalize().ok().as_deref() == Some(&self.root) + && fs::read_to_string(proc.join("comm")) + .unwrap_or_default() + .trim() + == "BH3.exe" + { + let pid = proc + .file_name() + .and_then(|n| n.to_str()) + .context("Invalid PID")? + .parse()?; + if let Some(start) = identity(pid) { + candidates.push((pid, start)); + } + } + } + ensure!(candidates.len() == 1, "Waiting for a unique client process"); + let (pid, start) = candidates.pop().unwrap(); + self.selected = Some((pid, start)); + Process::open(pid) + } + } + + fn stop(&mut self) -> Result<()> { + if self.stopped { + return Ok(()); + } + #[cfg(target_os = "linux")] + { + if let Some((pid, start)) = &self.selected + && identity(*pid).as_ref() == Some(start) + { + let _ = Command::new("kill") + .args(["-KILL", "--", &pid.to_string()]) + .status(); + for _ in 0..40 { + if identity(*pid).as_ref() != Some(start) { + break; + } + thread::sleep(Duration::from_millis(50)); + } + ensure!( + identity(*pid).as_ref() != Some(start), + "Client is still running. Close it before patching." + ); + } + let _ = Command::new("kill") + .args(["-KILL", "--", &format!("-{}", self.child.id())]) + .output(); + } + let _ = self.child.kill(); + self.child.wait()?; + self.stopped = true; + Ok(()) + } +} + +impl Drop for Game { + fn drop(&mut self) { + let _ = self.stop(); + } +} + +pub(super) fn acquire(exe: &Path, layout: &Layout, assembly: &[u8]) -> Result { + let mut game = Game::start(exe)?; + println!("[-] Capturing ACE initialization ..."); + crossterm::terminal::enable_raw_mode()?; + struct InputMode; + impl Drop for InputMode { + fn drop(&mut self) { + let _ = crossterm::terminal::disable_raw_mode(); + } + } + let _input = InputMode; + let deadline = Instant::now() + Duration::from_secs(120); + let mut previous = None; + let mut stable_since = Instant::now(); + let mut reason = String::from("Client has not initialized"); + while Instant::now() < deadline { + use crossterm::event::{self, Event, KeyCode, KeyModifiers}; + if event::poll(Duration::ZERO)? + && let Event::Key(key) = event::read()? + && (key.code == KeyCode::Esc + || (key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL))) + { + bail!("Capture cancelled. No patch written."); + } + if let Some(status) = game.child.try_wait()? { + bail!("Client launcher exited ({status}) before capture completed. {reason}"); + } + let result = (|| { + let process = game.process()?; + let state = snapshot(&process, layout)?; + runtime::normalize(layout, &state, assembly) + })(); + match result { + Ok(runtime) => { + if previous.as_ref() != Some(&runtime) { + stable_since = Instant::now(); + previous = Some(runtime); + } else if stable_since.elapsed() >= Duration::from_secs(2) { + game.stop()?; + return previous.context("Missing captured runtime"); + } + } + Err(error) => { + reason = format!("{error:#}"); + previous = None; + } + } + thread::sleep(Duration::from_millis(500)); + } + bail!( + "Capture timed out. {reason}. No patch was written. Protection may block memory access or initialization." + ); +} diff --git a/src/patch/ace/discovery.rs b/src/patch/ace/discovery.rs new file mode 100644 index 0000000..e437599 --- /dev/null +++ b/src/patch/ace/discovery.rs @@ -0,0 +1,180 @@ +//! Read-only discovery of the ACE client layout. Matching a layout does not +//! authorize reuse of captured runtime data. +use crate::{ + format::{ + bytes::{self, u32}, + pe::{Pe, SECTION_EXECUTE, SECTION_WRITE}, + }, + format::unity, + patch::region, +}; +use anyhow::{Context, Result, ensure}; +use std::{fs, path::Path}; + +#[derive(Debug, PartialEq)] +pub struct Layout { + pub region: &'static str, + pub decoder_globals: [u32; 3], + pub vm_tail_rva: u32, + pub vm_tail_size: u32, + pub assembly_image_size: u32, + pub unity_image_size: u32, + pub vm_section_rva: u32, + pub vm_raw_size: usize, +} + +/// Locate the three writable decoder globals in `UnityPlayer.dll`'s `.text`. +fn decoder(pe: &Pe) -> Result<[u32; 3]> { + let text = pe.section(b".text\0\0\0")?; + ensure!( + text.flags & SECTION_EXECUTE != 0, + "decoder section is not executable" + ); + let bytes = &pe.data[text.raw..text.raw + text.raw_size]; + let mut hits = Vec::new(); + for (offset, window) in bytes.windows(15).enumerate() { + if window[..3] == [0x48, 0x8b, 0x15] + && window[7..] == [0x0f, 0xb6, 0xbc, 0x02, 0x38, 0x0e, 0, 0] + { + let displacement = i32::from_le_bytes(window[3..7].try_into()?); + let target = i64::from(text.rva) + offset as i64 + 7 + i64::from(displacement); + hits.push(u32::try_from(target).context("decoder target outside image")?); + } + } + ensure!( + hits.len() == 2 && hits[0] == hits[1], + "unknown or ambiguous decoder pattern" + ); + let base = hits[0]; + let end = base.checked_add(24).context("decoder range overflow")?; + ensure!( + base % 8 == 0 + && pe.sections.iter().any(|s| s.flags & SECTION_WRITE != 0 + && base >= s.rva + && end <= s.rva + s.virtual_size), + "decoder globals are not in writable image data" + ); + Ok([base, base + 8, base + 16]) +} + +pub fn inspect(exe: &Path) -> Result { + ensure!( + exe.is_file() + && exe + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("BH3.exe")), + "select an existing BH3.exe" + ); + let root = exe.parent().context("missing client directory")?; + let shared = fs::read(root.join("BH3_Data/sharedassets0.assets"))?; + let region = region::identify(&shared[unity::script(&shared, "ChannelConfig")?])?; + let unity_data = fs::read(root.join("UnityPlayer.dll"))?; + let player = Pe::read(&unity_data)?; + player.require_sections_within_image()?; + player.require_export(b"UnityMain\0")?; + let assembly_data = fs::read(root.join("BH3_Data/Native/UserAssembly.dll"))?; + let assembly = Pe::read(&assembly_data)?; + assembly.require_sections_within_image()?; + assembly.require_export(b"il2cpp_get_api_table\0")?; + let vm = assembly.section(b".tvm0\0\0\0")?; + ensure!( + vm.flags & SECTION_EXECUTE != 0 && vm.virtual_size as usize > vm.raw_size, + "unknown generated-code section layout" + ); + Ok(Layout { + region: region.name, + decoder_globals: decoder(&player)?, + vm_tail_rva: vm.rva + vm.raw_size as u32, + vm_tail_size: vm.virtual_size - vm.raw_size as u32, + assembly_image_size: assembly.image_size, + unity_image_size: player.image_size, + vm_section_rva: vm.rva, + vm_raw_size: vm.raw_size, + }) +} + +pub(super) fn validate_original(exe: &[u8]) -> Result<()> { + let image = Pe::read(exe)?; + image.require_sections_within_image()?; + ensure!( + image.sections.len() == 7 && image.sections.iter().all(|s| &s.name[..4] == b".ace"), + "unknown or already-patched executable layout; use a clean BH3.exe" + ); + let opt = image.opt; + ensure!(u32(exe, opt + 32)? == 0x1000, "unsupported section alignment"); + ensure!( + u32(exe, opt + 112 + 9 * 8)? == 0, + "unsupported executable TLS initialization" + ); + let entry = u32(exe, opt + 16)?; + let code = image.at_rva(entry, 10)?; + ensure!( + code[..6] == [0x48, 0x83, 0xec, 0x28, 0xff, 0x15], + "unknown loader entry" + ); + let import = u32(exe, opt + 120)?; + let descriptor = image.at_rva(import, 40)?; + ensure!( + descriptor[20..40].iter().all(|b| *b == 0), + "unexpected loader imports" + ); + let name = u32(descriptor, 12)?; + ensure!( + image.at_rva(name, 12)?.eq_ignore_ascii_case(b"BH3Base.dll\0"), + "unknown loader DLL" + ); + let iat = u32(descriptor, 16)?; + let slot = i64::from(entry) + 10 + i64::from(i32::from_le_bytes(code[6..10].try_into()?)); + ensure!( + slot == i64::from(iat), + "loader entry does not call the expected import" + ); + let thunk = image.at_rva(iat, 16)?; + ensure!( + u64::from_le_bytes(thunk[..8].try_into()?) == 0x8000000000000001 + && thunk[8..].iter().all(|b| *b == 0), + "unknown loader ordinal" + ); + Ok(()) +} + +pub(super) fn expected_vm(assembly: &[u8], base: u64) -> Result> { + let image = Pe::read(assembly)?; + let section = image.section(b".tvm0\0\0\0")?; + let mut expected = assembly[section.raw..section.raw + section.raw_size].to_vec(); + let opt = image.opt; + let preferred = u64::from_le_bytes(bytes::array(assembly, opt + 24)?); + let reloc_rva = u32(assembly, opt + 112 + 5 * 8)?; + let reloc_size = u32(assembly, opt + 116 + 5 * 8)? as usize; + let relocs = image.at_rva(reloc_rva, reloc_size)?; + let mut cursor = 0; + while cursor < relocs.len() { + let page = u32(relocs, cursor)?; + let size = u32(relocs, cursor + 4)? as usize; + ensure!( + size >= 8 && size.is_multiple_of(2) && size <= relocs.len() - cursor, + "malformed PE relocations" + ); + for bytes in relocs[cursor + 8..cursor + size].as_chunks::<2>().0 { + let entry = u16::from_le_bytes(*bytes); + let rva = page + .checked_add(u32::from(entry & 4095)) + .context("relocation overflow")?; + if rva >= section.rva && ((rva - section.rva) as usize) < expected.len() { + ensure!(entry >> 12 == 0 || entry >> 12 == 10, "unsupported native relocation"); + if entry >> 12 == 10 { + let offset = (rva - section.rva) as usize; + let target = expected + .get_mut(offset..offset + 8) + .context("relocation exceeds section")?; + let value = u64::from_le_bytes(target.try_into()?); + target.copy_from_slice( + &value.wrapping_add(base).wrapping_sub(preferred).to_le_bytes(), + ); + } + } + } + cursor += size; + } + Ok(expected) +} diff --git a/src/patch/ace/generate.rs b/src/patch/ace/generate.rs new file mode 100644 index 0000000..0f9d814 --- /dev/null +++ b/src/patch/ace/generate.rs @@ -0,0 +1,191 @@ +//! Parameterized x64 entry stub; no build-specific captured payload is embedded here. +use super::{ + discovery::{Layout, validate_original}, + runtime::Runtime, +}; +use crate::format::bytes::u32 as u32_at; +use anyhow::{Context, Result, ensure}; +use serde_json::Value; + +fn align(value: usize, boundary: usize) -> Result { + ensure!(boundary.is_power_of_two(), "Invalid PE alignment"); + Ok(value + .checked_add(boundary - 1) + .context("Alignment overflow")? + & !(boundary - 1)) +} + +fn put(data: &mut [u8], at: usize, value: u32) -> Result<()> { + data.get_mut(at..at + 4) + .context("PE write out of bounds")? + .copy_from_slice(&value.to_le_bytes()); + Ok(()) +} + +pub(super) fn build(original: &[u8], layout: &Layout, runtime: &Runtime) -> Result> { + validate_original(original)?; + ensure!( + runtime.assets.len() == 3 && runtime.assets.iter().map(Vec::len).eq([0xe50, 0x40, 0x400]), + "Unsupported bootstrap decoder layout" + ); + ensure!( + runtime.blob.len() <= 16 * 1024 * 1024 && runtime.code_size > 0, + "Invalid runtime size" + ); + let pe = u32_at(original, 60)? as usize; + let opt = pe + 24; + let count = (u32_at(original, pe + 4)? >> 16) as usize; + let headers = opt + u32_at(original, pe + 20)? as u16 as usize; + let file_alignment = u32_at(original, opt + 36)? as usize; + ensure!( + (0x200..=0x1000).contains(&file_alignment), + "Unsupported file alignment" + ); + ensure!( + headers + (count + 2) * 40 <= u32_at(original, opt + 60)? as usize, + "No room for bootstrap section headers" + ); + ensure!( + original + .get(headers + count * 40..headers + (count + 2) * 40) + .is_some_and(|s| s.iter().all(|b| *b == 0)), + "Reserved section headers are not empty" + ); + let code_rva = u32::try_from(align(u32_at(original, opt + 56)? as usize, 4096)?)?; + let imports_rva = code_rva.checked_add(4096).context("Image RVA overflow")?; + let mut imports = vec![0; 80]; + for (index, (dll, functions)) in [ + ( + "KERNEL32.dll", + &[ + "GetCommandLineW", + "GetStartupInfoW", + "GetModuleHandleW", + "ExitProcess", + "VirtualAlloc", + "VirtualProtect", + ][..], + ), + ("UnityPlayer.dll", &["UnityMain"][..]), + ( + "BH3_Data\\Native\\UserAssembly.dll", + &["il2cpp_get_api_table"][..], + ), + ] + .iter() + .enumerate() + { + let dll_rva = imports_rva + imports.len() as u32; + imports.extend(dll.as_bytes()); + imports.push(0); + let mut names = Vec::new(); + for function in *functions { + if !imports.len().is_multiple_of(2) { + imports.push(0); + } + names.push(imports_rva + imports.len() as u32); + imports.extend([0, 0]); + imports.extend(function.as_bytes()); + imports.push(0); + } + imports.resize(align(imports.len(), 8)?, 0); + let int = imports_rva + imports.len() as u32; + for name in names.iter().copied().chain([0]) { + imports.extend(u64::from(name).to_le_bytes()); + } + let iat = imports_rva + imports.len() as u32; + for name in names.iter().copied().chain([0]) { + imports.extend(u64::from(name).to_le_bytes()); + } + put(&mut imports, index * 20, int)?; + put(&mut imports, index * 20 + 12, dll_rva)?; + put(&mut imports, index * 20 + 16, iat)?; + } + for c in "UnityPlayer.dll\0".encode_utf16() { + imports.extend(c.to_le_bytes()); + } + for payload in &runtime.assets { + imports.resize(align(imports.len(), 16)?, 0); + imports.extend(payload); + } + for c in "UserAssembly.dll\0".encode_utf16() { + imports.extend(c.to_le_bytes()); + } + imports.extend(&runtime.blob); + + let mut code = include_bytes!("bootstrap.bin").to_vec(); + let parameters: Value = serde_json::from_str(include_str!("bootstrap.json"))?; + let fields = [ + ("tail_rva", layout.vm_tail_rva), + ("tail_size", layout.vm_tail_size), + ("code_size", runtime.code_size), + ("asset0", layout.decoder_globals[0]), + ("asset1", layout.decoder_globals[1]), + ("asset2", layout.decoder_globals[2]), + ("table0_2", runtime.tables[0].0), + ("table0_3", runtime.tables[0].1), + ("table1_2", runtime.tables[1].0), + ("table1_3", runtime.tables[1].1), + ]; + for (name, value) in fields { + let baseline = parameters[name]["baseline"] + .as_u64() + .context("Invalid bootstrap parameter")? as u32; + for offset in parameters[name]["offsets"] + .as_array() + .context("Invalid bootstrap offsets")? + { + let at = offset.as_u64().context("Invalid bootstrap offset")? as usize; + let adjusted = + i64::from(u32_at(&code, at)? as i32) + i64::from(value) - i64::from(baseline); + put( + &mut code, + at, + i32::try_from(adjusted).context("Bootstrap displacement overflow")? as u32, + )?; + } + } + let mut output = original.to_vec(); + for (index, (name, rva, payload, flags)) in [ + (*b".bh3code", code_rva, code.as_slice(), 0x60000020), + (*b".bh3imp\0", imports_rva, imports.as_slice(), 0xc0000040), + ] + .iter() + .enumerate() + { + let raw = align(output.len(), file_alignment)?; + let raw_size = align(payload.len(), file_alignment)?; + output.resize(raw, 0); + output.extend(*payload); + output.resize(raw + raw_size, 0); + let header = headers + (count + index) * 40; + output[header..header + 40].fill(0); + output[header..header + 8].copy_from_slice(name); + put(&mut output, header + 8, u32::try_from(payload.len())?)?; + put(&mut output, header + 12, *rva)?; + put(&mut output, header + 16, u32::try_from(raw_size)?)?; + put(&mut output, header + 20, u32::try_from(raw)?)?; + put(&mut output, header + 36, *flags)?; + } + output[pe + 6..pe + 8].copy_from_slice(&u16::try_from(count + 2)?.to_le_bytes()); + put(&mut output, opt + 16, code_rva)?; + put( + &mut output, + opt + 56, + imports_rva + .checked_add(u32::try_from(align(imports.len(), 4096)?)?) + .context("Image size overflow")?, + )?; + for (at, size) in [(opt + 4, code.len()), (opt + 8, imports.len())] { + let size = u32_at(&output, at)? + .checked_add(u32::try_from(align(size, file_alignment)?)?) + .context("PE size overflow")?; + put(&mut output, at, size)?; + } + put(&mut output, opt + 120, imports_rva)?; + put(&mut output, opt + 124, 80)?; + put(&mut output, opt + 144, 0)?; + put(&mut output, opt + 148, 0)?; + put(&mut output, opt + 64, 0)?; + Ok(output) +} diff --git a/src/patch/ace/mod.rs b/src/patch/ace/mod.rs new file mode 100644 index 0000000..ff12a42 --- /dev/null +++ b/src/patch/ace/mod.rs @@ -0,0 +1,97 @@ +//! Region-aware automatic ACE patching from a validated local initialization +//! capture. Launches the clean client once, captures its initialized state, +//! and builds a patched `BH3.exe` with that state embedded. +mod capture; +pub mod discovery; +mod generate; +mod process; +mod runtime; + +use crate::util::replace; +use anyhow::{Context, Result, ensure}; +use sha2::{Digest, Sha256}; +use std::{fs, path::Path}; + +fn hash(data: &[u8]) -> String { + format!("{:x}", Sha256::digest(data)) +} + +fn file_hash(path: &Path) -> Result { + let mut file = + fs::File::open(path).with_context(|| format!("cannot read {}", path.display()))?; + let mut digest = Sha256::new(); + std::io::copy(&mut file, &mut digest)?; + Ok(format!("{:x}", digest.finalize())) +} + +pub fn patch(exe: &Path, confirm_capture: impl FnOnce(&str) -> Result) -> Result<()> { + ensure!( + exe.is_file() + && exe + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("BH3.exe")), + "select the BH3.exe file, not the client folder" + ); + let exe = exe.canonicalize().context("select an existing BH3.exe")?; + let original = fs::read(&exe)?; + let root = exe.parent().context("missing client directory")?; + let player_path = root.join("UnityPlayer.dll"); + let assembly_path = root.join("BH3_Data/Native/UserAssembly.dll"); + if let Some(at) = original.len().checked_sub(104) + && &original[at..at + 8] == b"HI3AUTO1" + { + ensure!( + Sha256::digest(&original[..at]).as_slice() == &original[at + 8..at + 40], + "automatic patch was modified; restore a clean executable" + ); + ensure!( + file_hash(&player_path)? == hex(&original[at + 40..at + 72]) + && file_hash(&assembly_path)? == hex(&original[at + 72..]), + "DLLs changed since capture; restore a clean executable before recapturing" + ); + println!("[OK] Client checked"); + println!("[OK] Already patched; no files changed"); + return Ok(()); + } + let layout = discovery::inspect(&exe)?; + discovery::validate_original(&original)?; + let player_hash = file_hash(&player_path)?; + let assembly = fs::read(&assembly_path)?; + let assembly_hash = hash(&assembly); + println!("[OK] Client checked"); + if !confirm_capture(layout.region)? { + println!("[--] Cancelled; no files changed"); + return Ok(()); + } + let runtime = capture::acquire(&exe, &layout, &assembly)?; + println!("[OK] Initialization captured"); + ensure!( + file_hash(&player_path)? == player_hash && file_hash(&assembly_path)? == assembly_hash, + "client DLLs changed during capture. No patch written." + ); + let mut replacement = generate::build(&original, &layout, &runtime)?; + let digest = Sha256::digest(&replacement); + replacement.extend(b"HI3AUTO1"); + replacement.extend(digest); + replacement.extend(unhex(&player_hash)?); + replacement.extend(unhex(&assembly_hash)?); + println!("[OK] Patch prepared"); + replace::verified(&exe, &original, &replacement)?; + println!("[OK] Client updated"); + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn unhex(value: &str) -> Result> { + ensure!( + value.len().is_multiple_of(2) && value.is_ascii(), + "invalid hex data" + ); + (0..value.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&value[i..i + 2], 16).map_err(Into::into)) + .collect() +} diff --git a/src/patch/ace/process.rs b/src/patch/ace/process.rs new file mode 100644 index 0000000..c30c175 --- /dev/null +++ b/src/patch/ace/process.rs @@ -0,0 +1,215 @@ +//! Read-only local process access. No injection, trust bypass, or protection disabling. +use anyhow::{Context, Result, ensure}; + +#[cfg(windows)] +mod platform { + use super::*; + use std::mem::{size_of, zeroed}; + use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, + System::{ + Diagnostics::{Debug::ReadProcessMemory, ToolHelp::*}, + Memory::*, + Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ}, + }, + }; + + struct Handle(HANDLE); + impl Drop for Handle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + pub(crate) struct Process { + handle: Handle, + pid: u32, + } + impl Process { + pub fn open(pid: u32) -> Result { + let handle = + unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) }; + ensure!( + !handle.is_null(), + "Cannot read client process: {}", + std::io::Error::last_os_error() + ); + Ok(Self { + handle: Handle(handle), + pid, + }) + } + pub fn read(&self, address: u64, size: usize) -> Result> { + ensure!( + size <= 16 * 1024 * 1024 && address.checked_add(size as u64).is_some(), + "Invalid memory range" + ); + let mut data = vec![0; size]; + let mut count = 0; + let ok = unsafe { + ReadProcessMemory( + self.handle.0, + address as _, + data.as_mut_ptr().cast(), + size, + &mut count, + ) + }; + ensure!( + ok != 0 && count == size, + "Client memory is not readable: {}", + std::io::Error::last_os_error() + ); + Ok(data) + } + pub fn modules(&self) -> Result<(u64, u64)> { + let raw = unsafe { + CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, self.pid) + }; + ensure!(raw != INVALID_HANDLE_VALUE, "Client modules not ready"); + let snapshot = Handle(raw); + let mut entry: MODULEENTRY32W = unsafe { zeroed() }; + entry.dwSize = size_of::() as u32; + let mut ok = unsafe { Module32FirstW(snapshot.0, &mut entry) }; + let (mut unity, mut assembly) = (None, None); + while ok != 0 { + let length = entry + .szModule + .iter() + .position(|v| *v == 0) + .unwrap_or(entry.szModule.len()); + let name = String::from_utf16_lossy(&entry.szModule[..length]).to_ascii_lowercase(); + match name.as_str() { + "unityplayer.dll" => unity = Some(entry.modBaseAddr as u64), + "userassembly.dll" => assembly = Some(entry.modBaseAddr as u64), + _ => {} + } + ok = unsafe { Module32NextW(snapshot.0, &mut entry) }; + } + Ok(( + unity.context("UnityPlayer not initialized")?, + assembly.context("UserAssembly not initialized")?, + )) + } + pub fn allocations(&self) -> Result)>> { + let mut regions = Vec::new(); + let mut address = 0usize; + let mut total = 0; + loop { + let mut info: MEMORY_BASIC_INFORMATION = unsafe { zeroed() }; + let count = unsafe { + VirtualQueryEx( + self.handle.0, + address as _, + &mut info, + size_of::(), + ) + }; + if count == 0 { + break; + } + let start = info.BaseAddress as usize; + let next = start + .checked_add(info.RegionSize) + .context("Memory map overflow")?; + ensure!(next > address, "Non-progressing memory map"); + if info.State == MEM_COMMIT + && info.Type == MEM_PRIVATE + && info.Protect + & (PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY) + != 0 + && info.Protect & PAGE_GUARD == 0 + && info.RegionSize <= 1024 * 1024 + { + total += info.RegionSize; + ensure!( + total <= 64 * 1024 * 1024, + "Too much generated process memory" + ); + regions.push((start as u64, self.read(start as u64, info.RegionSize)?)); + } + address = next; + } + Ok(regions) + } + } +} + +#[cfg(target_os = "linux")] +mod platform { + use super::*; + use std::{fs, os::unix::fs::FileExt}; + pub(crate) struct Process { + memory: fs::File, + pid: u32, + } + impl Process { + pub fn open(pid: u32) -> Result { + Ok(Self { + memory: fs::File::open(format!("/proc/{pid}/mem")).context( + "Cannot read client memory; ptrace permissions may restrict capture", + )?, + pid, + }) + } + pub fn read(&self, address: u64, size: usize) -> Result> { + ensure!( + size <= 16 * 1024 * 1024 && address.checked_add(size as u64).is_some(), + "Invalid memory range" + ); + let mut data = vec![0; size]; + self.memory.read_exact_at(&mut data, address)?; + Ok(data) + } + fn maps(&self) -> Result { + Ok(fs::read_to_string(format!("/proc/{}/maps", self.pid))?) + } + pub fn modules(&self) -> Result<(u64, u64)> { + let (mut unity, mut assembly) = (None, None); + for line in self.maps()?.lines() { + let fields: Vec<_> = line.split_whitespace().collect(); + if fields.len() < 6 { + continue; + } + let start = u64::from_str_radix( + fields[0].split('-').next().context("Bad memory map")?, + 16, + )?; + let lower = line.to_ascii_lowercase(); + if lower.ends_with("/unityplayer.dll") && unity.is_none() { + unity = Some(start); + } + if lower.ends_with("/userassembly.dll") && assembly.is_none() { + assembly = Some(start); + } + } + Ok(( + unity.context("UnityPlayer not initialized")?, + assembly.context("UserAssembly not initialized")?, + )) + } + pub fn allocations(&self) -> Result)>> { + let mut regions = Vec::new(); + let mut total = 0; + for line in self.maps()?.lines() { + let fields: Vec<_> = line.split_whitespace().collect(); + if fields.len() != 5 || !fields[1].contains('x') || !fields[1].contains('r') { + continue; + } + let (start, end) = fields[0].split_once('-').context("Bad memory map")?; + let start = u64::from_str_radix(start, 16)?; + let end = u64::from_str_radix(end, 16)?; + let size = usize::try_from(end.checked_sub(start).context("Bad memory range")?)?; + if size <= 1024 * 1024 { + total += size; + ensure!(total <= 64 * 1024 * 1024, "Too much generated memory"); + regions.push((start, self.read(start, size)?)); + } + } + Ok(regions) + } + } +} + +pub(super) use platform::Process; diff --git a/src/patch/ace/runtime.rs b/src/patch/ace/runtime.rs new file mode 100644 index 0000000..fc94857 --- /dev/null +++ b/src/patch/ace/runtime.rs @@ -0,0 +1,190 @@ +use super::discovery::{Layout, expected_vm}; +use anyhow::{Context, Result, ensure}; +use std::collections::BTreeSet; + +#[derive(Clone, PartialEq)] +pub(super) struct Snapshot { + pub base: u64, + pub section: Vec, + pub allocations: Vec<(u64, Vec)>, + pub assets: Vec>, +} + +#[derive(PartialEq)] +pub(super) struct Runtime { + pub assets: Vec>, + pub blob: Vec, + pub code_size: u32, + pub tables: [(u32, u32); 2], +} + +fn pointer(data: &[u8], at: usize) -> Result { + Ok(u64::from_le_bytes( + data.get(at..at + 8) + .context("Invalid pointer slot")? + .try_into()?, + )) +} + +fn slots(data: &[u8], valid: impl Fn(u64) -> bool) -> Result> { + let mut slots = BTreeSet::new(); + let mut confirmed = BTreeSet::new(); + let mut unresolved = Vec::new(); + for (at, marker) in data.windows(2).enumerate() { + if marker != [0xff, 0x25] { + continue; + } + let displacement = data + .get(at + 2..at + 6) + .context("Truncated generated jump")?; + let slot = at as i64 + 6 + i64::from(i32::from_le_bytes(displacement.try_into()?)); + if let Ok(slot) = usize::try_from(slot) + && slot.checked_add(8).is_some_and(|end| end <= data.len()) + && valid(pointer(data, slot)?) + { + slots.insert(slot); + confirmed.insert(at); + } else { + unresolved.push(at); + } + } + for at in unresolved { + let operand = (1..6).any(|delta| { + at.checked_sub(delta) + .is_some_and(|start| confirmed.contains(&start)) + }); + let pointer_bytes = slots + .range(..=at) + .next_back() + .is_some_and(|slot| at < slot + 8); + ensure!( + operand || pointer_bytes, + "Unresolved generated jump at {at:#x}" + ); + } + let offsets: Vec<_> = slots.into_iter().collect(); + ensure!( + offsets.windows(2).all(|p| p[0] + 8 <= p[1]), + "Overlapping pointer slots" + ); + Ok(offsets) +} + +pub(super) fn normalize(layout: &Layout, snapshot: &Snapshot, assembly: &[u8]) -> Result { + ensure!( + snapshot.assets.len() == 3 + && snapshot + .assets + .iter() + .map(Vec::len) + .eq([0xe50, 0x40, 0x400]), + "Unknown decoder state sizes" + ); + ensure!( + snapshot.section.len() == layout.vm_raw_size + layout.vm_tail_size as usize, + "Incomplete VM section" + ); + let expected = expected_vm(assembly, snapshot.base)?; + ensure!( + snapshot.section[..layout.vm_raw_size] == expected, + "Capture changed original native code" + ); + let mut tail = snapshot.section[layout.vm_raw_size..].to_vec(); + let module_end = snapshot + .base + .checked_add(u64::from(layout.assembly_image_size)) + .context("Module overflow")?; + let mut allocations = snapshot.allocations.clone(); + allocations.sort_by_key(|a| a.0); + ensure!( + allocations.iter().all(|(base, data)| !data.is_empty() + && data.len() <= 1024 * 1024 + && base.checked_add(data.len() as u64).is_some()), + "Invalid generated allocation" + ); + ensure!( + allocations + .windows(2) + .all(|p| p[0].0 + p[0].1.len() as u64 <= p[1].0), + "Overlapping allocations" + ); + let tail_slots = slots(&tail, |value| { + allocations + .iter() + .any(|(base, data)| value >= *base && value - base < data.len() as u64) + })?; + ensure!( + !tail_slots.is_empty(), + "Generated state has not initialized" + ); + let mut code = Vec::new(); + let mut ranges = Vec::new(); + for (base, data) in &allocations { + if tail_slots.iter().any(|slot| { + pointer(&tail, *slot).is_ok_and(|v| v >= *base && v - base < data.len() as u64) + }) { + ranges.push((*base, data.len() as u64, code.len() as u64)); + code.extend(data); + } + } + ensure!(code.len() <= 8 * 1024 * 1024, "Generated code too large"); + let code_slots = slots(&code, |value| value >= snapshot.base && value < module_end)?; + ensure!( + tail_slots.len() == code_slots.len(), + "Generated thunk counts disagree" + ); + // A marker inside a real instruction's operand must never hide a native branch target. + let valid_slots: BTreeSet<_> = tail_slots.iter().copied().collect(); + for (at, instruction) in expected.windows(5).enumerate() { + if !matches!(instruction[0], 0xe8 | 0xe9) { + continue; + } + let target = i64::from(layout.vm_section_rva) + + at as i64 + + 5 + + i64::from(i32::from_le_bytes(instruction[1..].try_into()?)); + let relative = target - i64::from(layout.vm_tail_rva); + if let Ok(at) = usize::try_from(relative) + && tail.get(at..at.saturating_add(2)) == Some(&[0xff, 0x25]) + { + let disp = i32::from_le_bytes( + tail.get(at + 2..at + 6) + .context("Invalid targeted thunk")? + .try_into()?, + ); + let slot = usize::try_from(at as i64 + 6 + i64::from(disp))?; + ensure!( + valid_slots.contains(&slot), + "Native branch targets unresolved generated code" + ); + } + } + for &slot in &tail_slots { + let value = pointer(&tail, slot)?; + let &(base, _, start) = ranges + .iter() + .find(|(base, size, _)| value >= *base && value - base < *size) + .context("Missing code range")?; + tail[slot..slot + 8].copy_from_slice(&(value - base + start).to_le_bytes()); + } + for &slot in &code_slots { + let value = pointer(&code, slot)? - snapshot.base; + code[slot..slot + 8].copy_from_slice(&value.to_le_bytes()); + } + let code_size = code.len() as u32; + let mut blob = tail; + blob.extend(code); + let mut tables = [(0, 0); 2]; + for (index, offsets) in [tail_slots, code_slots].iter().enumerate() { + tables[index] = (blob.len() as u32, offsets.len() as u32); + for offset in offsets { + blob.extend((*offset as u32).to_le_bytes()); + } + } + Ok(Runtime { + assets: snapshot.assets.clone(), + blob, + code_size, + tables, + }) +} diff --git a/src/patch/dispatch/android.rs b/src/patch/dispatch/android.rs new file mode 100644 index 0000000..e09b15e --- /dev/null +++ b/src/patch/dispatch/android.rs @@ -0,0 +1,310 @@ +//! Android Dispatch & SDK patch: rewrite URLs across supported APK assets +//! (serialized files, UnityFS bundles and text), redirect the CN passport +//! login URL in DEX, then repack into a separate signed APK. +use crate::{ + android::{self, sign}, + format::unity::{self, Bundle}, + patch::dispatch::{self, Target}, +}; +use anyhow::{Context, Result, ensure}; +use regex::Regex; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + fs, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions}; + +fn candidate(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + let base = lower + .rsplit_once(".split") + .filter(|(_, part)| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) + .map_or(lower.as_str(), |(base, _)| base); + if !lower.starts_with("assets/") + || ["assets/sdk/package_config.json", "assets/share_config.json"].contains(&base) + || base.ends_with(".wmv") + || base.ends_with(".bak") + { + return false; + } + lower.starts_with("assets/bin/data/") + || [ + ".json", ".xml", ".txt", ".ini", ".cfg", ".conf", ".dat", ".properties", ".yaml", + ".yml", + ] + .iter() + .any(|s| lower.ends_with(s)) +} + +/// Rewrite URLs in every TextAsset of a serialized file. Each asset is first +/// tried on the length-preserving (fit) path, byte-for-byte as before; only an +/// asset whose patched script no longer fits is grown, with the object table +/// rewritten via [`unity::replace_script`]. Returns the patched bytes, the +/// dispatch and URL counts, and whether any asset was enlarged. +fn patch_serialized(data: &[u8], target: &Target) -> Result<(Vec, usize, usize, bool)> { + let assets = unity::text_assets(data)?; + let mut out = data.to_vec(); + let (mut dispatches, mut urls) = (0, 0); + let mut grow: Vec<(String, Vec)> = vec![]; + for asset in &assets { + let original = &data[asset.script.clone()]; + // Length-preserving attempt: returns None if anything would overflow. + let fit = (|| -> Result, usize, usize)>> { + let mut updated = original.to_vec(); + let mut d = 0; + if dispatch::has_dispatch(original) { + match dispatch::dispatch(original, target)? { + Some((patched, count)) => { + updated = patched; + d = count; + } + None => return Ok(None), + } + } + match dispatch::android_urls(&updated, target) { + Ok((patched, count)) if patched.len() == asset.script.len() => { + Ok(Some((patched, d, count))) + } + _ => Ok(None), + } + })()?; + match fit { + Some((patched, d, u)) => { + dispatches += d; + urls += u; + out[asset.script.clone()].copy_from_slice(&patched); + } + None => { + // Grow: natural-length dispatch and URLs; rewrite offsets below. + let mut updated = original.to_vec(); + if dispatch::has_dispatch(original) { + let (patched, count) = dispatch::dispatch_grow(original, target)?; + updated = patched; + dispatches += count; + } + let (patched, count) = dispatch::android_urls_grow(&updated, target)?; + urls += count; + grow.push((asset.name.clone(), patched)); + } + } + } + let grew = !grow.is_empty(); + let mut result = out; + for (name, new_script) in grow { + result = unity::replace_script(&result, &name, &new_script)?; + } + Ok((result, dispatches, urls, grew)) +} + +fn patch_data(data: &[u8], target: &Target) -> Result<(Vec, usize, usize, bool)> { + if data.starts_with(b"UnityFS\0") { + let bundle = Bundle::read(data)?; + let nodes = bundle.nodes()?; + let mut logical = bundle.logical.clone(); + let (mut d, mut u) = (0, 0); + let mut grown: Option<(usize, Vec)> = None; + for (index, node) in nodes.iter().enumerate() { + let raw = &bundle.logical[node.clone()]; + if raw.len() < 16 || u32::from_be_bytes(raw[4..8].try_into()?) as usize != raw.len() { + continue; + } + let (patched, nd, nu, node_grew) = patch_serialized(raw, target)?; + d += nd; + u += nu; + if node_grew { + ensure!( + grown.is_none(), + "more than one SDK bundle node needs enlargement; unsupported" + ); + grown = Some((index, patched)); + } else { + logical[node.clone()].copy_from_slice(&patched); + } + } + if let Some((index, patched)) = grown { + // replace_node rebuilds from the original logical, so every other + // node must still be byte-identical (fit edits elsewhere are rare + // for these bundles and would be lost); verify before growing. + let node = &nodes[index]; + ensure!( + logical[..node.start] == bundle.logical[..node.start] + && logical[node.end..] == bundle.logical[node.end..], + "a sibling bundle node changed while another grew; unsupported" + ); + return Ok((bundle.replace_node(index, &patched)?, d, u, true)); + } + if logical == bundle.logical { + return Ok((data.to_vec(), d, u, false)); + } + return Ok((bundle.rebuild(data, &logical)?, d, u, false)); + } + if data.len() >= 16 && u32::from_be_bytes(data[4..8].try_into()?) as usize == data.len() { + return patch_serialized(data, target); + } + if std::str::from_utf8(data).is_ok() && !data.contains(&0) { + let (mut out, mut d, mut grew) = (data.to_vec(), 0, false); + if dispatch::has_dispatch(data) { + let (p, n) = match dispatch::dispatch(data, target)? { + Some(fit) => fit, + None => { + grew = true; + dispatch::dispatch_grow(data, target)? + } + }; + out = p; + d = n; + } + let (out, u) = match dispatch::android_urls(&out, target) { + Ok(fit) => fit, + Err(_) => { + grew = true; + dispatch::android_urls_grow(&out, target)? + } + }; + return Ok((out, d, u, grew)); + } + Ok((data.to_vec(), 0, 0, false)) +} + +/// Rebuild into a separate file. DEX, native libraries and packed `.wmv` files +/// are copied unchanged; the CN passport login URL is redirected in DEX. +pub fn repack(input: &Path, output: &Path, target: &Target) -> Result<()> { + ensure!( + input.canonicalize()? + != output + .canonicalize() + .unwrap_or_else(|_| output.to_path_buf()), + "input and output must differ" + ); + let mut archive = ZipArchive::new(fs::File::open(input)?)?; + let mut names = HashSet::new(); + let mut normal = vec![]; + let split = Regex::new(r"^(.*\.split)(\d+)$")?; + let mut groups: BTreeMap> = BTreeMap::new(); + for index in 0..archive.len() { + let file = archive.by_index(index)?; + let name = file.name().to_owned(); + ensure!(file.enclosed_name().is_some(), "unsafe APK path: {name}"); + ensure!(names.insert(name.clone()), "duplicate APK entry: {name}"); + if !candidate(&name) || file.is_dir() { + continue; + } + if let Some(cap) = split.captures(&name) { + groups + .entry(cap[1].to_owned()) + .or_default() + .insert(cap[2].parse()?, name); + } else { + normal.push(name); + } + } + let mut modified: HashMap> = HashMap::new(); + let mut removed = HashSet::new(); + let (mut dispatches, mut urls) = (0, 0); + let mut grew = false; + for name in normal { + let mut file = archive.by_name(&name)?; + ensure!(file.size() <= 1024 * 1024 * 1024, "APK asset too large: {name}"); + let mut data = vec![]; + file.read_to_end(&mut data)?; + let (patched, d, u, g) = + patch_data(&data, target).with_context(|| format!("cannot patch {name}"))?; + dispatches += d; + urls += u; + grew |= g; + if patched != data { + modified.insert(name, patched); + } + } + for (prefix, parts) in groups { + ensure!( + parts.keys().copied().eq(0..parts.len()), + "missing split asset parts: {prefix}" + ); + let mut data = vec![]; + let mut chunk_size = 0; + for (index, name) in &parts { + let mut file = archive.by_name(name)?; + if *index == 0 { + chunk_size = usize::try_from(file.size())?; + } + ensure!( + data.len() as u64 + file.size() <= 1024 * 1024 * 1024, + "split asset exceeds 1 GiB limit" + ); + file.read_to_end(&mut data)?; + } + ensure!(chunk_size > 0, "empty split0"); + let (patched, d, u, g) = + patch_data(&data, target).with_context(|| format!("cannot patch {prefix}"))?; + dispatches += d; + urls += u; + grew |= g; + if patched != data { + removed.extend(parts.into_values()); + for (i, chunk) in patched.chunks(chunk_size).enumerate() { + modified.insert(format!("{prefix}{i}"), chunk.to_vec()); + } + } + } + ensure!( + dispatches > 0, + "no supported ChannelConfig found; no APK was written" + ); + ensure!(urls > 0, "no SDK/resource URLs found; refusing partial APK output"); + if grew { + println!( + "[!] Server address is longer than the shortest original URL; client assets were enlarged." + ); + } + let output_file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(output)?; + let mut writer = ZipWriter::new(output_file); + for index in 0..archive.len() { + let mut file = archive.by_index(index)?; + let name = file.name().to_owned(); + if android::signature(&name) { + continue; + } + if name.starts_with("classes") && name.ends_with(".dex") { + let mut data = Vec::new(); + file.read_to_end(&mut data)?; + if let Some(patched) = super::cn_android::patch(&data, &target.origin)? { + writer.start_file( + name, + SimpleFileOptions::default() + .compression_method(file.compression()) + .unix_permissions(file.unix_mode().unwrap_or(0o644)), + )?; + writer.write_all(&patched)?; + continue; + } + drop(file); + writer.raw_copy_file(archive.by_index(index)?)?; + continue; + } + if let Some(data) = modified.remove(&name) { + let options = SimpleFileOptions::default() + .compression_method(file.compression()) + .unix_permissions(file.unix_mode().unwrap_or(0o644)); + writer.start_file(name, options)?; + writer.write_all(&data)?; + } else if !removed.contains(&name) { + writer.raw_copy_file(file)?; + } + } + for (name, data) in modified { + writer.start_file(name, SimpleFileOptions::default())?; + writer.write_all(&data)?; + } + writer.finish()?.sync_all()?; + Ok(()) +} + +pub fn patch(input: &Path, target: &Target) -> Result { + sign::signed_patch(input, |input, output| repack(input, output, target)) +} diff --git a/src/patch/dispatch/cn_android.rs b/src/patch/dispatch/cn_android.rs new file mode 100644 index 0000000..611cdb4 --- /dev/null +++ b/src/patch/dispatch/cn_android.rs @@ -0,0 +1,39 @@ +//! CN passport SDK login URL redirect in DEX. Reuses the `127.0.0.1` string +//! slot and remaps the login reference; string growth and checksum fix-up are +//! handled by `format::dex`. +use crate::format::dex; +use anyhow::{Result, ensure}; + +const CLASS: &[u8] = b"Lcom/mihoyo/platform/account/sdk/utils/RSAUtils;"; + +pub(super) fn patch(data: &[u8], origin: &str) -> Result>> { + if !data.windows(CLASS.len()).any(|part| part == CLASS) { + return Ok(None); + } + ensure!( + origin.starts_with("http://"), + "CN Android passport redirect requires HTTP" + ); + let local = dex::find_string(data, |s| s == b"http://127.0.0.1")? + .ok_or_else(|| anyhow::anyhow!("missing CN passport URL slot"))?; + let official = dex::find_string(data, |s| s == b"https://passport-api.mihoyo.com")? + .ok_or_else(|| anyhow::anyhow!("missing CN passport login URL"))?; + ensure!( + local > 0 + && local + 1 < dex::word(data, 0x38)? + && dex::string(data, local - 1)? < origin.as_bytes() + && origin.as_bytes() < dex::string(data, local + 1)?, + "server address does not fit CN DEX string order" + ); + // Reuse the 127.0.0.1 slot; a URL outside its sort range needs DEX ID remapping. + let local_ref = dex::find_const_string(data, local)?; + let login_ref = dex::find_const_string(data, official)?; + let mut out = data.to_vec(); + let added = dex::append_string(&mut out, origin.as_bytes())?; + let ids = dex::word(&out, 0x3c)?; + dex::put(&mut out, ids + local * 4, added)?; + out[local_ref + 2..local_ref + 4].copy_from_slice(&u16::try_from(local + 1)?.to_le_bytes()); + out[login_ref + 2..login_ref + 4].copy_from_slice(&u16::try_from(local)?.to_le_bytes()); + dex::recompute_checksums(&mut out)?; + Ok(Some(out)) +} diff --git a/src/patch/dispatch/mod.rs b/src/patch/dispatch/mod.rs new file mode 100644 index 0000000..13f4ed3 --- /dev/null +++ b/src/patch/dispatch/mod.rs @@ -0,0 +1,396 @@ +//! Dispatch & SDK patch: rewrite supported client URLs to a chosen HTTP or +//! HTTPS server. This module holds the platform-independent URL rewriting; +//! `windows` and `android` drive it over each client's asset containers. +pub mod android; +mod cn_android; +pub mod windows; + +use anyhow::{Result, bail, ensure}; +use regex::bytes::Regex; +use serde_json::Value; +use std::sync::LazyLock; +use url::{Position, Url}; + +static DISPATCH: LazyLock = + LazyLock::new(|| Regex::new(r#"(?s-u)"DispatchUrls"\s*:\s*\[(.*?)\]"#).unwrap()); +static QUOTED_URL: LazyLock = + LazyLock::new(|| Regex::new(r#"(?i-u)"(https?://[^"\r\n]+)""#).unwrap()); +static URL: LazyLock = + LazyLock::new(|| Regex::new(r#"(?i-u)(?:https?|wss?)://[^\s"'<>\\\x00]+"#).unwrap()); +static BARE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i-u)(?:[a-z0-9_-]+\.)*(?:honkaiimpact3\.com|bh3\.com|mihoyo\.com|hoyoverse\.com)(?::[0-9]+)?"#).unwrap() +}); + +/// A validated redirect destination: an HTTP/HTTPS origin and its authority. +#[derive(Debug)] +pub struct Target { + pub origin: String, + pub authority: String, + pub scheme: &'static str, +} + +impl Target { + pub fn new(input: &str, https: bool) -> Result { + let input = input.trim(); + ensure!( + !input.is_empty() && !input.contains("://") && !input.chars().any(char::is_whitespace), + "enter an IP or host with an optional port, without a URL path" + ); + let scheme = if https { "https" } else { "http" }; + let url = Url::parse(&format!("{scheme}://{input}"))?; + ensure!( + url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none(), + "invalid server address" + ); + let origin = url[..Position::BeforePath].to_owned(); + let authority = origin[scheme.len() + 3..].to_owned(); + Ok(Self { + origin, + authority, + scheme, + }) + } + + pub fn dispatch(&self) -> String { + format!("{}/query_dispatch", self.origin) + } +} + +fn dispatch_block(data: &[u8], target: &Target) -> Result<(std::ops::Range, usize, String)> { + let matches: Vec<_> = DISPATCH.captures_iter(data).collect(); + ensure!( + matches.len() == 1, + "expected one DispatchUrls block, found {}", + matches.len() + ); + let body = matches[0].get(1).unwrap(); + let count = QUOTED_URL.captures_iter(body.as_bytes()).count(); + ensure!(count > 0, "empty DispatchUrls list"); + let replacement = vec![format!("\"{}\"", target.dispatch()); count].join(","); + Ok((body.start()..body.end(), count, replacement)) +} + +/// Replace the single `DispatchUrls` list with repeated redirect entries, in +/// place within its fixed byte range. Returns the patched bytes and count, or +/// `None` if the server URL is too long to fit the block (caller should grow). +pub fn dispatch(data: &[u8], target: &Target) -> Result, usize)>> { + let (body, count, replacement) = dispatch_block(data, target)?; + if replacement.len() > body.len() { + return Ok(None); + } + let mut out = data.to_vec(); + out[body.clone()].fill(b' '); + out[body.start..body.start + replacement.len()].copy_from_slice(replacement.as_bytes()); + Ok(Some((out, count))) +} + +/// Natural-length `DispatchUrls` rewrite for the grow path: the block is +/// replaced with full-length entries, enlarging the surrounding script. +pub fn dispatch_grow(data: &[u8], target: &Target) -> Result<(Vec, usize)> { + let (body, count, replacement) = dispatch_block(data, target)?; + let mut out = data[..body.start].to_vec(); + out.extend_from_slice(replacement.as_bytes()); + out.extend_from_slice(&data[body.end..]); + Ok((out, count)) +} + +pub fn has_dispatch(data: &[u8]) -> bool { + DISPATCH.is_match(data) +} + +fn object_spans(data: &[u8]) -> Result>> { + let (mut depth, mut quoted, mut escaped, mut start) = (0usize, false, false, 0); + let mut spans = vec![]; + for (i, &b) in data.iter().enumerate() { + if quoted { + if escaped { + escaped = false; + } else if b == b'\\' { + escaped = true; + } else if b == b'"' { + quoted = false; + } + } else { + match b { + b'"' => quoted = true, + b'[' | b'{' => { + depth += 1; + if depth == 2 && b == b'{' { + start = i; + } + } + b']' | b'}' => { + ensure!(depth > 0, "unbalanced JSON"); + if depth == 2 && b == b'}' { + spans.push(start..i + 1); + } + depth -= 1; + } + _ => {} + } + } + } + ensure!(depth == 0 && !quoted, "unterminated JSON"); + Ok(spans) +} + +/// Compute the natural (unpadded) replacement for each production `bh3_*` SDK +/// record in `server_pc`: `(span, new_record_bytes)`, plus the URL count. +fn sdk_records( + script: &[u8], + target: &Target, +) -> Result<(Vec<(std::ops::Range, Vec)>, usize)> { + let _: Vec = serde_json::from_slice(script)?; + let mut records = Vec::new(); + let mut count = 0; + for span in object_spans(script)? { + let record: Value = serde_json::from_slice(&script[span.clone()])?; + let key = record["game_key"].as_str().unwrap_or(""); + if !key.starts_with("bh3_") + || record["env"].as_i64() != Some(if key == "bh3_cn" { 0 } else { 2 }) + { + continue; + } + let raw = &script[span.clone()]; + let mut replacement = vec![]; + let mut cursor = 0; + for cap in QUOTED_URL.captures_iter(raw) { + let found = cap.get(1).unwrap(); + let old = std::str::from_utf8(found.as_bytes())?; + let authority_start = old.find("://").unwrap() + 3; + let authority_end = authority_start + + old[authority_start..] + .find(['/', '?', '#']) + .unwrap_or(old.len() - authority_start); + replacement.extend_from_slice(&raw[cursor..found.start()]); + replacement.extend_from_slice(target.origin.as_bytes()); + replacement.extend_from_slice(&old.as_bytes()[authority_end..]); + cursor = found.end(); + count += 1; + } + replacement.extend_from_slice(&raw[cursor..]); + let _: Value = serde_json::from_slice(&replacement)?; + records.push((span, replacement)); + } + ensure!(count > 0, "no production BH3 SDK URLs found"); + Ok((records, count)) +} + +/// Rewrite the production `bh3_*` SDK records' URLs in `server_pc`, preserving +/// every record's byte length (space-padded). Returns the patched bytes and +/// URL count, or `None` if a record would grow (caller should grow the asset). +pub fn sdk(script: &[u8], target: &Target) -> Result, usize)>> { + let (records, count) = sdk_records(script, target)?; + let mut out = script.to_vec(); + for (span, replacement) in &records { + let raw_len = span.end - span.start; + if replacement.len() > raw_len { + return Ok(None); + } + let mut record = replacement.clone(); + ensure!(record.pop() == Some(b'}'), "invalid SDK record ending"); + record.resize(raw_len - 1, b' '); + record.push(b'}'); + let _: Value = serde_json::from_slice(&record)?; + out[span.clone()].copy_from_slice(&record); + } + Ok(Some((out, count))) +} + +/// Natural-length `server_pc` rewrite for the grow path: each record keeps its +/// real (unpadded) URLs, enlarging the script. +pub fn sdk_grow(script: &[u8], target: &Target) -> Result<(Vec, usize)> { + let (records, count) = sdk_records(script, target)?; + let mut out = Vec::new(); + let mut cursor = 0; + for (span, replacement) in &records { + out.extend_from_slice(&script[cursor..span.start]); + out.extend_from_slice(replacement); + cursor = span.end; + } + out.extend_from_slice(&script[cursor..]); + let _: Vec = serde_json::from_slice(&out)?; + Ok((out, count)) +} + +fn official(host: &str) -> bool { + host == "47.74.175.126" + || [ + "honkaiimpact3.com", + "bh3.com", + "mihoyo.com", + "hoyoverse.com", + ] + .iter() + .any(|d| host == *d || host.ends_with(&format!(".{d}"))) +} + +/// Rewrite Android client URLs in text/serialized data, keeping each URL's +/// byte length so serialized offsets remain valid. +pub fn android_urls(data: &[u8], target: &Target) -> Result<(Vec, usize)> { + let mut out = data.to_vec(); + let mut count = 0; + let mut occupied = vec![]; + for found in URL.find_iter(data) { + let old = std::str::from_utf8(found.as_bytes())?; + let Ok(parsed) = Url::parse(old) else { + continue; + }; + let host = parsed.host_str().unwrap_or(""); + let repatch = !parsed.username().is_empty() + && parsed.username().bytes().all(|b| b == b'x') + && host.parse::().is_ok(); + if !official(host) && !repatch && host != target.authority { + continue; + } + let prefix = old.find("://").unwrap() + 3; + let end = prefix + + old[prefix..] + .find(['/', '?', '#']) + .unwrap_or(old.len() - prefix); + let scheme = if parsed.scheme().starts_with("ws") { + if target.scheme == "https" { + "wss" + } else { + "ws" + } + } else { + target.scheme + }; + let base = format!("{scheme}://{}{}", target.authority, &old[end..]); + ensure!( + base.len() <= old.len(), + "server address is too long for Android URL {old}" + ); + let padding = old.len() - base.len(); + let user = if padding == 0 { + String::new() + } else { + format!("{}@", "x".repeat(padding - 1)) + }; + let new = format!("{scheme}://{user}{}{}", target.authority, &old[end..]); + ensure!(new.len() == found.len(), "Android URL length changed"); + out[found.range()].copy_from_slice(new.as_bytes()); + occupied.push(found.range()); + count += 1; + } + for found in BARE.find_iter(data) { + if occupied + .iter() + .any(|r| r.start < found.end() && found.start() < r.end) + { + continue; + } + if found.start() > 0 + && (data[found.start() - 1].is_ascii_alphanumeric() + || b"._-@".contains(&data[found.start() - 1])) + { + continue; + } + let padding = found.len().checked_sub(target.authority.len()); + let Some(padding) = padding else { + bail!("server address is too long for a bare Android host"); + }; + let new = if padding == 0 { + target.authority.clone() + } else { + format!("{}@{}", "x".repeat(padding - 1), target.authority) + }; + out[found.range()].copy_from_slice(new.as_bytes()); + count += 1; + } + Ok((out, count)) +} + +/// Natural-length variant of [`android_urls`] for the grow path: matched URLs +/// and bare hosts are rewritten to the target at their real length, without the +/// userinfo padding, so the data may change size. The caller must grow the +/// surrounding container (serialized asset, bundle node, split or text file). +pub fn android_urls_grow(data: &[u8], target: &Target) -> Result<(Vec, usize)> { + let mut edits: Vec<(std::ops::Range, Vec)> = vec![]; + let mut occupied: Vec> = vec![]; + for found in URL.find_iter(data) { + let old = std::str::from_utf8(found.as_bytes())?; + let Ok(parsed) = Url::parse(old) else { + continue; + }; + let host = parsed.host_str().unwrap_or(""); + let repatch = !parsed.username().is_empty() + && parsed.username().bytes().all(|b| b == b'x') + && host.parse::().is_ok(); + if !official(host) && !repatch && host != target.authority { + continue; + } + let prefix = old.find("://").unwrap() + 3; + let end = prefix + + old[prefix..] + .find(['/', '?', '#']) + .unwrap_or(old.len() - prefix); + let scheme = if parsed.scheme().starts_with("ws") { + if target.scheme == "https" { "wss" } else { "ws" } + } else { + target.scheme + }; + let new = format!("{scheme}://{}{}", target.authority, &old[end..]); + edits.push((found.range(), new.into_bytes())); + occupied.push(found.range()); + } + for found in BARE.find_iter(data) { + if occupied + .iter() + .any(|r| r.start < found.end() && found.start() < r.end) + { + continue; + } + if found.start() > 0 + && (data[found.start() - 1].is_ascii_alphanumeric() + || b"._-@".contains(&data[found.start() - 1])) + { + continue; + } + edits.push((found.range(), target.authority.clone().into_bytes())); + } + edits.sort_by_key(|(r, _)| r.start); + let count = edits.len(); + let mut out = Vec::with_capacity(data.len()); + let mut cursor = 0; + for (range, bytes) in edits { + ensure!(range.start >= cursor, "overlapping Android URL edits"); + out.extend_from_slice(&data[cursor..range.start]); + out.extend_from_slice(&bytes); + cursor = range.end; + } + out.extend_from_slice(&data[cursor..]); + Ok((out, count)) +} + +#[cfg(test)] +mod tests { + use super::Target; + + #[test] + fn builds_http_and_https_origins() { + let t = Target::new("127.0.0.1", false).unwrap(); + assert_eq!(t.scheme, "http"); + assert_eq!(t.origin, "http://127.0.0.1"); + assert_eq!(t.authority, "127.0.0.1"); + assert_eq!(t.dispatch(), "http://127.0.0.1/query_dispatch"); + + let t = Target::new("example.com:8080", true).unwrap(); + assert_eq!(t.origin, "https://example.com:8080"); + assert_eq!(t.authority, "example.com:8080"); + } + + #[test] + fn rejects_malformed_addresses() { + assert!(Target::new("", false).is_err()); + assert!(Target::new("http://x", false).is_err()); + assert!(Target::new("host/path", false).is_err()); + assert!(Target::new("a b", false).is_err()); + } +} diff --git a/src/patch/dispatch/windows.rs b/src/patch/dispatch/windows.rs new file mode 100644 index 0000000..87862ec --- /dev/null +++ b/src/patch/dispatch/windows.rs @@ -0,0 +1,171 @@ +//! Windows Dispatch & SDK patch: rewrite `ChannelConfig` dispatch URLs and the +//! `server_pc` SDK URLs across `sharedassets0.assets`, `resources.assets` and +//! the `mihoyo_sdk_res` UnityFS bundle, replacing the files atomically. +use crate::{ + format::unity::{self, Bundle}, + patch::{ + dispatch::{self, Target}, + region, + }, +}; +use anyhow::{Context, Result, ensure}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +pub struct Update { + pub path: PathBuf, + pub original: Vec, + pub patched: Vec, +} + +pub fn prepare(exe: &Path, target: &Target) -> Result> { + ensure!( + exe.is_file() + && exe + .file_name() + .is_some_and(|n| n.to_string_lossy().eq_ignore_ascii_case("BH3.exe")), + "select an existing BH3.exe" + ); + let client = exe.parent().context("missing client directory")?; + let data = client.join("BH3_Data"); + let shared_path = data.join("sharedassets0.assets"); + let shared = fs::read(&shared_path)?; + let channel_range = unity::script(&shared, "ChannelConfig")?; + let config = &shared[channel_range.clone()]; + let region = region::identify(config)?; + let mut grew = false; + let patched_shared = match dispatch::dispatch(config, target)? { + Some((config, _)) => { + let mut patched = shared.clone(); + patched[channel_range].copy_from_slice(&config); + patched + } + None => { + grew = true; + let (config, _) = dispatch::dispatch_grow(config, target)?; + unity::replace_script(&shared, "ChannelConfig", &config)? + } + }; + let resources_path = data.join("resources.assets"); + let resources = fs::read(&resources_path)?; + let range = unity::script(&resources, "server_pc")?; + let script = &resources[range.clone()]; + let records: Vec = serde_json::from_slice(script)?; + ensure!( + records + .iter() + .any(|r| r["game_key"] == region.sdk && r["env"].as_i64() == Some(region.env)), + "missing active {} SDK record", + region.name + ); + let bundle_path = data.join("StreamingAssets/MiHoYoSDKRes/PC/mihoyo_sdk_res"); + let raw = fs::read(&bundle_path)?; + let sdk = Bundle::read(&raw)?; + let mut located = None; + for (index, node) in sdk.nodes()?.into_iter().enumerate() { + if let Ok(range) = unity::script(&sdk.logical[node.clone()], "server_pc") { + ensure!( + located.is_none(), + "expected one server_pc TextAsset inside SDK bundle" + ); + let script_range = node.start + range.start..node.start + range.end; + located = Some((index, node, script_range)); + } + } + let (node_index, node_range, sdk_range) = + located.context("expected one server_pc TextAsset inside SDK bundle")?; + ensure!( + &sdk.logical[sdk_range.clone()] == script, + "the two server_pc copies differ; repair the client before patching" + ); + println!("[OK] Client checked"); + let (patched_resources, patched_bundle) = match dispatch::sdk(script, target)? { + Some((patched_script, _)) => { + let mut patched_resources = resources.clone(); + patched_resources[range].copy_from_slice(&patched_script); + let mut logical = sdk.logical.clone(); + logical[sdk_range].copy_from_slice(&patched_script); + (patched_resources, sdk.rebuild(&raw, &logical)?) + } + None => { + grew = true; + let (patched_script, _) = dispatch::sdk_grow(script, target)?; + let patched_resources = + unity::replace_script(&resources, "server_pc", &patched_script)?; + let new_node = + unity::replace_script(&sdk.logical[node_range], "server_pc", &patched_script)?; + (patched_resources, sdk.replace_node(node_index, &new_node)?) + } + }; + if grew { + println!( + "[!] Server address is longer than the shortest original URL; client assets were enlarged." + ); + } + println!("[OK] Patch prepared"); + Ok(vec![ + Update { + path: shared_path, + original: shared, + patched: patched_shared, + }, + Update { + path: resources_path, + original: resources, + patched: patched_resources, + }, + Update { + path: bundle_path, + original: raw, + patched: patched_bundle, + }, + ]) +} + +pub fn apply(updates: Vec) -> Result<()> { + let updates: Vec<_> = updates + .into_iter() + .filter(|u| u.original != u.patched) + .collect(); + if updates.is_empty() { + println!("[OK] Already patched; no files changed"); + return Ok(()); + } + for u in &updates { + ensure!( + fs::read(&u.path)? == u.original, + "client changed during preparation: {}", + u.path.display() + ); + } + let mut staged = vec![]; + for u in &updates { + let mut file = + tempfile::NamedTempFile::new_in(u.path.parent().context("no parent directory")?)?; + file.write_all(&u.patched)?; + file.as_file().sync_all()?; + fs::set_permissions(file.path(), fs::metadata(&u.path)?.permissions())?; + staged.push(file); + } + for (index, (u, file)) in updates.iter().zip(staged).enumerate() { + if let Err(error) = file.persist(&u.path) { + for previous in &updates[..index] { + fs::write(&previous.path, &previous.original) + .with_context(|| format!("rollback failed for {}", previous.path.display()))?; + } + return Err(error.error) + .context("could not replace client file; close the game/launcher and retry"); + } + } + println!("[OK] Done"); + Ok(()) +} + +pub fn patch(exe: &Path, target: &Target) -> Result<()> { + let exe = exe.canonicalize()?; + let updates = prepare(&exe, target)?; + apply(updates) +} diff --git a/src/patch/hoyokprotect/discovery.rs b/src/patch/hoyokprotect/discovery.rs new file mode 100644 index 0000000..9f5a34d --- /dev/null +++ b/src/patch/hoyokprotect/discovery.rs @@ -0,0 +1,130 @@ +//! HoYoKProtect loader discovery and validation in `UserAssembly.dll`. +use crate::format::{ + bytes::u32, + pe::{Pe, SECTION_EXECUTE}, +}; +use anyhow::{Context, Result, ensure}; +use regex::bytes::Regex; + +const PROLOGUE: &[u8] = b"\x55\x41\x57\x41\x56\x41\x54\x56\x57\x53\x48\x81\xec\xe0\0\0\0"; +const EPILOGUE: &[u8] = + b"\x44\x89\xe0\x48\x81\xc4\xe0\0\0\0\x5b\x5f\x5e\x41\x5c\x41\x5e\x41\x5f\x5d\xc3"; +pub(super) const DISABLED: &[u8] = b"\x31\xc0\xc3"; + +fn name_pattern() -> Result { + // Match the encoded string construction, not a version-specific RVA. + let mut pattern = String::from("(?s-u)"); + for (i, ch) in b"IsLoadMHYBase".iter().enumerate() { + pattern.push_str(&format!(r"\xb1\x{ch:02x}")); + if i == 0 { + pattern.push_str(r"\x31\xd2"); + } else { + pattern.push_str(&format!(r"\xba\x{i:02x}\x00\x00\x00")); + } + pattern.push_str(&format!(r"\xe8....\x88\x45\x{:02x}", 0x48 + i)); + } + pattern.push_str(r"\x31\xc9\xba\x0d\x00\x00\x00\xe8....\x88\x45\x55\xc6\x45\x56\x00\x48\x8d\x4d\x48\xe8....\x41\xb4\x01"); + Ok(Regex::new(&pattern)?) +} + +/// Returns file offset, RVA and whether the validated site is already disabled. +pub(super) fn locate(data: &[u8]) -> Result<(usize, u32, bool)> { + let image = Pe::read(data)?; + image.require_sections_within_image()?; + image.require_export(b"il2cpp_get_api_table\0")?; + let text = image.section(b".text\0\0\0")?; + ensure!( + text.flags & SECTION_EXECUTE != 0, + "native code section is not executable" + ); + let code = image.at_rva(text.rva, text.raw_size)?; + let pattern = name_pattern()?; + let hits: Vec<_> = pattern.find_iter(code).collect(); + ensure!( + hits.len() == 1, + "unknown or ambiguous HoYoKProtect loader pattern" + ); + let name_rva = text + .rva + .checked_add(u32::try_from(hits[0].start())?) + .context("RVA overflow")?; + let (table_rva, table_size) = image.data_dir(3)?; + let table_size = table_size as usize; + ensure!( + table_size > 0 && table_size.is_multiple_of(12), + "invalid native function table" + ); + let table = image.at_rva(table_rva, table_size)?; + let mut functions = Vec::new(); + for entry in table.as_chunks::<12>().0 { + let start = u32(entry, 0)?; + let end = u32(entry, 4)?; + if start <= name_rva && name_rva < end { + functions.push((start, end)); + } + } + ensure!( + functions.len() == 1, + "missing or ambiguous loader function boundary" + ); + let (start, end) = functions[0]; + ensure!( + start >= text.rva + && end + <= text + .rva + .checked_add(u32::try_from(text.raw_size)?) + .context("code range overflow")?, + "loader outside executable section" + ); + let function = image.at_rva(start, usize::try_from(end - start)?)?; + ensure!( + function.len() >= PROLOGUE.len() + EPILOGUE.len(), + "truncated loader function" + ); + ensure!( + function.ends_with(EPILOGUE), + "unknown loader return convention" + ); + let patched = function.starts_with(DISABLED); + ensure!( + (function.starts_with(PROLOGUE) || patched) && function[3..PROLOGUE.len()] == PROLOGUE[3..], + "unknown or modified loader entry" + ); + ensure!( + name_rva - start >= PROLOGUE.len() as u32 + && name_rva + .checked_add(u32::try_from(hits[0].len())?) + .is_some_and(|name_end| name_end <= end - EPILOGUE.len() as u32), + "loader pattern crosses function boundary" + ); + + // Both known consumers must agree: module checking and initialization. + let check = Regex::new( + r"(?s-u)^\x89\xc1\x88\x46\x04\x31\xc0\x84\xc9\x0f\x84....\x66\xb9\x6d\x00\x31\xd2\xe8....", + )?; + let init = Regex::new(r"(?s-u)^\x84\xc0\x74.\xe8....\x48\x89\xf9\xe8....\x89\xc6")?; + let mut consumers = [0, 0]; + let mut calls = 0; + for (at, bytes) in code.windows(5).enumerate() { + if bytes[0] != 0xe8 { + continue; + } + let destination = i64::from(text.rva) + + at as i64 + + 5 + + i64::from(i32::from_le_bytes(bytes[1..5].try_into()?)); + if destination != i64::from(start) { + continue; + } + calls += 1; + let after = &code[at + 5..]; + consumers[0] += usize::from(check.is_match(after)); + consumers[1] += usize::from(init.is_match(after)); + } + ensure!( + calls == 2 && consumers == [1, 1], + "unknown HoYoKProtect loader callers" + ); + Ok((text.raw + (start - text.rva) as usize, start, patched)) +} diff --git a/src/patch/hoyokprotect/mod.rs b/src/patch/hoyokprotect/mod.rs new file mode 100644 index 0000000..9c26779 --- /dev/null +++ b/src/patch/hoyokprotect/mod.rs @@ -0,0 +1,39 @@ +//! HoYoKProtect patch: force the client-side `IsLoadMHYBase` predicate in +//! `BH3_Data/Native/UserAssembly.dll` to return false. Independent of ACE. +mod discovery; + +use crate::{format::unity, patch::region, util::replace}; +use anyhow::{Context, Result, ensure}; +use discovery::{DISABLED, locate}; +use std::{fs, path::Path}; + +pub fn patch(exe: &Path) -> Result<()> { + ensure!( + exe.is_file() + && exe + .file_name() + .is_some_and(|n| n.eq_ignore_ascii_case("BH3.exe")), + "select an existing BH3.exe, not the client folder" + ); + let root = exe.parent().context("missing client directory")?; + let shared = fs::read(root.join("BH3_Data/sharedassets0.assets"))?; + region::identify(&shared[unity::script(&shared, "ChannelConfig")?])?; + let path = root.join("BH3_Data/Native/UserAssembly.dll"); + let original = fs::read(&path).context("cannot read UserAssembly.dll")?; + let (at, rva, patched) = locate(&original)?; + println!("[OK] Client checked"); + if patched { + println!("[OK] Already patched; no files changed"); + return Ok(()); + } + let mut replacement = original.clone(); + replacement[at..at + DISABLED.len()].copy_from_slice(DISABLED); + ensure!( + locate(&replacement)? == (at, rva, true), + "patched output validation failed" + ); + println!("[OK] Patch verified"); + replace::verified(&path, &original, &replacement)?; + println!("[OK] Client updated"); + Ok(()) +} diff --git a/src/patch/mod.rs b/src/patch/mod.rs new file mode 100644 index 0000000..133c370 --- /dev/null +++ b/src/patch/mod.rs @@ -0,0 +1,9 @@ +//! Patch modules. Each targets one client protection or redirection concern +//! and is independent of the others; shared binary-format code lives in +//! [`crate::format`]. +pub mod ace; +pub mod dispatch; +pub mod hoyokprotect; +pub mod protection; +pub mod region; +pub mod rsa; diff --git a/src/patch/protection/dex.rs b/src/patch/protection/dex.rs new file mode 100644 index 0000000..1aca186 --- /dev/null +++ b/src/patch/protection/dex.rs @@ -0,0 +1,237 @@ +//! Protection startup edits in DEX: NOP the native library loads in the +//! application and SDK `` methods and drop the SDK `attachBaseContext` +//! hook. Native loader reconstruction (loader/native) is also required. +use super::profile::Profile; +use crate::format::dex::recompute_checksums; +use anyhow::{Context, Result, ensure}; +use std::ops::Range; + +struct Dex<'a>(&'a [u8]); + +impl<'a> Dex<'a> { + fn bytes(&self, at: usize, size: usize) -> Result<&'a [u8]> { + self.0 + .get(at..at.checked_add(size).context("DEX range overflow")?) + .context("truncated DEX") + } + fn u16(&self, at: usize) -> Result { + Ok(u16::from_le_bytes(self.bytes(at, 2)?.try_into()?) as usize) + } + fn u32(&self, at: usize) -> Result { + Ok(u32::from_le_bytes(self.bytes(at, 4)?.try_into()?) as usize) + } + fn table(&self, header: usize, width: usize) -> Result> { + let count = self.u32(header)?; + let start = self.u32(header + 4)?; + let size = count.checked_mul(width).context("DEX table overflow")?; + ensure!(count == 0 || start >= 112, "invalid DEX table offset"); + self.bytes(start, size)?; + Ok(start..start + size) + } + fn item(&self, header: usize, width: usize, index: usize) -> Result { + let table = self.table(header, width)?; + ensure!(index < table.len() / width, "DEX index out of bounds"); + Ok(table.start + index * width) + } + fn uleb(&self, at: &mut usize) -> Result { + let mut value = 0u32; + for shift in (0..35).step_by(7) { + let byte = self.bytes(*at, 1)?[0]; + *at += 1; + ensure!(shift < 28 || byte <= 15, "invalid DEX ULEB128"); + value |= u32::from(byte & 127) << shift; + if byte & 128 == 0 { + return Ok(value as usize); + } + } + anyhow::bail!("invalid DEX ULEB128") + } + fn string(&self, index: usize) -> Result<&'a [u8]> { + let mut at = self.u32(self.item(56, 4, index)?)?; + ensure!(at >= 112, "invalid DEX string offset"); + self.uleb(&mut at)?; + let tail = self.0.get(at..).context("invalid DEX string")?; + let length = tail + .iter() + .position(|b| *b == 0) + .context("unterminated DEX string")?; + Ok(&tail[..length]) // Only ASCII identifiers are compared; other MUTF-8 is preserved. + } + fn class(&self, index: usize) -> Result<&'a [u8]> { + self.string(self.u32(self.item(64, 4, index)?)?) + } + fn method(&self, index: usize) -> Result<(&'a [u8], &'a [u8], &'a [u8])> { + let at = self.item(88, 8, index)?; + let proto = self.item(72, 12, self.u16(at + 2)?)?; + Ok(( + self.class(self.u16(at)?)?, + self.string(self.u32(at + 4)?)?, + self.string(self.u32(proto)?)?, + )) + } + /// Validate a `System.loadLibrary()` sequence and return its + /// instruction width (`const-string` is 10 bytes, `const-string/jumbo` 12). + fn load_library(&self, at: usize, library: &[u8]) -> Result { + let (width, index) = match self.bytes(at, 2)? { + [0x1a, 0] => (4, self.u16(at + 2)?), + [0x1b, 0] => (6, self.u32(at + 2)?), + _ => anyhow::bail!("unrecognized SDK const-string"), + }; + ensure!(self.string(index)? == library, "unexpected SDK library name"); + ensure!( + self.bytes(at + width, 2)? == [0x71, 0x10] && self.bytes(at + width + 4, 2)? == [0, 0], + "unrecognized SDK loadLibrary call" + ); + ensure!( + self.method(self.u16(at + width + 2)?)? + == ( + b"Ljava/lang/System;".as_slice(), + b"loadLibrary".as_slice(), + b"VL".as_slice() + ), + "SDK loader does not call System.loadLibrary(String)" + ); + Ok(width + 6) + } +} + +/// Returns the patched bytes and the number of recognized methods (three +/// across an APK). Refuses unfamiliar target methods; never changes input on +/// failure. +pub fn patch(data: &[u8], profile: &Profile) -> Result<(Vec, usize)> { + use sha1::{Digest, Sha1}; + let dex = Dex(data); + ensure!( + dex.bytes(0, 4)? == b"dex\n" && dex.bytes(7, 1)? == [0], + "not a DEX file" + ); + ensure!( + [b"035", b"037", b"038", b"039", b"040"] + .iter() + .any(|v| dex.bytes(4, 3).ok() == Some(v.as_slice())), + "unsupported DEX version" + ); + ensure!( + dex.u32(32)? == data.len() && dex.u32(36)? == 112 && dex.u32(40)? == 0x12345678, + "invalid DEX header" + ); + ensure!( + dex.u32(8)? as u32 == adler2::adler32_slice(&data[12..]) + && data[12..32] == Sha1::digest(&data[32..])[..], + "DEX checksum mismatch" + ); + let mut edits: Vec<(Range, Vec)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for at in dex.table(96, 32)?.step_by(32) { + let class = dex.class(dex.u32(at)?)?; + let app = class == profile.app_class; + if !app && class != profile.sdk_class { + continue; + } + let mut cursor = dex.u32(at + 24)?; + ensure!(cursor >= 112, "missing SDK class data"); + let counts = [ + dex.uleb(&mut cursor)?, + dex.uleb(&mut cursor)?, + dex.uleb(&mut cursor)?, + dex.uleb(&mut cursor)?, + ]; + ensure!( + counts.iter().all(|n| *n <= data.len() / 2), + "excessive DEX class members" + ); + for _ in 0..counts[0] + counts[1] { + dex.uleb(&mut cursor)?; + dex.uleb(&mut cursor)?; + } + for count in &counts[2..] { + let mut index = 0usize; + for _ in 0..*count { + index = index + .checked_add(dex.uleb(&mut cursor)?) + .context("DEX method index overflow")?; + let flags = dex.uleb(&mut cursor)?; + let code = dex.uleb(&mut cursor)?; + let (owner, method, shorty) = dex.method(index)?; + ensure!(owner == class, "DEX method belongs to another class"); + if method != b"" && !(app && method == b"attachBaseContext") { + continue; + } + ensure!(seen.insert((class, method)), "duplicate SDK method"); + ensure!(code >= 112 && code % 4 == 0, "invalid SDK code offset"); + let length = dex + .u32(code + 12)? + .checked_mul(2) + .context("DEX code overflow")?; + let start = code + 16; + dex.bytes(start, length)?; + if method == b"" { + ensure!(flags & 8 != 0 && shorty == b"V", "invalid SDK static initializer"); + if app { + ensure!(length == 36, "unfamiliar application initializer layout"); + let w0 = dex.load_library(start, profile.app_libs[0])?; + let w1 = dex.load_library(start + 10, profile.app_libs[1])?; + ensure!(w0 == 10 && w1 == 10, "unfamiliar application initializer layout"); + edits.push((start..start + 20, vec![0; 20])); + } else { + let width = dex.load_library(start, profile.sdk_lib)?; + ensure!(length == width + 2, "unfamiliar SDK initializer layout"); + ensure!( + dex.bytes(start + width, 2)? == [0x0e, 0], + "unfamiliar SDK initializer return" + ); + edits.push((start..start + width, vec![0; width])); + } + } else { + ensure!( + flags & 8 == 0 && shorty == b"VL" && length >= 10, + "invalid attachBaseContext signature" + ); + ensure!( + dex.bytes(start, 4)? == [0x12, 0x05, 0x6f, 0x20], + "unfamiliar attachBaseContext prologue" + ); + ensure!( + dex.method(dex.u16(start + 4)?)? + == ( + b"Landroid/app/Application;".as_slice(), + b"attachBaseContext".as_slice(), + b"VL".as_slice() + ), + "SDK attach hook does not call Application.attachBaseContext" + ); + ensure!( + length >= 16 + && dex.bytes(start + 8, 2)? == [0x6e, 0x10] + && dex.bytes(start + 12, 4)? == [8, 0, 0x0c, 0], + "unfamiliar attach hook instruction boundary" + ); + let (_, call, result) = dex.method(dex.u16(start + 10)?)?; + ensure!( + call == b"getApplicationInfo" && result == b"L", + "unfamiliar attach hook call" + ); + // Replace the complete invoke + move-result; leftover operands break TW verification. + edits.push((start + 8..start + 16, vec![0x0e, 0, 0, 0, 0, 0, 0, 0])); + } + } + } + ensure!( + seen.iter().filter(|(owner, _)| *owner == class).count() == if app { 2 } else { 1 }, + "SDK startup methods are missing" + ); + } + edits.sort_by_key(|e| e.0.start); + ensure!( + edits.windows(2).all(|w| w[0].0.end <= w[1].0.start), + "overlapping SDK edits" + ); + let mut output = data.to_vec(); + for (range, bytes) in &edits { + output[range.clone()].copy_from_slice(bytes); + } + if !edits.is_empty() { + recompute_checksums(&mut output)?; + } + Ok((output, edits.len())) +} diff --git a/src/patch/protection/loader.rs b/src/patch/protection/loader.rs new file mode 100644 index 0000000..413f056 --- /dev/null +++ b/src/patch/protection/loader.rs @@ -0,0 +1,511 @@ +//! Offline restoration of a protected AnoSDK/TSS ELF wrapper. No process +//! capture: the page permutation, section cipher, import table and dynamic +//! entries are reconstructed statically. +use super::profile::Profile; +use crate::format::{ + bytes::{be32, slice, u32, u64}, + elf::{Elf, Section, cstr, find_all, find_one, offset}, +}; +use aes::cipher::{BlockDecrypt, KeyInit}; +use anyhow::{Context, Result, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Discover the eight-page permutation through the decoder array's GOT reference. +pub fn permutation(sdk: &[u8], profile: &Profile) -> Result<[usize; 8]> { + let elf = Elf::new(sdk)?; + let symbols = elf.symbols()?; + let mut exports = symbols + .iter() + .filter(|s| s.name == profile.decoder_symbol && s.section != 0); + let array = exports.next().context("SDK decoder table missing")?.value; + ensure!( + exports.next().is_none() && array <= i64::MAX as u64 - 0x1a0, + "ambiguous SDK decoder table" + ); + let mut relocations = BTreeMap::new(); + for at in elf.relas()? { + if u64(sdk, at + 8)? == 1027 { + ensure!( + relocations.insert(u64(sdk, at)?, u64(sdk, at + 16)?).is_none(), + "duplicate SDK relocation" + ); + } + } + let start = *relocations + .get(&(array + 8)) + .context("SDK decoder entry missing")?; + let end = relocations + .range(array..array + 0x1a0) + .filter_map(|(_, v)| (*v > start).then_some(*v)) + .min() + .context("SDK decoder boundary missing")?; + ensure!( + end > start && end - start <= 1 << 20 && (end - start) % 4 == 0, + "invalid SDK decoder boundary" + ); + let size = offset(end - start)?; + let code = slice(sdk, elf.rva(start, size)?, size)?; + let words: Vec<_> = code + .as_chunks::<4>() + .0 + .iter() + .map(|w| u32::from_le_bytes(*w)) + .collect(); + let mut found = BTreeSet::new(); + for (i, &ins) in words.iter().enumerate() { + if ins & 0xffc0001f != 0xb9400003 { + continue; + } + let reg = ins >> 5 & 31; + for j in i.saturating_sub(8)..i { + let load = words[j]; + if load & 0xffc00000 != 0xf9400000 || load & 31 != reg || load >> 5 & 31 != reg { + continue; + } + for (k, &adr) in words.iter().enumerate().take(j).skip(j.saturating_sub(8)) { + if adr & 0x9f00001f != (0x90000000 | reg) { + continue; + } + let imm = (((adr >> 5 & 0x7ffff) << 2) | (adr >> 29 & 3)) as i32; + let imm = (imm << 11) >> 11; + let page = (((start + k as u64 * 4) & !4095) as i64) + .checked_add(i64::from(imm) << 12) + .context("SDK address overflow")?; + if page < 0 { + continue; + } + let got = (page as u64) + .checked_add(u64::from(load >> 10 & 4095) * 8) + .context("SDK GOT overflow")?; + let Some(&base) = relocations.get(&got) else { + continue; + }; + let address = base + .checked_add(u64::from(ins >> 10 & 4095) * 4) + .context("SDK constant overflow")?; + let value = u32(sdk, elf.rva(address, 4)?)?; + let digits: [usize; 8] = + std::array::from_fn(|n| ((value >> (28 - n * 4)) & 15) as usize); + let mut sorted = digits; + sorted.sort_unstable(); + if sorted == [0, 1, 2, 3, 4, 5, 6, 7] { + found.insert(digits); + } + } + } + } + ensure!(found.len() == 1, "unknown or ambiguous SDK page permutation"); + Ok(*found.first().context("missing SDK page permutation")?) +} + +fn section_row(elf: &Elf<'_>, at: usize) -> bool { + (|| -> Result { + let name = cstr(slice(elf.data, at, 16)?, 0)?; + if name.is_empty() { + return Ok(false); + } + let s = elf.section(name)?; + Ok(s.size > 0 + && u64::from(u32(elf.data, at + 16)?) == s.rva + && u32(elf.data, at + 20)? as usize == s.at + && u32(elf.data, at + 24)? as usize == s.size) + })() + .unwrap_or(false) +} + +fn decode_sections(elf: &Elf<'_>, output: &mut [u8], order: &[usize; 8], profile: &Profile) -> Result<()> { + let raw = elf.data; + let mut tables = BTreeSet::new(); + for mut at in find_all(raw, b".rodata\0\0\0\0\0\0\0\0\0")? { + if !section_row(elf, at) { + continue; + } + for _ in 0..7 { + if at < 48 || !section_row(elf, at - 48) { + break; + } + at -= 48; + } + tables.insert(at); + } + ensure!(tables.len() == 1, "missing or ambiguous protected section table"); + let table = *tables.first().context("protected section table missing")?; + slice(raw, table, 0x1a4)?; + let mode = u32(raw, table + 0x184)?; + ensure!(matches!(mode, 1 | 2), "unsupported section protection mode"); + let encoded = cstr(raw, table + 0x1a4)?; + ensure!(encoded.len() <= 2048, "oversized wrapper key record"); + let mut keys = STANDARD.decode(encoded).context("invalid wrapper key encoding")?; + for byte in &mut keys { + *byte ^= profile.key_xor; + } + ensure!(be32(&keys, 0)? == 0x20240829, "unknown wrapper key record version"); + let count = be32(&keys, 4)? as usize; + ensure!( + count > 0 && count <= 128 && keys.len() == 8 + count * 12, + "invalid wrapper key record length" + ); + let mut key = None; + let mut ids = BTreeSet::new(); + for at in (8..keys.len()).step_by(12) { + let id = be32(&keys, at)?; + let value = be32(&keys, at + 4)?; + ensure!( + id ^ value == be32(&keys, at + 8)? && ids.insert(id), + "wrapper key checksum/identity mismatch" + ); + if id == 1 { + key = Some(value); + } + } + let key = key.context("wrapper section key missing")?; + let xor = ((key >> 16) & 255) as u8; + let xor = if xor == 0 { 0x87 } else { xor }; + let cipher = aes::Aes128::new_from_slice(format!("{key:08x}{key:08x}").as_bytes()) + .map_err(|_| anyhow::anyhow!("invalid section cipher key"))?; + let mut ranges = Vec::new(); + for i in 0..8 { + let at = table + i * 48; + let size = u32(raw, at + 24)? as usize; + if size == 0 { + break; + } + ensure!(section_row(elf, at), "protected section descriptor mismatch"); + let name = cstr(slice(raw, at, 16)?, 0)?; + let s = elf.section(name)?; + ensure!( + s.flags & 2 != 0 && s.flags & 1 == 0 && s.kind != 8, + "unexpected protected section permissions" + ); + ensure!( + ranges + .iter() + .all(|&(start, end)| s.at + size <= start || s.at >= end), + "overlapping protected sections" + ); + ranges.push((s.at, s.at + size)); + let block = &mut output[s.at..s.at + size]; + if size < 1 << 20 { + for b in &mut *block { + *b ^= xor; + } + } else { + let start = 0x2000 - (s.rva as usize & 4095); + ensure!(start + 0x10000 <= size, "truncated protected block"); + for chunk in block[start..start + 0x4000].chunks_mut(2048) { + let mut previous: [u8; 16] = std::array::from_fn(|n| n as u8 + 2); + for encrypted in chunk.as_chunks_mut::<16>().0 { + let saved = *encrypted; + let mut decrypted = saved.into(); + cipher.decrypt_block(&mut decrypted); + for n in 0..16 { + encrypted[n] = decrypted[n] ^ previous[n]; + } + previous = saved; + } + } + if mode == 2 { + for b in &mut block[start + 0x4000..] { + *b ^= xor; + } + } else { + let start = start + 0x10000; + let full = (size - start) / 0x10000; + for j in 0..full { + let at = start + j * 0x10000; + for b in &mut block[at..at + 0x4000] { + *b ^= xor; + } + } + let mut saved = vec![0; 0x20000]; + for group in 0..full / 8 { + let at = start + group * 0x80000; + for j in 0..8 { + saved[j * 0x4000..(j + 1) * 0x4000] + .copy_from_slice(&block[at + j * 0x10000..at + j * 0x10000 + 0x4000]); + } + for (j, &dest) in order.iter().enumerate() { + block[at + dest * 0x10000..at + dest * 0x10000 + 0x4000] + .copy_from_slice(&saved[j * 0x4000..(j + 1) * 0x4000]); + } + } + for b in &mut block[start + full * 0x10000..] { + *b ^= xor; + } + } + } + ensure!( + crc32fast::hash(block) == u32(raw, at + 28)?, + "restored section CRC mismatch: {}", + String::from_utf8_lossy(name) + ); + } + ensure!(!ranges.is_empty(), "no protected sections restored"); + Ok(()) +} + +fn sleb(data: &[u8], cursor: &mut usize, limit: usize) -> Result { + let mut value = 0u64; + for shift in (0..70).step_by(7) { + ensure!(*cursor < limit, "truncated APS2 integer"); + let byte = data[*cursor]; + *cursor += 1; + ensure!( + // Bionic also accepts unsigned 64-bit encodings of negative addends. + shift < 63 || matches!(byte, 0 | 1 | 0x7f), + "APS2 integer overflow at {:#x} (last byte {byte:#x})", + *cursor - 1 + ); + value |= u64::from(byte & 127) << shift; + if byte < 128 { + if byte & 64 != 0 && shift < 57 { + value |= u64::MAX << (shift + 7); + } + return Ok(value as i64); + } + } + anyhow::bail!("invalid APS2 integer") +} + +fn aps_info(data: &mut [u8], cursor: &mut usize, limit: usize, wrappers: &BTreeSet) -> Result { + let start = *cursor; + let value = sleb(data, cursor, limit)?; + if value >= 0 && wrappers.contains(&((value as u64) >> 32)) { + data[start..*cursor].fill(0x80); + data[*cursor - 1] = 0; + return Ok(1); + } + Ok(0) +} + +fn strip_aps(data: &mut [u8], section: &Section<'_>, wrappers: &BTreeSet) -> Result { + let limit = section.at + section.size; + let mut cursor = section.at + 4; + let total = sleb(data, &mut cursor, limit)?; + ensure!( + total >= 0 && total as usize <= data.len(), + "invalid APS2 relocation count" + ); + sleb(data, &mut cursor, limit)?; + let mut done = 0; + let mut removed = 0; + while done < total { + let count = sleb(data, &mut cursor, limit)?; + let flags = sleb(data, &mut cursor, limit)?; + ensure!( + count > 0 && count <= total - done && (0..16).contains(&flags), + "invalid APS2 group" + ); + if flags & 2 != 0 { + sleb(data, &mut cursor, limit)?; + } + if flags & 1 != 0 { + removed += count as usize * aps_info(data, &mut cursor, limit, wrappers)?; + } + if flags & 12 == 12 { + sleb(data, &mut cursor, limit)?; + } + for _ in 0..count { + if flags & 2 == 0 { + sleb(data, &mut cursor, limit)?; + } + if flags & 1 == 0 { + removed += aps_info(data, &mut cursor, limit, wrappers)?; + } + if flags & 12 == 8 { + sleb(data, &mut cursor, limit)?; + } + } + done += count; + } + Ok(removed) +} + +/// Restore one protected game library; return it and the removed wrapper's +/// original `DT_INIT` entry for the native API helper. +pub fn restore(data: &[u8], order: &[usize; 8], profile: &Profile) -> Result<(Vec, u64)> { + let mut sorted = *order; + sorted.sort_unstable(); + ensure!(sorted == [0, 1, 2, 3, 4, 5, 6, 7], "invalid section page order"); + let elf = Elf::new(data)?; + let mut dynamics = elf.segments.iter().filter(|s| s.kind == 2); + let dynamic = dynamics.next().context("dynamic segment missing")?; + ensure!( + dynamics.next().is_none() && dynamic.size % 16 == 0, + "ambiguous dynamic segment" + ); + let mut entries = Vec::new(); + let mut terminated = false; + for at in (dynamic.at..dynamic.at + dynamic.size).step_by(16) { + let tag = u64(data, at)?; + if tag == 0 { + terminated = true; + break; + } + entries.push((tag, u64(data, at + 8)?)); + } + ensure!(terminated, "unterminated dynamic segment"); + let value = |tag| -> Result { + let values: Vec<_> = entries.iter().filter(|e| e.0 == tag).collect(); + ensure!(values.len() == 1, "required dynamic entry missing/duplicated"); + Ok(values[0].1) + }; + let init = value(12)?; + ensure!( + init <= i64::MAX as u64 - 48 + && elf.segments.iter().any(|s| s.kind == 1 + && s.flags & 1 != 0 + && init >= s.rva + && init + 48 <= s.rva + s.size as u64), + "wrapper initializer is not executable" + ); + let pc = init + 44; + let adr = u32(data, elf.rva(pc, 4)?)?; + ensure!(adr & 0x9f00001f == 0x10000001, "unknown wrapper initializer layout"); + let imm = (((adr >> 5 & 0x7ffff) << 2) | (adr >> 29 & 3)) as i32; + let imm = (imm << 11) >> 11; + let config_rva = (pc as i64) + .checked_add(i64::from(imm)) + .context("wrapper metadata address overflow")?; + ensure!(config_rva >= 0, "invalid wrapper metadata address"); + let config = elf.rva(config_rva as u64, 24)?; + ensure!( + slice(data, config, 16)? == b"BBMG311ENC12IAI\0", + "unsupported constructor metadata" + ); + let array = u64::from(u32(data, config + 16)?); + let count = u32(data, config + 20)? as usize; + ensure!(count <= 65536, "excessive constructor count"); + if count > 0 { + elf.rva(array, count * 8)?; + } + let strings_at = elf.rva(value(5)?, offset(value(10)?)?)?; + let strings = slice(data, strings_at, offset(value(10)?)?)?; + let mut needed = Vec::new(); + for &(tag, address) in &entries { + if tag == 1 && cstr(strings, offset(address)?)? == profile.needed_so { + needed.push(address); + } + } + ensure!(needed.len() == 1, "expected exactly one native wrapper dependency"); + let symbols = elf.symbols()?; + let wrappers: BTreeSet<_> = symbols + .iter() + .enumerate() + .filter(|(_, s)| { + s.section == 0 + && (profile.wrapper_prefixes.iter().any(|p| s.name.starts_with(p)) + || s.name == b"xxxxXXXXxxxx") + }) + .map(|(i, _)| i as u64) + .collect(); + ensure!(!wrappers.is_empty(), "native wrapper symbols missing"); + let mut output = data.to_vec(); + decode_sections(&elf, &mut output, order, profile)?; + + let table = elf.section(b".rela.plt")?; + ensure!( + table.kind == 4 && table.size % 24 == 0, + "invalid import relocation table" + ); + let imp = find_one(data, b"BBMG311DIS12IMP\0")?; + slice(data, imp, 0xe4)?; + let protected = u32(data, imp + 16)? as usize; + let skip_count = u32(data, imp + 32)? as usize; + ensure!( + protected > 0 + && protected <= table.size / 24 + && skip_count <= 32 + && u64::from(u32(data, imp + 24)?) == table.rva + && u32(data, imp + 28)? as usize == table.size / 24, + "unsupported import metadata layout" + ); + let excluded: BTreeSet<_> = (0..skip_count) + .map(|i| u32(data, imp + 36 + i * 4).map(u64::from)) + .collect::>()?; + let mut selected = Vec::new(); + for at in (table.at..table.at + table.size).step_by(24) { + let info = u64(data, at + 8)?; + let index = offset(info >> 32)?; + let symbol = symbols.get(index).context("import symbol index out of bounds")?; + if !excluded.contains(&u64(data, at)?) + && index != 0 + && symbol.section == 0 + && selected.len() < protected + { + ensure!(info as u32 == 1026, "unexpected import relocation type"); + selected.push((at, info)); + } + } + ensure!(selected.len() == protected, "incomplete protected import table"); + for (i, &(at, _)) in selected.iter().enumerate() { + output[at + 8..at + 16].copy_from_slice(&selected[(i + 1) % selected.len()].1.to_le_bytes()); + } + + let mut removed = 0; + for s in &elf.sections { + if s.kind != 8 && s.size >= 4 && slice(data, s.at, 4)? == b"APS2" { + removed += strip_aps(&mut output, s, &wrappers)?; + } + } + for at in elf.relas()? { + let address = u64(data, at)?; + let info = u64(data, at + 8)?; + let addend = u64(data, at + 16)?; + if wrappers.contains(&(info >> 32)) { + output[at + 8..at + 16].fill(0); + removed += 1; + } else if info == 1027 + && addend >= init + && elf.sections.iter().any(|s| { + matches!(s.name, b".got" | b".got.plt") + && address >= s.rva + && address < s.rva + s.size as u64 + }) + { + let code = slice(data, elf.rva(addend, 160)?, 160)?; + let patterns = [ + (b"\x04\x0d\x40\xf9\x80\x00\x1f\xd6".as_slice(), b"fread".as_slice()), + (b"\x04\x11\x40\xf9\x80\x00\x1f\xd6", b"fwrite"), + (b"\x81\x1a\x40\xf9\xe0\x03\x13\xaa", b"fclose"), + ]; + let names: Vec<_> = patterns + .iter() + .filter(|(p, _)| { + code.as_chunks::<4>() + .0 + .iter() + .enumerate() + .any(|(i, _)| code.get(i * 4..i * 4 + p.len()) == Some(*p)) + }) + .map(|(_, n)| *n) + .collect(); + ensure!(names.len() == 1, "unknown native file-I/O hook"); + let index = symbols + .iter() + .position(|s| s.section == 0 && s.name == names[0]) + .context("original file-I/O import missing")?; + output[at + 8..at + 16].copy_from_slice(&(((index as u64) << 32) | 1025).to_le_bytes()); + output[at + 16..at + 24].fill(0); + } + } + ensure!(removed > 0, "no wrapper relocations removed"); + entries.retain(|&(tag, address)| { + !matches!(tag, 12 | 25 | 27) && !(tag == 1 && address == needed[0]) + }); + if count > 0 { + entries.extend([(25, array), (27, count as u64 * 8)]); + } + ensure!( + (entries.len() + 1) * 16 <= dynamic.size, + "restored dynamic table does not fit" + ); + output[dynamic.at..dynamic.at + dynamic.size].fill(0); + for (i, (tag, value)) in entries.into_iter().enumerate() { + let at = dynamic.at + i * 16; + output[at..at + 8].copy_from_slice(&tag.to_le_bytes()); + output[at + 8..at + 16].copy_from_slice(&value.to_le_bytes()); + } + Ok((output, init)) +} diff --git a/src/patch/protection/mod.rs b/src/patch/protection/mod.rs new file mode 100644 index 0000000..47b26de --- /dev/null +++ b/src/patch/protection/mod.rs @@ -0,0 +1,215 @@ +//! Android protection removal for AnoSDK (overseas) and Mobile ACE/TSS (CN). +//! Both variants share one pipeline — identify the APK, restore four protected +//! ARM64 libraries, neutralize the SDK startup and native API calls, drop the +//! protection libraries, then sign a new APK — and differ only in the +//! [`profile::Profile`] constants. +pub mod dex; +pub mod loader; +pub mod native; +pub mod profile; + +use crate::{android::sign, format::unity}; +use anyhow::{Context, Result, ensure}; +use profile::Profile; +use std::{ + collections::{BTreeMap, HashSet}, + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions}; + +const LIBRARIES: &[&str] = &["libmain.so", "libunity.so", "libulua.so", "libil2cpp.so"]; + +/// Patch an APK as AnoSDK (overseas) clients. +pub fn anosdk(apk: &Path) -> Result { + patch(apk, &profile::ANOSDK) +} + +/// Patch an APK as Mobile ACE/TSS (CN) clients. +pub fn mobile_ace_tss(apk: &Path) -> Result { + patch(apk, &profile::TSS) +} + +/// Pick the variant from the protection libraries present in the APK. +pub fn detect(apk: &Path) -> Result<&'static Profile> { + let mut archive = ZipArchive::new(File::open(apk)?)?; + let mut names = HashSet::new(); + for i in 0..archive.len() { + names.insert(archive.by_index(i)?.name().to_owned()); + } + let has = |lib: &str| names.contains(&format!("lib/arm64-v8a/{lib}")); + if has("libtprt.so") { + Ok(&profile::TSS) + } else if has("libanort.so") { + Ok(&profile::ANOSDK) + } else { + anyhow::bail!("no supported protection library found in APK") + } +} + +/// Patch an APK, auto-detecting the variant. +pub fn auto(apk: &Path) -> Result { + patch(apk, detect(apk)?) +} + +fn patch(apk: &Path, profile: &Profile) -> Result { + inspect(apk, profile)?; + sign::signed_patch(apk, |input, output| repack(input, output, profile)) +} + +// ChannelConfig may have trailing commas; read only the two identity fields. +fn field<'a>(data: &'a [u8], key: &str) -> Result<&'a str> { + let pattern = regex::bytes::Regex::new(&format!(r#""{key}"\s*:\s*"([^"\\]*)""#))?; + let matches: Vec<_> = pattern.captures_iter(data).collect(); + ensure!(matches.len() == 1, "missing or duplicate {key}"); + Ok(std::str::from_utf8( + matches[0].get(1).context("missing field value")?.as_bytes(), + )?) +} + +/// Identify the APK region and protection variant; returns the region name. +fn inspect(path: &Path, profile: &Profile) -> Result<&'static str> { + ensure!( + path.is_file() + && path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("apk")), + "select an existing APK" + ); + let mut apk = ZipArchive::new(File::open(path)?)?; + let mut names = HashSet::new(); + let mut parts = BTreeMap::new(); + const ASSET: &str = "assets/bin/Data/sharedassets0.assets"; + for index in 0..apk.len() { + let entry = apk.by_index(index)?; + let name = entry.name().to_owned(); + ensure!( + entry.enclosed_name().is_some() && names.insert(name.clone()), + "unsafe or duplicate APK entry" + ); + if let Some(part) = name.strip_prefix(&format!("{ASSET}.split")) { + let part: usize = part.parse().context("invalid split asset index")?; + ensure!(parts.insert(part, name).is_none(), "duplicate split asset index"); + } + } + let files = if names.contains(ASSET) { + ensure!(parts.is_empty(), "ambiguous full and split assets"); + vec![ASSET.to_owned()] + } else { + ensure!( + !parts.is_empty() && parts.keys().copied().eq(0..parts.len()), + "missing ChannelConfig asset parts" + ); + parts.into_values().collect() + }; + let mut data = Vec::new(); + for name in files { + let mut entry = apk.by_name(&name)?; + ensure!( + data.len() as u64 + entry.size() <= 64 * 1024 * 1024, + "ChannelConfig asset exceeds 64 MiB" + ); + entry.read_to_end(&mut data)?; + } + let config = &data[unity::script(&data, "ChannelConfig")?]; + let channel_name = field(config, "ChannelName")?; + let bundle_id = field(config, "BundleIdentifier")?; + let region = profile + .regions + .iter() + .find(|(_, channel, bundle)| channel_name == *channel && bundle_id == *bundle) + .with_context(|| format!("APK region/channel is not recognized by {}", profile.label))?; + for library in profile.protection_libs { + let mut entry = apk + .by_name(&format!("lib/arm64-v8a/{library}")) + .with_context(|| format!("missing {library}"))?; + let mut header = [0; 20]; + entry.read_exact(&mut header)?; + ensure!( + &header[..6] == b"\x7fELF\x02\x01" && header[18..20] == [183, 0], + "expected ARM64 ELF protection library" + ); + } + ensure!( + !names.iter().any(|name| profile + .conflict_libs + .iter() + .any(|library| name.ends_with(&format!("/{library}")))), + "conflicting protection variants in APK" + ); + Ok(region.0) +} + +/// All edits remain in the signing workflow's temporary directory until +/// verification succeeds. +fn repack(input: &Path, output: &Path, profile: &Profile) -> Result<()> { + inspect(input, profile)?; + let mut apk = ZipArchive::new(File::open(input)?)?; + for i in 0..apk.len() { + let file = apk.by_index(i)?; + ensure!( + !file.name().starts_with("lib/") + || !file.name().ends_with(".so") + || file.name().starts_with("lib/arm64-v8a/"), + "only ARM64 APKs are supported" + ); + } + let mut sdk = Vec::new(); + let mut entry = apk.by_name(profile.decoder_lib)?; + ensure!(entry.size() <= 64 << 20, "SDK library exceeds 64 MiB"); + entry.read_to_end(&mut sdk)?; + drop(entry); + let order = loader::permutation(&sdk, profile)?; + drop(sdk); + let mut writer = ZipWriter::new(File::options().write(true).create_new(true).open(output)?); + let (mut libraries, mut methods) = (0, 0); + for i in 0..apk.len() { + let mut file = apk.by_index(i)?; + let name = file.name().to_owned(); + if crate::android::signature(&name) || profile.removed_libs.contains(&name.as_str()) { + continue; + } + let native = name + .strip_prefix("lib/arm64-v8a/") + .filter(|n| LIBRARIES.contains(n)); + if native.is_none() && !name.ends_with(".dex") { + writer.raw_copy_file(file)?; + continue; + } + ensure!(file.size() <= 1 << 30, "APK entry exceeds 1 GiB: {name}"); + let mut raw = Vec::new(); + file.read_to_end(&mut raw)?; + let patched = (|| -> Result> { + if let Some(library) = native { + let (restored, init) = loader::restore(&raw, &order, profile)?; + libraries += 1; + if library == "libil2cpp.so" { + let (patched, _) = native::neutralize(&restored, init, profile)?; + Ok(patched) + } else { + Ok(restored) + } + } else { + let (patched, count) = dex::patch(&raw, profile)?; + methods += count; + Ok(patched) + } + })() + .with_context(|| format!("cannot patch {name}"))?; + writer.start_file( + name, + SimpleFileOptions::default() + .compression_method(file.compression()) + .unix_permissions(file.unix_mode().unwrap_or(0o644)), + )?; + writer.write_all(&patched)?; + } + ensure!( + libraries == LIBRARIES.len() && methods == 3, + "incomplete {} patch: {libraries} libraries, {methods} Java methods", + profile.label + ); + writer.finish()?.sync_all()?; + Ok(()) +} diff --git a/src/patch/protection/native.rs b/src/patch/protection/native.rs new file mode 100644 index 0000000..228409f --- /dev/null +++ b/src/patch/protection/native.rs @@ -0,0 +1,208 @@ +//! Neutralize AnoSDK/TSS P/Invoke resolutions in a reconstructed ARM64 IL2CPP +//! image. Not an unpacker: the game's protected code/data must be restored by +//! [`super::loader`] first. +use super::profile::Profile; +use crate::format::{ + bytes::{slice, u32, u64}, + elf::{Elf, Section, offset}, +}; +use anyhow::{Context, Result, ensure}; +use std::collections::{BTreeMap, BTreeSet}; + +fn branch(from: u64, to: u64) -> Result { + let delta = i64::try_from(to)? - i64::try_from(from)?; + ensure!( + delta % 4 == 0 && (-(1 << 27)..1 << 27).contains(&delta), + "ARM64 helper is out of branch range" + ); + Ok(0x94000000 | ((delta / 4) as u32 & 0x03ffffff)) +} + +fn section_bytes<'a>(elf: &Elf<'a>, section: &Section<'a>) -> Result<&'a [u8]> { + slice(elf.data, section.at, section.size) +} + +/// `helper_rva` must be the removed wrapper's original `DT_INIT` entry, not a +/// code cave. Reuses that entry for a 16-byte helper; refuses still-active or +/// unknown initializers. +pub fn neutralize(data: &[u8], helper_rva: u64, profile: &Profile) -> Result<(Vec, usize)> { + let elf = Elf::new(data)?; + let mut helper = None; + let mut dynamic = false; + for seg in &elf.segments { + if seg.kind == 2 { + ensure!(!dynamic && seg.size % 16 == 0, "ambiguous ELF dynamic table"); + dynamic = true; + let mut terminated = false; + for entry in (seg.at..seg.at + seg.size).step_by(16) { + let tag = u64(data, entry)?; + if tag == 0 { + terminated = true; + break; + } + ensure!( + tag != 12, + "native wrapper initialization is still active; reconstruct the loader first" + ); + } + ensure!(terminated, "unterminated ELF dynamic table"); + } + if seg.kind == 1 + && seg.flags & 1 != 0 + && helper_rva >= seg.rva + && helper_rva + .checked_sub(seg.rva) + .and_then(|v| v.checked_add(16)) + .is_some_and(|v| v <= seg.size as u64) + { + ensure!(helper.is_none(), "ambiguous helper mapping"); + helper = Some(seg.at + offset(helper_rva - seg.rva)?); + } + } + ensure!(dynamic, "ELF dynamic table missing"); + let helper = helper.context("removed initializer is not file-backed executable code")?; + ensure!( + slice(data, helper, 8)? == [0xfd, 0x7b, 0xbf, 0xa9, 0xfd, 0x03, 0x00, 0x91], + "unrecognized removed wrapper initializer" + ); + let ro = elf.section(b".rodata")?; + let ro_data = section_bytes(&elf, ro)?; + let mut targets = BTreeMap::new(); + for name in profile.apis.iter().copied().chain([profile.guard_name]) { + let mut matches = ro_data + .windows(name.len() + 1) + .enumerate() + .filter(|(_, b)| b[..name.len()] == *name && b[name.len()] == 0); + let (at, _) = matches + .next() + .with_context(|| format!("{} names missing; image is still protected or unsupported", profile.label))?; + ensure!(matches.next().is_none(), "ambiguous SDK name"); + targets.insert(ro.rva + at as u64, name); + } + for (at, _) in ro_data + .windows(profile.api_prefix.len()) + .enumerate() + .filter(|(_, b)| *b == profile.api_prefix) + { + let tail = &ro_data[at..]; + let end = tail + .iter() + .position(|b| *b == 0) + .context("unterminated SDK name")?; + ensure!( + profile.apis.contains(&&tail[..end]), + "unknown SDK API; refusing a partial patch" + ); + } + let pages: BTreeSet<_> = targets.keys().map(|v| v & !4095).collect(); + let mut references = BTreeMap::new(); + for section in elf.sections.iter().filter(|s| s.flags & 4 != 0 && s.kind != 8) { + ensure!( + section.rva % 4 == 0 && section.at % 4 == 0, + "unaligned ARM64 code section" + ); + let code = section_bytes(&elf, section)?; + for (i, word) in code.as_chunks::<4>().0.iter().enumerate() { + let ins = u32::from_le_bytes(*word); + if ins & 0x9f000000 != 0x90000000 { + continue; + } + let immediate = (((ins >> 5 & 0x7ffff) << 2) | (ins >> 29 & 3)) as i32; + let immediate = (immediate << 11) >> 11; + let pc = section.rva + i as u64 * 4; + let page = ((pc & !4095) as i64) + .checked_add(i64::from(immediate) << 12) + .context("ARM64 page address overflow")?; + if page < 0 || !pages.contains(&(page as u64)) { + continue; + } + for delta in (4..32).step_by(4) { + let at = i * 4 + delta; + if at + 4 > code.len() { + break; + } + let add = u32(code, at)?; + if add & 0xffc00000 != 0x91000000 || (add >> 5 & 31) != ins & 31 { + continue; + } + if let Some(&name) = targets.get(&(page as u64 + u64::from(add >> 10 & 4095))) + && let Some(previous) = references.insert(pc, (name, section.at + i * 4)) + { + ensure!(previous.0 == name, "ambiguous native SDK reference"); + } + } + } + } + let mut calls = BTreeMap::new(); + let mut resolvers = BTreeSet::new(); + let mut found = BTreeSet::new(); + for (&pc, &(name, raw)) in &references { + if name == profile.guard_name { + continue; + } + ensure!( + references + .range(pc.saturating_sub(79)..pc) + .any(|(_, (n, _))| *n == profile.guard_name), + "SDK call does not reference the guard library" + ); + let section = elf + .sections + .iter() + .find(|s| s.flags & 4 != 0 && s.kind != 8 && pc >= s.rva && pc < s.rva + s.size as u64) + .context("SDK call outside code")?; + let mut resolved = false; + for delta in (0..100).step_by(4) { + ensure!( + raw + delta + 4 <= section.at + section.size, + "truncated SDK resolution" + ); + let ins = u32(data, raw + delta)?; + if ins & 0xfc000000 != 0x94000000 { + continue; + } + let immediate = (((ins & 0x03ffffff) as i32) << 6) >> 6; + let address = pc + delta as u64; + let destination = (address as i64) + .checked_add(i64::from(immediate) * 4) + .context("ARM64 branch address overflow")?; + ensure!( + destination >= 0 && destination as u64 != helper_rva, + "invalid or already patched SDK resolver" + ); + ensure!( + elf.sections.iter().any(|s| s.flags & 4 != 0 + && s.kind != 8 + && destination as u64 >= s.rva + && (destination as u64) < s.rva + s.size as u64), + "SDK resolver outside executable sections" + ); + resolvers.insert(destination); + ensure!( + calls.insert(raw + delta, branch(address, helper_rva)?).is_none(), + "overlapping SDK calls" + ); + found.insert(name); + resolved = true; + break; + } + ensure!(resolved, "missing SDK resolver call"); + } + ensure!( + found.len() == profile.apis.len() && resolvers.len() == 1, + "incomplete or ambiguous SDK call graph" + ); + ensure!( + calls.keys().all(|at| *at + 4 <= helper || *at >= helper + 16), + "helper overlaps SDK calls" + ); + let mut output = data.to_vec(); + // Reuse only the removed initializer, preserving all other native resolutions. + for (i, ins) in [0x10000040u32, 0xd65f03c0, 0x52800000, 0xd65f03c0].iter().enumerate() { + output[helper + i * 4..helper + i * 4 + 4].copy_from_slice(&ins.to_le_bytes()); + } + for (&at, &ins) in &calls { + output[at..at + 4].copy_from_slice(&ins.to_le_bytes()); + } + Ok((output, calls.len())) +} diff --git a/src/patch/protection/profile.rs b/src/patch/protection/profile.rs new file mode 100644 index 0000000..298f35b --- /dev/null +++ b/src/patch/protection/profile.rs @@ -0,0 +1,136 @@ +//! The two concrete protection variants. AnoSDK (overseas) and Mobile ACE/TSS +//! (CN) share one algorithm; everything that differs between them is captured +//! here so the loader, native and DEX passes stay generic. + +pub struct Profile { + /// Human-readable name used in messages. + pub label: &'static str, + /// `(region, ChannelName, BundleIdentifier)` triples accepted by this variant. + pub regions: &'static [(&'static str, &'static str, &'static str)], + /// Full APK path of the decoder library whose permutation is read. + pub decoder_lib: &'static str, + /// Full APK paths of the two protection libraries dropped from the output. + pub removed_libs: [&'static str; 2], + /// Basenames of the two protection libraries that must be present. + pub protection_libs: [&'static str; 2], + /// Basenames of the other variant's libraries, which must be absent. + pub conflict_libs: [&'static str; 2], + + /// Exported decoder-array symbol followed through the SDK decoder. + pub decoder_symbol: &'static [u8], + /// Byte the Base64 wrapper key record is XORed with. + pub key_xor: u8, + /// `DT_NEEDED` name of the native wrapper dependency to remove. + pub needed_so: &'static [u8], + /// Undefined-symbol name prefixes that mark wrapper relocations. + pub wrapper_prefixes: &'static [&'static [u8]], + + /// Native SDK API names neutralized in `libil2cpp.so`. + pub apis: &'static [&'static [u8]], + /// Common prefix of [`Self::apis`], scanned for unknown APIs. + pub api_prefix: &'static [u8], + /// Guard library name referenced next to each SDK call. + pub guard_name: &'static [u8], + + /// `Application` subclass whose ``/`attachBaseContext` are edited. + pub app_class: &'static [u8], + /// SDK class whose `` is edited. + pub sdk_class: &'static [u8], + /// The two libraries loaded by the application ``. + pub app_libs: [&'static [u8]; 2], + /// The library loaded by the SDK class ``. + pub sdk_lib: &'static [u8], +} + +/// Overseas variant: SEA, GLB, JP, KR, TW. +pub const ANOSDK: Profile = Profile { + label: "AnoSDK", + regions: &[ + ("SEA", "os_android_sim", "com.miHoYo.bh3oversea"), + ("GLB", "global_android_sim", "com.miHoYo.bh3global"), + ("JP", "jp_android", "com.miHoYo.bh3rdJP"), + ("KR", "kr_android", "com.miHoYo.bh3korea"), + ("TW", "tw_mycard", "com.miHoYo.bh3twmycard"), + ], + decoder_lib: "lib/arm64-v8a/libanort.so", + removed_libs: ["lib/arm64-v8a/libanogs.so", "lib/arm64-v8a/libanort.so"], + protection_libs: ["libanogs.so", "libanort.so"], + conflict_libs: ["libtersafe2.so", "libtprt.so"], + + decoder_symbol: b"g_acf_array", + key_xor: 0xdf, + needed_so: b"libanort.so", + wrapper_prefixes: &[b"g_acf_", b"g_aco_", b"REL_HOOK_", b"unwind_xx_"], + + apis: &[ + b"AnoSDKInitEx", + b"AnoSDKSetUserInfo", + b"AnoSDKOnPause", + b"AnoSDKOnResume", + b"AnoSDKIoctl", + b"AnoSDKFree", + ], + api_prefix: b"AnoSDK", + guard_name: b"anogs", + + app_class: b"Lcom/ano/gshell/AnoApplication;", + sdk_class: b"Lcom/gamesafe/ano/AnoSdk;", + app_libs: [b"anort", b"anogs"], + sdk_lib: b"anogs", +}; + +/// CN variant: Mobile ACE / Tencent TSS. +pub const TSS: Profile = Profile { + label: "Mobile ACE/TSS", + regions: &[("CN", "gf_android", "com.miHoYo.enterprise.NGHSoD")], + decoder_lib: "lib/arm64-v8a/libtprt.so", + removed_libs: ["lib/arm64-v8a/libtersafe2.so", "lib/arm64-v8a/libtprt.so"], + protection_libs: ["libtersafe2.so", "libtprt.so"], + conflict_libs: ["libanogs.so", "libanort.so"], + + decoder_symbol: b"g_tprt_pfn_array", + key_xor: 0x49, + needed_so: b"libtprt.so", + wrapper_prefixes: &[b"g_tprt_", b"REL_HOOK_", b"unwind_xx_"], + + apis: &[ + b"tp2_free_anti_data", + b"tp2_sdk_init_ex", + b"tp2_sdk_ioctl", + b"tp2_setoptions", + b"tp2_setuserinfo", + ], + api_prefix: b"tp2_", + guard_name: b"tersafe2", + + app_class: b"Lcom/ace/gshell/AceApplication;", + sdk_class: b"Lcom/tencent/tp/TssSdk;", + app_libs: [b"tprt", b"tersafe2"], + sdk_lib: b"tersafe2", +}; + +#[cfg(test)] +mod tests { + use super::{ANOSDK, Profile, TSS}; + + fn check(p: &Profile) { + assert!(!p.regions.is_empty()); + assert!(p.apis.iter().all(|a| a.starts_with(p.api_prefix))); + assert!(p.decoder_lib.starts_with("lib/arm64-v8a/")); + for removed in p.removed_libs { + assert!(removed.starts_with("lib/arm64-v8a/")); + } + // The decoder and guard basenames are exactly the protection libraries. + assert!(p.removed_libs.iter().any(|r| r.ends_with(p.protection_libs[0]))); + assert!(p.removed_libs.iter().any(|r| r.ends_with(p.protection_libs[1]))); + } + + #[test] + fn profiles_are_consistent() { + check(&ANOSDK); + check(&TSS); + // The two variants must reject each other's libraries. + assert_eq!(ANOSDK.protection_libs, TSS.conflict_libs); + assert_eq!(TSS.protection_libs, ANOSDK.conflict_libs); + } +} diff --git a/src/patch/region.rs b/src/patch/region.rs new file mode 100644 index 0000000..c23bfbf --- /dev/null +++ b/src/patch/region.rs @@ -0,0 +1,73 @@ +//! Windows client region identity, read from the `ChannelConfig` TextAsset. +use anyhow::{Context, Result, ensure}; +use regex::bytes::Regex; + +pub struct Region { + pub name: &'static str, + pub channel: &'static str, + pub bundle: &'static str, + pub sdk: &'static str, + pub env: i64, +} + +pub const REGIONS: &[Region] = &[ + Region { + name: "CN", + channel: "gf_pc", + bundle: "com.miHoYo.bh3pc", + sdk: "bh3_cn", + env: 0, + }, + Region { + name: "GLB", + channel: "global_pc", + bundle: "com.miHoYo.bh3globalpc", + sdk: "bh3_usa", + env: 2, + }, + Region { + name: "JP", + channel: "jp_pc", + bundle: "com.miHoYo.bh3rdJPpc", + sdk: "bh3_jp", + env: 2, + }, + Region { + name: "KR", + channel: "kr_pc", + bundle: "com.miHoYo.bh3koreapc", + sdk: "bh3_kr", + env: 2, + }, + Region { + name: "TW", + channel: "tw_pc", + bundle: "com.miHoYo.bh3asiapc", + sdk: "bh3_asia", + env: 2, + }, + Region { + name: "SEA", + channel: "os_pc", + bundle: "com.miHoYo.bh3overseapc", + sdk: "bh3_os", + env: 2, + }, +]; + +fn field(script: &[u8], key: &str) -> Result { + let re = Regex::new(&format!(r#""{key}"\s*:\s*"([^"]+)""#))?; + let values: Vec<_> = re.captures_iter(script).collect(); + ensure!(values.len() == 1, "missing or duplicate {key}"); + Ok(std::str::from_utf8(values[0].get(1).unwrap().as_bytes())?.to_owned()) +} + +/// Identify the PC region from a `ChannelConfig` JSON body. +pub fn identify(config: &[u8]) -> Result<&'static Region> { + let channel = field(config, "ChannelName")?; + let bundle = field(config, "BundleIdentifier")?; + REGIONS + .iter() + .find(|r| r.channel == channel && r.bundle == bundle) + .context("unknown or conflicting regional client identity") +} diff --git a/src/patch/rsa/android.rs b/src/patch/rsa/android.rs new file mode 100644 index 0000000..614f4a3 --- /dev/null +++ b/src/patch/rsa/android.rs @@ -0,0 +1,268 @@ +//! Android passport RSA patch. For overseas clients, replace the three +//! environment keys of the passport SDK's `RSAUtils.getRSA_PUBLIC_KEY`; CN +//! clients are handled by [`cn_android`]. The source APK is never modified. +use super::{cn_android, public_body}; +use crate::{android::sign, format::dex}; +use anyhow::{Context, Result, ensure}; +use std::{ + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions}; + +const PASSPORT_RSA: &[u8] = + b"Lcom/mihoyo/platform/account/oversea/sdk/internal/shared/crypto/RSAUtils;"; +const KEY_METHOD: &[u8] = b"getRSA_PUBLIC_KEY"; + +pub fn patch(apk: &Path, public_key: &str) -> Result { + let body = public_body(public_key)?; + sign::signed_patch(apk, |input, output| repack(input, output, body)) +} + +fn dex_key(body: &str, first_line: usize) -> Vec { + let mut lines = vec![&body[..first_line]]; + lines.extend( + (first_line..body.len()) + .step_by(64) + .map(|at| &body[at..(at + 64).min(body.len())]), + ); + lines.join("\n").into_bytes() +} + +fn u16_at(data: &[u8], at: usize) -> Result { + Ok(u16::from_le_bytes( + data.get(at..at + 2).context("truncated DEX")?.try_into()?, + )) +} + +fn u32_at(data: &[u8], at: usize) -> Result { + Ok(u32::from_le_bytes( + data.get(at..at + 4).context("truncated DEX")?.try_into()?, + )) +} + +fn uleb(data: &[u8], at: &mut usize) -> Result { + let mut value = 0; + for shift in (0..35).step_by(7) { + let byte = *data.get(*at).context("truncated DEX ULEB128")?; + *at += 1; + value |= ((byte & 0x7f) as u32) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + anyhow::bail!("invalid DEX ULEB128") +} + +fn dex_string(data: &[u8], ids: usize, count: u32, index: u32) -> Result<(usize, &[u8])> { + ensure!(index < count, "invalid DEX string index"); + let mut at = u32_at(data, ids + index as usize * 4)? as usize; + let _utf16_length = uleb(data, &mut at)?; + let length = data + .get(at..) + .context("invalid DEX string offset")? + .iter() + .position(|&byte| byte == 0) + .context("unterminated DEX string")?; + Ok((at, &data[at..at + length])) +} + +// Follow the passport RSA method's DEX string references, not the unrelated NoticeDialog key. +fn passport_keys(data: &[u8]) -> Result> { + ensure!(data.starts_with(b"dex\n") && data.len() >= 112, "invalid DEX"); + let strings = u32_at(data, 0x38)?; + let string_ids = u32_at(data, 0x3c)? as usize; + let find_string = |needle: &[u8]| -> Result> { + for index in 0..strings { + if dex_string(data, string_ids, strings, index)?.1 == needle { + return Ok(Some(index)); + } + } + Ok(None) + }; + let Some(descriptor) = find_string(PASSPORT_RSA)? else { + return Ok(Vec::new()); + }; + let type_ids = u32_at(data, 0x44)? as usize; + let mut class_type = None; + for index in 0..u32_at(data, 0x40)? { + if u32_at(data, type_ids + index as usize * 4)? == descriptor { + class_type = Some(index); + break; + } + } + let class_type = class_type.context("missing passport RSA DEX type")?; + let class_defs = u32_at(data, 0x64)? as usize; + let mut class_data = None; + for index in 0..u32_at(data, 0x60)? { + let at = class_defs + index as usize * 32; + if u32_at(data, at)? == class_type { + class_data = Some(u32_at(data, at + 24)? as usize); + break; + } + } + let Some(mut class_data) = class_data else { + return Ok(Vec::new()); // This DEX only references the class; another DEX defines it. + }; + let method_name = find_string(KEY_METHOD)?.context("missing passport RSA key method")?; + let method_ids = u32_at(data, 0x5c)? as usize; + let mut method = None; + for index in 0..u32_at(data, 0x58)? { + let at = method_ids + index as usize * 8; + if u16_at(data, at)? as u32 == class_type && u32_at(data, at + 4)? == method_name { + method = Some(index); + break; + } + } + let method = method.context("missing passport RSA key method ID")?; + ensure!(class_data != 0, "missing passport RSA DEX class data"); + let static_fields = uleb(data, &mut class_data)?; + let instance_fields = uleb(data, &mut class_data)?; + let direct_methods = uleb(data, &mut class_data)?; + let virtual_methods = uleb(data, &mut class_data)?; + for _ in 0..static_fields + instance_fields { + uleb(data, &mut class_data)?; + uleb(data, &mut class_data)?; + } + let mut code = None; + for count in [direct_methods, virtual_methods] { + let mut method_index = 0u32; + for _ in 0..count { + method_index = method_index + .checked_add(uleb(data, &mut class_data)?) + .context("invalid DEX method index")?; + uleb(data, &mut class_data)?; + let offset = uleb(data, &mut class_data)? as usize; + if method_index == method { + code = Some(offset); + } + } + } + let code = code.context("missing passport RSA key method body")?; + let size = u32_at(data, code + 12)? as usize; + let instructions = data + .get(code + 16..code + 16 + size * 2) + .context("truncated passport RSA key method")?; + let mut offsets = Vec::new(); + for at in (0..instructions.len()).step_by(2) { + let index = match instructions[at] { + 0x1a if at + 4 <= instructions.len() => u16_at(instructions, at + 2)? as u32, + 0x1b if at + 6 <= instructions.len() => u32_at(instructions, at + 2)?, + _ => continue, + }; + if index >= strings { + continue; + } + let (start, value) = dex_string(data, string_ids, strings, index)?; + if !value.starts_with(b"MIIBIjAN") { + continue; + } + ensure!( + start >= 2 && data[start - 2..start] == [0x8e, 0x03] && value.len() == 398, + "passport RSA key is not a 398-character DEX string" + ); + let body = String::from_utf8(value.iter().filter(|&&b| b != b'\n').copied().collect())?; + ensure!( + value.iter().filter(|&&b| b == b'\n').count() == 6, + "unexpected passport RSA key wrapping" + ); + public_body(&body)?; + if !offsets.iter().any(|&(_, at)| at == start) { + offsets.push((index, start)); + } + } + ensure!( + offsets.len() == 3, + "expected three passport RSA keys, found {}", + offsets.len() + ); + Ok(offsets) +} + +fn repack(input: &Path, output: &Path, body: &str) -> Result<()> { + // DEX string_ids are strictly sorted: distinct line wraps keep one DER key in three slots. + let replacements = [63, 64, 65].map(|first| dex_key(body, first)); + let mut source = ZipArchive::new(File::open(input)?)?; + let mut writer = ZipWriter::new(File::options().write(true).create_new(true).open(output)?); + let mut count = 0; + let mut cn = false; + for index in 0..source.len() { + let mut entry = source.by_index(index)?; + let name = entry.name().to_owned(); + ensure!(entry.enclosed_name().is_some(), "unsafe APK entry: {name}"); + if crate::android::signature(&name) { + continue; + } + let dex = name.starts_with("classes") && name.ends_with(".dex"); + if !dex { + writer.raw_copy_file(entry)?; + continue; + } + ensure!(entry.size() <= 128 << 20, "DEX too large: {name}"); + let mut data = Vec::new(); + entry.read_to_end(&mut data)?; + if let Some(patched) = cn_android::patch(&data, body)? { + ensure!(!cn && count == 0, "mixed CN and overseas passport SDKs"); + cn = true; + writer.start_file( + name, + SimpleFileOptions::default() + .compression_method(entry.compression()) + .unix_permissions(entry.unix_mode().unwrap_or(0o644)), + )?; + writer.write_all(&patched)?; + continue; + } + let mut offsets = passport_keys(&data)?; + if offsets.is_empty() { + drop(entry); + writer.raw_copy_file(source.by_index(index)?)?; + continue; + } + offsets.sort_by_key(|&(index, _)| index); + let mut patched = data; + for ((_, at), replacement) in offsets.iter().zip(&replacements) { + patched[*at..*at + replacement.len()].copy_from_slice(replacement); + } + let string_count = u32_at(&patched, 0x38)?; + let string_ids = u32_at(&patched, 0x3c)? as usize; + for &(index, _) in &offsets { + let value = dex_string(&patched, string_ids, string_count, index)?.1; + if index > 0 { + ensure!( + dex_string(&patched, string_ids, string_count, index - 1)?.1 < value, + "DEX string order changed" + ); + } + if index + 1 < string_count { + ensure!( + value < dex_string(&patched, string_ids, string_count, index + 1)?.1, + "DEX string order changed" + ); + } + } + ensure!( + patched.starts_with(b"dex\n") + && patched.len() >= 112 + && u32::from_le_bytes(patched[32..36].try_into()?) as usize == patched.len(), + "invalid DEX header: {name}" + ); + dex::recompute_checksums(&mut patched)?; + count += offsets.len(); + writer.start_file( + name, + SimpleFileOptions::default() + .compression_method(entry.compression()) + .unix_permissions(entry.unix_mode().unwrap_or(0o644)), + )?; + writer.write_all(&patched)?; + } + ensure!( + cn || count == 3, + "expected three overseas passport RSA keys, found {count}" + ); + ensure!(!cn || count == 0, "mixed CN and overseas passport SDKs"); + writer.finish()?.sync_all()?; + Ok(()) +} diff --git a/src/patch/rsa/cn_android.rs b/src/patch/rsa/cn_android.rs new file mode 100644 index 0000000..1a225e7 --- /dev/null +++ b/src/patch/rsa/cn_android.rs @@ -0,0 +1,57 @@ +//! CN passport RSA key replacement in DEX, independent of the overseas SDK. +//! Grows the DEX string data via [`crate::format::dex`] and remaps references. +use crate::format::dex; +use anyhow::{Context, Result, ensure}; + +const CLASS: &[u8] = b"Lcom/mihoyo/platform/account/sdk/utils/RSAUtils;"; + +pub(super) fn patch(data: &[u8], body: &str) -> Result>> { + if !data.windows(CLASS.len()).any(|part| part == CLASS) { + return Ok(None); + } + let key = dex::find_string(data, |s| s.starts_with(b"MIGfMA0") && s.len() == 219)? + .context("missing CN passport RSA key")?; + ensure!( + dex::string(data, key + 1)? == b"MIHOYO_API_CLASS_NAME" + && dex::string(data, key + 2)? == b"MIHOYO_LOGIN_PLATFORM_TRANSACTION_INFO", + "unsupported CN RSA string order" + ); + let key_ref = dex::find_const_string(data, key)?; + let transaction_ref = dex::find_const_string(data, key + 2)?; + let field_ids = dex::word(data, 0x54)?; + let fields = (0..dex::word(data, 0x50)?) + .map(|i| field_ids + i * 8 + 4) + .filter(|&at| dex::word(data, at).ok() == Some(key + 1)) + .collect::>(); + ensure!(fields.len() == 1, "unexpected CN RSA neighbor field references"); + let (_, encoded_start) = dex::section(data, 0x2005)?; + let (_, encoded_end) = dex::section(data, 0x1003)?; + let index = u16::try_from(key)?.to_le_bytes(); + let encoded = (encoded_start..encoded_end - 2) + .filter(|&at| data[at..at + 3] == [0x37, index[0], index[1]]) + .collect::>(); + ensure!(encoded.len() == 2, "unexpected CN RSA static-value references"); + + let wrapped = body + .as_bytes() + .chunks(64) + .collect::>() + .join(&b"\n"[..]); + ensure!(wrapped.len() == 398, "invalid 2048-bit key wrapping"); + let mut out = data.to_vec(); + let added = dex::append_string(&mut out, &wrapped)?; + let ids = dex::word(&out, 0x3c)?; + dex::put(&mut out, ids + key * 4, dex::word(data, ids + (key + 1) * 4)?)?; + dex::put(&mut out, ids + (key + 1) * 4, dex::word(data, ids + (key + 2) * 4)?)?; + dex::put(&mut out, ids + (key + 2) * 4, added)?; + out[key_ref + 2..key_ref + 4].copy_from_slice(&u16::try_from(key + 2)?.to_le_bytes()); + out[transaction_ref + 2..transaction_ref + 4] + .copy_from_slice(&u16::try_from(key + 1)?.to_le_bytes()); + dex::put(&mut out, fields[0], key)?; + let delta = out.len() - data.len(); + for at in encoded { + out[at + delta + 1..at + delta + 3].copy_from_slice(&u16::try_from(key + 2)?.to_le_bytes()); + } + dex::recompute_checksums(&mut out)?; + Ok(Some(out)) +} diff --git a/src/patch/rsa/mod.rs b/src/patch/rsa/mod.rs new file mode 100644 index 0000000..33b89df --- /dev/null +++ b/src/patch/rsa/mod.rs @@ -0,0 +1,45 @@ +//! Passport RSA key replacement for Windows and Android clients. +mod android; +mod cn_android; +mod windows; + +use anyhow::{Result, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use rsa::{BigUint, RsaPublicKey, pkcs8::DecodePublicKey, traits::PublicKeyParts}; +use std::path::Path; + +pub const DEFAULT_BODY: &str = concat!( + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx0KB8Hu5xK41gdUpe6SMOaNgy1zencrxKQbvbhH4b56zN7/VANl2EhMqPG5EH8ayfaCpyMV282S6", + "T71B4I0yGs/kFhvLcfmxWAy++dAOwjBHBayK+YxAzNu2rXnHgJqPCrS5yXCJpnnnEGU+JgH1eK3Mt56Ezn6wUFPi/evbsrPEO9HtPY1cjb9z9g0vC7iEA4Yi", + "z6QxR5R8PPjHwVi3iXcnx4znS75AWLJUuGFzg7q/4C/3NqnCDnErDL+EarkQwAYyTi+pje4X3BTo6vfLRW2RpLxuDAfdcZf3i0nLJiXdoondScPbBzEiV73A", + "LOB78pOIhEI0fpBQH/RN/gxAMQIDAQAB", +); + +/// Validate and return the single-line Base64 DER body of a 2048-bit RSA +/// public key with exponent 65537; an empty input selects [`DEFAULT_BODY`]. +pub fn public_body(input: &str) -> Result<&str> { + let body = if input.trim().is_empty() { + DEFAULT_BODY + } else { + input.trim() + }; + ensure!( + body.len() == DEFAULT_BODY.len(), + "expected a 2048-bit public key body" + ); + let der = STANDARD.decode(body)?; + let key = RsaPublicKey::from_public_key_der(&der)?; + ensure!( + key.n().bits() == 2048 && key.e() == &BigUint::from(65537u32), + "expected a 2048-bit RSA public key with exponent 65537" + ); + Ok(body) +} + +pub fn patch_windows(exe: &Path, public_key: &str) -> Result<()> { + windows::patch(exe, public_key) +} + +pub fn patch_android(apk: &Path, public_key: &str) -> Result { + android::patch(apk, public_key) +} diff --git a/src/patch/rsa/windows.rs b/src/patch/rsa/windows.rs new file mode 100644 index 0000000..41020fa --- /dev/null +++ b/src/patch/rsa/windows.rs @@ -0,0 +1,325 @@ +//! Windows passport RSA patch: add a `.hi3rsa` PE section holding the server +//! modulus and a wrapper, and redirect the two password-login encryption calls +//! in `BH3_Data/Native/UserAssembly.dll` through it. +use super::public_body; +use crate::{ + format::{bytes, pe::Pe, unity}, + patch::region, +}; +use anyhow::{Context, Result, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use rsa::{RsaPublicKey, pkcs8::DecodePublicKey, traits::PublicKeyParts}; +use std::{fs, path::Path}; + +const NAME: &[u8; 8] = b".hi3rsa\0"; +const MAGIC: &[u8; 8] = b"HI3RSA01"; +const HEADER: usize = 24; +const CRYPTO: &[u8] = &[ + 0x41, 0x57, 0x41, 0x56, 0x41, 0x55, 0x41, 0x54, 0x56, 0x57, 0x55, 0x53, 0x48, 0x83, 0xec, 0x78, + 0x49, 0x89, 0xd5, 0x48, 0x89, 0xce, 0x80, 0x3d, +]; + +fn u32_at(data: &[u8], at: usize) -> Result { + bytes::u32(data, at) +} +fn put(data: &mut [u8], at: usize, value: u32) { + data[at..at + 4].copy_from_slice(&value.to_le_bytes()); +} +fn align(value: u32, alignment: u32) -> Result { + ensure!(alignment.is_power_of_two(), "invalid PE alignment"); + Ok(value.checked_add(alignment - 1).context("PE size overflow")? & !(alignment - 1)) +} + +/// Read a PE with the extra structural guarantees this patch relies on. +fn read_pe(data: &[u8]) -> Result> { + let pe = Pe::read(data)?; + ensure!(pe.opt_size >= 152, "invalid PE optional header"); + ensure!((1..=32).contains(&pe.sections.len()), "invalid PE section count"); + ensure!(u32_at(data, pe.opt + 108)? >= 5, "missing PE data directories"); + Ok(pe) +} + +fn relative(from: u32, to: u32) -> Result { + i32::try_from(i64::from(to) - i64::from(from) - 5).context("branch outside rel32 range") +} +fn call_target(data: &[u8], pe: &Pe, rva: u32) -> Result { + let at = pe.offset(rva, 5)?; + ensure!(data[at] == 0xe8, "expected login call instruction"); + u32::try_from(i64::from(rva) + 5 + i64::from(u32_at(data, at + 1)? as i32)) + .context("invalid call target") +} + +// Only the account/password calls use this wrapper, not the shared RSA routine's other callers. +fn payload(rva: u32, calls: [u32; 2], crypto: u32, modulus: &[u8]) -> Result> { + ensure!(modulus.len() == 344, "expected a 2048-bit RSA modulus"); + let mut out = MAGIC.to_vec(); + for value in [calls[0], calls[1], crypto, 0] { + out.extend(value.to_le_bytes()); + } + out.extend([0x50, 0x51, 0x56, 0x57]); // preserve rax/rcx/rsi/rdi; rdx is untouched + let mut guards = Vec::new(); + out.extend([0x48, 0x85, 0xc9, 0x0f, 0x84]); + guards.push(out.len()); + out.extend([0; 4]); + out.extend([0x81, 0x79, 0x10]); + out.extend(505u32.to_le_bytes()); + out.extend([0x0f, 0x85]); + guards.push(out.len()); + out.extend([0; 4]); + // Check the observed XML layout before touching the managed string. + for (offset, expected) in [ + (0xa8u32, 0x004d003cu32), + (0xacu32, 0x0064006fu32), + (0x3b6u32, 0x002f003cu32), + ] { + out.extend([0x81, 0xb9]); + out.extend(offset.to_le_bytes()); + out.extend(expected.to_le_bytes()); + out.extend([0x0f, 0x85]); + guards.push(out.len()); + out.extend([0; 4]); + } + out.extend([0x48, 0x8d, 0xb9]); + out.extend(0xe2u32.to_le_bytes()); + out.extend([0x48, 0x8d, 0x35]); + let source = out.len(); + out.extend([0; 4]); + out.push(0xb9); + out.extend(344u32.to_le_bytes()); + out.extend([0xac, 0x88, 0x07, 0x48, 0x83, 0xc7, 0x02, 0xe2, 0xf7]); + let tail = out.len(); + out.extend([0x5f, 0x5e, 0x59, 0x58, 0xe9]); + let jump = out.len(); + out.extend([0; 4]); + for guard in guards { + put(&mut out, guard, (tail as i32 - guard as i32 - 4) as u32); + } + let key_at = out.len(); + put(&mut out, source, (key_at - source - 4) as u32); + put(&mut out, jump, relative(rva + jump as u32 - 1, crypto)? as u32); + out.extend(modulus); + Ok(out) +} + +fn patch_image(data: &[u8], modulus: &[u8]) -> Result> { + let pe = read_pe(data)?; + if let Some(s) = pe + .sections + .iter() + .find(|s| data.get(s.header..s.header + 8) == Some(NAME)) + { + ensure!( + data.get(s.raw..s.raw + 8) == Some(MAGIC), + "unknown existing RSA section" + ); + let calls = [u32_at(data, s.raw + 8)?, u32_at(data, s.raw + 12)?]; + let crypto = u32_at(data, s.raw + 16)?; + let code_at = pe.offset(crypto, CRYPTO.len())?; + ensure!( + &data[code_at..code_at + CRYPTO.len()] == CRYPTO, + "RSA routine changed after patching" + ); + for call in calls { + ensure!( + call_target(data, &pe, call)? == s.rva + HEADER as u32, + "RSA login call changed after patching" + ); + } + ensure!( + s.virtual_size as usize >= HEADER + 344 && s.virtual_size as usize <= s.raw_size, + "invalid RSA section size" + ); + let old_key = &data[s.raw + s.virtual_size as usize - 344..s.raw + s.virtual_size as usize]; + let expected = payload(s.rva, calls, crypto, old_key)?; + ensure!( + expected.len() == s.virtual_size as usize + && data[s.raw..s.raw + expected.len()] == expected, + "RSA patch payload changed" + ); + let replacement = payload(s.rva, calls, crypto, modulus)?; + let mut out = data.to_vec(); + out[s.raw..s.raw + replacement.len()].copy_from_slice(&replacement); + return Ok(out); + } + let code = pe + .sections + .iter() + .find(|s| data.get(s.header..s.header + 8) == Some(b"il2cpp\0\0")) + .context("missing il2cpp code section")?; + ensure!( + u32_at(data, code.header + 36)? & 0x20000000 != 0, + "il2cpp section is not executable" + ); + let bytes = &data[code.raw..code.raw + code.raw_size]; + let routines: Vec<_> = bytes + .windows(CRYPTO.len()) + .enumerate() + .filter_map(|(i, b)| (b == CRYPTO).then_some(code.rva + i as u32)) + .collect(); + ensure!( + routines.len() == 1, + "unknown or ambiguous XML RSA routine ({})", + routines.len() + ); + let crypto = routines[0]; + let mut pairs = Vec::new(); + for (i, b) in bytes.windows(123).enumerate() { + if b[..7] == [0x4c, 0x89, 0xf1, 0x48, 0x89, 0xfa, 0xe8] + && b[11..14] == [0x49, 0x89, 0xc6] + && b[109..116] == [0x4c, 0x89, 0xf9, 0x48, 0x89, 0xda, 0xe8] + && b[120..123] == [0x48, 0x89, 0xc3] + { + let calls = [code.rva + i as u32 + 6, code.rva + i as u32 + 115]; + if calls + .iter() + .all(|&call| call_target(data, &pe, call).ok() == Some(crypto)) + { + pairs.push(calls); + } + } + } + ensure!( + pairs.len() == 1, + "unknown or ambiguous passport login calls ({})", + pairs.len() + ); + let calls = pairs[0]; + let new_header = pe.table + pe.sections.len() * 40; + let first_raw = pe + .sections + .iter() + .filter(|s| s.raw_size != 0) + .map(|s| s.raw) + .min() + .context("empty PE")?; + ensure!( + new_header + 40 <= first_raw + && data + .get(new_header..new_header + 40) + .is_some_and(|b| b.iter().all(|&x| x == 0)), + "no room for RSA section header" + ); + let section_align = u32_at(data, pe.opt + 32)?; + let file_align = u32_at(data, pe.opt + 36)?; + let last_rva = pe + .sections + .iter() + .map(|s| { + s.rva + .checked_add(s.virtual_size.max(s.raw_size as u32)) + .context("PE size overflow") + }) + .collect::>>()? + .into_iter() + .max() + .context("empty PE")?; + let rva = align(last_rva, section_align)?; + let raw = pe + .sections + .iter() + .map(|s| s.raw + s.raw_size) + .max() + .context("empty PE")?; + ensure!( + align(u32::try_from(raw)?, file_align)? as usize == raw, + "unaligned PE section end" + ); + let cert = u32_at(data, pe.opt + 144)? as usize; + let cert_size = u32_at(data, pe.opt + 148)? as usize; + let known_prefix = cert.checked_sub(raw) == Some(80) + && data.get(raw..raw + 4) == Some(b"jk98") + && u32_at(data, raw + 8)? == 0x89ab_cdef + && u32_at(data, raw + 12)? == 64; + ensure!( + raw == data.len() && cert_size == 0 + || (cert == raw || known_prefix) && cert.checked_add(cert_size) == Some(data.len()), + "unknown PE overlay; refusing to move it" + ); + ensure!( + u32_at(data, pe.nt + 12)? == 0, + "PE symbol table is unsupported" + ); + let body = payload(rva, calls, crypto, modulus)?; + let raw_size = align(body.len() as u32, file_align)? as usize; + let insertion = if cert_size == 0 { raw } else { cert }; + let section_raw = align(u32::try_from(insertion)?, file_align)? as usize; + let section_end = section_raw.checked_add(raw_size).context("PE size overflow")?; + let mut out = Vec::with_capacity(data.len() + section_end - insertion); + out.extend_from_slice(&data[..insertion]); + out.resize(section_raw, 0); + out.extend_from_slice(&body); + out.resize(section_end, 0); + out.extend_from_slice(&data[insertion..]); + out[new_header..new_header + 8].copy_from_slice(NAME); + for (offset, value) in [ + (8, body.len() as u32), + (12, rva), + (16, raw_size as u32), + (20, section_raw as u32), + (36, 0x60000020), + ] { + put(&mut out, new_header + offset, value); + } + out[pe.nt + 6..pe.nt + 8].copy_from_slice(&((pe.sections.len() + 1) as u16).to_le_bytes()); + put( + &mut out, + pe.opt + 4, + u32_at(data, pe.opt + 4)? + .checked_add(raw_size as u32) + .context("code size overflow")?, + ); + put( + &mut out, + pe.opt + 56, + align( + rva.checked_add(body.len() as u32).context("image size overflow")?, + section_align, + )?, + ); + put(&mut out, pe.opt + 64, 0); + if cert_size != 0 { + put(&mut out, pe.opt + 144, u32::try_from(section_end)?); + } + for call in calls { + put( + &mut out, + pe.offset(call, 5)? + 1, + relative(call, rva + HEADER as u32)? as u32, + ); + } + Ok(out) +} + +pub fn patch(exe: &Path, public_key: &str) -> Result<()> { + ensure!( + exe.is_file() + && exe + .file_name() + .is_some_and(|n| n.eq_ignore_ascii_case("BH3.exe")), + "select an existing BH3.exe" + ); + let data_dir = exe + .parent() + .context("missing client directory")? + .join("BH3_Data"); + let shared = fs::read(data_dir.join("sharedassets0.assets"))?; + region::identify(&shared[unity::script(&shared, "ChannelConfig")?])?; + let key = RsaPublicKey::from_public_key_der(&STANDARD.decode(public_body(public_key)?)?)?; + let modulus = STANDARD.encode(key.n().to_bytes_be()); + let dll = data_dir.join("Native/UserAssembly.dll"); + let original = fs::read(&dll).with_context(|| format!("missing {}", dll.display()))?; + let patched = patch_image(&original, modulus.as_bytes())?; + println!("[OK] Client and key checked"); + ensure!( + patch_image(&patched, modulus.as_bytes())? == patched, + "RSA patch verification failed" + ); + println!("[OK] Patch verified"); + if patched == original { + println!("[OK] Already patched; no files changed"); + return Ok(()); + } + crate::util::replace::verified(&dll, &original, &patched)?; + println!("[OK] Client updated"); + Ok(()) +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..3328512 --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,2 @@ +//! Small cross-cutting utilities. +pub mod replace; diff --git a/src/util/replace.rs b/src/util/replace.rs new file mode 100644 index 0000000..ec379bb --- /dev/null +++ b/src/util/replace.rs @@ -0,0 +1,39 @@ +//! Atomic, verified in-place file replacement. +use anyhow::{Context, Result, ensure}; +use std::{fs, io::Write, path::Path}; + +/// Replace `path` with `replacement`, having confirmed it currently holds +/// `original`. The new bytes are written to a sibling temporary file, flushed +/// and read back, the target is re-checked for concurrent modification, then +/// the temporary file is persisted over it and the result verified. +pub fn verified(path: &Path, original: &[u8], replacement: &[u8]) -> Result<()> { + let mut temporary = + tempfile::NamedTempFile::new_in(path.parent().context("missing parent directory")?)?; + temporary.write_all(replacement)?; + temporary.as_file().sync_all()?; + ensure!( + fs::read(temporary.path())? == replacement, + "temporary file verification failed" + ); + ensure!( + fs::read(path)? == original, + "target changed during patching: {}", + path.display() + ); + fs::set_permissions(temporary.path(), fs::metadata(path)?.permissions())?; + temporary + .persist(path) + .map_err(|e| e.error) + .with_context(|| { + format!( + "could not replace {}; close the game and launcher", + path.display() + ) + })?; + ensure!( + fs::read(path)? == replacement, + "target verification failed; restore a clean backup: {}", + path.display() + ); + Ok(()) +}