Dalvik 下的壳通用脱壳技术
Dalvik 虚拟机简介
在深入脱壳技术之前,我们需要先理解 Dalvik 虚拟机的基本架构,因为所有 Dalvik 时代的脱壳方案都建立在这些底层机制之上。
DEX 文件格式
DEX(Dalvik Executable)是 Android 应用程序的可执行文件格式。一个 APK 包中通常包含一个 classes.dex 文件,它是所有 Java/Kotlin 代码经过编译后的产物。DEX 文件的核心结构包括:
- Header:文件头,以 Magic 值
dex\n035\0(或035\0)开头,包含 checksum、file_size、header_size 等校验字段 - String Table:字符串表,存储所有字符串常量
- Type Table:类型表,描述方法签名和字段类型
- Method Table:方法表,记录所有方法的信息
- Class Defs:类定义表,描述每个类的结构
- Data Section:数据区,包含字节码、字段数据等
DEX 文件的 Header 结构(C 语言表示):
struct DexHeader {
ubyte[8] magic; // "dex\n035\0" 或 "dex\n037\0"
uint32 checksum; // Adler32 校验和
ubyte[20] signature; // SHA-1 签名
uint32 file_size; // DEX 文件总大小
uint32 header_size; // Header 大小(固定 0x70)
uint32 endian_tag; // 字节序标识
uint32 link_size;
uint32 link_off;
uint32 map_off;
uint32 string_ids_size;
uint32 string_ids_off;
uint32 type_ids_size;
uint32 type_ids_off;
uint32 proto_ids_size;
uint32 proto_ids_off;
uint32 field_ids_size;
uint32 field_ids_off;
uint32 method_ids_size;
uint32 method_ids_off;
uint32 class_defs_size;
uint32 class_defs_off;
uint32 data_size;
uint32 data_off;
};
寄存器式虚拟机
Dalvik 与传统的 JVM(栈式虚拟机)不同,它采用寄存器式架构。这意味着 Dalvik 的指令操作直接基于虚拟寄存器,而不是操作数栈。
; JVM 栈式指令(示例)
iload_0 ; 从局部变量表加载到操作数栈
iload_1
iadd ; 栈顶两个值相加
istore_2 ; 结果存回局部变量表
; Dalvik 寄存器式指令(等价操作)
add-int v0, v1, v2 ; v0 = v1 + v2,直接使用寄存器
寄存器架构的优势在于指令更短、执行效率更高,这在移动设备资源受限的场景下尤为重要。
Dalvik vs ART
| 特性 | Dalvik | ART |
|---|---|---|
| 执行方式 | JIT(即时编译) | AOT(提前编译)+ JIT |
| 产物格式 | DEX | OAT(ELF 格式) |
| 运行时编译 | 每次运行都编译 | 安装时预编译 |
| 内存占用 | 较高(运行时编译开销) | 较低 |
| 首次启动速度 | 较慢 | 较快 |
| 脱壳难度 | 相对较低 | 较高 |
关键区别:Dalvik 在运行时将 DEX 字节码通过 JIT 编译为机器码执行,这意味着在内存中总能找到完整的 DEX 数据。而 ART 在安装阶段就通过 dex2oat 将 DEX 编译为 OAT/ELF 格式,运行时直接执行机器码,不再保留完整的 DEX 结构——这使得 ART 下的脱壳难度显著增加。
Dalvik 下 DEX 加载流程
理解 DEX 加载流程是掌握脱壳技术的核心。当一个 Android 应用启动时,系统会按照以下链路加载 DEX 文件:
dvmJarOpen
→ dvmDexFileOpen
→ dexFileParse // 解析 DEX 文件头和结构
→ loadClassesFromDex // 加载类定义到虚拟机
各阶段详解
1. dvmJarOpen
入口函数,负责打开 APK(ZIP 格式)文件,从中提取 classes.dex:
// dalvik/vm/JarFile.c
int dvmJarOpen(const char* fileName, DexFile** ppDexFile)
{
// 将 APK 作为 ZIP 文件打开
// 查找 classes.dex 入口
// 读取并解压 DEX 数据
return dvmDexFileOpen(pBytes, length, ppDexFile);
}
2. dvmDexFileOpen
接收解压后的 DEX 字节数据,调用解析器处理:
// dalvik/vm/DvmDex.c
int dvmDexFileOpen(const void* addr, size_t len, DexFile** ppDexFile)
{
DexFile* pDexFile;
// 分配内存并初始化 DexFile 结构
pDexFile = dexFileParse(addr, len);
if (pDexFile != NULL) {
// 加载类定义
if (!loadClassesFromDex(pDexFile)) {
// 加载失败处理
}
}
return result;
}
3. dexFileParse
解析 DEX 文件的所有结构,验证 Magic、checksum 和 signature:
// dalvik/libdex/DexFile.c
DexFile* dexFileParse(const u1* data, size_t length)
{
const DexHeader* pHeader = (const DexHeader*) data;
// 验证 Magic 值
if (!dexHasValidMagic(pHeader)) return NULL;
// 验证 checksum(Adler32)
if (!dexHasValidChecksum(pHeader)) return NULL;
// 验证 SHA-1 签名
if (!dexHasValidSignature(pHeader)) return NULL;
// 解析字符串表、类型表、方法表、类定义等
// ...
return pDexFile;
}
4. loadClassesFromDex
将 DEX 中的所有类定义加载到 Dalvik 内部的类表中:
// 遍历 class_defs,将每个类注册到虚拟机
for (i = 0; i < pDexFile->pHeader->classDefsSize; i++) {
loadClassFromDex(pDexFile, &pDexFile->pClassDefs[i]);
}
加壳 APP 的关键变形
对于加壳应用,原始的 DEX 文件被加密存储,真正的加载流程变成了:
应用启动 → 加载壳 DEX(壳代码)→ 壳代码解密原始 DEX → 动态加载解密后的 DEX
壳程序通常会在 Application.attachBaseContext() 或 Activity.onCreate() 中完成解密工作,然后通过自定义的 ClassLoader 或直接调用 Dalvik 内部函数加载解密后的 DEX。
通用脱壳原理
通用脱壳的核心思想非常简洁:在 DEX 文件被解密完成之后、被正式加载和执行之前的时机,将内存中的完整 DEX 数据 dump(转储)出来。
壳代码解密 DEX → [ ★ 脱壳点:此时 DEX 已在内存中明文存在 ] → loadClassesFromDex
由于加壳应用最终必须在 Dalvik 虚拟机中执行,解密后的 DEX 必然会以明文形式出现在内存中。我们只需要找到这个"明文窗口期",就可以提取出完整的原始 DEX。
脱壳点通常选择在以下位置:
dexFileParse函数入口处(DEX 刚解密,尚未被解析)loadClassesFromDex函数入口处(DEX 已解析,类即将被加载)- 或者更简单地,直接在内存中搜索已解密的 DEX 数据
经典脱壳方法
方法一:内存搜索法
这是最简单直接的方案。原理是在进程的内存空间中搜索 DEX 文件的 Magic 值,定位到 DEX 数据的起始位置,然后根据 Header 中的 file_size 字段dump 出完整文件。
DEX 文件的 Magic 值为 dex\n035\0(十六进制:64 65 78 0A 30 33 35 00),搜索这个特征字符串即可定位 DEX。
#!/usr/bin/env python3
"""
DEX 内存搜索 dump 脚本
从 /proc/pid/maps 中搜索 DEX Magic 并 dump 完整文件
"""
import os
import struct
import sys
def search_and_dump(pid, output_dir="/data/local/tmp"):
maps_path = f"/proc/{pid}/maps"
mem_path = f"/proc/{pid}/mem"
dex_magic = b"dex\n035\0"
# 读取 maps,找到可读内存区域
regions = []
with open(maps_path, "r") as f:
for line in f:
if "r" in line.split()[1]: # 可读区域
parts = line.split()[0].split("-")
start = int(parts[0], 16)
end = int(parts[1], 16)
regions.append((start, end))
print(f"[*] 找到 {len(regions)} 个可读内存区域")
dumped = 0
with open(mem_path, "rb") as mem:
for start, end in regions:
try:
mem.seek(start)
data = mem.read(end - start)
# 搜索 DEX Magic
offset = 0
while True:
pos = data.find(dex_magic, offset)
if pos == -1:
break
# 读取 file_size(偏移 0x20,4 字节,小端序)
if pos + 0x24 <= len(data):
file_size = struct.unpack("<I", data[pos+0x20:pos+0x24])[0]
# 检查 file_size 是否合理(防止误报)
if 0x1000 < file_size < 100 * 1024 * 1024: # 4KB ~ 100MB
print(f"[+] 找到 DEX @ 0x{start+pos:08x}, size={file_size}")
if pos + file_size <= len(data):
dex_data = data[pos:pos+file_size]
out_path = f"{output_dir}/dump_{pid}_{dumped}.dex"
with open(out_path, "wb") as out:
out.write(dex_data)
print(f"[*] 已 dump 到 {out_path}")
dumped += 1
offset = pos + 1
except Exception as e:
continue
print(f"\n[*] 共 dump {dumped} 个 DEX 文件")
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"用法: {sys.argv[0]} <pid>")
sys.exit(1)
search_and_dump(int(sys.argv[1]))
使用方法:
# 1. 在模拟器或 root 设备上运行
adb shell
su
# 2. 找到目标进程 PID
ps | grep com.target.app
# 3. 执行 dump 脚本
python3 /data/local/tmp/dex_dumper.py 12345
注意:内存搜索法可能会 dump 出系统自带的 DEX 文件(如 framework.dex),需要根据大小和内容进行筛选。通常原始应用的 DEX 会大于壳 DEX。
方法二:Hook dvmDexFileOpen
通过 Hook dvmDexFileOpen 函数,在壳代码调用该函数加载解密后的 DEX 时,拦截参数中的 DEX 数据并直接 dump。
这种方式的优势是精准——只有被打开的 DEX 文件才会被 dump,避免了内存搜索中的噪声。
Xposed 模块实现:
package com.unpack.dexdumper;
import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
public class DexDumperHook implements IXposedHookLoadPackage {
private static final String TARGET_PKG = "com.target.app";
private static final String DUMP_DIR = "/data/local/tmp/dex_dump";
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) {
if (!lpparam.packageName.equals(TARGET_PKG)) return;
XposedBridge.log("[DexDumper] 目标包: " + lpparam.packageName);
try {
// Hook dvmDexFileOpen
XposedHelpers.findAndHookMethod(
"dalvik.system.DexFile",
lpparam.classLoader,
"openDexFile",
String.class, // sourceName
byte[].class, // dexBytes (部分版本存在此重载)
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log("[DexDumper] openDexFile 被调用");
// 参数处理和 dump 逻辑
dumpDexFromParam(param);
}
}
);
} catch (Exception e) {
XposedBridge.log("[DexDumper] Hook 失败: " + e.getMessage());
}
}
private void dumpDexFromParam(XC_MethodHook.MethodHookParam param) {
try {
// 获取 DEX 字节数据
byte[] dexBytes = (byte[]) param.args[1];
if (dexBytes == null || dexBytes.length == 0) return;
// 验证 DEX Magic
String magic = new String(dexBytes, 0, 8);
if (!magic.startsWith("dex\n")) {
XposedBridge.log("[DexDumper] 非 DEX 数据,跳过");
return;
}
// dump 到文件
String fileName = DUMP_DIR + "/hook_" +
System.currentTimeMillis() + ".dex";
FileOutputStream fos = new FileOutputStream(fileName);
fos.write(dexBytes);
fos.close();
XposedBridge.log("[DexDumper] DEX 已 dump: " + fileName +
" (size=" + dexBytes.length + ")");
} catch (Exception e) {
XposedBridge.log("[DexDumper] dump 失败: " + e.getMessage());
}
}
}
方法三:Hook dexopt 过程
在 Dalvik 时代,APK 安装时系统会执行 dexopt 对 DEX 进行优化(生成 odex 文件)。对于加壳应用,dexopt 处理的是壳 DEX,但我们可以 Hook dexopt 流程来干预优化过程:
// Hook dalvik.system.DexFile 的 openInMemoryDex 或相关内部方法
// 在 DEX 优化前拦截原始数据
// 这种方式适用于需要分析壳 DEX 结构的场景
不过需要注意,dexopt Hook 方式在脱壳实践中使用相对较少,因为 dexopt 在安装阶段运行,此时壳代码尚未解密原始 DEX。
DEX 修复
从内存中 dump 出的 DEX 文件通常存在校验问题,因为壳程序在解密过程中可能修改了部分字段。我们需要进行修复才能正常使用 JADX 等工具进行反编译。
需要修复的字段
| 字段 | 偏移 | 大小 | 说明 |
|---|---|---|---|
| checksum | 0x08 | 4 | Adler32 校验和,覆盖整个文件 |
| signature | 0x0C | 20 | SHA-1 哈希,从 0x20 开始到文件末尾 |
| file_size | 0x20 | 4 | DEX 文件总大小 |
DEX 修复脚本
#!/usr/bin/env python3
"""
DEX 修复工具
修复从内存 dump 的 DEX 文件的 checksum 和 signature
"""
import hashlib
import struct
import sys
import zlib
def fix_dex(input_path, output_path=None):
with open(input_path, "rb") as f:
data = bytearray(f.read())
# 验证 Magic
if not data[:4] == b"dex\n":
print("[-] 不是有效的 DEX 文件")
return False
# 1. 修复 file_size(偏移 0x20)
actual_size = len(data)
struct.pack_into("<I", data, 0x20, actual_size)
print(f"[*] file_size 修复为: {actual_size}")
# 2. 修复 signature(SHA-1,偏移 0x0C,计算范围从 0x20 到文件末尾)
sha1 = hashlib.sha1(bytes(data[0x20:])).digest()
data[0x0C:0x20] = sha1
print(f"[*] signature 已更新: {sha1.hex()}")
# 3. 修复 checksum(Adler32,偏移 0x08,计算范围从 0x0C 到文件末尾)
checksum = zlib.adler32(bytes(data[0x0C:])) & 0xFFFFFFFF
struct.pack_into("<I", data, 0x08, checksum)
print(f"[*] checksum 已更新: 0x{checksum:08x}")
# 写入文件
out = output_path or input_path.replace(".dex", "_fixed.dex")
with open(out, "wb") as f:
f.write(data)
print(f"[+] DEX 已修复并保存到: {out}")
return True
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"用法: {sys.argv[0]} <input.dex> [output.dex]")
sys.exit(1)
fix_dex(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)
使用方式:
python3 fix_dex.py dump_12345_0.dex dump_fixed.dex
# 然后用 JADX 打开 dump_fixed.dex
实战案例:对第一代壳 APP 进行内存 dump 脱壳
下面通过一个完整的实战案例,演示如何在 Dalvik 环境下对第一代壳(整体加密型)APP 进行脱壳。
环境准备
- 设备:Android 4.4(Dalvik 虚拟机),已 Root
- 工具:DDMS、ADB、Python 脚本
- 目标:某第一代壳保护的应用(DEX 整体加密)
操作步骤
第一步:确认运行环境
# 确认目标设备使用 Dalvik
adb shell getprop ro.build.version.sdk
# 输出 19 或更低表示 Dalvik
# 确认设备已 Root
adb shell su -c "id"
# uid=0(root) 表示已 Root
第二步:启动目标应用
# 通过 ADB 启动目标应用
adb shell am start -n com.target.app/.MainActivity
第三步:获取 PID 并 dump
# 找到目标进程 PID
adb shell su -c "ps | grep com.target.app"
# 输出: u0_a123 12345 ... com.target.app
# 推送 dump 脚本
adb push dex_dumper.py /data/local/tmp/
adb shell chmod 755 /data/local/tmp/dex_dumper.py
# 执行 dump(等待几秒让壳代码完成解密)
adb shell su -c "sleep 5 && python3 /data/local/tmp/dex_dumper.py 12345"
第四步:拉取并分析
# 拉取 dump 出的文件
adb pull /data/local/tmp/dump_12345_0.dex ./
adb pull /data/local/tmp/dump_12345_1.dex ./
# 修复 DEX
python3 fix_dex.py dump_12345_0.dex original_fixed.dex
# 使用 JADX 反编译
jadx-gui original_fixed.dex
第五步:验证脱壳结果
在 JADX 中检查反编译结果:
- 查看包名是否为原始应用的包名(而非壳的包名)
- 检查 Activity、Service 等组件是否完整
- 搜索关键业务逻辑代码是否存在
- 如果代码能正常阅读且逻辑完整,说明脱壳成功
常见问题排查
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| dump 出的文件无法被 JADX 打开 | DEX 头损坏 | 检查是否多 dump 了偏移字节,调整搜索偏移 |
| 反编译后代码不完整 | dump 时机过早,壳尚未完全解密 | 增加等待时间或使用 Hook 方式精确捕获 |
| dump 出多个 DEX,不确定哪个是原始 DEX | 壳程序本身也产生 DEX | 根据 file_size 和包名筛选,大的通常是原始 DEX |
| 运行时报 checksum 错误 | DEX 校验未修复 | 使用 fix_dex.py 修复 |
Dalvik 脱壳的局限性
Dalvik 脱壳技术在 Android 5.0(Lollipop)之前非常有效,但存在以下局限性:
1. ART 时代的挑战
从 Android 5.0 开始,系统默认使用 ART 虚拟机替代 Dalvik。ART 在安装阶段通过 dex2oat 将 DEX 编译为 OAT(基于 ELF 格式):
- 运行时不再保留完整的 DEX 结构
- 内存中的代码已经是编译后的机器码
dvmDexFileOpen等 Dalvik 函数不再存在
这使得传统的内存搜索和 Hook 方式失效。
2. 第二代壳和第三代壳的演进
壳技术也在不断进化:
- 第二代壳(Dex 抽取壳):将 DEX 中方法体的字节码抽取到单独的 so 库中,运行时动态填充。即使 dump 出 DEX,方法体也是空的。
- 第三代壳(VMP/混淆壳):将 Java 方法转换为 Native 代码执行,DEX 中甚至没有方法体的占位。
3. 反调试和完整性检测
现代壳增加了更多对抗手段:
- 检测 Hook 框架(Xposed、Frida)
- 检测调试器附加
- 内存完整性校验(检测是否被 dump)
- 反内存读取保护
4. 从 Dalvik 到 ART 的脱壳技术演进
| 时代 | 代表壳类型 | 脱壳方案 |
|---|---|---|
| Dalvik(< 5.0) | 第一代整体加密 | 内存搜索、Hook dvmDexFileOpen |
| 早期 ART(5.0-6.0) | 第一代/第二代 | Hook LoadClass、内存搜索 OAT |
| 现代 ART(7.0+) | 第二代/第三代 | 指令修复、自定义 ClassLoader Hook、Frida il2cpp dump |
尽管Dalvik 脱壳技术已逐渐退出历史舞台,但理解其原理是学习现代脱壳技术的基础。Dalvik 时代的内存搜索思路、Hook 拦截思想、DEX 结构知识,在 ART 脱壳中依然适用——只是具体的 Hook 点和数据结构发生了变化。
总结
本文介绍了 Dalvik 虚拟机下通用的 DEX 脱壳技术,核心要点包括:
- DEX 加载流程是脱壳的理论基础——理解
dvmJarOpen → dvmDexFileOpen → dexFileParse → loadClassesFromDex链路至关重要 - 内存搜索法是最简单的通用方案,通过搜索 DEX Magic 定位内存中的明文 DEX
- Hook dvmDexFileOpen 是最精准的方案,在 DEX 被打开时直接拦截数据
- DEX 修复是脱壳后必不可少的步骤,需要修复 checksum、signature 和 file_size
- Dalvik 脱壳在 ART 时代受到限制,但其核心思想(内存 dump、Hook 拦截)延续至今
掌握了 Dalvik 下的脱壳原理,你就为学习更复杂的 ART 脱壳技术打下了坚实的基础。