进入移动安全这块之后,我给自己定了个规矩:每个平台上的题目,都按题号从易到难往下刷,每刷完一道,就把思考过程沉淀成笔记。这是第七十七篇,记录的是某 CTF 平台 mobile 区的第一道题 app1。题目本身不复杂,它的核心就是一个 Java 层的输入校验,但我觉得拿它当“逆向分析入门地图”非常合适——从拿到一个 APK、到找到校验函数、再到还原出目标字符串,整个链路是完整的。如果你也刚从 x86 的逆向切到 Android,或者对着一堆 jadx 反编译结果不知道先点哪里,这篇的思路可以直接照搬。
1. 思路拆解:拿到 APK 之后,先别急着反编译
1.1 先观察运行行为,再做技术判断
我见过不少朋友拿到 app1 之后的第一反应是把 APK 拖进 jadx,然后面对几十个类、几百个方法发懵。这个顺序其实可以优化一下。正确做法应该是先把 APK 装进模拟器,亲手点一遍。
用adb install app1.apk装完之后,打开应用,界面上一般就是一个输入框、一个按钮、一行用于显示结果的文本。这时候可以先试着输入几个字符串,比如admin、123456、随便一串字母,观察反馈。app1 这类入门题,错误提示往往非常直白,会在界面上显示一行Wrong或者It is wrong,甚至弹出 Toast。
这一段操作看起来很朴素,却是后续定位代码的关键。原因很简单:你看到的错误提示文字,一定会在反编译代码里以字符串常量的形式存在。哪怕 APK 里类名被混淆成a.b.c,字符串通常是变不了的。所以只要记住这个提示文案,后续用它在 jadx 里做全局搜索,就能直接跳到校验逻辑附近,省掉大把时间。
1.2 档案化收集 APK 的基础信息
在打开 jadx 之前,我还习惯先对 APK 做一次“档案化”的信息收集,工具就是file、apktool,再加上人眼扫一遍解包后的目录结构。
file app1.apk unzip -l app1.apk | head -50 apktool d app1.apk -o app1_srcfile是为了确认这是一个标准 APK/ZIP 包,unzip -l可以直接看包内有没有可疑资源,apktool d解包之后重点看三样东西:
第一是AndroidManifest.xml。这里能拿到包名、应用入口 Activity、申请了哪些权限。包名后面写 frida 脚本要用,入口 Activity 决定了你要分析的主类是谁。如果权限里有INTERNET,你要留个心眼,说明 flag 可能是联网校验的;app1 里没看到这类权限,基本能断定是本地校验。
第二是lib目录。如果里面有lib/armeabi-v7a或者lib/arm64-v8a下的.so文件,说明存在 native 层逻辑,可能后续要上 IDA。app1 解包后只有常见目录,没有额外 so 文件,意味着整个题目大概率是纯 Java 层,jadx 能还原得很完整。
第三是assets目录。有时题目会把加密后的 dex 或者关键数据藏在这里,解包时肉眼扫一眼能少踩很多坑。
1.3 静态优先、动态兜底的分析路线
我这次用的分析路线很明确:先静态定位,再动态验证,最后脚本还原。这也是我最推荐新手养成的习惯。
为什么要静态优先?因为 Java 层代码经过 jadx 反编译之后,调用关系、字符串、常量数组基本都是可见的。静态分析能直接告诉你“校验函数在哪、算法大概是什么”,这种精度是纯黑盒试输入给不了的。
但静态分析有个风险:人眼读代码可能把调用顺序读错,或者漏掉监听器里先做了一次trim()、toUpperCase()之类的预处理。这时候动态分析就派上用场了。用 frida hook 住关键函数,直接观察“传进去的值是什么、返回的值是什么”,一秒就能验证你的假设对不对。
所以整体路线就是:拿行为信息当索引,用反编译代码当地图,最后用动态 hook 当测量仪。
2. 核心定位:jadx 反编译和关键代码阅读
2.1 反编译与关键词搜索定位
静态分析阶段,我用的工具是 jadx。直接启动图形界面,把 app1.apk 拖进去,它会自动完成反编译和资源解析。命令行的方式也可以,但入门阶段图形界面更直观:
jadx -d jadx_out app1.apk打开之后,先不要漫无目的地翻类。第一步应该是按Ctrl+Shift+F打开全局搜索,输入记忆里的错误提示字符串,比如wrong、error、invalid。app1 的反编译结果里,这个字符串会直接带你跳到使用它的代码位置,通常就是MainActivity的某个方法里。
如果搜索wrong没有结果,也别慌,可以换flag、key、input、check、verify、equals这些词再试。还有一个非常高效的关键词是getText(),它是读取输入框内容的必经入口,顺着它往上找调用者,很快就能锁定按钮的点击监听器。
我实际在 jdax 里看到的核心结构,大体是这样一种模式(代码细节我做了脱敏替换,算法形态保持一致):
public class MainActivity extends Activity { private EditText editText; private TextView textView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); editText = findViewById(R.id.input); textView = findViewById(R.id.result); Button button = findViewById(R.id.button); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { String input = editText.getText().toString(); if (check(input)) { textView.setText("Correct!"); } else { textView.setText("Wrong!"); } } }); } private boolean check(String userInput) { byte[] data = userInput.getBytes(); byte[] target = new byte[]{ 0x24, 0x27, 0x26, 0x21, // 后面还有若干字节,本质是一个写死的目标数组 }; if (data.length != target.length) { return false; } for (int i = 0; i < data.length; i++) { data[i] = (byte) ((data[i] ^ 0x2A) + 1); } return MessageDigest.isEqual(data, target); } }注意,上面这些数字是我为了不直接变成答案搬运而做了扰动的示例,不是原题的真实常量。但它已经足以说明 app1 这类题的典型结构了:用户输入经过一个可逆的逐字节变换,然后和一个写死的字节数组做比较。
2.2 阅读核心校验逻辑
拿到核心代码之后,不要急着写脚本,先一行一行看懂它在做什么。
首先是getBytes()。这个方法默认使用平台字符集,在 Android 上通常是 UTF-8。输入内容是纯 ASCII 的时候没问题,一旦输入中文,字符会被编码成多个字节,长度直接对不上 target,所以这类题目默认输入就是英文字符或可打印符号,这个信息在后面写脚本时也用得上。
再看target数组。它是逆向分析的锚点,就像一把锁,我们要找的是能打开这把锁的钥匙。它在代码里是一个写死的byte[],你不需要知道它为什么是这些值,只需要知道它是“正确输入变换后的结果”。
然后是变换逻辑,这里的例子是data[i] = (byte) ((data[i] ^ 0x2A) + 1)。正向操作是异或一个常数,再加 1。异或是位运算,它的逆运算还是异或;加法 1 的逆运算是减 1。所以还原公式就是:input[i] = (target[i] - 1) ^ 0x2A。
最后是MessageDigest.isEqual(data, target)。这个函数和Arrays.equals在结果上基本等价,区别是它的比较过程是恒定时间的,通常用于安全校验,比如数字签名比对。对解题来说,这就是一个判断“是否相同”的开关,不影响算法还原。
有一种情况要额外注意:如果代码里出现了toUpperCase()、trim()、substring这类预处理,还原脚本里必须把同样的操作加回去,否则你算出来的字符串和实际输入的格式对不上,应用还是会提示错误。
2.3 用 Python 把逆向算法还原出来
Java 层看明白之后,写还原脚本只是几分钟的事。我把示例逻辑对应的 Python 脚本放在下面:
target = [ 0x24, 0x27, 0x26, 0x21, # 从 jadx 里按顺序抄下完整数组 ] result = [] for b in target: v = ((b - 1) & 0xFF) ^ 0x2A result.append(v) # 先看十六进制,再尝试解码成字符串 print(" ".join(f"{x:02x}" for x in result)) flag = bytes(result).decode("utf-8", errors="replace") print(flag)这里有一个新手极其容易踩的坑:Java 的byte是有符号的,取值范围是 -128 到 127。jadx 在展示target数组时,如果把某个字节写成了0xFF,实际代码里会显示成-1。而 Python 的整数没有这个符号概念。所以从 jadx 抄数据到 Python 时,所有字节都要用& 0xFF统一成 0 到 255 之间的无符号值,再参与运算。
另外,运算顺序要严格按照逆向的相反顺序来。正向是“异或再加”,逆向就必须“先减再异或”。这跟解栈一样,先进后出,顺序搞反了结果全错。脚本里((b - 1) & 0xFF) ^ 0x2A的括号不要省,-和^的优先级容易让人混淆。
如果脚本输出的字符串里前几位是flag{,恭喜,你已经拿到了答案。但我在笔记里习惯把最后一段输出打码留给你自己跑,因为刷题的价值在于亲手跑通这个过程。
3. 动态验证:frida 让假设变成结论
3.1 环境准备:模拟器、frida-server 与端口
静态脚本跑出了候选字符串,按理说可以直接去应用里试了。但我还是习惯先做一步动态验证,确认我对check函数的理解没错。用到的工具是 frida。
环境准备分三步。第一步,模拟器里最好有一个可用 root 的系统镜像,Android 9 或 Android 10 的经典镜像都行,因为老 APK 在这些版本上兼容性更好。第二步,下载与本机 frida 版本一致的 frida-server,注意架构,通过adb shell getprop ro.product.cpu.abi查看模拟器架构。第三步,推送并启动:
adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell "/data/local/tmp/frida-server &" adb forward tcp:27042 tcp:27042启动之后,用frida-ps -U能看到模拟器进程列表,就说明环境通了。
3.2 hook 关键函数,观察输入与输出
我写的验证脚本非常简单,核心就是给check方法挂一个代理,打印每一次调用的入参和返回值:
Java.perform(function () { var MainActivity = Java.use("com.ctf.app1.MainActivity"); MainActivity.check.implementation = function (str) { console.log("[*] input: " + str); var ret = this.check(str); console.log("[*] result: " + ret); return ret; }; });运行命令:
frida -U -f com.ctf.app1 -l solve_hook.js启动后,frida 会自动拉起应用,进入交互模式。输入%resume让应用继续运行,然后切到模拟器,在输入框里随便敲几个字符,点击按钮。回到 frida 控制台,就能看到类似这样的输出:
[*] input: admin [*] result: false如果result变成 true,说明此刻输入的字符串就是正确 flag。这个验证最大的价值在于:它直接证明了静态分析找到的check确实是实际调用的校验逻辑,没有找错方法,也没有漏掉前置处理。
如果连input都没有打印出来,那就要回头检查包名和方法名是不是写错了,或者应用是否真的调用了这个类的方法。可以顺手再 hook 一下android.widget.EditText.getText(),这条链路一定会在点击时被触发。
3.3 把还原结果输入应用完成闭环
验证通过后,就把 Python 脚本算出来的字符串完整地填进输入框。这里有几个细节值得注意。
一是别在字符串首尾留下多余空格。有些题目在比较之前没有做trim(),多一个空格都会导致长度不一致,直接判断失败。二是如果应用校验的是“输入后点击按钮”的交互,那就老老实实走一次人工点击,不要只依赖 hook 脚本里的自动调用。
如果填进去之后界面显示Correct!,那整个分析闭环就结束了。从行为观察到静态定位,再到脚本还原和动态验证,这条路径就是 app1 的完整解法流程。
3.4 动态验证中容易忽略的细节
我在实际调试中遇到过几次 frida 注入失败的情况,最常见的表象是frida -U -f之后应用没有自动启动,或者控制台一直卡住。排查顺序一般是:
先看 frida-server 有没有被杀,有的模拟器会限制 /data/local/tmp 下的进程运行,重启模拟器后再来一次。再看 SELinux 是不是 enforcing 模式,可以临时执行adb shell setenforce 0关掉,避免 frida-server 无法附加进程。最后检查 frida 和 frida-server 版本是否匹配,版本号差太多就会出现“连接被重置”之类的报错。
还有一个思路层面的建议:入门题里你完全可以直接改check的返回值为true来绕过校验,拿个“成功界面”。但我还是建议老老实实做算法还原,因为那道变换逻辑才是这道题真正想让你练的东西。
4. 踩坑实录与问题排查速查
4.1 静态分析和动态结果不一致时怎么办
有一种情况非常打击人:你按静态代码写好了脚本,字符串看起来也对,但 frida hook 之后发现check压根没被调用,或者调用的是另一个重载方法。这时候不要怀疑自己眼瞎,大概率是漏掉了某些入口逻辑。
app1 这题还算厚道,入口就在MainActivity。但复杂一点的题目会有多个 Activity,或者点击事件实际绑定在了一个自定义 View 上。排查手段还是老一套:往上追监听器。你在 jadx 里看到setOnClickListener的地方,往往是校验逻辑的真正入口。如果那里还有Intent传参、SharedPreferences 读取之类的操作,也得一并纳入分析范围。
4.2 字节符号与编码造成脚本“看起来没算错”
这是我最想让新手记住的一个坑。Java 里byte是有符号的,(byte) 0xA0打印出来会是负数 -96。你从 jadx 复制数组到 Python 时,如果照抄了负数,后续位运算结果很容易飘。
最简单有效的处理方式:抄写数组时统一转成0xFF格式,脚本里每个元素都& 0xFF。另外,如果你算出来的字符串前面部分正常、后面出现乱码,不要先怀疑算法,先用十六进制打印中间结果,把每一段和 Java 里的target数组逐字节对上,定位是哪个字节开始偏的。
4.3 如果题目换成加固/混淆版本
虽然 app1 是纯 Java 无加固,但练习的时候最好有个心理准备:真正的商业 app 或者稍难一点的赛题,往往不是这么友好。如果 jadx 打开之后只看到壳的入口,业务代码全都不见,那就不是“读代码”的问题了,而是“先脱壳”的问题。
判断是否加壳可以从 AndroidManifest 里看出端倪:如果应用自带的类很少,却有一堆com.xxx.shell、com.secneo之类的类名,或者 assets 下躺着加密的 dex 文件,那就基本坐实了。这时候要么先落下壳,要么在内存里 dump 出真正运行的 dex,再交给 jadx 分析。frida 在这类高难题目里依然是主力工具,可以用它遍历已加载的类,找到真实业务代码。
4.4 常见问题速查表
| 症状 | 可能原因 | 处理思路 |
|---|---|---|
| 全局搜索不到错误提示字符串 | 字符串被加密,或者提示来自 native 层 | 改用动态 hook 观察输入输出,或检查 so 文件 |
| 还原脚本输出乱码 | Java 有符号字节、编码问题 | 统一& 0xFF,先打印十六进制逐段核对 |
| frida hook 后没有日志输出 | 包名/方法名不匹配,或方法未被调用 | hookEditText.getText()向上逆向定位入口 |
| 输入看起来正确但提示错误 | 算法存在预处理,如trim()、大小写转换 | 回到 jadx 查看比对前的完整调用链 |
| 反编译代码异常精简 | APK 加壳或参数抽取 | 先用脱壳工具或内存 dump,再重新反编译 |
4.5 写在笔记末尾的几点经验
app1 这道题做完,我的最大体会是:逆向分析最值钱的不是那个flag,而是思路的顺序。从错误提示的字符串入手,比从类名和方法名入手高效太多;静态分析定位逻辑,比瞎猜输入高效太多;动态 hook 验证假设,比反复试错高效太多。
我一般会把这类 Java 层校验的题目称为“套模板题”:看入口、找 check、还原变换。但每一次的具体实现都可能有小变体,所以笔记里记录的重点永远是思考路径,而不是死记答案。希望这篇 mobile 区 app1 的记录,能帮你少走一点弯路。