rea-reverse

Android CTF 入门:逆向一道练习 APK 题,找出 flag

基于 REA 6.4.0更新于 约 8 分钟读完
本页目录

先说结论: 入门级的 Android CTF 题,大多是一个输入框、一个按钮,外加一个校验方法。解题分四步:先给 APK 做体检,找到入口 Activity,看代码有没有混淆;再按类名、方法名找到校验逻辑;然后用反编译器读懂它;最后写个小脚本反推输入,并回到 App 里确认 flag 正确。本文用一道自己编译的练习 APK 把这四步完整走一遍,从浏览器开始,最后用到 JADX、Apktool,以及 morluto 的开源项目 REA。

适合谁读: 做过一两道原生程序逆向题(或者读过 CTF 逆向入门),第一次碰到 .apk 题目的同学。不需要 Android 开发经验。要准备 JDK;想自己编译 APK 的话还要装 Android SDK 的 build-tools;模拟器可有可无。

你会学到:

  • 入门安卓题的四种常见题型,以及 flag 分别藏在哪
  • 怎么在浏览器里给 APK 做体检(文件不上传)
  • 怎么用 DEX 类浏览器定位校验方法,以及它看不到什么
  • 怎么用 JADX、Apktool 或 REA 的 Android 命令读懂校验逻辑
  • 怎么把校验逻辑写成解题脚本,并在模拟器上验证 flag

请只在自己编译的题目、公开发布的练习题,或者已获授权分析的 App 上练习。运行任何下载来的文件之前,请先阅读 REA 的 DISCLAIMER.md。

安卓题的 flag 一般藏在哪

入门安卓题大致分四类,难度依次递增:

题型 flag 在哪 先用什么工具
硬编码 flag DEX 代码里的字符串常量 strings、JADX
资源字符串 res/values/strings.xml,经常是诱饵 Apktool、REA decode-android-resources
简单校验算法 编码后的数据加一个循环(XOR、Base64、移位) 反编译器 + 解题脚本
原生库 通过 JNI 调用的 .so Ghidra 等原生反汇编工具

下面这道练习题把第二类和第三类合在了一起:资源里放了一个假 flag,真 flag 藏在 Base64 和 XOR 后面。出题人很喜欢用这种方式“惩罚”看到第一个像 flag 的字符串就交的人。

编译练习 APK

两个 Java 文件加一个 Manifest 就够了。FlagChecker.java 先别细看,读懂它正是这道题要练的。

// 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>

不用 Gradle,直接用 SDK 自带的 build-tools 打包:

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

我是在 macOS 上用 build-tools 36.0.0 和 JDK 17 编译的,得到的 APK 有 12,715 字节。你的签名密钥和我的不同,所以 APK 的 SHA-256 会不一样;只要 d8 版本相同,里面的 DEX 应该一致。targetSdkVersion 特意设成 34:设成 35 的话,Android 15 及以上会强制全屏绘制(edge-to-edge),这个没处理窗口边距的界面在我的模拟器上被标题栏整个挡住了。

第 1 步:在浏览器里给 APK 做体检

把 ctf-practice.apk 拖进本站的 APK / AAB 分析工具。解析全程在浏览器本地完成,文件不会上传。做 CTF 时重点看报告里的三处。

组件:只有一个 activity .MainActivity,已导出,带 intent filter。它就是启动入口,用户输入从这里进来。

代码(DEX):DEX 文件表格里,这个 APK 显示为:

文件 版本 大小 类 方法引用 字符串 校验和 / SHA-1
classes.dex 037 3.1 KB 4 26 67 一致 一致

表格下面写着读取了 1.7 KB 的 DEX 压缩数据(解压后 3.1 KB)。只有 4 个类,非常小。真实的题目会大一些,但通常也远远比不上正式上架的 App。

编译器标记(D8 / R8):这里显示 Google 的 dex 编译器写进 DEX 的标记字符串:

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

对逆向来说,这是整份报告里最有用的一行。只有 ~~D8 而没有 ~~R8,说明代码只是转成了 DEX,没有经过压缩和混淆,类名、方法名都是作者原本起的。分析工具的 PROT-02 规则(“未开启 R8 优化 / 混淆”)也因此给出了一条建议。如果看到的是 ~~R8,就要做好心理准备:类名会变成 a、b、c,得更多依靠字符串和调用关系,而不是名字。如果一个标记都没有,PROT-02 会改为统计一两个字母的类名占比,下结论前先看它给出的依据。

第 2 步:用类浏览器找到校验方法

在“代码(DEX)“下展开类和方法。这里是一棵包结构树(org → example → ctfpractice),列出每个类、它的父类,以及带访问修饰符的字段和方法。筛选框按类名、方法名、字段名匹配,不区分大小写。几个做题时常搜的词,在这个 APK 上的结果如下:

筛选词 匹配的类 列出的内容
flag 1 org.example.ctfpractice.FlagChecker,全部成员
check 2 FlagChecker(类名匹配),以及 R$string 的 check 字段
onClick 1 MainActivity,只列出 onClick(View): void
encoded 1 FlagChecker,只列出 ENCODED 字段
equals 0 没有

展开 FlagChecker:

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

题目的骨架已经出来了:一个静态字符串、一个 int 型的 key,再加一个接收用户输入、返回布尔值的方法。MainActivity(extends android.app.Activity)里有 onClick(View): void,按钮按下后就会走到这里。

同样要清楚类浏览器看不到什么。第一,它只列名字不列值,你知道有 ENCODED 这个字段,却看不到它的内容。第二,它只收录 APK 里自己定义的类,所以搜 equals 什么也没有:调用 String.equals 只是对框架方法的引用,不是 App 类的成员。第三,它不反编译代码。分析工具自带的字符串池扫描只找固定的几类模式(APK 下载地址、安装 intent 等),也不会把 flag 打印出来。下一步得靠反编译器。

第 3 步:看资源,识破诱饵

资源检查起来很快,有时 flag 就直接放在里面。用 Apktool 解码:

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

通过 REA 的话,decode-android-resources 会替你运行 apktool d --no-src,把字符串作为结构化数据返回。先把 REA_APKTOOL_COMMAND 指向 Apktool 启动脚本:

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 看着很像 flag。交之前先确认有没有代码拿它做比较。类浏览器其实已经给出了答案:R$string.hint 只是一个资源 ID,FlagChecker 用的是自己的 ENCODED 字段。第 5 步里我把这个字符串输进 App,结果显示 Nope.。

第 4 步:用 JADX 或 REA 读懂校验逻辑

JADX 能把 DEX 还原成 Java:

jadx -d out/jadx ctf-practice.apk

JADX 1.5.6 输出了 FlagChecker.java、MainActivity.java 和 R.java。校验方法如下:

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;
}

同一个文件的字段声明给出了具体的值:ENCODED = "Kig7OS4zOT8hIjUoBTs0PgU4Oyk/bG4FOyg/BTQ1LgU5KCMqLjUn"。常量 KEY 被内联成了十进制的 90。Apktool 输出的 smali 里,同一个循环写的是十六进制 xor-int/lit8 v3, v3, 0x5a。反编译结果看着不对劲的时候,拿 smali 对照一下很管用。

如果你习惯和 AI agent 一起做题,REA 6.4.0 把这几步做成了命令,返回的 Evidence 都绑定 APK 的 SHA-256。前提是装好完整的 JDK 17+,并把 jadx-headless-mcp 0.7.1 的 JAR 路径写进 REA_JADX_MCP_JAR,具体见 JADX MCP 配置指南。我跑了这几条:

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

inspect-android-package 大约 3 秒完成,报告了包名 org.example.ctfpractice、min_sdk 24、没有权限、class_count 3,以及 signature_verification: "not_performed"。JADX 只数顶层类,R$string 算在 R 里面,而类浏览器列出了全部 4 个。search-android-classes 按类名做区分大小写的子串搜索,Flag 匹配到 org.example.ctfpractice.FlagChecker。inspect-android-method 返回的 Java 和上面一样,并标明 representation: "java"、fell_back: false、overload_count: 1。trace-android-references 回答了“谁调用了校验方法”:

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

不用读 MainActivity,就确认了从按钮到校验方法的调用路径。题目再大一些、校验藏在好几层调用之后时,这条命令最省时间。inspect-android-class 也能列出 ENCODED 和 KEY 两个字段,但和类浏览器一样,只给名字和类型,不给值。

第 5 步:解出 flag 并验证

校验要求每个字节都满足 (input[i] ^ 0x5a) == decoded[i],而且长度相同。XOR 再做一次就还原了,所以每个输入字节就是 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}

算出来的 flag,App 认了才算数。我把 APK 装到一台 Android 16 arm64 模拟器上并启动:

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

用 adb shell input text 输入 flag,点 Check,再用 uiautomator dump 读屏幕,按钮下方显示 Correct!。作为对照,我又输入了资源里的诱饵 practice{not_the_real_flag},显示 Nope.。没有模拟器也没关系:这么简单的校验,解题脚本把方法里的每个条件都反推了一遍,本身就足以证明。

flag 藏在原生库里怎么办

有些安卓题会把校验挪到 C 代码里。迹象有两个:分析工具的原生库表格里出现 lib/<abi>/*.so,类浏览器里某个方法带 native 修饰符。在 JADX 里这个方法没有方法体,所在类会调用 System.loadLibrary。到这一步,题目就变成了原生逆向:挑一个 ABI 的 .so(arm64-v8a 通常最好读),找名为 Java_<包名>_<类名>_<方法名> 的导出函数,然后用 REA + Ghidra 或 ELF 在线查看器 接着分析。CTF 逆向入门 里的方法完全适用。

新手常见错误

  • 看到第一个像 flag 的字符串就交。 资源字符串、没被用到的常量都是常见诱饵,要找到真正做比较的代码。
  • 指望名字一直都在。 看到 ~~R8 标记时,改搜界面上显示的文字(Correct!、Wrong),再顺着引用找回校验方法。
  • 只信一个反编译视图。 JADX 的输出看着不对,或者带了警告注释,就拿 Apktool 的 smali 对照。
  • 在自己的手机上跑下载来的题目。 用模拟器,并保留一个随时能回滚的快照。

常见问题

做安卓 CTF 一定要装 Android Studio 吗?

不用。分析只需要 JADX 或 Apktool 加一个 JDK。自己编译练习 APK 需要 SDK build-tools(aapt2、d8、zipalign、apksigner)和对应平台的 android.jar。只有要做动态验证时,才需要 SDK 里的模拟器,或者一台归你所有的闲置设备。

浏览器里的分析工具能反编译 APK 吗?

不能。它解析 Manifest、签名、原生库和 DEX 结构(包括类和方法浏览器),但不生成 Java 代码。先用它确定该看哪里,再到 JADX 里打开对应的类。

源码里写的是 0x5a,JADX 为什么显示 90?

这是同一个数。编译器会把 static final 的基本类型常量内联,JADX 按十进制打印这个字面量。字段声明里也保留着原来的值。

还能去哪找安卓练习题?

OWASP MAS Crackmes(又叫 UnCrackable Apps)是 OWASP 移动应用安全项目出的一组移动端逆向题。截至 2026-10-14,GitHub 显示其仓库的许可证为 LGPL-3.0。卡内基梅隆大学的 picoCTF 要注意改名:截至 2026-10-14,picoctf.org 首页写着 “picoCTF.org is now CyLab Security Academy”。Hack The Box 有涵盖逆向在内多个方向的挑战题;crackmes.one 上是用户上传的 crackme,只能在虚拟机里运行。请遵守各平台的规则。

下一步