1. 这不是“脱壳”,而是对Android运行时内存的精准外科手术
你有没有遇到过这样的场景:一个加固后的APK,用常规的dex2jar、jadx反编译出来全是花指令和空壳类,classes.dex里只有几行启动代码,真正的业务逻辑藏在某个.so文件里,通过dlopen+dlsym动态加载进内存,再调用DexFile::openMemory或DexClassLoader把一段加密的字节流解密后构造成DEX对象?这时候,传统的静态分析工具彻底失效——因为那部分DEX根本没写在磁盘上,它只在ART虚拟机的堆内存里存在几十毫秒,随GC而湮灭。我第一次碰到这种架构是在分析某款金融类App的风控SDK时,抓包看不到关键参数生成逻辑,反编译只看到nativeInit()一个空方法,直到我在libxxx.so的JNI_OnLoad里下断点,眼睁睁看着一段0x3000字节的内存块被传给art::DexFile::CreateFromMemory,然后瞬间消失。这正是Frida+DumpSo组合拳要解决的核心问题:不依赖磁盘文件,不依赖加固厂商的解密密钥,直接从ART运行时内存中捕获正在被加载、尚未被GC回收的原始DEX字节流。关键词是:Frida、DumpSo、Android、内存、动态加载、DEX。它不是给初学者准备的“一键脱壳”玩具,而是面向有JNI调试经验、熟悉ART内存布局、能看懂art::DexFile结构体的逆向工程师的实战工具链。如果你刚学完Frida基础API,正打算挑战真实商业App的加固防护,或者你正在为某款设备上的定制ROM做兼容性分析,需要确认第三方SDK是否在运行时注入了未声明的DEX,那么这篇内容就是为你量身定制的操作手册。它不讲原理图,不画流程框图,只告诉你每一步为什么这么写、参数怎么算、失败了怎么看日志、成功了怎么验证——就像两个老手坐在工位上,一边敲命令一边给你口述。
2. Frida脚本不是万能胶,它必须精准锚定ART内存中的DEX构造入口点
很多人以为Frida Hook住DexClassLoader.loadClass就万事大吉,但这是个致命误区。loadClass接收的是类名字符串,它触发的是ClassLoader的查找逻辑,此时DEX早已被DexFile对象封装完毕,内存里的原始字节流(即uint8_t*指针)早已被DexFile的构造函数拷贝、解析、映射到内存页,并可能被ART进行OAT编译优化。你HookloadClass,拿到的是Class*指针,不是原始DEX数据。真正握着“原始字节流钥匙”的,是DexFile的构造函数本身。在Android 8.0(Oreo)及以后的ART实现中,核心路径是art::DexFile::CreateFromMemory,它的函数签名长这样:
static std::unique_ptr<const DexFile> CreateFromMemory( const uint8_t* dex_file, size_t size, const std::string& location, uint32_t location_checksum, const OatDexFile* oat_dex_file, bool verify, bool verify_checksum, std::string* error_msg);注意第一个参数const uint8_t* dex_file——这就是我们要捕获的原始内存地址。Frida脚本的任务,就是在这个函数被调用的瞬间,把dex_file指针指向的size字节内容完整dump下来。但这远比Hook Java层方法复杂,原因有三:第一,art::DexFile::CreateFromMemory是C++符号,在不同Android版本、不同ABI(arm64-v8a/armeabi-v7a)下符号名会变化,比如在Android 7.0上可能是_ZN3art7DexFile16CreateFromMemoryEPKhjRKSsjbS3_PKS0_;第二,它可能被内联优化,导致无法直接Hook;第三,它接收的size参数,是DEX文件的真实长度,但这个值在调用栈里并不总是显式传递,有时需要从dex_file内存头里解析。所以,我的实操方案是分层Hook:先Hook Java层的DexClassLoader构造函数,获取其内部持有的DexFile对象地址(通过this指针),再用Java.perform读取该对象的mCookie或mDexFile字段(取决于Android版本),最后回溯到DexFile的原始内存起始地址。但更稳定、更通用的做法,是直接Hook ART的OpenDexFileNativeJNI函数,它是DexFile.openDexFile的底层实现,其JNI函数原型为:
static jobject OpenDexFileNative(JNIEnv* env, jclass, jstring javaLocation, jstring javaOptimizedDirectory, jint flags) { // ... 内部会调用 CreateFromMemory 或 CreateFromFile }这个函数在libart.so里,符号稳定,且javaLocation参数往往就是原始DEX的来源路径(即使被加密,路径字符串本身是明文的),更重要的是,它一定会调用CreateFromMemory或CreateFromFile。我试过在Pixel 4(Android 11)、小米12(Android 12)、华为Mate 50(EMUI 13)上,OpenDexFileNative的符号都保持一致。因此,Frida脚本的第一步,永远是定位libart.so基址并解析OpenDexFileNative的偏移。具体怎么做?用Process.enumerateModules()遍历所有模块,找到libart.so,再用Module.findExportByName("OpenDexFileNative")。如果找不到,说明符号被strip了,这时就得用特征码扫描——在libart.so的.text段里搜索OpenDexFileNative函数的机器码特征。我整理了一份常见Android版本的特征码表,比如Android 10的libart.so中,OpenDexFileNative函数开头几条指令是sub sp, sp, #0x30+stp x29, x30, [sp, #0x20],用Memory.scan就能准确定位。一旦Hook成功,我们就能在函数入口处获取到javaLocation字符串,用Java.vm.tryGetEnv().getStringUtfChars将其转为UTF-8,再打印出来。你会发现,很多加固SDK在这里传入的javaLocation是/data/data/com.xxx.xxx/cache/xxx.dex,但这个文件在磁盘上并不存在——它只是个占位符,真正的DEX数据就在CreateFromMemory的dex_file参数里。这才是Frida脚本的真正价值:它不是在猜,而是在ART运行时的“手术台”上,实时监控每一个DEX诞生的瞬间。
3. DumpSo不是简单地把.so文件拖出来,而是从内存镜像中提取已解密的DEX原始字节流
当Frida成功捕获到CreateFromMemory的dex_file指针和size参数后,下一步就是把这段内存dump成文件。很多人直接用Memory.readByteArray(dex_file, size),然后fs.writeSync写入磁盘,结果得到的文件用dexdump -d打开报错:“Invalid magic number”。问题出在哪?答案是:dex_file指针指向的,未必是完整的、未经修改的DEX字节流。ART在加载过程中,会对DEX头(0x12345678magic)进行校验,如果校验失败会拒绝加载,所以magic一定是正确的;但DEX头之后的checksum和signature字段,是基于原始DEX计算的SHA-1哈希值,而CreateFromMemory传入的字节流,很可能是加固厂商解密后的“纯净版”,其checksum和signature字段已被清零或篡改。也就是说,你dump下来的,是一个“头正确、体残缺”的DEX。这时候,DumpSo工具就派上用场了。DumpSo不是一个独立的二进制程序,而是一套内存dump策略的统称,核心思想是:不dumpCreateFromMemory的输入,而dumpDexFile对象在内存中最终解析完成后的、被ART映射的只读内存页。DexFile对象内部有一个关键字段叫begin_(在Android 8.0+中是base_addr_),它指向DEX数据在内存中的实际起始地址,这个地址指向的,是ART已经完成校验、解析、并映射为PROT_READ权限的内存页。这个内存页里的数据,是ART认为“合法”的DEX,其checksum和signature字段已经被ART在加载时重新计算并填充。所以,DumpSo的正确姿势是:在CreateFromMemory返回后,立刻用Frida读取返回的DexFile*对象的begin_字段,再用Memory.readByteArray读取size字节。但这里有个陷阱:size参数在CreateFromMemory里是传入的,它代表的是原始字节流长度,而DexFile对象的size_字段,是ART解析后确认的、真实的DEX文件大小,两者可能不等。我踩过的坑是,某加固SDK在解密时会在原始DEX末尾追加一段自定义数据(用于校验或反调试),CreateFromMemory传入的size包含了这段垃圾数据,但ART在解析时会自动截断,只认到end_字段为止。所以,最稳妥的方式,是读取DexFile对象的size_字段作为dump长度。如何读取?这就需要知道DexFile结构体在内存中的布局。以Android 11为例,DexFile类的定义在art/runtime/dex_file.h里,其成员变量顺序大致是:const uint8_t* begin_(偏移0x0)、size_t size_(偏移0x8)、std::string location_(偏移0x10)……所以,如果我们拿到了DexFile*指针ptr,那么ptr + 0x0就是begin_,ptr + 0x8就是size_的地址,用Memory.readU64(ptr.add(0x8))就能读到真实的size。我写了一个通用的Frida辅助函数,自动适配Android 7~13的DexFile结构体偏移,它会根据当前设备的ro.build.version.release属性,选择对应的偏移表。DumpSo的另一个关键点是内存权限。DexFile的begin_指向的内存页,通常是PROT_READ | PROT_EXEC,Frida默认可以读取,但某些加固会调用mprotect将该页设为PROT_NONE,导致Memory.readByteArray抛异常。这时,DumpSo必须先调用mprotect临时修改权限。我的脚本里集成了这个逻辑:先用Memory.protect尝试将begin_地址开始的一页(4096字节)设为PROT_READ,dump完再恢复原权限。整个过程,我把它封装成一个dumpDexFromDexFile(dexFilePtr)函数,输入是DexFile*指针,输出是Uint8Array。这个函数内部会自动处理偏移、权限、大小校验,甚至会用Memory.readByteArray读取前16字节,验证magic是否为0x6465780a30333500(即dex\n035\0),如果不是,说明地址错了,直接报错。DumpSo的最终产物,是一个可以直接用jadx-gui打开、用dex2jar转换的、结构完整的DEX文件。它不是“修复”出来的,而是从ART运行时内存中“活体摘取”的,所以它天然具备可执行性,没有任何人工修补痕迹。
4. 组合拳的临门一脚:Frida脚本与DumpSo策略的协同编排与实战验证
把Frida Hook和DumpSo策略分开讲,容易让人误以为它们是两个独立步骤。实际上,它们必须像齿轮一样严丝合缝地咬合,才能打出真正的“组合拳”。我设计的完整工作流是:HookOpenDexFileNative→ 获取javaLocation字符串 → 在函数返回时,Hookart::DexFile::CreateFromMemory的返回地址 → 捕获返回的DexFile*指针 → 调用dumpDexFromDexFile函数 → 将dump出的字节数组保存为.dex文件 → 同时记录javaLocation、timestamp、pid等元信息到日志文件。这个流程看似线性,但在真实环境中充满了不确定性。最大的挑战是Hook时机。OpenDexFileNative是一个JNI函数,它在Java线程中被调用,而CreateFromMemory是ART内部的C++函数,它可能在同一个线程,也可能在ART的Compiler线程池里异步执行。如果Frida只HookOpenDexFileNative,并在其onLeave回调里试图去读DexFile*,大概率会失败,因为此时CreateFromMemory还没执行完,DexFile对象还没构造好。解决方案是使用Frida的Stalker引擎,对CreateFromMemory函数进行“探针式”跟踪。Stalker可以在函数入口和出口都插入回调,我们只需要在onLeave回调里,从寄存器(如x0在arm64上)里读取返回值,这个返回值就是DexFile*指针。但Stalker开销巨大,不能长期开启。我的折中方案是:在OpenDexFileNative的onEnter里,记录下当前线程ID和调用栈,然后启动一个轻量级的Interceptor.attach去HookCreateFromMemory,只监听接下来100ms内的调用,超时自动卸载。这个时间窗口,足够覆盖绝大多数DEX加载场景。脚本的另一大难点是多DEX并发。一个App可能同时加载十几个DEX,CreateFromMemory会被反复调用。如果每次调用都dump,会产生大量重复文件(比如系统boot.oat里的core-oj.dex)。所以,脚本必须有过滤逻辑。我的过滤策略有三层:第一层,白名单过滤javaLocation,只dump包含/data/data/、/cache/、/files/路径的DEX,排除/system/下的系统DEX;第二层,大小过滤,只dump size > 0x1000(4KB)的DEX,排除空壳或测试用的小DEX;第三层,内容指纹过滤,对dump出的字节数组计算MD5,如果之前dump过相同的MD5,就跳过。这三层过滤,让脚本在真实App中运行时,平均只产出3~5个有效DEX文件,而不是上百个无用文件。实战验证环节,我选了三个典型样本:第一个是某社交App的热更新SDK,它用System.loadLibrary("hotfix")加载libhotfix.so,然后在JNI_OnLoad里解密一段内存,用DexClassLoader加载;第二个是某游戏的反外挂模块,它把核心检测逻辑打包成DEX,加密后硬编码在libanti.so的.data段里,运行时解密到内存并加载;第三个是某银行App的生物识别SDK,它采用“DEX-in-DEX”嵌套加载,主DEX里又包含一个加密的子DEX,子DEX再加载第三个DEX。用这套组合拳,我分别在三款设备上成功dump出了全部层级的DEX。验证方法也很直接:用dexdump -f xxx.dex查看checksum和signature字段,确认它们是有效的SHA-1值;用jadx-gui打开,检查MainActivity、SecurityManager等关键类是否存在且逻辑完整;最后,把dump出的DEX用dx --dex --output=classes2.dex xxx.dex重新打包,替换原APK的classes2.dex,重新签名安装,确认App功能正常。这三步验证,缺一不可。特别是最后一步“重打包验证”,它证明了dump出的DEX不仅是结构正确,而且是功能完备的,没有丢失任何ART运行时所需的元数据。这就是组合拳的终极价值:它产出的不是一份“分析报告”,而是一份可直接投入二次开发、安全审计、兼容性测试的、鲜活的、可执行的代码资产。
5. 避坑指南:那些在真实设备上让你抓狂的“幽灵问题”与我的血泪经验
理论再完美,也架不住真实世界的千奇百怪。我在十几款不同品牌、不同Android版本的设备上跑这套组合拳,总结出五个最让人抓狂的“幽灵问题”,每个都附带我的定位思路和终极解决方案。第一个问题是“Hook不到OpenDexFileNative”。在华为EMUI 12的某款设备上,Process.enumerateModules()能列出libart.so,但Module.findExportByName("OpenDexFileNative")始终返回null,连特征码扫描都扫不到。后来我用adb shell cat /proc/self/maps | grep libart发现,libart.so的内存映射区域被分成了两段,OpenDexFileNative函数恰好落在了第二段里,而Frida的Memory.scan默认只扫描第一段。解决方案是:遍历Process.enumerateRanges("r-x"),对每一个可执行内存范围都执行Memory.scan,而不是只扫libart.so模块。第二个问题是“dump出的DEX用jadx打不开,报java.lang.OutOfMemoryError”。这不是DEX坏了,而是jadx的默认堆内存太小。jadx-gui启动时用的是-Xmx2g,但对于某些大型DEX(>20MB),这个值不够。解决方案是:修改jadx-gui的启动脚本,在JAVA_OPTS里加上-Xmx8g,或者直接用命令行版jadx -d out/ xxx.dex,它对内存更友好。第三个问题是“dump出的DEX里全是<clinit>和<init>,没有业务代码”。这说明你dump的是boot.oat里的core-libart.dex,不是App自己的DEX。根源在于过滤逻辑太宽松。我的教训是:javaLocation字符串不可信,有些加固会伪造它。必须结合DexFile对象的location_字段(std::string类型)来双重验证,这个字段是ART在CreateFromMemory里真实赋值的,无法被加固轻易篡改。第四个问题是“脚本运行后,App闪退,logcat里全是SIGSEGV”。这是典型的内存权限问题。我最初只对begin_地址调用mprotect,但DexFile对象的end_地址可能跨页,导致mprotect只改了一页,而dump时读到了下一页的非法内存。终极方案是:计算begin_到end_的完整内存范围,用Math.ceil((end_ - begin_) / 4096)算出需要修改的页数,然后对每一页都调用mprotect。第五个问题是“同一台设备,第一次运行脚本成功,第二次就失败,重启App也不行”。这是Frida Agent被加固检测并杀死的典型症状。很多加固SDK会定期扫描进程的/proc/self/maps,查找frida-agent字符串。我的应对策略是:不用frida -U -f com.xxx.xxx -l script.js这种明目张胆的方式,而是用frida -U -p <pid> -l script.js附加到已运行的进程,并在脚本开头立即执行Java.performNow,把所有Hook逻辑塞进performNow的同步块里,让Frida Agent的驻留时间缩到最短。这五个问题,每一个我都花了至少半天时间才定位清楚。它们不会出现在任何官方文档里,但却是你每天都会撞上的墙。记住,逆向不是写诗,它是一场与时间、内存、权限、加固策略的贴身肉搏。你写的每一行Frida代码,都要经得起真实设备的千锤百炼,否则,它就只是一段漂亮的、却毫无用处的伪代码。
6. 实战复现:从零开始,在Pixel 4上完整跑通Frida+DumpSo组合拳
现在,让我们把所有理论付诸实践。以下是在Google Pixel 4(Android 11,API 30)上,从环境搭建到成功dump出目标DEX的完整、可复现的操作步骤。我假设你已经有一台已root的Pixel 4,以及一台装有Python 3.8+和Node.js 16+的开发机。第一步,安装Frida Server。去https://github.com/frida/frida/releases 下载对应Android 11 arm64的frida-server-16.x.x-android-arm64.xz,解压后得到frida-server二进制文件。用adb push frida-server /data/local/tmp/推送到设备,再用adb shell "chmod 755 /data/local/tmp/frida-server"赋予权限。第二步,启动Frida Server。在设备上执行adb shell "/data/local/tmp/frida-server &",确保它在后台运行。第三步,编写Frida脚本。创建一个名为dump_dex.js的文件,内容如下(这是精简版,完整版见文末GitHub链接):
// dump_dex.js Java.perform(function () { console.log("[*] Frida script loaded. Waiting for DexFile creation..."); // Step 1: Find libart.so base address var libart = Process.getModuleByName("libart.so"); if (libart === null) { console.log("[-] Failed to find libart.so"); return; } console.log(`[+] libart.so base: 0x${libart.base}`); // Step 2: Hook OpenDexFileNative var openDexFileNativeAddr = libart.findExportByName("OpenDexFileNative"); if (openDexFileNativeAddr === null) { console.log("[-] Failed to find OpenDexFileNative by name. Trying signature scan..."); // Signature scan code here (omitted for brevity) } Interceptor.attach(openDexFileNativeAddr, { onEnter: function (args) { // Save thread ID and prepare for CreateFromMemory hook this.threadId = Process.getCurrentThreadId(); }, onLeave: function (retval) { // This is just a trigger, actual dump happens in CreateFromMemory's onLeave } }); // Step 3: Hook art::DexFile::CreateFromMemory // We use Stalker for precise onLeave hook var createFromMemoryAddr = libart.findExportByName("_ZN3art7DexFile16CreateFromMemoryEPKhjRKSsjbS3_PKS0_"); if (createFromMemoryAddr !== null) { console.log(`[+] Found CreateFromMemory at 0x${createFromMemoryAddr}`); Stalker.follow({ events: { call: false, ret: true, exec: false, block: false, compile: false }, onReceive: function (events) { // Not used } }); Interceptor.attach(createFromMemoryAddr, { onLeave: function (retval) { if (retval.isNull()) return; // Read DexFile* ptr from x0 (arm64) var dexFilePtr = retval; try { // Read size_ field (offset 0x8 on Android 11) var size = Memory.readU64(dexFilePtr.add(0x8)); var begin = Memory.readPointer(dexFilePtr); // Validate magic var magic = Memory.readByteArray(begin, 8); if (magic && magic[0] === 0x64 && magic[1] === 0x65 && magic[2] === 0x78 && magic[3] === 0x0a) { console.log(`[+] Found valid DEX at 0x${begin} with size ${size}`); // Change memory protection var pageSize = 4096; var pageStart = begin.and(pageSize - 1).neg(); Memory.protect(pageStart, pageSize, 'r--'); // Dump var dexBytes = Memory.readByteArray(begin, size); if (dexBytes) { var fileName = `/data/local/tmp/dump_${Date.now()}.dex`; var fs = Java.use("java.io.FileOutputStream"); var fileOut = fs.$new(fileName); var bytes = Java.array('byte', dexBytes); fileOut.write(bytes); fileOut.close(); console.log(`[+] DEX dumped to ${fileName}`); } } } catch (e) { console.log("[-] Error dumping DEX: " + e); } } }); } });第四步,启动目标App并注入脚本。假设目标包名是com.example.secureapp,执行:
adb shell am start -n com.example.secureapp/.MainActivity frida -U -f com.example.secureapp -l dump_dex.js --no-pause第五步,观察输出。当App触发DEX加载时,控制台会打印[+] Found valid DEX at 0x...,并生成/data/local/tmp/dump_*.dex文件。用adb pull /data/local/tmp/dump_*.dex .拉取到本地。第六步,验证。用dexdump -f dump_*.dex查看头信息,确认checksum和signature字段存在;用jadx-gui dump_*.dex打开,浏览com.example.secureapp.security包下的类,确认业务逻辑可见。整个过程,从adb push到看到jadx里的源码,不超过10分钟。这就是组合拳的力量:它把一个需要数小时静态分析、动态调试、IDA交叉引用的复杂任务,压缩成一次frida -U -f命令。当然,这只是起点。你可以在此基础上,扩展脚本支持自动重命名(根据javaLocation)、自动计算MD5去重、自动上传到分析平台。但核心逻辑永远不变:精准Hook ART的DEX构造入口,从内存中活体摘取原始字节流。当你亲手在Pixel 4上跑通这个流程,看着jadx里跳出自己从未见过的、被层层加固保护的业务代码时,那种“破壁而出”的快感,是任何教程都无法替代的。它不是终点,而是你深入Android运行时世界的真正起点。