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 完整性
关键步骤解读
- OpenDexFilesFromOat:尝试从 OAT 文件中打开 DEX。如果有对应的 OAT,直接从中读取;否则打开原始 DEX。
- LoadDexFiles:将 DEX 数据从文件加载到内存,进行格式校验。
- 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 脱壳的核心手段。基本流程如下:
- 遍历进程空间:通过
/proc/self/maps找到所有匿名内存映射区域(DEX 通常被映射到匿名区域) - 识别 DEX 魔数:在每个内存区域中搜索
dex\n035(DEX 文件头魔数) - 提取完整 DEX:从魔数位置开始,读取完整的 DEX 文件内容
- 修复 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 ClassLoader 的 loadClass 方法,在类被加载时自动 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 脱壳的主要难点
- OAT 绑定问题:ART 下 DEX 与 OAT 紧密绑定,单独 dump DEX 可能丢失 OAT 中的编译优化信息
- DEX 内存碎片化:某些壳不会将完整 DEX 映射到连续内存,而是分段解密,导致内存 dump 不完整
- 多 DEX 混合加载:壳可能将原始 DEX 分成多个部分,分别加载为不同的 DexFile 对象
- 内联优化:ART 的内联优化可能导致方法体在 OAT 中被优化合并,增加还原难度
- 反调试增强: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 数据。关键要点:
- 掌握 ART 加载流程是脱壳的理论基础,特别是
ClassLinker::DefineClass和DexFileLoader::Open这两个关键节点 - 内存 dump 是最通用的方法,但需要注意 ART 下的 OAT 绑定和内存碎片化问题
- Frida 是当前最灵活的脱壳工具,配合适当的 Hook 策略可以应对大多数第二代壳
- DEX 修复 是脱壳后的必要步骤,必须补全头部信息才能正常使用
- 面对第三代壳(VMP + DEX 混淆),需要结合静态分析、动态调试和算法还原等多种手段
免责声明:本文内容仅用于安全研究和学习目的。请勿将脱壳技术用于非法用途,尊重软件开发者的知识产权。