Frida内存提取实战:Android so与dex动态dump技术详解

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

Frida内存提取实战:Android so与dex动态dump技术详解 1. 这不是“一键脱壳”而是一场与Android运行时的精密协同你有没有遇到过这样的情况一个加固后的APK用常规工具反编译出来全是混淆的类名和空方法体Jadx打开后连onCreate()都找不到或者某款金融类App关键的加解密逻辑全藏在.so里IDA静态分析半天却始终无法确认它在运行时到底加载了哪几个动态库、又从哪个内存地址开始执行——因为它的libxxx.so根本不在/data/app/xxx/lib/下甚至压根没写死在AndroidManifest.xml里而是从服务器下发、解密后直接dlopen进内存。这时候光靠静态分析已经失效你真正需要的不是“dump出一个文件”而是在目标进程真实运行的瞬间精准捕获它正在使用的、尚未落地到磁盘的原始二进制数据。Frida正是解决这类问题的“手术刀”。它不依赖root虽然root能解锁更多能力也不需要修改APK重打包而是通过注入JavaScript脚本在目标进程的用户态空间中实时Hook关键系统调用监听dlopen、mmap、dvmDexFileOpenPartial等底层行为从而在so/dex被加载进内存、尚未被混淆或加密覆盖的“黄金窗口期”完成提取。这不是黑箱暴力扫描而是基于对Android ART/Dalvik运行时机制、Linux内存管理模型、ELF/Dex文件格式的深度理解所构建的主动式观测体系。本文聚焦的就是如何让这套体系稳定、高效、可复现地运转起来——不是教你怎么跑通一个Hello World脚本而是告诉你当dlopen返回的句柄指向一块匿名映射区域时你该用Process.enumerateRanges还是Memory.readByteArray当DexFile对象被创建后它的pDexFile字段在不同Android版本上偏移量为何会差4个字节为什么你dump出来的dex文件用Jadx打不开而用dex2oat --dex-file却能成功验证这些细节才是决定一次dump是“凑巧成功”还是“稳如磐石”的分水岭。关键词Frida、Android、so dump、dex dump、内存提取、ART运行时、ELF解析、Dex格式、动态分析2. Frida环境搭建与设备适配别让环境问题毁掉整个分析链路很多人卡在第一步不是因为不会写Hook脚本而是因为Frida Agent根本没真正附着到目标进程上。这背后涉及三个层面的适配设备架构、Android版本兼容性、以及Frida Server自身的稳定性。我见过太多人在一台AOSP 12的Pixel 3上脚本跑得飞起换到某国产厂商深度定制的Android 13系统上就反复报Failed to spawn: timeout最后发现只是因为厂商把/data/local/tmp/的SELinux上下文改成了u:object_r:shell_data_file:s0而默认的frida-server二进制没有对应权限。2.1 架构匹配arm64-v8a不是“万能钥匙”Android设备CPU架构绝非只有arm64一种。你需要先确认目标App的ABI支持列表。最可靠的方式不是看手机型号而是解压APK检查lib/目录下的子文件夹unzip -l app-release.apk | grep lib/ # 输出示例 # lib/arm64-v8a/libcrypto.so # lib/armeabi-v7a/libssl.so # lib/x86/libnative-lib.soFrida Server必须与目标进程的主ABI一致。例如一个只包含lib/arm64-v8a/的App在x86模拟器上运行时其so仍会以arm64模式执行由模拟器翻译此时你必须使用frida-server-16.1.10-android-arm64.xz而非x86版本。下载地址统一在 Frida Releases 页面注意区分android-arm64、android-arm、android-x86_64等后缀。我习惯的做法是在设备上执行getprop ro.product.cpu.abi再根据结果下载对应Server。2.2 Android版本与SELinux策略权限不是“给就完事”Android 8.0Oreo之后/data/local/tmp/目录的默认权限变为drwxrwx--x且SELinux策略严格限制了shell域对tmpfs类型文件的execute权限。这意味着即使你用adb push把frida-server放进去chmod x后执行也大概率失败。解决方案不是关闭SELinux不现实而是使用run-as命令切换到目标App的UID下启动Server# 先获取目标包名的UID假设包名为com.example.app adb shell dumpsys package com.example.app | grep userId # 输出userId10345 # 切换到该UID并在/data/data/com.example.app/目录下启动server adb shell run-as com.example.app sh -c cd /data/data/com.example.app /data/local/tmp/frida-server 但此法有前提App必须是debuggable的。对于release版App更通用的做法是利用adb root仅限userdebug/eng版本或借助Magisk模块Frida Manager自动处理SELinux上下文。我在小米13HyperOS 1.0上实测必须将frida-server的SELinux上下文手动修改为u:object_r:shell_data_file:s0命令如下adb shell su -c chcon u:object_r:shell_data_file:s0 /data/local/tmp/frida-server提示chcon命令本身也需要su权限且不同厂商ROM的SELinux策略名称可能不同。若报错Permission denied请先执行adb shell su -c id确认root权限是否生效再查ls -Z /data/local/tmp/确认当前上下文。2.3 Frida版本选择15.x与16.x的核心差异Frida 16.x引入了全新的Stalker引擎和Interceptor重构对ART 12的QuickMethodFrameHook更稳定但代价是内存占用翻倍。而Frida 15.1.17在Android 9-11上兼容性极佳且Java.perform的初始化延迟更低。我的经验是分析老版本ApptargetSdk 28优先选15.x分析新金融/游戏类ApptargetSdk 31且启用了android:hardwareAcceleratedtrue则必须用16.x否则Hookdlopen时会因art::Thread::Current()获取失败而崩溃。版本确认命令frida --version # 查看本地frida-tools版本 adb shell /data/local/tmp/frida-server --version # 查看设备端server版本二者版本号必须一致否则会出现frida: unable to connect to remote frida-server错误。这是新手最容易忽略的“版本错配”陷阱。3. so文件dump从dlopen到ELF头校验的完整闭环dump so的本质是捕获dlopen调用后目标so在内存中的映射基址与大小然后将其完整读取并保存为.so文件。但这远非readBytes(base, size)那么简单。一个健壮的dump流程必须包含四个关键环节Hook点选择、内存范围枚举、ELF结构校验、文件落地与修复。3.1 Hook点选择为什么dlopen比mmap更可靠初学者常误以为Hookmmap就能抓到所有so这是危险的。mmap是底层内存分配接口一个so的加载过程可能涉及多次mmap代码段、数据段、BSS段分别映射且中间还可能穿插mprotect修改页属性。而dlopen是so加载的语义入口它返回一个void*句柄这个句柄在glibc中实际指向struct link_map其第一个字段l_addr就是so的加载基址。Hookdlopen我们能100%确认“这是一个so的加载事件”且能直接拿到基址无需在茫茫内存中扫描。Frida脚本核心逻辑如下// Java层调用dlopen时参数为String path Interceptor.attach(Module.getExportByName(null, dlopen), { onEnter: function (args) { // args[0] 是so路径可能是绝对路径也可能是相对路径如libxxx.so const path Memory.readUtf8String(args[0]); console.log([] dlopen called for: path); this.path path; }, onLeave: function (retval) { if (retval.isNull()) return; // retval 即为dlopen返回的handle也就是so基址 const baseAddr retval; console.log([] so loaded at: baseAddr); // 后续dump逻辑在此触发 this.dumpSo(baseAddr, this.path); } });但这里有个致命陷阱dlopen的args[0]在某些加固方案中会被清空或替换为随机字符串如/dev/null导致你无法通过路径判断是否为目标so。此时必须结合Module.findBaseAddress()进行二次确认// 在onLeave中先尝试用路径找基址兼容未加固场景 let baseAddr Module.findBaseAddress(this.path); if (baseAddr.isNull()) { // 路径失效退化为遍历所有已加载模块匹配ELF魔数 const modules Process.enumerateModules(); for (let i 0; i modules.length; i) { const mod modules[i]; // 检查模块名是否包含关键词或直接读取前4字节是否为\x7fELF try { const magic Memory.readByteArray(mod.base, 4); if (magic[0] 0x7f magic[1] 0x45 magic[2] 0x4c magic[3] 0x46) { console.log([!] Found ELF module: mod.name mod.base); baseAddr mod.base; break; } } catch (e) { continue; } } }3.2 内存范围枚举为什么不能直接用dlopen返回值作为sizedlopen返回的handle只是基址不代表整个so的内存占用长度。一个so在内存中通常由多个不连续的mmap区域组成.text段、.rodata段、.data段、.bss段它们的protection标志PROT_READ|PROT_EXEC等也各不相同。如果直接readBytes(base, 0x100000)很可能读到的是不可读的mmap间隙抛出AccessViolation异常。正确做法是调用Process.enumerateRanges筛选出以baseAddr开头、且protection包含r-x可读可执行的所有连续内存块然后合并它们function getSoSize(baseAddr) { const ranges Process.enumerateRanges(r-x); // 只关心可读可执行段 let totalSize 0; let segments []; for (let i 0; i ranges.length; i) { const range ranges[i]; // 检查该range是否属于我们的so基址在range内或range起始接近基址 if (range.base.compare(baseAddr) 0 range.base.add(range.size).compare(baseAddr) 0) { segments.push(range); totalSize range.size; } } // 按地址排序合并相邻range实际中so段通常不相邻但需考虑 segments.sort((a, b) a.base.compare(b.base)); return { segments: segments, totalSize: totalSize }; } // 在dumpSo函数中调用 const info getSoSize(baseAddr); console.log([] Found info.segments.length executable segments, total size: 0x info.totalSize.toString(16));3.3 ELF头校验与修复dump出来的so为什么IDA打不开这是最常被忽视的环节。你dump下来的二进制是so在内存中的“运行时镜像”而非磁盘上的“静态ELF文件”。关键区别在于程序头表Program Header Table缺失磁盘ELF中e_phoff指向程序头表起始位置而内存中该表通常被丢弃因为mmap已由内核完成段映射。节头表Section Header Table被剥离Release版so通常用strip命令移除.symtab、.strtab等调试信息e_shoff被置零。动态段.dynamic地址偏移变化内存中.dynamic段的虚拟地址p_vaddr与磁盘文件中的p_paddr不同但p_filesz和p_memsz应一致。因此直接保存readBytes结果得到的是一个“无头ELF”IDA会报Invalid ELF file。必须手动修复ELF头。核心修复点有三恢复e_phoff和e_phnum计算程序头表在内存中的位置。通常程序头表紧跟在ELF头之后e_ehsize字节处且每个Elf64_Phdr大小为56字节。我们可以用Memory.readByteArray读取前e_ehsize e_phnum * 56字节再拼接成完整文件。修正e_entry内存中e_entry是运行时入口地址如base 0x1234而磁盘文件中它是相对于文件头的偏移0x1234。需减去base。补全e_shoff可选若需保留符号表可从readelf -S输出中提取节头信息但多数分析场景无需。我封装了一个Python脚本fix_elf.py输入为dump的原始二进制和基址输出为可被IDA识别的标准ELF#!/usr/bin/env python3 import sys import struct def fix_elf(dump_path, base_addr): with open(dump_path, rb) as f: data f.read() # 读取ELF头前64字节 e_ident data[0:16] e_type, e_machine, e_version, e_entry, e_phoff, e_shoff, e_flags, e_ehsize, e_phentsize, e_phnum, e_shentsize, e_shnum, e_shstrndx struct.unpack(HHIIIIIHHHHHH, data[16:52]) # 修正e_entry从VA转为文件偏移 e_entry_fixed e_entry - base_addr # 重建ELF头 new_header e_ident struct.pack(HHIIIIIHHHHHH, e_type, e_machine, e_version, e_entry_fixed, e_phoff, e_shoff, e_flags, e_ehsize, e_phentsize, e_phnum, e_shentsize, e_shnum, e_shstrndx) # 拼接新头 原始数据跳过原头 fixed_data new_header data[52:] with open(dump_path .fixed, wb) as f: f.write(fixed_data) print(f[] Fixed ELF saved to {dump_path}.fixed) if __name__ __main__: if len(sys.argv) ! 3: print(Usage: python fix_elf.py dump_file base_addr_in_hex) sys.exit(1) fix_elf(sys.argv[1], int(sys.argv[2], 16))注意此脚本假设e_phoff在dump数据中是有效的。若e_phoff为0常见于strip后的so则需用readelf -l分析原始APK中的so获取正确的程序头表布局再手动填充。这是高级技巧后续章节详述。4. dex文件dump从DexFile对象到odex/oat的逆向还原相比sodex的dump更复杂因为它涉及Android运行时的抽象层。Dalvik时代DexFile对象直接持有一个指向内存中DexFile结构体的指针而ART时代DexFile对象内部存储的是一个OatDexFile或OatFile的引用真正的dex字节码可能被AOT编译为.oat文件中的机器码或仍以.dex形式存在于oat文件的oat_dex_filesection中。因此“dump dex”在ART上本质是“dump oat文件中的dex数据段”。4.1 Dalvik与ART的DexFile结构差异偏移量为何总在变Dalvik的DexFile结构体定义在dalvik/vm/DexFile.h中其第一个字段pHeader指向DexHeader的偏移量固定为0x10ARM或0x18ARM64。而ART的DexFile类art/runtime/dex_file.h是一个C类其成员变量布局受编译器优化影响且不同Android版本的ART源码中DexFile的构造方式不同。例如Android 7.0NougatDexFile对象中begin_字段指向dex字节码起始偏移量为0x28Android 10QDexFile对象中begin_字段偏移量变为0x30Android 12SDexFile对象被重构begin_不再直接暴露需通过GetLocation()和GetBaseAddress()组合推导这意味着硬编码Memory.readPointer(obj.add(0x28))在跨版本时必然失效。可靠方案是不依赖固定偏移而通过Java层反射获取DexFile实例再用Java.use获取其内部字段。// Hook ClassLoader的findClass捕获新DexFile创建时机 Java.perform(function () { const DexClassLoader Java.use(dalvik.system.DexClassLoader); DexClassLoader.$init.overload(java.lang.String, java.lang.String, java.lang.String, java.lang.ClassLoader).implementation function (dexPath, optimizedDirectory, librarySearchPath, parent) { console.log([] DexClassLoader init: dexPath); // 此时dexPath对应的dex已被加载但DexFile对象尚未返回 // 我们需要Hook DexFile的构造函数 const DexFile Java.use(dalvik.system.DexFile); DexFile.$init.overload(java.lang.String).implementation function (name) { console.log([] DexFile created from: name); // 获取this对象即DexFile实例 const dexFileObj this; // 尝试反射获取其内部字段 try { const beginField dexFileObj.class.getDeclaredField(begin); beginField.setAccessible(true); const beginAddr beginField.get(dexFileObj); console.log([] Dex begin address: beginAddr); // 后续dump逻辑... } catch (e) { console.log([-] Failed to get begin field: e); // 降级为内存扫描 this.scanAndDumpDex(); } }; return this.$init(dexPath, optimizedDirectory, librarySearchPath, parent); }; });4.2 内存扫描法当反射失效时的终极手段当加固方案禁止反射如setAccessible(false)或ART版本过高导致字段不可见时唯一可靠的方法是在DexFile对象创建后以其this指针为起点在附近内存中搜索Dex魔数\x64\x65\x78\x0a\x30\x33\x35\x00dex\n035\0。原理是DexFile对象本身很小100字节而它所指向的dex字节码数据块通常紧邻其后或在附近几MB范围内。我们可以以this地址为中心向高地址扫描1MB每次读取8字节匹配魔数function scanAndDumpDex(dexFileObj) { const objAddr dexFileObj.handle; // 获取Java对象的底层指针 const startScan objAddr.add(0x100); // 跳过对象头 const endScan objAddr.add(0x100000); // 扫描1MB for (let addr startScan; addr.compare(endScan) 0; addr addr.add(1)) { try { const magic Memory.readByteArray(addr, 8); if (magic[0] 0x64 magic[1] 0x65 magic[2] 0x78 magic[3] 0x0a magic[4] 0x30 magic[5] 0x33 magic[6] 0x35 magic[7] 0x00) { console.log([] Found dex magic at: addr); // 读取dex header获取file_size const header Memory.readByteArray(addr, 0x20); const fileSize header.readUInt32LE(0x20); // dex header中0x20为file_size const dexData Memory.readByteArray(addr, fileSize); // 保存为.dex文件 const fileName /data/local/tmp/dump_ Date.now() .dex; const fd new File(fileName, wb); fd.write(dexData); fd.close(); console.log([] Dex dumped to: fileName); return; } } catch (e) { continue; // 访问违规跳过 } } console.log([-] Failed to find dex magic in memory); }4.3 OAT文件解析从odex到dex的“反编译”还原很多App会将dex预编译为.odexDalvik或.oatART文件存放在/data/dalvik-cache/下。这些文件并非纯机器码而是包含嵌入的dex字节码。ART的.oat文件格式是公开的其结构为OAT Header固定大小含magic、version等OAT DEX FILEs section包含多个OatDexFile每个对应一个dexOAT DEX FILE data真正的dex字节码紧跟在OatDexFile结构后因此dump.oat文件后可用oatdump工具Android NDK自带直接提取dex# 将oat文件pull到本地 adb pull /data/dalvik-cache/arm64/systemframeworkboot.oat ./boot.oat # 使用oatdump提取所有dex $NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/oatdump --oat-file./boot.oat --export-dex/tmp/exported_dex/但此法需root权限访问/data/dalvik-cache/且oatdump版本需与目标Android版本匹配如Android 12的oat文件必须用Android 12 NDK中的oatdump。更轻量的方案是用Python解析oat header定位OatDexFile数组再提取其中的dex数据。核心逻辑是读取oat header的oat_dex_file_offset_字段该字段指向OatDexFile数组的起始偏移然后遍历每个OatDexFile其dex_file_location_offset_字段即为dex数据在oat文件内的偏移。我编写了一个extract_dex_from_oat.py脚本支持Android 8-13的oat格式输入oat文件路径输出所有嵌入的dex文件。其关键代码段如下def parse_oat(oat_path): with open(oat_path, rb) as f: data f.read() # 读取oat header前2048字节 header data[0:2048] # 解析magic和versionAndroid 8 magic为oat\n009\0 magic header[0:8].decode(ascii, errorsignore) if not magic.startswith(oat\n): raise ValueError(Invalid OAT magic) # 定位oat_dex_file_offset_Android 8位于offset 0x100 oat_dex_file_offset struct.unpack(I, header[0x100:0x104])[0] # 读取OatDexFile数组每个OatDexFile大小为128字节 dex_file_array_start oat_dex_file_offset dex_count struct.unpack(I, header[0x104:0x108])[0] # dex_count字段 for i in range(dex_count): offset dex_file_array_start i * 128 if offset 128 len(data): break # 解析OatDexFile结构 dex_file_location_offset struct.unpack(I, data[offset0x10:offset0x14])[0] dex_file_size struct.unpack(I, data[offset0x14:offset0x18])[0] # 提取dex数据 dex_data data[dex_file_location_offset:dex_file_location_offsetdex_file_size] with open(f{oat_path}.dex_{i}, wb) as f_out: f_out.write(dex_data) print(f[] Extracted dex_{i}, size: {len(dex_data)} bytes)经验oatdump虽方便但对加固App的odex文件常报Invalid oat file。此时手写解析器反而更鲁棒因为它不依赖oatdump的内部校验逻辑只按规范读取字段。5. 实战避坑指南那些让你熬夜到凌晨三点的“幽灵Bug”理论再完美也抵不过真实设备上的一次Segmentation fault。以下是我踩过的、最具迷惑性的五个坑每一个都曾让我对着logcat发呆半小时以上。5.1 “dlopen返回null但so明明加载成功了”——符号重定向陷阱现象Hookdlopen时onLeave中retval.isNull()为true但用adb shell pm list packages确认App已正常启动。原因某些加固SDK如360加固会Hookdlopen并在内部将dlopen调用重定向到自己的dlopen_internal函数而原始dlopen的返回值被丢弃。此时Frida Hook到的仍是原始dlopen但它已被加固层“劫持”返回NULL。解决方案不要只Hookdlopen同时Hookdlsym和dlclose观察dlsym是否能成功获取到so中的符号。若能则说明so已加载只是dlopen被拦截。此时改用Process.enumerateModules()遍历所有模块用Memory.readByteArray(module.base, 4)校验ELF魔数。5.2 “dump的so用IDA打开显示‘Not a valid PE file’”——字节序与架构误判现象在x86_64电脑上用Frida dump arm64设备的so保存为文件后IDA提示Not a valid PE file。原因IDA默认按主机架构解析文件而dump的二进制是arm64的ELFIDA误以为是Windows PE。这不是文件损坏而是IDA的加载器配置错误。解决方案在IDA中File - Load file - Parse IDC file...选择ELF格式并在Processor type中明确指定ARM64。或者用命令行工具file dump.so确认其类型dump.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), ...。5.3 “dump的dex用Jadx打不开报‘Invalid dex file’”——校验和未重置现象内存dump的dex文件hexdump -C查看前8字节确实是64 65 78 0a 30 33 35 00但Jadx报错。原因dex header中checksum字段offset 0x084字节是整个dex文件除header外的adler32校验和。内存dump时该字段仍是原始值而dump后的文件内容可能因内存对齐、padding等原因与原始文件不一致导致校验失败。解决方案用Python重置checksum。读取dump文件计算[8:]部分的adler32再写回header的0x08位置import zlib def fix_dex_checksum(dex_path): with open(dex_path, rb) as f: data bytearray(f.read()) # 计算adler32从offset 12开始跳过signature和checksum本身 checksum zlib.adler32(data[12:]) # 写回header的0x08位置小端序 data[0x08] checksum 0xFF data[0x09] (checksum 8) 0xFF data[0x0a] (checksum 16) 0xFF data[0x0b] (checksum 24) 0xFF with open(dex_path .fixed, wb) as f: f.write(data) print(f[] Fixed dex checksum for {dex_path})5.4 “Frida脚本运行几秒后自动退出logcat无报错”——内存泄漏与GC压力现象脚本在Java.perform中创建大量Java.array或Memory.alloc分配内存运行一段时间后静默退出。原因Frida的JS引擎QuickJS在Android上内存受限频繁分配大块内存如一次dump 10MB so会触发GC而GC期间若JS线程正执行Memory.readByteArray可能导致SIGSEGV。解决方案避免在JS中一次性读取超大内存块。改用分块读取如每次readByteArray(addr, 0x1000)并插入setTimeout让出JS线程function readLargeBlock(addr, size) { const chunkSize 0x1000; const chunks []; for (let i 0; i size; i chunkSize) { const end Math.min(i chunkSize, size); const chunk Memory.readByteArray(addr.add(i), end - i); chunks.push(chunk); // 每100块暂停1ms缓解GC压力 if (i % (chunkSize * 100) 0) { Thread.sleep(0.001); } } return [].concat(...chunks); // 合并所有chunk }5.5 “同一台设备昨天dump成功今天失败”——SELinux策略的动态更新现象设备重启后Frida Server无法启动adb shell /data/local/tmp/frida-server报Permission denied。原因某些厂商ROM如华为EMUI会在系统更新后重置/data/local/tmp/的SELinux上下文或启用新的neverallow规则禁止shell域执行execmem。解决方案不要依赖一次性的chcon。每次启动前用adb shell su -c restorecon -R /data/local/tmp/恢复默认上下文再执行chcon。更彻底的方案是将frida-server放入/data/data/your_app/files/目录下该目录的SELinux上下文默认允许execute且不受系统更新影响。最后分享一个小技巧在Frida脚本开头加入console.log(Frida agent started at new Date().toISOString());并在onLeave中记录关键事件时间戳。当问题发生时对比logcat -b main -b system | grep frida的时间线能快速定位是脚本逻辑问题还是环境崩溃问题。这比盲目重启设备有效十倍。

相关新闻