前阵子处理一个内部安全评估的样本,目标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去符号处理。它内部用到的算法也是常见的AES+Base64组合,没有自定义魔改算法。这意味着分析者可以把精力集中在“如何定位函数、如何看汇编、如何还原逻辑”这些核心技能上,而不是被混淆对抗耗掉全部耐心。
选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 = b"OaSiS_kEy_2o24!!" cipher = AES.new(key, AES.MODE_ECB) padded = pad(plaintext.encode("utf-8"), AES.block_size, style="pkcs7") 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对应I,byte[]对应[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常用指令、常见算法常量特征,然后从最简单的样本开始反复练,一次不行就两次。花在样本上的时间不会浪费,每一条指令的折磨都会变成之后做真实项目时的底气和直觉。
对自己有点耐心,这个方向确实值得投入。