Reverse engineering workflow

September 17, 2026 · View on GitHub

How to go from an APK file to a working patch in this repo. Fingerprint authoring is covered in the patch development guide.

Pipeline

RECON → DECOMPILE → HUNT → WRITE → TEST
StageQuestionOutput
ReconWhat app is this?Identity + protections + framework notes
DecompileWhat does it do?decompiled/ (jadx Java) + smali/ (apktool)
HuntWhere is the check?Smali-verified target (class, method, instruction sequence)
WriteHow to bypass it?Fingerprints.kt + *Patch.kt under patches/src/main/kotlin/com/zeldrisho/patches/<app>/
TestDoes it match?./gradlew buildAndroid, then apply the .mpp in Morphe

Analysis work lives in this repo's gitignored analysis/ scratch workspace. Use the canonical analysis/<app>/<version>/ layout documented in analysis.md, with apk/, decoded/, decompiled/, smali/, mapping/, notes/, and runs/ used for their respective artifact types. Never commit analysis inputs or outputs. In the commands below, <analysis> means this repository's analysis/<app>/<version>/ directory (use its absolute path or run relative paths from the repo root).

Tools

See toolchain setup for the complete inventory and install commands, including fish PATH setup and the uv tool versus uvx decision. The Morphe CLI applies .mpp bundles; scripts/repatch.py finds the Morphe JAR in its standard locations with no setup.

scripts/apk_recon.py wraps the recon step (archive metadata, selected framework markers, native-library paths, aapt metadata, and optional apkid output); scripts/extract_smali.py wraps the DEX → smali step (including split .apkm/.xapk handling); scripts/hunt_signals.py <decompiled|smali> counts protection/billing/ads/Ktor/Koin signals in one pass before hunting; scripts/recover_kotlin_names.py <decompiled> rebuilds obfuscated → real Kotlin class names from @DebugMetadata/@Metadata.

Recon

Get the original split bundle only from APKMirror. Record the download page URL and input SHA-256 alongside versionCode and ABI. Run scripts/apk_recon.py:

python3 scripts/apk_recon.py <analysis>/apk/<app>_<version>.apkm

Manual equivalent:

  1. aapt dump badging <apk> — package, version, versionCode, SDK levels, label, launch activity.
  2. aapt dump xmltree <apk> AndroidManifest.xml | rg -i 'split|requiredSplit' — split-APK detection. For .apkm/.xapk, extract base.apk to a temp dir first and run aapt on that.
  3. uvx apkid <apk> — compiler, obfuscator, packer, anti-debug, anti-VM (per DEX / lib).
  4. unzip -l <apk> | rg '\.dex' — DEX count.
  5. unzip -l <apk> | rg 'index.android.bundle|libflutter|libapp' — framework: index.android.bundle = React Native, libflutter.so/libapp.so = Flutter, assets/www|public/ = Cordova/Capacitor, libmonodroid.so|assemblies/ = Xamarin/MAUI, else native (Compose vs Kotlin distinguished via androidx.compose / kotlin_module DEX strings — apk_recon.py does all of this automatically).
  6. Record native-lib architectures and notable permissions (billing, internet, etc.).
  7. Note HTTP/DI/billing stack signals from the recon report (Retrofit/OkHttp/Ktor/Apollo, Hilt/Koin, RevenueCat/Adapty/Play Billing) — they pick the hunt patterns in Hunt targets.

Save as <analysis>/notes/recon.md (rename the APK to <app>_<version>.<ext>).

Decompile

jadx -d <analysis>/decompiled <analysis>/apk/<app>_<version>.apkm
python3 scripts/extract_smali.py <analysis>/apk/<app>_<version>.apkm <analysis>/smali

JADX escalation for difficult classes

JADX is a navigation/decompilation aid, not the source of truth. When a class or method is missing or reconstructed incorrectly, retry only the relevant input with progressively less reconstruction:

jadx --single-class 'com.example.Target' <analysis>/<app>/apk/<app>_<version>.apkm
jadx --decompilation-mode simple --no-inline-methods <analysis>/<app>/apk/<app>_<version>.apkm
jadx --decompilation-mode fallback --single-class 'com.example.Target' \
  <analysis>/<app>/apk/<app>_<version>.apkm

Check jadx --help first because options vary by installed version. These outputs are for locating callers and strings only; verify the final target in smali from every DEX. --raw-cfg and --call-graph json are optional aids when control flow or callers remain unclear.

Remote decompilation for large APKs

Local jadx can OOM on large APKs:

KAGGLE_API_TOKEN=... KAGGLE_KERNEL_ID=user/jadx-apk-decompiler \
  python3 scripts/remote_decompile.py "<direct-apk-url>" <analysis>/
cd <analysis> && unzip *_decompiled.zip -d decompiled/

Needs the kaggle CLI plus a private Kaggle notebook with internet access. The URL must be a direct download link (mirror links expire in ~1 hour — use a fresh one).

Notes:

  • If you have a big machine handy, local jadx there works too — the rest of the workflow only needs the files copied back.
  • "finished with errors" from jadx is normal for obfuscated apps. Continue as long as .java files were produced.
  • Always extract smali from all DEX files; the class you need is often in classes2.dex or later, not classes.dex.

Hunt targets

Search in a fixed order — protections first, because an integrity/root check will break testing of everything else. Start with a one-pass triage:

python3 scripts/hunt_signals.py <analysis>/decompiled [--files]

scripts/hunt_signals.py is the canonical pattern list. The buckets below summarize intent only; read the script for exact expressions. When a pattern changes, update the script first, then the recipe that motivated the change in bypass patterns.

  • BuildConfig sweep (usually not obfuscated — base URLs, flavors, keys): search BuildConfig.java files for base URLs, flavors, and API keys. Read every hit; each Gradle module emits its own file.
  • Protections — integrity/license, signature verification, root, pinning.
  • Billing SDK detection — tells you which bypass recipe applies (RevenueCat, Adapty, Play Billing, LVL, local gates, remote config).
  • SDK-specific search — entitlement chains, purchase queries, or local isPremium-style fallbacks.
  • Adsload/show/initialize entry points. SDK class names are stable; app class names are not.
  • Feature gates — remote-config and feature-flag reads.
  • Modern Kotlin stacks (when Retrofit patterns miss — KMP/Kotlin-only apps): Ktor, Apollo, Koin, and request-signing signals.

Learning from other patch projects

Treat another project's hooks and symbol maps as candidate evidence, not a compatibility guarantee. Runtime-hook frameworks (such as LibXposed) and Morphe APK rewriting have different capabilities; transfer target knowledge and safety invariants rather than copying framework infrastructure.

  1. Record the reference repository revision and exact target profile. Compare like-for-like artifact hashes: a profile's base-APK hash must be compared with our extracted original base.apk, not the enclosing APKM or re-signed output. A matching versionCode alone is insufficient.
  2. Independently verify candidates in our input's smali. Record owner/signature, semantic anchors, field relationships, callers/consumers, intended mutation, and regression risks in local analysis. Reject ambiguous matches; heuristic scores and upstream verification labels do not replace evidence.
  3. Trace the state around the target. UI removal can require consistent lists, counts, indices, parallel arrays, adapters, and startup selection. Suppression should target specific branches or writes, preserving unrelated operations and unknown inputs rather than disabling whole subsystems.
  4. Borrow negative test cases as well as intended behavior: operational alerts and ordinary messages must survive notification filtering, for example. Do not silently import the reference project's broader feature scope.
  5. Distinguish fingerprint match, patch application, app launch, target-path execution, and observed behavior in the validation record. An installed hook or successful build does not prove the feature worked.
  6. Check licensing before copying code; retain required notices for copied substantial portions. Remote catalogs, settings, recording, and diagnostics infrastructure require separate scope decisions, not automatic adoption.

Apply these principles when investigating any app's local-data patch:

  • Execution context changes feasibility, not portability. An injected extension runs with the host app's permissions and can access its own app data without ADB. This does not grant access to another installation's sandbox or make encrypted files portable across devices, accounts, or reinstalls.
  • Prefer native machinery. Find existing backup/import/phone-transfer flows before designing a replacement. Verify entry points, prerequisites, callers, and restore ordering in smali and on-device; a hidden screen alone does not establish a working local export. New UI needs explicit scope approval under the standing rules.
  • Separate media from messages. External-file copies do not establish chat recovery or attachment associations. Trace databases, attachment references, consistent snapshot handling (including SQLite WAL), and key lifecycle. Android Keystore or device/account-bound keys can prevent raw-copy restoration; in-process file access alone is insufficient evidence.
  • Account for the first migration. A re-signed APK normally cannot update over stock. An in-app patch cannot recover stock private data after uninstall; initial migration needs a supported stock export/backup route. Distinguish stock-to-patched, patched-to-patched, and cross-device recovery claims.
  • Design for recovery before convenience. Export through a user-selected document destination outside app-owned storage so uninstall does not remove the backup. Preserve source data and recovery copies; validate archive paths, integrity, account/schema compatibility, and storage capacity before writes. Use bounded extraction and recoverable staging rather than wiping live data. Treat archives as sensitive; keep chat contents, credentials, and keys out of logs and committed analysis.
  • Prove a round trip. Test with disposable data: same-device reinstall, cross-device recovery if claimed, media-to-message associations, incompatible accounts/versions, corrupt archives, and interrupted transfers. Archive size, successful extraction, and app launch are not restoration proof. Do not change the existing reinstall order based only on an external utility's instructions.

Keep app-specific symbols and experimental results in gitignored analysis/; keep outstanding scope decisions in the plan, not a session transcript.

Recover Kotlin names for obfuscated Kotlin apps

R8 renames JVM symbols, but builds that keep @DebugMetadata/@Metadata strings leave a trail back to the original names. (Stripped or DexGuard-style builds may not; treat recovery coverage as best-effort.) Before tracing call flows, rebuild the real names:

python3 scripts/recover_kotlin_names.py <analysis>/decompiled <analysis>/mapping
# → mapping.tsv / mapping.json / by_package/

Use the mapping to find classes (never to match — fingerprints still anchor on SDK calls/strings/opcodes per the the fingerprint reference in patch development). jadx --deobf alone is not equivalent: it invents synthetic names instead of recovering the originals.

Obfuscation-resistant fallback: when call sites inline to a.b(c, "…"), grep the path literals themselves — R8 does not obfuscate string contents:

rg -o '"(/[A-Za-z0-9_{}.\-]+(/[A-Za-z0-9_{}.\-]+)+/?)"' <analysis>/decompiled -g '*.java'

Dynamic confirmation for runtime gates

Static locates, dynamic confirms. For runtime gates (pinning, root, signature, request signing), confirm the candidate actually runs before freezing a fingerprint. For pure static string gates (feature flags, manifest-gated ad SDK entry points), static smali evidence alone is acceptable; dynamic confirmation stays recommended but optional.

Prerequisites: USB debugging on, target device visible via adb, frida-server matching the device ABI running. On a non-rooted, non-debuggable Android build, ADB visibility alone is insufficient for Frida attach: use an early-loaded Gadget in a disposable repackaged build instead. An integrity check may terminate that build before an attached script runs; autonomous Gadget script mode or a rooted frida-server is required for earliest hooks. Keep instrumentation builds and signing keys outside the release pipeline. Stop with Ctrl-C (no device state is modified by the hooks below). Setup lives in toolchain setup.

adb devices && frida-ps -U                 # device + target process visible
frida -U -f com.target.app -l hook.js --pause   # spawn early, don't attach late

--pause leaves the main thread paused after spawning; omit it to let the app run immediately. If the installed Frida CLI reports a different flag set, check frida --help on that host before scripting it.

Minimal hooks (log first, mutate only after you see traffic):

function bytesToHex(b) { return Array.from(new Uint8Array(b)).map(x => ('0' + (x & 0xFF).toString(16)).slice(-2)).join(''); }
// Enable only for a disposable test account; never share raw capture logs.
const CAPTURE_CRYPTO_BYTES = false;
// Cryptographic metadata only by default.
Java.perform(function() {
  Java.use("javax.crypto.spec.SecretKeySpec").$init.overload('[B', 'java.lang.String')
    .implementation = function(k, a) {
      console.log("[key] algorithm=" + a + " bytes=" + k.length);
      if (CAPTURE_CRYPTO_BYTES) console.log("[key] raw=" + bytesToHex(k));
      return this.$init(k, a);
    };
  Java.use("javax.crypto.Cipher").doFinal.overload('[B')
    .implementation = function(b) {
      console.log("[cipher] algorithm=" + this.getAlgorithm() + " inBytes=" + b.length);
      if (CAPTURE_CRYPTO_BYTES) console.log("[cipher] in=" + bytesToHex(b));
      var r = this.doFinal(b);
      console.log("[cipher] outBytes=" + r.length);
      if (CAPTURE_CRYPTO_BYTES) console.log("[cipher] out=" + bytesToHex(r));
      return r;
    };
});
// OkHttp request / response (skip if okhttp3.* absent — R8-relocated; use TrustManager hooks instead)
Java.perform(function() {
  Java.use("okhttp3.RealCall").execute.implementation = function() {
    console.log("[http] " + this.request().method() + " " + this.request().url().toString());
    var r = this.execute(); console.log("[http] code=" + r.code()); return r;
  };
});
// Pinning triage: try one-click first, hand-roll only on failure (same command as below)

Order: uvx objection --gadget com.target.app explore -s "android sslpinning disable" → generic unpinning script (CertificatePinner.check + TrustManagerImpl.verifyChain + HostnameVerifier.verify) → hand-written hook for the app's exact class found in Hunt targets. If the app exits on inject, suspect anti-Frida (port/file self-check) — switch to frida-gadget/Zygisk rather than grinding more static patterns. Log the confirmed class/method/args into notes/<topic>.md alongside the smali quote; a fingerprint with static smali plus one dynamic observation outlives refactors that kill static-only guesses for runtime gates.

Smali verification is mandatory

Never trust JADX or third-party opcode tables alone — they can mis-decompile or misdescribe obfuscated code. The Android bytecode specification is authoritative for instruction formats and register limits. For every candidate:

  1. Find the smali file across all DEX dirs: find <analysis>/smali -name '<ClassName>.smali'.
  2. Read the exact method: rg -B 2 -A 50 '\.method.*<methodName>' <file>.
  3. Record: access flags, return type (the descriptor after ) in the method header), full parameter descriptors, register count, invoke sequence in order, and which DEX it came from.
  4. If Java and smali disagree, trust smali.
  5. Write the finding down (<analysis>/notes/<topic>.md) with the smali evidence quoted, plus a fingerprint strategy (which stable strings/calls to match on — see the the fingerprint reference in patch development). Unverified findings are not ready for patch-writing.

Write the patch

Covered in the the fingerprint reference in patch development and patch development. The handoff from hunting is:

  • Fully qualified class + exact smali method signature (smali verification, mandatory).
  • Ordered instruction sequence (invoke calls / const-strings).
  • Dynamic confirmation for runtime gates (Frida log of class/method/args, dynamic confirmation); static-only is acceptable for pure static gates, draft status otherwise.
  • Suggested bypass (addInstructions override, instruction replacement, etc.).

Test the patch

./gradlew buildAndroid

Check the patch is registered (list-patches in the Morphe CLI against patches/build/libs/patches-*.mpp), apply to the downloaded split bundle (never an extracted base.apk), install via adb install -r. If a fingerprint fails to match, go back to the hunt step and re-verify smali — the app version probably moved the code.

Native patching

Guidance for repository patches that modify native libraries inside split APKs.

Safe workflow

  1. Establish a stock control and record the exact version, ABI, input hash, and native-library hash.
  2. Isolate the failure in stages: library loading, constructors, entrypoint, helper calls, and finally the failing predicate.
  3. Preserve registration, TLS, JNI environment setup, cleanup, and normal error paths. Do not replace an entire initializer when only one dispatch is faulty.
  4. Use a version/ABI-gated raw-resource patch with an exact original-byte guard. Fail closed when the library, offset, or surrounding instructions differ.
  5. Test the smallest mutation first, then test it composed with the other patches and on a cold start.

Diagnostics and release

Diagnostic stubs and NOPs are evidence-gathering tools, not release patches; they may remove required initialization and create misleading secondary crashes. A release patch should change only the verified instruction and keep its original call context intact.

For split APKs, patch the native library in its owning ABI split and verify the resulting signed bundle, not just an extracted base.apk. Record the patch name, guarded byte pattern, ABI, signing result, runtime logs, foreground activity, and any remaining QA limitations.