在 ART 下抽取壳的实现

什么是抽取壳

在 Android 安全领域,抽取壳(Extraction Packer) 是一种常见且经典的加固方案。它的核心思想非常直观——把 DEX 文件中每个方法的字节码(机器指令)“抽走”,只留一个空壳。当应用运行时,壳程序再将这些字节码动态地填回原位,使程序能够正常执行。

具体来说,抽取壳会:

  1. 解析 classes.dex,找到每个方法对应的 code_item 结构体
  2. 提取 code_item 中的字节码数据(即 Dalvik 字节码指令序列)
  3. 清零原方法在 DEX 中的 code_off 字段,使其指向 0(无效地址)
  4. 将提取的字节码加密存储到 assets 目录或 SO 库中
  5. 运行时由壳 Application 负责解密并恢复

经过抽取壳处理后的 APK,用 jadx 等反编译工具打开,你会发现所有方法体都是空的——这正是抽取壳最显著的特征。

DEX 文件格式基础

要理解抽取壳的原理,首先需要了解 DEX 文件中与代码存储相关的几个关键数据结构。

method_id_item

method_id_item 是 DEX 文件中的方法索引表,记录了每个方法的声明信息:

method_id_item {
    ushort class_idx;    // 所属类的类型索引
    ushort proto_idx;    // 方法原型索引
    uint   name_idx;     // 方法名索引
}

它本身不包含方法体,只是方法的"身份证"。

code_item

code_item 才是真正存放方法字节码的结构体,它被 DEX 文件中的 data 区域引用:

code_item {
    ushort registers_size;     // 使用的寄存器数量
    ushort ins_size;           // 方法参数占用的寄存器数
    ushort outs_size;          // 调用其他方法时需要的参数寄存器数
    ushort tries_size;         // try-catch 块的数量
    uint   debug_info_off;     // 调试信息偏移
    uint   insns_size;         // 指令条数(以 16-bit code unit 为单位)
    ushort insns[insns_size];  // 实际的字节码指令数组
}

其中 insns 字段就是抽取壳的目标——Dalvik 字节码指令序列。

关联关系

class_def_itemclass_data_item 中,每个 encoded_method 都包含一个 code_off 字段,指向该方法的 code_item 在 DEX 文件中的偏移地址:

encoded_method {
    uint   method_idx_diff;   // 方法索引(差值编码)
    uint   access_flags;      // 访问标志(public、static 等)
    uint   code_off;          // 指向 code_item 的偏移量
}

抽取壳的核心操作就是:把 code_off 设为 0,让虚拟机找不到方法体。

抽取壳的实现流程

下面按照 APK 打包阶段和运行时阶段,完整梳理抽取壳的实现流程。

打包阶段:抽取字节码

┌─────────────────────────────────────┐
│         原始 classes.dex             │
│  ┌─────────┐  ┌─────────┐          │
│  │method_A │  │method_B │  ...     │
│  │code_item│  │code_item│          │
│  │(完整)   │  │(完整)   │          │
│  └─────────┘  └─────────┘          │
└──────────────┬──────────────────────┘
               │
               ▼ 加固工具处理
┌─────────────────────────────────────┐
│       处理后的 classes.dex           │
│  ┌─────────┐  ┌─────────┐          │
│  │method_A │  │method_B │  ...     │
│  │code_off=0│  │code_off=0│         │
│  │(空壳)   │  │(空壳)   │          │
│  └─────────┘  └─────────┘          │
└─────────────────────────────────────┘
               +
┌─────────────────────────────────────┐
│    加密的字节码数据 (assets/bin.dat)  │
│  [method_A_code] [method_B_code]... │
└─────────────────────────────────────┘

具体步骤如下:

第一步:解析 DEX 文件

读取 classes.dex,解析 DEX 头部获取 string_idstype_idsmethod_idsclass_defs 等各段偏移。

第二步:遍历所有方法的 code_item

通过 class_defsclass_data_itemencoded_methodcode_off,定位到每个方法的 code_item

第三步:提取并存储字节码

将每个 code_item 的完整数据(包括寄存器数量、指令数组等)提取出来,经过加密后存放到 assets 目录或嵌入 SO 文件中。为了运行时能对应恢复,需要记录每个方法与其字节码的映射关系(通常用方法的 dex_method_index 作为 key)。

第四步:清零 code_off

将原 DEX 中每个 encoded_methodcode_off 字段置 0,这样虚拟机在加载 DEX 时就找不到方法体了。同时,原始 code_item 占用的空间可以被清零或移除以减小体积。

第五步:生成壳 Application

创建一个自定义 Application 类替换原始的 Application,在 attachBaseContext() 中执行运行时修复逻辑。

运行时阶段:动态填充

壳 Application 在 attachBaseContext() 中的工作流程:

public class ShellApplication extends Application {
    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);

        // 1. 读取加密的字节码数据
        byte[] encryptedData = readEncryptedCodeFromAssets("bin.dat");

        // 2. 解密得到原始字节码映射
        Map<Integer, byte[]> methodCodeMap = decryptCodeData(encryptedData);

        // 3. 遍历已加载的类,找到被抽取的方法
        // 4. 将字节码写回内存中的 code_item,或修改 ArtMethod 的入口
        fixMethods(methodCodeMap);
    }
}

在 ART 虚拟机下,修复方法有两种主要途径:

方式一:内存修改 code_item

直接在内存中定位被清零的 code_item 区域,将原始字节码写回去。这种方式要求精确计算内存偏移:

// 伪代码:在 native 层修改内存中的 code_item
void fixCodeItem(ArtMethod* method, byte* originalCode, size_t codeSize) {
    if (method->GetCodeItem() == nullptr) {
        // code_off 为 0,code_item 不存在,需要手动分配
        CodeItem* newCodeItem = allocCodeItem(codeSize);
        memcpy(newCodeItem->insns_, originalCode, codeSize);
        method->SetCodeItem(newCodeItem);
    } else {
        // code_item 存在但被清零,直接覆盖
        memcpy(method->GetCodeItem()->insns_, originalCode, codeSize);
    }
}

方式二:修改 ArtMethod 的 entry_point_

ART 虚拟机中,每个 ArtMethod 都有一个 entry_point_from_quick_compiled_code_ 字段,指向方法的机器码入口。壳可以直接修改这个指针,使其指向解密后的代码:

// 伪代码:修改 ArtMethod 的入口点
void hookMethod(ArtMethod* method, void* newEntryPoint) {
    // 将 entry_point 指向我们自己解密出来的代码
    method->entry_point_from_quick_compiled_code_ =
        reinterpret_cast<void*>(newEntryPoint);
}

这种方式更直接,但需要处理好执行模式的切换(解释执行 vs 编译执行)。

Python 示例:读取 DEX 并提取 code_item

下面用一个 Python 脚本演示如何解析 DEX 文件并提取方法的 code_item 数据:

import struct
import os

class DexParser:
    def __init__(self, dex_path):
        with open(dex_path, 'rb') as f:
            self.data = f.read()
        self.parse_header()

    def parse_header(self):
        """解析 DEX 文件头"""
        magic = self.data[0:8]
        assert magic.startswith(b'dex\n'), "不是有效的 DEX 文件"

        # 使用小端序读取字段
        self.string_ids_size = struct.unpack_from('<I', self.data, 0x38)[0]
        self.string_ids_off = struct.unpack_from('<I', self.data, 0x3C)[0]
        self.method_ids_size = struct.unpack_from('<I', self.data, 0x60)[0]
        self.method_ids_off = struct.unpack_from('<I', self.data, 0x64)[0]
        self.class_defs_size = struct.unpack_from('<I', self.data, 0x60)[0]
        self.class_defs_off = struct.unpack_from('<I', self.data, 0x64)[0]

    def read_string(self, string_idx):
        """根据索引读取字符串"""
        str_off = struct.unpack_from('<I', self.data,
            self.string_ids_off + string_idx * 4)[0]
        # ULEB128 编码的长度 + MUTF-8 字符串
        size = self.data[str_off]
        start = str_off + 1
        end = start + size
        return self.data[start:end].decode('utf-8', errors='replace')

    def extract_code_items(self):
        """提取所有方法的 code_item"""
        results = []
        class_defs_off = 0x60
        class_defs_size = struct.unpack_from('<I', self.data, class_defs_off)[0]
        class_defs_off = struct.unpack_from('<I', self.data, class_defs_off + 4)[0]

        for i in range(class_defs_size):
            offset = class_defs_off + i * 32
            class_data_off = struct.unpack_from('<I', self.data, offset + 24)[0]
            if class_data_off == 0:
                continue

            # 解析 class_data_item(简化处理)
            pos = class_data_off
            static_fields_size = self.read_uleb128(pos); pos = self._next_pos(pos)
            instance_fields_size = self.read_uleb128(pos); pos = self._next_pos(pos)
            direct_methods_size = self.read_uleb128(pos); pos = self._next_pos(pos)
            virtual_methods_size = self.read_uleb128(pos); pos = self._next_pos(pos)

            # 跳过字段,处理方法
            for _ in range(static_fields_size + instance_fields_size):
                pos = self._skip_field(pos)

            # 提取 direct methods 的 code_item
            for j in range(direct_methods_size):
                method_idx_diff, access_flags, code_off, pos = \
                    self.read_encoded_method(pos)
                if code_off != 0:
                    code_item = self.parse_code_item(code_off)
                    results.append({
                        'method_idx': method_idx_diff,
                        'code_off': code_off,
                        'insns_size': code_item['insns_size'],
                        'code_bytes': code_item['insns']
                    })

            # 提取 virtual methods 的 code_item(同上)
            for j in range(virtual_methods_size):
                method_idx_diff, access_flags, code_off, pos = \
                    self.read_encoded_method(pos)
                if code_off != 0:
                    code_item = self.parse_code_item(code_off)
                    results.append({
                        'method_idx': method_idx_diff,
                        'code_off': code_off,
                        'insns_size': code_item['insns_size'],
                        'code_bytes': code_item['insns']
                    })

        return results

    def parse_code_item(self, offset):
        """解析单个 code_item"""
        registers_size = struct.unpack_from('<H', self.data, offset)[0]
        ins_size = struct.unpack_from('<H', self.data, offset + 2)[0]
        outs_size = struct.unpack_from('<H', self.data, offset + 4)[0]
        tries_size = struct.unpack_from('<H', self.data, offset + 6)[0]
        debug_info_off = struct.unpack_from('<I', self.data, offset + 8)[0]
        insns_size = struct.unpack_from('<I', self.data, offset + 12)[0]
        insns_start = offset + 16
        insns = self.data[insns_start : insns_start + insns_size * 2]
        return {
            'registers_size': registers_size,
            'ins_size': ins_size,
            'outs_size': outs_size,
            'tries_size': tries_size,
            'insns_size': insns_size,
            'insns': insns
        }

    def read_uleb128(self, pos):
        result = 0
        shift = 0
        while True:
            byte = self.data[pos]
            result |= (byte & 0x7F) << shift
            pos += 1
            if (byte & 0x80) == 0:
                break
            shift += 7
        return result

    def _next_pos(self, pos):
        while True:
            if (self.data[pos] & 0x80) == 0:
                return pos + 1
            pos += 1

    def _skip_field(self, pos):
        pos = self._next_pos(pos)  # field_idx_diff
        pos = self._next_pos(pos)  # access_flags
        return pos

    def read_encoded_method(self, pos):
        method_idx_diff = self.read_uleb128(pos); pos = self._next_pos(pos)
        access_flags = self.read_uleb128(pos); pos = self._next_pos(pos)
        code_off = self.read_uleb128(pos); pos = self._next_pos(pos)
        return method_idx_diff, access_flags, code_off, pos


# 使用示例
if __name__ == '__main__':
    parser = DexParser('classes.dex')
    items = parser.extract_code_items()
    print(f"共提取 {len(items)} 个方法的 code_item")

    for item in items[:5]:  # 打印前 5 个方法的信息
        print(f"  method_idx={item['method_idx']}, "
              f"code_off=0x{item['code_off']:X}, "
              f"insns_size={item['insns_size']}, "
              f"code_len={len(item['code_bytes'])} bytes")

这个脚本展示了 DEX 解析的核心流程。在实际的抽取壳工具中,会在此基础上将提取出的 code_item 数据加密存储,并在运行时解密恢复。

如何识别抽取壳

识别一个 APP 是否使用了抽取壳,最简单的方法是使用反编译工具查看:

使用 jadx 检测

jadx -d output/ target.apk

打开反编译后的代码,如果发现大部分方法体为空,如下所示:

public class MainActivity extends AppCompatActivity {
    protected void onCreate(Bundle bundle) {
        // 方法体为空——被抽取了
    }

    private void initViews() {
        // 方法体为空
    }
}

这几乎可以确定使用了抽取壳。

使用 dex2jar + JD-GUI 检测

d2j-dex2jar.sh classes.dex
jd-gui classes-dex2jar.jar

如果反编译结果中方法体只有 return 或完全为空,同样是抽取壳的标志。

其他特征

  • DEX 文件异常小:原始 DEX 在去掉所有 code_item 后体积会显著减小
  • assets 目录有可疑的加密文件:提取的字节码通常以加密形式存放在 assets 中
  • 自定义 Application 类:壳需要入口来执行运行时修复

抽取壳的局限性

尽管抽取壳实现简单且一度广泛使用,但它存在明显的局限性:

1. 不能抽取 native 方法

Native 方法(native 关键字修饰)的实现在 SO 库中,不在 DEX 的 code_item 里,因此无法被抽取。

2. 构造函数处理困难

<init><clinit>(实例初始化和类初始化)方法在类加载阶段就会被调用。如果这些方法也被抽取,壳需要在非常早期的时间点完成修复,否则类加载就会失败。很多抽取壳会跳过这些方法,留下未加固的突破口。

3. 反射调用和动态代理

通过反射可以获取 ArtMethod 对象,壳需要确保在修复完成之前,这些方法不会被反射调用,否则会触发 AbstractMethodError

4. 脱壳相对容易

相比其他加固方案,抽取壳的脱壳难度较低。常用的脱壳方法包括:

  • Frida Hook:Hook ArtMethod::Invoke,在方法第一次被调用时从解释器缓冲区中 dump 字节码
  • 内存 Dump:在壳修复完成后,直接从内存中 dump 完整的 DEX
  • dex2oat 分析:如果应用进行了 AOT 编译,可以从 OAT 文件中提取编译后的代码

5. ART 虚拟机的兼容性

不同 Android 版本的 ART 虚拟机在内存布局上有所差异。ArtMethod 的字段偏移、code_item 的内存管理方式都可能变化,壳开发者需要针对不同版本做适配。

总结

抽取壳是 Android 加固技术中最基础的方案之一,其原理并不复杂——将 DEX 中方法的字节码抽离,运行时再动态恢复。理解抽取壳对于逆向工程师来说是基本功,因为:

  1. 它帮助我们深入理解 DEX 文件格式和 ART 虚拟机的内部机制
  2. 掌握抽取壳的分析方法后,可以快速识别和绕过这类保护
  3. 抽取壳的原理是学习更复杂壳技术(如 VMP、Dex 加密壳)的基础

在实际的安全分析工作中,遇到抽取壳不用畏惧。使用 Frida 等工具,配合对 ART 虚拟机的理解,通常可以在较短时间内完成脱壳并获取原始代码。