攻防世界逆向区有一道叫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.dex:Java层代码,等会儿交给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、binwalk |
| Java层看不到入口 | 搜索onCreate、native关键字 | jadx -d out testre.apk |
| so导入表找不到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,建议按这个流程自己走一遍,不要急着看答案,卡几个小时之后再看经验贴,收获会完全不同。