Android加固应用内存Dump实战:基于Frida Hook的动态脱壳技术 1. 项目概述一次针对Android加固的“外科手术”在Android应用安全领域加固与脱壳是一场永不停歇的攻防博弈。应用开发者为了保护核心业务逻辑和知识产权会使用各种加固方案对APK进行加密、混淆和加壳处理。而作为安全研究员或逆向工程师我们的任务就是“剥开”这层外壳还原出最原始的DEX字节码进行分析。今天要聊的这个实战项目就是一次非常经典的“内存Dump”操作。它的核心目标很明确当一个应用被主流加固方案如某盾、某梆等保护后我们如何利用Frida这个动态插桩神器通过Hookdlopen函数这个关键入口在运行时从内存中精准地捕获并导出被解密后的原始DEX文件。这听起来像是一次“外科手术”因为我们需要在应用运行的生命周期中找到那个稍纵即逝的“解密完成但尚未被虚拟机加载或销毁”的完美时机将内存中的数据快照保存到磁盘。整个过程不依赖于静态解包那往往对新型加固无效而是直接与运行时的进程交互因此具有更高的通用性和成功率。无论你是想学习Android逆向的进阶技巧还是在实际工作中需要对某个加固应用进行安全评估掌握这套方法都至关重要。它不仅是技术更是一种解决问题的思路从动态运行中寻找静态分析无法触及的真相。2. 核心原理与攻击链拆解要成功实施这次“手术”我们必须先理解“病人”的身体结构也就是Android应用加载DEX的完整链条以及加固方案是如何介入这个链条的。2.1 Android DEX加载的传统路径在一个未被加固的标准Android应用中DEX文件的加载遵循一个相对清晰的路径应用启动Zygote进程fork出应用进程。类加载器初始化PathClassLoader或DexClassLoader被创建。DEX文件读取类加载器从APK文件通常是classes.dex或classesN.dex或指定路径读取DEX字节码。DexFile对象创建底层会通过openDexFile等Native函数最终调用libart.so或libdvm.so取决于Android版本和运行时中的方法将DEX文件映射到内存并解析成DexFile结构体。dlopen的间接角色值得注意的是dlopen函数本身并不直接加载DEX。它用于动态加载共享库.so文件。然而许多加固方案会将自己的解密逻辑封装在一个自定义的.so库中。应用启动时系统或加固代码会dlopen这个库从而执行其中的解密和代理加载逻辑。因此Hookdlopen成为了我们监控加固模块是否被加载的关键“哨所”。2.2 加固方案的“偷梁换柱”加固方案为了隐藏原始DEX会大幅修改上述流程源文件替换在APK打包后加固工具会将原始的classes.dex加密或替换为一个无效的/壳DEX文件。真正的DEX代码可能被加密后藏在assets文件夹、另一个so文件内甚至通过网络下发。植入代理在应用的Application类或主Activity中插入一段初始化代码。这段代码的首要任务就是加载加固提供的核心so库例如libshell.so。动态解密核心so库被dlopen加载后其初始化函数如JNI_OnLoad会开始执行。它的工作包括从某个地方读取加密的原始DEX数据在内存中进行解密然后通过系统API如DexFile::loadDex或反射调用DexClassLoader将解密后的字节码动态加载到内存中创建一个新的DexFile对象。接管执行此后所有对类的查找、方法的调用都会被加固的运行时环境接管它从自己动态加载的DEX中查找并执行从而对原始逻辑进行保护。2.3 我们的攻击链Hook与Dump理解了加固的运作方式我们的攻击链就清晰了定位时机Hook dlopen我们通过Frida Hooklibc中的dlopen函数。当加固的核心so库被加载时我们能第一时间获得通知并得到该so库的句柄和内存基地址。这标志着加固模块开始运行。追踪解密Hook 关键内存分配/加载函数仅仅知道so加载了还不够我们需要知道解密后的DEX被放在哪里。因此我们进一步Hook内存分配函数如malloc,mmap或Android运行时特有的DEX加载函数如art::DexFile::OpenMemory不同版本函数名不同。加固方案在解密后必然要通过这些函数为DEX数据分配内存或创建DexFile对象。识别特征过滤与判断并非所有分配的内存块都是DEX。我们需要在Hook点对内存内容进行判断。一个最典型的特征是DEX文件头魔数64 65 78 0a 30 33 35 00即字符串“dex\n035\0”或更新版本。当我们在某块新分配的内存起始位置检测到这个魔数时就可以高度确定这就是我们想要的原始DEX。实施抓捕内存Dump一旦识别出DEX内存块立即将其内容从起始地址开始按照估算的长度可以从DEX头部的fileSize字段读取完整地写入到手机的/s/data/local/tmp目录或通过Frida直接传回电脑。这就是“Dump”过程。善后处理文件修复Dump出的内存镜像可能不是完美的DEX文件可能存在内存对齐的空白或头部的轻微偏移。我们需要用工具如dexfixer或手动用010 Editor进行简单的修复使其能够被反编译工具如JADX、GDA正确识别。这个链条的核心思想是**“以静制动守株待兔”**。我们不在复杂的混淆代码里逆向解密算法而是安静地监视系统底层的关键函数调用等待加固方案自己把解密好的“兔子”DEX送到我们面前。3. 环境准备与Frida脚本框架搭建工欲善其事必先利其器。在开始编写复杂的Hook逻辑之前我们需要一个稳定、可调试的环境。3.1 基础环境配置测试设备一台已经Root的Android手机或模拟器如Genymotion、官方模拟器。这是使用Frida进行内存操作的前提。推荐使用Android 7.0 - 11之间的版本兼容性较好。Frida环境PC端通过pip安装pip install frida-tools。这会同时安装frida和frida-ps等命令行工具。设备端根据设备CPU架构通常是arm或arm64从Frida官网下载对应的frida-server二进制文件。推送到设备并运行adb push frida-server /data/local/tmp/ adb shell su cd /data/local/tmp chmod 755 frida-server ./frida-server 开发工具一个顺手的代码编辑器VS Code、Sublime等用于编写JavaScript的Frida脚本。同时准备好adb命令行工具用于与设备交互。3.2 Frida脚本的基本骨架我们的脚本将全部用JavaScript编写通过Frida的强大API与目标进程交互。首先搭建一个基础框架// dump_dex.js - 主脚本框架 Java.perform(function () { console.log([*] Script loaded. Starting DEX Dump Hunter...); // 模块1: Hook dlopen 以监控so加载 // 模块2: Hook 内存分配/加载函数 以捕获DEX内存块 // 模块3: DEX内存识别与Dump逻辑 // 模块4: 辅助功能遍历内存、修复文件头等 // 防止脚本过早退出 setInterval(function(){}, 1000); });这个框架在Java.perform中执行确保我们在Java运行时准备好的上下文中操作。最后的setInterval是一个小技巧让脚本保持活动状态防止执行完毕退出。3.3 目标进程附加与测试在编写具体功能前先测试环境是否通畅。假设我们要脱壳的目标应用包名是com.example.packedapp。在PC上使用命令行附加frida -U -f com.example.packedapp -l dump_dex.js --no-pause-U: 连接到USB设备。-f: 启动一个新进程。-l: 加载指定脚本。--no-pause: 启动后立即恢复进程执行重要否则进程会挂起。如果看到[*] Script loaded.的输出说明Frida注入成功脚本开始运行。此时应用应该会正常启动。如果应用崩溃很可能是加固方案检测到了Frida。这时需要考虑一些反反调试技巧如绕过frida-server的默认端口检测、隐藏Frida特征等这属于更进阶的话题。注意与加固方案的“第一回合”较量很多商业加固方案具备反调试、反注入能力。你的Frida脚本一附加上去应用就可能闪退。这是正常现象也是脱壳实战的第一道关卡。你需要准备一些对抗手段例如使用frida-server的隐藏版本。修改frida-server的默认端口27042。在非Root环境下使用objection等工具尝试绕过。在脚本开头先Hook一些常见的检测函数如ptrace,fopen读取/proc/self/status等。 实战中可能需要多次尝试和调整才能稳定附加。4. 核心Hook点实现从dlopen到DEX捕获环境就绪后我们开始实现最核心的Hook逻辑。我们将按照攻击链的顺序一步步编写代码。4.1 Hook dlopen设立第一道哨卡dlopen函数位于libc.so中。我们的目标是当它被调用时打印出加载的库名和基地址特别是要过滤出我们怀疑是加固核心的so库。// 模块1: Hook dlopen var dlopen Module.findExportByName(libc.so, dlopen); if (dlopen) { Interceptor.attach(dlopen, { onEnter: function (args) { this.libname args[0]; // 第一个参数是库路径/名称 this.flags args[1]; // 第二个参数是标志位 if (this.libname) { var name this.libname.readCString(); console.log([dlopen] Attempting to load: ${name}); // 如果库名包含特定关键词如shell, protect, sec则重点标记 if (name name.toLowerCase().indexOf(shell) ! -1) { console.log([!] Suspicious library detected: ${name}); this.isTargetLib true; } } }, onLeave: function (retval) { if (retval) { // 如果加载成功retval是库的句柄基地址 console.log([dlopen] Loaded at base: ${retval}); if (this.isTargetLib) { console.log([!!!] Target加固库已加载准备进入下一阶段监视。); // 可以在这里触发下一步的Hook比如Hook这个库的JNI_OnLoad } } else { console.log([dlopen] Failed to load.); } } }); } else { console.log([-] Could not find dlopen.); }这段代码会打印出进程加载的所有so库。你需要从中找出那个看起来像加固核心的库通常名字带有保护、壳、加密等含义或者由加固厂商命名。找到它就找到了突破口。4.2 Hook 内存分配与DEX加载函数布下天罗地网知道加固库加载了接下来就要等它“吐出”DEX。DEX数据在内存中必然要占据一块空间这块空间要么通过malloc/calloc申请要么通过mmap/mprotect映射。更直接的是Android运行时ART有专门的函数来加载DEX内存。方案AHook通用内存分配函数较通用但噪音大// 模块2A: Hook malloc (示例) var malloc Module.findExportByName(libc.so, malloc); Interceptor.attach(malloc, { onLeave: function (retval) { // retval是分配的内存地址 // 注意这里调用非常频繁必须做高效过滤 if (retval) { // 快速检查读取前8个字节判断是否是DEX魔数 var magic retval.readCString(8); // 读取8字节作为字符串 if (magic magic.indexOf(dex) 0) { // 检查是否以dex开头 console.log([!!!] Potential DEX found via malloc at: ${retval}); // 触发Dump流程需要进一步读取size但malloc的size在onEnter里 // 更好的做法是同时Hook malloc和calloc并在onEnter中保存size } } } });实操心得Hook malloc/calloc的陷阱直接Hookmalloc来找DEX就像在繁忙的高速公路上找一辆特定的车效率低且干扰大。malloc每秒可能被调用成千上万次如果对每个返回值都去读取内存判断会导致脚本性能急剧下降甚至拖垮目标进程。因此除非没有其他办法否则不建议作为首选。如果使用一定要在onEnter中保存size参数args[0]并在onLeave中只对大小符合DEX文件特征例如大于几十KB的内存块进行检查。方案BHook ART/DVM的DEX加载函数更精准但需适配版本这是更推荐的方法。ART虚拟机有诸如art::DexFile::OpenMemory、art::DexFile::OpenFile等函数。我们需要根据Android版本找到正确的函数符号。// 模块2B: 寻找并Hook ART的DEX加载函数 (以Android 8.0为例) var openMemoryFunc null; // 尝试寻找常见的符号不同版本符号名不同 var symbolsToTry [ _ZN3art7DexFile10OpenMemoryEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_, // Android 7,8 _ZN3art7DexFile10OpenMemoryEPKhmRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_, // 参数类型略有不同 // 可以添加更多版本的符号 ]; for (var sym of symbolsToTry) { openMemoryFunc Module.findExportByName(libart.so, sym); if (openMemoryFunc) { console.log([] Found OpenMemory function: ${sym}); break; } } if (openMemoryFunc) { Interceptor.attach(openMemoryFunc, { onEnter: function (args) { // 参数分析需要根据函数原型 // args[0]: const uint8_t* base (DEX内存起始地址) // args[1]: size_t size (DEX大小) this.dexBase args[0]; this.dexSize args[1]; console.log([OpenMemory] Called. Base: ${this.dexBase}, Size: ${this.dexSize}); // 立即检查魔数 if (this.dexBase) { var magic this.dexBase.readCString(8); if (magic magic.indexOf(dex) 0) { console.log([!!!] CONFIRMED DEX via OpenMemory!); this.shouldDump true; } } }, onLeave: function (retval) { if (this.shouldDump this.dexBase this.dexSize) { console.log([] Dumping DEX from OpenMemory...); dumpMemory(this.dexBase, this.dexSize, dex_openmemory_${Date.now()}.dex); } } }); } else { console.log([-] Could not find OpenMemory function. Trying alternative approach...); // 备用方案枚举libart.so的所有符号寻找包含DexFile和Open的 }注意事项符号名的地狱C函数名经过修饰mangled不同Android版本、不同厂商的ROM符号名都可能不同。上述代码中的长字符串就是Android 8.0上某个OpenMemory函数的修饰名。获取这些符号名有几种方法逆向libart.so用IDA Pro或Ghidra打开设备上的/system/lib/libart.so搜索相关函数。使用Frida的API枚举Module.enumerateSymbols(“libart.so”)然后过滤出包含DexFile和Open的符号。查阅开源代码和社区AOSP源码和逆向社区如看雪论坛经常有人分享不同版本的符号。 这是脱壳过程中最耗时、最需要耐心的部分。一个实用的技巧是写一个脚本先枚举所有符号并保存到文件然后在其中搜索关键词。4.3 实现内存Dump与修复功能无论通过哪个Hook点发现了DEX内存我们都需要一个可靠的dumpMemory函数来保存数据。// 模块3: Dump功能实现 function dumpMemory(baseAddress, size, filename) { console.log([*] Dumping ${size} bytes from ${baseAddress} to ${filename}...); try { // 读取内存数据 var dexBytes baseAddress.readByteArray(size); if (dexBytes) { // 将数据保存为文件通过Frida的send发送到PC端Python脚本处理更佳 // 这里演示直接写入到设备的/sdcard目录需要权限 var file new File(/sdcard/${filename}, wb); file.write(dexBytes); file.close(); console.log([] Dump successful: /sdcard/${filename}); // 可选立即进行基础修复检查 quickFixDexHeader(dexBytes, filename); } else { console.log([-] Failed to read memory at ${baseAddress}); } } catch (e) { console.log([-] Exception during dump: ${e}); } } // 模块4: 简单的DEX头修复检查 function quickFixDexHeader(dexBytes, filename) { // DEX文件头长度为0x70检查魔数和版本 var header new Uint8Array(dexBytes.slice(0, 8)); var magicStr String.fromCharCode.apply(null, header.slice(0, 3)); if (magicStr ! dex) { console.log([!] Warning: Dumped file magic is ${magicStr}, not dex. Might need manual fixing.); // 常见问题内存中的DEX前面可能有一个指向DexFile结构的指针4或8字节 // 可以尝试从第4/8字节开始查找‘dex’魔数 for (var i 4; i 32; i 4) { var potentialMagic String.fromCharCode.apply(null, new Uint8Array(dexBytes.slice(i, i3))); if (potentialMagic dex) { console.log([!] Found dex at offset ${i}. Consider trimming first ${i} bytes.); // 实际操作重新dump从baseAddressi开始size-i break; } } } else { console.log([*] DEX header looks good.); } }这个dumpMemory函数将内存数据写入设备的SD卡。在实际操作中更推荐通过Frida的send函数将二进制数据发送到PC端的Python控制脚本进行保存这样更稳定且不依赖设备存储权限。5. 实战流程与问题排查实录有了完整的脚本让我们串联起整个实战流程并记录下可能遇到的“坑”及其解决方案。5.1 完整实战步骤目标确认确定要脱壳的应用包名并将其安装到测试设备上。启动Frida-server在设备上以root权限运行frida-server。编写并调整脚本将上述模块整合成一个完整的dump_dex.js脚本。根据目标应用的Android版本调整Hook的ART函数符号。附加并启动应用在PC上运行frida -U -f com.target.app -l dump_dex.js --no-pause。观察日志密切关注控制台输出。你应该会看到应用启动一系列dlopen被调用。找到疑似加固库的加载信息。如果Hook到了正确的DEX加载函数会看到[OpenMemory] Called和[!!!] CONFIRMED DEX的日志。最后看到[] Dump successful。提取文件从设备的/sdcard/目录拉取dump出的.dex文件到电脑adb pull /sdcard/dex_xxxxxx.dex .。验证与修复用file命令检查文件类型用hexdump -C -n 40 dex_xxxxxx.dex查看文件头。如果魔数正确直接用JADX打开。如果不对使用010 Editor等二进制编辑器根据之前脚本的提示如“Found dex at offset 8”手动删除前面的多余字节。反编译分析用JADX或GDA打开修复后的DEX文件享受原始的、未被混淆或轻度混淆的Java代码。5.2 常见问题与排查技巧下面是一个实战中常见问题的速查表问题现象可能原因排查与解决方案应用启动后立即崩溃1. 加固方案反Frida。2. Frida脚本Hook了关键函数导致崩溃。1.反反调试尝试使用frida-server的隐藏模式或修改端口。使用objection的-N参数隐藏Frida特征。在脚本最开始Hookstrstr,fopen等函数阻止其读取/proc/self/maps或status中的frida关键词。2.排查Hook点注释掉所有Hook只留空脚本看是否还崩溃。然后逐一启用Hook定位问题函数。可能是函数原型不对参数访问越界。能看到dlopen但看不到DEX加载日志1. Hook的ART函数符号不对。2. 加固方案没有使用标准的OpenMemory而是自己实现了一套加载逻辑。3. DEX被分段加载或加密后JIT编译。1.确认符号在设备上adb shell后用objdump -T /system/lib/libart.so | grep DexFile(需Busybox) 或直接枚举符号确认。2.备用方案回归到Hookmmap或mprotect并过滤较大的、具有可执行权限PROT_EXEC的内存区域DEX被加载后可能需要执行。3.扩大搜索范围实现一个内存扫描器在加固库加载后定期扫描进程内存空间Process.enumerateRanges(rw-)寻找DEX魔数。Dump出的文件无法用JADX打开1. 文件头损坏前面有多余数据。2. DEX被抽取或混淆结构不完整。3. 抓取的时机不对内存中的数据还不是完整的DEX。1.修复文件头用二进制编辑器查看找到真正的dex\\n开头删掉前面的所有字节。使用命令行工具dddd ifbad.dex offixed.dex bs1 skipoffset。2.处理抽取壳某些加固会只保留方法体在运行时填充。这种情况需要HookdvmDexFileOpenPartial或ART的类加载方法并Dump出类结构信息后再做重组难度极大属于高级话题。3.调整Hook时机尝试Hook更早或更晚的函数比如Hookmemcpy监视从解密缓冲区到DEX加载缓冲区的数据拷贝。脚本运行缓慢导致应用卡死Hook了调用极其频繁的函数如malloc且onEnter/onLeave中的操作太耗时。1.优化过滤条件在onEnter中只做最简单的判断和记录把耗时的操作如读内存判断魔数放到条件满足后再执行。2.更换Hook点尽量使用更精确的ART函数避免通用函数。3.使用NativePointer缓存减少在回调函数中创建新对象。多DEX处理应用可能有多个classes.dex加固后可能对应多个内存DEX块。在Dump逻辑中为每个捕获到的DEX基地址生成一个唯一文件名如包含地址戳。脚本可以维护一个已Dump地址的列表避免重复Dump同一个DEX。5.3 高级技巧与思路延伸自动化与泛化可以将不同Android版本的符号查找逻辑自动化写一个“自适应”脚本自动枚举libart.so的符号并匹配特征。对抗内存扫脸有些加固会在DEX加载后立即擦除或覆盖文件头魔数。对付这种可以在Hook点捕获到数据后立即在onEnter中读取并保存而不是在onLeave中。关注JNI_OnLoadHook加固so的JNI_OnLoad函数这里是解密逻辑开始的地方。在这里下断点或打印回溯可以更清晰地理解加固的执行流。使用Frida的Stalker对于极度顽固的壳可以尝试使用Frida的代码追踪功能Stalker跟踪加固so中解密函数的执行精准定位解密后数据写入的位置。脱壳是一场精细的狩猎。成功的关键在于耐心、对系统机制的理解以及灵活的调试技巧。每一次失败的控制台输出都是通往成功的路标。当你第一次从内存中成功Dump出那个闪亮的、可反编译的DEX文件时那种成就感就是对所有努力最好的回报。记住工具和脚本是死的思路是活的。根据不同的加固方案灵活组合和调整你的Hook策略才是逆向工程师的核心能力。