Initial Commit

This commit is contained in:
Tenshi committed 2026-10-31 23:35:41 +01:00
commit 737780597b
51 files changed
+8675

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
/target/
/dist/
/tools/
/android-build-tools/
/java/
*.apk
*.keystore
*.bak
Generated
+1287
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -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"] }
+137
View File
@@ -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
View File
@@ -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`.
+56
View File
@@ -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.
+70
View File
@@ -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.
+28
View File
@@ -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.
+81
View File
@@ -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
View File
@@ -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.
+11
View File
@@ -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)))
}
+97
View File
@@ -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)
}
+339
View File
@@ -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
View File
@@ -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:?}");
}
}
}
+435
View File
@@ -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...")?;
}
}
}
}
}
+20
View File
@@ -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)
}
}
+104
View File
@@ -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());
}
}
+216
View File
@@ -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)
}
+212
View File
@@ -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};
+7
View File
@@ -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;
+142
View File
@@ -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));
}
}
+531
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
hi3_patcher::cli::run()
}
Binary file not shown.
+70
View File
@@ -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
]
}
}
+268
View File
@@ -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."
);
}
+180
View File
@@ -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)
}
+191
View File
@@ -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)
}
+97
View File
@@ -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()
}
+215
View File
@@ -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;
+190
View File
@@ -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,
})
}
+310
View File
@@ -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))
}
+39
View File
@@ -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))
}
+396
View File
@@ -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());
}
}
+171
View File
@@ -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)
}
+130
View File
@@ -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))
}
+39
View File
@@ -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(())
}
+9
View File
@@ -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;
+237
View File
@@ -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()))
}
+511
View File
@@ -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))
}
+215
View File
@@ -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(())
}
+208
View File
@@ -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()))
}
+136
View File
@@ -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);
}
}
+73
View File
@@ -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")
}
+268
View File
@@ -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(())
}
+57
View File
@@ -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))
}
+45
View File
@@ -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)
}
+325
View File
@@ -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(())
}
+2
View File
@@ -0,0 +1,2 @@
//! Small cross-cutting utilities.
pub mod replace;
+39
View File
@@ -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(())
}