Frida内存Dump技术:从Android SO文件提取到ELF修复实战
1. 项目概述:为什么我们需要Dump内存中的SO文件?
在Android逆向工程和安全分析领域,SO(Shared Object,共享库)文件是核心目标之一。它通常由C/C++编写,承载着应用的核心算法、加密逻辑、协议实现等关键功能。很多时候,开发者会对SO文件进行加固、混淆或加密,导致我们无法直接获取到磁盘上的原始二进制文件。这时,内存Dump技术就成为了“破局”的关键。
简单来说,内存Dump就是在应用运行时,将已经加载到内存中的SO文件镜像“抓取”出来。因为无论加固多强,代码最终都要在内存中以可执行的形态存在。Frida作为一个动态插桩框架,为我们提供了在运行时与目标进程交互的能力,使得“一键Dump”成为可能。这不仅仅是获取一个二进制文件那么简单,它往往是分析复杂商业逻辑、挖掘漏洞、学习优秀实现的第一步。
我遇到过不少情况,一个APK解压后,lib目录下的SO文件要么被抽空,要么被加密得面目全非。直接静态分析无从下手。这时候,一个稳定、可靠的内存Dump脚本就是救星。它让你能拿到最接近原始逻辑的代码,为后续的逆向分析铺平道路。接下来,我将分享一套经过实战检验的Frida脚本,并深入讲解如何修复Dump下来的SO文件,让它能被IDA Pro、Ghidra等反编译器正常识别和分析。
2. 核心思路与Frida脚本设计
2.1 理解SO文件的内存加载机制
在动手写脚本之前,我们必须先搞清楚目标在哪。Android系统中,SO文件通过dlopen和dlsym等函数动态加载。一个SO文件在内存中并非随意摆放,它需要被加载到符合其ELF(Executable and Linkable Format)头中指定的程序头(Program Header)所描述的内存段(Segment)中,尤其是PT_LOAD类型的段。
关键点在于,内存中的SO镜像与磁盘文件的结构并不完全一致。磁盘上的ELF文件有节区头(Section Header),包含了.text、.data、.rodata等节区的详细信息,便于链接和调试。但当SO被加载到内存后,操作系统关心的是段(Segment),而不是节(Section)。一个段可能包含多个节。因此,直接从内存Dump下来的数据,其ELF头中的节区头信息可能是无效的(因为加载时不需要),或者偏移是错误的,导致反编译器无法正确解析。
我们的脚本核心思路就是:枚举进程内存中所有已加载的模块,找到目标SO,然后根据其内存布局,重建一个有效的ELF文件。
2.2 Frida脚本:一键Dump的核心实现
下面这个Frida JavaScript脚本,是我在多次实战中提炼修改后的版本。它不仅能Dump,还初步处理了基址偏移问题。
// frida_dump_so.js Java.perform(function () { // 导入必要的Frida API var Process = Module.enumerateRanges('r-x'); var dumpedModules = {}; // 1. 枚举所有可读可执行的内存区域,这通常是代码段 Process.forEach(function (range) { // 尝试获取该内存区域所属的模块信息 var module = Process.findModuleByAddress(range.base); if (module) { var moduleName = module.name; // 避免重复Dump同一个模块 if (dumpedModules[moduleName]) { return; } dumpedModules[moduleName] = true; // 2. 筛选出我们感兴趣的SO文件,这里以包含‘target’关键词为例 if (moduleName.indexOf('libtarget') !== -1) { // 修改为你需要的SO名称 console.log(`[+] 发现目标模块: ${moduleName}`); console.log(` 基址: ${module.base}`); console.log(` 大小: ${module.size}`); console.log(` 路径: ${module.path}`); // 3. 计算模块的结束地址 var start = module.base; var end = ptr(module.base).add(module.size); var size = module.size; // 4. 读取整个模块的内存数据 var memoryDump = Memory.readByteArray(start, size); // 5. 构建输出文件名 var timestamp = new Date().getTime(); var fileName = `/sdcard/Download/dump_${moduleName}_${timestamp}.so`; // 6. 将数据写入文件(需要应用有写存储权限,或使用其他路径) var file = new File(fileName, "wb"); file.write(memoryDump); file.close(); console.log(`[+] 成功Dump到: ${fileName}`); console.log(`[!] 注意:此文件可能需要修复后才能用IDA等工具正确分析。`); } } }); });脚本关键点解析:
Module.enumerateRanges(‘r-x’):这是脚本的起点。它枚举当前进程所有可读且可执行的内存区域。SO的代码段(.text)必然具有‘r-x’属性。通过这个我们可以快速定位到所有可能包含代码的模块。Process.findModuleByAddress:根据内存地址反向查找该地址属于哪个模块。这比直接枚举所有模块再匹配更精准,能确保我们找到的是已经加载到内存中的有效模块。- 筛选逻辑:
if (moduleName.indexOf(‘libtarget’) !== -1)这里需要你根据实际情况修改。可以是完整的SO名(如libnative-lib.so),也可以是部分关键词。在复杂应用中,可能有几十个SO,精确筛选很重要。 - 内存读取与写入:
Memory.readByteArray和File对象完成了核心的Dump操作。注意输出路径/sdcard/Download/需要目标应用有写外部存储的权限,或者你可以根据上下文调整到其他可写目录,如/data/data/包名/。
注意:这个基础版本Dump的是从模块基址开始的整个内存范围。对于简单的、未加固的SO,这可能就足够了。但对于有
.bss段(未初始化数据)或经过复杂加载的SO,直接Dump的镜像可能不完整或偏移错误,这就需要后续的修复。
3. 进阶:精准Dump与内存段重建
基础脚本有时会Dump出“坏掉”的文件,因为SO在内存中可能不是连续存放所有段,或者中间有空洞。更稳健的方法是解析ELF程序头,只Dump有内容的PT_LOAD段。
3.1 解析内存中的ELF头
我们需要在内存中直接解析SO的ELF头信息,这需要一些对ELF格式的理解。下面是一个增强版的函数,用于获取内存中SO的段信息:
function dumpSoBySegments(moduleName) { var m = Process.getModuleByName(moduleName); if (!m) { console.log(`[-] 未找到模块: ${moduleName}`); return null; } var base = m.base; // 读取ELF头(前64字节,64位系统) var elfHeader = Memory.readByteArray(base, 0x40); // 这里简单判断魔数 if (elfHeader[0] != 0x7f || elfHeader[1] != 0x45 || elfHeader[2] != 0x4c || elfHeader[3] != 0x46) { console.log(`[-] ${moduleName} 在内存中的起始地址不是有效的ELF头!`); // 可能基址不对,尝试通过枚举模块信息获取 console.log(`[*] 尝试使用Module.findBaseAddress...`); base = Module.findBaseAddress(moduleName); if (!base) { return null; } elfHeader = Memory.readByteArray(base, 0x40); } // 假设是64位ELF var e_phoff = elfHeader.readUInt32LE(0x20); // 程序头表偏移 var e_phentsize = elfHeader.readUInt16LE(0x36); // 每个程序头大小 var e_phnum = elfHeader.readUInt16LE(0x38); // 程序头数量 console.log(`[+] ${moduleName} 程序头表偏移: 0x${e_phoff.toString(16)}`); console.log(`[+] 程序头数量: ${e_phnum}`); var segments = []; for (var i = 0; i < e_phnum; i++) { var phdrAddr = base.add(e_phoff).add(i * e_phentsize); // 读取程序头结构体,这里简化处理,读取关键字段 var p_type = Memory.readU32(phdrAddr); // 段类型 var p_offset = Memory.readU64(phdrAddr.add(0x8)); // 在文件中的偏移 var p_vaddr = Memory.readU64(phdrAddr.add(0x10)); // 在内存中的虚拟地址 var p_filesz = Memory.readU64(phdrAddr.add(0x20)); // 在文件中的大小 var p_memsz = Memory.readU64(phdrAddr.add(0x28)); // 在内存中的大小 // 只关心需要加载的段 if (p_type == 1) { // PT_LOAD = 1 segments.push({ vaddr: p_vaddr, offset: p_offset, filesz: p_filesz, memsz: p_memsz }); console.log(` PT_LOAD段: vaddr=0x${p_vaddr.toString(16)}, filesz=0x${p_filesz.toString(16)}`); } } // 对段按虚拟地址排序 segments.sort(function(a, b) { return a.vaddr.compare(b.vaddr); }); // 计算整个Dump文件的大小(近似为最后一个段的末尾偏移) var lastSeg = segments[segments.length - 1]; var totalSize = lastSeg.offset + lastSeg.filesz; // 创建一个ArrayBuffer来存放重建的SO文件 var dumpBuffer = new ArrayBuffer(Number(totalSize)); var dumpView = new Uint8Array(dumpBuffer); // 首先,将ELF头拷贝过来(从内存基址开始) var headerData = Memory.readByteArray(base, 0x400); // 多读一些,包含程序头表 var headerArray = new Uint8Array(headerData); dumpView.set(headerArray, 0); // 然后,拷贝每一个PT_LOAD段的内容 segments.forEach(function(seg, index) { var memoryData = Memory.readByteArray(base.add(seg.vaddr.sub(base)), Number(seg.filesz)); var segArray = new Uint8Array(memoryData); dumpView.set(segArray, Number(seg.offset)); console.log(`[*] 已拷贝段 ${index}: 内存地址 0x${seg.vaddr.toString(16)} -> 文件偏移 0x${seg.offset.toString(16)}`); }); var fileName = `/sdcard/Download/dump_${moduleName}_by_segments.so`; var file = new File(fileName, "wb"); file.write(dumpBuffer); file.close(); console.log(`[+] 基于段重建的SO已保存至: ${fileName}`); return fileName; } // 使用方式 Java.perform(function () { dumpSoBySegments('libtarget.so'); });这个进阶脚本做了以下几件关键事:
- 验证ELF头:检查内存起始位置是否是有效的ELF文件,防止基址判断错误。
- 解析程序头:读取
PT_LOAD段的信息,获取每个段在文件中的偏移(p_offset)、在内存中的虚拟地址(p_vaddr)和大小(p_filesz)。 - 按文件偏移重建:不再简单地从基址开始连续拷贝,而是根据每个段的
p_offset,将内存数据(p_vaddr开始)写入到Dump文件的正确位置。这能正确处理段与段之间的文件空洞。 - 保留ELF头:将内存中的ELF头(包含程序头表)原样拷贝到新文件的开头。这保证了文件结构的初始部分是正确的。
实操心得:在对抗某些加固时,SO的基址可能被故意隐藏或修改。Module.findBaseAddress(moduleName)有时比Process.getModuleByName(moduleName).base更可靠。如果两者都失效,可以尝试遍历Process.enumerateModules()来寻找。
4. Dump后SO文件的修复技巧
用上面的脚本Dump出来的SO,扔进IDA Pro很可能打不开,或者打开后函数地址错乱、字符串找不到。别急,这不是Dump失败了,而是缺少“修复”这一步。修复的核心是修正ELF文件中的节区头(Section Header)信息,因为动态加载器不关心它,但反编译器依赖它来理解文件结构。
4.1 使用开源工具修复:frida-fix
手动解析和修复ELF节区头非常繁琐。社区有优秀的工具可以自动化完成。最常用的是frida-fix(或类似原理的脚本)。它的原理是:以内存Dump的数据为基础,参考一个“模板”SO(通常是磁盘上未加固的版本,或同类编译器生成的SO)的节区头信息,将其移植到Dump文件上。
假设我们已经有一个从内存Dump出来的dump_libtarget.so,以及一个从APK中解压出来的、可能被抽空但节区头还存在的original_libtarget.so(或者任何其他编译器生成的、架构相同的有效SO文件)。
我们可以使用Python脚本进行修复:
#!/usr/bin/env python3 # 简化版修复思路演示 import struct import sys def copy_section_headers(from_so, to_so_dump): """ 一个概念性函数,演示如何复制节区头。 实际推荐使用现成工具如 `frida-fix` 或 `ELFixer`。 """ with open(from_so, 'rb') as f: from_data = f.read() with open(to_so_dump, 'r+b') as f: # 以读写二进制模式打开Dump文件 to_data = bytearray(f.read()) # 1. 解析模板SO的ELF头,找到节区头表偏移(e_shoff)、数量(e_shnum)、字符串表索引(e_shstrndx) # 这里省略详细的ELF解析代码... # e_shoff, e_shnum, e_shentsize, e_shstrndx = parse_elf_header(from_data) # 2. 将模板SO的整个节区头表(从e_shoff开始,长度为e_shnum * e_shentsize)拷贝到Dump文件的对应位置。 # 注意:需要确保Dump文件有足够的空间容纳节区头表。有时需要扩展文件。 # section_header_table = from_data[e_shoff: e_shoff + e_shnum * e_shentsize] # 3. 更新Dump文件ELF头中的节区头相关字段,指向新拷贝的节区头表。 # to_data[elf_header_offset_of_e_shoff] = ... # 写入新的偏移 # 4. 写入文件 # f.seek(0) # f.write(to_data) print("[*] 这是一个简化说明。实际操作请使用成熟工具。") if __name__ == '__main__': if len(sys.argv) < 3: print("用法: python fix_so.py <模板SO路径> <待修复的Dump SO路径>") sys.exit(1) template_so = sys.argv[1] dump_so = sys.argv[2] copy_section_headers(template_so, dump_so)强烈建议直接使用现成工具:
frida-fix: 一个专门为此场景编写的Python工具。你可以在GitHub上搜索frida-fix找到它。用法通常很简单:python frida-fix.py dumped.so original.so fixed.so。ELFixer: 另一个功能强大的ELF修复工具。LIEF(Library to Instrument Executable Formats): 一个强大的跨平台库,可以用编程方式解析、修改和重建ELF、PE等文件。写一个简单的修复脚本也很方便。
4.2 手动修复的检查清单
如果不想引入额外工具,或者遇到特殊情况,可以按照以下思路手动检查和修复:
- 检查ELF头魔数:用十六进制编辑器(如010 Editor)打开Dump文件,看前4字节是否是
7F 45 4C 46。如果不是,说明Dump的起始地址根本不是ELF头,需要调整脚本的基址。 - 检查程序头(Program Header):使用
readelf -l dumped.so命令。如果能看到多个PT_LOAD段,并且它们的VirtAddr、FileSiz值看起来合理(例如,.text段通常是可读可执行),说明Dump的主体结构是好的。 - 检查节区头(Section Header):使用
readelf -S dumped.so命令。如果命令报错,或者输出的节区信息全是0、地址错乱,那就确认是节区头损坏。 - 寻找模板:从同版本APK的
lib目录下,或者从模拟器/手机的/system/lib(64)/目录下,找一个由相同编译器(如Android NDK)编译的、架构相同的普通SO文件作为模板。 - 替换节区头:用十六进制编辑器,将模板SO的节区头表(通过
readelf -S template.so找到其文件偏移和大小)整个复制到Dump文件的末尾(避免覆盖已有的程序头和段数据)。然后,修改Dump文件ELF头中e_shoff(节区头表偏移)、e_shnum(节区头数量)、e_shentsize(每个节区头大小)和e_shstrndx(节区字符串表索引)这四个字段,使其指向新拷贝的节区头表。
踩坑记录:手动修复极其容易出错,尤其是计算偏移时。一个字节的错误就会导致整个文件解析失败。对于生产性分析,强烈推荐使用自动化工具。
frida-fix在大多数情况下都能完美工作。
5. 实战流程与问题排查
5.1 完整操作流程
假设我们要分析一个名为com.example.targetapp的应用中的libcore.so。
环境准备:
- 测试机:一台已Root的Android手机或模拟器(如雷电模拟器,开启Root)。
- Frida环境:
- 电脑端安装Frida和frida-tools:
pip install frida-tools - 在测试机上安装对应架构的Frida-server(从GitHub Release页面下载,
adb push到设备,adb shell后chmod +x并运行)。
- 电脑端安装Frida和frida-tools:
- 目标APK:安装到测试机。
启动与附加:
- 在电脑上运行
frida -U -f com.example.targetapp以Spawn模式启动应用并附加。或者先启动应用,再用frida -U com.example.targetapp附加。 - 如果应用有反调试或反Frida机制,可能需要先绕过。这不是本文重点,但常见方法有:使用
-fspawn模式、使用frida的--pause参数、或注入反反调试脚本。
- 在电脑上运行
执行Dump脚本:
- 将上面的JavaScript脚本保存为
dump.js。 - 在Frida的CLI中,使用
%load dump.js加载并执行脚本。 - 观察控制台输出,确认目标SO已被发现并Dump成功,文件保存在设备的
/sdcard/Download/目录下。
- 将上面的JavaScript脚本保存为
拉取文件到电脑:
adb pull /sdcard/Download/dump_libcore_xxxx.so .
修复SO文件:
- 从APK中解压出原始的
libcore.so(即使它被抽空,其ELF结构通常还在)作为模板。 - 使用
frida-fix工具:python frida-fix.py dump_libcore_xxxx.so original_libcore.so fixed_libcore.so
- 从APK中解压出原始的
静态分析:
- 用IDA Pro或Ghidra打开
fixed_libcore.so。现在,函数列表、字符串引用、交叉引用应该都正常了。
- 用IDA Pro或Ghidra打开
5.2 常见问题与解决方案
| 问题现象 | 可能原因 | 排查与解决方案 |
|---|---|---|
| Frida连接被拒绝 | Frida-server未运行或版本不匹配 | 1.adb shell检查ps | grep frida。2. 确保设备上的 frida-server与电脑frida版本兼容(frida --version对比)。3. 使用 adb forward tcp:27042 tcp:27042和adb forward tcp:27043 tcp:27043转发端口。 |
| 脚本执行后找不到目标SO | 1. SO名称不匹配。 2. SO尚未被加载。 3. 应用有多进程,附加错了进程。 | 1. 先枚举所有模块:在Frida CLI中执行Process.enumerateModules(),查看准确的SO全名。2. 在应用启动后,触发相关功能(如点击某个按钮)后再执行Dump脚本,确保SO已动态加载。 3. 使用 frida-ps -Ua查看所有进程,确认附加的是主进程还是子进程。 |
| Dump出的文件大小异常小(如几KB) | Dump的起始地址(基址)错误 | 1. 使用Module.findBaseAddress(‘libxxx.so’)和Process.getModuleByName(‘libxxx.so’).base分别打印,看是否一致。2. 尝试使用 Module.enumerateRanges(‘r-x’)遍历,打印每个区域的基址和大小,人工判断哪个更像SO的代码段。 |
| IDA Pro打开Dump文件报错“File format not recognized” | ELF头损坏或魔数错误 | 1. 用十六进制编辑器检查文件头7F 45 4C 46。2. 可能是Dump的基址不对,没有从ELF头开始读。回退到“进阶脚本”,确保从正确的基址开始解析。 |
| IDA能打开但函数很少,字符串缺失 | 节区头信息丢失或错乱 | 这是最典型的情况,说明Dump成功但需要修复。严格按照第4节的方法,使用frida-fix等工具修复节区头。 |
| 修复后IDA分析仍有大量“unrecognized”数据 | 1. SO被混淆或加密。 2. Dump的时机不对,代码还未解密。 | 1. 这超出了单纯Dump的范畴。需要结合动态调试,在代码解密完成后的瞬间(内存中已是明文)进行Dump。 2. 可能需要Hook关键的初始化或解密函数,在其执行完毕后触发Dump脚本。 |
| 写入文件失败(Permission denied) | 目标进程没有写存储权限 | 1. 修改脚本中的输出路径,尝试写到应用私有目录:/data/data/com.example.targetapp/。2. 或者先Dump到内存变量,再通过Frida的 send()函数发送到电脑端保存。 |
个人经验:在对抗强加固时,单纯的模块枚举可能会失败。可以尝试更底层的Process.enumerateRanges(‘r-x’),并结合对内存特征码的搜索来定位SO的代码段。例如,ARM架构的指令通常有固定的起始模式。此外,时机至关重要。对于运行时解密的SO,需要在解密函数执行后、但代码可能被再次抹掉前进行Dump,这需要精细的Hook点设置。
6. 总结与扩展思路
通过Frida进行内存Dump,是从动态运行环境中提取关键代码的有效手段。核心在于准确定位内存镜像和正确重建ELF文件结构。本文提供的脚本和修复方法,覆盖了从基础到进阶的常见场景。
对于更复杂的对抗环境,可以进一步探索:
- 对抗反调试/反注入:在加载Frida脚本前,先运行一个“清理”脚本,Hook
ptrace、fork、readlink(检测frida-server)等函数,绕过检测。 - 精准Hook触发Dump:不盲目Dump,而是Hook目标SO的
JNI_OnLoad或某个关键的初始化函数,在其末尾调用我们的Dump函数,确保时机准确。 - Dump非标准内存区域:有些加固方案会将代码片段映射到非连续、属性非常规的内存区域。需要更精细地枚举所有内存区域(
Process.enumerateRanges(‘rwx’)、‘rw-’等),并基于特征进行识别。 - 全自动化:将Frida脚本、修复脚本、ADB命令整合成一个Python自动化工具,实现“指定APK和SO名,一键得到可分析的SO文件”。
最后,工具是死的,思路是活的。理解ELF格式、进程内存布局和动态链接机制,能让你在遇到新问题时,快速找到解决方案。这套方法不仅适用于Android SO,其原理对Linux、iOS等平台的动态库分析也有借鉴意义。
