最近在整理CTF安卓逆向的笔记,把某平台Mobile新手区那道App3重新从头到尾啃了一遍。很多人私信我新手第一道APK逆向题应该选什么,我一般都会推荐这道题。它的考点非常集中:先找Java层入口,再顺着JNI调用摸到Native层,最后还原一个简单的校验算法。整套流程就是移动逆向最基础也最核心的链路,没有任何花里胡哨的混淆对抗,非常适合零基础的人建立解题框架。
这篇Write Up不是简单贴个答案,而是把我当时的完整思考过程、工具选择、删掉重来的几个弯路都写清楚。就算你之前完全没碰过安卓逆向,只要会装软件、能看懂一点C语言,按这个思路走一遍,也能把题啃下来。
1. 拿到安装包之后,先别急着反编译
1.1 先看文件,再谈分析
我见过太多新手拿到App3.apk直接双击打开jadx,然后对着反编译代码发呆。不是说不行,而是缺少一个“先看全貌”的过程。正确的第一步应该是三条命令:
file App3.apk ls -lh App3.apk sha256sum App3.apk虽然APK的本质就是ZIP压缩包,但file会告诉你它到底是不是一个合法的APK,有没有被改过后缀、包体有没有异常。sha256sum记录哈希主要是方便你解题后写Write Up时标注文件特征,也能在多个来源下载时确认是同一个样本。
接着用解压工具直接看一层结构。我习惯在终端里操作:
unzip -l App3.apk重点看几个目录:
lib/:如果存在lib/armeabi-v7a、lib/arm64-v8a之类的目录,说明有Native代码,后面肯定要碰so。assets/:可能有额外的加密资源、内置文件或DEX。classes.dex:正常情况下会有多个dex,如果只看到一个极小体积的dex,那大概率是加壳了。
新手区题目一般不会故意加壳,但养成这个检查习惯非常重要,遇到真正实战样本时,这一步能帮你判断整个分析策略。
1.2 用jadx把入口点找出来
看完文件结构后,再把APK丢进jadx。很多人对jadx的用法停留在“打开就能看到代码”,其实最关键的是先看AndroidManifest.xml。
在jadx左侧目录里找到AndroidManifest.xml,重点看<application>节点下的<activity>列表,找带有MAIN和LAUNCHER两个action的Activity。这就是APK的启动入口,也就是App打开后第一个运行的界面。
比如题目里入口往往是这样的:
<activity android:name="com.example.app3.MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity>找到MainActivity之后,别急着往下翻代码,先在jadx里按一下全局搜索快捷键,搜几个关键词试试水:
flagpasswordinputcheckkeynative
通常能很快看到几个可疑的函数名。这一步等于先把地图铺开,之后再定位具体逻辑就快得多。
2. Java层定位校验逻辑,是解题的第一道分水岭
2.1 找到那个“判断对错”的函数
打开MainActivity之后,你会看到类似这样的代码逻辑:
public class MainActivity extends AppCompatActivity { // 加载动态库,注意这个名称后面会用到 static { System.loadLibrary("app3"); } private native String nativeCheck(String input); public void onClick(View v) { String input = this.editText.getText().toString(); String result = nativeCheck(input); if (result != null && result.equals("correct")) { Toast.makeText(this, "You got it!", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(this, "Wrong password", Toast.LENGTH_SHORT).show(); } } }这类代码结构在CTF里太常见了:界面有一个输入框,一个按钮,点击之后把输入交给某个函数判断,然后根据返回值提示正确还是错误。这里最大的特征是:
System.loadLibrary("app3")加载了一个名为libapp3.so的动态库。nativeCheck是一个native方法,说明真正的判断逻辑不在Java层,而在so里。
很多人卡在这一步就开始不知所措。其实这时候你要做两件事:一是确认函数到底是什么签名,二是搞清楚它内部做了什么。签名可以通过jadx反编译出来的代码直接看到:
- 如果方法是
private native String nativeCheck(String input),说明入参是一个Java字符串,返回值也是一个Java字符串。 - 如果方法是
private native boolean check(String input),说明返回值是布尔值。
确认签名非常重要,因为后面在Native层分析时,JNI函数的第二个参数和第三个参数会对应这些Java类型。
2.2 静态注册和动态注册,完全两种分析思路
既然核心逻辑在so里,下一步就是把so拖下来分析。但先别急着用IDA,先搞清楚这个native函数是怎么注册到Java层的,因为注册方式决定了你在so里找函数的思路。
静态注册是最传统的方式,要求so里的导出函数名严格按照JNI规范命名:
Java_com_example_app3_MainActivity_nativeCheck也就是Java_+ 包名 + 类名 + 方法名,中间用下划线分隔。这种函数可以直接在so的导出表里看到,用IDA或Ghidra打开so,直接找这个名字就行。
动态注册则灵活得多,它在so的JNI_OnLoad函数里通过RegisterNatives手动建立Java native方法名与C函数指针的映射关系。这种情况下,so的导出表里往往只能看到JNI_OnLoad,你需要分析JNI_OnLoad里的RegisterNatives调用,找到真正被注册的C函数。
App3这道题很多版本用的是动态注册。为什么新手题要考这个?因为动态注册在实际开发中用得越来越多,很多商业App都用它隐藏真实native函数名,CTF出题人自然想让新手尽早接触这个知识点。
判断方式是:用readelf -s libapp3.so | grep Java_或者直接在Ghidra的Symbol Tree里找。如果没找到Java_开头的导出函数,那基本就是动态注册了。
3. Native层逆向,核心是还原那个校验算法
3.1 打开so之后,先从JNI_OnLoad开始
把so从APK里提取出来:
unzip -o App3.apk "lib/armeabi-v7a/*" file lib/armeabi-v7a/libapp3.so如果只有armeabi-v7a目录,说明这是32位ARM架构的so,用IDA或Ghidra分析时选择32位模式;如果是arm64-v8a,则选64位模式。
我个人新手期更推荐用Ghidra,因为免费且功能足够。打开so后,先看导出函数。如果发现了JNI_OnLoad,双击进去,你会看到类似这样的C伪代码:
jint JNI_OnLoad(JavaVM *vm, void *reserved) { JNIEnv *env; if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } // 关键就在这个注册过程 static const JNINativeMethod methods[] = { { "nativeCheck", "(Ljava/lang/String;)Ljava/lang/String;", (void*)real_check_function } }; jclass clazz = env->FindClass("com/example/app3/MainActivity"); env->RegisterNatives(clazz, methods, 1); return JNI_VERSION_1_6; }看不懂这段伪代码没关系,你只需要抓住一件事:methods数组里那三个元素,第一个是Java层native方法名,第二个是JNI方法签名,第三个是真正的C函数地址。App3里第三个字段指向的函数名,往往是一个看不出含义的名字,比如real_check_function、sub_xxxx,这就是你要分析的目标。
再说一句关于JNI签名的事,新手一定要会认。(Ljava/lang/String;)Ljava/lang/String;的意思很直白:
- 括号里是方法入参,
Ljava/lang/String;表示一个Java字符串。 - 括号后面是返回值,还是
Ljava/lang/String;。
如果看到(Ljava/lang/String;I)Z,意思是入参是字符串加整数,返回值是布尔值。CTF里判断函数特征基本靠这个签名,建议直接记住几个常用的:
| JNI签名 | 对应Java类型 |
|---|---|
Z | boolean |
I | int |
J | long |
Ljava/lang/String; | String |
[B | byte[] |
([BII)[B | byte[]输入,int,int参数,返回byte[] |
3.2 还原校验逻辑时,先把“目标值”找出来
找到真正执行校验的函数后,不管函数名叫什么,它的逻辑万变不离其宗:拿你的输入做一堆变换,最后和一个内置的目标值比较,相等就返回正确,不等就返回错误。
用Ghidra把C函数的伪代码调出来。常见的校验逻辑长这样:
jstring nativeCheck(JNIEnv *env, jobject obj, jstring input) { const char *str = (*env)->GetStringUTFChars(env, input, NULL); int len = strlen(str); // 对输入做某种变换 for (int i = 0; i < len; i++) { str[i] = str[i] ^ 0x22; } // 与内置目标比较 unsigned char target[] = {0x66, 0x6C, 0x61, 0x67, 0x7B, ...}; for (int i = 0; i < len; i++) { if ((unsigned char)str[i] != target[i]) { return (*env)->NewStringUTF(env, "wrong"); } } return (*env)->NewStringUTF(env, "correct"); }注意看这里,核心不是那层变换本身有多难,而是你要识别出target数组,并且知道输入字符串经历了什么运算。这类运算通常是:
- 异或固定值
- 逐字节加/减一个数
- Base64编解码
- 简单换表
新手题基本不会上AES或RSA,最多就是异或加Base64。识别方法很简单:看到^ 0x22、+ 3、- 1这种操作,基本就是单字节变换;看到有一个64字符的查表,基本就是Base64变种。
3.3 用Python把算法还原出来
一旦确定了变换逻辑和目标数组,剩下的就是写脚本。假设我发现变换是对输入逐字节异或0x22,而目标数组是:
66 6C 61 67 7B 36 33 38 63 32 62 32 65 64 7D那么还原脚本就是:
target = bytes.fromhex("66 6C 61 67 7B 36 33 38 63 32 62 32 65 64 7D") flag = ''.join(chr(b ^ 0x22) for b in target) print(flag)为什么能这么还原?因为异或运算的特性是“对称的”:如果encrypted = plain ^ 0x22,那么plain = encrypted ^ 0x22。目标数组就是加密后的结果,拿它再异或一次密钥,自然就还原出明文。
运行结果通常会是一串以flag{...}开头的字符串,这就是这题的答案。CTF里的flag格式一般固定,看到flag{基本可以确定你走对了。
这里要特意提醒一句:还原脚本一定要把目标数组从Ghidra里逐个抄准,抄错一个字节结果就会全错。我见过很多新手在手工复制数组时漏了一位,然后对着错误的输出怀疑人生。
4. 动静结合,用Frida验证你的判断
4.1 先搭一个能运行的环境
静态分析还原算法是一条路,但有时候你会遇到“伪代码看不太懂”“找不到目标数组”的情况。这时候动态调试就派上用场了。App3虽然能纯静态解出来,但用Frida验证一下自己的分析结果会很有成就感,也能为后续更难的题目打基础。
先把APK装到设备上。这里有个新手最常踩的坑:如果你的so只有armeabi-v7a,用常见的x86架构模拟器直接安装后会闪退,因为系统找不到匹配架构的so。报错通常是:
java.lang.UnsatisfiedLinkError: dlopen failed: library "libapp3.so" not found这不算逆向问题,是架构匹配问题。我自己的经验是,新手阶段最省事的选择是:用真机调试,或者用支持ARM镜像的模拟器启动一个ARM64虚拟机。Frida对模拟器的支持一般不如真机稳定,真机能省掉很多不必要的环境折腾。
环境准备好后,把APK装上去:
adb install -r App3.apk frida-ps -Ufrida-ps -U能看到设备上的进程列表,先验证Frida服务正常连接。
4.2 Hook Java层,快速定位入参与返回值
Frida最爽的用法就是hook Java层方法,不需要重打包,直接运行时注入。拿App3来举例,我想看看nativeCheck到底接收了什么、返回了什么:
Java.perform(function () { var MainActivity = Java.use("com.example.app3.MainActivity"); MainActivity.nativeCheck.implementation = function (input) { console.log("[+] nativeCheck input = " + input); var result = this.nativeCheck(input); console.log("[+] nativeCheck result = " + result); return result; }; });保存为hook_app3.js,然后运行:
frida -U -f com.example.app3 -l hook_app3.js-f参数会重启目标App并注入脚本。在App里随便输入一串字符串,点击按钮,Frida控制台就会打印出nativeCheck的入参和返回值。
这一步的意义在于:如果Java层有多个可疑函数,你不需要每个都静态分析,hook一下就能看出哪个函数在真正校验输入。同时也能确认静态分析中关于方法签名的理解是不是正确。
4.3 Hook Native层,直接看C函数的入参
光hook Java层还不够,我们已经确认校验逻辑在so里。如果so里的函数是静态注册的,那就更简单了。比如导出函数叫Java_com_example_app3_MainActivity_nativeCheck,可以直接用Frida hook它:
var nativeFunc = Module.findExportByName("libapp3.so", "Java_com_example_app3_MainActivity_nativeCheck"); if (nativeFunc) { Interceptor.attach(nativeFunc, { onEnter: function (args) { // 读取JNI字符串参数,在32位下它是第三个参数,64位也一致 var env = args[0]; var inputJString = args[2]; var JNIEnv = Java.vm.tryGetEnv(); if (JNIEnv) { var input = JNIEnv.getStringUtfChars(inputJString); console.log("[+] native input = " + input.readCString()); } }, onLeave: function (retval) { console.log("[+] native retval = " + retval); } }); }如果题目是动态注册,导出表里没有带Java_前缀的函数名,那你要么在Ghidra里先弄清楚真实C函数的偏移地址,用Module.findBaseAddress("libapp3.so").add(offset)来hook,要么直接在JNI_OnLoad附近下断点。
动态调试的核心价值,是解决“静态分析看不出目标数组从哪来”的尴尬。比如数组是运行时动态解密出来的,你静态翻半天找不着。这时候用Frida在C函数入口处把参数打出来,在返回之前把内存dump出来,问题就迎刃而解。
5. 新手最容易踩的坑,和一套排查思路
5.1 so加载崩溃,多半是架构问题
这个问题我在4.1里提了一嘴,但它实在太常见,值得单独展开。如果你把APK装到模拟器上,点击按钮后直接闪退,先看一眼Logcat:
adb logcat | grep -i "dlopen\|UnsatisfiedLinkError"常见的报错有这么几类:
dlopen failed: library "libapp3.so" not found:so根本不在包里,或者lib目录下找不到对应架构的so。dlopen failed: "libapp3.so" is 32-bit instead of 64-bit:你在64位进程环境下尝试加载32位so,或者反过来。wrong ELF class: ELFCLASS32:架构位数不匹配。
解决思路是先在unzip -l阶段就看清lib目录下有哪些架构。只有armeabi-v7a,就老老实实找一台ARM设备。如果真的不想换设备,备选方案是用模拟器支持ARM翻译的镜像,但性能和稳定性都会差一些。
5.2 数组抄了一半,还原结果全是乱码
这个坑更多是操作层面的。在Ghidra里复制目标数组时,注意看伪代码里的数据类型。有些字节数组被识别成char target[],有些被识别成unsigned char,还有的会以十六进制字符串形式显示。copy的时候很容易漏字节。
我的习惯是:先选中整个数组,右键Copy Special,选择Python bytes literal格式,直接粘贴到脚本里。这样能最大程度避免手抄出错。然后用len()验证一下长度是否和Ghidra里的数组大小一致。
5.3 字符串输出有中文或特殊字符,编码不对
JNI层处理字符串时,GetStringUTFChars返回的其实是Modified UTF-8编码,跟标准UTF-8略有差异。绝大部分情况下英文字符和数字没有影响,但如果flag里出现中文字符或者特殊符号,用Python处理时要小心编码。一般先.decode('utf-8'),有问题再换编码。CTF flag基本是ASCII字符,这个坑出现的概率不高,但遇到乱码时先想到它。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 点击按钮闪退 | so架构不匹配 | `adb logcat |
| jadx里找不到敏感字符串 | 字符串被加密或分片 | 搜索loadLibrary、native关键词 |
so里没有Java_导出函数 | 动态注册 | 分析JNI_OnLoad中的RegisterNatives |
| 还原脚本结果乱码 | 目标数组抄错 | 对比Ghidra数组长度,逐个字节检查 |
| Frida hook后App崩溃 | Frida服务端版本不匹配 | 使用匹配的frida-server版本,真机重试 |
| 静态分析结果与实操不符 | Java层多入口校验 | 用Frida hook所有可疑方法,确认实际调用 |
5.5 一个值得养成的习惯:先穷举,再深挖
如果遇到一个校验函数看得一头雾水,有个通用办法:直接把所有可能的输入枚举一遍。CTF里很多简单的输入变换,本质就是单字节映射。写一个Python脚本遍历printable字符,把每个字符经过相同变换后与目标数组对比,很多时候瞬间就解开了。这个办法尤其适合异或、加减这种可逆变换,因为就算你搞不清内部完整流程,只要知道“输入与输出之间是单字节映射关系”,就可以用已知条件反推。
6. 从App3到真正的移动逆向入门路线
App3这道题本身不难,但它点上了一条完整的技术线。做完这个题,我建议你再主动往前拓展几步,而不要急着去刷高难度题。
第一步,用Android Studio自己动手写一个APK,要求:输入一串字符串,经过native函数,内部做异或加密,然后与内置目标比较。自己写一遍之后再反编译自己写的APK,你会发现“原来我在Java层定义的native方法,编译成JNI后是这个样子”。这一步极其有价值,它会帮你建立正向开发与逆向分析之间的映射感。
第二步,尝试给自己写的APK加一个“动态注册”,用RegisterNatives替代默认的静态注册流程,然后重新用jadx和Ghidra分析一次。做过这个实验,你在逆向别人的动态注册so时就不会两眼一抹黑。
第三步,把工具链固化下来。我个人的推荐组合是:
- jadx:看Java层反编译代码。
- apktool:解包、重打包、查看资源。
- Ghidra或IDA Pro:看so反汇编。
- Frida:动态hook验证。
- Android Studio自带的模拟器或一台真机:跑样本。
这五样就够用了,不需要一开始就配一堆重型工具。工具越多,注意力越容易被分散。
回到App3这道题,我把解题链路再串一遍:拿到APK,先看文件结构和Manifest;用jadx定位到加载so的Activity和native方法;提取so,判断静态注册还是动态注册;在Ghidra里还原校验算法,找到目标数组;写Python脚本还原出flag。整个过程没有高深的知识,但是一步扣一步,每一步都不是废话。
最后再分享一个我个人的小技巧:分析so的时候,不要一上来就盯着反汇编代码看。先在strings输出里过一遍,找找有没有可疑的英文单词、提示信息、Base64字母表、以及自然的数字序列。App3这类新手题,目标数组往往就以十六进制常量的形式躺在函数附近,你多看一眼strings结果,可能比在伪代码里抠半天更快。这是我反复踩坑之后总结出来的实战经验,也是新手提高效率最快的一环。