mirror of
https://git.ely.my.id/Tenshi/Hi3-Patcher
synced 2026-11-04 13:36:51 +01:00
Initial Commit
This commit is contained in:
commit
737780597b
51 files changed
+8675
No files matched your search
@@ -0,0 +1,8 @@
|
||||
/target/
|
||||
/dist/
|
||||
/tools/
|
||||
/android-build-tools/
|
||||
/java/
|
||||
*.apk
|
||||
*.keystore
|
||||
*.bak
|
||||
Generated
+1287
File diff suppressed because it is too large.
Load diff
+39
@@ -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"] }
|
||||
@@ -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 `<name>_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 <command> [options]
|
||||
|
||||
dispatch --platform <windows|android> --input <path> --server <host[:port]> [--https]
|
||||
rsa --platform <windows|android> --input <path> [--key <base64-der>]
|
||||
ace --input <BH3.exe|folder> [--yes]
|
||||
hoyok --input <BH3.exe|folder>
|
||||
protection --input <app.apk> [--variant <ano|tss|auto>]
|
||||
|
||||
--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 |
|
||||
+37
@@ -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`.
|
||||
@@ -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
|
||||
`<name>_patched.apk`.
|
||||
|
||||
## cli/
|
||||
|
||||
`interactive` is the terminal menu (no arguments). `args` parses
|
||||
`<command> --flag value` (or `--flag=value`) and validates the whole
|
||||
command line before running the patch, then reports via exit code and
|
||||
optional JSON.
|
||||
@@ -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
|
||||
`"<origin>/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.
|
||||
@@ -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.
|
||||
@@ -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.<clinit>` | 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 `<clinit>` | 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.
|
||||
+51
@@ -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 `<Modulus>` 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.
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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 `<name>_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<PathBuf> {
|
||||
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)
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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::<Vec<_>>();
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<u32> {
|
||||
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(())
|
||||
}
|
||||
+329
@@ -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 <command> [options]
|
||||
|
||||
Commands:
|
||||
dispatch Redirect dispatch & SDK URLs to a server
|
||||
--platform <windows|android> --input <path>
|
||||
--server <host[:port]> [--https]
|
||||
rsa Replace the passport RSA public key
|
||||
--platform <windows|android> --input <path> [--key <base64>]
|
||||
ace Remove ACE from a Windows client (<= 8.9); requires a capture
|
||||
--input <BH3.exe|folder> [--yes]
|
||||
hoyok Disable HoYoKProtect in a Windows client (9.0+)
|
||||
--input <BH3.exe|folder>
|
||||
protection Remove Android protection and re-sign the APK
|
||||
--input <app.apk> [--variant <ano|tss|auto>]
|
||||
|
||||
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<String, String>,
|
||||
switches: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<Parsed, String> {
|
||||
let (options, flags) =
|
||||
accepted(command).ok_or_else(|| format!("unknown command: {command}"))?;
|
||||
let mut values = HashMap::new();
|
||||
let mut switches: Vec<String> = 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<Platform, String> {
|
||||
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<PathBuf, String> {
|
||||
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<Job, String> {
|
||||
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<Option<PathBuf>> {
|
||||
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<Option<PathBuf>>, 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<Job, String> {
|
||||
let argv: Vec<String> = 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<char> {
|
||||
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<char> {
|
||||
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<String> {
|
||||
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<Option<PathBuf>> {
|
||||
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<String> {
|
||||
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<Option<PathBuf>> {
|
||||
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<bool> {
|
||||
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...")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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<const N: usize>(data: &[u8], at: usize) -> Result<[u8; N]> {
|
||||
Ok(range(data, at, N)?.try_into().unwrap())
|
||||
}
|
||||
|
||||
pub fn u16(data: &[u8], at: usize) -> Result<u16> {
|
||||
Ok(u16::from_le_bytes(array(data, at)?))
|
||||
}
|
||||
|
||||
pub fn u32(data: &[u8], at: usize) -> Result<u32> {
|
||||
Ok(u32::from_le_bytes(array(data, at)?))
|
||||
}
|
||||
|
||||
pub fn u64(data: &[u8], at: usize) -> Result<u64> {
|
||||
Ok(u64::from_le_bytes(array(data, at)?))
|
||||
}
|
||||
|
||||
pub fn be32(data: &[u8], at: usize) -> Result<u32> {
|
||||
Ok(u32::from_be_bytes(array(data, at)?))
|
||||
}
|
||||
|
||||
pub fn be64(data: &[u8], at: usize) -> Result<u64> {
|
||||
Ok(u64::from_be_bytes(array(data, at)?))
|
||||
}
|
||||
|
||||
/// `u32` read widened to `usize`.
|
||||
pub fn u32z(data: &[u8], at: usize) -> Result<usize> {
|
||||
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<usize> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<usize> {
|
||||
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<Option<usize>> {
|
||||
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<usize> {
|
||||
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<u8>, value: &[u8]) -> Result<usize> {
|
||||
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::<Result<usize>>()?;
|
||||
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)
|
||||
}
|
||||
@@ -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> {
|
||||
usize::try_from(value).context("ELF offset overflow")
|
||||
}
|
||||
|
||||
fn u16z(data: &[u8], at: usize) -> Result<usize> {
|
||||
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<Section<'a>>,
|
||||
pub segments: Vec<Segment>,
|
||||
}
|
||||
|
||||
impl<'a> Elf<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Result<Self> {
|
||||
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<usize> {
|
||||
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<Vec<Symbol<'a>>> {
|
||||
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<Vec<usize>> {
|
||||
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<Vec<usize>> {
|
||||
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<usize> {
|
||||
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};
|
||||
@@ -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;
|
||||
@@ -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<Section>,
|
||||
}
|
||||
|
||||
impl<'a> Pe<'a> {
|
||||
pub fn read(data: &'a [u8]) -> Result<Self> {
|
||||
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<usize> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<u32> {
|
||||
Ok(u32::from_be_bytes(self.bytes(4)?.try_into()?))
|
||||
}
|
||||
fn be64(&mut self) -> Result<u64> {
|
||||
Ok(u64::from_be_bytes(self.bytes(8)?.try_into()?))
|
||||
}
|
||||
fn le32(&mut self) -> Result<u32> {
|
||||
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<usize>,
|
||||
}
|
||||
|
||||
pub fn text_assets(data: &[u8]) -> Result<Vec<TextAsset>> {
|
||||
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<Range<usize>> {
|
||||
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<Vec<u8>> {
|
||||
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<usize>,
|
||||
logical: Range<usize>,
|
||||
flags: u16,
|
||||
}
|
||||
pub struct Bundle {
|
||||
header: Vec<u8>,
|
||||
info: Vec<u8>,
|
||||
size_offset: usize,
|
||||
info_offset: usize,
|
||||
flags: u32,
|
||||
blocks: Vec<Block>,
|
||||
pub logical: Vec<u8>,
|
||||
}
|
||||
|
||||
fn decode(data: &[u8], size: usize, flags: u32) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Self> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<Range<usize>>> {
|
||||
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<Vec<u8>> {
|
||||
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<u8>> = 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)
|
||||
}
|
||||
}
|
||||
+12
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
hi3_patcher::cli::run()
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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<Snapshot> {
|
||||
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<String> {
|
||||
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<Self> {
|
||||
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<Process> {
|
||||
#[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<Runtime> {
|
||||
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."
|
||||
);
|
||||
}
|
||||
@@ -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<Layout> {
|
||||
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<Vec<u8>> {
|
||||
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)
|
||||
}
|
||||
@@ -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<usize> {
|
||||
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<Vec<u8>> {
|
||||
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)
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<bool>) -> 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<Vec<u8>> {
|
||||
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()
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<Vec<u8>> {
|
||||
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::<MODULEENTRY32W>() 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<Vec<(u64, Vec<u8>)>> {
|
||||
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::<MEMORY_BASIC_INFORMATION>(),
|
||||
)
|
||||
};
|
||||
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<Self> {
|
||||
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<Vec<u8>> {
|
||||
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<String> {
|
||||
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<Vec<(u64, Vec<u8>)>> {
|
||||
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;
|
||||
@@ -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<u8>,
|
||||
pub allocations: Vec<(u64, Vec<u8>)>,
|
||||
pub assets: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(super) struct Runtime {
|
||||
pub assets: Vec<Vec<u8>>,
|
||||
pub blob: Vec<u8>,
|
||||
pub code_size: u32,
|
||||
pub tables: [(u32, u32); 2],
|
||||
}
|
||||
|
||||
fn pointer(data: &[u8], at: usize) -> Result<u64> {
|
||||
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<Vec<usize>> {
|
||||
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<Runtime> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<u8>, 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<u8>)> = vec![];
|
||||
for asset in &assets {
|
||||
let original = &data[asset.script.clone()];
|
||||
// Length-preserving attempt: returns None if anything would overflow.
|
||||
let fit = (|| -> Result<Option<(Vec<u8>, 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<u8>, 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<u8>)> = 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<String, BTreeMap<usize, String>> = 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<String, Vec<u8>> = 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<PathBuf> {
|
||||
sign::signed_patch(input, |input, output| repack(input, output, target))
|
||||
}
|
||||
@@ -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<Option<Vec<u8>>> {
|
||||
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))
|
||||
}
|
||||
@@ -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<Regex> =
|
||||
LazyLock::new(|| Regex::new(r#"(?s-u)"DispatchUrls"\s*:\s*\[(.*?)\]"#).unwrap());
|
||||
static QUOTED_URL: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r#"(?i-u)"(https?://[^"\r\n]+)""#).unwrap());
|
||||
static URL: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r#"(?i-u)(?:https?|wss?)://[^\s"'<>\\\x00]+"#).unwrap());
|
||||
static BARE: LazyLock<Regex> = 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<Self> {
|
||||
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>, 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<Option<(Vec<u8>, 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<u8>, 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<Vec<std::ops::Range<usize>>> {
|
||||
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<usize>, Vec<u8>)>, usize)> {
|
||||
let _: Vec<Value> = 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<Option<(Vec<u8>, 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<u8>, 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<Value> = 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<u8>, 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::<std::net::IpAddr>().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<u8>, usize)> {
|
||||
let mut edits: Vec<(std::ops::Range<usize>, Vec<u8>)> = vec![];
|
||||
let mut occupied: Vec<std::ops::Range<usize>> = 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::<std::net::IpAddr>().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());
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
pub patched: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn prepare(exe: &Path, target: &Target) -> Result<Vec<Update>> {
|
||||
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::Value> = 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<Update>) -> 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)
|
||||
}
|
||||
@@ -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<Regex> {
|
||||
// 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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Protection startup edits in DEX: NOP the native library loads in the
|
||||
//! application and SDK `<clinit>` 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<usize> {
|
||||
Ok(u16::from_le_bytes(self.bytes(at, 2)?.try_into()?) as usize)
|
||||
}
|
||||
fn u32(&self, at: usize) -> Result<usize> {
|
||||
Ok(u32::from_le_bytes(self.bytes(at, 4)?.try_into()?) as usize)
|
||||
}
|
||||
fn table(&self, header: usize, width: usize) -> Result<Range<usize>> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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(<library>)` sequence and return its
|
||||
/// instruction width (`const-string` is 10 bytes, `const-string/jumbo` 12).
|
||||
fn load_library(&self, at: usize, library: &[u8]) -> Result<usize> {
|
||||
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<u8>, 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<usize>, Vec<u8>)> = 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"<clinit>" && !(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"<clinit>" {
|
||||
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()))
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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<i64> {
|
||||
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<u64>) -> Result<usize> {
|
||||
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<u64>) -> Result<usize> {
|
||||
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<u8>, 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<u64> {
|
||||
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::<Result<_>>()?;
|
||||
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))
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
patch(apk, &profile::ANOSDK)
|
||||
}
|
||||
|
||||
/// Patch an APK as Mobile ACE/TSS (CN) clients.
|
||||
pub fn mobile_ace_tss(apk: &Path) -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
patch(apk, detect(apk)?)
|
||||
}
|
||||
|
||||
fn patch(apk: &Path, profile: &Profile) -> Result<PathBuf> {
|
||||
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<Vec<u8>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<u32> {
|
||||
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<u8>, 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()))
|
||||
}
|
||||
@@ -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 `<clinit>`/`attachBaseContext` are edited.
|
||||
pub app_class: &'static [u8],
|
||||
/// SDK class whose `<clinit>` is edited.
|
||||
pub sdk_class: &'static [u8],
|
||||
/// The two libraries loaded by the application `<clinit>`.
|
||||
pub app_libs: [&'static [u8]; 2],
|
||||
/// The library loaded by the SDK class `<clinit>`.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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")
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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<u8> {
|
||||
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<u16> {
|
||||
Ok(u16::from_le_bytes(
|
||||
data.get(at..at + 2).context("truncated DEX")?.try_into()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn u32_at(data: &[u8], at: usize) -> Result<u32> {
|
||||
Ok(u32::from_le_bytes(
|
||||
data.get(at..at + 4).context("truncated DEX")?.try_into()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn uleb(data: &[u8], at: &mut usize) -> Result<u32> {
|
||||
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<Vec<(u32, usize)>> {
|
||||
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<Option<u32>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<Option<Vec<u8>>> {
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
ensure!(encoded.len() == 2, "unexpected CN RSA static-value references");
|
||||
|
||||
let wrapped = body
|
||||
.as_bytes()
|
||||
.chunks(64)
|
||||
.collect::<Vec<_>>()
|
||||
.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))
|
||||
}
|
||||
@@ -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<std::path::PathBuf> {
|
||||
android::patch(apk, public_key)
|
||||
}
|
||||
@@ -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<u32> {
|
||||
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<u32> {
|
||||
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<Pe<'_>> {
|
||||
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> {
|
||||
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<u32> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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::<Result<Vec<_>>>()?
|
||||
.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(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Small cross-cutting utilities.
|
||||
pub mod replace;
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user