
前阵子在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由两部分组成KSAKey Scheduling Algorithm用密钥初始化256字节的S盒PRGAPseudo-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拿到flag4.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 hookptrace让它故意返回-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盒初始化特征后面基本就是套公式剩下的工作全在提取数据和写脚本上。