Files
apk-reverse/references/dex-patching.md
T
inliver233 c66658b6b3 feat: apk-reverse Agent Skill
Progressive-disclosure skill for Android APK reverse engineering:
recon, ad removal, membership limits, dex surgical patching, repack/sign,
runtime data, Frida, environment setup, and a hard-won failure catalogue.
2026-09-19 21:25:35 +08:00

196 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Dex patching — choosing and executing the surgical edit
The single most important decision in a patch task. Get this wrong and you produce an APK that assembles perfectly and dies at runtime.
## Pick the least destructive technique that can express your change
Ordered from safest to most dangerous.
| # | Technique | Expresses | Structure risk | Use when |
|---|---|---|---|---|
| 1 | **dexlib2 method-level rewrite** | Replace a method's implementation (return a constant, no-op, emit a fixed value) | **Lowest** — only the target `code_item` changes | Default choice. Any behavior change that can be expressed as "this method now does X instead" |
| 2 | **Byte-level string constant patch** | Change a string literal to another of **equal length** | Medium — must preserve `string_ids` ordering (see P2) | Renaming a path/key/protocol token |
| 3 | **Resource / asset edit** | Change a config file, JSON, image, or bundled jar | Low (no dex change) | The behavior is data-driven |
| 4 | **Manifest edit** | Disable a component, drop a permission, flip a flag | Low, but re-signing required | Turning off a service/receiver/activity |
| 5 | **Whole-tree smali round-trip** | Arbitrary code edits | **High** — breaks R8 output (see P3) | Last resort, and verify by running |
**Do not reach for #5 because it is familiar.** It is the most likely to produce a broken build.
## Where to patch: pick the layer, not the symptom
Ads and gates can be suppressed at several layers. Higher in this list = smaller blast radius.
| Layer | What it looks like | Example | Risk |
|---|---|---|---|
| **SDK initialization** | A single helper that calls `Sdk.init()` | `AdHelper.k(Application)` | Low — SDK never starts, nothing else depends on it |
| **Feature entry point** | The app method that shows the thing | `AdHelper.showSplash()`, `showReward()` | Low–medium — must preserve callbacks the caller awaits |
| **Data consumption** | Where a server list is filtered/rendered | filter out a `position` before it reaches UI state | Medium — must not break sibling data |
| **Renderer** | The composable/view for a **specific** element | a dedicated ad card composable | **High** — generic components are shared (see P6) |
| **Transport** | Blocking/repointing an endpoint | `/app/adverts` → dead path | **Very high** — shared endpoints take screens down (see P5) |
**Rule:** prefer the highest layer (closest to the SDK/feature) that produces the required effect. Never go to the transport layer to hide a UI element.
## Before you patch a method: blast-radius check
```bash
python scripts/find_refs.py <smali_tree_or_dex_dir> 'Lcom/pkg/Helper;->methodName(args)RetType'
```
- **1–3 callers, all in the same feature area** → safe to patch.
- **Many callers, or callers across unrelated packages** → it is a general utility. Do not patch it. Go one level up and patch the specific caller instead.
- **Look at the parameters too.** If the signature mentions `Modifier`, `ContentScale`, `ColorFormat`, `Shape`, `View`, or a content-generic model type, it is not specific to your target.
Real example: a composable `(AdModel, ColorScheme, Modifier, Function0, ContentScale, Shape, Composer, II)V` looked ad-specific because of its first parameter. It was actually the shared image-card renderer; neutering it removed all cover art and broke playback.
## Technique 1: dexlib2 method-level rewrite (preferred)
Concept: load the dex, find the target class, replace **only** the target method's implementation, write a new dex. Nothing else is touched.
See `scripts/dexpatch/` for a working Java implementation and build instructions.
Shape of the code:
```java
DexFile dex = DexFileFactory.loadDexFile(new File(in), Opcodes.forApi(API));
List<ClassDef> out = new ArrayList<>();
for (ClassDef cd : dex.getClasses()) {
List<Method> direct = new ArrayList<>(); for (Method m : cd.getDirectMethods()) direct.add(m);
List<Method> virtual = new ArrayList<>(); for (Method m : cd.getVirtualMethods()) virtual.add(m);
boolean touched = false;
// ... for the target class: find the method, build new instructions, list.set(i, newMethod)
out.add(touched
? new ImmutableClassDef(cd.getType(), cd.getAccessFlags(), cd.getSuperclass(), cd.getInterfaces(),
cd.getSourceFile(), cd.getAnnotations(), cd.getStaticFields(),
cd.getInstanceFields(), direct, virtual)
: cd);
}
DexFileFactory.writeDexFile(out, new ImmutableDexFile(Opcodes.forApi(API), out));
```
### Practical details that decide success
- **`direct` vs `virtual`.** `static`, `private`, and constructors live in `directMethods`; everything else (including `public final`) lives in `virtualMethods`. **Scan both** when searching. A "method not found" that is actually "in the other list" wastes a lot of time.
- **Preserve the register count.** Keep `m.getImplementation().getRegisterCount()`. `registers` must be `>= parameterRegisters`, and parameter registers are the **highest-numbered** ones: with `registers = N` and `k` parameters (including `this` for non-static), `p0 = N - k`.
- **Method parameters** come from the prototype. For `(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;` on an instance method, `k = 3` → `p0=3, p1=4, p2=5` when `registers = 6`.
- **`const-wide` needs the 64-bit instruction form.** `ImmutableInstruction51l`, not `31i`.
- **Instruction register operands in `35c` form** (invoke) are `(opcode, registerCount, regC, regD, regE, regF, regG, ref)`. `invoke-static {v1, v2}, ...` → `registerCount=2, regC=1, regD=2`.
- **Every method body must end with a return of the right type.**
- **One dex, one write pass.** If you need two edits to the same dex, do them in the same program (see P4).
- **Null `tryBlocks` / `debugItems` on the rewritten method is fine** — you are replacing the whole body.
### Common rewrites
**Return a boolean constant** (`Z`):
```
const/4 v0, 0x1
return v0
```
`registers` = original count is fine; `const/4` needs a 4-bit register (`v0`–`v15`).
**No-op** (`V`):
```
return-void
```
**Return an object/empty collection**: build the constant, then `return-object`. For `java.lang.Long`, `const-wide` + `Long->valueOf(J)` + `move-result-object`.
**Coerce a suspend/Flow lambda to always yield a fixed value** — this is what "make a local flag permanently true" usually looks like in practice. See the worked example below.
### Worked example: permanently "not expired" without touching a shared helper
**Situation.** A local preference (`<key>_expires_at`) gates a promo popup. Writing a large value into the datastore works on the current device but is **runtime data**, so a fresh install loses it (see P11). The read path is a Flow built at construction time, so the getter cannot be patched meaningfully, and the generic `Long.valueOf` wrapper has 30+ callers (see P6).
**Target.** The dedicated map lambda, e.g. `feature/data/SomeStore$currentX$$inlined$map$1$2`:
```
.method public final emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
iget-object v0, p0, L<same class>;->b:Lkw2; # downstream collector
const-wide v1, <big value>
invoke-static {v1, v2}, Ljava/lang/Long;->valueOf(J)Ljava/lang/Long;
move-result-object v1
invoke-interface {v0, v1, p2}, Lkw2;->emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
move-result-object v0
return-object v0
.end method
```
Note the shape: the lambda class holds the downstream collector in a field (here `b:Lkw2;`). Always read that field name from the actual smali — do not assume.
This class has exactly one purpose, so patching it is safe.
## Technique 2: byte-level string patch
Only for **equal-length** replacements. Must preserve `string_ids` ordering.
```bash
python scripts/dex_strpatch.py <in.dex> <out.dex> "<old>" "<new>"
```
The script:
1. requires `len(new) == len(old)`,
2. requires exactly one occurrence,
3. looks up the entry's neighbours in `string_ids` and **rejects** the replacement unless `prev < new < next`,
4. recomputes `signature` (SHA-1 over bytes from offset 32) and `checksum` (adler32 over bytes from offset 12).
If a candidate is rejected, try another string that sorts inside the same interval. `/app/noadver` was rejected (`n > c` against neighbour `/app/configs/`); `/app/blocked` was accepted.
**Do not** use this technique to change behavior. Strings are renamed, not logic.
## Technique 3: smali tree editing (only when you must)
Use when the change cannot be expressed as a single-method rewrite (adding a field, restructuring control flow).
Tools: `scripts/smtool.py` (baksmali/smali with a bundled classpath) and `scripts/patch_smali.py` (method-body replacement by signature).
```bash
python scripts/smtool.py d <in.dex> <out_tree>
python scripts/patch_smali.py <tree> <patch.json> [--dry-run]
python scripts/smtool.py a <tree> <out.dex>
```
Then **must** verify (P3 will bite otherwise):
```bash
python scripts/dex_classdiff.py <original.dex> <rebuilt.dex>
```
Expect: same class count, `only_in_A=0`, `only_in_B=0`, `ACC_INTERFACE mismatch=0`.
If the class table is clean but the app crashes with `IncompatibleClassChangeError` / `VerifyError`, the round-trip damaged code items that table comparison cannot see. Fall back to technique 1.
Patch spec format for `patch_smali.py`:
```json
[
{
"file": "com/pkg/Helper.smali",
"method": ".method public final showAd(Landroid/app/Activity;)V",
"registers": 3,
"body": ["return-void"],
"note": "why this is safe"
}
]
```
## Finding the call site
Reverse-lookup from a smali tree or a set of disassembled dex:
```bash
python scripts/find_refs.py <tree> 'Lcom/pkg/AdHelper;->showSplash(...)V'
```
Search strategies that work:
- Search the **string table** first (`scripts/dex_strings.py`). API paths, keys, and SDK class names are usually string literals, and they are unique.
- Search for the **SDK's entry class** (`Sdk.init`, `Sdk.show`). The app's wrapper is almost always the only caller.
- When the decompiler is confusing, trust the **byte offsets** — `grep` the dex for the literal and note where it sits relative to other strings of the same family.
## Order of operations for a real patch task
1. Identify every place the behavior is produced (`recon` + `find_refs`).
2. Pick the highest safe layer per the table above.
3. Write all edits for a given dex into **one** dexlib2 program.
4. Patch each dex **once**.
5. `dex_classdiff` every patched dex against the original → expect zero structural drift.
6. Repack, sign, install, launch, exercise, read logcat.
7. If anything is off, revert that dex to the original and re-verify the control build.