
1. 项目概述为什么我们需要Dump内存中的SO文件在Android逆向工程和安全分析领域SOShared 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文件在内存中并非随意摆放它需要被加载到符合其ELFExecutable 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段: vaddr0x${p_vaddr.toString(16)}, filesz0x${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, rb) 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-toolspip install frida-tools在测试机上安装对应架构的Frida-server从GitHub Release页面下载adb push到设备adb shell后chmod x并运行。目标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/目录下。拉取文件到电脑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静态分析用IDA Pro或Ghidra打开fixed_libcore.so。现在函数列表、字符串引用、交叉引用应该都正常了。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转发端口。脚本执行后找不到目标SO1. SO名称不匹配。2. SO尚未被加载。3. 应用有多进程附加错了进程。1. 先枚举所有模块在Frida CLI中执行Process.enumerateModules()查看准确的SO全名。2. 在应用启动后触发相关功能如点击某个按钮后再执行Dump脚本确保SO已动态加载。3. 使用frida-ps -Ua查看所有进程确认附加的是主进程还是子进程。Dump出的文件大小异常小如几KBDump的起始地址基址错误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脚本前先运行一个“清理”脚本Hookptrace、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等平台的动态库分析也有借鉴意义。