Frida动态脱壳实战:从内存中提取安卓加固应用原始代码
1. 项目概述:为什么需要动态脱壳工具
在移动应用安全分析领域,逆向工程师和分析师常常会遇到一个棘手的问题:应用加固。为了保护核心代码逻辑和关键数据不被轻易窥探,开发者会使用各种加固技术对应用进行“加壳”。你可以把它想象成一个保险箱,把真正的应用代码(我们称之为“原始DEX”或“原始SO”)锁在里面。静态分析工具面对这个保险箱往往束手无策,因为它们只能看到加固外壳的代码,而无法触及内部的真实逻辑。
这时,“动态脱壳”技术就成为了破局的关键。它的核心思想是“在应用运行时,从内存中把原始代码‘捞’出来”。因为无论外壳多么坚固,应用最终必须在内存中解密、还原出原始代码才能执行。Frida,作为一个强大的动态代码插桩框架,为我们提供了在运行时窥探和干预应用行为的绝佳能力。将Frida与脱壳逻辑结合,就诞生了各种Frida-unpack工具。这类工具的目标非常明确:在目标应用启动、运行的关键时刻,通过注入的脚本,从内存中找到解密后的原始代码镜像,并将其完整地转储(Dump)到本地文件系统中,供后续的静态分析使用。
这个教程适合所有对移动安全、安卓逆向感兴趣的朋友,无论你是刚刚入门的新手,还是已经有一定经验但想系统掌握动态脱壳技巧的从业者。通过本教程,你将不仅学会如何使用一个现成的Frida脱壳工具,更能深入理解其背后的工作原理、实现细节,以及在实际操作中可能遇到的各种“坑”及其解决方案。我们将从环境搭建开始,一步步走到脚本编写、实战脱壳和结果修复,最终让你拥有独立分析和处理加固应用的能力。
2. 核心原理与工具链深度解析
2.1 Frida框架的工作机制
要玩转Frida-unpack,首先得理解Frida是怎么“附身”到目标进程上的。Frida的核心是一个注入式引擎,它通过多种方式(如frida-server、Gadget)将一个小型运行时环境注入到目标进程的地址空间。这个运行时环境就像一个“内应”,它能够执行我们编写的JavaScript(或Python)脚本。
脚本通过Frida提供的API,可以做到几件关键事情:拦截函数调用(Hook)、读取/修改内存、枚举模块和导出函数、甚至动态加载额外的原生库。对于脱壳而言,我们最依赖的是内存访问和函数Hook能力。当加固外壳在内存中完成解密,将原始DEX或SO文件映射到内存后,其代码段和数据段对于进程自身就是完全可见的。我们的Frida脚本,作为进程内部的一份子,自然也能访问到这些内存区域。脱壳的本质,就是找到这些内存区域,并把它拷贝出来。
2.2 动态脱壳的关键时机与内存特征
脱壳不是在任何时候都能成功的,必须抓住正确的时机。这个时机通常是在外壳的解密和加载逻辑执行完毕之后,但应用主逻辑尚未开始之前。对于安卓应用的DEX脱壳,有几个关键的Hook点:
ClassLoader加载流程:特别是DexFile相关的构造函数或loadDex方法。外壳通常会自定义ClassLoader,在这里进行解密和加载。OpenMemory与DexFile构造函数:这是Android运行时加载DEX的核心入口。Hooklibart.so或libdvm.so中的OpenMemory或DexFile构造函数,可以捕获到解密后的DEX内存地址和大小。JNI_OnLoad函数:对于使用原生加固的SO库,JNI_OnLoad往往是解密代码执行的第一站,在这里下钩子也能捕获到关键状态。
除了时机,识别内存中的DEX或ELF结构也至关重要。一个有效的DEX文件在内存中通常以“dex\n035\0”或“dex\n037\0”等魔数开头。而一个ELF(SO文件)则以“\x7fELF”开头。我们的脚本需要在内存中扫描这些特征,或者更精准地,通过Hook到的指针直接定位到这些结构的起始地址。
2.3 工具链准备与环境搭建
工欲善其事,必先利其器。一个稳定的Frida-unpack环境需要以下组件:
- Frida 环境:包括PC端的Frida工具包(
frida-tools)和移动设备端的frida-server。版本匹配至关重要,PC端和Server端的主版本号必须一致。 - Python 环境:用于编写控制脚本和运行Frida的Python绑定。推荐使用Python 3.7+。
- 目标设备:一台已Root的安卓物理设备或模拟器(如雷电、夜神)。Root权限是访问进程内存、注入代码的前提。对于模拟器,确保其架构(x86/x86_64/arm)与
frida-server匹配。 - 辅助工具:
adb:用于连接设备、推送文件、执行命令。- 十六进制编辑器(如010 Editor):用于验证和修复脱出来的文件。
- 反编译工具(如JADX、GDA):用于验证脱壳后的DEX可读性。
注意:在下载
frida-server时,务必从官方GitHub仓库或可信源获取。网络上一些来路不明的“整合包”或“破解版”可能包含恶意代码。安装时,通过adb push将frida-server推送到设备的/data/local/tmp/目录,并赋予可执行权限(chmod 755)。
3. 实战:一个通用Frida脱壳脚本的编写与解析
纸上得来终觉浅,绝知此事要躬行。下面我们将一步步拆解一个用于脱取DEX的通用Frida脚本。这个脚本的思路是Hooklibart.so中的OpenMemory函数,因为它是最稳定、最通用的DEX加载入口之一。
3.1 脚本骨架与模块枚举
首先,我们需要在脚本中附加到目标进程,并枚举其加载的模块,以找到我们要Hook的库。
Java.perform(function () { console.log("[*] Script loaded. Attaching to process..."); // 枚举所有已加载的模块 Process.enumerateModules({ onMatch: function (module) { // 寻找 libart 或 libdvm if (module.name.indexOf('libart') !== -1 || module.name.indexOf('libdvm') !== -1) { console.log('[+] Found target module: ' + module.name + ' @ ' + module.base); hook_dexload(module); } }, onComplete: function () { console.log("[*] Module enumeration complete."); } }); });这段代码在脚本被加载后执行。Java.perform确保我们的代码在Java上下文中运行。Process.enumerateModules遍历进程的所有内存模块,当找到包含“libart”或“libdvm”的模块时,就调用我们的核心Hook函数hook_dexload。
3.2 核心Hook函数实现
接下来是hook_dexload函数。我们需要先找到OpenMemory函数的地址。在Android不同版本中,这个函数的符号名可能略有差异。
function hook_dexload(module) { var openMemoryAddr = null; var symbols = module.enumerateSymbols(); for (var i = 0; i < symbols.length; i++) { var symbol = symbols[i]; // 查找包含 'OpenMemory' 或 'DexFile' 关键字的符号 if (symbol.name.indexOf('OpenMemory') !== -1 || (symbol.name.indexOf('DexFile') !== -1 && symbol.name.indexOf('constructor') !== -1)) { console.log('[+] Potential target symbol: ' + symbol.name + ' @ ' + symbol.address); openMemoryAddr = symbol.address; break; } } if (openMemoryAddr) { console.log('[+] Hooking OpenMemory at: ' + openMemoryAddr); Interceptor.attach(openMemoryAddr, { onEnter: function (args) { // args[0] 通常指向 dex 数据的起始地址 (uint8_t*) // args[1] 通常是 dex 数据的大小 (size_t) this.dexStart = args[0]; this.dexSize = args[1].toInt32(); console.log('[+] OpenMemory called. Start: ' + this.dexStart + ', Size: ' + this.dexSize + ' bytes'); }, onLeave: function (retval) { // 函数执行完成后,内存中的数据已是解密状态 if (this.dexStart && this.dexSize > 0) { console.log('[+] Dumping DEX from memory...'); dumpDex(this.dexStart, this.dexSize); } } }); } else { console.log('[-] Could not find OpenMemory symbol in this module.'); } }这个函数首先枚举目标模块的所有符号,寻找包含关键字的函数地址。找到后,使用Interceptor.attach进行挂钩。onEnter回调在函数被调用前触发,我们在这里保存DEX内存起始地址和大小。onLeave回调在函数执行后触发,此时内存中的数据理应已被外壳解密,正是脱壳的最佳时机,我们调用dumpDex函数执行转储。
3.3 内存转储与文件保存
dumpDex函数负责将内存数据写入文件。
function dumpDex(dexStart, dexSize) { // 读取内存数据 var dexData = Memory.readByteArray(dexStart, dexSize); // 生成唯一文件名,避免覆盖 var timestamp = new Date().getTime(); var filePath = '/sdcard/Download/dex_dump_' + timestamp + '.dex'; // 将数据写入文件(需要文件写入权限) var file = new File(filePath, 'wb'); file.write(dexData); file.close(); console.log('[+] DEX dumped to: ' + filePath); console.log('[+] Verifying DEX header...'); // 简单验证文件头 var header = Memory.readUtf8String(dexStart, 4); if (header === 'dex\n') { console.log('[+] Header verification PASSED: ' + header); } else { console.log('[-] Header verification FAILED. Got: ' + header); // 可能是压缩或混淆,需要进一步处理 } }这里使用了Memory.readByteArray来读取原始内存字节。文件保存在设备的/sdcard/Download/目录下,方便通过adb pull拉取到电脑。保存后,脚本还简单读取了文件头的前4个字节进行验证,确保它是一个有效的DEX文件。
实操心得:在实际操作中,你可能会遇到
OpenMemory被调用数十次甚至上百次的情况,对应着多个DEX文件(主DEX、分包、依赖库等)。我们的脚本目前会转储每一个,这可能会产生大量文件。一个优化策略是:在dumpDex函数中加入去重判断,比如计算内存数据的哈希值(MD5/SHA-1),如果之前已经转储过相同内容,则跳过,避免重复文件。
4. 高级技巧与对抗加固策略
基础的脚本可能无法应对所有加固方案。成熟的加固厂商会采用多种反制手段。
4.1 对抗反调试与Frida检测
许多加固会检测Frida的存在,常见手段包括:
- 检测端口:扫描
27042等Frida默认端口。 - 检测进程名:查找
frida-server、gum-js-loop等进程。 - 检测内存特征:在内存中搜索Frida相关字符串或代码片段。
应对策略:
- 端口重命名:启动
frida-server时使用-l 0.0.0.0:8080参数指定非默认端口,并在连接时指定。# 设备端 ./frida-server -l 0.0.0.0:8080 # PC端 frida -H 设备IP:8080 -f com.target.app - 进程隐藏:使用
frida-server的改名功能,或通过修改内核、Magisk模块等方式隐藏进程。 - 脚本混淆:将Frida脚本中的关键字符串和函数名进行混淆,避免静态特征检测。
- 延迟注入:不在一开始就附加进程,而是等待应用启动完成、反调试逻辑执行完毕后再注入。可以使用
setTimeout或监听特定事件。
4.2 处理多DEX与SO加固
现代应用普遍使用多DEX,加固也可能对每个DEX单独加壳。我们的脚本需要能处理这种情况。
- 遍历ClassLoader:通过Java API枚举所有的
DexClassLoader和PathClassLoader,尝试从它们的pathList中提取DexFile对象,进而获取内存地址。这需要更深入的Java层Hook。Java.enumerateClassLoaders({ onMatch: function(loader){ try { var dexFileClass = Java.use("dalvik.system.DexFile"); // ... 通过loader和反射获取内部DexFile对象 } catch(e){} }, onComplete: function(){} }); - SO文件脱壳:原理类似,但Hook点不同。通常Hook
dlopen、android_dlopen_ext或SO自身的init/init_array段。转储时,需要解析ELF头,获取各个段(如.text代码段、.data数据段)在内存中的位置和大小,可能需要拼接多个段才能还原出完整的SO。
4.3 内存扫描与特征定位
当无法通过稳定API Hook定位时,可以退而求其次,采用内存扫描法。
function scanMemoryForDex() { var ranges = Process.enumerateRanges('r--'); // 扫描所有可读内存页 for (var i = 0; i < ranges.length; i++) { var range = ranges[i]; // 只处理大小合理的范围 if (range.size > 1024 && range.size < 50 * 1024 * 1024) { var header = Memory.readUtf8String(range.base, 4); if (header === 'dex\n') { console.log('[+] Found DEX header at: ' + range.base.toString()); // 进一步验证DEX结构,然后转储 dumpMemoryRange(range.base, range.size); } } } }这种方法比较暴力,耗时长且可能产生误报。但它作为备用方案,在对抗某些自定义加载流程的加固时可能有效。
5. 脱壳后的文件处理与修复
从内存中直接Dump出来的文件,有时并非“完美”的、可直接被反编译工具识别的DEX或ELF文件。
5.1 DEX文件修复
内存中的DEX可能缺少文件尾部的MapItem等非必要结构,或者其checksum、signature字段因内存修改而失效。
使用
baksmali/smali修复:# 将dump的dex反汇编为smali代码 java -jar baksmali.jar d dumped.dex -o out_smali # 再将smali代码汇编回dex java -jar smali.jar a out_smali -o fixed.dex这个过程会重新生成一个结构规范的DEX文件。
使用专业工具:如
dexfixer等工具可以自动修复DEX头信息。
5.2 SO文件修复
从内存Dump的ELF文件问题更多:
- 缺少节区头(Section Header):加载到内存后,节区头信息可能被丢弃。
- 动态链接信息不完整。
修复SO通常更复杂:
- 使用
LIEF库:这是一个强大的二进制文件操作库(Python),可以解析、修改和重建ELF文件。你可以用Python脚本,以内存Dump的数据为基础,参考一个同版本未加固的SO文件头,重建出一个可被IDA Pro等工具正确加载的ELF文件。 - 手动修复(高阶):使用十六进制编辑器,对照ELF规范,手动修补
e_phoff(程序头表偏移)、e_shoff(节区头表偏移)等字段。这需要对ELF格式有深刻理解。
5.3 验证脱壳成果
修复后,必须验证文件的有效性:
- 对于DEX:使用
d2j-dex2jar转换为JAR,再用JD-GUI查看;或直接用JADX打开,看是否能成功反编译出有意义的Java代码。 - 对于SO:使用
file命令查看文件类型,使用readelf -h查看ELF头是否有效,最后用IDA Pro或Ghidra加载,看函数识别和反汇编是否正常。
6. 常见问题排查与实战心得
在实际操作中,你几乎一定会遇到下面这些问题。这里是我踩过坑后总结的排查思路。
6.1 Frida连接与注入失败
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
Failed to spawn: unable to connect to remote frida-server | 1.frida-server未运行。2. 设备与PC不在同一网络。 3. 端口被防火墙阻止。 4. 使用了错误的架构版本。 | 1.adb shell进入设备,ps | grep frida确认进程存在,./data/local/tmp/frida-server &启动。2. 使用 adb devices确认连接,或尝试设备IP直连。3. 检查PC和设备防火墙设置,尝试关闭或添加规则。 4. 使用 adb shell getprop ro.product.cpu.abi查看设备架构,下载对应版本。 |
Error: unable to find process with name 'xxx' | 1. 进程名错误。 2. 应用尚未启动。 3. 进程被守护或双进程保护。 | 1. 使用frida-ps -U列出所有进程,确认准确包名。2. 先启动应用,再使用 -F(前台应用)或-n(按名称附加)选项。3. 尝试在应用启动的早期阶段(如 zygote)注入,或使用-f(生成新进程)选项。 |
| 脚本注入后应用闪退 | 1. 脚本存在语法错误或无限循环。 2. Hook了关键函数导致崩溃。 3. 触发了应用的反调试或反Hook机制。 | 1. 先在简单应用上测试脚本。 2. 注释掉部分Hook代码,定位导致崩溃的Hook点。 3. 尝试使用 setImmediate延迟执行脚本,或加入反反调试代码。 |
6.2 脱壳脚本执行无输出或未抓到数据
- 检查Hook点是否正确:Android版本差异巨大。在Android 8.0(API 26)以上,
OpenMemory的符号名和参数可能发生变化。使用frida的Module.enumerateSymbols()仔细查看目标库的所有导出符号,寻找类似_ZN3art7DexFile10OpenMemoryEPKhjRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEjPNS_6MemMapEPKNS_10OatDexFileEPS9_这样的mangled name。也可以尝试Hooklibdexfile.so等更底层的库。 - 确认时机:你的脚本可能附加得太晚,错过了DEX加载的时机。尝试使用
-f选项让Frida在应用启动时即附着,并在脚本开头使用setImmediate立即执行Hook逻辑。 - 内存权限:尝试读取内存时,确保该内存区域有读取权限(
r--)。使用Process.enumerateRanges()查看目标地址所在范围的权限。
6.3 脱出的文件无法解析
- 文件头损坏:用十六进制编辑器打开文件,查看开头几个字节。DEX应为
64 65 78 0A("dex\n"),ELF应为7F 45 4C 46("\x7fELF")。如果不是,说明抓取的起始地址不对。你可能需要根据文件结构特征(如DEX的magic、checksum、signature的偏移)在内存数据中搜索真正的起始点。 - 文件不完整:大小不对。可能是
dexSize参数获取有误。有些加固方案会修改标准函数参数。尝试不依赖参数,而是通过解析内存中的DEX结构(从magic开始,根据file_size字段)来动态计算真实大小。 - 加固对抗:高级加固可能对内存中的DEX进行碎片化存储、实时解密执行(不完整映射到连续内存)或混淆。这种情况下,通用脚本可能失效,需要针对该加固方案进行定制化分析,可能需要Hook多个点,分片抓取内存后再重组。
6.4 性能与稳定性问题
- 脚本导致应用卡顿:如果Hook非常频繁的函数(如
libc的read/write),或在循环中进行大量内存扫描,会严重拖慢目标应用,甚至导致ANR。优化策略是:精确Hook,避免全局Hook;将耗时的操作(如大内存范围扫描)放在setImmediate或setTimeout中异步执行。 - Frida进程不稳定:长时间附着或频繁注入/分离可能导致
frida-server崩溃。确保使用稳定版本的Frida。对于需要长期稳定的脱壳任务,考虑将核心脱壳逻辑编译成Frida的Gadget,以内嵌方式与目标应用一起启动,这样耦合度更高,也更隐蔽。
我个人在实际操作中的体会是,动态脱壳没有一成不变的银弹。每个加固方案都是一道独特的谜题。通用脚本能解决60%-70%的常见情况,而剩下的则需要你静下心来,结合静态分析(看外壳代码)、动态调试(观察运行时行为)和Frida脚本的灵活编写,去一步步揭开它的防护。最重要的不是记住某个脚本,而是理解“在内存中寻找解密数据”这一核心思想,并掌握使用Frida这一强大工具去实现该思想的方法。当你成功脱掉一个顽固的壳,看到清晰的源代码呈现出来时,那种成就感是无与伦比的。最后再分享一个小技巧:建立一个自己的“武器库”,将验证过的、针对不同加固和不同Android版本的Hook脚本分门别类保存好,下次遇到类似情况,就能快速组合出击,事半功倍。
