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

11 KiB
Raw Blame History

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

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:

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.

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).

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):

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:

[
  {
    "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:

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.