mirror of
https://codeberg.org/reverse-skill-lab/apk-reverse
synced 2026-11-15 19:44:20 +08:00
Absorb game-reserve's Unity/Lua and AssetBundle reverse engineering
Adds references/unity-lua-and-assetbundles.md: Unity + Lua containers, the Lua 5.1 header-byte tamper, the loader's bytecode-only check, the CRC manifest the game actually reads, UnityFS partial-encryption windows, rebuild traps (layout loss, dependency null, compressed == raw), TextAsset read-back, and half-encrypted Mono assemblies behind a publisher shell. Extends framework-runtimes with LayaAir Conch (DCC checksum and loader patch), recon with resource completeness and the encryption map, repack-and-sign with package rename, split-apk with the narrow apktool merge, dex-patching with call-deletion instead of method rewrite, and packers with PairIP as a target-selection signal. Adds pitfalls P51-P53 and two symptom rows. Registration: one reference row, two capability rows (both partial/unverified), two evidence-summary rows, two route-inventory rows, two coverage bullets, one known-limitation bullet, and EXTENSION-game-reserve-2026-11.md. Gates and pytest green.
This commit is contained in:
@@ -38,6 +38,10 @@ tools/
|
||||
# resting on "nobody has added them yet".
|
||||
.ref/
|
||||
|
||||
# A public skill clone read by the 2026-11 game-reserve absorption pass. Same rule: the method
|
||||
# and the anonymised record ship, the upstream tree does not.
|
||||
game-reserve/
|
||||
|
||||
# sandbox installs made while testing `npx skills add .` against this repo
|
||||
.claude/
|
||||
.agents/
|
||||
|
||||
@@ -200,6 +200,13 @@ references/ loaded on demand, one topic each
|
||||
dnSpy-Unity-mono patch that makes a retail Mono player accept a
|
||||
debugger, a run-time class lookup over frida-il2cpp-bridge, and
|
||||
the audit probes when the deliverable is a report
|
||||
unity-lua-and-assetbundles.md
|
||||
the Unity that is neither plain Mono nor IL2CPP: Lua logic in a
|
||||
bundle (xLua/tolua/SLua), the Lua 5.1 header-byte tamper, bundle
|
||||
CRC in the manifest the game actually reads, partial-encryption
|
||||
windows, UnityFS rebuild traps (layout loss, stripped dependency
|
||||
null, compressed == raw), TextAsset read-back, and half-encrypted
|
||||
Mono assemblies behind a publisher shell
|
||||
il2cpp-protected-metadata.md
|
||||
when that pair is not readable: absent, 0-byte, encrypted or
|
||||
embedded metadata, the consumer-side dump, version-independent
|
||||
|
||||
@@ -120,6 +120,12 @@ references/ 按需加载,每个文件一个主题
|
||||
用 Il2CppDumper 导出这一对文件、按能成功的顺序把表导入 IDA、
|
||||
让零售 Mono 播放器接受调试器的 dnSpy-Unity-mono 补丁、
|
||||
基于 frida-il2cpp-bridge 的运行时类查找,以及交付物是报告时的审计探针
|
||||
unity-lua-and-assetbundles.md
|
||||
既不是纯 Mono 也不是 IL2CPP 的 Unity:逻辑在 Lua bundle 里
|
||||
(xLua/tolua/SLua)、Lua 5.1 头字节篡改、游戏真正读取的 manifest
|
||||
CRC、局部加密窗口、UnityFS 重建陷阱(布局丢失、依赖被剥后返回
|
||||
null、压缩大小等于原始大小)、TextAsset 读回,以及发行方壳后面的
|
||||
半加密 Mono 程序集
|
||||
il2cpp-protected-metadata.md
|
||||
当这一对不可读时:缺失、0 字节、加密或内嵌的 metadata、
|
||||
从消费端 dump、不信任版本号的 header 定长、XOR/VM/XXTEA
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# EXTENSION — absorbing the reverse-engineering half of `game-reserve` (2026-11-09)
|
||||
|
||||
A public game-preservation skill was cloned at the repository root (`game-reserve/`). It is
|
||||
mostly a workflow for turning a dead server into an offline build. That workflow is **not** what this
|
||||
pass took. This pass read the case reports and the pitfalls for the reverse-engineering mechanisms
|
||||
they contain and landed only those: how the Unity/Lua script layer is found and repaired, how
|
||||
AssetBundles are checked and rebuilt, how a half-encrypted Mono assembly is read, how LayaAir's Conch
|
||||
cache is recomputed, and what recon should measure before a route is chosen.
|
||||
|
||||
## Sources
|
||||
|
||||
- `game-reserve/` (the clone at the repository root) — its recon reference (logic-layer table,
|
||||
encryption map, CRC log, resource-completeness check), four anonymised case reports (Unity + Lua,
|
||||
LayaAir + JS, Unity Mono + SLua, native Kotlin), and its patch/pitfalls notes (UnityFS rebuild
|
||||
traps, TextAsset read-back, hot-update merge, R8 minSdk branches, `os.date` race).
|
||||
|
||||
All are public secondary sources. Their case reports are `observed` **by their authors**; nothing in
|
||||
this repository executed them. Identifiers, package names, hosts, keys, offsets and the source's own
|
||||
scripts were deliberately not copied.
|
||||
|
||||
## What was added
|
||||
|
||||
One new reference file, because the topic had no owner:
|
||||
|
||||
| File | Covers | Why no existing file owned it |
|
||||
|---|---|---|
|
||||
| `references/unity-lua-and-assetbundles.md` | Unity + Lua (xLua/tolua/SLua) container discovery, script decryption and Lua 5.1 header repair, loader bytecode-only check, AssetBundle CRC source, UnityFS partial-encryption windows, bundle rebuild traps (layout loss, stripped dependency null, compressed == raw), added/replaced TextAsset read-back, half-encrypted Mono assemblies and the publisher shell | `unity-runtime.md` owns Mono/IL2CPP and `il2cpp-protected-metadata.md` owns protected metadata; neither owns a Lua layer or UnityFS container editing. `packers.md` owns generic shell identification, not the Mono-assembly decryptor shape |
|
||||
|
||||
Existing topic files were extended, not duplicated:
|
||||
|
||||
- `references/framework-runtimes.md` — a **LayaAir native (Conch)** section: identification, the
|
||||
path-addressed cache, the DCC filetable checksum, metadata regeneration from shipped content, the
|
||||
loader patch and the startup-delay race, plus the Laya render-loop freeze and poisoned-XHR shapes.
|
||||
- `references/recon.md` — Unity + Lua and LayaAir rows in the code-location table, and a
|
||||
**§1c** for resource completeness and the encryption map (manifest diff, player backup, entropy is
|
||||
not encryption, fixed prefixes, config-table field check, separate "unreadable" from "not in hand").
|
||||
- `references/repack-and-sign.md` — **renaming the package** across binary AXML, the `resources.arsc`
|
||||
package block and the launcher label, with the validation that both builds coexist.
|
||||
- `references/split-apk.md` — the narrow manual resource merge through `apktool` that the source's
|
||||
native case used, with the condition that makes it safe and the collision that stops it.
|
||||
- `references/dex-patching.md` — deleting specific calls from an original smali method instead of
|
||||
hand-rewriting a lifecycle method, removing the argument-preparation instructions, and putting new
|
||||
logic in an extra dex behind one `invoke-static`.
|
||||
- `references/packers.md` — PairIP as a target-selection signal (Play licensing plus partial code
|
||||
virtualization): prefer an older/regional build without it rather than fighting a re-signed suicide.
|
||||
- `references/pitfalls.md` — **P51** (R8 deleted the low-API branch when `minSdk` was lowered),
|
||||
**P52** (a decompiled Lua file does not match its name because the writer's `read()` returns the
|
||||
template/original bytes) and **P53** (a retained AssetBundle returns null after its dependency was
|
||||
stripped).
|
||||
- `SKILL.md` and `references/routing.md` — two symptom rows: a Lua decompiler rejecting every
|
||||
script with `non-standard lua format`, and a re-signed Unity/Mono build exiting cleanly with code
|
||||
4 two seconds in.
|
||||
|
||||
## Deliberately not absorbed
|
||||
|
||||
- **The offline-revival workflow itself**: feasibility verdicts, fake local servers, virtual-clock
|
||||
testing, player-order playtest, save-preserving release, media/OCR recovery, distribution and the
|
||||
unattended-run playbook. It is a legitimate body of work, but it is a product workflow rather than
|
||||
an APK-reverse mechanism, and the first pass that imported it was corrected for exactly that.
|
||||
- **Target-specific constants**: package names, hosts, keys, offsets, counts, paths and the source's
|
||||
scripts.
|
||||
- **The source's local-server implementation**: `RPCWaitSend` handling, entity-property bags and the
|
||||
rest belong to a game-revival project, not to this skill's protocol-reverse files.
|
||||
- **The media/OCR and night-shift stacks**: no script surface is imported that this pass cannot test.
|
||||
|
||||
## Registration surface updated
|
||||
|
||||
- `references/routing.md`: one reference-index row; two symptom rows mirrored from `SKILL.md`.
|
||||
- `README.md` and `README.zh-CN.md`: one entry in the structure block.
|
||||
- `evidence/capability-matrix.json`: `unity-lua-and-assetbundle-recovery` and
|
||||
`layaair-conch-dcc-recovery`, both `partial` / `route_measured: false` / `unverified`.
|
||||
- `references/evidence-summary.md`: the same two rows.
|
||||
- `references/coverage/route-inventory.md` and `coverage-and-limits.md`: two documented-route rows
|
||||
and two bullets.
|
||||
- `evidence/known-limitations.md`: one "never exercised" bullet.
|
||||
- `.gitignore`: the upstream clone is ignored, so a later `git add -A` cannot commit it.
|
||||
|
||||
## Verification
|
||||
|
||||
None of the absorbed reverse-engineering mechanisms is `observed` in this repository. No Unity or
|
||||
LayaAir package was decrypted, no bundle was rebuilt, no Mono assembly was reconstructed, and no
|
||||
device build was installed. The checks performed here are structural: the new file and every changed
|
||||
section resolve, the symptom mirror agrees, and the registration surface names the new capability.
|
||||
|
||||
## What would change a label
|
||||
|
||||
- **Unity + Lua**: decrypt one anonymous Unity+Lua package, decompile a script, patch one anchor,
|
||||
rebuild the bundle, install it and show the changed behaviour after a cold start, with a control
|
||||
build. That moves the Lua half toward `observed`.
|
||||
- **UnityFS**: rebuild one bundle with the same layout and show it loads; then reproduce the
|
||||
compressed-size-equals-raw crash in a control and fix it in one field. The single-field experiment is
|
||||
the cheapest.
|
||||
- **Mono half-encryption**: reconstruct one PE header from the documented landmarks and have a
|
||||
decompiler open it; the shell-recovery half additionally needs the original process.
|
||||
- **LayaAir DCC**: recompute the filetable for a package and show the engine accepts the regenerated
|
||||
metadata offline. The checksum algorithm is arithmetic and can be host-tested on synthetic files;
|
||||
the full route still needs a package.
|
||||
|
||||
## Gate status
|
||||
|
||||
Run after the change:
|
||||
|
||||
```text
|
||||
python check_repo.py && python check_refs.py && python check_routing.py \
|
||||
&& python check_commands.py && python check_budget.py && python -m pytest -q
|
||||
```
|
||||
|
||||
Result on 2026-11-09: `check_repo` 0 problems; `check_refs` 489 reachable references, 0 dangling,
|
||||
0 warnings; `check_routing` coherent (72 symptom rows, 63 reference rows, 64 script rows);
|
||||
`check_commands` 196 checked, 0 drift; `check_budget` 448 lines / ~12,987 tokens, 0 failures,
|
||||
0 warnings; `pytest` 796 passed, 209 skipped, 25 xfailed. A green run proves the inventory resolves
|
||||
and the mirrors agree; it says nothing about whether a decrypted script or a rebuilt bundle works,
|
||||
which is what the labels above are for.
|
||||
@@ -76,8 +76,8 @@ those are called out in the matrix rather than buried here.
|
||||
## Absorption-pass record
|
||||
|
||||
A later pass absorbed mechanics from external skill repositories (`reverse-skill`,
|
||||
`awesome-game-security`, `android-reverse-engineering-claude-skill`, `MobileRE-Skill`), keeping each
|
||||
project's own discipline: only the phase, the checklist and the command pattern cross over, with the
|
||||
`awesome-game-security`, `android-reverse-engineering-claude-skill`, `MobileRE-Skill`, `game-reserve`),
|
||||
keeping each project's own discipline: only the phase, the checklist and the command pattern cross over, with the
|
||||
source URL and the access date recorded, and nothing vendored as a runtime dependency. These files
|
||||
live here rather than in the skill because two of them corrected a claim the repository had already
|
||||
written down.
|
||||
@@ -89,6 +89,7 @@ written down.
|
||||
| `EXTENSION-detection-pipeline.md` | Naming the check that fires: three arms on a public MASTG target (control alive, probe arm dead in ≈300 ms via `strstr("frida")`, with the `TracerPid=0` versus 4-frida-mappings asymmetry), `svc_scan.py`'s two decoders agreeing on an identical 214-site set, the layered Java-to-svc descent, and the ptrace-free dump boundary on targets whose dex is deflated inside the APK |
|
||||
| `EXTENSION-dexsql.md` | The SQL query layer adopted as a **third indexer** beside rasc/droidasc/ddc: identical class-definition sets on four archives (zero differences either way), the string xref agreement and the `=` versus `LIKE` match-semantics trap, `decode` against `ddc disasm` line for line, and the persistent cache measured as **not** a speed switch |
|
||||
| `EXTENSION-ref-absorption-2026-10.md` | Sixteen public write-ups in four groups (Unity/IL2CPP metadata protection, linker-level SO packing, VM protector idioms, Pyarmor bytecode), the four reference files they became, the registration surface that changed, and the one cheap experiment that would move each row off `unverified` |
|
||||
| `EXTENSION-game-reserve-2026-11.md` | The reverse-engineering half of a public game-preservation skill: Unity + Lua script extraction and the Lua 5.1 header tamper, AssetBundle CRC and UnityFS rebuild traps, half-encrypted Mono assemblies, LayaAir Conch DCC checksums, plus recon/repack/split/dex/packer additions — all absorbed as `unverified`, since no package was rebuilt here |
|
||||
|
||||
## Hardware-layer pass record
|
||||
|
||||
|
||||
@@ -252,6 +252,8 @@ unread in this repository.
|
||||
| A `dlopen`-able `.so` will not load after a custom loader, or a repaired `soinfo` resolves symbols to garbage | `linker-and-soinfo-repair.md` — bias versus base, `seg_page_offset`, the defining library's bias, `APS2` packed relocs, the minimal `soinfo` field set, and the dump window after `LoadSegments` |
|
||||
| A native trace has no `cmp`/`sub`/`jcc` — only `and/or/not/shr/neg`, a flags slot, and a `ret` that lands at a VM entry | `vm-protector-analysis.md` — virtual eflags is data, NAND lowering, branchless select, data-anchored breakpoints, and trace-then-search-by-constant; dispatcher slicing stays in `native-vm-recovery.md` |
|
||||
| The logic you need is Python bytecode — a `.pyc`/PyInstaller/Pyarmor payload rather than Java, Dart or native | `python-bytecode-recovery.md` — key schedule, AES-CTR nonce rules, the 12-byte VM, version-gated `pyc` parsing and the extra-section TLV; static and offline, then call the recovered module |
|
||||
| A Lua decompiler rejects every script with **`non-standard lua format: <n>`** | `unity-lua-and-assetbundles.md` §2 — a changed Lua 5.1 header byte, not encryption |
|
||||
| A re-signed Unity/Mono build exits cleanly with code 4 about two seconds in, with no Java exception | `unity-lua-and-assetbundles.md` §5 — a publisher shell owns the process |
|
||||
|
||||
## Gates — clear these before you patch, in order
|
||||
|
||||
|
||||
@@ -1078,6 +1078,39 @@
|
||||
"docs/tool-verification/fixtures/verify_ref_claims_10c.py and EXTENSION-ref-absorption-2026-10c.md section Verification pass (repository root, not shipped)"
|
||||
],
|
||||
"limits": "The measured half is host-side and synthetic: an early-exit byte compare costs k+1 comparisons for a k-byte correct prefix over k=0..8 while an MD5-then-compare costs a constant 16, so the closed and open cases are separable without a target. Nothing was run against a real serial, licence or activation gate. Reading the comparator's counter requires a hook on the comparison itself; wall-clock timing on a device is not the measured form and is expected to be dominated by scheduler and JIT noise. A remote check is a rate-limited network experiment and a value the server also holds is not releaseable client-side, so the route stops at local gates."
|
||||
},
|
||||
{
|
||||
"capability": "unity-lua-and-assetbundle-recovery",
|
||||
"group": "unpacking",
|
||||
"status": "partial",
|
||||
"route_measured": false,
|
||||
"strength": "unverified",
|
||||
"benchmark_rows": [],
|
||||
"evidence": [
|
||||
"references/unity-lua-and-assetbundles.md",
|
||||
"references/unity-runtime.md",
|
||||
"references/repack-and-sign.md"
|
||||
],
|
||||
"evidence_not_shipped": [
|
||||
"game-reserve/ (repository root, untracked, not shipped): anonymised Unity + Lua and Unity Mono + SLua case reports; absorbed 2026-11-09"
|
||||
],
|
||||
"limits": "Absorbed only: locating a Lua container on top of Unity, header-byte repair, loader bytecode-only check, bundle CRC source, UnityFS partial-encryption windowing, UnityFS rebuild traps (layout loss, dependency null, compressed==raw), added/replaced TextAsset read-back, and half-encrypted Mono assemblies behind a publisher shell. No Unity package was decrypted, no bundle rebuilt and no assembly reconstructed here; every route is unverified. It assumes the Lua bytecode opcodes are not shuffled and that the bundle's container format is one UnityPy can parse."
|
||||
},
|
||||
{
|
||||
"capability": "layaair-conch-dcc-recovery",
|
||||
"group": "runtime",
|
||||
"status": "partial",
|
||||
"route_measured": false,
|
||||
"strength": "unverified",
|
||||
"benchmark_rows": [],
|
||||
"evidence": [
|
||||
"references/framework-runtimes.md",
|
||||
"references/recon.md"
|
||||
],
|
||||
"evidence_not_shipped": [
|
||||
"game-reserve/ (repository root, untracked, not shipped): anonymised LayaAir native + JS case report and DCC notes; absorbed 2026-11-09"
|
||||
],
|
||||
"limits": "Absorbed only: Conch identification, the path-addressed cache layout, the DCC filetable checksum (8-byte little-endian word XOR plus tail), regenerating the four metadata files from shipped content, and the loader patch plus its startup-delay race. No LayaAir package was reversed here, and the checksum was reverse-engineered by the source against its own package, not measured in this repository."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -115,7 +115,12 @@ built on them has nothing to check itself against.
|
||||
- **No real OLLVM trace and no trace pipeline verified on a device.** Exclusion keeps a target alive
|
||||
but does not restore event delivery, and a zero-event trace is a boundary to identify rather than a
|
||||
recipe to follow.
|
||||
|
||||
- **The Unity + Lua / AssetBundle and LayaAir Conch routes were never exercised here.** The Lua
|
||||
header repair, bundle CRC selection, UnityFS partial-encryption windowing, bundle rebuild traps,
|
||||
TextAsset read-back, half-encrypted Mono assemblies and the LayaAir DCC checksum are absorbed from
|
||||
two public case reports; no Unity or LayaAir package was decrypted, rebuilt or installed in this
|
||||
repository. Treat every step in `references/unity-lua-and-assetbundles.md` and
|
||||
`references/framework-runtimes.md` §LayaAir native (Conch) specifics as `unverified`.
|
||||
## Conclusions that changed — an older copy may still state the old one
|
||||
|
||||
Each of these was a claim this skill used to make and no longer does. If you are reading a copy or a
|
||||
|
||||
@@ -220,7 +220,17 @@ its own strength note at the top.
|
||||
(`kernel-event-injection.md`, `scripts/kernel_instrument_scaffold.py`).
|
||||
- **Working from the phone itself** — MT Manager, its APK MCP surface, LSPosed Manager, on-device
|
||||
data inspection (`on-device-tooling.md`, `scripts/mt_mcp_probe.py`).
|
||||
|
||||
- **Unity + Lua and AssetBundle recovery** — finding a Lua container on top of Unity, repairing the
|
||||
Lua 5.1 header byte, checking the loader's bytecode-only flag, reading the CRC manifest the game
|
||||
actually reads, bounding a partial-encryption window, rebuilding a UnityFS bundle without losing
|
||||
its layout or dependency edges, verifying added/replaced TextAsset read-back, and reading
|
||||
half-encrypted Mono assemblies behind a publisher shell. Absorbed from two anonymised case reports;
|
||||
no package was decrypted or bundle rebuilt here, so every step is `unverified`
|
||||
(`unity-lua-and-assetbundles.md`).
|
||||
- **LayaAir native (Conch) DCC recovery** — Conch identification, the path-addressed cache, the
|
||||
filetable checksum, regenerating metadata from shipped content, and the loader patch with its
|
||||
startup-delay race. Absorbed from one case report; no LayaAir package was reversed here
|
||||
(`framework-runtimes.md` §LayaAir native (Conch) specifics).
|
||||
## Dependencies this skill does not ship — name them before the workflow starts
|
||||
|
||||
- **JS AST rewriting needs node + babel** (`@babel/parser`/`traverse`/`generator`) — the detector
|
||||
|
||||
@@ -61,6 +61,8 @@ route was not measured on this repository's evidence**. Treat them as leads with
|
||||
| Attaching a real debugger to a `.so` | IDA `dbgsrv` with the ABI match, wait-for-debugger plus the JDWP handshake, and the `extractNativeLibs="false"` failure where no breakpoint binds — `ida-so-debugging.md` |
|
||||
| Kernel-side event injection | watchpoints and register rewrites from a driver, as gated templates — `kernel-event-injection.md` |
|
||||
| Working from the phone itself | MT Manager edit/repack/sign and its APK MCP, LSPosed Manager, Termux — `on-device-tooling.md` |
|
||||
| Unity + Lua scripts and AssetBundle rebuilds | find/decrypt the Lua container, repair a Lua 5.1 header, locate the live CRC manifest, bound a partial-encryption window, rebuild a UnityFS bundle without losing layout/dependencies, verify TextAsset read-back, and recover half-encrypted Mono assemblies behind a publisher shell — absorbed from two case reports; nothing was run here — `unity-lua-and-assetbundles.md` |
|
||||
| LayaAir native (Conch) DCC recovery | Conch identification, path-addressed cache, DCC filetable checksum and metadata regeneration, and the loader patch with its startup-delay race — absorbed; no LayaAir package was reversed here — `framework-runtimes.md` §LayaAir native (Conch) specifics |
|
||||
| Publishing without publishing the target | the leak scanner, the do-not-anonymize list, and the graded precedent library — `desensitization-and-leak-scans.md`, `scripts/scan_leaks.py`, `precedents/` |
|
||||
| Native dump window and syscall reader | dump at `android_dlopen_ext` entry when self-decryption and watchdog startup share `JNI_OnLoad`; read via raw syscalls; zero-fill unreadable pages so offsets survive repair — absorbed, nothing run here — `linker-and-soinfo-repair.md` |
|
||||
| White-box crypto devirtualization | fold a deterministic VM transform into a straight-line `(op, args)` program plus constant table, output map and template, pinned to one build — absorbed, no extract produced — `native-vm-recovery.md` |
|
||||
|
||||
@@ -180,6 +180,32 @@ Patch spec format for `patch_smali.py`:
|
||||
]
|
||||
```
|
||||
|
||||
### Deleting calls instead of rewriting the method
|
||||
|
||||
For a large lifecycle method (`onCreate`, an Application initialiser, a main-screen controller), do
|
||||
not hand-write the whole body in smali. The source's native case tried that and produced two failure
|
||||
classes: `VerifyError` from a `invoke-static/range` whose argument registers were placed at the
|
||||
wrong end of the range, and a missing repository initialiser that surfaced later as
|
||||
`lateinit property ... has not been initialized`.
|
||||
|
||||
The safer shape:
|
||||
|
||||
1. **Keep the original method body.** Remove `.line` directives only for the purpose of matching.
|
||||
2. **Delete the specific calls** (network, analytics, push, billing, account) with multi-line anchors,
|
||||
and assert each anchor matches exactly once.
|
||||
3. **Delete the instructions that existed only to prepare that call** — the `move-object` and
|
||||
`check-cast` pairs that feed its arguments. Leaving them is harmless only if their registers stay
|
||||
live; removing the call and leaving a half-built argument list is how verification fails.
|
||||
4. **Put new logic in a Java class** compiled with `javac` + `d8` into an extra `classesN.dex`;
|
||||
the smali edit becomes one `invoke-static` line. The new class is testable on the host and does not
|
||||
disturb register allocation in the original method.
|
||||
5. **Find the real input point, not every consumer.** In the source's case the dead profile supplied
|
||||
"current character" and "language"; changing those two getters to read local settings made the
|
||||
whole default-model and greeting chain work without touching the model loader.
|
||||
|
||||
Verify with `dex_classdiff`, install, cold-start, and exercise the exact entry that used the deleted
|
||||
call. A build that verifies can still have removed an initialiser that only one screen needs.
|
||||
|
||||
## Finding the call site
|
||||
|
||||
Reverse-lookup from a smali tree or a set of disassembled dex:
|
||||
|
||||
@@ -86,6 +86,8 @@ next** — and points at the one section that says "the full record is elsewhere
|
||||
| Native dump window and syscall reader | Absorbed operating rules: dump at `android_dlopen_ext` entry because the window closes inside `JNI_OnLoad`, a raw-syscall reader outside a self-hooked libc, and zero-filled holes so repair keeps offsets. Zero-fill offset arithmetic host-checked on a synthetic image; the window itself never exercised here | partial | unverified | `references/linker-and-soinfo-repair.md` §6.2 |
|
||||
| White-box crypto devirtualization to a program | Absorbed route: symbolic input, constant folding, a straight-line `(op, args)` program plus its constant table, output map and template; version-bound, deterministic functions only. No extract was produced here | partial | unverified | `references/native-vm-recovery.md` §11 |
|
||||
| Cipher classification by differential probe | Method: header length, determinism, one-byte diffusion width and the length law separate CTR, CBC and ECB (the earlier prefix-XOR-only claim was falsified and is corrected); involution wrappers cancel; Java/Python encoding and compression drift decide the last mile. Probes and algebra host-checked on AES-128 and synthetic inputs; no target body classified | partial | inferred | `references/protocol-reverse.md` §7 |
|
||||
| Unity + Lua scripts and AssetBundle rebuilds | Absorbed: Locating a Lua container on top of Unity, the Lua 5.1 header-byte tamper, loader bytecode-only check, the CRC manifest the game actually reads, UnityFS partial-encryption windows, rebuild traps (layout loss, dependency null, compressed == raw), TextAsset read-back, and half-encrypted Mono assemblies behind a publisher shell. No package was decrypted or bundle rebuilt here | partial | unverified | `references/unity-lua-and-assetbundles.md` |
|
||||
| LayaAir native (Conch) DCC recovery | Absorbed: Conch identification, the path-addressed cache, the DCC filetable checksum, regenerating metadata from shipped content, the loader patch, and the startup-delay race. No LayaAir package was reversed here | partial | unverified | `references/framework-runtimes.md` §LayaAir native (Conch) specifics |
|
||||
|
||||
## Where the full record lives — repository root, not shipped
|
||||
|
||||
|
||||
@@ -93,6 +93,49 @@ workflow, and how to patch this layer safely.
|
||||
- **The metadata can be encrypted while the `.so` is not, or the reverse.** Check each file's magic
|
||||
before concluding the dump failed; the two failures have different routes.
|
||||
|
||||
## LayaAir native (Conch) specifics
|
||||
|
||||
LayaAir's native shell is a recognisable shape: `lib/<abi>/liblayaair.so` plus `libv8*.so`, a
|
||||
`assets/scripts/` directory (`index.js`, `apploader.js`, `config.js`), an `assets/config.ini`,
|
||||
and a cache tree `assets/cache/https.__<host>_<path>/`. If those are present, the game logic is
|
||||
JavaScript and the engine is Conch, not a WebView.
|
||||
|
||||
- **Cache file names are content-addressed by path, not by content:** each relative path (leading `/`)
|
||||
is named `'%08x' % crc32(path)`. The same directory holds `allfiles.txt`, `filetable.txt`,
|
||||
`filetable1.txt`, `filetable.bin` and `assetsid.txt`.
|
||||
- **The game's logic is usually one plaintext compressed file:** `js/bundle.js`. Before reversing it,
|
||||
fetch `js/bundle.js.map` from the same CDN path if it exists — in the source's project it contained
|
||||
470 TypeScript source files, which is the source, not a reconstruction.
|
||||
- **DCC checksums are recomputed by the engine, not trusted from the table.** `filetable.txt` is a
|
||||
`ffeeffee 1` header, the assets id split over two lines, then one `fileid chksum` line per file.
|
||||
The checksum is XOR over 8-byte blocks of little-endian `uint32` words, with the remaining 0–7
|
||||
bytes XORed individually. The source matched 8,940 of 8,943 files. The engine recomputes it from the
|
||||
file's content; a mismatch makes it re-download, so offline it fails with
|
||||
`JsAppCache::loadCachedURL Error, no cache data` or `Load start url error`.
|
||||
- **Do not copy the CDN's file table.** The source found its values inconsistent with the file contents
|
||||
(same content, different checksum). Recompute every checksum from the bytes you are shipping and
|
||||
regenerate all four metadata files, then `bundle.js` can be edited freely.
|
||||
- **The loader is plain JS and lives outside the cache** (`index.js` under `assets/scripts/`). It calls
|
||||
`updateDcc()`; offline that fails and, if a cached id exists, the app exits with
|
||||
`onLayaInitError`. Patch the loader to skip the update but **re-create the side effect it had**:
|
||||
`cache.setResourceID('appurl', urlpath)`. Then keep a delay of roughly three seconds before
|
||||
starting: in the source, the network update had been hiding a startup race, and an instant start
|
||||
crashed in `JCScriptRuntime::dispatchLayaGLBuffer` on the GL thread (x86 and arm), with or without
|
||||
any offline JS module. Prove it with a control build that contains only the loader patch.
|
||||
- **`loadLib()` is asynchronous.** A hook installed before `loadLib('js/bundle.js')` returns sees no
|
||||
`Laya` object; poll until the engine exists, then install the hook.
|
||||
- **A local module is loaded by `require("offline_server")`**, which Conch resolves from
|
||||
`assets/scripts/`, the same way the game's own modules load.
|
||||
|
||||
**Failure shape to remember:** one uncaught exception in a Laya render callback freezes the game,
|
||||
because the main loop is `requestAnimationFrame(loop)` and an exception stops the next frame. The
|
||||
symptom is a frozen or black screen while the debug channel still answers; the check is
|
||||
`Laya.timer.currFrame` not increasing. Wrapping `Laya.stage._loop` in try/catch is a diagnostic
|
||||
guard, not a fix for the underlying null asset. Mocked XHR responses can also poison a pooled
|
||||
`HttpRequest` instance — clear the own properties you defined in `open()` or every later request
|
||||
on that instance fails instantly. These are engine-side behaviours from the source's case, not
|
||||
measured here.
|
||||
|
||||
## Locating logic without symbols
|
||||
|
||||
- **Strings first.** User-visible text (dialog bodies, feature labels) is the cheapest anchor in any
|
||||
|
||||
@@ -88,6 +88,11 @@ rounds) re-deriving what a cleaner experiment would have shown in one run.
|
||||
changes nothing about the shell.
|
||||
- **Equal-length renames** keep binary XML chunk structure valid, so no manifest re-encoding is needed:
|
||||
replace the name bytes in `AndroidManifest.xml` in place, size unchanged.
|
||||
- **A build with Google PairIP is usually the one to leave alone.** Look for `com.pairip` classes and
|
||||
a `LicenseClient` before committing: it combines Play licensing with partial code virtualization and
|
||||
terminates a re-signed process. If the app ships several variants, an older or regional build without
|
||||
PairIP is a better target than an unwinnable one — that is target selection, not defeat. The source's
|
||||
native case chose the older regional split build and accepted fewer cosmetics.
|
||||
|
||||
## Native libraries as a place to live
|
||||
|
||||
|
||||
@@ -1521,3 +1521,78 @@ the wrapper in the report, because it is the reason the algorithm looked unfamil
|
||||
the differential probes that identify the primitive underneath, is `protocol-reverse.md` §7; the
|
||||
mask identity itself is host-checked there (`observed`: zero counterexamples over 65,536 eight-bit
|
||||
pairs and 200,000 random 16-bit pairs).
|
||||
|
||||
## P51. The low-API branch you need was removed by R8
|
||||
|
||||
**Symptom**
|
||||
A build with `minSdkVersion 29` is changed to 28 and installed on Android 9. It starts, then dies on
|
||||
`No virtual method rebase()`, `registerActivityLifecycleCallbacks`, or `WindowInsets$Builder`; or it
|
||||
runs but lifecycle events never arrive, so coroutines inside `lifecycleScope` never execute.
|
||||
|
||||
**Root cause**
|
||||
R8 evaluated `if (SDK_INT >= 29)` as true for the declared `minSdk` and deleted the else branch. The
|
||||
`$ApiNNImpl` fallbacks below 29, the old `WindowInsetsCompat` implementation, and the pre-29 path in
|
||||
`ReportFragment.dispatch` (which became an empty function) are no longer in the dex.
|
||||
|
||||
**Why it is hard to see**
|
||||
The APK installs and starts; only the path that needs the deleted branch fails. The stack names a
|
||||
framework or AndroidX class, so the first hypothesis is an SDK/framework mismatch, not "our own build
|
||||
deleted this code". The empty lifecycle method is silent: nothing throws, the observable is "the app
|
||||
does nothing".
|
||||
|
||||
**The rule**
|
||||
**Lowering `minSdk` is a code change, not a manifest edit.** Audit every `$ApiNNImpl` with N at or
|
||||
above the original minSdk, the lifecycle dispatch, and `WindowInsetsCompat`; add a version guard that
|
||||
returns the default on old devices and restore the old implementation. Test on the oldest API you
|
||||
claim to support. Prefer a newer emulator over lowering the floor; if you must lower it, treat every
|
||||
`SDK_INT` comparison as a branch you may have to re-create.
|
||||
|
||||
## P52. A decompiled Lua file does not match its filename
|
||||
|
||||
**Symptom**
|
||||
A batch decompile produces valid Lua, but many files in different directories contain the same
|
||||
unrelated script (the source found 112). The running game is fine, so the "source" is the only thing
|
||||
that is wrong. A later variant: a script you replaced is not the script the build patched; constants
|
||||
from the hot update disappear, and a runtime stack contains a function name that does not exist
|
||||
anywhere in the decompiled source.
|
||||
|
||||
**Root cause**
|
||||
The bundle writer cloned a template `TextAsset` and wrote new bytes with `save_typetree`; the
|
||||
library's `read()` kept parsing the template object's original bytes. The same cache applies after a
|
||||
hot-update merge: `set_raw`/replacement writes the new script, but `read()` still returns the packaged
|
||||
old one, so a read-modify-write patch operates on the old file and silently overwrites the new one.
|
||||
|
||||
**Why it is hard to see**
|
||||
Every file is syntactically valid Lua. Nothing in the pipeline reports an error; the only signal is
|
||||
semantic — a function that should exist does not, or two entries share identical content. If the
|
||||
analysis copy is wrong, the patch is written against a file the device never runs.
|
||||
|
||||
**The rule**
|
||||
**Compare the entry name with the chunk name before analysing a batch.** Lua 5.1 bytecode stores
|
||||
`@UI/...` immediately after the header; zero mismatches is the gate for trusting the export. In your
|
||||
own bundle writer, keep a sidecar map of every written payload and make reads return it first, then
|
||||
compare the bytes read back with the hot-update source. The quick runtime signal is a stack frame
|
||||
naming a function absent from the "source" — that means you are reading the old script.
|
||||
|
||||
## P53. A retained AssetBundle returns null after stripping unused bundles
|
||||
|
||||
**Symptom**
|
||||
You remove bundles that the package does not seem to use. The build installs and starts, but a bundle
|
||||
you kept now loads as null or its assets are missing. There is no CRC error and the removed files are
|
||||
not mentioned in the log.
|
||||
|
||||
**Root cause**
|
||||
`AssetBundleManifest` still lists the removed bundle as a dependency of a retained one. Unity resolves
|
||||
the dependency graph before loading; a missing edge makes the parent unavailable even though its own
|
||||
bytes are intact.
|
||||
|
||||
**Why it is hard to see**
|
||||
The failure is attributed to the bundle that is still present, or to the patch you just made. The
|
||||
removed bundle is absent from the evidence by definition, and "the file exists" looks like proof that a
|
||||
bundle should load.
|
||||
|
||||
**The rule**
|
||||
**When you remove an AssetBundle, rebuild the dependency list in the manifest in the same step.**
|
||||
Never treat file presence as loadability. Load each retained bundle in a control build and check for
|
||||
null before signing; keep the removed-bundle list in the build record so a later change can rebuild
|
||||
the graph.
|
||||
|
||||
@@ -54,6 +54,51 @@ launch screen, popups and tab set are **client decisions** or **server data**,
|
||||
which is the difference between a five-minute patch and a dead end. See
|
||||
`server-config-and-updates.md`.
|
||||
|
||||
## 1c. Resource completeness and the encryption map
|
||||
|
||||
Two questions are cheap on day one and expensive after a week of patching: **how much of the game is
|
||||
in this APK**, and **what exactly is encrypted**. Answer both before choosing a route.
|
||||
|
||||
**Completeness.** A 1.2 GB package can be a quarter of the game. Find the game's own catalogue
|
||||
(`manifest.*`, `AssetDatabase.manifest`, a version file, a native manifest) and diff it against the
|
||||
files actually present, summed by directory. Then diff the content tables (costumes, characters,
|
||||
levels) against entries that have a model or texture. In the source's micro-client case, the manifest
|
||||
listed 42,700 bundles / 4.62 GB while the APK held 12,120 / 1.05 GB, and about 91% of the clothing
|
||||
models were absent. The rest was downloaded on first launch and died with the server.
|
||||
|
||||
A player's app backup is often the only complete source; treat it as untrusted: identify the container
|
||||
(headers, compression, encryption), verify every file against the backup's own manifest and MD5, and
|
||||
confirm the APK inside is the **same build** — the source found a backup APK with the same version
|
||||
name and versionCode but 1,518 differing files. Merge the hot-update layer (scripts, manifests, updated
|
||||
bundles) into the package and make it survive the client's first-launch cleanup, or the files are
|
||||
deleted before they are read. If the missing resources were never in any player's copy, stop and say so
|
||||
rather than patching around a game that cannot be complete.
|
||||
|
||||
**Encryption map.** Do not answer "is anything else encrypted?" from memory. Spend half an hour:
|
||||
|
||||
1. **Classify every entry** — installed files plus the hot-update directory: size, first 16 bytes,
|
||||
magic type, and entropy over the first 64 KB. The goal is zero "unknown" entries.
|
||||
2. **High entropy is not encryption.** Compressed bundles, audio and textures all sit above 7.5.
|
||||
Test whether the standard loader opens the file (a resource bundle that UnityPy loads is not
|
||||
encrypted), whether a public header is present and plausible (e.g. `FSB5`), and whether a sample
|
||||
decodes to a real image or sound.
|
||||
3. **Judge text resources one by one** — readable text, a public binary format (skeleton, sound bank,
|
||||
protobuf), or high entropy and a length that is a multiple of the block size (a cipher candidate).
|
||||
4. **A fixed prefix is not encryption.** Several zero bytes at the start of every bundle are a common
|
||||
anti-unpacker trick; count them in the original, skip them on read, preserve them on write.
|
||||
5. **Check config tables by field**, not by vibe: decode with the protocol description, then scan for
|
||||
field numbers the description does not contain. Zero unknown fields, and a re-serialization of the
|
||||
same byte length, are two independent confirmations.
|
||||
6. **Find the second copy.** Packages often contain an old config database while the phone's
|
||||
hot-updated copy is newer. Compare row counts per table and build from the newer one.
|
||||
7. **List runtime crypto points separately** — script load, login token, resource check, payment,
|
||||
network packet — and record for each whether the offline route needs it.
|
||||
8. **Separate "cannot decrypt" from "not in hand".** A server database or an unreadable private
|
||||
directory is missing data, not an encryption problem; mixing the two produces a false "cracked".
|
||||
|
||||
The map is a deliverable: it lets a later reader see which conclusions were measured, which were
|
||||
inferred, and which files nobody could open.
|
||||
|
||||
## 2. Is it packed?
|
||||
|
||||
Read `AndroidManifest.xml` → `application android:name`.
|
||||
@@ -129,6 +174,8 @@ Heuristic: the business dex mixes the app's package prefix (hundreds of hits) wi
|
||||
| `libflutter.so` + `libapp.so` | **Flutter (Dart AOT)** — dex contains only a thin shell |
|
||||
| `libil2cpp.so` + `assets/bin/Data/` | **Unity (IL2CPP)** — native |
|
||||
| `libmono*.so` + `assets/bin/Data/Managed/` | **Unity (Mono)** — managed DLLs under `assets/` |
|
||||
| `libunity.so` + a `luascript` bundle or thousands of Lua `TextAsset` entries | **Unity + Lua** (xLua / tolua / SLua) — the logic is in a bundle, not the assembly — `unity-lua-and-assetbundles.md` |
|
||||
| `liblayaair.so` + `libv8*.so` + `assets/scripts/*.js` | **LayaAir native (Conch)** — JavaScript logic and a DCC cache — `framework-runtimes.md` §LayaAir native (Conch) specifics |
|
||||
| `libreactnativejni.so` + `assets/index.android.bundle` | **React Native** — JS bundle under `assets/` |
|
||||
| `libhermes.so` | **Hermes bytecode** |
|
||||
| Almost everything in `.so` | **Native** — different toolchain entirely |
|
||||
|
||||
@@ -206,6 +206,33 @@ Always pass the explicit range (Route B above) and read the scheme list from tha
|
||||
|
||||
Useful detail: keeping the same keystore across builds lets you iterate with `-r` and **preserve a logged-in session**, which matters a lot when the feature you are testing needs auth.
|
||||
|
||||
### Renaming the package so two builds coexist
|
||||
|
||||
Same package name plus a different signature is an install refusal, and uninstalling the official app
|
||||
destroys its data. To install alongside it, rename the package in three places — a manifest-only edit
|
||||
produces an APK that installs and then fails to find its own resources:
|
||||
|
||||
1. **Binary `AndroidManifest.xml`.** Rebuild the UTF-16 string pool and replace the package name and
|
||||
**every string that carries its prefix** (permissions, provider authorities, the file provider, the
|
||||
push `C2D_MESSAGE` permission, and similar). Enumerate the suffixes from a table rather than
|
||||
guessing; update the chunk sizes and the file size; assert that no occurrence of the old name
|
||||
remains. Do not rename Java class names.
|
||||
2. **The `resources.arsc` package block.** Its name is a fixed 128-UTF-16-character field. Miss it
|
||||
and the build installs but resource resolution fails at start — the same symptom as a missing
|
||||
resource, far from the cause.
|
||||
3. **The launcher label**, in the arsc UTF-8 string pool. This can be replaced in place only when the
|
||||
new label is not longer than the old one.
|
||||
|
||||
Then grep Java/smali for quoted package-name literals and change build scripts to read the name from
|
||||
one variable instead of hardcoding it. The app's data directory becomes
|
||||
`Android/data/<new-package>/`, so old saves are not carried over — say so in the install notes.
|
||||
|
||||
Verify with `aapt dump badging` (package and label), then install both builds on one device: the new
|
||||
one must reach its main screen and read/write a save while the official one remains installed. The
|
||||
source's LayaAir/Conch case also had to change the arsc package name (Android 9+ compares it when
|
||||
resolving resources); hardcoded private-directory paths used a build-time placeholder, and the local
|
||||
licence value derived from `ANDROID_ID` did not change with the package name, while saves did.
|
||||
|
||||
### Vendor install interception (OEM shells)
|
||||
|
||||
Some ROMs route `adb install` through their own "security centre", which can return
|
||||
|
||||
@@ -90,6 +90,8 @@ reference file is not named here, or when a script is not named here.
|
||||
| A `dlopen`-able `.so` will not load after a custom loader, or a repaired `soinfo` resolves symbols to garbage | `linker-and-soinfo-repair.md` — bias versus base, `seg_page_offset`, the defining library's bias, `APS2` packed relocs, the minimal `soinfo` field set, and the dump window after `LoadSegments` |
|
||||
| A native trace has no `cmp`/`sub`/`jcc` — only `and/or/not/shr/neg`, a flags slot, and a `ret` that lands at a VM entry | `vm-protector-analysis.md` — virtual eflags is data, NAND lowering, branchless select, data-anchored breakpoints, and trace-then-search-by-constant; dispatcher slicing stays in `native-vm-recovery.md` |
|
||||
| The logic you need is Python bytecode — a `.pyc`/PyInstaller/Pyarmor payload rather than Java, Dart or native | `python-bytecode-recovery.md` — key schedule, AES-CTR nonce rules, the 12-byte VM, version-gated `pyc` parsing and the extra-section TLV; static and offline, then call the recovered module |
|
||||
| A Lua decompiler rejects every script with **`non-standard lua format: <n>`** | `unity-lua-and-assetbundles.md` §2 — a changed Lua 5.1 header byte, not encryption |
|
||||
| A re-signed Unity/Mono build exits cleanly with code 4 about two seconds in, with no Java exception | `unity-lua-and-assetbundles.md` §5 — a publisher shell owns the process |
|
||||
|
||||
_70 row(s) below the header._
|
||||
|
||||
@@ -155,6 +157,7 @@ _70 row(s) below the header._
|
||||
| `references/on-device-tooling.md` | Working **from the phone itself**: MT Manager edit/repack/sign and its built-in APK MCP, LSPosed Manager, Termux+frida, and on-device data inspection |
|
||||
| `references/split-apk.md` | The target is a **split APK / App Bundle set** (`base.apk` + `split_config.*.apk`), or `pm path <PKG>` returned several files: reading a set, merge versus unified re-signing, and the install refusal each mistake produces |
|
||||
| `references/unity-runtime.md` | The package is **Unity**: telling Mono from IL2CPP, dumping `libil2cpp.so` + `global-metadata.dat` with Il2CppDumper and importing it into IDA in the order that works, a run-time lookup over frida-il2cpp-bridge, the dnSpy-Unity-mono patch for a Mono player, and the audit probes when the deliverable is a report |
|
||||
| `references/unity-lua-and-assetbundles.md` | A Unity game keeps its logic in **Lua** (xLua/tolua/SLua), or the edit is in an AssetBundle: finding and decrypting the scripts, the Lua 5.1 header tamper, bundle CRC in the manifest the game actually reads, partial-encryption windows, UnityFS rebuild traps (layout loss, stripped dependencies, compressed==raw), added/replaced TextAsset read-back, and half-encrypted Mono assemblies behind a publisher shell |
|
||||
| `references/il2cpp-protected-metadata.md` | A Unity IL2CPP pair is **not readable**: `global-metadata.dat` is absent, 0 bytes, encrypted, embedded, or its version lies. Consumer-side dump, version-independent header sizing, the XOR/VM/XXTEA container shapes, the live-image dump, and the ELF repair that makes a dump usable |
|
||||
| `references/python-bytecode-recovery.md` | The logic is **Python bytecode** behind Pyarmor or PyInstaller: key derivation, AES-CTR nonce rules, the 12-byte VM, a version-gated `pyc` parser, the extra-section TLV, and per-code-object / per-string decryption |
|
||||
| `references/linker-and-soinfo-repair.md` | A protected `.so` must be loaded or dumped **by hand**: the `dlopen` pipeline as a loader spec, load_bias and segment arithmetic, `DT_*` parsing, relocation rules and the two biases, the minimal `soinfo` repair set, anti-dump header XOR, and where a dump is valid |
|
||||
|
||||
@@ -221,6 +221,28 @@ the attribute chunks around it, which is what `apktool` is for — and note that
|
||||
`apktool b` rebuild rewrites the whole archive
|
||||
(`references/repack-and-sign.md` §Repacking an unpacked (de-shelled) app).
|
||||
|
||||
### A narrow manual merge through apktool (when bundletool is unavailable)
|
||||
|
||||
The rule above is the safe one: do not fold resource tables by hand. There is one exception narrow
|
||||
enough to name — a set whose resource splits are **additive** (density or language variants that do not
|
||||
redefine an existing name with a different id). The source's native case did this and installed the
|
||||
result as a single APK:
|
||||
|
||||
1. Install the base as an apktool framework: `apktool if base.apk -p <dir> -t <tag>`.
|
||||
2. Decode each split with the same `-p/-t` so its resources keep their **original ids** instead of
|
||||
being renumbered by the decoder.
|
||||
3. Copy the split's `res/` files into the base and add their ids to the base's `public.xml`. Skip a
|
||||
name that already exists with the same id; replace `APKTOOL_DUMMY_0x...` names by looking the real
|
||||
name up in the base's id table.
|
||||
4. Copy `lib/<abi>/` into the base. In the manifest, remove `requiredSplitTypes`, `splitTypes`,
|
||||
`vending.splits` and `stamp.*`, and set `extractNativeLibs="true"`.
|
||||
5. Verify every resource reference resolves and the app renders the split-only content. If two members
|
||||
define the same resource name with different ids, **stop** — that is the folding problem the first
|
||||
rule warns about, and bundletool is the route.
|
||||
|
||||
This is a per-set workaround, not a resource merger. Say in the delivery which splits were folded and
|
||||
which resource families they carried.
|
||||
|
||||
## 6. Install refusals, and what each one means
|
||||
|
||||
| Message | What it actually says | Attribution |
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Unity + Lua, AssetBundles, and half-encrypted Mono assemblies
|
||||
|
||||
Load this when a Unity APK keeps its game logic in Lua (xLua / tolua / SLua), when the thing you need to
|
||||
edit is an AssetBundle rather than an assembly, or when a rebuilt Unity package fails in a way that
|
||||
looks like the patch: a decompiler rejects every script, a bundle loads as null, a repacked bundle
|
||||
crashes only on device, or a re-signed Mono build exits cleanly a second or two after the splash. It
|
||||
is the script-and-asset half of Unity; `unity-runtime.md` owns Mono/IL2CPP identification and the
|
||||
managed-code route, and `il2cpp-protected-metadata.md` owns protected IL2CPP metadata.
|
||||
|
||||
**Claim strength.** Absorbed from a public game-preservation skill's anonymised case reports: one
|
||||
Unity + Lua target (1,443 encrypted TextAssets, a Lua bundle, CRC in an AssetBundle manifest) and one
|
||||
Unity Mono + SLua target (8,420 Lua 5.1 TextAssets, a publisher shell that decrypts the first 4 KB of
|
||||
each assembly, a 1.2 GB micro-client). The source reports these as work performed on real devices;
|
||||
**this repository has run none of it** — no Unity package was decrypted, no bundle rebuilt, and no
|
||||
Mono assembly reconstructed here. Every mechanism below is therefore `unverified`, and the counts
|
||||
and offsets are the source's, not this repository's measurements. What generalises is the failure
|
||||
shape, not the constants.
|
||||
|
||||
## 1. Recognise the shape before choosing a tool
|
||||
|
||||
| What is in the package | Where the logic is | Route |
|
||||
|---|---|---|
|
||||
| `libunity.so` + `libmono*.so` + `assets/bin/Data/Managed/Assembly-CSharp.dll` | C# IL | `unity-runtime.md` §Mono |
|
||||
| `libunity.so` + `libil2cpp.so` + `global-metadata.dat` | native + metadata | `unity-runtime.md` §IL2CPP |
|
||||
| `libunity.so` + a `luascript` bundle or thousands of Lua `TextAsset` entries | Lua source or Lua 5.1 bytecode | this file §2 |
|
||||
| `assets/AssetBundles/<platform>/` with per-bundle `.manifest` files | assets, prefabs, textures, sometimes scripts | this file §3–§4 |
|
||||
| `Application` is a vendor shell class + `libtprt.so` / `__tpinfo.tsa` | shell decrypts managed assemblies at run time | this file §5 |
|
||||
|
||||
The first question is not "how do I decrypt Lua" but **which layer owns the behaviour**. A Lua game
|
||||
can have almost no interesting C#: the C# boots the engine, loads a bundle and calls into Lua. In that
|
||||
shape, editing IL is the wrong layer even when the IL is readable.
|
||||
|
||||
## 2. Getting Lua out of a Unity package
|
||||
|
||||
### 2.1 Find the container and the key
|
||||
|
||||
Scripts usually live in one of three places: a dedicated bundle such as `luascript`, individual
|
||||
`TextAsset` entries inside many bundles, or a loose file list such as `all_lua_files.lua` (which is
|
||||
often the one plaintext index that names the rest). Locate the loader first: a C# method with names
|
||||
like `LuaScriptKey`, `ScriptKey`, `Key()` or `LuaRuntimeConfig` is where the key is chosen.
|
||||
|
||||
The source's case A: 1,443 `TextAsset` entries, DES-CBC, and **`key == IV ==` an 8-byte constant**
|
||||
taken from `LuaRuntimeConfig.LuaScriptKey()`. Do not assume every script is encrypted: in that case
|
||||
`all_lua_files.lua` was plaintext. Decrypt a sample, then prove the output is a script (it parses,
|
||||
and its first line is plausible Lua) before batch-processing 1,443 files.
|
||||
|
||||
Patterns worth recognising without reversing the decryptor:
|
||||
|
||||
- **Header-only tamper.** A Lua 5.1 chunk starts with signature `1B 4C 75 61` and version `51`;
|
||||
the next byte (offset 5) is the format byte, normally `00`. A build that changes it to `01` makes
|
||||
`unluac` say *"non-standard lua format: 1"* while the rest of the file is untouched. Compare the
|
||||
first 12 bytes with the standard header before concluding "encrypted".
|
||||
- **A single-byte XOR tail** with a strongly encrypted head (see §5 for the assembly version). The
|
||||
tell is the plaintext zero: deep in the file, the same byte repeats in long runs. One byte of XOR
|
||||
turns every plaintext zero into that byte.
|
||||
- **A "decrypt" that is only a wrapper.** Before implementing a cipher, check whether the transform
|
||||
cancels (a repeating XOR with a fixed key, or a stream cipher applied twice). `protocol-reverse.md`
|
||||
§7 has the differential probe for classifying a primitive; use it when the shape is not obvious.
|
||||
|
||||
### 2.2 Repair the header, then decide what the loader accepts
|
||||
|
||||
For the byte-5 tamper, copy the standard 5.1 header over the first 12 bytes, then decompile one file.
|
||||
If the opcodes were not shuffled, the output is clean source; if the decompiler produces nonsense,
|
||||
the build may also have shuffled opcodes, which is a different problem.
|
||||
|
||||
The other half is the loader: the C# call that loads a chunk often takes a mode/`bytecodeOnly` flag.
|
||||
If the bundle path loads with `bytecodeOnly == false`, your patch can be **plaintext Lua** — no need
|
||||
to find a matching 32-bit `luac`. If it is true, recompile the module to the exact Lua version and
|
||||
ABI, or patch the loader to accept source. Read the call site, do not guess from the file extension.
|
||||
|
||||
Batch decompilation is CPU-bound per file (a JVM start per script in the source's toolchain); run it
|
||||
in parallel and keep the output indexed by entry name, because the next step is a lookup, not a read.
|
||||
|
||||
### 2.3 Verify content against entry names
|
||||
|
||||
Lua 5.1 bytecode stores its source name immediately after the header (for example
|
||||
`@UI/reputationshop/shop.lua`). After a batch decompile, compare **the entry name with the chunk
|
||||
name** before analysing anything. In the source's case, 112 files decompiled to the same unrelated
|
||||
file because their bundle writer had cloned a template object and `read()` kept returning the
|
||||
template's bytes. The game itself was fine; only the analysis copy was wrong.
|
||||
|
||||
The same warning applies to a hot-update merge: if your bundle wrapper replaces `main.lua` but
|
||||
`read()` still returns the packaged original, the build patches the **old** script, and the
|
||||
hot-update version's constants silently disappear. Keep a sidecar map of every byte you write
|
||||
(replacements and additions) and make your reader return it first; then compare the bytes read back
|
||||
with the hot-update file before trusting a patch.
|
||||
|
||||
## 3. AssetBundle integrity: the CRC is not where you guess
|
||||
|
||||
Unity verifies a bundle's CRC when it loads it. The value can live in:
|
||||
|
||||
- `<bundle>.manifest` next to the bundle, and
|
||||
- `assets/AssetBundles/<platform>/AssetDatabase.manifest` (the catalogue).
|
||||
|
||||
Guessing which one is live costs a full round. **Run the unmodified build once and read the log** —
|
||||
Unity prints both values:
|
||||
|
||||
```text
|
||||
E/Unity: CRC Mismatch. Provided d906612, calculated ab764880 from data.
|
||||
Will not load AssetBundle 'luascript'
|
||||
```
|
||||
|
||||
Change the manifest the game actually reads (the catalogue in the source's case, not the same-named
|
||||
bundle manifest) to `0` (skip) or to the computed value. Then verify the bundle loads, not only that
|
||||
the error disappeared.
|
||||
|
||||
**An 8-byte zero prefix is not corruption.** In the source's case A, 2,354 of 2,355 bundles began with
|
||||
eight `0x00` bytes in the original package; the only exception was the catalogue. All of them loaded.
|
||||
An earlier pass "repaired" 13 of them and carried the damage into the next baseline. Count the prefix
|
||||
across the original package before treating it as a fault; skip it on read and preserve it on write.
|
||||
|
||||
### 3.1 A bundle whose first block will not decompress
|
||||
|
||||
The shape is: a valid `UnityFS` header, the first compressed block fails to decompress, later blocks
|
||||
are fine. That is a **partial-encryption window**, not a corrupt download. To bound it:
|
||||
|
||||
1. Parse the UnityFS header and block table yourself; decompress each block independently and record
|
||||
which blocks fail.
|
||||
2. Entropy-scan the failing block's compressed stream. A sharp drop in entropy marks the end of the
|
||||
encrypted window.
|
||||
3. Run the block's compressor (often LZ4) directly over the ciphertext; the byte offset where it
|
||||
crashes is the start of the window.
|
||||
4. Compare the start/end across several bundles. The source found 1,691 bundles with the same
|
||||
`[56:4096]` window, which proves a build-wide scheme rather than per-file damage.
|
||||
5. XOR two ciphertexts from different bundles: if the transform is a fixed stream cipher, the
|
||||
plaintext-derived difference is stable. That tells you the shape, not the key.
|
||||
|
||||
Only after this do you know what you are missing: the key or the algorithm. Do not rebuild the
|
||||
bundles before the window is pinned.
|
||||
|
||||
## 4. Rebuilding a bundle without changing it
|
||||
|
||||
A bundle is a serialized container, not a zip. Two independent traps:
|
||||
|
||||
**The rewriter changes the header/layout.** UnityPy's `save_typetree` can produce a much smaller
|
||||
serialized file than the original (the source saw 6,700 → 3,552 bytes) because it drops the original
|
||||
header layout and alignment padding. Compare the object metadata and header of old and new files; if
|
||||
they differ, pad/restore the original layout before writing the bundle back. For a texture, render the
|
||||
new text with the game's own font and encode it back to the original format (ETC2 in that case); do
|
||||
not assume a PNG round-trip is equivalent.
|
||||
|
||||
**A stripped resource is still in the dependency graph.** If you remove an AssetBundle but leave the
|
||||
`AssetBundleManifest` listing it as a dependency of a retained bundle, Unity returns **null** for the
|
||||
retained bundle. Remove the dependency edges when you remove the assets, not only the files.
|
||||
|
||||
**The compressed-size edge case.** A rebuilt bundle can crash where a block's compressed size equals
|
||||
its raw size — the loader takes a branch that assumes compression. Do not debug this inside a full
|
||||
build. Replace one hand-made bundle into an existing signed APK, re-align and re-sign, install and
|
||||
cold-start; each experiment is about a minute. Write a minimal UnityFS reader that lets you change one
|
||||
block size, one compression flag or one field at a time, and keep a control bundle that differs in
|
||||
nothing. That is how the source found the exact equality that broke the load.
|
||||
|
||||
### 4.1 Adding or replacing a TextAsset
|
||||
|
||||
When a Lua script is added to an existing bundle, the writer has to create a new serialized object and
|
||||
update the container index. Two checks make this safe:
|
||||
|
||||
- **Keep the bytes you wrote.** Do not rely on a library's `read()` after `set_raw`/`save_typetree`;
|
||||
some versions return the template object or the original bytes. Store the written payload in a
|
||||
sidecar map and serve it on read.
|
||||
- **Verify entry name and chunk name agree**, for every added and every replaced script. Then verify
|
||||
the bytes read back equal the source file you intended (especially after merging a hot-update
|
||||
layer). A build that patches the old script is indistinguishable from a patch that did nothing.
|
||||
|
||||
## 5. Mono assemblies: half-encryption, a rebuilt PE header, and the publisher shell
|
||||
|
||||
This is a different shape from a packed dex. The assembly file is present and looks like a PE, but its
|
||||
first `0x1000` bytes are strongly encrypted and the rest is XORed with one byte. The tell is the same
|
||||
as §2: deep in the file, one value repeats in long runs, because plaintext zeros became that value.
|
||||
Autocorrelation does not show a period; the repeated value does.
|
||||
|
||||
To read it without reversing the runtime's decryptor:
|
||||
|
||||
1. XOR the tail with the single byte.
|
||||
2. Rebuild the PE header from structural landmarks rather than guessing offsets: the
|
||||
`_CorDllMain`/`mscoree.dll` import, the `FF 25 00 20 40 00` entry trampoline, the `.reloc`
|
||||
block that points at it, `VS_VERSION_INFO` in `.rsrc`, and the `BSJB` metadata root.
|
||||
3. Watch for an extra `.sdata` section (static-field initial values): it appears as a hole between
|
||||
`.text` and `.rsrc` and as a file offset `0x400` instead of `0x200` for `.text`. Missing it
|
||||
makes ILSpy report *"Field data (rva=...) could not be found in any section"*.
|
||||
4. Accept the boundary: methods whose bodies lived in the first 4 KB are lost (the source counted 87
|
||||
small constructors and 123 getters across two assemblies). Static analysis of the rest still works.
|
||||
|
||||
**The publisher shell changes the plan.** If `AndroidManifest`'s `application` is a vendor class
|
||||
(for example a `TPShellApplication`) and `lib/` contains its runtime, the shell checks the APK
|
||||
(signature/CRC/package) and kills the process about two seconds after launch. The symptom is
|
||||
`Process ... exited cleanly (4)` with **no Java exception** and no tombstone. Do not read that as a
|
||||
normal crash. The same shell often decrypts the assembly heads, so removing it also removes the thing
|
||||
that made the assemblies readable.
|
||||
|
||||
The source's route: switch the shell off through its own dex string constants (same-length value
|
||||
change, then recompute the dex header's SHA-1 and adler32), and recover the plaintext assemblies from
|
||||
the **original game's process memory** — root, read `/proc/<pid>/maps`, stream anonymous regions,
|
||||
locate the known XORed body, and restore the DOS header the shell zeroed. That route is specific to
|
||||
that shell; the general rule is: identify the shell's own kill signal and its own string gate before
|
||||
patching, and do not ship a rebuild while the shell still owns the process. Shell identification and
|
||||
the keep/drop decision are in `packers.md` and `code-virtualization-and-custom-linkers.md`;
|
||||
`advanced-unpacking.md` owns the memory-dump boundary.
|
||||
|
||||
## 6. Verification
|
||||
|
||||
A Unity asset patch is done when:
|
||||
|
||||
- the bundle **loads** (the CRC error is gone, and no null bundle appears in the log);
|
||||
- the script's **entry name, chunk name and decompiled content** agree;
|
||||
- a replaced script's bytes read back byte-identical to the source you wrote;
|
||||
- an added entry exists in the container index and is returned by the loader;
|
||||
- the on-device screen shows the change, after a cold start, on the same build you installed;
|
||||
- a control build with **no** patch shows the old behaviour on the same device.
|
||||
|
||||
A hash of the APK is not evidence that the bundle inside it changed — check the bundle entry, not the
|
||||
APK size. Signing and alignment stay in `repack-and-sign.md`.
|
||||
|
||||
## 7. Boundary
|
||||
|
||||
- **IL2CPP metadata and native code** — `unity-runtime.md` and `il2cpp-protected-metadata.md`.
|
||||
- **A packer or a self-terminating native check** — `packers.md`,
|
||||
`code-virtualization-and-custom-linkers.md`, `native-tamper-and-suicide.md`.
|
||||
- **Crypto classification** — `protocol-reverse.md` §7; do not implement a cipher from a guess.
|
||||
- **The game's network layer** — `protocol-reverse.md` and `server-api.md`.
|
||||
- **Splitting, signing and package rename** — `split-apk.md` and `repack-and-sign.md`.
|
||||
Reference in New Issue
Block a user