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
| Stage | Question | Output |
|---|---|---|
| Recon | What app is this? | Identity + protections + framework notes |
| Decompile | What does it do? | decompiled/ (jadx Java) + smali/ (apktool) |
| Hunt | Where is the check? | Smali-verified target (class, method, instruction sequence) |
| Write | How to bypass it? | Fingerprints.kt + *Patch.kt under patches/src/main/kotlin/com/zeldrisho/patches/<app>/ |
| Test | Does 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:
aapt dump badging <apk>— package, version, versionCode, SDK levels, label, launch activity.aapt dump xmltree <apk> AndroidManifest.xml | rg -i 'split|requiredSplit'— split-APK detection. For.apkm/.xapk, extractbase.apkto a temp dir first and runaapton that.uvx apkid <apk>— compiler, obfuscator, packer, anti-debug, anti-VM (per DEX / lib).unzip -l <apk> | rg '\.dex'— DEX count.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 viaandroidx.compose/kotlin_moduleDEX strings —apk_recon.pydoes all of this automatically).- Record native-lib architectures and notable permissions (billing, internet, etc.).
- 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
jadxthere 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.javafiles were produced.- Always extract smali from all DEX files; the class you need is often in
classes2.dexor later, notclasses.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.javafiles 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. - Ads —
load/show/initializeentry 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.
- 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. - 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.
- 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.
- 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.
- 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.
- 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:
- Find the smali file across all DEX dirs:
find <analysis>/smali -name '<ClassName>.smali'. - Read the exact method:
rg -B 2 -A 50 '\.method.*<methodName>' <file>. - 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. - If Java and smali disagree, trust smali.
- 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 (
addInstructionsoverride, 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
- Establish a stock control and record the exact version, ABI, input hash, and native-library hash.
- Isolate the failure in stages: library loading, constructors, entrypoint, helper calls, and finally the failing predicate.
- Preserve registration, TLS, JNI environment setup, cleanup, and normal error paths. Do not replace an entire initializer when only one dispatch is faulty.
- Use a version/ABI-gated raw-resource patch with an exact original-byte guard. Fail closed when the library, offset, or surrounding instructions differ.
- 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.