SO逆向入门实战:定位JNI函数到还原AES加密算法
2026/9/15 15:15:20 网站建设 项目流程

前阵子处理一个内部安全评估的样本,目标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_encdec,说明符号还在,分析难度直线下降。如果被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.js

App启动后,我手动触发一次登录操作,控制台立刻打印出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对应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-v7aarm64-v8ax86x86_64

查看so架构的命令我已经在前面写过。如果OASIS的lib目录里只有arm64版本,没有x86版本,模拟器又是x86_64的,就只能在ARM真机或者ARM模拟器里分析。

一个专门用来测试的Pixel模拟器镜像(ARM架构)对做逆向来说真的很有必要,别省这个时间成本。另外提一句,.so从x86迁移到ARM其实不只是重新编译那么简单,涉及大小端、字节对齐、寄存器宽度等一堆差异,这也是为什么很多App在不同指令集下表现不一致。分析时理清目标App跑在哪种指令集上,能省去大量无效调试。

写在最后的几条经验

OASIS这个样本我分析了三次,每次都能收获一些新东西。第一次是走通了流程,第二次是加深了对JNI加载机制的理解,第三次是把AES、Base64这些算法识别练成肌肉记忆。

入门SO逆向最大的障碍不是工具不熟悉,而是面对“一堆看不懂的汇编”产生的挫败感。我的建议是先背熟几张表——JNI函数命名规则、ARM常用指令、常见算法常量特征,然后从最简单的样本开始反复练,一次不行就两次。花在样本上的时间不会浪费,每一条指令的折磨都会变成之后做真实项目时的底气和直觉。

对自己有点耐心,这个方向确实值得投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询