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 中检查反编译结果:

  1. 查看包名是否为原始应用的包名(而非壳的包名)
  2. 检查 Activity、Service 等组件是否完整
  3. 搜索关键业务逻辑代码是否存在
  4. 如果代码能正常阅读且逻辑完整,说明脱壳成功

常见问题排查

现象 可能原因 解决方案
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 脱壳技术,核心要点包括:

  1. DEX 加载流程是脱壳的理论基础——理解 dvmJarOpen → dvmDexFileOpen → dexFileParse → loadClassesFromDex 链路至关重要
  2. 内存搜索法是最简单的通用方案,通过搜索 DEX Magic 定位内存中的明文 DEX
  3. Hook dvmDexFileOpen 是最精准的方案,在 DEX 被打开时直接拦截数据
  4. DEX 修复是脱壳后必不可少的步骤,需要修复 checksum、signature 和 file_size
  5. Dalvik 脱壳在 ART 时代受到限制,但其核心思想(内存 dump、Hook 拦截)延续至今

掌握了 Dalvik 下的脱壳原理,你就为学习更复杂的 ART 脱壳技术打下了坚实的基础。