使用 Frida 增强 FART 脱壳能力
FART 的局限性分析
FART 作为首款 ART 环境自动化脱壳工具,虽然开创了系统级自动脱壳的先河,但在实际使用中存在一些局限性:
需要刷机
FART 原版的核心实现需要修改 Android 系统源码(AOSP)并重新编译为自定义 ROM,然后刷入目标手机。这一要求带来了几个问题:
- 设备受限:不是所有 Android 手机都支持刷入自定义 ROM,特别是搭载高通 Bootloader 锁的设备
- 版本固定:FART 原版仅支持 Android 6.0 和 7.0,升级到更高版本需要大量适配工作
- 维护成本高:每次需要更换目标设备或 Android 版本时,都需要重新编译和刷机
- 日常使用不便:刷了 FART ROM 的手机不适合作为日常主力机使用
版本兼容性受限
Android 系统的快速迭代导致 ART 内部结构频繁变化。FART 依赖的 ClassLinker::LinkClass 函数在不同 Android 版本中的签名和内部实现差异较大:
- Android 8.0 重构了类链接的部分逻辑
- Android 10 引入了 hiddenapi 限制
- Android 12+ 对 ART 内部符号进行了大量混淆和限制
这意味着 FART 原版很难直接在较新的 Android 版本上使用,每次版本升级都需要进行代码适配。
检测与对抗
虽然 FART 本身是系统级实现,难以被应用层直接检测,但壳程序可以通过其他方式间接对抗 FART:
- 检测自定义 ROM:检查系统属性、build 信息,发现非官方 ROM 则拒绝运行
- 延迟加载 DEX:分多次、多阶段加载 DEX,FART 只能 dump 已加载的部分
- 运行时完整性校验:检查内存中 DEX 数据的完整性,发现被读取则清除
- 反调试手段:检测 ptrace、frida-server 等调试工具
使用 Frida 实现无刷机脱壳
鉴于 FART 原版的局限性,使用 Frida 实现"无需刷机"的脱壳方案成为了更灵活的选择。Frida 是一个动态代码注入框架,可以在运行时 hook Java 方法和 native 函数,非常适合用于脱壳场景。
Frida 脱壳的基本思路
Frida 脱壳的核心思路是模拟 FART 的脱壳流程,但不需要修改系统源码:
- 通过 Frida 注入目标进程
- hook ART 虚拟机的关键函数(与 FART 相同的脱壳点)
- 在 hook 回调中执行 DEX dump 逻辑
- 将 dump 的数据保存到文件
相比 FART,Frida 方案只需要 root 权限(不需要刷机),且可以快速适配不同 Android 版本。
基础环境搭建
使用 Frida 进行脱壳前的准备工作:
# 安装 frida-tools
pip install frida-tools
# 手机端安装 frida-server(需要 root)
adb push frida-server /data/local/tmp/
adb shell chmod 755 /data/local/tmp/frida-server
adb shell /data/local/tmp/frida-server &
# 验证连接
frida-ps -U | grep target_app
Frida 脚本模拟 FART 脱壳流程
核心 dump 逻辑
以下 Frida 脚本模拟了 FART 在 ClassLinker::LinkClass 阶段的 dump 逻辑:
// frida_fart.js —— 模拟 FART 脱壳流程
Java.perform(function() {
var artModule = Process.findModuleByName("libart.so");
if (!artModule) {
console.log("[-] libart.so not found");
return;
}
// 存储已 dump 的 DEX,避免重复
var dumpedDexSet = new Set();
// 查找 ClassLinker::LinkClass 的符号地址
// 注意:符号名会随 Android 版本变化
var symbols = artModule.enumerateSymbols();
var linkClassAddr = null;
for (var i = 0; i < symbols.length; i++) {
if (symbols[i].name.indexOf("ClassLinker") !== -1 &&
symbols[i].name.indexOf("LinkClass") !== -1 &&
symbols[i].type === 'function') {
linkClassAddr = symbols[i].address;
console.log("[*] Found LinkClass at: " + linkClassAddr);
break;
}
}
if (!linkClassAddr) {
// 备选方案:通过偏移搜索
console.log("[*] Trying offset-based search...");
return;
}
// Hook LinkClass 函数
Interceptor.attach(linkClassAddr, {
onEnter: function(args) {
// args[1] 通常是 mirror::Class 的 Handle
this.klassHandle = args[1];
},
onLeave: function(retval) {
// 链接成功才 dump
if (retval.toInt32() !== 1) return;
try {
dumpDexFromClass(this.klassHandle);
} catch(e) {
console.log("[-] Dump error: " + e);
}
}
});
function dumpDexFromClass(klassHandle) {
// 获取 mirror::Class 对象
var klass = klassHandle.readPointer();
// 获取关联的 DexFile 对象地址
// 注意:偏移量随 Android 版本不同而变化
var dexFilePtr = klass.add(getDexFileOffset()).readPointer();
// 获取 DEX 文件的内存起始地址和大小
var begin = dexFilePtr.add(getBeginOffset()).readPointer();
var size = dexFilePtr.add(getSizeOffset()).readU32();
if (dumpedDexSet.has(begin.toString())) return;
dumpedDexSet.add(begin.toString());
// 验证 DEX magic number
var magic = Memory.readByteArray(begin, 4);
var magicArr = new Uint8Array(magic);
if (magicArr[0] !== 0x64 || magicArr[1] !== 0x65 || magicArr[2] !== 0x78) {
return; // 不是 "dex" 开头
}
console.log("[+] Found DEX at " + begin + ", size: " + size);
// Dump DEX 到文件
var dexData = Memory.readByteArray(begin, size);
var filename = "/data/local/tmp/fart_dump_" +
Date.now() + "_" +
begin.toString(16) + ".dex";
var f = new File(filename, "wb");
f.write(dexData);
f.close();
console.log("[+] DEX dumped to: " + filename);
}
});
使用方法
# 启动目标应用并注入脚本
frida -U -f com.example.target -l frida_fart.js --no-pause
# 或附加到已运行的应用
frida -U com.example.target -l frida_fart.js
hook ClassLoader 加载时机 dump DEX
除了直接 hook ClassLinker::LinkClass,还可以从 Java 层的 ClassLoader 入手进行 dump:
hook DexClassLoader
Java.perform(function() {
// Hook DexClassLoader 构造函数
var DexClassLoader = Java.use("dalvik.system.DexClassLoader");
DexClassLoader.$init.implementation = function(dexPath, optDir, libPath, parent) {
console.log("[*] DexClassLoader created");
console.log(" dexPath: " + dexPath);
console.log(" optDir: " + optDir);
// 调用原始构造函数
this.$init(dexPath, optDir, libPath, parent);
// 延迟一小段时间,确保 DEX 加载完成
setTimeout(function() {
dumpFromDexClassLoader(dexPath);
}, 1000);
};
});
hook BaseDexClassLoader.findClass
Java.perform(function() {
var BaseDexClassLoader = Java.use("dalvik.system.BaseDexClassLoader");
BaseDexClassLoader.findClass.implementation = function(name) {
var clazz = this.findClass(name);
if (clazz !== null) {
// 获取类所在的 DEX 文件路径
var dexPathList = this.pathList.value;
var dexElements = dexPathList.dexElements.value;
for (var i = 0; i < dexElements.length; i++) {
var dexFile = dexElements[i].dexFile.value;
if (dexFile !== null) {
var fileName = dexFile.mFileName.value;
console.log("[*] Class " + name + " from: " + fileName);
}
}
}
return clazz;
};
});
通过 DexFile 对象直接 dump
Java.perform(function() {
var DexFile = Java.use("dalvik.system.DexFile");
// hook DexFile.openDexFile
DexFile.openDexFile.overload(
'java.lang.String', 'java.lang.String', 'int'
).implementation = function(sourceName, outputName, flags) {
var result = this.openDexFile(sourceName, outputName, flags);
console.log("[*] DexFile opened: " + sourceName);
// 通过内部 API 获取 DEX 的 Cookie
var mInternalCookie = this.mInternalCookie.value;
var cookie = Java.cast(mInternalCookie, Java.use("long[]"));
var cookieNative = cookie[0];
// Cookie 中包含了 DexFile 的 native 指针
// 可以通过指针 dump 完整 DEX
dumpDexFromCookie(cookieNative);
return result;
};
});
Frida-FART 综合方案
单一的 Frida 脱壳或 FART 脱壳各有优势,将两者结合使用可以发挥更大的效果。以下是推荐的 Frida + FART 综合脱壳方案:
方案概述
阶段一:Frida 动态分析
├── 分析壳类型和加载流程
├── 定位壳程序的解密函数
├── 获取壳程序使用的 ClassLoader 信息
└── 初步 dump 内存中的 DEX 片段
阶段二:FART 完整 dump
├── 在 FART 环境中运行目标应用
├── 利用阶段一获取的信息,确保所有类都被加载
├── FART 自动 dump 完整 DEX 和方法字节码
└── 输出到 /sdcard/fart/
阶段三:修复与验证
├── 使用 dex_fix 修复 dump 的 DEX
├── 使用 jadx 验证修复结果
└── 对 VMP 保护部分进行专项分析
阶段一:Frida 动态分析脚本
// phase1_analyze.js —— 分析壳程序行为
Java.perform(function() {
console.log("=== Phase 1: Shell Analysis ===");
// 1. 监控所有文件打开操作
var open = Module.findExportByName("libc.so", "open");
Interceptor.attach(open, {
onEnter: function(args) {
var path = Memory.readCString(args[0]);
if (path.indexOf(".dex") !== -1 ||
path.indexOf(".jar") !== -1 ||
path.indexOf(".dat") !== -1 ||
path.indexOf(".bin") !== -1) {
console.log("[File Open] " + path);
}
}
});
// 2. 监控内存分配(大块内存分配可能是解密 DEX)
var malloc = Module.findExportByName("libc.so", "malloc");
Interceptor.attach(malloc, {
onEnter: function(args) {
this.size = args[0].toInt32();
},
onLeave: function(retval) {
if (this.size > 1024 * 1024) { // 大于 1MB
console.log("[Large malloc] " + this.size + " bytes at " + retval);
}
}
});
// 3. 监控 DexClassLoader 创建
var DexClassLoader = Java.use("dalvik.system.DexClassLoader");
DexClassLoader.$init.implementation = function(dexPath, optDir, libPath, parent) {
console.log("[DexClassLoader] dexPath: " + dexPath);
console.log("[DexClassLoader] optDir: " + optDir);
console.log("[DexClassLoader] libPath: " + libPath);
console.log("[DexClassLoader] parent: " + parent);
return this.$init(dexPath, optDir, libPath, parent);
};
// 4. Hook Application.attachBaseContext(壳程序入口)
var Application = Java.use("android.app.Application");
Application.attachBaseContext.implementation = function(context) {
console.log("[Application] attachBaseContext called");
console.log("[Application] Class: " + this.getClass().getName());
this.attachBaseContext(context);
};
});
阶段二:FART dump 增强
在了解了壳程序的行为后,可以在 FART 环境中使用 Frida 辅助触发更多类加载:
// phase2_assist.js —— 辅助 FART 完整 dump
Java.perform(function() {
console.log("=== Phase 2: Assist FART Dump ===");
// 延迟遍历应用的所有 Activity,触发类加载
setTimeout(function() {
var ActivityThread = Java.use("android.app.ActivityThread");
var app = ActivityThread.currentApplication();
var context = app.getApplicationContext();
// 获取 AndroidManifest.xml 中声明的所有 Activity
var pm = context.getPackageManager();
var packageInfo = pm.getPackageInfo(
context.getPackageName(),
0x00000040 // GET_ACTIVITIES
);
var activities = packageInfo.activities;
console.log("[*] Found " + activities.length + " activities");
for (var i = 0; i < activities.length; i++) {
console.log("[*] Activity: " + activities[i].name);
try {
// 触发类加载(不实际启动 Activity)
Java.use(activities[i].name);
} catch(e) {
console.log("[!] Failed to load: " + activities[i].name);
}
}
}, 5000); // 等待壳程序完成初始化
});
实际案例:某加固 APP 的 Frida + FART 联合脱壳
以下通过一个实际案例演示 Frida + FART 联合脱壳的完整流程。目标应用使用了某商业加固方案,运行环境为 Android 7.1.2。
步骤一:识别壳类型
# 解压 APK 分析
unzip -l target.apk | grep -E "\.so|assets"
# 发现特征文件
# lib/arm64-v8a/libjiagu.so → 360 加固
# assets/classes0.dex → 加密的原始 DEX
步骤二:Frida 动态分析
# 注入分析脚本
frida -U -f com.example.target -l phase1_analyze.js --no-pause
# 观察输出:
# [File Open] /data/app/.../base.apk
# [File Open] /data/app/.../classes0.dex ← 加密 DEX
# [Large malloc] 3145728 bytes ← 解密 DEX 内存分配
# [DexClassLoader] dexPath: /data/data/.../cache/decrypted.dex
# [Application] Class: com.stub.StubApp ← 壳 Application
分析结论:目标使用 360 加固,壳 Application 为 com.stub.StubApp,解密后的 DEX 被写入缓存目录并通过 DexClassLoader 加载。
步骤三:Frida 直接 dump
# 使用 Frida dump 脚本
frida -U -f com.example.target -l frida_fart.js --no-pause
# 输出:
# [+] Found DEX at 0x7a12345000, size: 2097152
# [+] DEX dumped to: /data/local/tmp/fart_dump_..._7a12345000.dex
步骤四:验证 dump 结果
# 拉取 dump 文件
adb pull /data/local/tmp/fart_dump_*.dex ./
# 反编译验证
jadx -d ./output/ ./fart_dump_*.dex
# 发现部分方法体为空(DEX 抽取特征),需要 FART 完整 dump
步骤五:FART 环境完整 dump
# 在刷了 FART 的设备上运行目标应用
adb shell am start -n com.example.target/.MainActivity
# 等待应用完全启动后,遍历各功能页面触发类加载
# 拉取 FART dump 数据
adb pull /sdcard/fart/ ./fart_output/
# 使用 dex_fix 修复
python dex_fix.py \
--dex ./fart_output/com.example.target_0.dex \
--methods ./fart_output/com.example.target_0_method.txt \
--output ./fart_output/fixed.dex
步骤六:验证最终结果
# 反编译修复后的 DEX
jadx -d ./final_output/ ./fart_output/fixed.dex
# 检查关键方法是否完整
grep -r "targetMethod" ./final_output/sources/
进阶技巧
绕过 Frida 检测
部分壳程序会检测 Frida 的存在。常见的绕过方法:
// 绕过 Frida 检测(常见检测方式)
// 1. 检测 frida-server 端口
var socket = Module.findExportByName("libc.so", "connect");
Interceptor.attach(socket, {
onEnter: function(args) {
var port = args[1].add(2).readU16(); // sin_port
if (port === 27042) { // frida 默认端口
console.log("[!] Frida port detection blocked");
args[1].add(2).writeU16(0); // 修改端口为 0
}
}
});
// 2. 检测 /tmp/frida-* 或 /data/local/tmp/frida-*
var readdir = Module.findExportByName("libc.so", "readdir");
// ... 拦截目录读取,过滤 frida 相关条目
// 3. 检测线程名 "frida-*"
var pthread_create = Module.findExportByName("libc.so", "pthread_create");
Interceptor.attach(pthread_create, {
onEnter: function(args) {
var name = args[2];
if (name && !name.isNull()) {
var nameStr = Memory.readCString(name);
if (nameStr && nameStr.indexOf("frida") !== -1) {
console.log("[!] Frida thread name blocked");
Memory.writeUtf8String(name, "pool-1");
}
}
}
});
多 DEX dump
对于使用了 MultiDex 的应用,需要确保所有 DEX 都被 dump:
// 枚举进程内存中所有 DEX
function enumerateAllDex() {
var ranges = Process.enumerateRanges('r--');
for (var i = 0; i < ranges.length; i++) {
var range = ranges[i];
try {
var magic = Memory.readByteArray(range.base, 4);
var magicArr = new Uint8Array(magic);
if (magicArr[0] === 0x64 && magicArr[1] === 0x65 &&
magicArr[2] === 0x78) {
// 读取 DEX file_size
var fileSize = range.base.add(32).readU32();
if (fileSize > 0 && fileSize < range.size) {
console.log("[DEX] " + range.base + " size=" + fileSize);
}
}
} catch(e) {}
}
}
总结
Frida 为 FART 脱壳框架提供了重要的能力增强。通过 Frida 可以实现无刷机脱壳、动态分析壳程序行为、辅助触发更多类加载,以及绕过壳程序的检测机制。Frida + FART 的综合方案在实际逆向工作中表现优异:先用 Frida 进行快速分析和初步 dump,再用 FART 进行完整的方法级 dump,最后使用 dex_fix 修复并验证。这种分层脱壳策略能够应对大多数主流加固方案,是 Android 逆向分析者的必备技能组合。