☰
解析so文件实战:ELF、readelf、Ghidra与Frida完整攻略
2026/10/13 13:54:58 网站建设 项目流程

简介:解析.so文件是Android原生开发中绕不开的环节,这份资源以实际Eclipse工程形式提供了一套完整的.so解析样例,面向需要理解动态链接库结构、掌握readelf/objdump/nm等GNU工具用法、以及想熟悉JNI调用链路的初中级Android开发者。压缩包共30个文件,包含18个class、5个java、2个h、2个so文件,以及classpath、prefs、project等工程配置,大小仅58KB。其中.so文件可直接用于练习查看ELF头、动态符号表和反汇编代码,Java源码和C头文件则方便对照Java层与Native层的函数映射,便于梳理JNIEXPORT/JNICALL等关键声明。目前已有3139人学习下载。借助这份资源,既能直观理解.so文件在运行时的加载与交互机制,又能逐步上手静态分析工具链,对排查原生崩溃、优化应用性能会很有帮助。

1. 解析so文件到底在解什么:从一行nm输出说起

“解析so文件”这句话,第一次从同事嘴里蹦出来时,我以为是某种系统破解仪式;等我亲手处理过一个崩溃堆栈后才发现,它其实是把二进制共享库翻译回“人话”的一套组合拳。so文件本质是 Linux/Android 下动态链接库的 ELF 包装,解析它通常要回答三件事:这个 so 导出了哪些函数、依赖了哪些外部符号、它在运行时会被加载到哪个地址。对应到工具链,就是 readelf/nm 看结构、Ghidra/IDA 看逻辑、Frida 看行为,三层缺一层解析结论都是半成品。这篇文章写给被崩溃日志、SDK 黑匣子和兼容性问题折磨过的人——你不需要成为逆向专家,但需要知道怎么下手、坑在哪里。

2. 先看懂ELF骨架:解析so文件的readelf底子

很多人在拿到一个so文件时的第一反应是直接拖进反编译器,然后对着一个没有函数名的窗口发呆。我一般会先劝自己冷静,先花两分钟用readelf把骨架摸清楚。动态库的ELF和普通可执行文件不一样,它不关心从哪里开始执行,更关心“被加载时动态链接器要处理哪些表”。所以解析so文件的第一刀,不是找代码,而是找这些表。

2.1 从ELF Header读起:一秒钟确认架构与入口

拿到一个so,我第一个动作永远是readelf -h。不动脑子先跑这一条,比直接拉进反编译器更快地回答“这是给谁用的”:是 arm 32 还是 arm 64,字节序是小端还是大端,入口点在哪。

# 只看ELF头部,确认架构、字节序、入口点 readelf -h libxxx.so

输出里最重要的三行是Class、Machine、Entry point。Class 显示 ELFCLASS32 还是 ELFCLASS64,决定你后续所有解析应该按 32 位还是 64 位的结构体来理解;Machine 告诉你目标 CPU 架构,拿错架构的so去解析,后面所有结论都是错的;Entry point 在共享库里往往为 0 或者指向某个初始化函数,如果它不是 0,说明这个so在加载时有一段自己的启动逻辑。

这里有个很实在的经验:我曾在项目里遇到某开发者买的第三方so在 x86 模拟器上转圈,进程起来就崩。拿readelf -h一看,Machine 字段是 Intel 80386,而应用跑在 arm64 模拟器上,架构根本不匹配,问题立刻就清楚了。所以解析so文件的第一步永远是确认架构,而不是急着反编译。

2.2 节表与段表:解析so文件的静态地图

档案头是身份证,节表和段表才是地图。readelf -S输出节表,readelf -l输出程序头(也就是段)。加载器只看程序头,链接器和调试器才看节表;而解析so文件时,两边都要看。程序头告诉你这个文件被加载到内存后分成几块、哪块可读可写、哪块可执行;节表则告诉你符号表、字符串表、重定位表都藏在哪里。

readelf 参数输出内容解析so文件时的用途
-hELF 头部确认架构、字节序、入口
-lProgram headers看 PT_LOAD 如何映射、有无 PT_DYNAMIC
-SSection headers定位 .dynsym、.dynstr、.rel.plt
-dDynamic section看依赖库、符号版本、init/fini 数组
-rRelocations重定位表,hook 和 patch 必看
--dyn-syms动态符号表导出符号的权威来源

在 .so 里,真正决定行为的是.dynamic段里那一串DT_NEEDED(依赖了谁)、DT_INIT/DT_INIT_ARRAY(加载时执行什么)、DT_SYMTAB(符号表在哪)。readelf -d能看到这些,它和 PT_DYNAMIC 是同一件事的两个视角:一个是文件视角,一个是内存加载视角。解析so文件时我习惯把四个选项叠在一起跑,输出会有点长,但顺着关键字看就能快速建立全貌。

# 一次性列出程序头、节表、动态段和重定位表 readelf -l -S -d -r libxxx.so > libxxx_elf.txt

把输出重定向到文本文件,是因为信息量确实大。之后用grep -E "PT_LOAD|dynsym|DT_NEEDED|FUNC"去过滤,比在终端里翻屏靠谱得多。这个习惯帮我省了不少时间,尤其当so文件体积大、重定位项以万计时。

2.3 符号表要看 dynsym 而不是 symtab:解析so文件的一个长期误区

新手最容易在两张符号表之间绕晕。strip之前有.symtab,包含局部符号和静态函数;strip之后.symtab被剥掉,剩下的只有.dynsym。动态链接器只认.dynsym,导出的 GLOBAL 和 FUNC 都在这张表里。直接读动态符号表的正确姿势是这样:

# 列出动态符号表,过滤出函数符号,取最后一列符号名 readelf --dyn-syms -W libxxx.so | awk '$4 == "FUNC" {print $8}' # 或者用 nm 的 -D 选项,效果等价 nm -D libxxx.so | grep ' T '

-W参数让 readelf 输出不截断,awk 按空格切列,第 4 列是符号类型,等于 FUNC 才输出,第 8 列是符号名。nm -D底层也是读.dynsym,两个命令的结果应该一致。如果你发现两边对不上,先怀疑是不是用了不同版本的 binutils,再做排序去对比。

JNI 静态注册的Java_com_xxx会以 T 类型出现在这张表里,JNI_OnLoad一般也能看到。注意一个反直觉的点:如果编译时加了-fvisibility=hidden或者 version script,部分内部函数即使逻辑上存在,也不会出现在.dynsym里。所以导出表里找不到目标函数,不代表代码里没有,只是它没被导出。

提示:nm -D和readelf --dyn-syms在个别 binutils 版本下输出顺序不同,做自动化对比时先sort再diff,别被顺序差异误导。

走到这一步,静态结构已经清楚:架构、入口、依赖、导出函数。但符号表不能告诉你函数内部调用了谁、操作了哪些字符串,这些要交给反编译器。

3. 摊开so文件的第一层:用Ghidra反编译导出函数

符号表把函数名和地址摆在那里,但解析so文件真正花时间的地方在理解函数内部逻辑。反汇编出来的汇编代码对大多数从业者来说效率太低,反编译成近似 C 的伪代码才是主流做法。这里我优先说 Ghidra 的路径,因为它解决了“免费”和“可批量”两个痛点。

3.1 是免费够用还是直接上商业工具:解析so文件的选型理由

解析导出函数有两条路线:一条是纯反汇编,一条是反编译。反汇编工具遍地都是,但能把机器码还原成近似 C 代码的反编译器才是效率来源。目前常见选择就两个阵营,一个 Ghidra,一个是商业 IDA 工具链。

对比维度Ghidra商业版 IDA
成本开源免费,可脚本化批量商业授权,成本较高
反编译引擎内置 Decompiler内置商业反编译分支
脚本接口GhidraScript / PythonIDAPython
命令行批处理analyzeHeadless 很成熟部分版本支持 headless

解析so文件往往不是单个文件的需求,而是一批:几十个版本、不同 ABI 的 so,或者同一 so 的不同 patch 版本。Ghidra 的 headless 模式让我能一条命令跑完整批分析,结果落成文本,省去在 GUI 里手工点几百次的功夫。另外一个容易被忽略的理由是脚本接口的开放程度——我在解析时想导出哪些函数、过滤哪些符号,可以直接写在脚本里,而不是靠肉眼在成千上万行反编译结果里翻。

3.2 最小操作路径:从导入so到拿到第一个函数伪代码

GUI 下的操作路径很快:File → Import File 选中 so,勾选自动分析,几分钟后左侧 Symbol Tree 的 Functions 里就会出现导出函数列表。双击任意函数,中间是反汇编,右侧是伪代码。这个流程在函数少于几十个时很舒服,但一旦函数数量上百、导出符号又被 strip 过,就需要更稳的路径:headless 批量一把梭。

# 用 headless 模式导入 so,并在分析完成后执行 ExportFuncs.java 脚本 analyzeHeadless /tmp/ghidra_proj proj_name \ -import ./libxxx.so \ -scriptPath /tmp/ghidra_scripts \ -postScript ExportFuncs.java

脚本内容是一个 GhidraScript,遍历当前程序里的所有函数,把名字和入口地址打出来:

// ExportFuncs.java —— 遍历函数管理器,把函数名和入口地址打印到控制台 import ghidra.app.script.GhidraScript; import ghidra.program.model.listing.Function; import ghidra.program.model.listing.FunctionManager; import ghidra.program.model.symbol.SymbolType; public class ExportFuncs extends GhidraScript { @Override public void run() throws Exception { FunctionManager fm = currentProgram.getFunctionManager(); // getFunctions(true) 遍历所有函数,顺序按地址升序排列 for (Function f : fm.getFunctions(true)) { // 跳过外部导入函数,避免把动态库依赖当成业务逻辑 if (!f.isExternal()) { println(f.getName() + " @ " + f.getEntryPoint()); } } } }

逻辑说明:脚本通过getFunctions(true)拿到全部函数,isExternal()剔除从其他 so 导入的函数,剩下的就是这个 so 自己实现的逻辑。println的输出会进 headless 的运行日志,终端里就能直接看到。参数说明:-import指定要导入的文件;-scriptPath指向脚本目录;-postScript在自动分析完成后执行。Ghidra 会给没有符号的函数自动命名FUN_xxx,这类名字大量出现时,说明这个 so 是 stripped 的,需要配合字符串交叉引用还原语义。

3.3 顺着xrefs把调用关系抠出来:字符串和函数引用是第二个抓手

导出函数经常只是面子,真正业务逻辑藏在内部函数里。解析so文件时,我通常不会逐个函数读反编译结果,而是先搜字符串。日志、错误码、版本号、协议头,这些内容在二进制里清理不掉。用 Search → Memory 搜一遍关键字,找到命中项后按 Ctrl+Shift+F 查看谁引用了这个字符串,两三个跳转就能摸到关键函数。

操作快捷键 / 菜单用途
查看谁引用了当前地址Ctrl+Shift+F向上找调用者
查看当前函数调用了谁双击反汇编中的 call 目标向下找调用链
搜索内存中的字符串Search → Memory找日志、版本号、业务关键字

这个方法的威力在符号被 strip 后尤其明显。函数名叫FUN_0012ab3c时你根本不知道它是干嘛的,但如果它引用了字符串"login failed",它的职责立刻清晰。代码可能被混淆,但字符串引用始终是最稳定的地图。解析so文件只要能把“字符串 → 引用它的函数 → 调用它的函数”这条链走通,大部分黑匣子就不黑了。

4. 运行时解析so文件:基址、导出地址与JNI绑定

静态分析把文件从头到尾拆了一遍,但有一个问题它回答不了:这个so在进程里,真实地址是多少。因为 ASLR 的存在,同一个文件每次启动加载的基址都不同。解析so文件到这一步,要把静态结论搬到运行时的进程里验证一遍。

4.1 静态对完表,运行时还要对一次基址

静态分析拿到的导出函数地址是相对虚拟地址。进程启动后 so 被加载到内存,真实地址等于基址加偏移,直接用静态地址去调试器里下断点必然落空。解析so文件不能只停留在文件分析,还要回答它“在哪个进程、哪个模块、实际地址是多少”。这里 Frida 这类动态插桩工具比 gdb 顺手,一条命令就能枚举模块和导出符号,而且不需要 root 之外的特殊权限。

# 加载脚本到目标进程,等到模块出现后再执行 frida -U -f com.example.app -l enumerate_exports.js

4.2 用Frida枚举so文件的导出符号并算出实际地址

下面的脚本解决两个问题:确认模块是否加载、导出符号的真实地址是多少。

// enumerate_exports.js —— 找出目标so在内存中的导出表 var mod = Process.findModuleByName("libxxx.so"); if (mod) { // 打印模块的基址、大小、加载路径 console.log("base=" + mod.base + " size=" + mod.size + " path=" + mod.path); // 遍历导出符号,按JNI相关前缀过滤 mod.enumerateExports().forEach(function (exp) { if (exp.name.indexOf("Java_") === 0 || exp.name.indexOf("JNI_") === 0) { // exp.address 已经是绝对地址,可以直接用于 Hook console.log(exp.name + " -> " + exp.address); } }); } else { // 模块没加载时,先打印全模块列表核对名字拼写 Process.enumerateModules().forEach(function (m) { console.log(m.name + " @ " + m.base); }); }

逻辑说明:Process.findModuleByName按名字找模块,找不到就枚举所有模块来核对拼写,常见坑是大小写和版本后缀不一致。module.enumerateExports()在运行时解析.dynsym并完成基址换算,返回的exp.address是绝对地址。过滤条件里Java_前缀代表 JNI 静态注册函数,JNI_通常覆盖JNI_OnLoad这类生命周期函数。参数说明:-U表示连接 USB 设备,-f表示启动应用并注入,so 如果在启动早期就被加载,脚本会在模块出现前运行,这时候需要在一个setTimeout里重试,或者改为 hookdlopen。

4.3 Hook一层皮:拦截JNI函数看参数和返回值

导出表对上地址以后,下一步是确认参数和返回值。JNI 函数有个固定 ABI:C 函数第一个参数是JNIEnv*,第二个是jobject或jclass,业务参数从第三个开始。这个约定让运行时解析so文件舒服很多,不用猜参数布局。

// hook_jni.js —— 拦截 Java_xxx 字符串函数,打印入参与返回值 var target = Module.findExportByName("libxxx.so", "Java_com_demo_NativeBridge_stringFromJNI"); if (target) { Interceptor.attach(target, { onEnter: function (args) { // args[0] 是 JNIEnv*, args[1] 是 this 对象,业务参数从 args[2] 开始 console.log("enter this=" + args[1]); }, onLeave: function (retval) { // jstring 是 JNI 引用类型,需要用 getStringUtfChars 转成 C 字符串 var env = Java.vm.getEnv(); var result = env.getStringUtfChars(retval, null); console.log("leave retval=" + result); } }); } else { console.log("not found, check export table first"); }

逻辑说明:Module.findExportByName在导出表里查找目标函数地址,找到后Interceptor.attach在函数入口和出口各插一段代码。onEnter里能看到参数原始值,onLeave里能拦截返回值。参数说明:Java.vm.getEnv()获取当前线程的 JNIEnv,getStringUtfChars把jstring转成可读字符串。如果返回的是对象而不是字符串,需要用Java.cast做类型转换。

这里有一个很常见的注意点:如果 so 的 Java 方法不是静态注册,而是通过RegisterNatives动态注册,Java_xxx不会出现在导出表里。后面避坑章节会展开说这个场景。

5. 解析so文件常见坑与排查:五个必踩的翻车现场

把静态和动态两条路径走完,解析so文件的主流程就算通了。但真正做事的时候,翻车点远比我上面写的那些顺畅路径多。这一章写我实际踩过的五个坑,每一条都是现象、原因、解决三段式。这五个坑里只要中一个,前面的工具链再顺也会卡住。

5.1 导出表里全是导入符号,就是找不到自己的函数

现象是readelf --dyn-syms跑完,看到的都是__cxa_throw、__android_log_print这类导入函数,自己想找的Java_com_xxx或业务函数一个都不在。原因是编译时用了-fvisibility=hidden,默认符号全部隐藏,只有显式标注或 version script 里列出的符号才会进.dynsym。

解决的思路分两条。静态层面搜整个 so 的字符串,找到版本号、错误码后靠字符串交叉引用定位函数;动态层面 hookJNI_OnLoad,看它是否调用了RegisterNatives,如果调了,在RegisterNatives的第二个和第三个参数里能拿到注册的函数名和函数指针。两条路组合,基本能把隐藏掉的函数找回来。

5.2 反编译结果全是奇怪的伪代码,函数入口对不上

现象是在 Ghidra 里手工创建一个函数,反编译窗口出现一堆mov.w、cbz的碎片,看不出业务逻辑,更像数据被当成代码执行了。原因是 ARM 32 位 so 有 ARM 和 Thumb 两套指令集,函数地址的 bit0 是状态位。Thumb 函数如果被强制按 ARM 解码,反汇编结果就是一团乱麻。

解决方法是先看导出函数地址的 bit0,如果是 1,说明代码是 Thumb;在 Ghidra 里右键函数 → Edit Function → 勾选 Thumb 选项。Frida 枚举导出地址时也要把 bit0 掩掉再传给拦截器,否则 attach 阶段就会失败。这个坑的隐蔽性在于,地址差 1 在肉眼看起来微乎其微,但解码结果天差地别。

5.3 32位so和64位so用同一套脚本,算出的地址总差一截

现象是同一份解析脚本,解析 32 位 so 没问题,换成 64 位版后导出地址对不上,甚至指向了.data段。原因是Elf32_Sym和Elf64_Sym两个结构体成员顺序和大小完全不同,手工解析.dynsym时按 32 位结构取字段,在 64 位文件上取错 offset 是必然的。

解决方法是先跑readelf -h确认 ELFCLASS,再按对应结构体解析。更省事的做法是不要自己拼解析逻辑,直接交给 readelf 或 Frida 的enumerateExports。想自己写脚本解析so文件前,先花十分钟查一下Elf32_Sym和Elf64_Sym的字段定义,这个前置时间能避免一个晚上的白费功夫。

5.4 Frida hook了导出函数,但永远不触发

现象是导出表里明明有Java_com_xxx,Module.findExportByName也返回了地址,hook 之后函数确实被 Java 层调用,但 Interceptor 没有反应。原因是目标应用或 SDK 用自研 loader 手动dlopen的方式加载 so,导出符号表可能是伪造的,或者调用点根本不在模块的导出函数上;还有一种可能是存在两个同名不同路径的 so,hook 错了对象。

解决方法是先在运行期用Process.enumerateModules打印所有 so 的路径,确认 hook 的是目标文件;再对字符串引用做全局 grep,找到真实调用点;最后用Thread.backtrace()把调用栈打出来,看实际执行的模块路径。解析so文件时,运行期看到的现象永远比静态表格更可信。

5.5 直接改了so里的导出符号表,启动就崩

现象是用十六进制编辑器改了 so 里某个导出函数名,进程一加载就报cannot locate symbol或者直接 SIGSEGV。原因是动态链接器查找符号依赖.gnu.hash或.hash哈希表,改了名字没同步哈希表,查找就会失效;更别提重定位表里还有一堆引用等着更新。

解决方法是不直接改二进制里的符号表。要么在编译期用 version script 和动态注册完成改名,要么走 Ghidra 层面的 patch 并同步导出重定位信息。解析so文件最大的原则之一是:看可以随便看,动之前先想清楚动态链接器要查哪些表。哈希表、字符串表、重定位表三处不一致,文件一定起不来。

6. 验证解析结论的后手:三次对表才敢动手

解析so文件不结束在反编译窗口关掉的那一刻,结束在三张表对上的那一刻。第一张表是readelf --dyn-syms的输出,第二张是 Ghidra 反编译出的函数列表,第三张是 Frida 在运行时枚举的导出地址。符号地址可能受重定位影响有偏差,但函数名和数量应当一致。每次看完直接改结论之前,我习惯把三张表拉一次对称对齐:

# 静态表:只看全局导出的函数符号 readelf --dyn-syms -W libxxx.so | awk '$4 == "FUNC" && $5 == "GLOBAL" {print $8}' | sort # 备查:用 objdump 的 -T 选项做二次确认 objdump -T libxxx.so | grep targetFunc

如果 readelf 里有、objdump 里没有,先检查目标文件是否被 strip 或被 version script 过滤;如果两者都有但 Frida 运行时取不到,问题基本出在哈希表不一致或加固绕过。三次对表这个动作,能把我上面提到的大部分“玄学”变成可解释的技术问题。

我后来养成的习惯是,凡是遇到函数名带FUN_前缀的内部函数,先看它的长度和引用关系,再决定要不要深入。有一次我盯一个FUN_0012ab3c看了大半天,逻辑怎么都对不上,最后发现那是 v7 架构的遗留兼容函数,实际永远不会被调用。解析so文件的大部分时间浪费在“读了不该读的函数”上。先导出一份导出白名单,再对着白名单逐项看,是这个方向里性价比最高的习惯。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询