ART 下的壳通用脱壳技术

ART 虚拟机简介

从 Android 5.0(Lollipop)开始,Dalvik 虚拟机被 ART(Android Runtime)正式取代。ART 带来了根本性的架构变革,理解这些变革是掌握 ART 脱壳技术的前提。

AOT 编译

ART 最大的特点是引入了 AOT(Ahead-Of-Time)预编译。在应用安装时,系统会将 DEX 字节码编译为本地机器码(ELF 格式),存储在 OAT 文件中。运行时直接执行机器码,省去了 Dalvik 中每次运行都要解释执行的 overhead。

安装阶段: APK → DEX → AOT编译 → OAT文件(机器码)
运行阶段: 直接加载 OAT 文件执行机器码

JIT 编译

Android 7.0 又引入了 JIT(Just-In-Time)即时编译作为补充。JIT 在运行时对热点代码进行编译并缓存到 profile 文件中,下次安装或 OTA 时根据 profile 进行选择性 AOT 编译,这就是所谓的 混合编译策略:

  • 首次安装:仅解释执行 + JIT 编译热点代码
  • 后续安装:根据 profile 对热点代码进行 AOT 编译

OAT 文件与 VDEX 文件

  • OAT 文件:本质是 ELF 格式的共享库,包含编译后的本地机器码和原始 DEX 数据。路径通常在 /data/app/<package>/oat/arm64/
  • VDEX 文件(Android 8.0+):包含未优化的 DEX 数据和验证信息,在快速启动模式下使用

这些文件的存在使得 ART 下加壳的技术方案与 Dalvik 时代有很大不同——壳不再只需要处理 DEX,还需要考虑 OAT/VDEX 的关系。

ART 下 DEX 加载流程

理解 DEX 在 ART 中的加载流程,是寻找脱壳切入点的关键。以下是一个简化的调用链:

ClassLoader.loadClass("com.example.Main")
  └→ BaseDexClassLoader.findClass()
    └→ DexPathList.findClass()
      └→ DexFile.loadClassBinaryName()
        └→ ClassLinker::FindClass()
          └→ ClassLinker::DefineClass()    ← DEX 在此时已解密

更底层的 DEX 文件打开流程:

DexFileLoader::Open()
  └→ OpenDexFilesFromOat()
    └→ OatFileManager::OpenDexFiles()
      └→ LoadDexFiles()       ← 从 OAT/VDEX 中提取 DEX
        └→ VerifyAndLoadDex() ← 验证 DEX 完整性

关键步骤解读

  1. OpenDexFilesFromOat:尝试从 OAT 文件中打开 DEX。如果有对应的 OAT,直接从中读取;否则打开原始 DEX。
  2. LoadDexFiles:将 DEX 数据从文件加载到内存,进行格式校验。
  3. DefineClass:将 DEX 中的类定义注册到 ART 运行时。此时 DEX 必须已经完全解密,否则类无法正常加载。

ART 脱壳的关键时机和切入点

ART 脱壳的核心思想是:在壳将 DEX 解密到内存之后、DEX 数据被使用之前,将内存中的 DEX 抓取出来。

1. ClassLinker::DefineClass —— 类定义时

这是最经典的 ART 脱壳时机。当 ClassLinker::DefineClass 被调用时,DEX 一定已经完成了解密,因为 ART 需要读取 DEX 中的类定义信息。

// ART 源码 (art/runtime/class_linker.cc)
void ClassLinker::DefineClass(Thread* self,
                              const char* descriptor,
                              Handle<mirror::ClassLoader> class_loader,
                              const DexFile& dex_file,
                              const DexFile::ClassDef& class_def) {
  // 此时 dex_file 已解密,可以读取完整数据
  StackHandleScope<1> hs(self);
  Handle<mirror::Class> klass = hs.NewHandle(AllocClass(self, SizeOfClassWithoutFields()));
  // ... 后续类定义逻辑
}

Hook 思路:在 DefineClass 入口处,遍历当前进程中所有已打开的 DexFile 对象,dump 出内存中的 DEX 数据。

2. DexFileLoader::Open —— DEX 文件打开时

在 DEX 文件被打开的瞬间进行拦截,可以获取到原始的 DEX 数据流。对于第一代壳(只加密 DEX 文件),这个时机非常有效。

// 拦截 DexFileLoader::Open
// 获取打开的 DEX 路径和内存中的 DEX 数据
// 如果 DEX 来源于内存(如壳解密后写入临时文件),此时可以拿到解密后的数据

3. OatFileManager::OpenDexFiles —— OAT 文件管理

对于使用 OAT 方式加壳的方案,Hook OatFileManager 相关函数可以获取到与 DEX 关联的 OAT 信息。

// art/runtime/oat_file_manager.cc
bool OatFileManager::OpenDexFilesFromOat(...) {
  // 遍历 OAT 文件并加载关联的 DEX
  // 可以在此处获取 DEX 和 OAT 的映射关系
}

内存 dump 方法在 ART 下的实现

内存 dump 是 ART 脱壳的核心手段。基本流程如下:

  1. 遍历进程空间:通过 /proc/self/maps 找到所有匿名内存映射区域(DEX 通常被映射到匿名区域)
  2. 识别 DEX 魔数:在每个内存区域中搜索 dex\n035(DEX 文件头魔数)
  3. 提取完整 DEX:从魔数位置开始,读取完整的 DEX 文件内容
  4. 修复 DEX:补全被破坏的头部信息(某些壳会修改 DEX 头部校验值)
# Python 伪代码:内存 dump DEX 的核心逻辑
def dump_dex_from_memory(pid):
    dex_magic = b'dex\n035'
    dex_list = []
    
    # 读取 /proc/{pid}/maps
    with open(f'/proc/{pid}/maps', 'r') as f:
        for line in f:
            if 'r' in line.split()[1]:  # 可读区域
                addr_range = line.split()[0]
                start, end = [int(x, 16) for x in addr_range.split('-')]
                
                # 读取该内存区域
                with open(f'/proc/{pid}/mem', 'rb') as mem:
                    mem.seek(start)
                    data = mem.read(end - start)
                    
                    # 搜索 DEX 魔数
                    offset = 0
                    while True:
                        pos = data.find(dex_magic, offset)
                        if pos == -1:
                            break
                        # 找到 DEX,尝试提取
                        dex_data = extract_dex(data[pos:])
                        if dex_data:
                            dex_list.append(dex_data)
                        offset = pos + 4
    
    return dex_list

注意:ART 下内存 dump 比 Dalvik 更复杂,因为 ART 可能同时存在 DEX 和 OAT 中的机器码,需要正确区分和提取。

常用 ART 脱壳工具

FDex2

FDex2 是目前最流行的 ART 内存脱壳工具之一。它通过 Hook ClassLoaderloadClass 方法,在类被加载时自动 dump 内存中的 DEX 文件。

  • 原理:Hook java.lang.ClassLoader.loadClass(),每次加载新类时遍历 DexPathList 中的 DexFile,提取 DEX 数据
  • 支持:Android 5.0 - 9.0
  • 使用:需要 Xposed 框架或 VirtualXposed 环境

DexDump

DexDump 是一个经典的 DEX 内存 dump 工具,适用于多种 Android 版本。

  • 原理:扫描进程内存中的 DEX 魔数,提取完整的 DEX 文件
  • 特点:不依赖特定 Hook 点,通用性较强

反射大师

反射大师(Reflection Master)通过反射机制枚举进程中所有 ClassLoader,获取其加载的 DEX 文件信息。

  • 原理:反射调用 BaseDexClassLoader.pathList.dexElements 获取 DexFile 对象数组
  • 优势:可以直接看到每个 DEX 的路径和加载状态

FART (ART 脱壳利器)

FART 是第一个基于主动调用的脱壳方案,通过修改 ART 源码,在类加载时主动调用类中的每个方法,从而触发壳对整个 DEX 的解密。

  • 原理:修改 ClassLinker::DefineClass,在类定义完成后主动调用类中的所有方法(dvmCallMethod
  • 效果:壳为了执行方法必须解密整个方法体,FART 在解密后立即 dump
  • 局限:需要刷入修改过的 ROM

Frida 脱壳脚本示例

下面是一个使用 Frida 进行 ART 内存脱壳的实战脚本。该脚本 Hook ClassLoader.loadClass,在类加载时 dump 出内存中所有已打开的 DEX。

// frida_art_dump.js - ART 内存脱壳脚本
// 使用方式: frida -U -f com.target.app -l frida_art_dump.js

Java.perform(function () {
    console.log("[*] ART DEX Dump Script Started");

    var savedDex = [];

    function dumpDex(dexFileObj) {
        try {
            var begin = dexFileObj.mCookie.value;       // 获取 DEX 内存起始地址
            var end = dexFileObj.mInternalCookie.value;  // 获取 DEX 内存结束地址

            if (begin.equals(end)) return;  // 空指针检查

            var size = end.sub(begin).toInt32();
            console.log("[*] Found DEX: begin=" + begin + " size=" + size);

            // 读取内存中的 DEX 数据
            var dexBytes = Memory.readByteArray(begin, size);

            // 保存到文件
            var filename = "/data/local/tmp/dex_" + Date.now() + ".dex";
            var file = new File(filename, "wb");
            file.write(dexBytes);
            file.close();
            console.log("[+] DEX saved to: " + filename);
        } catch (e) {
            console.log("[-] Error: " + e);
        }
    }

    // Hook ClassLoader.loadClass
    var classLoader = Java.use("java.lang.ClassLoader");
    classLoader.loadClass.overload("java.lang.String").implementation = function (name) {
        var clazz = this.loadClass(name);

        // 获取 BaseDexClassLoader 的 pathList
        try {
            var baseDexLoader = Java.cast(this, Java.use("dalvik.system.BaseDexClassLoader"));
            var pathList = baseDexLoader.pathList.value;
            var dexElements = pathList.dexElements.value;

            for (var i = 0; i < dexElements.length; i++) {
                var dexFile = dexElements[i].dexFile.value;
                if (dexFile != null) {
                    // 通过反射获取内存中的 DEXFile 对象
                    var mCookie = dexFile.mCookie.value;
                    console.log("[*] Class: " + name + " | DEX Cookie: " + mCookie);
                }
            }
        } catch (e) {
            // 非 BaseDexClassLoader 的子类,忽略
        }

        return clazz;
    };

    console.log("[*] Hooks installed, waiting for class loading...");
});

使用步骤

# 1. 连接设备,启动 Frida Server
adb shell "su -c '/data/local/tmp/frida-server -D &'"

# 2. 以 Spawn 模式启动目标应用并注入脚本
frida -U -f com.target.app -l frida_art_dump.js

# 3. 操作目标应用,触发类加载
# 4. 拉取 dump 出的 DEX 文件
adb pull /data/local/tmp/ /tmp/dex_dump/

DEX 修复技术

从内存中 dump 出来的 DEX 往往存在各种问题,需要修复后才能正常使用。

DexHunter 的修复思路

DexHunter 是一个经典的 DEX 修复工具,主要修复以下内容:

问题 原因 修复方法
DEX 头部被篡改 壳修改了 magic、checksum、file_size 等字段 根据实际内容重新计算并补全
字符串偏移异常 壳对字符串表进行了加密偏移 遍历修复 string_ids 表
方法体缺失 壳对方法体进行了单独加密 通过主动调用触发解密
DEX 截断 内存 dump 不完整 结合多个 dump 结果拼接
# DEX 头部修复示例(Python 伪代码)
import struct
import hashlib

def fix_dex_header(data):
    # 确保是有效的 DEX 数据
    if data[:4] != b'dex\n':
        raise ValueError("Not a valid DEX file")
    
    # 修复 file_size(偏移 0x20)
    file_size = struct.pack('<I', len(data))
    data = data[:0x20] + file_size + data[0x24:]
    
    # 重新计算 SHA-1 签名(偏移 0x0C,长度 20 字节)
    sha1 = hashlib.sha1(data[0x20:]).digest()
    data = data[:0x0C] + sha1 + data[0x20:]
    
    # 重新计算 Adler-32 校验和(偏移 0x08)
    checksum = adler32(data[0x0C:])  # 从签名之后开始计算
    data = data[:0x08] + struct.pack('<I', checksum) + data[0x0C:]
    
    return data

ART 脱壳 vs Dalvik 脱壳的区别和难点

ART 相比 Dalvik 的架构变化,给脱壳带来了全新的挑战:

维度 Dalvik ART
执行方式 解释执行字节码 AOT 编译执行机器码
DEX 存储 仅 DEX 文件 DEX + OAT + VDEX
加壳难度 较低 较高(需同时处理 OAT)
脱壳时机 dvmDexFileOpen DefineClass / OpenDexFiles
内存 Dump 相对简单 需区分 DEX 和机器码
壳的强度 第一代壳为主 第二代、第三代壳为主

ART 脱壳的主要难点

  1. OAT 绑定问题:ART 下 DEX 与 OAT 紧密绑定,单独 dump DEX 可能丢失 OAT 中的编译优化信息
  2. DEX 内存碎片化:某些壳不会将完整 DEX 映射到连续内存,而是分段解密,导致内存 dump 不完整
  3. 多 DEX 混合加载:壳可能将原始 DEX 分成多个部分,分别加载为不同的 DexFile 对象
  4. 内联优化:ART 的内联优化可能导致方法体在 OAT 中被优化合并,增加还原难度
  5. 反调试增强:ART 提供了更强的反调试能力(如 Debug.waitingForGcToComplete),壳可以更有效地检测 Frida 等工具

实际案例:使用 Frida 对第二代壳进行 ART 内存脱壳

下面以一个典型的第二代壳(DEX 加密 + 运行时解密)为例,演示完整的 ART 脱壳流程。

环境准备

  • 设备:Pixel 4 / Android 10
  • 工具:Frida 16.1.5、dex2jar、JD-GUI
  • 目标:某加固后的 APP

分析阶段

首先确认目标 APP 的壳类型和加固方案:

# 查看 APK 结构
unzip -l target.apk | grep -E "dex|oat|so"

# 典型输出:
# classes.dex          (壳的 loader DEX,较小)
# lib/arm64-v8a/libjiagu.so  (加固 SO 库)
# assets/libExec.so    (加密的原始 DEX)

通过分析可以发现,classes.dex 是壳的加载器(引导 DEX),真正的业务 DEX 被加密存储在 SO 库或 assets 中,运行时由 libjiagu.so 解密加载。

脱壳实施

// frida_2nd_gen_unpack.js - 第二代壳脱壳脚本
Java.perform(function () {
    console.log("[*] Starting 2nd-gen packer unpacking...");

    // 方法一:定时轮询,等待壳完成解密
    // 使用 setTimeout 延迟执行,确保壳的解密流程完成
    setTimeout(function () {
        console.log("[*] Delayed dump starting...");

        // 枚举所有 ClassLoader
        Java.enumerateClassLoaders({
            onMatch: function (loader) {
                try {
                    var baseLoader = Java.cast(loader, Java.use("dalvik.system.BaseDexClassLoader"));
                    var pathList = baseLoader.pathList.value;
                    var elements = pathList.dexElements.value;

                    console.log("[*] ClassLoader: " + loader);
                    console.log("[*] DexElements count: " + elements.length);

                    for (var i = 0; i < elements.length; i++) {
                        try {
                            var dexFile = elements[i].dexFile.value;
                            if (dexFile != null) {
                                var fileName = dexFile.mFileName.value;
                                console.log("[+] DEX file: " + fileName);

                                // 尝试获取 DEX 的内存数据
                                var cookie = dexFile.mCookie.value;
                                console.log("[+] Cookie: " + cookie);
                            }
                        } catch (e2) {
                            // 忽略单个 element 的错误
                        }
                    }
                } catch (e) {
                    // 非 BaseDexClassLoader
                }
            },
            onComplete: function () {
                console.log("[*] ClassLoader enumeration complete");
            }
        });
    }, 5000);  // 延迟 5 秒,确保壳解密完成

    // 方法二:Hook DexFile 构造函数,捕获所有新加载的 DEX
    try {
        var DexFile = Java.use("dalvik.system.DexFile");
        DexFile.$init.overload("java.io.File").implementation = function (file) {
            console.log("[+] DexFile opened: " + file.getPath());
            return this.$init(file);
        };
        DexFile.$init.overload("java.lang.String").implementation = function (fileName) {
            console.log("[+] DexFile opened: " + fileName);
            return this.$init(fileName);
        };
    } catch (e) {
        console.log("[-] Hook DexFile failed: " + e);
    }
});

执行与结果

# 启动应用并注入脚本
frida -U -f com.target.app -l frida_2nd_gen_unpack.js --no-pause

# 等待 5 秒后脚本自动 dump
# 输出示例:
# [*] Starting 2nd-gen packer unpacking...
# [+] DexFile opened: /data/app/.../base.apk
# [*] Delayed dump starting...
# [*] ClassLoader: dalvik.system.PathClassLoader
# [*] DexElements count: 2
# [+] DEX file: /data/app/.../base.apk
# [+] DEX file: /data/local/tmp/decrypted.dex  ← 脱壳成功!

DEX 修复与验证

# 修复 dump 出的 DEX
python fix_dex.py /data/local/tmp/decrypted.dex -o fixed.dex

# 验证 DEX 有效性
d2j-dex2jar.sh fixed.dex
# 如果转换成功且能用 JD-GUI 打开,说明脱壳成功

# 使用 jadx 查看
jadx -d output/ fixed.dex

总结

ART 下的脱壳技术相比 Dalvik 时代更加复杂,但核心思想一脉相承:在壳完成解密后的最佳时机,从内存中提取原始 DEX 数据。关键要点:

  1. 掌握 ART 加载流程是脱壳的理论基础,特别是 ClassLinker::DefineClassDexFileLoader::Open 这两个关键节点
  2. 内存 dump 是最通用的方法,但需要注意 ART 下的 OAT 绑定和内存碎片化问题
  3. Frida 是当前最灵活的脱壳工具,配合适当的 Hook 策略可以应对大多数第二代壳
  4. DEX 修复 是脱壳后的必要步骤,必须补全头部信息才能正常使用
  5. 面对第三代壳(VMP + DEX 混淆),需要结合静态分析、动态调试和算法还原等多种手段

免责声明:本文内容仅用于安全研究和学习目的。请勿将脱壳技术用于非法用途,尊重软件开发者的知识产权。