Frida内存Dump so文件:ELF解析与加固壳对抗全攻略
1. 为什么要从内存里捞so加固与抽取壳的攻防现实做Android逆向的朋友应该都遇到过这种情况从应用市场下了一个目标App用jadx或GDA打开APKlib目录底下倒是躺着一堆so文件但你心里清楚这些静态文件根本没多大用处。尤其是现在主流App基本都上了360、腾讯乐固、梆梆、爱加密这类加固方案或者用了百度加固、娜迦、顶象的抽取壳静态拿到的so往往只是个空壳——真正的代码逻辑在运行时才会被解密、映射到内存里函数在磁盘文件里可能全是空的或者被抽走了。这时候想分析核心算法、还原加密流程唯一的出路就是把运行中的so从内存里捞出来。业内管这个动作叫dumpFrida是干这活最顺手的工具没有之一。我最早接触这个需求是在分析一个带数字签名SDK的App时静态反编译发现关键的签名函数全部被抽空导出表里只剩几个JNI入口点进去一看函数体全是无意义的填充字节。折腾了两天静态分析毫无进展后来转念一想——反正App跑起来的时候函数是完整的直接去内存里把完整模块抠出来不就完了Frida跑完dump出来的so用IDA一加载函数逻辑清清楚楚。那次之后内存dump so 修复就成了我逆向工作流里的常规操作。这篇文章就围绕这条完整链路展开从理解so在内存里的加载机制到用Frida在正确时机把so完整捞出来再到处理dump产物常见的各种问题加载基址偏移、段对齐、导出表失效、重定位信息缺失等最后给出我个人实测下来最稳的一套方案。适合有基本逆向基础、想深入掌握Frida实战技巧的读者。2. dump之前的必要认知ELF文件在内存中的真面目想把so完整dump出来第一步不是写脚本而是搞清楚两件事so在内存里长什么样以及dump哪个内存区间才是完整模块。这两个问题搞不明白写出来的脚本大概率dump出残缺文件拿到IDA里根本没法看。2.1 磁盘so与内存so的差异加载偏移和节区布局一个标准的ELF格式so文件ET_DYN类型在磁盘上由ELF头、程序头表Program Header Table、节区头表Section Header Table以及各个节区Section组成。程序头表里记录的是段Segment节区表记录的是节Section前者决定模块如何被加载进内存后者主要服务于链接和调试。当内核和linker把so加载进内存时并没有逐字节地把整个文件搬到内存里而是按照程序头表里的PT_LOAD段描述把每个LOAD段映射到进程地址空间。这里有个关键点每个PT_LOAD段的虚拟地址p_vaddr、内存大小p_memsz和文件偏移p_offset之间不是简单的相等关系。举个例子一个典型so的程序头表可能是这样的TypeOffsetVirtAddrPhysAddrFileSizMemSizAlignPHDR0x0000400x0000400x0000400x0001c80x0001c80x8LOAD0x0000000x0000000x0000000x04b6640x04b6640x1000LOAD0x04b6640x04c6640x04c6640x00a7ec0x0dc4bc0x1000DYNAMIC0x04c8e80x04d8e80x04d8e80x0001f00x0001f00x8看到第二个LOAD段没有p_vaddr是0x04c664p_offset是0x04b664两者之间差了0x1000。这是因为ELF要求段在页边界对齐Align0x1000而该段在文件里从0x04b664开始到内存里要映射到0x04c664这个虚拟地址中间那0x1000的差值就是加载偏移。所有段在内存中的地址都等于其在文件中的偏移量加上这个偏移值。这个机制带来的直接后果是你从/proc/pid/maps里看到的so映射区间头尾边界跟磁盘文件的起始结束位置往往对不齐。如果直接把maps里显示的区间整段读出来存成文件得到的产物开头可能有垃圾数据结尾可能多出一截BSS段.bss未初始化数据段文件大小也跟原始so完全对不上。2.2 为什么按maps区间整段读取不靠谱我见过不少新人写dump脚本时直接这样做start int(maps_line.split(-)[0], 16) end int(maps_line.split(-)[1], 16) data open(/proc/pid/mem, rb).read_range(start, end) open(dump.so, wb).write(data)这个做法在早期某些场景下确实能跑通但绝大多数时候会踩坑。第一个问题是基址对齐。Linker加载so时映射的起始地址是base 第一个LOAD段的p_vaddr通常第一个LOAD段的p_vaddr为0所以起始地址就是模块基址。但maps里显示的起始地址可能因为前一个页的映射残留或者段合并在极端情况下出现细微偏差导致整个dump文件的ELF头识别失败。第二个问题是区间重叠。maps里同一个so通常会有多行记录比如7f4a80000000-7f4a80001000 r--p 00000000 fd:14 12345 /data/app/.../lib/arm64/libnative-lib.so 7f4a80001000-7f4a80024000 r-xp 00001000 fd:14 12345 /data/app/.../lib/arm64/libnative-lib.so 7f4a80024000-7f4a80025000 rw-p 00024000 fd:14 12345 /data/app/.../lib/arm64/libnative-lib.so三行分别对应代码段、只读数据段、可写数据段权限不同所以映射成了不同的VMA虚拟内存区域。如果简单取每一行的start和end拼接极易出现重复或漏掉部分数据。第三个问题是内存so不等于文件so。运行中的so在内存里它的段加载位置、BSS段大小、甚至是代码段的实际内容都可能因为linker的重定位操作和运行时解密而跟磁盘文件不一样。对于加固App代码段通常是运行时才解密的你dump的目标恰恰就是运行中完整形态的这个版本所以这是必然的但处理时也要清楚这一点——dump出来的东西不是让你直接替换APK里的so用的它是供IDA分析的素材。2.3 正确思路以ELF程序头表为基准重建文件综合以上问题一个稳妥的dump策略应该分三步走从maps里定位so的基址通常找包含r-xp权限、文件偏移为0的那一行。从基址处读取ELF头验证魔数\x7fELF。解析程序头表遍历所有PT_LOAD段根据每个段的p_offset和p_filesz精确地从内存对应位置读取数据按文件的偏移布局写入新文件而不是按内存地址区段盲目拼接。这样dump出来的文件段在文件里的位置跟原始布局一致ELF头、程序头表都是合法的IDA打开后能正确识别节区。不过这套逻辑在Frida脚本里怎么落地还涉及几个细节点下一节展开。3. 准备Frida环境与确定dump时机环境准备这块网上资料不少我只讲几个实际操作中容易卡壳的点。我们的核心需求是在一台root过的测试机上或者刷了Magisk的模拟器跑Frida-server然后在目标进程中hook某个关键函数或等到so加载完成后执行dump。3.1 版本匹配frida-tools、frida-server与设备的三角关系Frida版本这个问题新入坑的人几乎都栽过。Frida客户端PC上的frida-tools和frida-python、Frida-server推送到手机上的二进制文件和手机CPU架构之间必须严格对应。具体来说frida-tools和frida-python版本要匹配比如都用16.xfrida-server版本必须和frida-python版本一致至少大版本一致。16.0.19的server配16.1.3的python连接时会报unable to connect或者version mismatchfrida-server的架构选择arm64对应64位ARM设备arm对应32位ARM设备x86_64和x86对应模拟器# 查看设备架构 adb shell getprop ro.product.cpu.abi # 假设输出 arm64-v8a就下载 frida-server-16.x.x-android-arm64.xz检查版本匹配的快速方法# PC端 frida --version # 设备端注意在adb shell里跑 adb shell /data/local/tmp/frida-server --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 3.2 定位module基址Module.findBaseAddress的局限性Frida提供了非常方便的APIvar baseAddr Module.findBaseAddress(libnative-lib.so);这个API在绝大多数情况下都能拿到模块基址。但有两个特殊情况要留意第一App如果做了模块反调试检测maps里出现frida特征可能主动unlink掉自己的module信息导致Module.findBaseAddress返回null。这种情况我遇到过几次解决办法是降级方案——直接在maps文件里找。第二某些抽取壳在so没被解密完之前Module.findBaseAddress可能能拿到地址但内存里还是加密数据。所以dump时机比dump方法更重要。3.3 dump时机hook dlopen还是等加载完成什么时候执行dump取决于你的目标纯静态dump代码段等so完全加载、初始化完成后dump就行这时所有段都解密完毕。可以用setTimeout延时或者hook一个确定在该so加载之后才执行的函数。dump解密后的函数逻辑如果目标是魔改UPX或者自写的so加壳解密通常在JNI_OnLoad前后完成那么挂钩JNI_OnLoad或dlopen的返回时机就很关键。我通常的做法是hookandroid_dlopen_extAndroid 7.0以上或dlopen在加载完成后立刻dumpvar dlopen Module.findExportByName(null, android_dlopen_ext); if (dlopen) { Interceptor.attach(dlopen, { onLeave: function(retval) { var path /data/app/.../lib/arm64/libnative-lib.so; // 判断是否为目标so dumpModule(path); } }); }不过注意Interceptor.attach的onLeave时机是在so的构造函数执行之后、dlopen返回之前。有些加固壳的执行流程是先解密代码段再调用构造函数所以在onLeave里dump是安全且时机准确的。4. 高效dump的核心实现从ELF头到段数据的完整脚本前面铺垫了原理和时机现在给出完整可用的脚本。这个脚本我迭代了很多版目前的版本在我的测试机上稳定跑通覆盖了从ELF头校验到PT_LOAD段重建的全部逻辑。4.1 在Frida中读取目标进程内存NativePointer与Memory.readByteArrayFrida读取任意内存地址的数据用的核心API是Memory.readByteArray(addr, size)。它返回一个ArrayBuffer然后可以转成字节数组写入文件。注意这个API是同步阻塞的读大段内存时比如一个50MB的so会导致目标进程卡顿几百毫秒到几秒对正在运行的App可能有感知但大多数场景下可接受。读内存的封装函数function readBytes(addr, size) { return Array.from(Memory.readByteArray(addr, size)); }4.2 解析ELF头和程序头表ELF头的固定格式这里不赘述关键字段是e_phoff程序头表在文件中的偏移位于ELF头偏移0x20处4字节e_phentsize每个程序头条目大小偏移0x36处2字节标准值是3232位ELF或5664位ELFe_phnum程序头条目数量偏移0x38处2字节以64位ELF为例读取方式如下function readELFHeader(baseAddr) { var magic Memory.readByteArray(baseAddr, 4); var mv Buffer.from(magic); if (mv[0] ! 0x7f || mv[1] ! 0x45 || mv[2] ! 0x4c || mv[3] ! 0x46) { throw new Error(Invalid ELF header at baseAddr); } var e_phoff Memory.readU64(baseAddr.add(0x20)); var e_phentsize Memory.readU16(baseAddr.add(0x36)); var e_phnum Memory.readU16(baseAddr.add(0x38)); return { e_phoff: e_phoff, e_phentsize: e_phentsize, e_phnum: e_phnum }; }程序头表从baseAddr.add(e_phoff)开始每个条目大小e_phentsize通常为56遍历e_phnum个条目。每个64位程序头的关键字段偏移大小字段含义0x004p_type段类型1为PT_LOAD0x088p_offset段在文件中的偏移0x108p_vaddr段在内存中的虚拟地址0x208p_filesz段在文件中的大小0x288p_memsz段在内存中的大小4.3 分段读取PT_LOAD并写回文件核心逻辑对于每个PT_LOAD段它的内存实际地址是baseAddr.add(p_vaddr)注意这里不是 p_vaddr - p_offset 之类的换算因为在linker眼里虚拟地址就是基址加p_vaddr需要读取p_filesz字节然后写入目标文件的p_offset位置。这里有个重要细节读到内存大小和文件大小不一样时怎么处理。PT_LOAD段的p_memsz可能大于p_filesz多出来的是BSS段内容全为0在文件里不占空间。所以dump时只读取和写入p_filesz大小即可多余部分不需要管。另外为了效率可以把所有PT_LOAD段先读取到buffer里再一次性写入文件避免频繁的IO操作。Frida脚本里常用FileAPI写文件function dumpSo(baseAddr) { var elfHeader readELFHeader(baseAddr); var file new File(/data/local/tmp/dump_libnative-lib.so, wb); for (var i 0; i elfHeader.e_phnum; i) { var phdrAddr baseAddr.add(elfHeader.e_phoff i * elfHeader.e_phentsize); var p_type Memory.readU32(phdrAddr); if (p_type ! 1) continue; // 只处理PT_LOAD var p_offset Memory.readU64(phdrAddr.add(0x08)); var p_vaddr Memory.readU64(phdrAddr.add(0x10)); var p_filesz Memory.readU64(phdrAddr.add(0x20)); var segData Memory.readByteArray(baseAddr.add(p_vaddr), p_filesz); // 按文件偏移写入 file.seek(p_offset); file.write(segData); } file.flush(); file.close(); }4.4 从maps兜底获取基址如果Module.findBaseAddress失败就用maps兜底。在Frida里可以用Process.enumerateModules()获取模块信息或者自己读maps文件function findModuleBase(moduleName) { var modules Process.enumerateModules(); for (var i 0; i modules.length; i) { if (modules[i].name moduleName) { return modules[i].base; } } return null; }Process.enumerateModules比Module.findBaseAddress更靠谱一点因为它遍历的是Frida自己维护的模块快照某些情况下findBaseAddress因为缓存原因找不到但enumerateModules能列出来。更底层的方式是自己解析mapsfunction findBaseFromMaps(libpath) { var mapsContent new File(/proc/self/maps, r).read(); var regex new RegExp(^([0-9a-f]).* libpath.replace(/[.*?^${}()|[\]\\]/g, \\$) $, m); var m mapsContent.match(regex); if (m) return ptr(parseInt(m[1], 16)); return null; }注意在Frida脚本里/proc/self/maps读的是被hook进程的maps这点很关键千万别在PC端自己读。4.5 完整脚本整理把上面几段拼起来就是我常用的完整dump脚本。为了方便我一般把脚本保持为dump_so.js通过命令行传参指定模块名function readBytes(addr, size) { return Memory.readByteArray(addr, size); } function dumpModuleByName(moduleName, outputPath) { var base Module.findBaseAddress(moduleName); if (base null) { base findModuleBase(moduleName); } if (base null) { console.log([-] Module not found: moduleName); return; } console.log([*] Module base: base); // 验证ELF魔数 var magic Memory.readByteArray(base, 4); var mv Buffer.from(magic); if (mv[0] ! 0x7f || mv[1] ! 0x45 || mv[2] ! 0x4c || mv[3] ! 0x46) { console.log([-] Invalid ELF at base); return; } var e_phoff Memory.readU64(base.add(0x20)); var e_phentsize Memory.readU16(base.add(0x36)); var e_phnum Memory.readU16(base.add(0x38)); console.log([*] phoff e_phoff, phentsize e_phentsize, phnum e_phnum); var file new File(outputPath, wb); var totalSegSize 0; for (var i 0; i e_phnum; i) { var phdrAddr base.add(e_phoff i * e_phentsize); var p_type Memory.readU32(phdrAddr); if (p_type 1) { // PT_LOAD var p_offset Memory.readU64(phdrAddr.add(0x08)); var p_vaddr Memory.readU64(phdrAddr.add(0x10)); var p_filesz Memory.readU64(phdrAddr.add(0x20)); console.log([*] LOAD segment: off0x p_offset.toString(16) vaddr0x p_vaddr.toString(16) filesz0x p_filesz.toString(16)); var segData readBytes(base.add(p_vaddr), p_filesz); file.seek(p_offset); file.write(segData); totalSegSize p_filesz; } } file.flush(); file.close(); console.log([] Dumped moduleName to outputPath (segments: totalSegSize bytes)); } function findModuleBase(moduleName) { var modules Process.enumerateModules(); for (var i 0; i modules.length; i) { if (modules[i].name moduleName) { return modules[i].base; } } return null; } function main() { var moduleName libnative-lib.so; var outputPath /data/local/tmp/dump_ moduleName; // 等待模块加载 var timer setInterval(function() { var base Module.findBaseAddress(moduleName); if (base ! null) { clearInterval(timer); dumpModuleByName(moduleName, outputPath); } }, 500); } main();这个脚本默认等待模块加载加载后自动dump。如果希望注入后立刻执行hook流程再dump可以在Interceptor.attach的onLeave回调里调用dumpModuleByName。4.6 实战执行命令# 先启动frida-server然后执行 frida -U -f com.example.app -l dump_so.js --no-pause--no-pause参数很关键不加的话Frida默认暂停主线程某些app可能直接ANR或者触发反调试。dump完以后用adb pull /data/local/tmp/dump_libnative-lib.so ./把文件拉到PC端分析。5. dump产物的修复为什么直接打开是废的以及怎么修脚本跑通、文件拉到本地以后真正的技术活才刚刚开始。新dump出来的so文件用IDA打开往往能识别ELF头但函数列表乱七八糟反编译结果完全不可用甚至直接打开就报错。这非常正常原因有三个修复思路就围绕这三个原因展开。5.1 修复一程序头表与节区表的文件偏移修正先解释最常见的IDA打不开或加载失败问题。正常的so文件里程序头表位于文件开头附近e_phoff通常很小节区头表在文件末尾e_shoff很大。但内存dump出来的文件各段的写入位置是按p_offset恢复的如果原始文件里第一个LOAD段的p_offset不为0比如从0x40开始那么dump文件开头会有约0x40字节的ELF头区之后紧跟偏移0x40处的段数据整体上文件结构跟原始磁盘文件几乎一致程序头表、节区头表理论上也都是可用的。但实测中经常发现一个问题加固App加载so后linker修改了内存中ELF头的一些字段值。有些加壳实现会在运行时清空或改写节区头表为了隐藏符号表导致dump出来的文件节区表区域全是0或者无效数据。IDA打开这种文件时解析节区会失败但程序头表还在所以通常还能看只是没有节区名、函数名也大多是sub_xxx的形式。这种情况下需要的修复是从相同版本的原版so或者同版本App未加固的lib目录下的so提取节区头表替换到dump文件里。但如果原版so也带了加固壳节区头表本身就不完整这条修复路子就没意义了。我的经验是只要程序头表正常IDA用Load a new file并选择Manual Load时强制按程序头表解析通常能识别出.text段和其他可执行段函数也能反汇编。节区名的缺失只影响美观不阻塞核心分析。5.2 修复二处理运行时被改写的数据——BSS段和动态链接信息第二个问题是内存so和磁盘so天然不同造成的。最典型的是BSS段。原始so的BSS段在文件里不占空间p_memsz p_filesz的那部分但运行后内存中对应的区域被分配了物理页内容初始为0运行时又被程序自身改写了。如果你按p_filesz只读取段的前半部分这部分就没问题但如果用整区间读取方式把BSS段也捞进了文件那么文件末尾会多出一大截零填充或者运行时的脏数据。这个脏数据不影响IDA加载代码段但会导致文件大小异常某些校验工具会报错。更隐蔽的问题是.dynamic段。.dynamic段保存着动态链接信息依赖项、重定位表、符号表、GOT表等它在运行时会被linker读取并改写。具体来说DT_DEBUG条目、某些DT_JMPREL相关的GOT条目在运行时会被填上实际的内存地址。所以dump出来的.dynamic段跟磁盘原始so对比会有细微差别。不过这些差别通常只在so被重新链接或二次加载时才有影响——你只是用IDA分析IDA主要依赖节区表和程序头表来做地址计算.dynamic段里的内存地址值对它影响有限。真正影响分析的是运行时linker可能对代码段执行的解密、脱壳操作。对于加壳so代码段在内存里被解密了dump出来的当然没问题。但对于某些反调试运行时自修改的壳代码段某些部分可能还没被完全恢复dump时机太早就会导致部分函数反汇编出来是乱码。解决办法就是等——等App完全启动、目标功能触发一段时间后再dump或者hook关键解密函数在其执行完后再dump。5.3 修复三GOT表重定位和全局偏移表修复如果dump完用IDA分析时发现代码里大量引用外部函数比如memcpy、strlen、JNI函数的地址不正确或者F5反编译出奇奇怪怪的东西问题大概率出在GOT表上。GOT表在磁盘上存放的是偏移量或者符号解析信息加载后linker会把它改写为实际的内存地址。dump出来时这些地址是运行时地址对于符号级分析来说其实影响不大因为IDA通常通过动态链接的符号表来识别外部函数引用而不是通过GOT表内容。但如果dump时错过了linker已完成重定位的时机且目标so是自己实现的静态链接JNI注册可能需要在修复时对比原版so的GOT链接信息。这块没有万能修复方法我的建议是优先保证代码段完整优先保证节区表能被识别导出表信息如果损坏用readelf -s对比原版so恢复符号名5.4 实用的辅助工具与手动修复流程实战中我的修复流程一般是这样的先用readelf -h dump.so看ELF头是否正常再用readelf -S dump.so看节区表能否解析如果不能用readelf -l确认程序头表是否完好最后用IDA加载如果提示File format not recognized多半是文件仍存在字节布局问题遇到第四种情况手动用010 Editor或者Python的lief库来修复。lief是一个特别好用的ELF解析库可以重建程序的段布局import lief # 以二进制方式读取dump文件 with open(dump.so, rb) as f: data f.read() # 尝试用lief解析如果能解析说明ELF结构基本完好 binary lief.parse(data) if binary: print(Parsed OK) else: print(Parse failed)lief最大的价值在于当dump文件结构被破坏时它可以把程序头表中记录的段按照正确的文件偏移重新布局生成一个结构干净的ELF文件。这个操作相当于清洗一遍文件结构比手动改字节可靠得多。需要说明的是这类修复操作的核心目的就一个让IDA能正确识别代码段并反汇编。只要IDA能出代码分析就能继续其他都是次要的。6. 实战中的反调试对抗与特殊场景处理dump任务里最难受的两种情况一是App检测到Frida直接退出二是App的so把反调试逻辑嵌入到加载流程里导致dump时机极难把握。这两个场景在真实商业App里出现频率很高单独拿出来讲讲。6.1 反调试检测Frida的常见手段与绕过思路常见的反Frida检测手段有这么几类检测端口。Frida-server默认监听27042端口App可以尝试连接localhost:27042能连上就说明有Frida。绕过方法启动frida-server时指定监听端口/data/local/tmp/frida-server -l 127.0.0.1:9999PC端连接时加上-H 127.0.0.1:9999参数指定远程地址。不过这种方式需要先做端口转发adb forward tcp:9999 tcp:9999 frida -H 127.0.0.1:9999 -f com.example.app -l script.js扫描maps中的frida痕迹。Frida注入后maps里会出现frida-agent相关的映射区域。绕过方法用gadget替换frida-server或者用frida的stalker特性来隐藏——但这对新手来说太复杂了。实用方案是使用修改版的frida-agent或者用magiskhide/shamiko把frida字节特征藏起来不过这个得刷模块看具体测试机环境。检测线程和命名管道。Frida注入会创建特定命名的线程和管道文件App可以用pthread_getname_np或者遍历/proc/self/fd来发现异常。这种检测比较底层绕过的代价也高。对于刚开始接触frida的朋友我建议先别急着研究各种魔改隐藏方案先从反调试策略的源头入手很多App的反调试只在一定条件下触发比如检测到调试器附加、检测到关键函数被hook。在dump场景下往往只需要在App启动的最早期注入趁反调试逻辑还没布置好就完成dump绕过的难度就大幅降低。用frida -f配合--no-pause在App的main函数或.init_array执行之前注入成功很多时候直接规避了最严格的检测。6.2 处理so加载完成后立即自毁的情况有些加固会在so初始化完成后做自校验发现代码段被修改比如检测到调试断点或内存dump痕迹就主动把代码段清零或者退出进程。对付这种情况我的经验是不要等加载完再dump而是hook住解密函数的返回点在解密完成但自校验还没执行的那个极短窗口内dump。这要求你对该so的加解密流程有一定了解通常需要配合静态分析来定位关键函数。一个更朴素的方案是批量dump在App启动过程中反复dump同一个模块保存多份总有一份是完整且未自毁的。配合Frida循环dump的逻辑脚本稍微改一下就能实现。6.3 32位与64位so的差异化处理ARM32和ARM64的ELF结构字段大小不同主要区别体现在程序头表条目大小32位是32字节64位是56字节对应的Frida脚本读写内存时用的API也要区分32位Memory.readU32、Memory.readPointer4字节指针64位Memory.readU64、Memory.readPointer8字节指针建议在脚本里通过Process.pointerSize自动判断var is64 (Process.pointerSize 8); if (is64) { var p_offset Memory.readU64(phdrAddr.add(0x08)); } else { var p_offset Memory.readU32(phdrAddr.add(0x08)); }实测中很多App同时携带lib/arm64-v8a和lib/armeabi-v7a两份so目标App在不同设备上加载的版本不同脚本要能自适应。6.4 模拟器环境下的额外坑很多朋友喜欢用模拟器做逆向测试省去真机root的麻烦。但模拟器环境下dump so有额外的坑一是x86模拟器上跑的arm转译代码。如果你用普通的x86模拟器没有ARM翻译层或翻译层不完整App加载的so是x86版本这与真机上的ARM64版本不同dump出来分析的意义有限。想分析ARM64的so要么用真机要么用支持ARM翻译的模拟器如新版Android Studio Emulator的ARM镜像但这类模拟器性能一般且兼容性不稳定。二是模拟器的maps布局和真机有差异。某些模拟器会把匿名映射和文件映射混合排列导致regex匹配maps时出现误匹配。我的策略是正则表达式尽量匹配文件路径全称避免模糊匹配。7. 实际案例复盘从dump到修复完成的一个完整示例理论说了这么多用一个简化但真实的过程来完整演示一下。为了不涉及具体产品信息这里用目标App的libSecCore.so来代称。7.1 问题描述与静态初步分析目标App来自一次移动安全测试核心加密算法在native层。静态解包后lib/arm64-v8a/libSecCore.so存在但用IDA打开后代码段只有不到10个函数全部是一堆nop和跳转指令明显是壳的loader。真正逻辑应该在运行时解密。7.2 Frida dump的完整命令和操作记录设备是Pixel 2Android 10root后装Magiskfrida-server 16.0.19与PC端frida-tools匹配。# 推送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 # 查看目标进程是否已启动 adb shell ps -A | grep com.target.app # 目标进程名是 com.target.app # 执行dump脚本。注意目标so必须在App启动后才能加载所以用spawn模式启动 frida -U -f com.target.app -l dump_so.js --no-pause脚本运行后输出[*] Module base: 0x7d2c892000 [*] phoff64 phentsize56 phnum11 [*] LOAD segment: off0x0 vaddr0x0 filesz0x4d000 [*] LOAD segment: off0x4d000 vaddr0x4e000 filesz0x3c0a8 [] Dumped libSecCore.so to /data/local/tmp/dump_libSecCore.so (segments: 889000 bytes)注意filesz加起来是0x889000字节约8.9MB对于一个so来说挺大的说明代码段和数据段内容丰富。7.3 修复过程中遇到的坑与解决记录拉回本地后readelf检查adb pull /data/local/tmp/dump_libSecCore.so . readelf -h dump_libSecCore.soELF头解析正常。再看节区表readelf -S dump_libSecCore.so | head -30输出是空的只有几个奇怪的节名说明节区表可能损坏。用readelf -l查看程序头表发现PT_LOAD段信息完整。直接用IDA打开IDA弹出警告Section headers are not available选择Continue然后程序还是被加载了可以正常F5反汇编但函数名都是sub_xxxx导出表为空。不过这不影响分析——直接看代码逻辑逐步还原算法即可。问题比较严重的是IDA反汇编到某些地址时报错显示FF FF FF FF之类的非法字节。对比一下发现这些地址正好落在第二个LOAD段尾部怀疑是BSS段延伸或者解密不完全。解决方式在App里触发一次目标功能点击按钮调用加密接口后等1秒钟再dump。此时该段内容已被完整解密再次dump后问题消失。7.4 核心收获这个案例说明一个点dump不是一次成功。很多时候需要多次尝试结合App的实际运行状态来调整dump时机。第一次dump如果发现代码段不完整优先考虑是不是解密时机太早而不是怀疑脚本逻辑。另外也验证了不依赖节区表也能分析这条路。只要程序头表完好IDA在Manual Load模式下能正确映射代码段F5反编译和交叉引用都能正常出结果。8. 经验总结与一套完整的dump修复工作流最后把这套方法论整理成一份可以直接复制的工作流清单按这个顺序执行可以省掉大量试错成本。第一步明确目标与环境确认设备架构arm64还是arm下载对应架构和版本的frida-server确认frida-tools与frida-server版本一致确认App能正常启动、不闪退若闪退优先排查反调试第二步定位目标so与加载时机静态解包APK确认so的路径和架构目录使用hook dlopen或Process.enumerateModules轮询确认加载时机如果so在App启动时才动态加载用frida -f的spawn模式配合--no-pause第三步执行dump并检查产物完整性运行dump脚本记录输出的段信息adb pull拉回本地用readelf -l和readelf -S快速验证文件结构用IDA或Ghidra加载测试确认代码段能反汇编第四步迭代修复代码段不完整调整dump时机触发关键功能后再dump节区表不可用不强制修复使用程序头表模式加载部分函数反编译异常确认是否解密不完整重新在更晚时机dump文件打不开用lief重新构建ELF结构第五步记录与归档保存dump时的基址、段信息、App运行状态方便复现对多版本dump做对比确认内容一致性我在实际工作中发现这套流程最花时间的环节并不是dump本身而是确定正确时机。很多时候一次dump不干净需要反复观察App的加载行为、尝试不同注入点。建议新手在动手写dump脚本之前先用Frida把App的模块加载时序摸清楚看Process.enumerateModules()的日志输出搞清楚so是什么时候加载的、加载前有没有前置解密步骤。这一步摸透了后面就是按部就班执行脚本的活。最后再分享一个实用技巧如果你遇到的目标so非常大超过20MB在Frida脚本里读内存时可能会遇到性能瓶颈。这时可以把dump逻辑拆成多次执行每次只读一个PT_LOAD段分段写入文件避免单次readByteArray阻塞时间过长导致App ANR或触发看门狗。实测中分段dump比整包dump在稳定性上好很多尤其适合生产环境里的线上分析任务。