前阵子处理一个内部安全评估的样本目标App叫OASIS功能逻辑看着不复杂但登录接口的加密参数怎么都找不到生成地方。Java层翻了个底朝天全是空壳最后顺藤摸瓜在lib目录下发现一个liboasis.so才意识到真正的算法被塞进了native层。如果你也卡在这种“Java层干净、so层才有货”的境地里这篇SO逆向入门实战教程就是给你准备的。这篇文章会以OASIS为案例完整走一遍“定位native函数 - 静态分析so - 动态调试验证 - 算法还原 - 代码验证”的全流程。适合刚接触安卓逆向、对ELF和汇编还不熟、但想快速上手so分析的同学。文章里所有操作都基于自有设备或有授权的测试环境合规边界先划清楚别拿真实商业App去练手。1. 内容整体设计与思路拆解1.1 为什么把OASIS选作入门目标OASIS这个样本我评估过很多次每次都觉得它是教科书级的入门靶子。它刻意保留了最典型的逆向痛点Java层只留一个native方法声明所有核心逻辑全部下沉到so层。这种设计在真实商业App里太常见了厂商会把签名、加密、风控参数全部移入native层企图用“汇编可读性差”来阻挡分析者。OASIS的so文件没有做加壳保护和虚拟化混淆只做了最基本的strip去符号处理。它内部用到的算法也是常见的AESBase64组合没有自定义魔改算法。这意味着分析者可以把精力集中在“如何定位函数、如何看汇编、如何还原逻辑”这些核心技能上而不是被混淆对抗耗掉全部耐心。选OASIS作为入门目标的第二个原因是它的函数调用链足够简单。从Java的native方法声明到so里的JNI导出函数再到内部调用的AES加密函数整条链路一目了然。等把这条链路彻底吃透再去碰ollvm混淆、反调试、字符串加密这些进阶特性就有底子了。1.2 SO逆向的整体工作流与关键决策拿到一个so文件我习惯把分析过程拆成四个阶段定位、静态、动态、还原。定位阶段要回答“这个so文件是干什么的”以及“Java层是哪个方法调到了这里”。通过jadx反编译APK看到native方法声明就能推算出JNI函数在导出表中的命名格式。静态分析阶段是不运行程序、直接对so文件本身做拆解。用Ghidra或IDA打开so看导出表、读反汇编、识别字符串和算法特征。静态分析的优势是全面缺点是遇到混淆或反调试时会失真。动态分析阶段让程序真实跑起来用Frida hook函数的入参和返回值验证静态分析得出的结论。动态分析能拿到最真实的运行时数据但前提是目标设备环境可控模拟器或root真机都行。还原阶段是把分析结论翻译成可复现的代码通常用Python或Go实现一份等价逻辑用来跑通协议或者生成数据。一个常见误区是新手一上来就开IDA硬刚汇编几小时下来没有任何产出。正确顺序应该先用动态hook拿到函数级输入输出建立“黑盒认识”再回到静态分析查内部实现效率会高很多。1.3 工具链选型与各环节分工我的交叉验证工具组合固定是这几样工具用途为什么选它jadx反编译APK定位native方法图形化界面友好反编译准确率高Ghidra静态分析so文件免费开源反编译C伪代码能力强IDA Pro静态反汇编动态调试交互式反汇编体验最好浮动分析强大Frida动态hook、函数插桩跨平台、脚本灵活免重打包Python算法还原、验证生态完备密码学库齐全工具之间不是替代关系而是互补关系。我用Ghidra做初筛用IDA跟进细节用Frida验证结论三者的结论能互相印证。静态分析出的调用关系如果是错的动态hook立刻能发现。2. 核心细节解析与实操要点2.1 先搞懂ELF文件结构与JNI函数导出规则so文件本质是ELF格式理解它不需要背完整个规范但有几个关键结构必须心里有数。头部记录文件类型和入口点程序头表描述段segment如何映射到内存节头表描述节section的信息比如符号表、字符串表、重定位表。分析so时字符串表是宝贝。函数名、错误信息、算法常量、密钥材料全在里面。用Ghidra打开一个so先看Strings窗口往往能直接找到关键线索。JNI函数导出是有固定命名规则的Java层的全限定类名加点换成下划线再加上包名前缀。比如这么一行Java声明package com.oasis.security; public class NativeBridge { public static native String enc(String input); }对应到so里的导出函数名就是Java_com_oasis_security_NativeBridge_enc有了这个函数名静态分析的第一步就有了抓手。这也是为什么建议分析so前一定要先看Java层的native声明它能直接告诉我们导出函数的准确名字。2.2 从Java层追踪到native方法的定位思路整个定位流程我会先做一次全局梳理把App里所有native方法的声明位置全部找出来。用jadx打开APK后搜索native关键字把所有native方法列出来记录每个方法所在的类、方法名、签名以及它们被哪段业务逻辑调用。OASIS这个样本里native方法只有两个一个是enc另一个是dec。看到enc和dec成对出现基本可以断定是AES对称加密的封装。此时可以做一个初步推断so内部必然实现了AES相关的算法逻辑而且会保存一个固定密钥。接下来要确认Java层哪些地方调用了enc。在jadx里按住方法名跳转查看引用关系通常会看到网络层在构造请求之前先调用enc处理敏感字段。我们可以在调用点下断点然后在动态分析阶段观察传入的明文和拿到的密文。这种“先看Java、再推native”的做法能把so分析的范围缩小很多从“看整个so”变成“看某一个导出函数”。2.3 逆向分析前必会的ARM汇编快速入门大多数so是ARM架构的和x86的语法风格完全不同。说实话如果想等到把ARM汇编全部学完再动手那基本永远入不了门。我的建议是先背住这几条核心指令就足以应付大多数入门级的so分析MOV寄存器赋值相当于等号LDR/STR从内存加载到寄存器 / 从寄存器存入内存BL/BX跳转到子函数相当于C语言的函数调用ADD/SUB加减运算CMP/B比较和条件分支相当于if和跳转在Ghidra里看到BL后面跟一个函数名就先把它当作一个函数调用来理解。看到LDR R0, [SP, #0x10]就理解为“从栈偏移0x10处取一个值放到R0寄存器”。还有一套重要的约定ARM过程调用标准AAPCS规定前四个参数存放在R0到R3寄存器剩余参数存放在栈上。函数返回值存放在R0。JNI函数有个特殊之处前两个参数是固定的JNIEnv指针和Java对象引用真正的业务参数从第三个参数开始也就是从R2寄存器开始。掌握了这套规则看反汇编时就有了一条清晰的主线先看R0到R3怎么被赋值再追踪内部的BL调用最后看返回值怎么放回R0。2.4 字符串定位与算法范式识别的经验静态分析时我总喜欢先用字符串窗口扫一遍很多秘密其实就藏在肉眼可见的字符串里。OASIS这个so里我一眼就看到了AES相关的S盒特征数据一组16x16的十六进制常量以及一个看起来很像密钥的16字节字符串“OaSiS_kEy_2o24!!”。在未加混淆的so里硬编码密钥就是这种暴力的直接存在。除了密钥错误提示也是很好的定位线索。比如看到“invalid input length”这样的字符串就在反汇编里搜索交叉引用XREF直接跳到引用它的代码位置往往就是加解密逻辑的核心区域。算法范式识别这件事靠的是积累。AES的S盒、Base64的索引表、MD5的四个魔数0x67452301等都是有固定特征的。多分析几个样本看到这些常量就能立刻判断出so里用了什么算法。这里有个小技巧用Ghidra的插件或工具findcrypt来自动扫算法常量一个命令就能找出AES、RC4、MD5等常见算法的特征。3. 实操过程与核心环节实现3.1 环境准备模拟器、Frida与逆向分析工具的搭建环境搭对了后面能省不少心。我用了Pixel系列的模拟器镜像带Google APIs版本因为这类镜像默认可以开启root权限对Frida调试非常友好。Electron模拟器、夜神模拟器也能用但如果涉及CPU指令集验证优先用真机和ARM模拟器镜像。安装Frida分两端PC端跟模拟器端。PC端直接用pip装模拟器端需要下载对应版本的frida-server。注意版本号必须一致否则连不上。# PC端安装 pip install frida-tools frida # 查看版本 frida --version # 模拟器端推送frida-server adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server 装好后用frida-ps -U验证连接能看到进程列表就说明环境通了。然后在jadx里确认OASIS的包名比如com.oasis.security。静态分析工具我同时装了Ghidra和IDA。Ghidra免费且分析能力强IDA在交互式反汇编上更顺手。把liboasis.so分别拖进两个工具交叉对照结果这样能减少单工具误判的概率。3.2 静态分析拿到liboasis.so后的第一步先理解so是什么架构直接运行文件命令file liboasis.so # 输出类似ELF 32-bit LSB shared object, ARM如果是ARM是32位还是64位分析方式会略有不同但JNI导出表的结构基本一致。接下来用nm命令查看动态符号表如果so没有strip掉动态符号可以这样nm -D liboasis.so | grep Java_如果输出里找到了Java_com_oasis_security_NativeBridge_enc和dec说明符号还在分析难度直线下降。如果被strip掉了就改用Ghidra的符号扫描功能让它自动识别JNI导出。在Ghidra里导入so后等待分析完成直接打开Export窗口搜索enc这个关键词。找到导出函数后双击进入反汇编视图按一下反编译快捷键F5就能看到C伪代码。OASIS样本的enc函数伪代码通常会是这样jstring Java_com_oasis_security_NativeBridge_enc(JNIEnv *env, jclass clazz, jstring input) { char *plain (*env)-GetStringUTFChars(env, input, NULL); unsigned char key[16] {0x4f, 0x61, 0x53, 0x69, ...}; unsigned char cipher[256]; int len strlen(plain); aes_encrypt((unsigned char*)plain, len, key, cipher); char *base64 base64_encode(cipher, aligned_len); return (*env)-NewStringUTF(env, base64); }看到这样的结构核心工作就变成了确认AES加密的具体模式和填充方式确认Base64编码细节还原出可复现的Python代码。AES模式怎么确认关键看加密的逻辑里是否对IV有处理。OASIS的样本里没有IV的操作说明是ECB模式。ECB模式是最简单也最不安全的AES模式同样的明文会得到同样的密文这也是为什么很多App会用因为它最容易被还原。3.3 动态调试用Frida验证函数入参与返回值静态分析只能给出“可能是什么”动态调试才能给出“实际是什么”。我用Frida编写一个很小的hook脚本直接在Java层hookenc方法。Java.perform(function () { var NativeBridge Java.use(com.oasis.security.NativeBridge); NativeBridge.enc.implementation function (input) { console.log([*] enc called, input input); var result this.enc(input); console.log([*] enc result result); return result; }; });保存为hook_enc.js用Frida启动frida -U -f com.oasis.security -l hook_enc.jsApp启动后我手动触发一次登录操作控制台立刻打印出enc方法的输入和输出。比如输入{username:test,password:123456}输出U2FsdGVkX1/d2o8nQjfVBVjJhJXVcj9k。有了这个真实样本就可以对照静态分析结果。字符串指纹对上了AES的密钥对上了Base64的输出格式也对上了那么还原的可靠性就很高。动态调试更大的价值在于处理那些“看不清”的东西比如哪段代码才是真正的加密入口。我可以在so层hookaes_encrypt如果能在加密函数前打印出明文的hex转储就能100%确认这条调用链。Interceptor.attach(Module.findExportByName(liboasis.so, aes_encrypt), { onEnter: function (args) { console.log([*] aes_encrypt enter, plaintext hex hexdump(args[0])); } });hook到了静态分析结论就被锁死了。3.4 算法还原从native加密函数到完整Python实现到此分析的核心产出就是一份等价的Python实现。OASIS的加密逻辑我已经能确定AES-128-ECB加密密钥是硬编码的16字节字符串OaSiS_kEy_2o24!!输出做Base64编码。等等还有个细节没确认填充方式。常见的AES填充有PKCS7和ZeroPadding两种。检查方法是看输入长度不是16倍数时加密后的长度变化。如果密文是32字节说明填充到了32如果密文是16字节说明可能是ZeroPadding或者只加密一次。我从动态hook到的样本长度判断出OASIS用的是PKCS7填充这正是OpenSSL的默认填充规则。Python实现可以很简单import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import pad def oasis_encrypt(plaintext: str) - str: key bOaSiS_kEy_2o24!! cipher AES.new(key, AES.MODE_ECB) padded pad(plaintext.encode(utf-8), AES.block_size, stylepkcs7) encrypted cipher.encrypt(padded) return base64.b64encode(encrypted).decode(utf-8) if __name__ __main__: test_input {username:test,password:123456} print(oasis_encrypt(test_input))拿这个脚本跑出来的结果和Frida打印的结果对比如果完全一致就说明算法还原成功了。这就是逆向分析的交付物可以脱离App独立生成一份协议数据。这里我会刻意强调真实样本里我还会检查是不是有自定义的异或变换、字节反转等魔改逻辑。用动态hook到的输出和纯AES标准输出对比一次就能看出有没有魔改。OASIS这个样本没有额外的魔改所以Python实现和Frida抓到的样本完全一致。3.5 结果验证把还原逻辑完整跑通还原代码能跑通不是终点要证明它和so行为完全一致还需要做批量验证。我准备了10组不同长度的测试样本一律通过Frida调用enc函数拿到输出然后用Python脚本计算相同输入逐行比对。验证脚本如下test_cases [ , a, hello, 1234567890123456, # 正好16字节 1234567890123456789012345, # 25字节 {username:admin,password:123456}, ] for case in test_cases: result oasis_encrypt(case) print(f{case!r} - {result})然后在Frida控制台手动调用相同输入比对输出。这里有几个容易踩的坑后面专门讲。验证通过后整个OASIS的SO逆向入门流程就走完了。4. 常见问题与排查技巧实录4.1 JNI_OnLoad被混淆导致的加载失败问题逆向过程中经常遇到一个情况App改了liboasis.so以后安装直接crash或者提示so加载失败。这种现象多半是JNI加载流程出问题。JNI_OnLoad是so被加载时的初始化入口里面通常会注册自定义函数或者做一些反调试检查。排查时用logcat看崩溃日志是最直接的。如果崩溃原因是UnsatisfiedLinkError说明so没有被正确加载。这时检查so的导出表是否包含JNI_OnLoad如果方法名对不上或者函数签名不对系统就找不到入口加载自然失败。分析时要养成一个习惯打开JNI_OnLoad来看一眼。它往往能直接揭示so的抗分析能力。OASIS的JNI_OnLoad很简单只做了版本校验没有附加反调试逻辑这大大降低了分析难度。4.2 去符号表后怎么定位关键函数很多样本做了strip导出表里空空如也JNI函数名被彻底去掉。这种情况下用函数名定位的思路就走不通了需要换招。第一招看字符串交叉引用。找到so里的错误提示字符串双击到引用位置往上翻看是哪个函数用了它这个函数就是切入点。第二招从Java层的native声明倒推。用jadx拿到native方法所在类和方法名然后用Ghidra的“匹配JNI函数”特性自动扫描。Ghidra对JNI函数有较好的识别能力即使符号缺失也能根据Java层的类名和函数名进行推测匹配。第三招动态hook。Frida的Module.enumerateExports()往往能列出所有动态导出如果so被strip得不彻底动态导出表里可能还有线索。如果完全没有导出表就只能用内存搜索特征来定位函数了这属于进阶玩法入门阶段先不展开。4.3 Frida hook不上native方法的签名坑Frida hook不上native方法绝大多数情况都是方法签名写错。JNI方法签名里jstring对应的签名是Ljava/lang/String;int对应Ibyte[]对应[B。如果你这样写NativeBridge.enc.implementation function (input) {但真的方法是enc(String input)那么Frida的use还是能识别到。但如果是重载方法就必须要拼上签名var overloads NativeBridge.enc.overloads; console.log(overloads.length); NativeBridge.enc.overload(Ljava/lang/String;).implementation function (input) { ... };在hook之前先打印overloads能避免很多“为什么hook不到”的玄学问题。还有一个坑是App可能在加载so之前就已经把native方法注册好了如果Frida附加得晚可能已经错过了初始化时机。解决方案是使用-f模式冷启动App保证Frida在App运行早期就注入。4.4 IDA动态调试连不上真机的处理思路IDA动态调试so比静态分析更接近真相但连接坑很多。远程调试需要在设备上运行android_server然后用IDA的Remote ARM Linux调试器连接。连不上通常有四个原因端口没转发、调试器版本不匹配、ptrace权限不足、so没有被加载。逐一排查# 端口转发 adb forward tcp:23946 tcp:23946 # 确认android_server有执行权限以root运行 adb shell su -c /data/local/tmp/android_server如果还连不上就在IDA的Debugger菜单里选择“Attach to process”在进程列表里找到App进程附加进去。这里有个技巧先在App里断点JNI_OnLoad运行到so加载完成后再附加能看到完整的内存布局。4.5 模拟器指令集引发的so加载失败问题最常见的新手翻车现场是把别人给的ARM版本so直接塞进x86模拟器结果App崩溃。so也要分指令集armeabi-v7a、arm64-v8a、x86、x86_64。查看so架构的命令我已经在前面写过。如果OASIS的lib目录里只有arm64版本没有x86版本模拟器又是x86_64的就只能在ARM真机或者ARM模拟器里分析。一个专门用来测试的Pixel模拟器镜像ARM架构对做逆向来说真的很有必要别省这个时间成本。另外提一句.so从x86迁移到ARM其实不只是重新编译那么简单涉及大小端、字节对齐、寄存器宽度等一堆差异这也是为什么很多App在不同指令集下表现不一致。分析时理清目标App跑在哪种指令集上能省去大量无效调试。写在最后的几条经验OASIS这个样本我分析了三次每次都能收获一些新东西。第一次是走通了流程第二次是加深了对JNI加载机制的理解第三次是把AES、Base64这些算法识别练成肌肉记忆。入门SO逆向最大的障碍不是工具不熟悉而是面对“一堆看不懂的汇编”产生的挫败感。我的建议是先背熟几张表——JNI函数命名规则、ARM常用指令、常见算法常量特征然后从最简单的样本开始反复练一次不行就两次。花在样本上的时间不会浪费每一条指令的折磨都会变成之后做真实项目时的底气和直觉。对自己有点耐心这个方向确实值得投入。