Android CTF 入门:逆向一道练习 APK 题,找出 flag
本页目录
入门级的 Android CTF 题,大多是一个输入框、一个按钮,外加一个校验方法。解题分四步:先给 APK 做体检,找到入口 Activity,看代码有没有混淆;再按类名、方法名找到校验逻辑;然后用反编译器读懂它;最后写个小脚本反推输入,并回到 App 里确认 flag 正确。本文用一道自己编译的练习 APK 把这四步完整走一遍,从浏览器开始,最后用到 JADX、Apktool,以及第三方开源工具 REA。
本文假定你做过一两道原生程序逆向题(或者读过 CTF 逆向入门),不需要 Android 开发经验。要准备 JDK;想自己编译 APK 还要装 Android SDK 的 build-tools;模拟器可有可无。请只在自己编译的题目、公开发布的练习题,或者已获授权分析的 App 上练习。
安卓题的 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.7.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 里的模拟器,或者一台归你所有的闲置设备。
还能去哪找安卓练习题?
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,只能在虚拟机里运行。请遵守各平台的规则。