多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

APK逆向入门:从JNI调用到so中Base64变种算法的完整调试

APK逆向入门:从JNI调用到so中Base64变种算法的完整调试 攻防世界逆向区有一道叫testre的题我刚开始学逆向工程时就在这上面卡了好几天。后来回头看其实它考的东西非常基础APK结构、JNI调用、以及一个藏在so里的Base64变种算法。这篇文章就把我实际调试的过程完整梳理一遍给同样卡住的新手一个参照也顺便聊聊我在这个过程中总结的通用逆向分析方法——这套思路不仅适用于CTF题目放到游戏逆向里也同样管用。1. 拿到题目先别急着反编译把这四件事做扎实1.1 文件识别testre到底是个什么文件很多新手拿到题目压缩包第一步就是双击打开或者直接扔进jadx结果发现打不开、识别不了然后就懵了。其实第一步应该先确认文件类型避免后面工具选错。攻防世界里的testre最初我下载下来是一个.apk文件名字就叫testre.apk。但你本地拿到的压缩包可能是.zip或者题目附件先解压出来然后在终端里执行file testre.apk如果输出里有Android、Java archive之类的字样确定是APK。接着用解压工具或者命令行看看内部结构unzip -l testre.apk重点关注几个东西AndroidManifest.xml入口Activity、权限、组件信息。classes.dexJava层代码等会儿交给jadx还原。lib/目录存放so文件这是逆向重点。assets/、res/可能有额外数据有些题目会把密文藏在资源文件里。如果lib/下面有libnative-lib.so那基本宣判这是一道典型的JNI调用题。testre的套路正是这样Java层只搭了一个壳真正做校验的逻辑全在so里你必须跟到native层才能拿到flag。1.2 定路线静态分析优先动态调试兜底我的习惯是“先静态后动态”但也不是死板的顺序。对testre这个难度静态分析能解决90%的问题动态调试主要是验证猜想。所谓静态分析分两步Java层用jadx看dex搞清楚界面入口、监听器、native方法声明。Native层用IDA看so定位JNI函数分析加密/校验函数。动态调试我一般用Frida和IDA远程调试。为什么还要动态因为有些算法在伪代码里看不出来比如常数被运行时填充、反调试、或者函数指针间接调用静态看很容易走偏。动态调试可以直接看函数输入输出相当于拿着答案倒推过程。新手常见误区是拿到so先一顿strings扫描看到什么flag、key就说找到答案了。这种做法在CTF简单题里偶尔能瞎猫碰到死耗子但遇到testre这种带自定义编码的题目就会翻车。正确做法是顺着调用链走从Java层入口一路跟到最后的strcmp或memcmp比较点把比较的双方摸清楚这题基本就拿下了。2. Java层静态分析把入口函数和JNI调用链理出来2.1 用Jadx还原MainActivity的调用逻辑jadx是目前我用得最顺手的APK反编译工具一条命令就能把dex还原成可读的Java代码jadx -d output testre.apk然后打开output/sources找MainActivity。testre的Java层逻辑非常直白核心代码大概是这个画风public class MainActivity extends Activity { static { System.loadLibrary(native-lib); } private native String getFlag(String input); protected void onCreate(Bundle savedInstanceState) { // ... Button btn findViewById(R.id.button); btn.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { String input editText.getText().toString(); String result getFlag(input); if (result.equals(success)) { // show flag } } }); } }这里的关键信息有两个System.loadLibrary(native-lib)说明so文件名为libnative-lib.so。native String getFlag(String input)这是本地方法我们要在so里找Java_com_xxx_MainActivity_getFlag这样的导出函数。如果看到类似result.equals(success)的代码可以推测native函数返回什么字符串决定了用户输入是否正确。有些版本会返回具体提示有些版本会直接返回flag但testre是返回一个固定成功标记真正flag藏在比较逻辑里需要我们自己解密目标串得到。2.2 从JNI导出函数反查so层实现定位so后先不要着急IDA。先用nm看一下导出表nm -D libnative-lib.so | grep Java或者用IDA打开so在Exports窗口里搜索Java_前缀。如果看到类似Java_com_example_testre_MainActivity_getFlag那就直接用IDA跳过去。不过这里有个坑新版Android NDK写JNI时可能使用RegisterNatives动态注册导出表里没有Java_开头的函数所有注册逻辑都在JNI_OnLoad里。遇到这种情况先去JNI_OnLoad函数里看RegisterNatives的注册表把native方法名和函数指针对应上。testre不是新题基本走标准导出函数路线。但如果你做题时在导出表里找不到别慌按上面说的思路去找JNI_OnLoad就行。3. so层核心算法base64变种与字节变换3.1 用IDA定位校验函数并看懂F5伪代码IDA打开libnative-lib.so左侧函数窗口定位到Java_com_example_testre_MainActivity_getFlag按F5反编译成伪代码。第一次看到伪代码的时候不要被一大坨变量名吓住只需要抓住三件事输入从哪里来一般是通过GetStringUTFChars拿到用户输入的C字符串指针。数据怎么变循环、位运算、查表。最后比什么strcmp、memcmp或者自定义比较函数。testre的伪代码结构大概可以抽象成下面的样子jstring Java_com_example_testre_MainActivity_getFlag(JNIEnv *env, jobject thiz, jstring input) { const char *in (*env)-GetStringUTFChars(env, input, 0); int len strlen(in); char buf[64]; for (int i 0; i len; i) { buf[i] in[i] ^ key[i % key_len]; } char *encoded custom_base64_encode(buf, len); if (strcmp(encoded, target_string) 0) { return (*env)-NewStringUTF(env, success); } return (*env)-NewStringUTF(env, fail); }注意这只是还原后的示意伪代码真实题目的变量名和结构不会这么干净。但核心脉络是一致的先对输入做字节变换再做Base64编码最后和硬编码字符串比较。3.2 识别自定义Base64表与XOR预处理Base64在逆向题里太常见了因为它在代码层面有很强的特征。一个标准Base64编码函数处理流程是每次取3字节输入分成4个6比特的索引查表得到4个输出字符。在汇编里通常能看到这样的位运算组合 2取第一个6比特 4、 2、 0x3f取第二个6比特 6、 4、 0x3f取第三个6比特 8、 6、 0x3f取第四个6比特还有明显的长度变化输入3字节变输出4字节。如果你在F5伪代码里看到一堆、和0x3F大概率就是Base64系列编码。testre这道题有意思的地方在于它的Base64不是标准实现。它可能在编码前先把每个字节和某个固定key异或或者对字母表做了重排。怎么确认字母表从函数里找到编码时查表操作引用的数组然后在IDA的Hex窗口看那段数据。如果看到ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/这是标准表。但如果看到的是一串乱序的字符比如zBkQ...这种那就是自定义表。自定义表的价值在于即使你知道它是Base64也不能直接标准解码必须提取真实表。testre里还常出现一个预处理in[i] ^ 0x12或类似异或。这是很多新手忽略的地方。只看到Base64就直接去解码目标串结果解出来全乱码原因就是漏了这层异或。所以做逆向题一定要“从输入到比较点”正着推一遍确保每个变换都覆盖到。3.3 提取目标串与恢复字母表的两种方法目标串通常是硬编码在.rodata段里的字符串。在IDA里直接看伪代码中strcmp的第二个参数往往是一个全局地址点过去就能看到那段字符。有些题目为了防止字符串被直接看到会把目标串按字节存在数组里或者按int数组存储。这个时候点过去看到的不是明文而是一串十六进制数字。这时候别慌用Python把数组转成字符串即可。比如data [0x45, 0x77, 0x50, 0x32, 0x62, 0x42, 0x31, 0x6A] print(bytes(data).decode())如果数组是int型的注意观察每个数字的范围。如果都在0x00-0xFF之间直接转字节如果有0x65 0x77这种两个字节一个字符的可能是UTF-16编码需要调整解析方式。恢复字母表也有两种路径静态提取从内存数据区把64字节的表复制出来。强烈建议用xxd或者Python直接读取so文件的偏移而不是手抄手抄容易抄错一个字符导致后面全乱。动态提取如果表是运行时生成的少见但存在就写Frida脚本在编码函数执行时dumper内存快照再还原。我一般两种都做先用静态提取拿到表如果解码结果不对再上动态调试确认是不是还有别的表。4. 动态调试和脚本还原让答案自己跳出来4.1 Frida Hook native函数直接看输入输出静态分析只能看到代码不能直接看到运行时值。比如我们怀疑编码前有异或但想验证最快的方法是hook住native函数看看传入什么、返回什么。Frida的安装很简单PC端执行pip install frida-tools模拟器/真机上运行目标APK然后执行frida -U -f com.example.testre -l hook.jshook.js内容大致这样Java.perform(function () { var MainActivity Java.use(com.example.testre.MainActivity); MainActivity.getFlag.implementation function (str) { var ret this.getFlag(str); console.log(input str); console.log(output ret); return ret; }; });如果这个hook能打出来输入输出你就可以直接测试各种输入观察返回值变化。比如输入abc返回fail输入正确flag返回success。但光有这个还不够因为你不知道内部处理细节。更进阶的做法是用Frida Hook so层的strcmp、memcmp、strlen等函数frida-trace -U com.example.testre -i strcmp -i strlen -i strstr这样能直接看到native层在哪里、拿什么值和目标比较。运行后随便输入一个字符串就能在终端里看到类似strcmp(EwP2bB1j..., EwP2bB1j...) 0左边的参数往往就是编码后的输入右边的参数就是内置目标串。这个信息对逆向算法极其关键。4.2 Python反向解码脚本的完整实现拿到字母表、目标串、以及异或key之后就可以写逆向脚本。假设我调试testre时还原出的逻辑是先对输入逐字节异或0x12然后用自定义表进行Base64编码最后与目标串比较。那么正向流程为import base64 CUSTOM_ALPHABET ZYXABCDEFGHIJKLMNOPQRSTUVWXYZzyxabcdefghijklmnopqrstuvwxyz0123456789/ # 示例 XOR_KEY 0x12 TARGET EwP2bB1j... # 示例 def custom_b64encode(data): # 使用自定义表编码 ...不过我们不直接写编码而是写解码。由于Base64编码本质是把3字节映射成4字节解码就是把4字节映射回3字节。可以用Python标准库的base64.b64decode配合自定义表的翻译import base64 CUSTOM_ALPHABET ... STD_ALPHABET ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789/ TRANS_TABLE str.maketrans(CUSTOM_ALPHABET, STD_ALPHABET) def custom_b64decode(s): s s.translate(TRANS_TABLE) return base64.b64decode(s) encoded TARGET.encode() decoded_bytes custom_b64decode(encoded) flag bytes([b ^ XOR_KEY for b in decoded_bytes]).decode() print(flag)如果你提取的字母表正好是标准表那translate这步可以省略直接base64.b64decode。但大部分变种题不会这么简单。注意str.maketrans要求两个字符串长度相等都是64字节否则会报错。如果你提取的表里混入了\x00或者长度不对先清洗数据。跑完脚本如果输出形如flag{...}的字符串那就恭喜答案到手。如果输出是乱码回头看三处字母表抄错、异或key找错目标串、或者目标串本身不是编码结果而是其他加密结果。4.3 如果调用链太绕用IDA远程调试跟栈Frida适合看关键函数结果但如果遇到算法复杂、调用链深我还会用IDA远程调试。流程简要记录一下模拟器必须是root权限把IDA的android_server拷贝到/data/local/tmp。执行./android_server -p 23946监听端口。终端里做adb forward tcp:23946 tcp:23946端口转发。IDA选择Debugger - Remote ARM Android设置host为127.0.0.1、端口23946。附加进程或启动调试下断点。断点位置选择有讲究。我一般先下在strcmp、memcmp看比较参数可以帮助快速定位目标串。然后再逆着调用栈找上层是哪段算法产生了这个值。如果so有反调试会在ptrace上拦截此时直接在ptrace调用处改返回值绕过或者干脆用静态patch。5. 新手最容易踩的坑和排查速查表5.1 闪退与so加载失败不少人在模拟器上一运行APK就闪退日志显示java.lang.UnsatisfiedLinkError: dlopen failed: library libnative-lib.so not found原因一般是两种APK是armeabi-v7a的so而模拟器用的是x86或arm64架构直接不兼容。某些模拟器支持arm翻译但需要在系统设置里开启。处理方法换一个原生支持对应架构的模拟器或者使用真机测试。CTF题目一般只要求分析so不一定非要跑起来。但如果需要动态调试尽量找一个arm64的模拟环境。另外如果APK在安装时提示解析失败可能是文件下载损坏重新解压/下载即可。5.2 Hook不到Java方法时怎么办Frida脚本报ClassNotFoundException或MethodNotFound先检查包名和类名。jadx反编译出来的包名可能带debug、test后缀直接Java.use(com.example.testre.MainActivity)可能不对要用jadx里的实际包路径。如果类名和包名都对了还是hook不到可能是方法名被混淆也可能是native方法没有对应的可调用Java代码。此时直接hook.so层导出函数效果一样var nativeAddr Module.findExportByName(libnative-lib.so, Java_com_example_testre_MainActivity_getFlag); Interceptor.attach(nativeAddr, { onEnter: function(args) { // args[2] 是 jstring, 通过Java.vm.getEnv()读取 }, onLeave: function(retval) { // 打印返回值 } });这种方式不依赖Java类名更容易成功。5.3 字符串提取和字节序的坑在IDA里看到的目标串可能不是正常ASCII排列。比如在.rodata里以.word存储那实际读出来是倒序的字节。我踩过一次坑用IDA的String窗口没找到目标串后来发现它是按32位整数存储的比如0x6C6C6548在内存中表现为H e l l手动整理才能得到Hello。稳妥做法是直接在IDA的Hex窗口选中数据Copy - Select all然后粘贴到Python里按大小端解码。遇到大端或小端不对调一下bytes转换方式。如果目标是动态生成的此时必须用调试器dump堆内存或者hook字符串比较函数。target字符串经过编码后可能不是可见字符打印时注意转义。5.4 快速自查表问题排查方向常用命令/工具文件类型不确定看魔数、扩展名、压缩结构file、binwalkJava层看不到入口搜索onCreate、native关键字jadx -d out testre.apkso导入表找不到Java函数看JNI_OnLoad动态注册nm -D、IDA Exports找不到目标串检查是否按数组、宽字符存储xxd、Python解析动态调试被反调试断ptrace、修改返回值IDA、Frida解码乱码检查字母表、异或key、字节序Python脚本6. 一点个人体会testre这道题放在今天来看难度不算高但它非常适合新手完整走一遍“Java层定位-so层分析-算法还原-脚本验证”的闭环。我自己做完这题之后再看很多游戏逆向的native函数思路都是通着的游戏里也经常有so层的校验逻辑同样是通过JNI暴露给Unity/UE同样是查表、异或、Base64、AES这些基本操作。区别只是游戏工程更大、混淆更强、反调试更狠但核心分析方法不会变。最后分享一个我后来一直在用的小技巧做逆向题时不要直接搜flag先搜“比较点”。无论算法多花哨最终一定有一个地方会把计算结果和目标值做比较。找到这个比较点逆向一半就完成了。剩下的就是把从输入到比较点之间的每一个变换都列出来然后用Python反向实现。这个方法在攻防世界的逆向题里适用性很高我靠它在后面几道中等难度的题上省了不少时间。如果你现在正卡在testre建议按这个流程自己走一遍不要急着看答案卡几个小时之后再看经验贴收获会完全不同。
返回列表