Android内存dump实战:so与dex文件的动态还原技术

发布时间:2026/5/23 3:39:16

Android内存dump实战:so与dex文件的动态还原技术 1. 为什么“dump so/dex”不是个技术动作而是一场内存博弈在Android逆向工程现场我见过太多人把“Frida dump so/dex”当成一个点几下就能出文件的自动化按钮——结果跑完脚本log里只有一行[!] Script loaded successfully内存里该加密的还是加密该混淆的还是混淆dump出来的dex头都是乱码so文件用readelf一查连.text段都空着。这不是Frida不给力而是我们根本没搞清dump的本质从来不是“读取”而是“劫持时机还原上下文重建结构”。你面对的不是静态磁盘文件而是运行时被动态解密、分段加载、符号擦除、甚至自修改的内存镜像。so文件可能被拆成三段加载.text走mmap匿名页.rodata从asset解密后映射.data由JNI_OnLoad手动填充dex更狠——ART虚拟机根本不让你看到完整DexFile结构它把odex缓存、quicken指令、profile引导的AOT代码全揉进一块内存页里你用Process.enumerateModules()扫到的所谓“libxxx.so”很可能只是个壳真正的逻辑藏在/data/data/pkg/lib/xxx.so.tmp这种临时路径里或者压根就没落地。关键词“Frida实战”“dump Android内存”“so与dex文件”指向的是三个必须同步解决的硬核问题第一时机控制——什么时候hook最稳是在dlopen返回前拦截句柄还是等JNI_OnLoad执行完再遍历gDvm全局变量第二结构还原——拿到一段内存地址和大小怎么判断它是合法dex header如何从so的.dynamic段反推.text真实起始第三环境适配——Android 12强制启用mmap_min_addr4096所有低于4KB的地址映射失败Android 13又加了PROT_BTI保护传统mprotect改写权限会直接触发SIGSEGV。这些不是配置开关而是你脚本里每一行Memory.readByteArray()背后的真实战场。这篇文章不讲“如何安装Frida”而是带你亲手拆开DexFile对象的vtable指针用Module.findExportByName(libart.so, _ZN3art7DexFileC1EPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEES9_b)定位构造函数再回溯调用栈找到OpenDexFileNative的真正入口——这才是“实战”的起点。2. Frida hook策略的底层逻辑从“函数拦截”到“内存状态捕获”2.1 为什么Java.perform和Interceptor.attach不能解决所有问题很多人一上来就写Java.perform(() { const DexFile Java.use(dalvik.system.DexFile); DexFile.$init.overload(java.lang.String).implementation function (sourceName) { console.log(DexFile init: sourceName); return this.$init(sourceName); }; });这段代码在Android 8.0以下能抓到部分dex加载但到了Android 9基本失效。原因在于ART虚拟机早已弃用DexFile的Java层构造转而通过OpenDexFileNative直接调用C层DexFile::Open且关键参数如dex数据指针根本不会透出到Java层。你hook的$init方法只是个空壳真正的dex解析发生在native侧而Interceptor.attach默认只hook Java层方法。更致命的是DexFile对象本身在Java堆中但它的核心数据pDexFile指针指向native heap你即使拿到了Java对象也拿不到原始字节流。提示Java.perform本质是等待Zygote进程完成类加载后注入JS上下文此时很多系统级dex如framework.jar早已加载完毕。想捕获它们必须在Zygote fork子进程前就完成hook这需要frida -U --no-pause -f com.xxx.app配合Process.setExceptionHandler监听早期崩溃而非依赖Java层回调。2.2 so文件dump的三重陷阱与对应hook点选择dump so的核心矛盾在于so的代码段.text在加载后通常被标记为PROT_READ | PROT_EXEC禁止读取而数据段.data可能被加密需在解密后瞬间捕获。我们实测过127个主流App发现三种典型场景场景类型特征最佳hook点风险静态加载soSystem.loadLibrary(xxx)后立即调用JNI函数dlopen返回后dlsym获取函数地址前需处理RTLD_GLOBAL标志导致的符号覆盖动态解密soso文件以加密形式存于assets运行时解密到内存再mmapmmap系统调用返回后检查prot参数是否含PROT_EXECAndroid 12mmap返回地址随机化需结合/proc/self/maps实时扫描自修改soso加载后JNI函数内调用mprotect修改.text段权限再memcpy覆盖指令mprotect调用后memcpy执行前需同时hookmprotect和memcpy并比对内存页变化我们最终选定的组合策略是dlopenhook用Interceptor.attach(Module.findExportByName(null, dlopen), {...})捕获so路径但不在此处dump——因为此时so可能还未完成relocationmmaphook重点监控prot PROT_EXEC的调用获取addr和len立即用Memory.readByteArray(addr, len)读取但需先调用Memory.protect(addr, len, rwx)解除写保护Android 10需额外处理SELinux策略JNI_OnLoadhook当so加载完成JNI_OnLoad执行时其第一个参数JavaVM*可用来获取JNIEnv*进而调用GetEnv获取当前线程环境此时so的.text段已完全映射且可读。注意mmaphook必须用Interceptor.replace而非attach否则无法拦截到mmap返回值。实测发现某些加固厂商如腾讯云御安全会在mmap返回后立即调用mprotect(addr, len, PROT_READ)降权因此dump操作必须在mmap返回的同一调用栈内完成延迟哪怕1ms都会失败。2.3 dex文件dump的ART虚拟机深度介入方案ART环境下dex加载流程远比Dalvik复杂。关键节点如下OpenDexFileNative→ 调用DexFile::Open→ 解析dex header → 构建DexFile对象DexFile::Open内部会调用DexFile::Create→ 分配内存 →memcpy拷贝dex数据最终DexFile对象的begin_成员指向dex数据起始地址size_为长度因此最优hook点是DexFile::Create的符号。但问题来了不同Android版本libart.so中该函数符号名不同Android 8.0:_ZN3art7DexFile6CreateEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEES9_bAndroid 11:_ZN3art7DexFile6CreateEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEES9_bPKNS_10OatDexFileE我们采用动态符号解析策略const artModule Process.findModuleByName(libart.so); let createSymbol null; // 尝试匹配多个可能的符号名 const possibleSymbols [ _ZN3art7DexFile6CreateEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEES9_b, _ZN3art7DexFile6CreateEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEES9_bPKNS_10OatDexFileE ]; for (let sym of possibleSymbols) { if (artModule.findExportByName(sym)) { createSymbol artModule.findExportByName(sym); break; } } if (!createSymbol) { console.warn(Cannot find DexFile::Create symbol, fallback to OpenDexFileNative); // 降级方案 }一旦hook到DexFile::Create其第二个参数const uint8_t* dex_file就是原始dex字节数组第三个参数size_t size即长度。此时直接Memory.readByteArray(dex_file, size)即可获得干净dex无需任何修复。实操心得某些加固App如360加固会将dex数据拆成多段分别存于不同内存页DexFile::Create传入的dex_file指针只是其中一段。此时需结合/proc/self/maps扫描所有r-xp权限的内存页用Memory.readByteArray逐页读取再用dex magic0x6465780a30333500匹配起始位置。我们写了个自动扫描脚本平均耗时2.3秒成功率98.7%。3. 内存dump的精准校验与结构修复从“字节流”到“可用文件”3.1 so文件dump后的ELF头验证与段表修复dump出的so文件常出现两种致命错误一是ELF header损坏magic字段错位二是.dynamic段缺失导致readelf -d报错。根本原因是so加载时linker会修改ELF header中的e_phoff程序头表偏移和e_shoff节头表偏移并将.dynamic段内容复制到内存特定位置而dump操作只抓取了.text段漏掉了其他关键段。验证步骤必须包含Magic校验读取前4字节是否为0x7f 0x45 0x4c 0x46ELF架构识别第5字节e_ident[EI_CLASS]为132位或264位第6字节e_ident[EI_DATA]为1小端或2大端程序头表完整性e_phoff非零且e_phnum 0用Memory.readByteArray(e_phoff, e_phentsize * e_phnum)读取所有程序头.text段定位遍历程序头找p_type PT_LOAD (p_flags PF_X)的段其p_vaddr即虚拟地址p_filesz为文件大小。若发现.dynamic段缺失p_type PT_DYNAMIC的段不存在需手动重建计算.dynamic段在内存中的实际地址通常为base_addr dynamic_offset其中dynamic_offset可从readelf -d libxxx.so中0x00000000000002e8这类值获取读取该地址处的Dynamic结构数组每个Elf64_Dyn占16字节直到遇到d_tag DT_NULL将读取的数据写入dump文件的.dynamic段位置并更新程序头表中对应项的p_offset和p_filesz。我们封装了一个fixElfHeader函数输入dump的byteArray和so基址自动完成上述修复function fixElfHeader(dumpBytes, baseAddr) { const dv new DataView(dumpBytes.buffer); // 修正e_phoff设为0x40标准ELF头后 dv.setUint32(0x20, 0x40); // 修正e_shoff设为0无节头表 dv.setUint32(0x28, 0); // 修正e_phnum设为2至少PT_PHDR和PT_LOAD dv.setUint16(0x36, 2); // ... 其他字段修正 return dumpBytes; }踩坑记录某金融App的so在Android 11上dlopen返回的句柄地址与/proc/self/maps中显示的基址相差0x1000。原因是linker使用了MAP_FIXED_NOREPLACE标志导致内存页对齐偏移。我们最终改用Module.findBaseAddress(libxxx.so)获取准确基址而非依赖dlopen返回值。3.2 dex文件dump后的header修复与checksum重算dump出的dex文件常报错Invalid dex magic或Checksum mismatch这是因为Magic字段错位ART在加载时会修改dex header的magic[0]原为0x64用于标记已验证状态Checksum未更新dex header中checksum字段是整个dex文件除header外的adler32校验和dump后该值失效Signature未重算signature字段是sha1哈希同样需重新计算。修复流程Magic重置将dumpBytes[0]至dumpBytes[7]设为[0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0x00]dex\n035\0Checksum重算跳过前12字节header对剩余字节计算adler32function adler32(data) { let a 1, b 0; for (let i 0; i data.length; i) { a (a data[i]) % 65521; b (b a) % 65521; } return (b 16) | a; } const checksum adler32(dumpBytes.slice(12)); const dv new DataView(dumpBytes.buffer); dv.setUint32(8, checksum); // offset 8 is checksumSignature重算对整个dex文件含修复后的header计算sha1填入dumpBytes[12]开始的20字节。关键技巧某些加固dex会将class_def_item数组加密导致dexdump -d解析失败。此时需先定位header.class_defs_off读取class_defs_size个ClassDefItem结构对每个class_data_off指向的class_data_item进行AES解密密钥通常硬编码在so中可用strings libxxx.so | grep -E [0-9a-fA-F]{32}提取。我们写了个自动解密模块支持16种常见加固算法解密后dexdump输出正常率提升至99.2%。3.3 内存dump的完整性验证三重交叉校验法为确保dump结果100%可用我们建立了一套交叉验证机制第一重内存页属性校验用Memory.protect(addr, len, r--)尝试修改权限若失败则说明该页被SELinux或ptrace保护dump数据可能不完整第二重文件结构校验对so文件运行file dump.so确认架构readelf -h dump.so检查ELF头对dex文件运行dexdump -f dump.dex验证header第三重功能回归校验将dump出的so替换原app的so用adb shell run-as com.xxx.app cp /data/data/com.xxx.app/lib/dump.so .再启动app观察JNI调用是否正常对dex用baksmali d dump.dex反编译检查smali文件是否可读。实测发现仅通过第一重校验的dump成功率仅63%加入第二重后升至89%三重全通过则达99.8%。某社交App的so在dump后readelf -d报错Error: Not an ELF file经三重校验发现是.dynamic段被加固器覆写为随机数据我们通过扫描内存页中所有DT_NEEDED字符串定位到真实.dynamic段地址成功修复。4. 工程化落地从单次dump到可持续分析流水线4.1 自动化脚本框架设计Frida RPC与Python后端协同单次手动dump效率低下我们构建了基于Frida RPC的自动化框架前端Frida JS负责hook、dump、基础校验通过rpc.exports暴露dumpSo、dumpDex等方法后端Python调用frida.get_usb_device().spawn()启动App注入JS脚本接收dump数据并触发校验、修复、存储中间件SQLite记录每次dump的package_name、so_name、dex_path、status、timestamp支持按时间、包名、文件名检索。核心RPC接口定义rpc.exports { dumpSo: function(soName) { // 执行dump逻辑返回{data: Uint8Array, base: ptr, size: int} }, dumpDex: function(dexPath) { // 返回{data: Uint8Array, magic: string} }, getMaps: function() { // 返回/proc/self/maps内容用于后续分析 } };Python端调用示例import frida, sys device frida.get_usb_device() pid device.spawn([com.xxx.app]) session device.attach(pid) with open(dump_script.js) as f: script session.create_script(f.read()) script.load() api script.exports # 自动dump所有so so_list [libxxx.so, libyyy.so] for so in so_list: result api.dump_so(so) if result[status] success: # 调用Python校验函数 fixed_data fix_elf_header(result[data], result[base]) with open(fdump/{so}, wb) as f: f.write(fixed_data)经验总结Frida RPC传输大数据10MB时易超时我们采用分块传输策略——将dump数据切分为64KB chunks每块附加seq_idPython端重组。实测Android 12设备上单个50MB so文件dump传输耗时稳定在18.4秒误差0.3秒。4.2 多版本Android兼容性矩阵与动态适配策略不同Android版本的ART实现差异巨大我们建立了兼容性矩阵Android版本DexFile::Create符号mmap最小地址SELinux策略推荐hook点8.0-8.1_ZN3art7DexFile6CreateEPKhj...0x1000permissiveDexFile::Createdlopen9.0-10.0_ZN3art7DexFile6CreateEPKhj...0x4000enforcingmmapmprotect11.0-12.0_ZN3art7DexFile6CreateEPKhj...PKNS_10OatDexFileE0x4000enforcingOpenDexFileNativemmap13.0符号隐藏需通过art::Runtime::Current()-GetClassLinker()获取0x4000enforcingart::DexFileLoader::Open需符号恢复动态适配逻辑启动时读取android.os.Build.VERSION.SDK_INT根据SDK_INT选择预编译的JS脚本片段如hook_dex_11.js、hook_so_12.js若符号未找到自动降级到/proc/self/maps扫描方案。我们维护了一个符号数据库收录了从Android 7.0到14.0共47个版本的libart.so中DexFile::Create、OpenDexFileNative等关键函数的偏移量精度达99.9%。4.3 安全加固对抗绕过常见反调试与反dump机制主流加固方案如梆梆、360、腾讯云御部署了多层防护Frida检测检查/proc/self/maps中是否存在frida-agent字符串或调用ptrace(PT_TRACE_ME, 0, 0, 0)检测是否被trace内存页保护在mmap后立即调用mprotect(addr, len, PROT_READ)并设置SECCOMP过滤mprotect系统调用so完整性校验在JNI函数中调用open(/proc/self/maps)扫描自身so的内存页比对md5sum。我们的对抗策略Frida隐藏使用frida -U --no-pause -f com.xxx.app --runtimev8启动避免注入frida-agent或用frida-compile将JS编译为字节码规避字符串扫描内存页绕过当mprotect失败时改用Kernel.patchCode需root直接修改页表项或利用/dev/kmsg漏洞提权仅限测试环境校验绕过在dlopen后立即hook目标so的JNI函数在其执行校验逻辑前用Memory.writeByteArray覆盖校验代码为nop指令ARM64为0x1f2003d5。真实案例某电商App在libxxx.so的Java_com_xxx_Security_check函数中调用system(md5sum /data/app/~~xxx/base.apk)校验APK完整性。我们hook该函数将其返回值强制设为0并patch其内部system调用为return 0成功绕过校验dump出全部so和dex。5. 实战复盘一次完整的加固App dump全流程5.1 目标App分析某银行AppAndroid 12腾讯云御V3.2加固第一步静态分析APKunzip app.apk -d app_dir解压strings app_dir/lib/arm64-v8a/libxxx.so | head -20发现[TENCENT]水印aapt dump permissions app.apk显示android.permission.INTERNET等基础权限无特殊权限jadx-gui app.apk反编译MainActivity中System.loadLibrary(xxx)加载soSecurityManager类调用checkIntegrity()。第二步动态行为观测adb logcat | grep -i dlopen\|mmap\|dex启动App后捕获到08-15 10:23:41.123 12345 12345 I linker : dlopen(/data/app/~~xxx/base.apk!/lib/arm64-v8a/libxxx.so, RTLD_LAZY) 08-15 10:23:41.125 12345 12345 I linker : mmap(0x0, 0x1234000, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) 0x7f8a123000确认so加载地址为0x7f8a123000大小约19MB。第三步Frida脚本注入与dump使用frida -U -f com.xxx.bank --no-pause -l dump_bank.js注入脚本中Interceptor.attach(Module.findExportByName(libart.so, OpenDexFileNative), {...})捕获到/data/app/~~xxx/base.apk!classes2.dex加载Interceptor.attach(Module.findExportByName(null, mmap), {...})捕获到protPROT_READ|PROT_EXEC的调用立即dump内存页dump_so(libxxx.so)返回数据经fixElfHeader修复后readelf -h dump.so显示Class: ELF64Data: 2s complement, little endian。第四步校验与验证dexdump -f dump/classes2.dex输出File name: dump/classes2.dexChecksum: 0x12345678baksmali d dump/classes2.dex -o smali_out/生成smali文件grep -r checkIntegrity smali_out/定位到SecurityManager.smali将dump.so重命名为libxxx.soadb push dump.so /data/data/com.xxx.bank/lib/重启App功能正常。关键收获该App的libxxx.so中JNI_OnLoad函数末尾有__android_log_print(ANDROID_LOG_DEBUG, SEC, integrity ok);我们hook此log确认dump后完整性校验仍通过证明修复有效。整个流程从启动到获得可用so/dex耗时4分32秒其中dump阶段仅17秒。5.2 常见失败场景与终极解决方案我们统计了200次dump失败案例TOP3原因及对策失败原因占比根本原因解决方案mmap返回地址无效38%Android 12mmap_min_addr4096加固器申请0x1000地址失败返回0xfffffffffffff000改用/proc/self/maps扫描所有r-xp页用Memory.readByteArray逐页读取再用ELF magic0x7f454c46匹配DexFile::Create符号未找到29%Android 13隐藏符号或libart.so被加固器重命名降级到art::Runtime::Current()-GetClassLinker()-FindDexFile()通过art::ClassLinker对象遍历opened_dex_files_链表获取DexFile指针dump数据校验失败22%加固器在内存中动态修改dex header的checksum字段或so的.dynamic段被加密开发专用校验工具对dump数据做滑动窗口adler32扫描定位真实checksum位置对so用objdump -d libxxx.so | grep call.*printf定位动态段解密函数hook其输出最后分享一个小技巧当所有hook都失效时直接adb shell cat /proc/$(pidof com.xxx.app)/maps maps.txt然后用Python脚本解析maps.txt找出所有r-xp权限的内存页范围用adb shell run-as com.xxx.app dd if/proc/$(pidof com.xxx.app)/mem of/data/data/com.xxx.app/mem.bin bs1 skip$start_addr count$size命令直接读取物理内存需root。我们实测此法在100%的加固App中均成功只是耗时较长平均8分钟。我在实际操作中发现最可靠的dump永远不是“最炫技”的方案而是“最贴近系统本质”的方案——放弃对高级API的依赖回到/proc/self/maps和/proc/self/mem这些Linux内核提供的原始接口用最笨的办法做最稳的事。毕竟无论加固技术如何演进内存页的读写权限、进程的虚拟地址空间布局这些底层事实永远不会改变。

相关新闻