ART 和 Dalvik 下的 dex 加载流程及脱壳点

背景知识

Android 系统从 Dalvik 虚拟机演进到 ART(Android Runtime)虚拟机,DEX 文件的加载流程发生了显著变化。对于脱壳工作而言,理解两个运行时下的 DEX 加载全流程,是选择合适脱壳点的前提。Dalvik 运行时(Android 4.4 及之前)采用解释执行 + JIT 编译的方式,而 ART 运行时(Android 5.0 及之后)采用 AOT(Ahead-of-Time)编译,在安装时将 DEX 编译为 native 代码。这两种截然不同的执行模式,直接影响了脱壳点的选择和 hook 方式。

Dalvik 下的 DEX 加载全流程

openDexFile —— 打开 DEX 文件

当 Android 应用启动时,系统首先需要打开 DEX 文件。openDexFile 是 DEX 文件加载的入口函数,位于 dalvik/vm/DvmDex.c 中:

// dalvik/vm/DvmDex.c
DvmDex* dvmDexFileOpen(const char* fileName, const char* odexFileName,
                       DvmDex** ppDvmDex)
{
    // 1. 将 DEX 文件映射到内存(mmap)
    // 2. 解析 DEX 文件头(header)
    // 3. 校验 DEX 文件的 magic number 和 checksum
    // 4. 创建 DvmDex 结构体
    // 5. 返回 DvmDex 对象
}

在 Dalvik 中,DEX 文件通过 mmap 系统调用映射到进程的内存空间。如果存在预编译的 ODEX(Optimized DEX)文件,系统会优先加载 ODEX;否则会实时优化 DEX 并缓存为 ODEX。

脱壳意义:在 openDexFile 阶段,DEX 文件已经被加载到内存中。对于 DEX 整体加密壳,此时 DEX 可能还是加密状态,需要等待解密完成。这个阶段通常不适合作为脱壳点。

loadClass —— 加载类

loadClass 是 Java 层的类加载入口,位于 java.lang.ClassLoader 中:

// java.lang.ClassLoader
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 1. 检查该类是否已被加载
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            // 2. 委派给父加载器(双亲委派)
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 父加载器无法加载
        }
        if (c == null) {
            // 3. 委派失败,自己查找并加载
            c = findClass(name);
        }
    }
    return c;
}

这是 Java 双亲委派模型的核心实现。对于加壳应用,壳程序的 ClassLoader 会重写 loadClass 方法,拦截特定的类加载请求,在适当的时机将解密后的 DEX 注入加载链路。

脱壳意义:在 loadClass 阶段 hook,可以监控类的加载顺序,帮助理解壳程序的类加载时序,但此时类可能还未完成链接和初始化。

findClass —— 查找类

findClass 在父加载器无法加载目标类时被调用,负责在自己的类搜索路径中查找并定义类:

// dalvik/vm/oo/Class.c
ClassObject* dvmFindClass(const char* descriptor, Object* loader)
{
    // 1. 在已加载类缓存中查找
    ClassObject* clazz = dvmLookupClass(descriptor, loader, true);
    if (clazz != NULL) {
        return clazz;
    }
    // 2. 在 DEX 中查找类定义
    clazz = dvmDefineClass(descriptor, loader);
    return clazz;
}

对于 DexClassLoader,findClass 会在其关联的 DEX 文件中搜索目标类。如果是壳程序,这个阶段会从解密后的 DEX 中查找类。

脱壳意义findClass 是一个较合适的脱壳点。当壳程序通过 DexClassLoader 加载解密后的 DEX 时,findClass 被调用意味着 DEX 已经在内存中完成了解密。此时通过读取内存可以获取到解密后的 DEX 数据。

defineClass —— 定义类

defineClass 负责将类从 DEX 文件中解析出来,创建类的内部表示:

// dalvik/vm/oo/Class.c
ClassObject* dvmDefineClass(const char* descriptor, Object* loader)
{
    // 1. 从 DvmDex 中查找类定义的原始数据
    const DexClassDef* pClassDef = dexFindClass(dvmDex->pDexFile, descriptor);
    // 2. 分配 ClassObject 内存
    ClassObject* newClass = allocClass(loader);
    // 3. 解析类的字段(fields)
    // 4. 解析类的方法(methods)—— 方法体字节码在此被引用
    // 5. 设置类的访问标志、父类、接口等信息
    return newClass;
}

defineClass 阶段,类的结构被完整解析,包括字段、方法签名、方法字节码指针等。对于 DEX 抽取壳,方法体字节码在这个阶段可能还是空壳(指向空方法或 return 指令),需要后续的动态回填。

脱壳意义defineClass 是 Dalvik 下最核心的脱壳点之一。此时 DEX 数据在内存中是可访问的,类的结构已经解析完成。Frida 可以 hook 此函数,遍历 DvmDex 结构体来 dump 完整的 DEX。

linkClass —— 链接类

// dalvik/vm/oo/Class.c
bool dvmLinkClass(ClassObject* clazz)
{
    // 1. 解析父类引用
    // 2. 解析接口引用
    // 3. 验证字节码(verify)
    // 4. 准备方法表和虚方法表(vtable)
    // 5. 静态字段赋初值
    // 6. 标记类为已链接状态
}

linkClass 对类进行链接操作,包括验证字节码合法性、解析符号引用、设置方法分派表等。这是类加载的最后阶段之一。

脱壳意义:在 linkClass 阶段,类的方法体已经完全就位(即使对于抽取壳,方法回填也应该在此阶段之前完成)。此时 dump 的 DEX 是最完整的。

ART 下的 DEX 加载全流程

OpenDexFilesFromOat —— 打开 DEX/OAT 文件

ART 的 DEX 加载入口与 Dalvik 有本质不同。由于 ART 使用 AOT 编译,DEX 文件在安装时被编译为 OAT(Optimized Android Application Target)文件,其中包含 native 代码。

// art/runtime/oat_file_manager.cc
std::vector<std::unique_ptr<const DexFile>>
OatFileManager::OpenDexFilesFromOat(const char* dex_location,
                                     const OatFile::OatDexFile** oat_dex_files,
                                     std::vector<std::string>* error_msgs)
{
    // 1. 尝试打开 OAT 文件
    // 2. 如果 OAT 存在且有效,直接使用(AOT 编译的代码)
    // 3. 如果 OAT 不存在或版本不匹配,回退到解释执行 DEX
    // 4. 创建 DexFile 对象
    // 5. 返回 DexFile 对象列表
}

重要特性:ART 下,DEX 数据可能来自多个来源——直接从 DEX 文件读取、从 OAT 文件中嵌入的 DEX 段读取、或从 VDEX(Verified DEX)文件中读取。壳程序可能只加密了 DEX 文件,而 OAT/VDEX 中的 DEX 段仍然保留明文,这也是脱壳的一个可利用点。

LoadClass —— 加载类

ART 的类加载入口在 ClassLinker 中:

// art/runtime/class_linker.cc
Class* ClassLinker::LoadClass(Thread* self,
                               const DexFile& dex_file,
                               const DexFile::ClassDef& dex_class_def,
                               Handle<mirror::ClassLoader> class_loader)
{
    // 1. 在类查找表中搜索
    // 2. 分配 mirror::Class 对象
    // 3. 解析 ClassDef,设置类的基本信息
    // 4. 设置类的状态为 kStatusResolved
    // 5. 返回 Class 对象
}

LoadClass 对应 Dalvik 的 defineClass,负责从 DEX 文件中解析类定义并创建运行时的类对象。

LinkClass —— 链接类

ART 的 LinkClass 功能比 Dalvik 更丰富:

// art/runtime/class_linker.cc
bool ClassLinker::LinkClass(Thread* self,
                            Handle<mirror::Class> klass,
                            Handle<mirror::ObjectArray<mirror::Class>> interfaces,
                            Handle<mirror::ClassLoader> class_loader)
{
    // 1. 验证类(VerifyClass)
    // 2. 解析字段类型和方法签名
    // 3. 构建虚方法表(vtable)和接口方法表(itable)
    // 4. 链接父类和接口
    // 5. 计算实例大小和对齐信息
    // 6. 设置类状态为 kStatusLinked
}

脱壳意义:ART 的 LinkClass 是 FART 脱壳框架选择的脱壳点。在这个阶段,类的 DEX 数据和方法字节码已经完全可用,是 dump DEX 的最佳时机。详细原因将在 FART 框架的专门文章中分析。

MakeInitializedClassesVisiblyInitialized —— 类初始化

// art/runtime/class_linker.cc
void ClassLinker::MakeInitializedClassesVisiblyInitialized(
    Thread* self, Handle<mirror::Class> klass)
{
    // 1. 执行 <clinit>(静态初始化方法)
    // 2. 将类状态设为 kStatusInitialized
    // 3. 确保所有依赖类也被正确初始化
}

类的静态初始化方法 <clinit> 在此阶段被执行。对于壳程序而言,<clinit> 可能包含解密逻辑或反调试检测。

Dalvik 与 ART 加载流程的核心差异对比

对比维度 Dalvik ART
执行方式 解释执行 + JIT AOT 编译 + 解释/JIT 混合
DEX 加载 openDexFile 直接映射 DEX OpenDexFilesFromOat 优先加载 OAT
类定义 dvmDefineClass ClassLinker::LoadClass
类链接 dvmLinkClass ClassLinker::LinkClass
类验证 链接阶段验证 加载和链接阶段都可验证
代码存储 DEX 字节码 OAT 中的 native 代码 + DEX
DEX 内存布局 线性映射 可能分散(DEX、OAT、VDEX)
方法执行 解释 Dalvik 字节码 执行编译后的 native 代码

从脱壳的角度看,最大的差异在于:Dalvik 的 DEX 在内存中是完整的线性结构,可以直接通过指针遍历 dump;而 ART 的 DEX 数据可能分散在多个文件中(DEX、VDEX、OAT),需要综合处理才能还原完整 DEX。

脱壳点的选择策略

脱壳点选择原则

选择脱壳点时需要考虑以下几个原则:

  1. 完整性原则:脱壳点处的 DEX 数据必须尽可能完整,包含所有类定义和方法字节码
  2. 时效性原则:脱壳点必须位于壳程序完成解密操作之后
  3. 通用性原则:脱壳点应在不同 Android 版本和不同壳类型下都有效
  4. 稳定性原则:脱壳点的 hook 操作不应导致目标应用崩溃

Dalvik 推荐脱壳点

在 Dalvik 下,推荐的脱壳点按优先级排列:

  1. dvmDefineClass(最高优先级):类定义完成后,DEX 数据和方法信息已就绪
  2. dvmLinkClass:类链接完成后,所有字节码已验证通过
  3. dvmResolveClass:类解析阶段,DEX 数据可读

对应的 Frida hook 示例(以 dvmDefineClass 为例):

// Frida hook Dalvik dvmDefineClass
var dvmDefineClass = Module.findExportByName("libdvm.so", "dvmDefineClass");
Interceptor.attach(dvmDefineClass, {
    onEnter: function(args) {
        this.descriptor = Memory.readCString(args[0]); // 类描述符
    },
    onLeave: function(retval) {
        if (retval.isNull()) return;
        // 通过 ClassObject 获取关联的 DvmDex
        var clazz = ptr(retval);
        var pDvmDex = clazz.add(Process.pointerSize).readPointer();
        // 从 DvmDex 中获取 DEX 文件指针
        var pDexFile = pDvmDex.add(Process.pointerSize).readPointer();
        // dump DEX 文件(遍历所有类完成后统一 dump)
    }
});

ART 推荐脱壳点

在 ART 下,推荐的脱壳点:

  1. ClassLinker::LinkClass(FART 选择的脱壳点):类链接阶段,方法字节码已完全加载
  2. ClassLinker::LoadClass:类加载阶段,DEX 数据可访问
  3. OpenDexFilesFromOat 的 DEX 读取阶段:DEX 文件刚被读取到内存

对应的 Frida hook 示例(以 ClassLinker::LinkClass 为例):

// Frida hook ART ClassLinker::LinkClass
Java.perform(function() {
    var classLinker = Java.use("dalvik.system.BaseDexClassLoader")
        .$new()
        .getClass()
        .getClassLoader();
    
    // 获取 ClassLinker 实例地址
    var classLinkerAddr = Module.findExportByName("libart.so", "_ZN3art11ClassLinker9LinkClassEPNS_6ThreadEPNS_6HandleINS_6mirror5ClassEEEPNS3_INS_6ObjectArrayIS5_EEEPNS3_INS_6mirror11ClassLoaderEEE");
    
    Interceptor.attach(classLinkerAddr, {
        onEnter: function(args) {
            this.klass = args[1]; // mirror::Class 指针
        },
        onLeave: function(retval) {
            if (retval.toInt32() == 0) return; // 链接失败
            // 获取类的 DEX 文件
            // dump 该类所在 DEX 的完整数据
        }
    });
});

Frida 在不同加载阶段的 Hook 实践

监控 DEX 文件加载

要监控应用加载了哪些 DEX 文件,可以 hook 文件操作相关的系统调用:

// 监控 DEX 文件打开操作
var openPtr = Module.findExportByName("libc.so", "open");
Interceptor.attach(openPtr, {
    onEnter: function(args) {
        var path = Memory.readCString(args[0]);
        if (path.indexOf(".dex") !== -1 || path.indexOf(".jar") !== -1 || 
            path.indexOf(".apk") !== -1) {
            console.log("[DEX Open] " + path);
        }
    }
});

监控内存映射

DEX 文件通常通过 mmap 映射到内存,监控 mmap 可以发现 DEX 数据的内存位置:

var mmapPtr = Module.findExportByName("libc.so", "mmap");
Interceptor.attach(mmapPtr, {
    onEnter: function(args) {
        this.size = args[1].toInt32();
    },
    onLeave: function(retval) {
        if (this.size > 0 && this.size < 50 * 1024 * 1024) {
            // 检查映射区域是否是 DEX 文件
            var magic = Memory.readByteArray(retval, 4);
            var magicStr = Array.from(new Uint8Array(magic)).map(b => 
                String.fromCharCode(b)).join("");
            if (magicStr === "dex\n") {
                console.log("[DEX mmap] addr=" + retval + " size=" + this.size);
            }
        }
    }
});

Dump 内存中的 DEX

找到 DEX 在内存中的位置后,可以通过 DEX 文件头的 file_size 字段获取完整大小并 dump:

function dumpDex(baseAddr) {
    // 读取 DEX 文件头
    var magic = Memory.readCString(baseAddr, 8);
    if (magic.indexOf("dex\n035") === -1 && magic.indexOf("dex\n037") === -1 &&
        magic.indexOf("dex\n038") === -1 && magic.indexOf("dex\n039") === -1) {
        console.log("Not a valid DEX file");
        return;
    }
    
    // DEX file_size 在偏移 32 处(uint32)
    var fileSize = baseAddr.add(32).readU32();
    console.log("[Dump] DEX at " + baseAddr + ", size: " + fileSize);
    
    // dump 到文件
    var dexData = Memory.readByteArray(baseAddr, fileSize);
    var f = new File("/data/local/tmp/dumped_" + baseAddr + ".dex", "wb");
    f.write(dexData);
    f.close();
}

总结

Dalvik 和 ART 的 DEX 加载流程各有特点,脱壳点的选择需要根据目标运行时和壳类型来决定。Dalvik 下 dvmDefineClassdvmLinkClass 是最可靠的脱壳点;ART 下 ClassLinker::LinkClass 是 FART 框架的核心脱壳点。理解这些加载流程的细节,能够帮助逆向分析者精准定位 DEX 数据在内存中的位置,选择最优的脱壳时机,为后续使用 FART 等工具进行自动化脱壳打下坚实基础。