CTF安卓逆向App3解题全记录:Java层到Native层完整链路

发布时间:2026/10/12 16:29:19
CTF安卓逆向App3解题全记录:Java层到Native层完整链路
最近在整理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:namecom.example.app3.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.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类型ZbooleanIintJlongLjava/lang/String;String[Bbyte[]([BII)[Bbyte[]输入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装到模拟器上点击按钮后直接闪退先看一眼Logcatadb logcat | grep -i dlopen\|UnsatisfiedLinkError常见的报错有这么几类dlopen failed: library libapp3.so not foundso根本不在包里或者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 logcatjadx里找不到敏感字符串字符串被加密或分片搜索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结果可能比在伪代码里抠半天更快。这是我反复踩坑之后总结出来的实战经验也是新手提高效率最快的一环。