Skip to main content

June 5, 2026

Android APK Reverse Engineering for CTF: From .apk to Flag

Android APK reversing for CTF: an APK is a ZIP and Dalvik bytecode decompiles to Java. Walk the picoCTF droids ladder from a logcat flag to a smali patch.

An APK is a ZIP, and that ZIP is full of Java

Hello! I want to talk you out of being scared of Android challenges.

Here is the thing that took me embarrassingly long to internalize: an APK (Android Package Kit, the file you install on a phone) is just a ZIP archive. Not "like" a ZIP. It is a ZIP. You can rename it, you can unzip it, and the files spill out onto your disk with no Android device anywhere in sight.

unzip app.apk -d app_extracted/

Inside you get classes.dex (the compiled code), AndroidManifest.xml (the app's declaration of what it is and what it's allowed to do), resources.arsc and a res/ folder (strings, images, layouts), an assets/ folder (raw bundled files), and lib/ (native code, if any). That one classes.dex holds every Java class the app ships, packed into Dalvik bytecode (the instruction format Android's runtime executes).

And here is the part that makes Android the gentlest reversing target you will meet in a CTF. Dalvik bytecode decompiles back to readable Java. Not pseudocode, not a wall of x86 with the variable names sanded off. Java, with method names and string literals mostly intact. The decompiler hands you something close to the source the developer wrote. If you have fought a stripped C binary in Ghidra or chased a function past ASLR in GDB, this is going to feel like a vacation.

My understanding of the whole skill, after grinding through picoCTF's Android set, is that it collapses into a short three-rung ladder:

  1. Read what the app already wrote down (logs, resources, metadata).
  2. Read the decompiled Java to recover a password or follow the logic.
  3. When reading isn't enough, patch one line of bytecode and re-sign the APK.

picoCTF lays this ladder out for you almost on purpose. The droids series runs from droids0 through droids4, and each one adds exactly one new idea. Here is the whole map before we climb it.

ChallengeWhat protects the flagMain toolThe move
droids0Nothing. It's in the log.adb logcatPress the button, watch the log stream
droids1A password in a string resourcejadxRead strings.xml, type the password
droids2A password built by array mathjadxEvaluate the index arithmetic by hand
droids3The right method is never calledapktool + apksignerPatch one smali call, rebuild, re-sign
droids4Logic pushed into native code (no Java)Frida + GhidraHook the check at runtime (rung four, below)
The decompiler does the hard part for you. The skill you're actually building is knowing where in the app to look, and how to get a modified APK back onto the device.
Note: Setup, once: install a JDK (Java 11+, since jadx and apktool are Java tools), and install Android Studio, which gives you the Android Debug Bridge (adb, the command-line link to a device), one Android Virtual Device (AVD, the emulator), and the SDK build-tools that contain apksigner, zipalign, and keytool. Add the build-tools folder to your PATH. The good news: most of rung one and all of rung two need no device at all, just the unzipped files. You only fire up the emulator to type a password in or watch a patched app run.

Rung one: the flag is sitting in the logs or the resources

The first reflex of every reverse engineer applies here too: before you decompile anything, check whether the app simply left the flag lying around. Apps leak data in three boring, reliable places, and a real share of Android CTF challenges never make you go past them.

The system log. droids0 shows a button that says "not today" when you press it. Rude, and also a lie. The flag is written to logcat (Android's system log stream) with a normal Log.d() call. Nothing shows on screen because the developer logged it instead of displaying it. Install the app, start watching the log, press the button:

# start the AVD from Android Studio's Device Manager first, then:
adb install droids0.apk
# tap the app's icon in the emulator's launcher to open it,
# then in a terminal watch the log while you press the button:
adb logcat | grep picoCTF

The flag scrolls past. This is not a contrived puzzle, by the way. Logging a secret to logcat is a real, common mistake. Any app with the READ_LOGS permission, and anyone with adb access, can read it. droids0's whole point is to teach you to look at the log before you do anything clever.

The resources. Remember that an APK is a ZIP. mob-psycho (picoCTF 2024) hides its flag in res/color/flag.txt, and you never need a phone to find it. Unzip and grep the resource tree:

unzip mobpsycho.apk -d mobpsycho_dir && cd mobpsycho_dir
find res -type f -exec strings {} + | grep -i flag
# the file holds continuous hex; decode it:
xxd -r -p res/color/flag.txt

The file is a plain hex dump, and xxd -r -p reverses it back to ASCII (the Hex Dumps guide covers that exact pattern). No emulator, no decompiler, just shell tools on an unzipped archive.

The build metadata. timer (picoCTF 2023) looks like it needs a deep dive, and it does not. Android's build system bakes your app version into a generated class called BuildConfig. This app's flag is the VERSION_NAME constant. You can dig it out with apktool, or skip the tooling entirely and run strings on the raw dex:

strings classes3.dex | grep -i picoCTF
# or, properly:
apktool d timer.apk && grep -rn picoCTF timer/
Tip: mob-psycho and timer sit on the same rung as droids0. They're not harder, they just hide the flag in a different boring place: log, resource file, version string. Train yourself to sweep all three before you reach for jadx.

I am, honestly, a little fond of how dumb this rung is. The first time I solved droids0 I had spent twenty minutes setting up an emulator to fight a hard reversing problem, and the answer was "read the log you were already printing." Mobile security has a lot of that energy.

Rung two: jadx turns the app back into Java

When the flag isn't lying around, you read the code. The tool for that is jadx (a decompiler that takes Dalvik bytecode straight to Java), and it is the single most useful tool in your Android kit. For nearly every Android CTF, jadx is where you start.

# install (pick one)
brew install jadx # macOS
sudo apt install jadx # Debian/Ubuntu
 
# command-line: decompile to a folder
jadx droids1.apk -d droids1_java/
 
# or the GUI, which is genuinely nicer for browsing:
jadx-gui droids1.apk

Open the GUI and the app's classes show up in a tree you can search. For these challenges the interesting class is always the one wired to the button you press, usually MainActivity or a helper next to it.

droids1 is the simplest version. The button handler compares your input against a string it fetches with getString(R.string.password). That's a resource lookup, so the actual password lives in res/values/strings.xml, fully readable. jadx shows you both the comparison and the resource. Read the password, type it into the emulator, get picoCTF{pining.for.the.fjords}.

droids2 is where it gets fun, because the password isn't stored anywhere. It'scomputed. The class is called FlagstaffHill, and its getFlag method declares a six-element string array of witch names (weatherwax, ogg, garlick, nitt, aching, dismass), then builds the expected password by selecting elements through index arithmetic:

String[] witches = {"weatherwax", "ogg", "garlick",
"nitt", "aching", "dismass"};
int first = 3;
int second = 3 - 3; // 0
int third = (3 / 3) + second; // 1
// ... concatenated with dots into the password

You don't run anything. You read the arithmetic and evaluate it by hand, which is the entire exercise: can you follow decompiled Java closely enough to predict its output? Work the indices and the password falls out as dismass.ogg.weatherwax.aching.nitt.garlick. Type it in, and the app hands over picoCTF{what.is.your.favourite.colour}.

Note: If jadx ever shows you // decompilation failed, the app is obfuscated or aggressively optimized. Don't panic, switch to the Smali tab in the same window. You lose readability but you never lose access, because smali always disassembles even when Java decompilation gives up. That fallback is exactly what rung three is built on.

One opinion before we climb higher: jadx is so good that it makes the next two rungs feel harder than they are. Once you're used to getting clean Java for free, dropping down to bytecode feels like a punishment. It isn't. It's a one-line edit.

Rung three: patch one line of smali

droids3 is the first challenge where reading isn't enough. The app always returns "don't wanna" no matter what you type, and no password exists to find. Decompile it in jadx and you see why. FlagstaffHill.getFlag() calls a method named nope(), which returns the brush-off string. There is a second method, yep(), that calls a native function (cilantro()) and returns the real flag. The app just never calls it.

So you make it. You can't edit Java back into an APK, but you can edit smali (the human-readable text form of Dalvik bytecode), reassemble it, and reinstall. For that you switch from jadx to apktool (it decodes resources and disassembles the dex to smali, then rebuilds the lot):

# read it in jadx first to find what to change, then:
apktool d droids3.apk -o droids3_src
# edit the smali, then rebuild:
apktool b droids3_src -o droids3_patched.apk

The line you need is in droids3_src/smali/com/hellocmu/picoctf/FlagstaffHill.smali. You are looking for one instruction. Here is the only smali pattern you need to learn for this whole class of challenge:

# before:
invoke-static {p0}, Lcom/hellocmu/picoctf/FlagstaffHill;->nope(Ljava/lang/String;)Ljava/lang/String;
move-result-object v0
 
# after (one word changed):
invoke-static {p0}, Lcom/hellocmu/picoctf/FlagstaffHill;->yep(Ljava/lang/String;)Ljava/lang/String;

Let me decode that line, because once it clicks, smali stops being scary:

  • invoke-static calls a static method (no object instance needed).
  • {p0} is the argument register. Parameters are p0, p1, ...; local variables are v0, v1, ....
  • Lcom/hellocmu/picoctf/FlagstaffHill;->nope(...) is the method reference. Smali writes type names in a compact form: a leading L means "this is a class," the trailing ; ends the name, and slashes replace the dots, so Lcom/hellocmu/picoctf/FlagstaffHill; is just the class com.hellocmu.picoctf.FlagstaffHill. After it, -> points at the method, and the parts in parentheses are the argument and return types in that same form. Ljava/lang/String; is String, so this method takes a String and returns a String.
  • move-result-object v0 stashes the returned object in v0.

Because nope and yep have the identical signature (String in, String out), swapping the name is a safe edit. Everything downstream still type-checks. A one-character difference (well, two) redirects the app from the decoy to the real thing. You can make the change by hand in an editor or with a surgical sed:

sed -i 's/->nope(Ljava\/lang\/String;)/->yep(Ljava\/lang\/String;)/' \
droids3_src/smali/com/hellocmu/picoctf/FlagstaffHill.smali
You did not break the app's logic. You re-routed one method call. That is the entire idea behind smali patching, and it scales from CTF crackmes to real license-check bypasses.

Rebuild with apktool b and you have a patched APK. Now comes the step that is going to lie to you about whether you succeeded.

Warning: Before you try to install that patched APK: it is currently unsigned, and Android will refuse it. This is the wall the next section is about. If you followed an older tutorial that signs with jarsigner, it will look like it worked and then fail to install. Read on before you blame your patch.

INSTALL_PARSE_FAILED_NO_CERTIFICATES: re-signing is where everyone gets stuck

Here is the thing that cost me the most time in all of Android reversing, and it has nothing to do with reversing. Android refuses to install an APK that isn't validly signed. When you rebuilt with apktool, you stripped the original signature, so your patched APK is unsigned. The package manager rejects it, often with this exact error:

adb: failed to install droids3_patched.apk:
Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES: ...
No signature found in package of version 2 or newer]

Read that last line carefully, because it is the whole trap: "version 2 or newer." Android has several signature schemes. The original is v1 (JAR signing, which signs files inside the ZIP). v2 (Android 7.0) hashes the whole APK as one blob; v3 (Android 9) adds key rotation. And here is the part the tutorials never updated: since Android 11 (API level 30), a v1-only signature is rejected at install.

People think they botched the bytecode patch. They actually botched the signature. The app never gets the chance to run, so the patch is never even tested.

This matters because the most-copied re-signing recipe on the internet, including older picoCTF writeups, uses jarsigner, which produces a v1-only signature. On an older emulator it worked. On a modern one it produces exactly the error above, and you sit there re-checking your smali edit when the smali was fine the whole time. The fix is to stop using jarsigner and use apksigner (the modern signer that ships in the Android SDK build-tools), with one rule about ordering.

The rule: zipalign first, then sign. apksigner's own documentation is blunt about it, "if you use zipalign, use it before signing." Aligning after signing invalidates the signature. zipalign (also in build-tools) aligns the archive so Android can map it efficiently. Here is the block worth saving forever:

# 1. make a throwaway keystore once (any passwords are fine):
keytool -genkeypair -v -keystore debug.keystore \
-alias androiddebugkey -keyalg RSA -keysize 2048 \
-validity 10000 -storepass android -keypass android
 
# 2. align BEFORE signing:
zipalign -p -f 4 droids3_patched.apk droids3_aligned.apk
 
# 3. sign with apksigner (adds a v2/v3 signature):
apksigner sign --ks debug.keystore \
--ks-pass pass:android droids3_aligned.apk
 
# 4. verify, then install:
apksigner verify droids3_aligned.apk
adb install -r droids3_aligned.apk

If you would rather not think about any of this, uber-apk-signer wraps the whole dance, it zipaligns, signs with a built-in debug key, and verifies, in one command:

java -jar uber-apk-signer.jar --apks droids3_patched.apk

Install the signed APK, press the button, and droids3 finally returns picoCTF{tis.but.a.scratch} from the native code it was hiding all along. The flag was never the hard part. The signature was.

Key insight: If your patched APK won't install on a modern emulator, suspect the signature before the patch every single time. Sign with apksigner (zipalign first) or uber-apk-signer, never jarsigner alone. This one fact will save you more hours than any decompiler trick in this post.

When the code hides: go dynamic with Frida

droids4 is the top of the ladder, and it breaks the comfortable pattern. You decompile it in jadx and the interesting logic isn't there, it has been pushed into a native .so library under lib/. Native code doesn't decompile to Java; it's ARM machine code, the kind you'd open in Ghidra. Static analysis still works, but it's slow, and there's a faster way to ask the app what it's doing: watch it do it.

That's dynamic analysis, and on Android the tool is Frida (it injects a JavaScript engine into a running process so you can intercept any function call live). The Frida guide covers the tool in depth; the Android-specific setup is a rooted emulator plus a matching-architecture frida-server binary pushed to the device:

# match the device ABI, then push and run frida-server as root:
adb shell getprop ro.product.cpu.abilist
adb push frida-server /data/local/tmp/ && adb shell \
'/data/local/tmp/frida-server &'
 
# auto-trace every method that looks like a check:
frida-trace -U -f com.hellocmu.picoctf -j '*!*check*'

frida-trace generates a handler for each matching method and prints calls as they happen, so you can watch arguments and return values flow through the comparison without ever reversing the native function fully. Hook the method that receives the flag string, read it off, done. droids4 gives up picoCTF{not.particularly.silly}.

The reason this rung matters past CTF is that the same hook defeats the two "protections" mobile apps lean on hardest, and that's where I get a little opinionated.

  • Certificate pinning. An app pins the exact server certificate it expects so a proxy's man-in-the-middle cert gets rejected and you can't inspect its traffic in Burp. A Frida script hooks the validation path (the TrustManager or OkHttp's CertificatePinner) and makes every check pass. Drop-in scripts like httptoolkit's frida-multiple-unpinning handle most apps with no custom work.
  • Root detection. An app probes for the su binary or Magisk and refuses to run on a rooted device. Frida hooks the isRooted()-style method and forces it to return false. The app believes it's on a clean phone.

Both of those are serious-looking defenses that fold to a few lines of JavaScript. objection packages them so you don't even write the script: objection -g com.app explore drops you in a REPL, and android sslpinning disable neutralizes pinning in one line.

Note: frida-server needs root. If you can't root the target, the alternative is frida-gadget: a shared library you repackage into the APK (then re-sign it, using the section above). Instrumentation runs without elevated privileges, which is handy precisely because you've already learned to patch and re-sign.

Quick reference

The whole ladder, as commands you can paste:

# rung one: is it just lying around?
adb logcat | grep -i picoCTF # the log
unzip app.apk -d out/ && grep -rai flag out/ # the files
strings out/classes.dex | grep -i picoCTF # the dex
 
# rung two: read the Java
jadx-gui app.apk # browse classes
jadx app.apk -d app_java/ # or dump to disk
 
# rung three: patch the bytecode
apktool d app.apk -o app_src # disassemble to smali
# edit app_src/smali/.../Target.smali
apktool b app_src -o patched.apk # reassemble
 
# re-sign (the part everyone forgets):
zipalign -p -f 4 patched.apk aligned.apk
apksigner sign --ks debug.keystore --ks-pass pass:android aligned.apk
adb install -r aligned.apk
 
# rung four: go dynamic
frida-trace -U -f com.app -j '*!*check*'
Tip: Tool versions as of June 2026, in case a command drifts: jadx 1.5.5, apktool 3.0.2, uber-apk-signer 1.3.0, objection 1.12.5. apksigner and zipalign come from the Android SDK build-tools.

That's the entire skill. Read what the app wrote down, read the Java the decompiler gives you for free, patch one line of bytecode when you have to, and drop to Frida when the logic hides in native code. The bytecode was never the hard part, and now you know the one place (the signature) where it's easy to waste an afternoon.

So go rename an .apk to .zip and open it up. It's just a ZIP with Java inside. I still think that's a little ridiculous, and I've never stopped finding it useful.

Sources and further reading

Sources and further reading

An APK is a ZIP with a documented layout, and every file inside it has a published format. Nothing here needs guessing.

  • The DEX format specification documents the string, type, and method identifier tables. Like a Java class file, a DEX keeps names and string literals in readable pools, which is why strings on classes.dex so often finds the flag before any decompiler runs.
  • The manifest reference tells you the entry point, the exported components, and the debuggable flag. Reading it first tells you which activity actually runs.
  • APK signing explains why a repackaged APK must be re-signed before it installs, and Frida is the alternative when patching is harder than hooking at runtime.

Keep reading

Guides that build on the same ideas, plus the roadmap this topic sits under.