rea-reverse

Android CTF for Beginners: Reverse a Practice APK, Find the Flag

Written for REA 6.4.0Updated 9 min read
On this page

Short answer: most beginner Android CTF challenges are an app with a text box, a button and a check method. You solve them in four moves. First, triage the APK to find the entry Activity and see whether the code is obfuscated. Second, find the check by searching class and method names. Third, read the check in a decompiler. Fourth, reverse it with a short script and prove the flag in the app. This guide does all four on a small practice APK that you build yourself. It starts in the browser and finishes with JADX, Apktool and REA, the open-source project by morluto.

Who this is for: people who have solved a native “rev” challenge or two (or read CTF reverse engineering for beginners) and now face their first .apk. You don’t need Android development experience. You need a JDK, the Android SDK build tools if you want to build the APK, and optionally an emulator.

What you will learn:

  • The four kinds of beginner Android challenge, and where each one hides the flag
  • How to triage an APK in the browser without uploading it
  • How to use the DEX class browser to find the check, and what it can’t show you
  • How to read the check with JADX, Apktool or REA’s Android commands
  • How to turn the check into a solver and prove the flag on an emulator

Practice only on challenges you built, on challenges published for practice, or on apps you are authorized to analyze. Before running anything you downloaded, read REA’s DISCLAIMER.md.

Where Android challenges hide the flag

Beginner Android challenges come in four types, roughly in order of difficulty:

Type Where the flag lives First tool
Hardcoded flag A string constant in the DEX code strings, JADX
Resource string res/values/strings.xml, often a decoy Apktool, REA decode-android-resources
Simple check algorithm Encoded data plus a loop (XOR, Base64, a shift) Decompiler plus a solver script
Native library A .so file called through JNI A native disassembler such as Ghidra

The practice APK below combines the second and third types. It has a decoy flag in its resources and the real one behind Base64 and XOR. That’s a common way challenge authors punish people who stop at the first flag-shaped string.

Build the practice APK

Two Java files and a manifest are enough. Try not to read FlagChecker.java closely yet, since figuring it out is the exercise.

// src/org/example/ctfpractice/MainActivity.java
package org.example.ctfpractice;

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;

public class MainActivity extends Activity implements View.OnClickListener {
    private EditText input;
    private TextView result;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        LinearLayout root = new LinearLayout(this);
        root.setOrientation(LinearLayout.VERTICAL);
        input = new EditText(this);
        input.setHint(R.string.prompt);
        Button button = new Button(this);
        button.setText(R.string.check);
        button.setOnClickListener(this);
        result = new TextView(this);
        root.addView(input);
        root.addView(button);
        root.addView(result);
        setContentView(root);
    }

    @Override
    public void onClick(View v) {
        String guess = input.getText().toString().trim();
        result.setText(FlagChecker.check(guess) ? "Correct!" : "Nope.");
    }
}
// src/org/example/ctfpractice/FlagChecker.java
package org.example.ctfpractice;

import android.util.Base64;
import java.nio.charset.StandardCharsets;

final class FlagChecker {
    private static final String ENCODED = "Kig7OS4zOT8hIjUoBTs0PgU4Oyk/bG4FOyg/BTQ1LgU5KCMqLjUn";
    private static final int KEY = 0x5a;

    private FlagChecker() {}

    static boolean check(String guess) {
        byte[] expected = Base64.decode(ENCODED, Base64.DEFAULT);
        byte[] actual = guess.getBytes(StandardCharsets.UTF_8);
        if (actual.length != expected.length) return false;
        for (int i = 0; i < expected.length; i++) {
            if ((actual[i] ^ KEY) != expected[i]) return false;
        }
        return true;
    }
}
<!-- res/values/strings.xml -->
<resources>
    <string name="app_name">CTF Practice</string>
    <string name="prompt">Enter the flag</string>
    <string name="check">Check</string>
    <string name="hint">practice{not_the_real_flag}</string>
</resources>
<!-- AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="org.example.ctfpractice"
    android:versionCode="1"
    android:versionName="1.0">
    <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
    <application android:label="@string/app_name" android:allowBackup="false">
        <activity android:name=".MainActivity" android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Build it with the SDK’s build tools. No Gradle is needed:

BT=$HOME/Library/Android/sdk/build-tools/36.0.0
JAR=$HOME/Library/Android/sdk/platforms/android-36/android.jar
mkdir -p build/gen build/classes build/dex
$BT/aapt2 compile --dir res -o build/res.zip
$BT/aapt2 link -I $JAR --manifest AndroidManifest.xml --java build/gen -o build/unsigned.apk build/res.zip
javac --release 11 -cp $JAR -d build/classes build/gen/org/example/ctfpractice/R.java src/org/example/ctfpractice/*.java
$BT/d8 --release --min-api 24 --lib $JAR --output build/dex $(find build/classes -name '*.class')
(cd build/dex && zip -q ../unsigned.apk classes.dex)
$BT/zipalign -f -p 4 build/unsigned.apk build/aligned.apk
keytool -genkeypair -keystore practice.keystore -storepass android -keypass android \
  -alias practice -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=CTF Practice"
$BT/apksigner sign --ks practice.keystore --ks-pass pass:android --out ctf-practice.apk build/aligned.apk

I built it on macOS with build-tools 36.0.0 and JDK 17. The result was a 12,715-byte APK. Your signing key differs, so your APK’s SHA-256 will too. The DEX inside should be identical if you use the same d8. I set targetSdkVersion to 34 on purpose: with 35, Android 15 and later draw the app edge to edge, and this layout, which has no inset handling, ended up hidden under the title bar on my emulator.

Step 1: Triage the APK in the browser

Drop ctf-practice.apk into this site’s APK and AAB analyzer. It parses the file in your browser and doesn’t upload it. Three parts of the report matter for a CTF.

Components lists one activity, .MainActivity, exported, with an intent filter. That’s the launcher entry, so it’s where the user’s input arrives.

Code (DEX) has a table of DEX files. For this APK it showed:

File Version Size Classes Method refs Strings Checksum / SHA-1
classes.dex 037 3.1 KB 4 26 67 match match

Below it, the report said it read 1.7 KB of compressed DEX data (3.1 KB after inflating). Four classes is tiny. Real challenges are bigger, but often they’re still nowhere near the size of a production app.

Compiler markers (D8 / R8) shows the marker string that Google’s dexers write into the DEX:

~~D8{"backend":"dex","compilation-mode":"release","has-checksums":false,"min-api":24,"sha-1":"a7ad18a70460b799d0482e497c109a75bf7f91de","version":"8.10.9-dev"}

For a reverser this is the most useful line in the report. A ~~D8 marker with no ~~R8 marker means the code was dexed but not shrunk or obfuscated, so class and method names are the author’s own. The analyzer’s rule PROT-02 (“R8 optimization / obfuscation not enabled”) flagged it as advice for the same reason. If you see ~~R8 instead, expect classes named a, b and c, and plan to lean on strings and call references rather than names. With no marker at all, PROT-02 falls back to counting one- and two-letter class names, so read its evidence line before drawing conclusions.

Step 2: Find the check in the class browser

Under Code (DEX), open Classes and methods. It shows a package tree (org, then example, then ctfpractice) with every class, its superclass, and its fields and methods with access flags. The filter box matches class, method and field names, case-insensitively. Here is what a few typical CTF searches returned on this APK:

Filter Matching classes What was listed
flag 1 org.example.ctfpractice.FlagChecker, all members
check 2 FlagChecker (by name), and the check field of R$string
onClick 1 MainActivity, only onClick(View): void
encoded 1 FlagChecker, only the ENCODED field
equals 0 nothing

Expanding FlagChecker showed:

FlagChecker  final · classes.dex
Fields (2)
  private static final ENCODED: String
  private static final KEY: int
Methods (2)
  private constructor <init>(): void
  static check(String): boolean

That’s the whole challenge in outline: a static string, an int key, and a method that takes the guess and returns a boolean. MainActivity (extends android.app.Activity) has onClick(View): void, which is where a button press lands.

It’s just as important to know what the browser doesn’t show. It lists names, not values, so you see that ENCODED exists but not its contents. It indexes only classes defined in the APK, which is why equals found nothing: a call to String.equals is a reference to a framework method, not a member of an app class. It doesn’t decompile code. The analyzer’s own string-pool scan looks only for a fixed set of patterns (APK download URLs, install intents and the like), so it won’t print a flag either. You need a decompiler for the next step.

Step 3: Check the resources, and spot the decoy

Resources are quick to check and sometimes hold the flag outright. Apktool decodes them:

java -jar apktool_3.0.3.jar d -o out/apktool ctf-practice.apk

Through REA, decode-android-resources runs apktool d --no-src for you and returns the strings as data. Point REA_APKTOOL_COMMAND at an Apktool launcher first:

rea decode-android-resources /abs/ctf-practice.apk --json
"strings": [
  { "name": "app_name", "value": "CTF Practice" },
  { "name": "check", "value": "Check" },
  { "name": "hint", "value": "practice{not_the_real_flag}" },
  { "name": "prompt", "value": "Enter the flag" }
]

hint looks like a flag. Before you submit it, check whether any code compares against it. The class browser already answered that: R$string.hint is a resource ID, and FlagChecker has its own ENCODED field. Entering this string in the app printed Nope., as shown in Step 5.

Step 4: Read the check with JADX or REA

JADX turns the DEX back into Java:

jadx -d out/jadx ctf-practice.apk

JADX 1.5.6 wrote FlagChecker.java, MainActivity.java and R.java. The check method:

static boolean check(String str) {
    byte[] bArrDecode = Base64.decode(ENCODED, 0);
    byte[] bytes = str.getBytes(StandardCharsets.UTF_8);
    if (bytes.length != bArrDecode.length) {
        return false;
    }
    for (int i = 0; i < bArrDecode.length; i++) {
        if ((bytes[i] ^ 90) != bArrDecode[i]) {
            return false;
        }
    }
    return true;
}

The field declaration in the same file gives the value: ENCODED = "Kig7OS4zOT8hIjUoBTs0PgU4Oyk/bG4FOyg/BTQ1LgU5KCMqLjUn". JADX inlined the constant KEY as decimal 90. Apktool’s smali for the same loop shows it in hex, xor-int/lit8 v3, v3, 0x5a, which is a good cross-check when a decompiler’s output looks odd.

If you work with an AI agent, REA 6.4.0 exposes the same steps as commands that return Evidence bound to the APK’s SHA-256. They need a full JDK 17+ and the jadx-headless-mcp 0.7.1 JAR in REA_JADX_MCP_JAR. The JADX MCP setup guide covers both. I ran:

rea inspect-android-package /abs/ctf-practice.apk --json
rea search-android-classes /abs/ctf-practice.apk Flag --json
rea inspect-android-method /abs/ctf-practice.apk org.example.ctfpractice.FlagChecker check --json
rea trace-android-references /abs/ctf-practice.apk org.example.ctfpractice.FlagChecker --method-name check --json

The package call took about 3 seconds. It reported org.example.ctfpractice, min_sdk 24, no permissions, class_count 3 and signature_verification: "not_performed". JADX counts top-level classes, so R$string sits inside R, while the browser lists all four. search-android-classes is a case-sensitive substring search over class names: Flag matched org.example.ctfpractice.FlagChecker. The method call returned the same Java as above, with representation: "java", fell_back: false and overload_count: 1. The trace answered “who calls the check?”:

"references": [
  {
    "kind": "method",
    "reported_full_name": "org.example.ctfpractice.MainActivity.onClick",
    "source_line": null,
    "dex_offset": null
  }
]

That confirms the path from the button to the check without reading MainActivity at all. On a larger challenge, where the check might be reached through several layers, this is the command that saves time. inspect-android-class lists the fields ENCODED and KEY too, but like the browser it gives names and types, not values.

Step 5: Solve it and prove it

The check requires (input[i] ^ 0x5a) == decoded[i] for every byte, and the lengths must match. XOR undoes itself, so each input byte is decoded[i] ^ 0x5a:

import base64

ENCODED = "Kig7OS4zOT8hIjUoBTs0PgU4Oyk/bG4FOyg/BTQ1LgU5KCMqLjUn"  # FlagChecker.ENCODED
KEY = 0x5A  # JADX shows 90; smali shows xor-int/lit8 ... 0x5a

decoded = base64.b64decode(ENCODED)
print(bytes(b ^ KEY for b in decoded).decode())
practice{xor_and_base64_are_not_crypto}

A computed flag isn’t a flag until the app accepts it. I installed the APK on an Android 16 arm64 emulator and launched it:

adb install ctf-practice.apk
adb shell am start -n org.example.ctfpractice/.MainActivity

I typed the flag with adb shell input text and tapped Check. A uiautomator dump of the screen then read Correct! under the button. As a negative control I entered the resource decoy, practice{not_the_real_flag}, and got Nope.. Without an emulator, the solver alone is enough proof for a check this simple, because it inverts every condition in the method you just read.

When the flag is in a native library

Some Android challenges move the check into C. Signs of this: the analyzer’s native library table lists lib/<abi>/*.so files, and the class browser shows a method with the native modifier. In JADX that method has no body, and the class calls System.loadLibrary. From there it’s a native reversing problem. Pick the .so for one ABI (arm64-v8a is usually easiest to read), look for exported functions named Java_<package>_<class>_<method>, and continue with REA and Ghidra or the ELF viewer. The workflow from CTF reverse engineering for beginners applies unchanged.

Common beginner mistakes

  • Submitting the first flag-shaped string. Resource strings and unused constants are classic decoys. Find the code that does the comparison.
  • Expecting names to survive. With an ~~R8 marker, search for the strings shown on screen (Correct!, Wrong), then trace references back to the check.
  • Trusting one decompiler view. If JADX output looks wrong or carries a warning comment, compare the smali from Apktool.
  • Running downloaded challenges on your own phone. Use an emulator, and a snapshot you can roll back.

FAQ

Do I need Android Studio for Android CTFs?

No. To analyze you need JADX or Apktool and a JDK. To build a practice APK you need the SDK build tools (aapt2, d8, zipalign, apksigner) and a platform android.jar. An emulator from the SDK, or a spare device you own, is only needed for dynamic checks.

Can the browser analyzer decompile the APK?

No. It parses the manifest, signing, native libraries and DEX structure, including the class and method browser, but it doesn’t produce Java. Use it to find where to look, then open that class in JADX.

Why does JADX show 90 when the source says 0x5a?

They’re the same number. The compiler inlines static final primitive constants, and JADX prints the literal in decimal. The field declaration keeps the original value too.

Where can I find more Android practice challenges?

OWASP MAS Crackmes, also known as the UnCrackable Apps, are mobile reverse engineering challenges from the OWASP Mobile Application Security project. As of 2026-10-14, GitHub lists the repository’s licence as LGPL-3.0. For picoCTF, from Carnegie Mellon University, note the rebrand: as of 2026-10-14, picoctf.org announces that “picoCTF.org is now CyLab Security Academy”. Hack The Box offers challenges across categories including reversing, and crackmes.one hosts user-submitted crackmes, which you should run only in a VM. Follow each platform’s rules.

Next steps