FART 中的脱壳点选择和分析
前言
在前面的文章中,我们已经了解了 FART 的整体使用场景和脱壳流程。然而,FART 之所以能够高效地完成脱壳,核心在于它选择了恰到好处的"脱壳点"——即 DEX 文件在内存中被解密完成、但尚未被系统做进一步处理的那个瞬间。
本文将深入分析 FART 在 Dalvik 和 ART 两个运行时下分别选择了哪些脱壳点,这些脱壳点各自的优缺点是什么,以及在实际对抗不同壳时,如何灵活选择最佳脱壳策略。
一、什么是脱壳点
"脱壳点"本质上是一个时机概念。当一个加壳 APP 运行时,壳程序会在内存中将加密的 DEX 解密成原始 DEX。这个解密完成的瞬间,原始 DEX 以明文形式存在于内存中。如果我们能在 DEX 解密完成、但系统还没把它加工(如 oat 编译、类链接等)之前截获它,就能拿到完整的原始 DEX。
用一张简单的流程图来理解:
加密DEX(磁盘) → 壳解密 → 明文DEX(内存) → [脱壳点] → 系统加载/优化 → 运行
↑
我们要在这里截获
脱壳点的选择直接决定了脱壳的成败和效率。选得太早,DEX 可能还没解密完全;选得太晚,DEX 可能已经被修改或覆盖。
二、脱壳点选择的三大原则
FART 在选择脱壳点时遵循以下三大原则:
1. 时机准确
脱壳点必须精确地落在 DEX 完全解密之后。如果壳采用的是边解密边加载的策略(如函数级抽取壳),那么脱壳点需要选择在类方法被还原之后。
2. 覆盖全面
脱壳点需要能覆盖尽可能多的壳类型。不同的壳可能在不同的阶段完成 DEX 解密,单一脱壳点可能无法覆盖所有情况。
3. 性能可接受
脱壳本身是一种侵入性操作。如果在过于底层的函数中做 hook 和 DEX dump,可能会导致严重的性能问题甚至系统崩溃。因此需要在"早"和"稳"之间找到平衡。
三、FART 在 Dalvik 下的脱壳点
在 Android 4.x 及以下的 Dalvik 虚拟机时代,FART 选择的脱壳点主要围绕 DEX 文件的打开和解析过程。
3.1 dvmDexFileOpen
这是 Dalvik 虚拟机中打开 DEX 文件的核心函数。当一个 DEX 文件被加载时,dvmDexFileOpen 负责将 DEX 文件映射到内存并解析其头部信息。
// dalvik/vm/DvmDex.cpp 中的关键函数
int dvmDexFileOpen(const char* fileName, DvmDex** ppDvmDex) {
// 打开 DEX 文件,映射到内存
// 此时 DEX 已从磁盘读入内存
// 如果壳在这里做了解密,此时内存中的就是明文 DEX
}
优点:时机非常早,DEX 刚被加载到内存,几乎没有被系统处理过,保留的信息最完整。
缺点:某些壳可能在这之后才执行解密操作(延迟解密策略),此时拿到的可能还是密文。
3.2 dexopt 相关函数
dexopt 是 Dalvik 对 DEX 进行优化编译的过程,生成 ODEX 文件。在这个过程中,DEX 的内容会被完整读取和处理。
// dalvik/dexopt/OptMain.cpp
int dexopt(const char* dexPath, int dexoptFlags) {
// 对 DEX 进行优化,此时 DEX 必须是明文状态
// 壳必须在此之前完成解密,否则 dexopt 无法正常工作
}
优点:由于 dexopt 需要读取完整的 DEX 结构,壳必须在这之前完成解密,因此这个时机非常可靠。
缺点:不是所有 DEX 加载都会触发 dexopt,有些动态加载的 DEX 不经过此流程。
四、FART 在 ART 下的脱壳点(重点)
从 Android 5.0 开始,Google 使用 ART 替代了 Dalvik,运行机制发生了根本性变化。FART 针对 ART 的脱壳点选择也更加精细和多样。
4.1 ClassLinker::DefineClass
这是 FART 中最核心的脱壳点。每当 ART 虚拟机定义一个 Java 类时,都会调用 ClassLinker::DefineClass。在这个函数中,ART 会从 DEX 文件中读取类的定义信息并构建类对象。
// art/runtime/class_linker.cc
mirror::Class* ClassLinker::DefineClass(Thread* self,
const char* descriptor,
Handle<mirror::ClassLoader> class_loader,
const DexFile& dex_file,
const DexFile::ClassDef& dex_class_def) {
// 在这里,dex_file 中包含着完整的 DEX 数据
// 此时类正在被定义,DEX 必须已经解密
// FART 在这里 dump DEX
}
FART 在这个函数中做的核心操作是:
- 获取当前
DexFile对象的起始地址 - 通过
DexFile::Begin()获取 DEX 数据的内存指针 - 根据
DexFile::Size()确定数据大小 - 将整块内存 dump 到文件
// FART 核心脱壳逻辑(简化版)
void dumpDexFile(const DexFile* dex_file, const char* save_path) {
const uint8_t* begin = dex_file->Begin(); // DEX 数据起始地址
size_t size = dex_file->Size(); // DEX 数据大小
// 将内存中的 DEX 数据写入文件
FILE* fp = fopen(save_path, "wb");
fwrite(begin, 1, size, fp);
fclose(fp);
}
优点:
- 粒度细,每个类定义时都能触发,不会遗漏
- 对于函数级抽取壳(如 360 加固),在
DefineClass时方法体已经被还原
缺点:
- 每个类都会触发一次,效率较低,会产生大量重复 dump
- FART 的解决方案是通过 MD5 去重,只保存不同的 DEX
4.2 DexFile::Open / OpenCommon
DexFile::Open 是 ART 打开 DEX 文件的入口函数,而 OpenCommon 是其底层实现。当系统加载一个 DEX 文件时,会经过这个函数。
// art/runtime/dex_file.cc
bool DexFile::Open(const uint8_t* base,
size_t size,
const std::string& location,
uint32_t location_checksum,
const OatDexFile* oat_dex_file,
std::string* error_msg) {
// base 指向 DEX 数据在内存中的起始位置
// size 是 DEX 数据的总大小
// 此时 DEX 已经被加载到内存
}
优点:
- 时机较早,DEX 刚被打开,数据完整
- 对于整体加壳(非函数抽取),在这里 dump 效果最好
缺点:
- 对于函数级抽取壳,方法体的 code_item 可能还没有被还原
- 部分 OAT 优化过的 DEX,这里拿到的可能是优化后的版本
4.3 OpenDexFilesFromOat
这个函数负责从 OAT 文件中打开 DEX。在 ART 下,已安装的 APP 的 DEX 会被预先编译成 OAT 文件。但如果壳修改了 DEX 的加载流程,可能不走标准 OAT 路径。
// art/runtime/oat_file_manager.cc
bool OatFileManager::OpenDexFilesFromOat(...) {
// 尝试从 OAT 文件中加载 DEX
// 如果 OAT 不存在或验证失败,则 fallback 到原始 DEX
}
优点:
- 覆盖了标准 OAT 加载流程,对使用 OAT 缓存的壳有效
缺点:
- 大部分壳会破坏 OAT 验证,实际触发此脱壳点的概率不高
- 可能拿到的不是最新的 DEX(OAT 有缓存)
五、各脱壳点优缺点对比
| 脱壳点 | 运行时 | 时机 | 覆盖壳类型 | 性能影响 | 推荐场景 |
|---|---|---|---|---|---|
| dvmDexFileOpen | Dalvik | 最早 | 整体壳 | 低 | Dalvik 机型首选 |
| dexopt | Dalvik | 中等 | 整体壳 | 中 | 二次确认 |
| DefineClass | ART | 类加载时 | 抽取壳+整体壳 | 较高(需去重) | ART 下首选 |
| DexFile::Open | ART | 较早 | 整体壳 | 中 | 配合 DefineClass |
| OpenDexFilesFromOat | ART | 较晚 | OAT 相关 | 低 | 特定场景补充 |
六、FART 源码中的关键 Hook 点分析
FART 通过修改 ART 源码,在编译层面插入了脱壳逻辑。以下是其核心 hook 点的源码分析:
6.1 ClassLinker::DefineClass 中的脱壳逻辑
FART 在 art/runtime/class_linker.cc 的 DefineClass 函数中插入了以下逻辑:
// art/runtime/class_linker.cc (FART 修改版)
mirror::Class* ClassLinker::DefineClass(Thread* self,
const char* descriptor,
Handle<mirror::ClassLoader> class_loader,
const DexFile& dex_file,
const DexFile::ClassDef& dex_class_def) {
// [FART ADD] 获取当前 DEX 文件的信息
const uint8_t* dex_begin = dex_file.Begin();
size_t dex_size = dex_file.Size();
// [FART ADD] 计算当前 DEX 的 MD5
std::string dex_md5 = computeMD5(dex_begin, dex_size);
// [FART ADD] 去重判断:如果这个 DEX 已经 dump 过,跳过
if (dumped_dex_set_.find(dex_md5) == dumped_dex_set_.end()) {
// 生成 dump 文件路径
std::string dump_path = generateDumpPath(dex_file, dex_md5);
// 执行 dump
dumpMemoryToFile(dex_begin, dex_size, dump_path);
// 记录已 dump 的 DEX
dumped_dex_set_.insert(dex_md5);
LOG(INFO) << "[FART] Dump DEX: " << dump_path
<< " size=" << dex_size
<< " md5=" << dex_md5;
}
// 原始 DefineClass 逻辑继续执行...
}
6.2 DexFile::OpenCommon 中的脱壳逻辑
// art/runtime/dex_file.cc (FART 修改版)
const DexFile* DexFile::OpenCommon(const uint8_t* base,
size_t size,
const std::string& location,
uint32_t location_checksum,
const OatDexFile* oat_dex_file,
std::string* error_msg) {
// [FART ADD] 校验 DEX 魔数,确认是有效的 DEX 文件
if (size >= 4 && memcmp(base, "dex\n", 4) == 0) {
// [FART ADD] 同样进行 MD5 去重和 dump
std::string dex_md5 = computeMD5(base, size);
if (dumped_dex_set_.find(dex_md5) == dumped_dex_set_.end()) {
std::string dump_path = "/sdcard/fart/" + dex_md5 + ".dex";
dumpMemoryToFile(base, size, dump_path);
dumped_dex_set_.insert(dex_md5);
}
}
// 原始 OpenCommon 逻辑继续...
}
6.3 MD5 去重机制
FART 使用 std::set<std::string> 来维护已 dump DEX 的 MD5 集合。由于 DefineClass 会对每个类触发一次,同一个 DEX 会被多次 dump,MD5 去重保证了最终只保留一份。
// 全局去重集合
static std::set<std::string> g_dumped_dex_set;
// MD5 计算函数
std::string computeMD5(const uint8_t* data, size_t len) {
MD5_CTX ctx;
MD5_Init(&ctx);
MD5_Update(&ctx, data, len);
unsigned char digest[16];
MD5_Final(digest, &ctx);
char hex[33];
for (int i = 0; i < 16; i++) {
sprintf(hex + i * 2, "%02x", digest[i]);
}
return std::string(hex);
}
七、根据不同壳类型选择最佳脱壳点
不同的加固方案采用的技术路线不同,对应的最佳脱壳点也不同:
整体加密壳(如早期梆梆、爱加密)
这类壳将整个 DEX 文件加密存储,运行时整体解密到内存。对于这类壳:
- 首选脱壳点:
DexFile::Open/OpenCommon - 原因:DEX 被整体解密后直接通过
Open打开,此时 dump 效率高、数据完整 - 补充脱壳点:
DefineClass(兜底)
函数级抽取壳(如 360 加固、某梆梆二代)
这类壳将 DEX 中方法的 code_item 抽取出来加密,运行时在类加载时动态还原:
- 首选脱壳点:
ClassLinker::DefineClass - 原因:只有到了
DefineClass阶段,方法体才被还原,在此之前 dump 会丢失 code_item - 特别注意:必须确保 dump 时
insns(指令)已经还原到内存
Dex 格式修复壳(如某些 VMP 壳)
这类壳会修改 DEX 文件的头部和结构,解密后的 DEX 可能格式不标准:
- 需要额外修复:无论选择哪个脱壳点,dump 出来后都需要修复 DEX 结构
- 建议:配合 FART 的修复组件(
fdex2)使用
多 DEX壳(如某些壳将 DEX 拆分)
- 关键:需要确保
OpenDexFilesFromOat等覆盖 secondary DEX 的加载路径 - FART 的处理:在多个脱壳点同时 hook,确保不遗漏
八、脱壳点选择的进阶技巧
8.1 Hook 链路设计
在实际对抗高强度壳时,单一的脱壳点可能不够。建议设计多层 hook 链路:
DEX加载链路:
DexFile::Open → ClassLinker::DefineClass → ClassLinker::LinkClass
↓ ↓ ↓
[第一层dump] [第二层dump] [第三层dump]
(整体结构) (类级确认) (链接后校验)
第一层确保拿到 DEX 整体结构,第二层确保方法体已还原,第三层兜底确认。
8.2 指纹识别判断是否需要脱壳
并非所有 DEX 都需要 dump(如系统 DEX、未加壳的 DEX)。FART 中可以通过检查 DEX 的特征来判断:
bool needDump(const DexFile& dex_file) {
const DexHeader& header = dex_file.GetHeader();
// 检查 1: DEX 文件大小是否异常(被壳修改过)
if (header.file_size_ > MAX_NORMAL_DEX_SIZE) return true;
// 检查 2: class_defs 数量与 string_ids 不匹配
if (header.class_defs_size_ == 0 && header.string_ids_size_ > 0) return true;
// 检查 3: DEX 路径中是否包含可疑关键词
std::string location = dex_file.GetLocation();
if (location.find("classes") != std::string::npos) {
return true; // 应用自带的 DEX,可能被加壳
}
return false;
}
九、实际调试:IDA 分析 ClassLinker::DefineClass 调用链
为了更直观地理解 DefineClass 的触发时机,我们可以使用 IDA Pro 分析其调用链:
调试准备
- 编译一个带符号的 AOSP(Android 7.0 为例)
- 提取
libart.so并加载到 IDA - 搜索
ClassLinker::DefineClass
调用链分析
在 IDA 中可以看到 DefineClass 的主要调用来源:
ClassLinker::FindClass
└─ ClassLinker::DefineClass ← FART 脱壳点
├─ DexFile::FindClassDef ← 查找类定义
├─ ClassLinker::LoadClass ← 加载类数据
└─ ClassLinker::LinkClass ← 链接类(父类、接口等)
ClassLinker::FindClassInPathClassLoader
└─ ClassLinker::DefineClass ← 通过 PathClassLoader 触发
ClassLinker::FindLoadedClass
└─ ClassLinker::DefineClass ← 未找到缓存时触发
设置断点
在 Frida 中可以这样设置断点来验证脱壳时机:
// 使用 Frida 在 DefineClass 上设置断点
Interceptor.attach(Module.findExportByName("libart.so",
"_ZN3art11ClassLinker11DefineClassEPNS_6ThreadEPKcNS_6HandleINS_11mirror_11ClassLoaderEEERKNS_7DexFileERKNS0_8ClassDefE"), {
onEnter: function(args) {
var self = args[0];
var descriptor = args[2];
var dex_file = args[3];
console.log("[DefineClass] Loading class: " + Memory.readUtf8String(descriptor));
console.log("[DefineClass] DexFile addr: " + dex_file);
}
});
运行后可以看到每个类被加载时的日志,这有助于理解壳的解密时机。
十、常见脱壳失败原因和对策
1. DEX 数据不完整
原因:脱壳时机太早,DEX 还在解密过程中;或壳使用流式解密,DEX 并非一次性全部解密。
对策:延迟 dump 时机,改用 DefineClass 而非 OpenCommon。
2. Dump 出来的 DEX 无法使用
原因:DEX 头部被修改或校验值不正确。部分壳会修改 DEX 的 checksum 或 signature。
对策:使用 FART 的修复组件重新计算校验值:
# 使用 baksmali 验证 DEX 是否完整
java -jar baksmali.jar d dumped.dex -o output/
# 如果报错,尝试修复 DEX 头部
python fix_dex_header.py dumped.dex
3. 方法体丢失(code_item 为空)
原因:这是典型的函数抽取壳问题。在 OpenCommon 阶段方法体还没有被还原。
对策:确保在 DefineClass 阶段进行 dump,并在 dump 后验证方法体是否非空。
4. 重复 dump 同一个 DEX
原因:DefineClass 被每个类触发,导致大量重复。
对策:实现 MD5 去重机制(如 FART 所做),或通过 std::set 记录已 dump 的 DEX 地址。
5. 壳的反调试检测
原因:某些壳会检测 FART 的特征(如特定目录、so 文件名)。
对策:混淆 FART 的标识,修改 dump 路径,或使用 Frida 版 FART(动态加载)来规避静态检测。
总结
FART 的脱壳点选择体现了"在合适的时机做合适的事"这一核心思想:
- 在 ART 下以
ClassLinker::DefineClass为主力脱壳点,确保覆盖函数抽取壳 - 以
DexFile::OpenCommon为辅助脱壳点,在更早的时机兜底 - 通过 MD5 去重机制解决重复 dump 的性能问题
- 多层 hook 链路设计确保不同类型的壳都能被覆盖
理解脱壳点的选择原理,不仅能帮助我们更好地使用 FART,更能为自定义脱壳方案的开发打下坚实的理论基础。在下一篇文章中,我们将深入分析 FART 的组件设计和源码实现,看看这些脱壳点是如何在代码层面落地的。