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 在这个函数中做的核心操作是:

  1. 获取当前 DexFile 对象的起始地址
  2. 通过 DexFile::Begin() 获取 DEX 数据的内存指针
  3. 根据 DexFile::Size() 确定数据大小
  4. 将整块内存 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.ccDefineClass 函数中插入了以下逻辑:

// 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 分析其调用链:

调试准备

  1. 编译一个带符号的 AOSP(Android 7.0 为例)
  2. 提取 libart.so 并加载到 IDA
  3. 搜索 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 的组件设计和源码实现,看看这些脱壳点是如何在代码层面落地的。