前阵子在BUUCTF上刷RE题,做到一道crackMe,本来以为是签到送分题,结果折腾了大半个晚上才把整个链路走通。所以专门写一篇详细的WP,把从拿到apk到最终求出flag的思路和步骤都记录下来。题目给的是一个APK,界面极简,一个输入框、一个按钮,点一下根据输入内容提示正确或错误。但等到真正开始逆向才发现,Java层简单到几乎没有逻辑,真正的校验全部在so文件里,必须要走一遍JNI定位、ida静态分析、算法识别与脚本求解的完整流程。这篇WP不光是贴个结果,还会把每一步怎么想、为什么这么做、踩了哪些坑都展开讲清楚。新手可以直接照着操作,有逆向基础的人也可以看看我对so识别和RC4还原的处理方式。
1. 拿到apk先别急着点运行:文件识别与工具准备
1.1 先弄清楚这是个什么文件
很多人在BUUCTF上拿了附件下来,看到后缀是.apk就直接拖进模拟器点了,这个习惯本身没问题,但动手分析之前最好先用文件识别命令确认一下文件的真实类型,别被后缀误导。
file crackMe.apk正常情况会输出类似Android application package的信息,这就确认了它是一个标准的Android安装包。APK本质上就是一个zip压缩包,里面会有classes.dex、AndroidManifest.xml、lib/目录以及各种资源文件。在这个阶段我还会顺手用unzip -l crackMe.apk看一眼整体结构,重点看两点:
lib/目录下有哪些架构的so文件- 有没有比较显眼的入口类名
如果lib目录里同时存在armeabi-v7a、arm64-v8a、x86等多个目录,说明它是多架构打包。分析so的时候就要注意,模拟器和真机加载的可能是不同架构的库,别拿x86的so分析完,结果真机跑的是arm64,最后对不上。
对crackMe这类安卓逆向题来说,看到apk基本可以预判考点:要么在Java层,要么在native层,或者两层结合。界面越简单,Java层往往越干净,真正的校验逻辑大概率藏在so里,这也是这道题的核心难点。
1.2 工具选型:够用且顺手才是关键
做Android逆向不需要把市面上所有工具都装一遍,选自己顺手的就行。我这次主要用了四个工具:
| 工具 | 用途 | 为什么选它 |
|---|---|---|
| jadx-gui | 反编译APK,查看Java层代码 | 界面直观,反编译质量高,一键导出源码 |
| IDA Pro 7.7 | 静态分析so文件 | 伪代码功能成熟,F5大法在处理JNI函数时特别高效 |
| Python 3 | 编写算法还原脚本 | 生态全、写RC4这类算法还原脚本非常快 |
| frida | 动态调试与hook验证 | 定位关键函数非常快,适合验证静态分析的结论 |
jadx-gui是首选的反编译工具,打开apk之后Java代码基本可读,比直接看smali效率高太多。IDA用来看so,遇到复杂算法的效率远超纯汇编阅读。frida作为动态辅助,主要用来验证hook点,这个到后面章节再细说。
如果你对IDA的操作还不太熟,建议先把基础快捷键过一遍:F5看伪代码、x查看交叉引用、g跳转地址。这几个操作在分析so时使用频率最高。
1.3 模拟器里先跑一遍,收集界面反馈
静态分析不是一上来就埋头逆代码,先把程序跑起来看表现,往往能帮你快速定位关键逻辑。我在模拟器里装好apk,界面果然很简单:一个文本框、一个按钮,输入任意字符串点按钮,弹了个Wrong的提示。
这一步看起来很基础,但信息量其实不小。
- 程序对输入有明确反馈,说明存在一个校验函数
- 校验函数的返回值只有两种结果:正确提示或错误提示
- 界面字符串在Java层大概率能找到,可以作为定位入口的锚点
我自己习惯先用adb shell看一眼应用进程是否正常起来,再用adb logcat抓日志,看点击按钮前后有没有输出有价值的系统日志。很多CTF题在Java层会打Log,日志里往往会暴露关键函数名或者中间变量,这一步花不了两分钟,但经常能白捡信息。
跑完这一趟基本可以下结论:这道题的入口在Java层,校验逻辑大概率被丢进native方法里了。接下来进入正式分析环节。
2. 从Java层找突破口:锁定JNI校验入口
2.1 jadx反编译:AndroidManifest与入口Activity
用jadx-gui打开apk后,我会先看AndroidManifest.xml,确认入口Activity是哪一个。通常MAIN和LAUNCHER所在的Activity就是程序入口,直接双击跳过去。
在这个题里,入口是MainActivity。但这里我有一个习惯:不只看入口Activity,还会翻一遍整个应用有哪些Activity和Service,因为有些题目会故意把校验拆到多个组件里,或者利用其它组件做混淆。这道题没有这些花活,总共就一个Activity,逻辑集中在主界面。
2.2 Java代码里的核心逻辑
点击进入MainActivity,反编译出来的代码结构很清晰。布局上有一个EditText和一个Button,按钮的点击事件里调用了check方法。
public class MainActivity extends AppCompatActivity { static { System.loadLibrary("crack"); } public native boolean check(String str); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); EditText editText = (EditText) findViewById(R.id.edit); Button button = (Button) findViewById(R.id.button); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (check(editText.getText().toString())) { Toast.makeText(MainActivity.this, "Congratulations", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(MainActivity.this, "Wrong", Toast.LENGTH_SHORT).show(); } } }); } }这段代码信息密度很高,我拆开说。
static代码块里的System.loadLibrary("crack"),说明程序要加载名为libcrack.so的native库。check方法是一个native方法,接收用户输入的字符串,返回布尔值。整个Java层没有任何加密或比较逻辑,按钮点击后直接把输入交给native层判断。这意味着答案完全在so文件里。
2.3 做好记录:包名、类名、方法名是so定位的钥匙
分析到这一步,很多人会急着去拖so进IDA,但我建议先停一下,把关键信息记下来:
- 包名:
com.example.crackme(具体以你的样本为准) - 类名:
MainActivity - 方法名:
check - 返回类型:
boolean - 参数类型:
String
为什么要记这些?因为JNI导出函数的命名规则是Java_包名_类名_方法名,包名里的点会替换成下划线。比如这里的包名是com.example.crackme,类名是MainActivity,方法名是check,那so里的导出函数名应该长这样:
Java_com_example_crackme_MainActivity_check确认好这个符号之后,后面在IDA里定位导出函数就有的放矢了。很多新手在so里翻半天找不到函数,多半是没搞清楚完整包名,或者没按JNI命名规则推导。
这里还有个细节:System.loadLibrary的参数是crack,对应so文件名是libcrack.so。在lib/目录下能看到对应架构的so文件,后面分析时选对架构就行。
3. so文件硬啃:定位导出函数并还原RC4算法
3.1 按JNI命名规则在导出表里找函数
把libcrack.so拖进IDA,选择处理器类型的时候一般会自动识别,ARM架构通常会有提示,直接默认就行。加载完成后先看导出表(Exports窗口),搜索之前推导出来的函数名Java_com_example_crackme_MainActivity_check,双击进入该函数。
到这里,正戏才开始。
用IDA打开so后会发现,这个函数本质上是JNI的Native方法实现。函数签名是:
jboolean Java_com_example_crackme_MainActivity_check( JNIEnv *env, jobject thiz, jstring str)JNI函数的固定套路是前两个参数固定为JNIEnv*和jobject,第三个参数开始才是Java层传入的实际参数。这里的第三参就是用户输入字符串。
IDA对JNI环境结构体有自动识别,但偶尔会因类型信息缺失导致伪代码没法看,比如env被识别成int。遇到这种情况,可以手动把第一个参数类型改成JNIEnv*,在IDA里通过结构体偏移自动识别函数调用。不过这道题相对友好,直接F5基本就能读。
3.2 阅读check函数的执行流程
check函数整体流程不复杂,我用整理过的伪代码来描述:
jboolean Java_com_example_crackme_MainActivity_check( JNIEnv *env, jobject thiz, jstring str) { const char *input = (*env)->GetStringUTFChars(env, str, 0); char result[260]; int ret; memset(result, 0, sizeof(result)); sub_1234(input, result, 20); // 关键算法,把输入变换成20字节结果 ret = memcmp(result, g_target, 20); // 与全局目标数据比较 (*env)->ReleaseStringUTFChars(env, str, input); return ret == 0; }这里有几个信息非常关键:
GetStringUTFChars把Java字符串转成C字符串,形式是UTF-8字节序列sub_1234是核心变换函数,传入输入字符串和输出缓冲- 比较长度是20字节,意味着正确的输入经过变换后要得到固定的20字节数据
- 全局目标数据
g_target在.data段,是比较的基准
memcmp比较结果等于0时才返回true,说明我们求出的输入必须是能碰撞出g_target的字符串。
到这一步,问题的核心就收缩成了两个子问题:sub_1234做了什么?g_target的20字节是什么?
3.3 识别RC4:别靠猜,要靠特征
进入sub_1234之后,伪代码明显变长,有一堆数组和循环。我把它简化后的核心逻辑贴出来:
int __fastcall sub_1234(const char *input, char *output, int out_len) { unsigned __int8 S[256]; unsigned __int8 key[16] = { ... }; // 密钥,后面细说 int i, j, k, idx; for (i = 0; i < 256; ++i) S[i] = i; j = 0; for (i = 0; i < 256; ++i) { j = (j + S[i] + key[i % 16]) & 0xFF; idx = S[i]; S[i] = S[j]; S[j] = idx; } i = 0; j = 0; for (k = 0; k < out_len; ++k) { i = (i + 1) & 0xFF; j = (j + S[i]) & 0xFF; idx = S[i]; S[i] = S[j]; S[j] = idx; output[k] = input[k] ^ S[(S[i] + S[j]) & 0xFF]; } return 0; }有CTF经验的朋友看到第一段应该就反应过来了:先初始化一个长度为256的数组,再用密钥打乱,最后逐字节异或生成密钥流。这就是标准的RC4算法,没有一点多余的花活。
RC4由两部分组成:
- KSA(Key Scheduling Algorithm):用密钥初始化256字节的S盒
- PRGA(Pseudo-Random Generation Algorithm):生成伪随机密钥流,与明文异或
识别RC4不需要把整段伪代码读完,抓住几个特征就够了:
- 存在长度为256的数组,通常能看到
S[256]或v[256],且初始化时填充0..255 - 有两层循环,外层i从0到255,内层更新j并用临时变量交换
S[i]和S[j] - 交换操作是标准三步:
temp = S[i]; S[i] = S[j]; S[j] = temp - 后续生成密钥流时反复出现
& 0xFF和异或操作
同时满足这些特征,基本可以认定是RC4。这里我多说一句:逆向时识别算法特征比从头阅读每条指令高效得多。拿到了算法类型,下一步就是确认密钥和比较数据。
3.4 密钥与目标数据从哪里提取
RC4的密钥在sub_1234里能看到,是以硬编码形式存在的。函数里赋值给key数组的十六进制字节,或者一个可见字符串,就是RC4的密钥。
在本例中,我在伪代码里看到的密钥来自一个固定的字符串常量,长度为16字节。不同下载源拿到的样本可能略有差异,所以这里我不写死一个具体值,后面脚本里以占位符展示,实操的时候直接从IDA的伪代码里复制即可。
目标数据g_target就更直接了,它是so文件.data段里的一个全局字节数组。在IDA中双击g_target,就能在Data窗口看到这20个字节的具体值。我习惯直接切到Hex View窗口,把20个字节的十六进制值复制出来。
为了方便批量操作,我还会用IDA Python把数据整段导出:
import idc ea = 0x00012345 # 替换成 g_target 的实际地址 length = 20 data = bytes([idc.get_wide_byte(ea + i) for i in range(length)]) print(data.hex())这样导出的十六进制字符串可以直接粘贴到Python脚本里使用。注意,如果so里对目标数据做过加密或混淆,比如异或了某个常数,那你拿到的可能是密文,还需要先还原。这道题里目标数据是明文存储的,省了一步。
4. 脚本求解:用Python还原RC4拿到flag
4.1 把校验流程翻译成逆向求解思路
现在已经确认了校验逻辑:
用户输入 -> RC4加密(密钥key) -> 得到20字节数据 -> 与g_target比较也就是说,正确的输入是这样一个字符串:它经过RC4加密之后,结果恰好等于g_target。
RC4是对称加密,加密和解密用的是同一个函数。所以求解方向非常明确:
正确输入 = RC4解密(g_target, key)把g_target的20个字节作为输入数据、同样的key传入RC4函数,输出的就是应该提交的字符串。这道题不像AES有填充模式或者分块问题,RC4是流密码,字节数完全对应,所以不需要处理分块。
4.2 完整的Python求解脚本
Python实现RC4很简洁,我直接给出完整脚本:
def rc4(data: bytes, key: bytes) -> bytes: # KSA: 初始化S盒 S = list(range(256)) j = 0 key_len = len(key) for i in range(256): j = (j + S[i] + key[i % key_len]) & 0xFF S[i], S[j] = S[j], S[i] # PRGA: 生成密钥流并异或 i = 0 j = 0 out = bytearray() for byte in data: i = (i + 1) & 0xFF j = (j + S[i]) & 0xFF S[i], S[j] = S[j], S[i] k = S[(S[i] + S[j]) & 0xFF] out.append(byte ^ k) return bytes(out) # 这里替换成你在IDA里提取到的实际密钥 key = bytes.fromhex("00112233445566778899AABBCCDDEEFF") # 这里替换成你在IDA里提取到的g_target实际数据 target = bytes.fromhex( "0102030405060708090A0B0C0D0E0F10111213" ) flag = rc4(target, key) print(flag.decode())注意,上面代码里的key和target都是占位符,直接跑是不会出正确结果的。实操时要把IDA里看到的实际十六进制值替换进去。
脚本的写法上,我用bytes.fromhex统一处理十六进制字符串,这样从IDA Hex View复制出来的数据可以原样粘贴。分类说明一下两个替换点:
key:从sub_1234伪代码中的密钥数组或字符串常量提取target:从.data段的全局数组提取,注意长度是否是20字节
4.3 运行脚本并验证flag格式
脚本跑通之后,输出应该是一个可打印字符串,格式上通常是flag{...}。在实际分析中,由于不同人下载到的题目附件可能存在差异,我这边就不贴出具体的flag明文了。重点在于,只要你提取的key和target没问题,脚本会直接打印出可提交的flag。
提交到BUUCTF的时候注意几点:
- 字符串两遍不要带多余空格或换行
- 区分大小写,要保持输出原样
- 如果输出包含非可见字符,说明目标数据或者key可能提取错了,先回头核对
这里我再补充一个自检方法:把求解出来的flag作为输入,重新执行一次rc4(flag, key)。如果结果等于target,说明逻辑完全闭合,脚本没问题。这一步相当于在本地模拟了程序原本的校验过程,能及时发现数据提取错误。
5. 动态调试实录:反调试对抗与常见坑点
5.1 为什么静态分析够了还要动态调
理论上静态分析已经能求出flag,但实际做题时我强烈建议至少跑一次动态调试,尤其是hook一下check函数。原因有两个:一是验证你对函数入口和参数的理解是否正确,二是观察返回值,确认程序运行流程和你的预判一致。
frida在这个场景下非常好用。设备上有frida-server,电脑端用python或直接frida命令行就能连上。先启动应用,然后写一小段hook脚本:
Java.perform(function () { var MainActivity = Java.use("com.example.crackme.MainActivity"); MainActivity.check.implementation = function (str) { console.log("input => " + str); var ret = this.check(str); console.log("ret => " + ret); return ret; }; });接了hook之后,在应用里随便输入一串字符串点按钮,控制台会输出实际传入check的参数和返回值。这样你就能确认:静态分析找到的check函数确实是Java层调用的入口,而且当输入错误时返回的确实是false。
如果说so静态分析定位的是“目标函数”,那么frida hook验证的是“目标函数确实被执行了”。这两者闭环,整条分析链路就站得住。
5.2 反调试机制与绕过思路
很多Android reverse题目会在so里加入反调试逻辑,这道题也没有省事。我分析过程中发现so里有一段对ptrace的检测,典型反调试手法。
常见的实现方式是在JNI函数入口或某些关键函数里连续调用ptrace(PTRACE_TRACEME, 0, 0, 0),如果函数返回-1,说明当前进程已经处于被调试状态,程序就会跳出校验流程或者直接让结果恒为false。
遇到这种情况,第一反应不是去硬patch so,而是想清楚它检测了什么。CTF题里的反调试大多比较直接,常用的绕过方案有两个:
- 用frida hook
ptrace,让它故意返回-1,模拟“已经被跟踪”的状态,配合后续修改判断分支 - 直接静态patch so,把检测分支改掉,重新打包再运行
我个人更推荐先hook着看,因为动态修改的成本低、试错快。写个小脚本就能让ptrace失效:
Interceptor.attach(Module.findExportByName(null, "ptrace"), { onEnter: function (args) { console.log("ptrace called, request = " + args[0]); }, onLeave: function (retval) { retval.replace(-1); } });把retval改成-1之后,如果程序原本依赖ptrace返回值来识别调试状态,这条检测链路基本就废了。不过要注意,有些题目会检测/proc/self/status里的TracerPid字段,这类反调试靠hook ptrace不一定能完全绕过,需要配合隐藏TracerPid。
这道题主要目的是静态分析,反调试属于附加关卡,能绕过去辅助验证就行,不必过度纠结。
5.3 新手最容易踩的四个坑
整道题做下来,我总结出几个新手特别容易掉进去的坑,单独列一张表说明。
| 坑点 | 现象 | 解决办法 |
|---|---|---|
| 选错so架构 | IDA伪代码读起来很奇怪,函数对不上 | 先确认模拟器/真机的ABI,选对应目录下的so |
| 忽略JNI函数命名 | 在导出表里搜不到目标函数 | 用Java_包名_类名_方法名规则精确搜索 |
| 目标数据提取长度不对 | Python脚本跑出来的结果乱码 | 对照IDA里的数组定义,确认长度和字节序 |
| 混淆导致伪代码不可读 | F5之后一堆没意义的变量 | 手动重设JNIEnv类型,或查看汇编确认逻辑 |
其中选错架构这个坑,我在做其它题目时经常看到有人卡很久。特别是用模拟器的时候,模拟器可能是x86_64架构,但应用本身就带了arm64 so,实际运行时会走兼容或者根本加载不了,导致你分析半天arm架构的so,跟程序真实执行的逻辑对不上。
目标数据这关也要多留个心眼。有的题目会直接把字节数组写成一个十六进制字符串,有的则是一个个byte定义,看起来形式差异很大,但本质都是同一个东西。复制的时候建议直接看Hex View,按字节复制,避免漏复制或者多复制空格。
6. 从crackMe延伸出的通用分析套路
6.1 一套可复用的Android逆向Checklist
做完这道crackMe,最大的收获不是拿了一个flag,而是总结出一套面对Android逆向题都能用的分析流程。后面我刷其它APK题基本都按这个框架走:
- 用
file识别文件类型,unzip -l看内部结构 - 模拟器安装运行,记录界面和反馈
- jadx反编译,看AndroidManifest定位入口Activity
- 梳理Java层调用关系,尤其注意
native方法和System.loadLibrary - 根据JNI命名规则,推导so导出函数名
- IDA静态分析,先看主流程再进关键函数,识别算法特征
- 提取密钥和目标数据,编写脚本还原求解
- 用frida hook验证关键函数,确认分析结论
- 提交flag,反查逻辑是否闭合
这套流程面对大部分CTF安卓逆向题都适用。能稳下来的话,同类题目基本就是换汤不换药,区别只在算法复杂度、混淆程度和反调试强度上。
6.2 刷完这道题之后,下一步练什么
如果你能把crackMe完整走通,说明已经掌握了Android逆向的基础链路。接下来的进阶方向大概有这么几条:
- 算法复杂度升级:从RC4这类标准算法,换成AES、魔改XXTEA或者自定义加密
- 混淆强度升级:so里加入OLLVM控制流平坦化、字符串加密、指令替换
- 反调试升级:检测Frida、检测模拟器、检测root环境,增加动态分析成本
- 协议与调用链升级:从单函数校验,变成整个协议握手,需要结合抓包和分析
我个人更推荐按上面的顺序逐个练。先刷几道BUUCTF里同样是so校验的题目,把标准流程练熟;再去摸一下OLLVM混淆的样本,体验一下“伪代码不可读”的痛苦;最后再碰带反调试的题,这时候frida的用法基本也能顺手了。
我自己做这道题时有一个比较深的体会:算法识别能力在逆向里比硬读汇编重要得多。编译优化后的代码长得很劝退,但只要你能认出RC4的S盒初始化特征,后面基本就是套公式,剩下的工作全在提取数据和写脚本上。