11 KiB
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.
Load this when: you have located the class and method to change and must choose the edit technique. It gives the least-destructive-first order and the signal that says a byte patch is not enough.
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|dex|dir|apk> 'Lcom/pkg/Helper;->methodName(args)RetType'
Read the [scanned] line before the count. It reports how many inputs were actually opened, and
[scanned] 0 file(s) exits 2 with the reason: an unsupported path, a typo, or a directory of archives
is not the same answer as "read 2 dex files and found nothing", and the two once printed
identically — which put a false zero on exactly the decision this check exists to protect. A dex input
is decoded directly (dexutil.py), so baksmali is not required.
- 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
directvsvirtual.static,private, and constructors live indirectMethods; everything else (includingpublic final) lives invirtualMethods. 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().registersmust be>= parameterRegisters, and parameter registers are the highest-numbered ones: withregisters = Nandkparameters (includingthisfor 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=5whenregisters = 6. const-wideneeds the 64-bit instruction form.ImmutableInstruction51l, not31i.- Instruction register operands in
35cform (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/debugItemson 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:
- requires
len(new) == len(old), - requires exactly one occurrence,
- looks up the entry's neighbours in
string_idsand rejects the replacement unlessprev < new < next, - recomputes
signature(SHA-1 over bytes from offset 32) andchecksum(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 —
grepthe 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
- Identify every place the behavior is produced (
recon+find_refs). - Pick the highest safe layer per the table above.
- Write all edits for a given dex into one dexlib2 program.
- Patch each dex once.
dex_classdiffevery patched dex against the original → expect zero structural drift.- Repack, sign, install, launch, exercise, read logcat.
- If anything is off, revert that dex to the original and re-verify the control build.