FART+Frida动态脱壳:Android加固应用逆向分析的利器
1. 项目概述:为什么我们需要“FART+Frida”?
在移动安全分析,尤其是Android应用逆向的日常工作中,脱壳一直是个绕不开的“硬骨头”。你肯定遇到过这种情况:拿到一个App,用静态分析工具打开一看,核心的DEX文件要么被加密得面目全非,要么干脆就是个空壳,真正的逻辑代码在运行时才会被动态加载。这时候,传统的静态分析工具就束手无策了,你必须得让App跑起来,在内存里“抓住”那个解密后的、活生生的DEX。FART(Fix ART)就是为解决这个问题而生的一个经典工具,它通过修改Android运行时(ART),在应用执行时自动将内存中的DEX文件“吐”出来,实现脱壳。但FART本身更像一个“沉默的捕手”,它把DEX文件写到了设备的/data/data/包名目录下,你需要手动去拉取,而且对于更复杂的、多层壳或者有反调试、反脱壳检测的应用,FART有时会显得力不从心。
这就是Frida登场的时候。Frida是一个动态插桩框架,它允许你将自己的JavaScript脚本注入到目标进程的内存中,去Hook函数、修改逻辑、甚至实时与内存交互。如果把FART比作一个设置好的捕兽夹,那Frida就是一位手持万能钥匙和探测器的特工。将两者结合,我们就能打造一个更强大、更灵活、更智能的动态脱壳分析环境。Frida可以帮我们绕过一些简单的反调试,可以在关键的解密函数被调用时发出通知,甚至可以实时修改内存数据,辅助FART更精准、更完整地捕获到目标DEX。这个组合拳,能让分析效率提升好几个档次,尤其适合对付那些“狡猾”的加固应用。
2. 环境搭建与工具选型解析
2.1 核心组件:FART与Frida的版本匹配
工欲善其事,必先利其器。搭建环境的第一步是确保核心组件的兼容性。FART本身不是一个独立App,它是一套需要编译进Android系统镜像(通常是AOSP)的补丁。这意味着你需要一个已经集成了FART的Android系统环境。对于大多数研究者,最实际的选择是使用一台已经Root的Android真机,并刷入集成了FART的定制ROM(例如基于Android 8.1或9.0的版本)。网上有一些热心开发者编译好的镜像,你可以根据自己手头的设备型号去寻找。这里有个关键点:FART的版本(或者说其适配的Android API级别)需要与你使用的Frida-server版本大致匹配。虽然不要求绝对一致,但如果你在一个Android 9(API 28)的FART环境下,强行运行一个为Android 12(API 31)编译的frida-server,很可能会遇到兼容性问题导致崩溃。
我的建议是,优先确定你的FART环境所基于的Android版本。然后,去Frida的官方GitHub Release页面,下载对应架构(通常是arm或arm64)和对应Android版本的frida-server。例如,你的设备是arm64-v8a架构,Android 9.0,那么就下载类似frida-server-16.1.4-android-arm64.xz这样的文件。版本号不必追求最新,稳定兼容更重要。我个人的经验是,选择一个比你的Android版本稍早但仍在活跃维护的Frida大版本(如15.x, 16.x),通常兼容性最好。
2.2 辅助工具链准备
除了FART环境和Frida,一个顺畅的分析流程还需要其他工具辅助:
- ADB(Android Debug Bridge):这是与设备通信的生命线。确保你的电脑上安装了最新版的Android SDK Platform-Tools,并配置好环境变量。通过
adb devices命令能正常识别到你的设备是第一步。 - Python环境与Frida-tools:在电脑端,你需要Python环境(3.7+)并通过pip安装frida-tools:
pip install frida-tools。这为你提供了frida-ps、frida命令行工具等,用于列出进程、注入脚本。 - 代码编辑器与调试终端:推荐使用VS Code或PyCharm来编写和管理你的Frida JavaScript脚本。同时,准备至少两个终端窗口:一个用于执行ADB命令,另一个用于运行Python脚本或Frida CLI。
- 逆向分析主力工具:如JADX-GUI、Ghidra、IDA Pro等,用于分析脱壳后得到的DEX或so文件。
注意:整个环境搭建过程,请务必在合规合法的测试环境(如自己的测试设备、授权的测试应用)中进行。所有技术讨论仅限用于安全研究、学习交流目的。
3. FART脱壳原理与基础操作复盘
在引入Frida之前,我们必须先吃透FART本身是怎么工作的。知其然,更要知其所以然,这样才能知道在哪个环节引入Frida能产生“化学反应”。
3.1 FART的核心Hook点:LoadMethod与DexFile
FART的魔法主要施加在ART虚拟机的两个关键环节。ART虚拟机在加载和执行一个DEX文件中的方法时,会经历DexFile的解析和LoadMethod的过程。FART的补丁正是在这些关键路径上插入了“钩子”。
DexFile的Dump:当ART加载一个DEX文件(无论是主DEX还是动态加载的)时,FART的代码会介入,将内存中已经解密、准备被虚拟机使用的DexFile结构体完整地拷贝出来,写入到文件。这个文件通常以包名_类名.dex的格式命名,保存在应用的数据目录。这是获取完整DEX的基础。LoadMethod的CodeItem Dump:这是FART更精妙的一步。有些加固方案不会一次性解密整个DEX,而是采用“方法级”的加密,即用到某个方法时才解密该方法的字节码(CodeItem)。FART在ART的LoadMethod函数中插入逻辑,每当一个方法被首次加载和执行时,就将其对应的CodeItem(包含具体的操作指令)dump下来。这些零散的CodeItem后期可以通过FART提供的工具(如fart)进行合并重组,还原出可被反编译工具识别的DEX。
3.2 标准FART脱壳流程
标准的、不结合Frida的FART脱壳流程是这样的:
- 刷机与启动:将集成了FART的ROM刷入测试机,并启动。
- 安装目标应用:通过
adb install安装待脱壳的APK。 - 运行应用触发脱壳:启动目标应用,并尽可能多地遍历其功能界面。这一步的目的是触发尽可能多的类和方法被加载,让FART有机会dump下更多的CodeItem。对于简单的壳,可能启动完主界面,完整的DEX就已经被dump到
/data/data/包名目录下了。 - 提取脱壳文件:应用运行一段时间后,通过
adb shell进入设备,找到应用数据目录,将里面生成的.dex和.bin(CodeItem文件)拉取到电脑。adb shell su cd /data/data/com.target.app ls -la *.dex *.bin exit adb pull /data/data/com.target.app . - 合并与修复:使用FART工具包里的
fart工具(或配套的Python脚本)处理拉取的.bin文件,将它们合并到对应的.dex中,最终生成一个完整的、可被反编译的DEX文件。 - 静态分析:用JADX等工具打开修复后的DEX文件进行分析。
这个流程在对付常规加固时是有效的,但它被动且“笨拙”。如果应用有启动检测,在FART完全生效前就崩溃了怎么办?如果某些关键方法只有在特定分支条件下才会被加载,而你在手动遍历时漏掉了呢?这时,我们就需要Frida来赋予这个流程“主动性”和“智能”。
4. Frida赋能:动态干预与精准脱壳
Frida的介入,可以从多个维度增强FART脱壳流程的鲁棒性和精准度。下面我们分场景来看。
4.1 场景一:绕过基础反调试与反脱壳检测
许多加固会在应用启动初期进行反调试检测,如果发现调试器(包括Frida的注入行为)或运行环境异常(如检测到FART相关的痕迹),就会主动退出或执行垃圾代码干扰。Frida可以在应用进程启动的极早期注入,并Hook这些检测函数,使其失效。
例如,常见的检测点包括:
android.os.Debug.isDebuggerConnected(): 检测是否被调试。System.getProperty查询ro.debuggable等: 检测系统是否可调试。- 遍历
/proc/self/status或/proc/self/task查看TracerPid: 检测是否有跟踪进程。
我们可以编写一个Frida脚本,在应用启动时立即注入并Hook这些函数:
Java.perform(function () { // Hook isDebuggerConnected,固定返回false var Debug = Java.use("android.os.Debug"); Debug.isDebuggerConnected.implementation = function () { console.log("[反调试绕过] isDebuggerConnected() 被调用,返回 false"); return false; }; // Hook System.getProperty,对特定查询返回“安全”值 var System = Java.use("java.lang.System"); var originalGetProperty = System.getProperty.overload('java.lang.String'); originalGetProperty.implementation = function (key) { var result = originalGetProperty.call(this, key); if (key === "ro.debuggable") { console.log("[反调试绕过] 查询 ro.debuggable,返回 0"); return "0"; } // 可以继续处理其他敏感属性 return result; }; });使用Frida命令注入这个脚本:frida -U -f com.target.app -l anti-anti-debug.js --no-pause。-f表示启动应用,--no-pause表示立即启动主线程,这对于早期注入至关重要。这样,就能为FART的脱壳过程创造一个“安全”的运行环境。
4.2 场景二:监控与触发关键解密流程
有些高级壳并非在应用启动时一次性解密所有代码,而是在运行时,当某个类或方法被首次访问时,才调用底层的Native函数进行解密。FART的LoadMethodHook虽然能捕获结果,但如果我们能知道“解密发生在何时、何处”,就能更主动地触发它。
假设通过初步分析,我们怀疑解密逻辑在Native层的一个叫JNI_OnLoad的函数里,或者在一个名为OLLVM混淆过的decrypt_data函数中。我们可以用Frida的Interceptor来监控这些函数的调用。
// 监控 Native 函数 Interceptor.attach(Module.findExportByName("libtarget.so", "decrypt_data"), { onEnter: function (args) { console.log("[解密监控] decrypt_data 被调用!"); console.log("参数1 (数据地址): " + args[0]); console.log("参数2 (数据长度): " + args[1]); // 可以在这里打印堆栈,看看是谁调用的解密 console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n')); }, onLeave: function (retval) { console.log("[解密监控] decrypt_data 返回,返回值: " + retval); // 返回后,解密后的数据应该已经在内存中,此时FART的LoadMethod Hook很可能被触发 } });更进一步,我们可以主动调用某些Java方法,来触发解密流程。例如,发现应用有一个LicenseManager.check()方法,调用后才会初始化核心模块。我们可以用Frida脚本在合适的时机主动调用它:
Java.perform(function () { setTimeout(function(){ // 延迟几秒,等待应用基本初始化完成 Java.choose("com.target.app.LicenseManager", { onMatch: function(instance) { console.log("[主动触发] 找到 LicenseManager 实例,调用 check()"); instance.check(); // 主动调用,触发后续解密 }, onComplete: function() {} }); }, 5000); });4.3 场景三:内存检索与DEX文件定位辅助
有时候,FART虽然dump了文件,但我们可能想确认dump是否完整,或者想直接内存中搜索DEX的魔术头(dex\n035或dey\n035for cdex)。Frida可以轻松扫描进程内存。
function scanForDexInMemory() { console.log("[内存扫描] 开始扫描 DEX 魔术头..."); Process.enumerateRanges('r--').forEach(function (range) { // 只扫描可读的内存区域 var magic = range.base.readCString(4); // 读取前4个字节 if (magic === "dex\n" || magic === "dey\n") { console.log("[内存扫描] 发现 DEX 区域!地址: " + range.base + ", 大小: " + range.size); // 可以将这块内存直接dump到文件,与FART dump的进行比对 var dexBytes = range.base.readByteArray(range.size); var dexFilePath = "/data/local/tmp/dex_from_memory_" + range.base + ".dex"; var file = new File(dexFilePath, "wb"); file.write(dexBytes); file.close(); console.log("[内存扫描] DEX 已保存至: " + dexFilePath); } }); } // 在合适的时机调用,比如在触发解密后 Java.perform(function () { setTimeout(scanForDexInMemory, 8000); });这个脚本能帮助我们发现那些可能被FART遗漏的、或者以非标准方式映射到内存中的DEX片段,是FART脱壳结果的有效补充和验证。
5. 一体化自吐Frida脚本设计与实现
将上述能力整合,我们可以设计一个“一体化”的Frida脚本。这个脚本的目标是:一键注入后,自动完成反调试绕过、监控解密、触发关键流程、扫描内存,并在检测到DEX被加载时,甚至可以尝试通过Frida直接调用FART的内置功能(如果FART环境提供了JNI接口)或者通知我们手动操作。
5.1 脚本架构设计
一个健壮的一体化脚本应该包含以下模块:
- 初始化与配置模块:定义目标包名、关键函数签名、触发时机等。
- 反检测模块:实现常见的Java层和Native层反调试、反注入绕过。
- 监控模块:
- Java层:Hook
ClassLoader.loadClass、DexClassLoader构造函数等,监控类加载行为。 - Native层:Hook
dlopen、dlsym以及疑似解密的函数。
- Java层:Hook
- 触发模块:在监控到特定事件(如某个类加载失败)或定时器到期后,主动调用特定的初始化方法。
- 内存辅助模块:定时或事件驱动地扫描内存中的DEX结构,并可与FART输出进行比对。
- 日志与输出模块:将关键事件、内存地址、调用堆栈等格式化输出到控制台或文件,便于分析。
5.2 核心代码片段示例
下面是一个高度整合的示例脚本框架:
// config var TARGET_PACKAGE = "com.target.app"; var TRIGGER_CLASS = "com.target.app.Initializer"; var TRIGGER_METHOD = "initCore"; // 主函数 function main() { console.log("[*] 开始注入目标应用: " + TARGET_PACKAGE); // 1. 反检测 antiDetection(); // 2. 监控类加载 monitorClassLoading(); // 3. 监控Native解密 monitorNativeDecrypt(); // 4. 延迟后尝试主动触发 setTimeout(activeTrigger, 3000); // 5. 定期扫描内存 setInterval(scanForDexInMemory, 10000); } function antiDetection() { Java.perform(function () { // ... 实现上述反调试Hook代码 ... console.log("[+] 基础反检测措施已部署"); }); } function monitorClassLoading() { Java.perform(function () { var ClassLoader = Java.use("java.lang.ClassLoader"); ClassLoader.loadClass.overload('java.lang.String').implementation = function (className) { // 过滤掉系统类,减少日志噪音 if (!className.startsWith("android.") && !className.startsWith("java.")) { console.log(`[类加载监控] 尝试加载: ${className}`); } try { return this.loadClass.call(this, className); } catch (e) { console.log(`[类加载监控] 加载失败 ${className}: ${e}`); // 这里可以加入触发逻辑,比如当某个关键类加载失败时,主动调用初始化 if (className === TRIGGER_CLASS) { setTimeout(activeTrigger, 500); } throw e; } }; }); } function monitorNativeDecrypt() { // 遍历所有模块,寻找可疑导出函数进行Hook Process.enumerateModules().forEach(function (module) { if (module.name.indexOf("libshield") !== -1 || module.name.indexOf("libprotect") !== -1) { console.log(`[+] 发现可疑模块: ${module.name} (${module.base})`); // 可以尝试Hook这个模块的所有导出函数,或者通过模式匹配寻找解密函数 } }); // ... 具体的Interceptor.attach代码 ... } function activeTrigger() { Java.perform(function () { try { var Initializer = Java.use(TRIGGER_CLASS); // 假设initCore是静态方法 console.log(`[主动触发] 尝试调用 ${TRIGGER_CLASS}.${TRIGGER_METHOD}()`); Initializer[TRIGGER_METHOD](); } catch (e) { console.log(`[主动触发] 调用失败: ${e}`); // 如果静态方法失败,尝试寻找实例 Java.choose(TRIGGER_CLASS, { onMatch: function(instance) { console.log(`[主动触发] 找到实例,调用方法`); instance[TRIGGER_METHOD](); }, onComplete: function() {} }); } }); } // 执行主函数 setTimeout(main, 0);5.3 脚本使用与调试心得
将上述脚本保存为unpacker.js。使用Frida注入时,有几个实用技巧:
- 保持会话:使用
frida -U -f com.target.app -l unpacker.js并保持终端打开,实时查看日志。 - 脚本重载:在Frida CLI中,如果修改了脚本,可以使用
%load命令重新加载,无需重启应用。 - 崩溃诊断:如果注入后应用立即崩溃,可能是Hook点不对或脚本有误。可以尝试先注释掉所有Hook,然后逐一启用,定位问题点。Frida的
-D参数可以输出更详细的设备端日志。 - 性能考虑:Hook太多函数,尤其是高频函数(如
ClassLoader.loadClass),可能会拖慢应用速度,甚至导致超时或行为异常。在实际使用中,要尽量精准Hook,或者添加更严格的过滤条件。
6. 实战问题排查与进阶技巧
即便有了FART和Frida的组合,实战中依然会遇到各种“妖魔鬼怪”。下面分享一些我踩过的坑和总结的技巧。
6.1 Frida连接失败或进程崩溃
这是最常见的问题之一。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
frida-ps -U无输出或报错 | 1. USB调试未开启/未授权 2. frida-server未运行 3. 设备未Root或权限不足 4. 端口冲突(默认27042) | 1.adb devices确认设备在线,并弹窗授权电脑。2. adb shell后su提权,执行/data/local/tmp/frida-server &(确保已push)。3. 检查 ps | grep frida确认进程存在。4. 尝试重启adbd: adb kill-server && adb start-server。 |
| 注入时目标进程崩溃 | 1. Frida版本与系统/应用不兼容。 2. 脚本Hook了不稳定的早期函数。 3. 应用有强力的反Frida检测。 | 1. 更换Frida-server版本(尝试更旧或更测试版)。 2. 使用 --no-pause让应用先启动再注入,或使用setTimeout延迟注入脚本主体。3. 加强反检测脚本,或使用Frida的隐身模式(如修改特征字符串),但高级壳仍可能检测。 |
出现Security Violation等提示 | 应用集成了商业级加固,检测到Frida运行环境。 | 1.修改Frida特征:这是猫鼠游戏。可以尝试使用开源工具如frida-skeleton或自行编译修改frida-gum库中的特征字符串。2.绕过时机:尝试在应用完成检测之后再注入Frida。这需要精确计时或找到检测完成后的一个稳定Hook点。 3.使用替代方案:考虑使用 ptrace或LD_PRELOAD等更底层的注入方式,但这超出了本文范畴,且复杂度极高。 |
6.2 FART脱壳不完整或失败
- 现象:拉取到的目录下没有
.dex文件,或者只有很小的一个。- 排查:首先检查应用是否真的启动了。查看
logcat日志,过滤FART相关的tag(如ActivityThread,fart等),看是否有错误信息。可能FART的补丁没有生效,或者ROM刷写有问题。 - 解决:确保刷入的ROM确实包含FART,并且版本匹配。对于某些加固,可能需要更彻底地遍历应用。结合Frida,你可以编写脚本自动点击、跳转页面,比手动操作更高效。
- 排查:首先检查应用是否真的启动了。查看
- 现象:有
.dex文件,但用JADX打开后,很多方法体是空的或显示“nop”。- 排查:这说明FART只dump了
DexFile结构,但没有捕获到方法的CodeItem。这通常发生在“函数级”抽取壳上。你需要确保相关方法被执行过。 - 解决:这就是Frida大显身手的地方。用Frida脚本更全面地触发应用功能,特别是那些隐藏在深层逻辑、特定条件分支下的方法。可以Hook一些UI控件的监听器,模拟用户交互去触发。
- 排查:这说明FART只dump了
6.3 内存扫描与DEX修复的进阶操作
- 定位动态加载的DEX:有些壳不会通过标准的
DexClassLoader加载,而是直接调用dalvik.system.DexFile.loadDex或Native方法mmap一个文件。Frida可以Hook这些底层函数。var DexFile = Java.use("dalvik.system.DexFile"); DexFile.loadDex.implementation = function (sourcePathName, outputPathName, flags) { console.log(`[Dex加载监控] loadDex被调用: ${sourcePathName}`); var result = this.loadDex.call(this, sourcePathName, outputPathName, flags); // 此时,DEX文件可能已被解密并映射到内存 setTimeout(scanForDexInMemory, 1000); // 延迟扫描内存 return result; }; - 手动修复DEX结构:即使FART合并工具有时也可能失败。你需要了解DEX文件格式。使用
010 Editor配合DEX模板,可以手动分析和修复文件头、method_ids、class_defs等结构。这需要较强的耐心和基础知识。 - 处理OAT或VDEX:在高版本Android上,可能会遇到OAT或VDEX格式。FART通常也支持dump出对应的
.odex或.vdex文件,但分析它们需要不同的工具链(如oat2dex、vdexExtractor)。Frida可以帮助你确认运行时加载的到底是哪种格式。
6.4 保持环境稳定与可复现
动态分析环境非常“娇气”。一次成功的脱壳后,建议做好快照。
- 对于模拟器:使用VirtualBox或VMware的快照功能,在干净FART环境配置好后保存一个状态。
- 对于真机:刷入稳定ROM后,使用
TWRP等Recovery做一个完整的系统备份(NANDroid Backup)。 - 脚本管理:将好用的Frida脚本进行版本管理(如Git),并写好注释。记录下成功脱壳某个版本应用时使用的脚本版本、Frida版本和ROM信息。
打造“FART+Frida”的动态脱壳环境,是一个从被动接受到主动干预的思维转变。FART提供了强大的底层捕获能力,而Frida赋予了我们在应用运行时进行侦查、干预和控制的灵活性。两者结合,不仅能提高脱壳的成功率,更能让我们深入理解加固方案的工作原理。这个过程没有一成不变的银弹,需要根据目标应用的特点,灵活组合监控、触发、绕过等技术。最重要的永远是耐心、细致的观察和基于理解的尝试。当你亲手从一片混沌的内存中,提取出清晰的Java代码时,那种成就感,就是驱动安全研究者不断向前的最大乐趣。
