ART 下抽取壳的实现,FART中的脱壳点

抽取壳的原理

在理解 FART 的脱壳点之前,必须深入理解抽取壳的工作原理。抽取壳是目前主流的 Android 加固方案所采用的核心技术,它不是简单地对整个 DEX 进行加密,而是对 DEX 内部的方法级代码进行精确操作。

code_item 与 DEX 结构

在 DEX 文件格式中,每个方法都通过 code_item 结构来存储其 Dalvik 字节码。一个 DEX 文件的层次结构如下:

DEX Header
├── string_ids     (字符串ID表)
├── type_ids       (类型ID表)
├── proto_ids      (方法原型ID表)
├── field_ids      (字段ID表)
├── method_ids     (方法ID表)
├── class_defs     (类定义表)
│     └── ClassDef
│           ├── class_data_item
│           │     ├── direct_methods
│           │     │     └── method_item → code_item (字节码在这里)
│           │     └── virtual_methods
│           │           └── method_item → code_item (字节码在这里)
│           └── ...
└── data           (数据区,code_item 的实际内容存储在这里)

code_item 结构包含了方法的实际执行指令:

struct code_item {
    uint16_t registers_size;        // 方法使用的寄存器数量
    uint16_t ins_size;              // 方法参数占用的寄存器数量
    uint16_t outs_size;             // 方法调用其他方法时需要的参数寄存器数量
    uint16_t tries_size;            // try-catch 块的数量
    uint32_t debug_info_off;        // 调试信息的偏移量
    uint32_t insns_size;            // 指令的数量(以 16-bit 为单位)
    uint16_t insns[insns_size];     // 实际的 Dalvik 字节码指令
    // 后面可能跟 try_item 和 handler
};

insns 数组就是方法的执行代码,也是逆向工程师最关心的部分。抽取壳的目标就是将这段代码从 DEX 中移除。

抽取壳的三步操作

第一步:提取 code_item

壳工具在加壳时,会遍历 DEX 中所有类的所有方法,提取每个方法的 code_item 数据(主要是 insns 指令数组)。提取的顺序是:

  1. 解析 DEX Header,找到 class_defs 偏移量
  2. 遍历每个 ClassDef,找到对应的 class_data_item
  3. 遍历每个方法的 method_item,通过 code_off 字段定位到 code_item
  4. 读取 code_item 中的 insns_sizeinsns[] 数据
  5. 将所有方法的指令数据单独保存并加密
# 抽取伪代码
for class_def in dex.class_defs:
    for method in class_def.methods:
        if method.code_item:
            encrypted_code = encrypt(method.code_item.insns)
            save_to_shell(encrypted_code)

第二步:DEX 字段清零

提取完成后,壳工具会将原始 DEX 中对应方法的 code_item 进行清零处理。通常是:

  • insns_size 设为 0
  • insns[] 数组清空
  • 或者直接将整个 code_item 区域填充为 0

清零后的 DEX 文件在结构上是"完整"的——方法名、类名、字段信息都还在,只是方法的实际执行代码为空。如果直接用 jadx 或 apktool 打开这样的 DEX,可以看到类和方法的结构,但方法体是空的。

第三步:运行时恢复

壳程序在运行时,需要确保每个方法在被调用之前,其 code_item 被正确恢复。运行时恢复的常见方式有:

  • 方式一:在 DexClassLoader 加载 DEX 后,遍历所有方法,解密并回填 code_item
  • 方式二:hook 方法的入口点,在方法首次被调用时触发回填(懒加载)
  • 方式三:使用 ART 的 entry_point_ 替换,将方法的入口指向壳的解密函数

ART 中 DEX 加载的关键时机

在 ART 虚拟机中,一个 DEX 文件从被加载到方法被执行,经历了多个阶段。理解每个阶段的特性,是选择脱壳点的关键。

阶段一:OpenDexFilesFromOat

这是 DEX 文件被打开的阶段。ART 从 OAT 文件中提取嵌入的 DEX 数据,或者直接打开独立的 DEX 文件。此时 DEX 数据被映射到内存中。

  • 特点:DEX 数据已进入内存,但类结构尚未解析
  • 对于抽取壳:如果壳程序还未执行解密,此时内存中的 DEX 可能还是加密状态
  • 脱壳可行性:取决于壳的解密时机,通常不可靠

阶段二:LoadClass

ART 解析 DEX 中每个类的定义,创建虚拟机内部的 Class 对象。此时会读取方法的 method_idcode_item 偏移量。

  • 特点:类结构已注册到虚拟机,方法的元数据(名称、签名、访问标志)可用
  • 对于抽取壳:如果壳程序在 LoadClass 之前完成了回填,此时 code_item 可能已经完整
  • DexHunter 的选择:DexHunter 就是在 LoadClass 阶段进行 dump 的

阶段三:LinkClass

ART 对类进行链接,包括验证字节码、解析引用关系、设置方法的入口点。这是类可以被使用的最后准备步骤。

  • 特点:所有类间引用已解析,方法的 entry_point_ 已设置
  • 对于抽取壳:壳程序通常需要在 LinkClass 之前完成 code_item 回填,否则字节码验证会失败
  • FART 的选择:FART 就是在 LinkClass 阶段进行主动调用和 dump

阶段四:方法执行

当应用代码实际调用某个方法时,ART 通过 entry_point_ 跳转到方法的机器码或解释器执行。

  • 特点:方法正在运行
  • 脱壳可行性:此时进行 dump 风险高,可能导致数据不一致

FART 选择的脱壳点

FART 将脱壳点设置在 LinkClass 完成之后,具体来说是在 art/runtime/class_linker.ccLinkClass 函数返回之前。

为什么选择 LinkClass 阶段

FART 选择 LinkClass 作为脱壳点基于以下考虑:

1. 时机准确性

在 LinkClass 阶段,ART 已经完成了对类和方法的所有准备工作。对于抽取壳而言,壳程序必须在 LinkClass 之前完成 code_item 的回填,因为 ART 在 LinkClass 中会对字节码进行验证。如果 code_item 仍然为空或无效,验证会失败,类将无法链接。

这意味着:在 LinkClass 阶段,壳程序已经完成了 code_item 的回填工作,此时内存中的方法代码是完整的。

2. 安全性

在 LinkClass 阶段进行操作是相对安全的。此时类已经链接完成,但方法尚未被执行,不会出现并发访问或数据不一致的问题。

3. 完整性

在 LinkClass 之后,不仅 code_item 已经回填,类间的引用关系也已经解析完成。此时 dump 的 DEX 数据是结构上最完整的。

FART 的具体实现

FART 在 class_linker.cc 中的修改逻辑如下:

// art/runtime/class_linker.cc
void ClassLinker::LinkClass(Thread* self,
                            Handle<mirror::Class> klass,
                            Handle<mirror::ObjectArray<mirror::Class>> interfaces) {
    // ... 原始的 LinkClass 逻辑 ...
    // 包括 VerifyClass、InitializeStaticFields、LinkMethods 等
    
    // === FART 新增逻辑开始 ===
    if (UNLIKELY(thread_local_dex_is_fart)) {
        // 获取当前 DEX 文件
        const DexFile& dex_file = klass->GetDexFile();
        
        // 遍历 DEX 中的所有类定义
        for (size_t class_def_idx = 0;
             class_def_idx < dex_file.NumClassDefs();
             class_def_idx++) {
            const DexFile::ClassDef& class_def =
                dex_file.GetClassDef(class_def_idx);
            
            // 获取类对象
            const char* descriptor = dex_file.GetClassDescriptor(class_def);
            // ... 加载类对象 ...
            
            // 主动调用该类的所有方法
            dumpdexfilebyclass(&dex_file, dump_path, loaded_class);
        }
        
        // dump 完整的 DEX
        dumpdexfile(&dex_file, dump_dir);
    }
    // === FART 新增逻辑结束 ===
}

主动调用的细节

FART 在 dump DEX 之前,会主动调用每个类中的所有方法。主动调用会触发以下链式反应:

FART 调用方法 A
    → ART 检查方法 A 的 entry_point_
    → 如果壳程序使用了 lazy loading 方案
    → 壳程序在方法 A 的 entry_point_ 处设置了解密函数
    → 解密函数执行,将 code_item 回填到内存
    → 方法 A 的真实代码被还原
    → FART 收集方法 A 的 code_item 数据

这样,即使壳程序采用了懒加载(仅在方法首次执行时回填 code_item),FART 的主动调用也能确保所有方法的代码都被还原。

脱壳点在不同 Android 版本上的差异

FART 最初基于 Android 6.0(Marshmallow)开发,但 ART 的内部实现在不同 Android 版本之间存在差异,这导致 FART 的脱壳点代码在不同版本上需要做相应的适配。

Android 5.0(Lollipop)

Android 5.0 是 ART 首次成为默认运行时的版本。ART 的类加载机制还不够成熟:

  • LinkClass 的实现相对简单
  • 方法入口点的设置逻辑与后续版本有差异
  • FART 的基本方案可以直接适用,但需要注意 class_linker.cc 中的函数签名可能不同

Android 6.0(Marshmallow)

这是 FART 的主要开发和测试版本,也是 FART 方案最稳定的版本:

  • LinkClass 实现成熟
  • ClassLinker 的接口稳定
  • ArtMethod 的结构清晰
  • dex_file 的获取路径明确

Android 7.0(Nougat)及 7.1

Android 7.0 引入了一些 ART 的重大变更:

  • CompilerFilter 机制:引入了速度、快速、平衡、一切、无等编译过滤器
  • profile-guided compilation:基于 profile 的 JIT 编译
  • 类验证机制变更:类的验证流程有所调整

这些变更导致 LinkClass 的执行路径更加复杂。FART 在 Android 7.x 上的适配需要考虑:

  • 编译模式的不同可能影响方法 entry_point_ 的设置时机
  • JIT 编译可能改变方法的执行路径

版本适配的关键差异点

差异点 Android 6.0 Android 7.x
ClassLinker 头文件 class_linker.h class_linker.h(结构变化)
LinkClass 签名 三参数版本 可能有额外参数
ArtMethod 结构 GetCodeItem() 直接可用 可能需要额外处理
DEX 获取路径 klass->GetDexFile() 路径可能不同
编译影响 主要为 AOT AOT + JIT 混合

FART 脱壳点的代码修改位置

对于想在不同 Android 版本上移植 FART 的开发者,以下是需要修改的关键文件和函数:

核心修改文件

art/runtime/
├── class_linker.cc          # 修改 LinkClass 函数,插入脱壳逻辑
├── class_linker.h            # 添加 FART 相关的函数声明
├── fart.cpp                  # FART 的核心实现(新建文件)
├── fart.h                    # FART 的头文件(新建文件)
└── Android.mk / Android.bp   # 编译配置,添加 fart.cpp

class_linker.cc 修改要点

LinkClass 函数的末尾,类链接完成后,插入 FART 的脱壳逻辑:

// 在 LinkClass 函数的末尾、return 之前添加

if (UNLIKELY(thread_local_dex_is_fart)) {
    const DexFile* dex_file = &klass->GetDexFile();
    if (dex_file != nullptr) {
        // 设置 dump 目录
        char dump_dir[256];
        snprintf(dump_dir, sizeof(dump_dir),
                 "/data/data/%s/fartdex",
                 GetCurrentProcessName().c_str());
        mkdir(dump_dir, 0755);
        
        // dump DEX 和方法指令
        dumpdexfilebydexfile(dex_file, dump_dir);
    }
}

fart.cpp 的新增内容

FART 需要新增以下函数和变量:

// 全局变量
__thread bool thread_local_dex_is_fart = false;

// 核心函数
void dumphex(char* file_path, uint8_t* begin, int len);
void dumpdexfile(DexFile* dex_file, const char* dumpdir);
void dumpdexfilebyclass(DexFile* dex_file, char* dexfile_path,
                         Mirror::Class* klass);
void dumpdexinsns(DexFile* dex_file, char* dexfile_path,
                  ArtMethod* method);

总结

抽取壳通过将方法的 code_item 从 DEX 中提取并加密,运行时再动态回填,极大地提高了逆向的难度。FART 选择 ART 的 LinkClass 阶段作为脱壳点,利用了这个时机壳程序必然已完成 code_item 回填的特性,通过主动调用确保所有方法的代码都被还原。FART 的实现主要修改 class_linker.cc 中的 LinkClass 函数,在不同 Android 版本上需要做相应的适配。掌握抽取壳的原理和 FART 脱壳点的选择逻辑,是有效进行脱壳实战的关键基础。