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.
5.6 KiB
Verification — what "done" means
The difference between a hobby patch and a usable deliverable is verification. "It assembles" and "the log is quiet" are not verification.
The claim ladder
Each rung is stronger evidence. Climb as high as the task requires, and state clearly which rung you reached.
| Rung | Claim | Evidence required |
|---|---|---|
| 0 | "The dex was edited" | Byte-level diff of the target method |
| 1 | "The structure is intact" | dex_classdiff: same classes, zero ACC_INTERFACE drift, unchanged classes byte-identical |
| 2 | "It builds and signs" | Signature verification passes |
| 3 | "It installs and launches" | Process alive, logcat free of fatal signatures |
| 4 | "The target behavior changed" | The specific feature observed changed (screen exercised, endpoint observed, UI confirmed) |
| 5 | "Nothing else regressed" | Adjacent features exercised: images, playback, lists, login, settings |
| 6 | "The mechanism is proven" | Independent evidence of why — e.g. the SDK's domains are never resolved because init never ran |
Rung 3 is the minimum for any deliverable. Rung 4 for a claim that the patch solved the problem. Rung 5 before handing it to a user. Rung 6 when the claim is "the subsystem is dead", not merely "the ad is hidden".
The control build rule
Before blaming a patch, build a control: the same pipeline with zero patches.
python scripts/repack.py --apk original.apk --dexdir <extracted> --out control.apk
- Control fails → your pipeline, the environment, or the device is at fault. Stop debugging the patch (
pitfalls.mdP9). - Control passes, patched fails → the patch is implicated. Bisect: revert one dex at a time.
Do this whenever something unexpectedly fails, and at least once per project.
Structural verification (automate it)
After every dex edit:
python scripts/dex_classdiff.py <original.dex> <patched.dex>
Expect exactly:
only_in_A=0 only_in_B=0
ACC_INTERFACE mismatch: 0
access_flags diff (same interface-ness): 0
Caveat, and it is an important one: this check cannot detect code-item damage. A whole-tree smali round-trip can pass every table check and still crash with IncompatibleClassChangeError (pitfalls.md P3). Passing this check is necessary, not sufficient.
Also confirm you changed what you intended and nothing more:
- Reverse the patched dex and inspect the target method.
- Compare unmodified classes byte-for-byte against the original where feasible.
Runtime verification
adb -s <serial> logcat -c
adb -s <serial> shell "am start -n <pkg>/<activity>"
sleep 5; adb -s <serial> shell "pidof <pkg>" # empty == died
sleep 20; adb -s <serial> shell "pidof <pkg>" # same pid == stable
adb -s <serial> logcat -d -v brief | grep -E 'FATAL|VerifyError|IncompatibleClassChange|uncaughtException|Failure starting process'
Sample the pid twice, spaced apart. A single reading catches apps that die in a crash loop.
Failure signature → cause table lives in references/environment.md.
Logcat is often not enough. If the app installs a crash handler (友盟/UCrash/Bugly), the Java stack never reaches logcat. A process that dies with only uncaughtException time: ... and no stack has crashed — treat it as a failure, not as noise. Extraction techniques: references/environment.md §signal extraction.
Behavioral verification (the part people skip)
Launching proves nothing about your change. Exercise it.
- Ads removed → open the screens that had ads (splash, home, detail, player, reward button). Confirm absence and confirm the screens still function.
- A gate patched → walk the gated flow end to end.
- A timeout/limit patched → trigger it.
- Data patched → stop the app, clear/re-write the value, cold start, re-check (proves it is not an in-memory artifact).
And then test the neighbours. The most common real-world regression is a patch that fixed its target and quietly broke something unrelated:
- Images / cover art render?
- Video or audio playback works?
- Lists scroll and paginate?
- Login / session still valid?
- Settings screens open?
- Downloads still start?
This is exactly how a "generic image-card composable" patch removed all cover art while the ad still showed (pitfalls.md P6).
Screenshot discipline
Capture before/after screenshots for each claim. Text descriptions are not evidence; a screenshot of the screen without the ad, alongside one from the control build with it, is.
Reporting
Build: <path> <sha256>
Base: <original apk name/version>
Changes: <dex → what changed>, one line each
Structural: dex_classdiff result (per dex)
Signature: verified (v1/v2/v3)
Device: <model / android / abi / rooted?>
Runtime: pid stable over N s; no fatal signatures
Behavior: <screens exercised>, <before/after>
Regressions: <adjacent features checked>
Rung reached: 0..6
Residual: <what is NOT fixed, and the exact reason>
Be explicit about residuals. "Ad X remains because it is delivered inside the screen's main data payload, and suppressing it at the transport layer takes the whole screen down" is a useful, honest result. Silently omitting it is not.
Anti-patterns
- Declaring success from a successful assembly.
- Claiming "no ads" from a quiet logcat when the SDK never printed anything either way.
- Concluding from a single launch without exercising the feature.
- Quoting emulator results as device results.
- Reporting a fixed feature without checking adjacent features.
- Hiding a known residual failure.