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