Files
apk-reverse/references/pitfalls.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

12 KiB

Pitfalls — the failure catalogue

Every entry here cost real time and produced a silently broken artifact. Skim this before building. When you lose more than thirty minutes to something new, add it here.

Each entry: symptom → root cause → why it is hard to see → what to do instead.


P1. Stripping the whole META-INF/ breaks the app at startup

Symptom

java.lang.IllegalStateException: Module with the Main dispatcher is missing.
Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'

App dies immediately on launch. Sometimes a different ClassNotFoundException for an unrelated library class.

Root cause META-INF/ is not only signatures. It holds ServiceLoader registrations that Android reads at runtime. Deleting the directory to prepare for re-signing deletes them too.

Real examples found in one APK:

META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory  -> kc
META-INF/services/io.ktor.client.engine.HttpClientEngineContainer    -> OkHttpEngineContainer
META-INF/services/io.ktor.serialization.kotlinx.KotlinxSerializationExtensionProvider
META-INF/services/kotlinx.coroutines.CoroutineExceptionHandler       -> wc
META-INF/services/com.arialyy.aria.core.inf.IUtil
META-INF/services/com.arialyy.aria.core.listener.IEventListener

Why it is hard to see The error message names Kotlin coroutines, not your repack. You will spend an hour blaming the dex.

Do instead Strip only signature artifacts, and only at the top level of META-INF/:

SIG = ('MANIFEST.MF',)
SIG_EXT = ('.SF', '.RSA', '.DSA', '.EC')

def is_signature_entry(name):
    if not name.upper().startswith('META-INF/'):
        return False
    rest = name[len('META-INF/'):]
    if '/' in rest:          # services/, androidx/, native-image/ ... keep
        return False
    up = rest.upper()
    return up in SIG or up.endswith(SIG_EXT)

scripts/repack.py already does this.


P2. Byte-level string patching without an ordering check rejects the whole dex

Symptom App cannot load any class:

ClassNotFoundException: Didn't find class "<App.Application>" on path: DexPathList[[zip file ".../base.apk"]]

Application construction fails, process never starts.

Root cause Dex requires the string_ids table to be sorted. Replacing a string with an equal-length string keeps all offsets valid, but changes where that entry should sit in the sorted order. If the new value crosses its neighbours, the loader rejects the entire dex.

Concrete case: /app/adverts sat between /api/v1/crashtrack/upload?chk= and /app/configs/. Replacing it with /app/noadver put n after c → out of order → whole dex rejected. Replacing with /app/blocked (b < c) was accepted.

Why it is hard to see checksum (adler32) and signature (SHA-1) recompute perfectly, so every integrity check passes. baksmali parses the file fine. Only the runtime loader cares about ordering.

Do instead Always run the ordering guard: scripts/dex_strpatch.py looks up the target's neighbours in string_ids and refuses any replacement outside (prev, next). Pick a candidate that stays inside the interval.


P3. Whole-tree smali round-trip damages R8-optimized dex

Symptom App installs, then dies with:

java.lang.IncompatibleClassChangeError: Found interface io.ktor.client.engine.HttpClientEngine,
but class was expected
    at io.ktor.client.engine.HttpClientEngine.access$checkExtensions(...)

(or VerifyError, or a class-load failure in an unrelated library)

Root cause baksmali → smali rebuild does not faithfully reproduce R8's synthetic access bridges / optimization artifacts. The class is still declared as an interface, but the bridge method that ART expects to find as a class member is gone.

Why it is hard to see A structural diff of class_def entries shows nothing: same class count, zero ACC_INTERFACE mismatches, zero access-flag differences. The damage is at the code-item / reference level, invisible to table-level checks. It only appears at runtime.

Do instead Use method-level surgical rewriting with dexlib2 — read the dex, replace only the target method's implementation, write it back. See scripts/dexpatch/. Never rebuild the whole tree for a one-method change.


P4. Rewriting the same dex twice makes ART refuse to start the process

Symptom

E/ActivityManager: Failure starting process <pkg>
I/ActivityManager: Force stopping <pkg> appid=... user=0: start failure

No Java exception anywhere. logcat shows a splash screen appearing then vanishing. baksmali still parses the dex, and smali re-assembles it fine.

Root cause Serializing a dex a second time loses metadata that the first serialization preserved. Chaining two patch tools over the same file (patch A → patch B → repack) triggers this.

Why it is hard to see Both intermediate files look valid and round-trip cleanly. Only ART rejects the final one.

Do instead Combine all edits to one dex into a single read/write pass. One program, one loadDexFile, apply every change, one writeDexFile.


P5. Killing an endpoint to hide a UI element takes the whole screen down

Symptom Home screen becomes the app's generic error state ("something went wrong / retry"), or a blank screen, after redirecting or 404-ing an ad endpoint.

Root cause The ad request is a child request of the screen's main data load. When it throws, the parent load fails with it.

Concrete case: /app/adverts was redirected to a nonexistent path. That endpoint is requested from inside MainScreenStore.loadData as a sub-request, so the home screen's entire data load failed.

Why it is hard to see Removing ads sounds like it should only remove ads. The coupling is invisible until runtime.

Do instead Suppress at the data-consumption or render layer, not at the transport layer. Let the request succeed and discard/ignore the result. Never make a shared endpoint fail.


P6. Patching a shared helper breaks unrelated features

Symptom Images stop loading, video playback fails, or downloads break — after patching something that looked ad-specific.

Root causes, two variants

  • Patching a generic utility: a Long.valueOf-style boxing helper had 30+ callers across player, download, paging and history sync. Patching it would have broken all of them.
  • Patching a "card" renderer you assumed was ad-only: the composable's signature was (itemModel, ColorScheme, Modifier, onClick, ContentScale, Shape, Composer, II) — a generic image card shared by normal content. Making it return-void killed all cover art and the player pipeline.

Why it is hard to see The class name and the model type it consumes suggest it is ad-specific. It is not.

Do instead Before patching any method, count its callers (scripts/find_refs.py). If it has many, or if its parameters look content-generic (image/graphics/Modifier/ContentScale parameters), it is not specific to your target. Also check whether the parameter model type is shared with non-ad content.


P7. Client-side VIP forgery breaks the app instead of unlocking it

Symptom Blank screen; or logged in, but playback fails.

Root cause The authoritative gate is server-side. Real access is granted by an API response. Forcing the client's local isVip() to true makes the client believe it has rights the server will not honor, so it walks a path that assumes data it never receives.

Concrete case: /v2/sections/{id}/play-url returns 401 with no token and 401 with a forged Bearer token; metadata endpoints returned 200 but deliberately omitted any play URL. Patching local VIP state produced a white screen.

Do instead Determine server authority before patching. See references/membership-and-limits.md. If the gate is server-side, the honest deliverable is "not achievable client-side", plus any genuinely client-side wins (unlocking UI, removing ads).


P8. DataStore / protobuf hand-editing fails silently

Symptom App dies with a bare uncaughtException and no stack trace (a crash-reporter SDK swallowed it). Or the app launches but ignores your injected value.

Root cause (encoding) AndroidX Preferences maps are protobuf map<string, Value> fields. An entry needs two levels of tag:

outer : 0A <len(entry)>
inner : 0A <len(key)> <key>  12 <len(value)> <value>

Writing only the inner part produces a file DataStore cannot deserialize.

Root cause (lifecycle) DataStore caches in memory and writes back. Editing the file while the app runs is either ignored or overwritten. Also the file is owned by the app's uid — a file written as root with the wrong owner is unreadable to the app.

Do instead

  • Encode with scripts/datastore_inject.py (implements both tag levels).
  • force-stop the app first, write, then start.
  • Preserve ownership: write via su, then chown to the app uid (or cp -f over the existing file, which keeps its owner).
  • If a value must survive a fresh install, code-level patching is the only way — runtime data is not part of the APK.

P9. Blaming the patch when the device or environment is broken

Symptom Every build fails, including a completely unmodified original.

Root causes seen in practice

  • Device in a bad state: Failure starting process for all builds (including stock). A device reboot fixed it.
  • Offline device: app shows a generic network error, easily mistaken for a server rejection or signature problem.
  • App data directory uid mismatch after reinstall: crashes in a database-init path (Cannot open database ... Directory ... doesn't exist). Fix with chown -R <uid>:<uid> /data/user/0/<pkg>.
  • Emulator that cannot run the app at all (different ABI, missing platform pieces). A working emulator is not evidence about a real device.

Do instead Always run a control. Install and launch the unmodified original under the exact same conditions. If the original fails too, stop debugging your patch.


P10. Trusting static decompilation over runtime behavior

Symptom You patch what the decompiler showed, and nothing changes; or the app crashes in a path you did not know existed.

Root cause Decompiler output is a guess reconstructed from bytecode. Interface/class relationships, inlined code, and obfuscated bridges are regularly misrepresented. Also, dead code and shadowed branches look identical to live ones.

Do instead Rank evidence: live runtime behavior > captured network traffic > served assets > current process/config state > persisted state > generated artifacts > source > comments and dead code. Use source to explain runtime, not to override it.


P11. Assuming a repackaged APK ships runtime data

Symptom A fix verified on the target device does not work after a fresh install.

Root cause Some fixes are runtime data, not code: a DataStore value, a preferences file, a cached token. Those live in /data/data/<pkg>/ and are gone on a clean install.

Do instead Ask, for every fix: is this in the APK or in app data? If it is app data and the deliverable is an APK, re-implement it as a code-level change (patch the read path so it always yields the desired value).


P12. PowerShell (or any shell) eats device-side commands

Symptom adb shell "su -c '...'" fails with host-side path or parsing errors: "Could not find a part of the path", "Missing type name after '['", unexpanded $VAR, or a regex that got mangled.

Root cause The host shell expands $, |, >, and quotes before adb sees them. Windows PowerShell additionally mangles $var:, [^"] and $(.

Do instead Never build device commands inline in the host shell. Either:

  • call adb from a small script file (scripts/devsh.py), or
  • put the device-side logic in a script that you push and execute.

Same rule for javac: always pass -encoding UTF-8 when sources contain non-ASCII, or the compiler reads them as the platform default and fails.