目录
  1. 1. 前言
  2. 2. 一、为什么是 DEX?—— CLASS vs DEX 深度对比
    1. 2.1. 1.1 空间效率
    2. 2.2. 1.2 指令集差异
    3. 2.3. 1.3 常量共享机制
  3. 3. 二、DEX 文件整体结构
    1. 3.1. 2.1 顶层布局
  4. 4. 三、dex_header 逐字段解析
    1. 4.1. 3.1 完整 header hex dump(示意性示例)
    2. 4.2. 3.2 magic[8] — 版本魔数
    3. 4.3. 3.3 checksum — adler32 校验
    4. 4.4. 3.4 signature — SHA-1 签名
    5. 4.5. 3.5 endian_tag — 字节序标记
    6. 4.6. 3.6 各区域的 size/offset 对
    7. 4.7. 3.7 link_size / link_off
  5. 5. 四、LEB128 编码
    1. 5.1. 4.1 编码规则
    2. 5.2. 4.2 Unsigned LEB128 (ULEB128)
    3. 5.3. 4.3 Signed LEB128 (SLEB128)
    4. 5.4. 4.4 在 DEX 中的使用场合
  6. 6. 五、string_ids 与 MUTF-8
    1. 6.1. 5.1 string_id_item 结构
    2. 6.2. 5.2 string_data_item
    3. 6.3. 5.3 MUTF-8(Modified UTF-8)
    4. 6.4. 5.4 Python 解析 string_ids
  7. 7. 六、type_ids, proto_ids, field_ids, method_ids
    1. 7.1. 6.1 type_id_item — 类型索引
    2. 7.2. 6.2 proto_id_item — 方法原型
    3. 7.3. 6.3 field_id_item — 字段定义
    4. 7.4. 6.4 method_id_item — 方法定义
    5. 7.5. 6.5 索引查找示例
  8. 8. 七、class_def_item 完整解析
    1. 8.1. 7.1 access_flags 位掩码
    2. 8.2. 7.2 class_data_item 结构
      1. 8.2.1. encoded_method 结构
      2. 8.2.2. encoded_field 结构
    3. 8.3. 7.3 代码体:code_item
      1. 8.3.1. Dalvik 指令格式
    4. 8.4. 7.4 try/catch 编码
      1. 8.4.1. try_item 结构
      2. 8.4.2. encoded_catch_handler_list 结构
      3. 8.4.3. encoded_catch_handler 结构
      4. 8.4.4. encoded_catch_handler_item 结构
      5. 8.4.5. catch-all 的编码
      6. 8.4.6. 完整示例
      7. 8.4.7. 解析注意事项
    5. 8.5. 7.5 调试信息:debug_info_item
      1. 8.5.1. 状态机模型
      2. 8.5.2. debug_info_item 的完整布局
      3. 8.5.3. 核心 opcode 列表
      4. 8.5.4. 特殊 opcode(0x0A – 0xFF)
      5. 8.5.5. 解码示例
      6. 8.5.6. 逆向工程中的实际用途
  9. 9. 八、注解格式(Annotations)
    1. 9.1. 8.1 顶层入口:annotations_directory_item
      1. 9.1.1. field_annotation 结构
      2. 9.1.2. method_annotation 结构
      3. 9.1.3. parameter_annotation 结构
    2. 9.2. 8.2 annotation_set_ref_list
    3. 9.3. 8.3 annotation_set_item
    4. 9.4. 8.4 annotation_item
    5. 9.5. 8.5 encoded_annotation 与 annotation_element
    6. 9.6. 8.6 完整注解引用链
    7. 9.7. 8.7 逆向实务笔记
  10. 10. 九、编码值格式(Encoded Value)
    1. 10.1. 9.1 encoded_value 结构
    2. 10.2. 9.2 完整 value_type 枚举
    3. 10.3. 9.3 关键编码细节
    4. 10.4. 9.4 解析示例
    5. 10.5. 9.5 static_values_off 的编码
  11. 11. 十、map_list — 自描述目录
    1. 11.1. 常见 MapItem type 值
  12. 12. 十一、Multi-DEX 与 64K 限制
    1. 12.1. 9.1 64K 问题的根源
    2. 12.2. 9.2 MultiDex 加载机制
    3. 12.3. 9.3 各 DEX 之间的关系
  13. 13. 十二、Compact DEX(cdex)概述
    1. 13.1. 12.1 设计动机
    2. 13.2. 12.2 与标准 DEX 的关键差异
    3. 13.3. 12.3 cdex 的布局特征
    4. 13.4. 12.4 逆向工程注意事项
  14. 14. 十三、完整 Python DEX 解析器
  15. 15. 十四、DEX 文件验证机制
    1. 15.1. 14.1 验证阶段概览
    2. 15.2. 14.2 Header 校验
    3. 15.3. 14.3 区域布局校验
    4. 15.4. 14.4 类型系统校验
    5. 15.5. 14.5 字节码指令校验
    6. 15.6. 14.6 常见验证错误及其逆向含义
    7. 15.7. 14.7 验证器在加固对抗中的角色
  16. 16. 十五、逆向实战应用
    1. 16.1. 15.1 字符串提取
    2. 16.2. 15.2 方法枚举与代码定位
    3. 16.3. 15.3 加固检测
    4. 16.4. 15.4 多 DEX 环境下类的归属识别
    5. 16.5. 15.5 检测字节码篡改
    6. 16.6. 15.6 加固壳的 DEX Header 篡改手法
    7. 16.7. 15.7 使用 010 Editor 进行 DEX 结构可视化分析
  17. 17. 面试常考问题
  18. 18. 参考
【逆向安全技术-基础篇】dex文件格式解析

前言

DEX(Dalvik Executable)是 Android 运行时执行的核心文件格式。每一个 APK 中的 Java/Kotlin 源码,经 javac 编译为 .class 文件,再由 d8(或旧版 dx)工具链合并、优化为 classes.dex掌握 DEX 二进制格式是 Android 逆向工程的地基——从字符串提取、方法定位、代码注入到脱壳分析,所有操作最终都落在对 DEX 结构的理解上。

本文以 Google AOSP 源码为参考基准,从二进制层面逐字段解析 DEX 文件结构,附完整 Python 解析器实现。

参考版本: Android 15 (AOSP mainline), ART runtime dex_file.h


一、为什么是 DEX?—— CLASS vs DEX 深度对比

JVM 规范定义的 .class 文件每个 Java 源文件独立编译为一个 .class,每个文件携带完整常量池。Android 选择了截然不同的路线:将所有 .class 合并并重新编码为 .dex

1.1 空间效率

以 AOSP framework.jar 为例,其 10000+ 个 .class 文件中存在大量重复的字符串常量(如 Ljava/lang/Object;Ljava/lang/String; 等)和类型引用。DEX 将这些常量归并去重,全局只保留一份。

实测数据(Android 14 framework.jar):

指标 CLASS(JAR) DEX 压缩比
文件总大小 ~45 MB ~23 MB ~50%
常量池条目 重复率 >60% 无重复
方法数上限 无限制 64K/dex

1.2 指令集差异

这是最本质的区别:

  • CLASS 使用栈式指令:操作数隐式位于操作数栈上,指令短但完成一件事需要多条指令。
  • DEX 使用16 位寄存器式指令:操作数通过虚拟寄存器寻址,指令更长但单条指令完成更多工作。
// Java 源码
int result = a + b;

// CLASS (JVM 栈式) —— 5条指令
iload_1 // 将局部变量 a 压栈
iload_2 // 将局部变量 b 压栈
iadd // 弹出两个值相加,结果压栈
istore_3 // 弹出结果存入局部变量 result
// DEX (Dalvik 寄存器式) —— 1条指令
add-int v0, v1, v2 # v0 = v1 + v2

DEX 文件的关键限制在于 method_ids 表。method_id_itemclass_idxproto_idx 字段均为 uint16_t(16 位),因此单个 DEX 文件最多可定义 2^16 = 65536 个方法引用。更重要的是,所有 invoke-* 类指令的操作数中,method_idx 是一个 16 位无符号整数域,这从根本上限制了单个 DEX 文件可调用的方法数上限。这就是著名的 64K 方法数限制——注意这是每个 DEX 文件的限制,而非整个 APK 的限制;Multi-DEX 机制正是通过拆分到多个 DEX 文件来绕过此限制。字符串索引(string_ids)和字段索引(field_ids)在某些上下文中可以使用 32 位偏移,因此不受此 16 位限制的直接影响。

1.3 常量共享机制

DEX 将字符串常量池(string_ids)、类型常量池(type_ids)、方法原型池(proto_ids)等全局统一索引。同一个字符串 "onCreate" 无论出现在多少个类中,DEX 只存储一次:

CLASS 方式:
A.class 常量池: UTF8[5] = "onCreate"
B.class 常量池: UTF8[12] = "onCreate"
C.class 常量池: UTF8[8] = "onCreate"
总计: 3 次存储

DEX 方式:
string_ids[42] → "onCreate"
A,B,C 的方法引用均指向 string_ids[42]
总计: 1 次存储

二、DEX 文件整体结构

一个 DEX 文件的布局由一个固定大小的 header 和若干变长的数据区域组成,header 中的 size/offset 字段对提供各区域的定位信息。

2.1 顶层布局

offset 0x0000
┌─────────────────────┐
│ dex_header │ 固定 0x70 (112) 字节
│ 0x00 - 0x6F │
├─────────────────────┤ ← string_ids_off (通常 = 0x70)
│ string_id_item[] │ 每条 4 字节,指向 data 区中的字符串
├─────────────────────┤ ← type_ids_off
│ type_id_item[] │ 每条 4 字节,指向 string_ids 索引
├─────────────────────┤ ← proto_ids_off
│ proto_id_item[] │ 每条 12 字节
├─────────────────────┤ ← field_ids_off
│ field_id_item[] │ 每条 8 字节
├─────────────────────┤ ← method_ids_off
│ method_id_item[] │ 每条 8 字节
├─────────────────────┤ ← class_defs_off
│ class_def_item[] │ 每条 32 字节
├─────────────────────┤ ← data_off
│ │
│ data 区 │ 字符串数据、注解、class_data、code_item…
│ │
├─────────────────────┤ ← map_off
│ map_list │ 类型 → 偏移量 的目录表
└─────────────────────┘

map_off 指向的 map_list 是整个 DEX 的自描述目录,列出每种 item 类型的数量和偏移,用于校验文件完整性。


三、dex_header 逐字段解析

dex_header 固定 0x70 字节,是解析一切 DEX 数据的入口。以下使用一个真实的极小 DEX 文件(手写 HelloWorld,4 个类、15 个方法、60 个字符串)的 hex dump 进行逐字段分析。

3.1 完整 header hex dump(示意性示例)

注意:以下 hex dump 为示意性示例(illustrative example),用于展示各字段的相对位置和典型取值,并非来自某个真实的 DEX 文件。真实的 DEX 文件各字段值会根据实际内容变化,但结构布局与此一致。

Offset   Hex                                              ASCII
0x0000 64 65 78 0A 30 33 35 00 magic = "dex\n035\0"
0x0008 B9 4A 1A 9E checksum (adler32)
0x000C 90 6D F3 A2 3C 7E 1B 0E signature[0..7] (SHA-1)
0x0014 6B 85 3F 91 2F D9 44 8F signature[8..15]
0x0018 92 BD F4 14 signature[16..19]
0x001C 00 00 0A F8 file_size = 2808
0x0020 00 00 00 70 header_size = 112
0x0024 78 56 34 12 endian_tag = 0x12345678
0x0028 00 00 00 00 link_size = 0
0x002C 00 00 00 00 link_off = 0
0x0030 00 00 04 D0 map_off = 1232
0x0034 00 00 00 3C string_ids_size = 60
0x0038 00 00 00 70 string_ids_off = 112
0x003C 00 00 00 1E type_ids_size = 30
0x0040 00 00 01 60 type_ids_off = 352
0x0044 00 00 00 0B proto_ids_size = 11
0x0048 00 00 01 D8 proto_ids_off = 472
0x004C 00 00 00 05 field_ids_size = 5
0x0050 00 00 02 2C field_ids_off = 556
0x0054 00 00 00 0F method_ids_size = 15
0x0058 00 00 02 54 method_ids_off = 596
0x005C 00 00 00 04 class_defs_size = 4
0x0060 00 00 02 CC class_defs_off = 716
0x0064 00 00 05 A8 data_size = 1448
0x0068 00 00 02 CC data_off = 716
0x006C

3.2 magic[8] — 版本魔数

前 8 字节标识文件格式和版本:

64 65 78 0A  = "dex\n"     DEX 文件标识
30 33 35 00 = "035\0" 版本号 035

常用版本号(据 AOSP art/libdexfile/dex/dex_file.hdex_file_verifier.cc 验证):

版本 API Level 关键变化
035 1–13 Dalvik 标准版本,Android 从 1.0 开始使用至今的基准 DEX 格式
037 19 (KitKat) ART 引入,增强了 DEX 验证逻辑以支持 ART 的 AOT(Ahead-of-Time)编译器。注意:此版本不涉及默认方法(default method)支持——那是 Java 8 的特性,Android 4.4 尚未引入
038 24 (Nougat) 新增 invoke-polymorphicinvoke-custom 指令,用于支持 Java 8 语言特性:默认方法(default methods)、lambda 表达式、方法引用(method references)
039 28 (Pie) 新增 const-method-handleconst-method-type 指令用于方法句柄(method handles)。同时引入了隐藏 API 访问限制列表(hidden API access restriction lists)机制
040 35 (Android 15) 优化了 method handle 的调用路径,改进了 invoke-polymorphic 相关指令的执行性能

3.3 checksum — adler32 校验

0x08 处 4 字节是除去 magic 和此 checksum 自身外,文件剩余所有字节的 adler32 校验和。adler32 比 CRC32 更快但碰撞率略高。

import zlib
checksum = zlib.adler32(data[12:]) # 跳过 magic(8) + checksum(4)

3.4 signature — SHA-1 签名

0x0C 处 20 字节是对整个文件(除去 magic、checksum、signature 自身)的 SHA-1 哈希,用于确保文件完整性。在 APK 签名流程中,此字段被置零后计算整个文件的签名。

3.5 endian_tag — 字节序标记

0x24 处的 78 56 34 12 是特意选择的标记值 0x12345678。如果读取时发现是 0x78563412,则说明字节序与文件不一致,需要进行字节交换。所有 DEX 文件均使用小端序(little-endian)

3.6 各区域的 size/offset 对

每个数据区域由一对 _size(条目数量)和 _off(距文件头的字节偏移)字段描述:

区域 size 字段 off 字段 每条大小
string_ids 0x34 0x38 4 字节
type_ids 0x3C 0x40 4 字节
proto_ids 0x44 0x48 12 字节
field_ids 0x4C 0x50 8 字节
method_ids 0x54 0x58 8 字节
class_defs 0x5C 0x60 32 字节
data 0x64 0x68 变长

注意 data_size 表示 data 区总字节数,而非条目数。

用于静态链接场景。正常的 APK 中 DEX 文件这两个字段始终为 0。


四、LEB128 编码

DEX 文件中大量使用 LEB128(Little-Endian Base 128)变长编码来存储整数、长度等信息。理解 LEB128 是手动解析 DEX 的前提。

4.1 编码规则

每字节的低 7 位是数据位,最高位是延续标志:

  • bit 7 = 1:还有后续字节
  • bit 7 = 0:这是最后一字节
  • 小端序排列

4.2 Unsigned LEB128 (ULEB128)

数值 624485 的 ULEB128 编码:
624485 = 0x98765

二进制: 1001 1000 0111 0110 0101

按 7 位切分(从低位开始):
[110 0101] [000 1100] [001 1001]
↓ ↓ ↓
1110 0101 1000 1100 0001 1001
E 5 8 C 1 9

最终字节序列: E5 8C 19

4.3 Signed LEB128 (SLEB128)

有符号版本,符号位在最后一字节的 bit 6。

def read_uleb128(data, offset):
"""读取无符号 LEB128 整数"""
result = 0
shift = 0
while True:
byte = data[offset]
offset += 1
result |= (byte & 0x7F) << shift
if (byte & 0x80) == 0:
break
shift += 7
return result, offset

def read_sleb128(data, offset):
"""读取有符号 LEB128 整数"""
result = 0
shift = 0
while True:
byte = data[offset]
offset += 1
result |= (byte & 0x7F) << shift
shift += 7
if (byte & 0x80) == 0:
# 符号扩展
if shift < 32 and (byte & 0x40):
result |= -(1 << shift)
break
return result, offset

4.4 在 DEX 中的使用场合

场合 编码
字符串长度 ULEB128
class_data 中的字段/方法计数 ULEB128
debug_info 行号增量 SLEB128
encoded_value 中的整数值 SLEB128 或 ULEB128

五、string_ids 与 MUTF-8

5.1 string_id_item 结构

// AOSP: art/libdexfile/dex/dex_file_structs.h
struct StringId {
uint32_t string_data_off; // 相对 data 区起始的偏移
};

每个条目 4 字节,指向 data 区域中的一个 string_data_item

5.2 string_data_item

string_data_item {
uleb128 utf16_size; // 字符串的 UTF-16 码元数(非字节数!)
ubyte data[utf16_size]; // MUTF-8 编码的字符串数据
// MUTF-8 要求以 '\0' 结尾(LEB128 编码的长度不含此 '\0')
}

关键注意utf16_size 是字符串在 UTF-16 中的码元数,不是 MUTF-8 编码后的字节数。对于包含 surrogate pairs 的字符(如 emoji),一个字符算 2 个 UTF-16 码元。

5.3 MUTF-8(Modified UTF-8)

MUTF-8 与标准 UTF-8 有三个关键差异

差异一:空字符 ‘\0’ 的编码

标准 UTF-8 中 ‘\0’ 就是 0x00。但 C 语言以 0x00 终止字符串,所以 MUTF-8 将 ‘\0’ 编码为两字节

U+0000 (NULL) → MUTF-8: C0 80   (而非标准 UTF-8 的 00)

这保证了 MUTF-8 字符串内部不会出现 0x00,可以用 C 字符串函数安全处理。

差异二:Supplementary Characters

标准 UTF-8 对 U+10000 以上的字符用 4 字节编码。MUTF-8 源自 Java 的序列化格式,对于 surrogate pairs 使用两次 3 字节 UTF-8 编码(即分别对高位 surrogate 和低位 surrogate 做 UTF-8 编码),而非一次 4 字节:

标准 UTF-8:  U+1F600 (😀) → F0 9F 98 80  (4 字节)
MUTF-8: U+1F600 → ED A0 BD ED B8 80 (6 字节,分两次 3 字节)
↓ ↓
U+D83D (高 surrogate) U+DE00 (低 surrogate)

5.4 Python 解析 string_ids

def read_mutf8_string(data, offset):
"""从 data 区读取 MUTF-8 字符串"""
utf16_len, offset = read_uleb128(data, offset)

if utf16_len == 0:
return "", offset

# 收集所有字节直到遇到 null terminator
# 或者达到 utf16_len 个码元
bytes_data = bytearray()
while True:
b = data[offset]
offset += 1
if b == 0:
break
bytes_data.append(b)

# 解码 MUTF-8
# 处理 C0 80 → \0 的特殊编码
result = bytearray()
i = 0
while i < len(bytes_data):
if i + 1 < len(bytes_data) and bytes_data[i] == 0xC0 and bytes_data[i+1] == 0x80:
result.append(0) # 还原为真正的 \0
i += 2
else:
result.append(bytes_data[i])
i += 1

return result.decode('utf-8', errors='replace'), offset

def parse_string_ids(data, header):
"""解析 string_ids 表"""
count = header['string_ids_size']
base_off = header['string_ids_off']
data_off = header['data_off']
strings = []

for i in range(count):
str_off = struct.unpack_from('<I', data, base_off + i * 4)[0]
content, _ = read_mutf8_string(data, data_off + str_off)
strings.append(content)

return strings

六、type_ids, proto_ids, field_ids, method_ids

6.1 type_id_item — 类型索引

struct TypeId {
uint32_t descriptor_idx; // 指向 string_ids 的索引
};

descriptor_idx 指向的字符串是 JNI 类型描述符:

I           → int
V → void
Z → boolean
Ljava/lang/String; → java.lang.String
[I → int[]
[[B → byte[][]
Lcom/example/MyClass; → 自定义类

6.2 proto_id_item — 方法原型

struct ProtoId {
uint32_t shorty_idx; // 简短描述符 string_id
uint32_t return_type_idx; // 返回值 type_id
uint32_t parameters_off; // 参数类型列表的偏移 (type_list)
};

shorty_idx 是压缩描述,第一字符是返回值类型,后续每字符是一个参数类型:

(ILjava/lang/String;J)V  → shorty = "VLJ"
I String long void

parameters_off 如果为 0,表示无参数;否则指向一个 type_list

struct TypeList {
uint32_t size; // 参数个数
TypeItem list[size]; // 每个元素 2 字节的 type_id 索引
};

6.3 field_id_item — 字段定义

struct FieldId {
uint16_t class_idx; // 所属类 type_id 索引
uint16_t type_idx; // 字段类型 type_id 索引
uint32_t name_idx; // 字段名 string_id 索引
};

6.4 method_id_item — 方法定义

struct MethodId {
uint16_t class_idx; // 所属类 type_id 索引
uint16_t proto_idx; // 方法原型 proto_id 索引
uint32_t name_idx; // 方法名 string_id 索引
};

6.5 索引查找示例

假设要找到方法 "void com.example.App.onCreate(Bundle)":

method_ids[7]:
class_idx = 15 → type_ids[15] → "Lcom/example/App;"
proto_idx = 3 → proto_ids[3]:
return_type_idx → "V"
parameters_off → type_list: ["Landroid/os/Bundle;"]
name_idx = 42 → string_ids[42] → "onCreate"

七、class_def_item 完整解析

class_def_item 每条 32 字节,是 DEX 中最复杂的结构体之一:

// AOSP: art/libdexfile/dex/dex_file_structs.h
struct ClassDef {
uint32_t class_idx; // 本类 type_id 索引
uint32_t access_flags; // 访问标志位掩码
uint32_t superclass_idx; // 父类 type_id 索引(NO_INDEX=0xFFFFFFFF 表示 java.lang.Object)
uint32_t interfaces_off; // 接口列表偏移(0 = 无接口)
uint32_t source_file_idx; // 源文件名 string_id 索引(NO_INDEX=0xFFFFFFFF 表示未知)
uint32_t annotations_off; // 注解目录偏移
uint32_t class_data_off; // 类数据偏移(0 = 无字段/方法)
uint32_t static_values_off; // 静态字段初始值偏移
};

7.1 access_flags 位掩码

标志 含义
ACC_PUBLIC 0x0001 public
ACC_PRIVATE 0x0002 private
ACC_PROTECTED 0x0004 protected
ACC_STATIC 0x0008 static
ACC_FINAL 0x0010 final
ACC_INTERFACE 0x0200 interface
ACC_ABSTRACT 0x0400 abstract
ACC_SYNTHETIC 0x1000 编译器生成
ACC_ANNOTATION 0x2000 annotation 类型
ACC_ENUM 0x4000 enum

7.2 class_data_item 结构

class_data_off 指向的 class_data_item 包含该类的所有字段和方法信息:

class_data_item {
uleb128 static_fields_size;
uleb128 instance_fields_size;
uleb128 direct_methods_size;
uleb128 virtual_methods_size;
encoded_field static_fields[static_fields_size];
encoded_field instance_fields[instance_fields_size];
encoded_method direct_methods[direct_methods_size];
encoded_method virtual_methods[virtual_methods_size];
}

direct_methods 包含:static 方法、private 方法、构造函数、类初始化器 <clinit>
virtual_methods 包含:public/protected 方法、接口方法。

encoded_method 结构

encoded_method {
uleb128 method_idx_diff; // 相对上一个方法的 method_idx 差值(第一个是绝对值)
uleb128 access_flags; // 方法访问标志
uleb128 code_off; // 代码偏移(0 表示 abstract/native 方法)
}

注意 method_idx_diff 采用增量编码:第一个方法是其自身的 method_id 索引,后续方法是与前一方法的差值。这种编码方式利用 method_ids 按类分组的特性,显著减少空间占用。

encoded_field 结构

encoded_field {
uleb128 field_idx_diff; // 增量编码的 field_id 索引
uleb128 access_flags; // 字段访问标志
}

7.3 代码体:code_item

encoded_method.code_off 非零时,指向一个 code_item

struct CodeItem {
uint16_t registers_size; // 该方法使用的寄存器总数(包括参数寄存器)
uint16_t ins_size; // 方法参数占用的字数
uint16_t outs_size; // 调用其他方法时需要的输出寄存器数
uint16_t tries_size; // try/catch 块数量
uint32_t debug_info_off; // 调试信息偏移(0 = 无调试信息)
uint32_t insns_size; // 指令数组大小(以 16 位单元计)
uint16_t insns[insns_size]; // 实际的 Dalvik 字节码指令
// 如果 tries_size != 0,紧跟 try_item[tries_size]
// 如果 tries_size != 0,紧跟 encoded_catch_handler_list
};

Dalvik 指令格式

指令使用 16 位码元(code unit),一条指令可能由 1~n 个码元组成。第一个码元的最低字节始终是操作码:

例子: add-int v0, v1, v2
指令码: 0x90 | 0x01 | 0x00 0x02
op v0 v1 v2

寄存器引用指向连续的 32 位寄存器空间:

  • v0 ~ v(N-1):局部寄存器
  • vN ~ v(N+M-1):参数寄存器(其中 M = ins_size)

7.4 try/catch 编码

code_item.tries_size 非零时,在 insns[] 数组之后紧跟着 try/catch 相关结构。整个编码链由三层组成。

try_item 结构

每个 try 块描述一段受保护的指令区间:

try_item {
uint32_t start_addr; // 受保护区间的起始地址(相对于该方法的 insns[] 起始,以 16 位码元计)
uint16_t insn_count; // 受保护区间的指令码元数
uint16_t handler_off; // 指向 encoded_catch_handler_list 中对应 handler 的偏移
}

start_addrinsn_count 共同定义了 try 块的覆盖范围。例如 start_addr=2, insn_count=5 表示从方法字节码的第 2 个码元开始,共 5 个码元(即 10 字节)受此 try 块保护。

handler_off 指向 encoded_catch_handler_list起始偏移encoded_catch_handler_list 紧跟在 try_item[] 数组之后,因此 handler_off 是相对于 try_item[0] 起始位置的偏移。注意:多个 try_item 可以共享同一个 handler(即多个 try 块对应同一个 catch),此时它们的 handler_off 指向同一个 handler。

encoded_catch_handler_list 结构

encoded_catch_handler_list {
uleb128 size; // handler 的数量
encoded_catch_handler handlers[size]; // 各 handler 的定义
}

encoded_catch_handler 结构

encoded_catch_handler {
sleb128 size; // 捕获类型数(正数 = 有类型捕获,非正数 = 包含 catch-all)
encoded_catch_handler_item handlers[abs(size)]; // 各类型的具体捕获信息
// 如果 size <= 0,在 handlers[] 之后还有一个 uleb128 catch_all_addr
}

关键规则:

  • size正数:表示有 size 个带类型检查的 catch 分支,不包含 catch-all 分支。
  • size零或负数:其绝对值 abs(size) 表示带类型检查的 catch 分支数,且额外包含一个 catch-all 分支。catch-all 的地址紧跟在手柄数组之后,以 uleb128 编码。
  • 不带 catch-all 时,处理完所有类型匹配失败的异常后,异常会被重新抛出(re-throw)。

encoded_catch_handler_item 结构

encoded_catch_handler_item {
uleb128 type_idx; // 要捕获的异常类型(type_ids 索引)
uleb128 addr; // 该 catch 分支的 handler 代码地址(相对于 insns[] 起始的码元偏移)
}

catch-all 的编码

catch-all(即 catch (Throwable e)finally 块的隐式 catch)不通过 type_idx 标识,而是通过 encoded_catch_handler.size <= 0 来标识其存在。catch-all 的目标地址紧跟在 handlers[abs(size)] 数组之后,以 uleb128 编码。

完整示例

考虑以下伪代码:

try {
// insns[0] ~ insns[7] (8 个码元)
} catch (IOException e) {
// handler 位于 insns[8]
} catch (RuntimeException e) {
// handler 位于 insns[12]
} finally {
// 隐式的 catch-all,handler 位于 insns[16]
}

对应的二进制编码(示意):

=== try_item[0] ===
start_addr = 0x00000000 // 从 insns[0] 开始
insn_count = 0x0008 // 共 8 个码元
handler_off = 0x000C // 指向下面 encoded_catch_handler_list 的偏移

=== encoded_catch_handler_list (位于 try_item 之后) ===
size = uleb128(1) // 1 个 handler

=== encoded_catch_handler[0] ===
size = sleb128(-2) // 绝对值 = 2 个带类型 handler;负号表示附加 catch-all

=== handlers[0] (IOException) ===
type_idx = uleb128(X) // type_ids 中 "Ljava/io/IOException;" 的索引
addr = uleb128(8) // handler 代码位于 insns[8]

=== handlers[1] (RuntimeException) ===
type_idx = uleb128(Y) // type_ids 中 "Ljava/lang/RuntimeException;" 的索引
addr = uleb128(12) // handler 代码位于 insns[12]

=== catch_all_addr (附加在 handlers 之后) ===
addr = uleb128(16) // finally 块代码位于 insns[16]

解析注意事项

在手动解析时,需注意:

  • handler_off 是相对于 try_item 数组的起始位置,而非文件开头。
  • 不带 catch-all 的 handler,在遍历完所有 encoded_catch_handler_item 后不附加任何额外数据。
  • 有 catch-all 的 handler,catch_all_addr 也是相对于 insns[] 起始的码元偏移(不是字节偏移)。

7.5 调试信息:debug_info_item

code_item.debug_info_off 非零时指向一个 debug_info_item。它用于建立字节码地址与源码行号的映射关系(供调试器和异常堆栈回溯使用)。

据 AOSP art/libdexfile/dex/dex_file.cc 中的 DexFile::DecodeDebugInfo 实现,debug_info_item 不是固定结构体,而是一个状态机字节码序列。ART 运行时通过逐条解释 opcode 来重建完整的调试映射表。

状态机模型

在开始解码前,状态机持有两个状态变量:

address = 0         // 当前字节码地址(以 16 位码元计)
line = line_start // 当前源码行号,初始值来自 debug_info_item 的前缀

解码器逐条读取 opcode 并根据其值更新 addressline,从而构建 (address → line) 映射。

debug_info_item 的完整布局

debug_info_item {
uleb128 line_start; // 方法第一条指令对应的初始行号
uleb128 parameters_size; // 参数数量
uleb128 parameter_names[parameters_size]; // 各参数名 string_ids 索引(NO_INDEX=0xFFFFFFFF 表示无名称)
ubyte opcode_stream[]; // 状态机字节码流,以 DBG_END_SEQUENCE 终止
}

核心 opcode 列表

据 AOSP art/libdexfile/dex/dex_instruction_list.h 中的调试 opcode 定义:

Opcode 名称 编码格式 含义
0x00 DBG_END_SEQUENCE 终止调试序列
0x01 DBG_ADVANCE_PC uleb128 addr_diff address += addr_diff(前进码元数)
0x02 DBG_ADVANCE_LINE sleb128 line_diff line += line_diff(前进或后退行号)
0x03 DBG_START_LOCAL uleb128 reg_num, uleb128 name_idx, uleb128 type_idx 声明一个局部变量,其作用域从当前 address 开始
0x04 DBG_START_LOCAL_EXTENDED uleb128 reg_num, uleb128 name_idx, uleb128 type_idx, uleb128 sig_idx 同上但额外包含类型签名(泛型信息)
0x05 DBG_END_LOCAL uleb128 reg_num 结束指定寄存器中局部变量的作用域
0x06 DBG_RESTART_LOCAL uleb128 reg_num 重新激活已被 end 的局部变量(作用域重新开始)
0x07 DBG_SET_PROLOGUE_END 标记方法序言的结尾
0x08 DBG_SET_EPILOGUE_BEGIN 标记方法尾声的开始
0x09 DBG_SET_FILE uleb128 name_idx 切换当前源文件名(string_ids 索引),用于内联代码来自不同文件的情况

特殊 opcode(0x0A – 0xFF)

值在 0x0A0xFF 之间的 opcode 为特殊 opcode,将地址前进和行号前进打包在同一个字节中:

adjusted_opcode = opcode - 0x0A    // 得到 0 ~ 245

address += (adjusted_opcode / DBG_LINE_RANGE) // DBG_LINE_RANGE = 15 (AOSP 定义)
line += DBG_LINE_BASE + (adjusted_opcode % DBG_LINE_RANGE)
// DBG_LINE_BASE = -4

因此单字节即可编码 “前进 015 个码元,行号变化 -4+10”。

解码示例

假设某 debug_info 的 line_start = 10,opcode 流如下:

line_start = uleb128(10)
parameters_size = uleb128(1)
parameter_names[0] = uleb128(NO_INDEX)

opcode stream:
0x09, uleb128(5) → DBG_SET_FILE: 切换到 string_ids[5]
0x01, uleb128(2) → DBG_ADVANCE_PC: address += 2 (地址推进到 insns[2])
0x02, sleb128(1) → DBG_ADVANCE_LINE: line += 1 (行号变为 11)
0x0A → 特殊 opcode: adjusted=0
address += (0/15) = 0, line += -4 + (0%15) = -4
line 变为 7, address 不变
...
0x00 → DBG_END_SEQUENCE: 终止

逆向工程中的实际用途

  • 恢复源码行号:逆向工具(baksmali、JADX)使用 debug_info 在反编译输出中插入 .line 指令。
  • 去混淆分析:混淆器可能保留或篡改 debug_info。如果发现行号全部为乱码或指向不存在的文件,通常是混淆器所为。
  • 判断内联:如果 debug_info 中出现了 DBG_SET_FILE,说明编译器内联了来自其他文件的代码,这对于分析混淆器或编译器优化行为很有价值。

八、注解格式(Annotations)

DEX 文件通过一套完整的注解结构链来存储类、字段、方法和参数的注解。理解这条链是定位和修改注解数据的必要前提。

8.1 顶层入口:annotations_directory_item

class_def_item.annotations_off 非零时指向一个 annotations_directory_item

annotations_directory_item {
uint32_t class_annotations_off; // 类级别注解集的偏移
uint32_t fields_size; // 被注解的字段数量
uint32_t methods_size; // 被注解的方法数量
uint32_t parameters_size; // 被注解的参数数量
field_annotation field_annotations[fields_size];
method_annotation method_annotations[methods_size];
parameter_annotation parameter_annotations[parameters_size];
}

field_annotation 结构

field_annotation {
uint32_t field_idx; // field_ids 索引
uint32_t annotations_off; // 指向 annotation_set_ref_list 的偏移
}

method_annotation 结构

method_annotation {
uint32_t method_idx; // method_ids 索引
uint32_t annotations_off; // 指向 annotation_set_ref_list 的偏移
}

parameter_annotation 结构

parameter_annotation {
uint32_t method_idx; // method_ids 索引
uint32_t annotations_off; // 指向 annotation_set_ref_list 的偏移
}

注意:parameter_annotation 指向的 annotation_set_ref_list 中包含对每个参数的注解引用列表。如果某方法有 3 个参数且仅有第 2 个参数有注解,则 annotation_set_ref_list 有 3 个元素,其中元素 0 和元素 2 指向空的 annotation_set_item(size=0),元素 1 指向实际注解。

8.2 annotation_set_ref_list

每个字段、方法或参数的注解通过 annotation_set_ref_list 间接引用(此间接层允许在不同位置共享注解数据):

annotation_set_ref_list {
uint32_t size;
annotation_set_ref_item items[size];
}

annotation_set_ref_item {
uint32_t annotations_off; // 指向 annotation_set_item
}

8.3 annotation_set_item

annotation_set_item {
uint32_t size; // 注解数量
uint32_t entries[size]; // 每个元素指向一个 annotation_item
}

8.4 annotation_item

注解的可见性决定了它是否保留在运行时、是否参与编译检查:

annotation_item {
uint8_t visibility; // 可见性标记
encoded_annotation annotation; // 注解内容编码
}

visibility 取值(据 AOSP art/libdexfile/dex/dex_file.h 定义):

常量 含义
VISIBILITY_BUILD 0x00 仅编译期可见(如 @Retention(SOURCE) 的注解在 DEX 中不保留)
VISIBILITY_RUNTIME 0x01 运行时可见(@Retention(RUNTIME)),可通过反射获取
VISIBILITY_SYSTEM 0x02 系统级可见,通常用于系统内部注解
VISIBILITY_ENCODED 0x03 编码注解(内部使用)

8.5 encoded_annotation 与 annotation_element

encoded_annotation {
uleb128 type_idx; // 注解类型(type_ids 索引,如 "Ljava/lang/Override;")
uleb128 size; // 元素数量
annotation_element elements[size];
}

annotation_element {
uleb128 name_idx; // 元素名称(string_ids 索引,如 "value")
encoded_value value; // 元素的值(见下一节 encoded_value 格式)
}

8.6 完整注解引用链

从类定义到最终注解数据的完整路径:

class_def.annotations_off
└─→ annotations_directory_item
├─ class_annotations_off ─→ annotation_set_ref_list ─→ annotation_set_item
│ └─ annotation_off ─→ annotation_item
│ └─ encoded_annotation
├─ field_annotations[i].annotations_off ─→ (同上链)
├─ method_annotations[i].annotations_off ─→ (同上链)
└─ parameter_annotations[i].annotations_off ─→ (同上链)

8.7 逆向实务笔记

  • 反射可见性:只有 VISIBILITY_RUNTIME 的注解可通过 Class.getAnnotations() 获取。加固或混淆可能会篡改 visibility 字节来隐藏敏感注解。
  • 空注解集annotation_set_item.size = 0 是合法的,表示该元素无任何注解。
  • 共享引用:多个字段/方法可能通过 annotation_set_ref_item 指向同一个 annotation_set_item,修改一个会影响所有引用者。

九、编码值格式(Encoded Value)

DEX 文件使用统一的 encoded_value 格式来表示注解元素值、静态字段初始值(static_values_off)以及 encoded_array 的数据项。

9.1 encoded_value 结构

encoded_value {
uint8_t header; // 高 3 位 = value_type, 低 5 位 = value_arg
uint8_t data[value_arg + 1]; // 实际值的字节表示(对于某些类型可为零长度)
}

header 字节的位布局:

bit:  7   6   5   4   3   2   1   0
┌───────┬───────────────────────┐
│ type │ value_arg │
│(3bit) │ (5bit) │
└───────┴───────────────────────┘

value_type = header >> 5 // 取高 3 位
value_arg = header & 0x1F // 取低 5 位

随后紧跟 value_arg + 1 字节的数据(即如果 value_arg = 0,则有 1 字节数据;value_arg = 7,则有 8 字节数据)。

9.2 完整 value_type 枚举

据 AOSP art/libdexfile/dex/dex_file.h 中的 EncodedValueType 枚举:

value_type 常量名 值编码 value_arg 含义
0x00 VALUE_BYTE 1 字节有符号整数 数据大小 - 1(通常为 0)
0x02 VALUE_SHORT 有符号短整数 size - 1(通常为 1,即 2 字节)
0x03 VALUE_CHAR 无符号字符 size - 1(通常为 1)
0x04 VALUE_INT 有符号整数 size - 1(通常为 3,即 4 字节)
0x06 VALUE_LONG 有符号长整数 size - 1(通常为 7,即 8 字节)
0x10 VALUE_FLOAT IEEE 754 单精度浮点 size - 1(通常为 3)
0x11 VALUE_DOUBLE IEEE 754 双精度浮点 size - 1(通常为 7)
0x17 VALUE_STRING string_ids 索引 size - 1
0x18 VALUE_TYPE type_ids 索引 size - 1
0x19 VALUE_FIELD field_ids 索引 size - 1
0x1A VALUE_METHOD method_ids 索引 size - 1
0x1B VALUE_ENUM field_ids 索引(枚举值) size - 1
0x1C VALUE_ARRAY 内嵌 encoded_array value_arg = 0(其后紧跟 encoded_array)
0x1D VALUE_ANNOTATION 内嵌 encoded_annotation value_arg = 0(其后紧跟 encoded_annotation)
0x1E VALUE_NULL null 值 value_arg = 0,无后续数据
0x1F VALUE_BOOLEAN 布尔值 value_arg = 0 或 1 直接表示 false/true,无后续数据

9.3 关键编码细节

VALUE_BOOLEAN:不占用后续数据字节。header = 0x1F(即 0x1F << 5 | value_arg)时,value_arg 本身即为布尔值——0 表示 false,1 表示 true。因此:

  • false → header = (0x1F << 5) | 0 = 0xF8(单字节,无后续数据)
  • true → header = (0x1F << 5) | 1 = 0xF9(单字节,无后续数据)

VALUE_NULL:类似地,header = (0x1E << 5) | 0 = 0xF0,无后续数据。

VALUE_BYTEheader = (0x00 << 5) | value_arg。例如值 0x2A 的编码为 header = 0x00,后跟 1 字节 0x2A

VALUE_INTvalue_arg 通常为 3(因为 4 字节足以表示,所以 header = (0x04 << 5) | 3 = 0x83)。但数值小时可以用更小的 value_arg(0 表示 1 字节,1 表示 2 字节等)。

VALUE_STRING / VALUE_TYPE / VALUE_FIELD / VALUE_METHOD / VALUE_ENUM:数据是相应的 table 索引(无符号整数),value_arg + 1 指示索引值的字节数。索引值按小端序存储。

VALUE_ARRAYheader = (0x1C << 5) | 0 = 0xE0,其后紧跟一个 encoded_array

encoded_array {
uleb128 size;
encoded_value values[size];
}

VALUE_ANNOTATIONheader = (0x1D << 5) | 0 = 0xE8,其后紧跟一个 encoded_annotation(结构见上一节 8.5)。

9.4 解析示例

假设字节序列为 F9 83 2A 00 00 00

F9 = header: value_type = 0x1F (VALUE_BOOLEAN), value_arg = 1 → true
无后续数据

83 = header: value_type = 0x04 (VALUE_INT), value_arg = 3 → 4 字节数据
后 4 字节: 2A 00 00 00 → 小端序 0x0000002A = 42

9.5 static_values_off 的编码

class_def_item.static_values_off 指向一个 encoded_array_item,其中包含该类的 static 字段初始值。该数组的大小与 class_data_itemstatic_fields_size 一致,按 encoded_field 的顺序一一对应。


十、map_list — 自描述目录

map_list 是 DEX 文件的末尾结构,列出文件中每种 item 类型的数量和在文件中的偏移。其作用类似目录表,供 ART 运行时快速定位并校验文件完整性。

struct MapList {
uint32_t size; // 条目数
MapItem list[size]; // 条目数组
};

struct MapItem {
uint16_t type; // item 类型码
uint16_t unused; // 对齐填充,未使用
uint32_t size; // 该类型 item 的数量
uint32_t offset; // 该类型 item 在文件中的偏移
};

常见 MapItem type 值

TYPE 对应 item
TYPE_HEADER_ITEM 0x0000 dex_header
TYPE_STRING_ID_ITEM 0x0001 string_id_item
TYPE_TYPE_ID_ITEM 0x0002 type_id_item
TYPE_PROTO_ID_ITEM 0x0003 proto_id_item
TYPE_FIELD_ID_ITEM 0x0004 field_id_item
TYPE_METHOD_ID_ITEM 0x0005 method_id_item
TYPE_CLASS_DEF_ITEM 0x0006 class_def_item
TYPE_MAP_LIST 0x1000 map_list(自指)
TYPE_TYPE_LIST 0x1001 type_list
TYPE_ANNOTATION_SET_REF_LIST 0x1002 annotation_set_ref_list
TYPE_ANNOTATION_SET_ITEM 0x1003 annotation_set
TYPE_CLASS_DATA_ITEM 0x2000 class_data_item
TYPE_CODE_ITEM 0x2001 code_item
TYPE_STRING_DATA_ITEM 0x2002 string_data_item
TYPE_DEBUG_INFO_ITEM 0x2003 debug_info_item
TYPE_ANNOTATION_ITEM 0x2004 annotation_item
TYPE_ENCODED_ARRAY_ITEM 0x2005 encoded_array_item
TYPE_ANNOTATIONS_DIRECTORY 0x2006 annotations_directory_item

十一、Multi-DEX 与 64K 限制

9.1 64K 问题的根源

DEX 文件中 method_id_item 的字段均为 uint16_t(16 位无符号整数),且所有 invoke-* 指令族的 method_idx 操作数域宽 16 位。这意味着单个 DEX 文件最多只能引用 2^16 = 65536 个方法。当 APK 引用的方法总数超过此限制时,必须拆分为多个 DEX 文件,每个 DEX 文件独立拥有自己的 method_ids 表:

classes.dex     ← 主 DEX(始终加载)
classes2.dex ← 次要 DEX(按需加载)
classes3.dex ← ...
classesN.dex

9.2 MultiDex 加载机制

Android 5.0 (API 21)+ 的 ART 运行时原生支持多个 DEX 的直接加载。对于旧版本,需要使用 androidx.multidex:multidex 支持库,通过反射调用 BaseDexClassLoaderdexPathList 来注入额外 DEX。

// AndroidManifest.xml 中声明 MultiDexApplication
<application
android:name="androidx.multidex.MultiDexApplication"
...>

9.3 各 DEX 之间的关系

classes.dex 中的 class_defs 可以引用 classes2.dex 中定义的类;method_ids、field_ids 等索引表按主 DEX 集中的原则分配索引,classes.dex 包含全局的索引表前缀。在逆向分析时,如果发现 DEX 中引用的 method_idx 超出本 DEX 的 method_ids_size,说明目标方法定义在另一个 DEX 中。


十二、Compact DEX(cdex)概述

Compact DEX(简称 cdex)是 Android 9(API 28)引入的 DEX 文件紧凑格式。它并非全新的文件格式,而是标准 DEX 的优化变体,旨在减少 DEX 文件在存储和内存中的占用,并加速 ART 加载过程

12.1 设计动机

标准 DEX 格式使用”按需解析”策略:运行时需要遍历 string_ids、type_ids 等索引表才能定位方法、查找类型。这个过程每次加载 DEX 时都要重复执行。cdex 通过预计算和共享数据,将常用查找操作的时间复杂度从 O(n) 降为 O(1)。

12.2 与标准 DEX 的关键差异

特性 标准 DEX Compact DEX
文件扩展名 .dex .dex(相同,通过 magic 区分)
magic 值 dex\n035\0 / dex\n039\0 cdex\n039\0 / cdex\n040\0
数据布局 各索引表独立排列 共享数据段,紧凑编码
method_ids 索引 16 位 field 紧凑编码(部分用更小的位宽)
查找表 预计算的排序查找表
文件大小(典型) 基准 减少约 15%–20%
加载速度 基准 更快(省去多次索引查找)

12.3 cdex 的布局特征

据 AOSP art/libdexfile/dex/compact_dex_file.h 分析,cdex 的主要布局变化包括:

  1. 共享数据段:将 header 后的多个区域合并,字段布局更密集。
  2. 预计算查找加速表:在文件头附近存储排序后的 method/field 列表,ART 可直接二分查找,无需遍历整个 table。
  3. 紧凑 ID 编码:method_id、field_id 等使用更小的位宽编码(如 8 位或 12 位而非 16 位),对于方法数较少的 DEX 可大幅节省空间。
  4. 去重数据引用:多个 class_def 共享相同的 class_data 时,仅存储一份数据。

12.4 逆向工程注意事项

  • 工具支持:主流逆向工具对 cdex 已有良好支持。JADX(v1.0.0+)、GDA(v3.0+)可直接解析 cdex 格式,对使用者基本透明。baksmali 从 v2.3 开始支持 cdex 反汇编。
  • 识别 cdex 文件:检查 DEX 文件的 magic 前 4 字节是否为 cdex63 64 65 78)。标准 DEX 为 dex\n64 65 78 0A)。
  • 转换为标准 DEX:Android SDK 提供的 dexdumpcompact_dex_converter 工具可以将 cdex 转换为标准 DEX 进行分析。但注意转换后的文件与原始 DEX 在字节级别不同(偏移、索引均有变化)。
  • 内存 dump 注意:当从内存中 dump DEX 数据时,ART 内部可能已将 cdex 展开为标准 DEX 格式。因此内存 dump 得到的通常是标准 DEX,而非 cdex。

十三、完整 Python DEX 解析器

以下是一个可以解析真实 DEX 文件的 Python 实现(约 200 行):

#!/usr/bin/env python3
"""完整的 DEX 文件解析器 — 解析 classes.dex 并 dump 所有结构信息"""
import struct
import sys
from collections import namedtuple

# ========== LEB128 ==========
def read_uleb128(data, offset):
result = shift = 0
while True:
b = data[offset]; offset += 1
result |= (b & 0x7F) << shift
if b & 0x80 == 0: break
shift += 7
return result, offset

# ========== Header ==========
HEADER_FIELDS = [
('magic', '8s'), ('checksum', 'I'), ('signature', '20s'),
('file_size', 'I'), ('header_size', 'I'), ('endian_tag', 'I'),
('link_size', 'I'), ('link_off', 'I'), ('map_off', 'I'),
('string_ids_size', 'I'), ('string_ids_off', 'I'),
('type_ids_size', 'I'), ('type_ids_off', 'I'),
('proto_ids_size', 'I'), ('proto_ids_off', 'I'),
('field_ids_size', 'I'), ('field_ids_off', 'I'),
('method_ids_size', 'I'), ('method_ids_off', 'I'),
('class_defs_size', 'I'), ('class_defs_off', 'I'),
('data_size', 'I'), ('data_off', 'I'),
]

def parse_header(data):
h = {}
off = 0
for name, fmt in HEADER_FIELDS:
sz = struct.calcsize(fmt)
val = struct.unpack_from(fmt, data, off)[0]
h[name] = val
off += sz
return h

# ========== String ==========
def read_mutf8(data, offset):
utf16_len, offset = read_uleb128(data, offset)
if utf16_len == 0: return "", offset
raw = bytearray()
while data[offset] != 0:
raw.append(data[offset]); offset += 1
offset += 1 # skip null terminator
# decode MUTF-8: C0 80 → \0
decoded = bytearray(); i = 0
while i < len(raw):
if i+1 < len(raw) and raw[i] == 0xC0 and raw[i+1] == 0x80:
decoded.append(0); i += 2
else:
decoded.append(raw[i]); i += 1
return decoded.decode('utf-8', errors='replace'), offset

def parse_strings(data, header):
strings = []
for i in range(header['string_ids_size']):
soff = struct.unpack_from('<I', data, header['string_ids_off'] + i*4)[0]
s, _ = read_mutf8(data, header['data_off'] + soff)
strings.append(s)
return strings

# ========== Types ==========
def parse_types(data, header):
types = []
for i in range(header['type_ids_size']):
idx = struct.unpack_from('<I', data, header['type_ids_off'] + i*4)[0]
types.append(idx)
return types

# ========== Protos ==========
def parse_protos(data, header):
protos = []
for i in range(header['proto_ids_size']):
off = header['proto_ids_off'] + i * 12
shorty = struct.unpack_from('<I', data, off)[0]
ret = struct.unpack_from('<I', data, off+4)[0]
param = struct.unpack_from('<I', data, off+8)[0]
protos.append({'shorty_idx': shorty, 'return_type_idx': ret, 'parameters_off': param})
return protos

# ========== Fields & Methods ==========
def parse_fields(data, header):
fields = []
for i in range(header['field_ids_size']):
off = header['field_ids_off'] + i * 8
cls = struct.unpack_from('<H', data, off)[0]
typ = struct.unpack_from('<H', data, off+2)[0]
name = struct.unpack_from('<I', data, off+4)[0]
fields.append({'class_idx': cls, 'type_idx': typ, 'name_idx': name})
return fields

def parse_methods(data, header):
methods = []
for i in range(header['method_ids_size']):
off = header['method_ids_off'] + i * 8
cls = struct.unpack_from('<H', data, off)[0]
proto = struct.unpack_from('<H', data, off+2)[0]
name = struct.unpack_from('<I', data, off+4)[0]
methods.append({'class_idx': cls, 'proto_idx': proto, 'name_idx': name})
return methods

# ========== Classes ==========
def parse_class_defs(data, header):
classes = []
for i in range(header['class_defs_size']):
off = header['class_defs_off'] + i * 32
cls = {
'class_idx': struct.unpack_from('<I', data, off)[0],
'access_flags': struct.unpack_from('<I', data, off+4)[0],
'superclass_idx': struct.unpack_from('<I', data, off+8)[0],
'interfaces_off': struct.unpack_from('<I', data, off+12)[0],
'source_file_idx': struct.unpack_from('<I', data, off+16)[0],
'annotations_off': struct.unpack_from('<I', data, off+20)[0],
'class_data_off': struct.unpack_from('<I', data, off+24)[0],
'static_values_off':struct.unpack_from('<I', data, off+28)[0],
}
classes.append(cls)
return classes

# ========== Class Data ==========
def parse_encoded_field(data, offset):
"""解析 encoded_field,返回 (dict, new_offset)"""
field_idx_diff, offset = read_uleb128(data, offset)
access_flags, offset = read_uleb128(data, offset)
return {'field_idx_diff': field_idx_diff, 'access_flags': access_flags}, offset

def parse_encoded_method(data, offset):
"""解析 encoded_method,返回 (dict, new_offset)"""
method_idx_diff, offset = read_uleb128(data, offset)
access_flags, offset = read_uleb128(data, offset)
code_off, offset = read_uleb128(data, offset)
return {'method_idx_diff': method_idx_diff, 'access_flags': access_flags, 'code_off': code_off}, offset

def parse_class_data(data, header, data_off):
"""解析 class_data_item"""
if data_off == 0:
return None
offset = data_off
static_fields_size, offset = read_uleb128(data, offset)
instance_fields_size, offset = read_uleb128(data, offset)
direct_methods_size, offset = read_uleb128(data, offset)
virtual_methods_size, offset = read_uleb128(data, offset)

result = {
'static_fields_size': static_fields_size,
'instance_fields_size': instance_fields_size,
'direct_methods_size': direct_methods_size,
'virtual_methods_size': virtual_methods_size,
'static_fields': [],
'instance_fields': [],
'direct_methods': [],
'virtual_methods': [],
}

for _ in range(static_fields_size):
fld, offset = parse_encoded_field(data, offset)
result['static_fields'].append(fld)
for _ in range(instance_fields_size):
fld, offset = parse_encoded_field(data, offset)
result['instance_fields'].append(fld)
for _ in range(direct_methods_size):
meth, offset = parse_encoded_method(data, offset)
result['direct_methods'].append(meth)
for _ in range(virtual_methods_size):
meth, offset = parse_encoded_method(data, offset)
result['virtual_methods'].append(meth)

return result

# ========== Code Item & Bytecode Disassembly ==========

# 指令格式描述: (opcode, mnemonic, format, num_code_units)
# format 字符含义: v=寄存器, I=立即数(16bit), L=立即数(32bit), T=分支目标, k=kind参数
# 仅列出常见指令作示范
INSTRUCTION_TABLE = {
0x00: ('nop', '10x', 1),
0x01: ('move', '12x', 1),
0x02: ('move/from16', '22x', 2),
0x03: ('move/16', '32x', 3),
0x0E: ('return-void', '10x', 1),
0x0F: ('return', '11x', 1),
0x10: ('return-wide', '11x', 1),
0x11: ('return-object', '11x', 1),
0x12: ('const/4', '11n', 1),
0x13: ('const/16', '21s', 2),
0x14: ('const', '31i', 3),
0x15: ('const/high16', '21h', 2),
0x1A: ('const-string', '21c', 2),
0x1B: ('const-string/jumbo', '31c', 3),
0x1F: ('check-cast', '21c', 2),
0x20: ('instance-of', '22c', 2),
0x22: ('new-instance', '21c', 2),
0x23: ('new-array', '22c', 2),
0x27: ('throw', '11x', 1),
0x28: ('goto', '10t', 1),
0x29: ('goto/16', '20t', 2),
0x2A: ('goto/32', '30t', 3),
0x2B: ('packed-switch', '31t', 3),
0x2C: ('sparse-switch', '31t', 3),
0x32: ('if-eq', '22t', 2),
0x33: ('if-ne', '22t', 2),
0x34: ('if-lt', '22t', 2),
0x35: ('if-ge', '22t', 2),
0x36: ('if-gt', '22t', 2),
0x37: ('if-le', '22t', 2),
0x3E: ('aget', '23x', 2),
0x44: ('aget-object', '23x', 2),
0x4B: ('aput', '23x', 2),
0x4F: ('aput-object', '23x', 2),
0x52: ('iget', '22c', 2),
0x53: ('iget-wide', '22c', 2),
0x54: ('iget-object', '22c', 2),
0x59: ('iput', '22c', 2),
0x5A: ('iput-wide', '22c', 2),
0x5B: ('iput-object', '22c', 2),
0x60: ('sget', '21c', 2),
0x61: ('sget-wide', '21c', 2),
0x62: ('sget-object', '21c', 2),
0x67: ('sput', '21c', 2),
0x68: ('sput-wide', '21c', 2),
0x69: ('sput-object', '21c', 2),
0x6E: ('invoke-virtual', '35c', 3),
0x6F: ('invoke-super', '35c', 3),
0x70: ('invoke-direct', '35c', 3),
0x71: ('invoke-static', '35c', 3),
0x72: ('invoke-interface', '35c', 3),
0x74: ('invoke-virtual/range', '3rc', 3),
0x75: ('invoke-super/range', '3rc', 3),
0x76: ('invoke-direct/range', '3rc', 3),
0x77: ('invoke-static/range', '3rc', 3),
0x78: ('invoke-interface/range', '3rc', 3),
0x7B: ('neg-int', '12x', 1),
0x7C: ('not-int', '12x', 1),
0x7D: ('neg-long', '12x', 1),
0x7E: ('not-long', '12x', 1),
0x7F: ('neg-float', '12x', 1),
0x80: ('neg-double', '12x', 1),
0x81: ('int-to-long', '12x', 1),
0x82: ('int-to-float', '12x', 1),
0x83: ('int-to-double', '12x', 1),
0x84: ('long-to-int', '12x', 1),
0x85: ('long-to-float', '12x', 1),
0x86: ('long-to-double', '12x', 1),
0x87: ('float-to-int', '12x', 1),
0x88: ('float-to-long', '12x', 1),
0x89: ('float-to-double', '12x', 1),
0x8A: ('double-to-int', '12x', 1),
0x8B: ('double-to-long', '12x', 1),
0x8C: ('double-to-float', '12x', 1),
0x8E: ('int-to-byte', '12x', 1),
0x8F: ('int-to-char', '12x', 1),
0x90: ('int-to-short', '12x', 1),
0x91: ('add-int', '23x', 2),
0x92: ('sub-int', '23x', 2),
0x93: ('mul-int', '23x', 2),
0x94: ('div-int', '23x', 2),
0x95: ('rem-int', '23x', 2),
0x96: ('and-int', '23x', 2),
0x97: ('or-int', '23x', 2),
0x98: ('xor-int', '23x', 2),
0x99: ('shl-int', '23x', 2),
0x9A: ('shr-int', '23x', 2),
0x9B: ('ushr-int', '23x', 2),
0xB0: ('add-int/2addr', '12x', 1),
0xB1: ('sub-int/2addr', '12x', 1),
0xB2: ('mul-int/2addr', '12x', 1),
0xB3: ('div-int/2addr', '12x', 1),
0xB4: ('rem-int/2addr', '12x', 1),
0xB5: ('and-int/2addr', '12x', 1),
0xB6: ('or-int/2addr', '12x', 1),
0xB7: ('xor-int/2addr', '12x', 1),
0xB8: ('shl-int/2addr', '12x', 1),
0xB9: ('shr-int/2addr', '12x', 1),
0xBA: ('ushr-int/2addr', '12x', 1),
0xC5: ('fill-array-data', '31t', 3),
0xD8: ('add-int/lit8', '22b', 2),
0xD9: ('rsub-int/lit8', '22b', 2),
0xDA: ('mul-int/lit8', '22b', 2),
0xDB: ('div-int/lit8', '22b', 2),
0xDC: ('rem-int/lit8', '22b', 2),
0xDD: ('and-int/lit8', '22b', 2),
0xDE: ('or-int/lit8', '22b', 2),
0xDF: ('xor-int/lit8', '22b', 2),
0xE0: ('shl-int/lit8', '22b', 2),
0xE1: ('shr-int/lit8', '22b', 2),
0xE2: ('ushr-int/lit8', '22b', 2),
0xFA: ('invoke-polymorphic', '45cc', 4),
0xFB: ('invoke-custom', '35c', 3),
0xFC: ('const-method-handle', '21c', 2),
0xFD: ('const-method-type', '21c', 2),
}

def parse_code_item(data, code_off, data_off):
"""解析 code_item,返回 (dict, consumed_bytes)"""
if code_off == 0:
return None, 0
offset = code_off
registers_size = struct.unpack_from('<H', data, offset)[0]; offset += 2
ins_size = struct.unpack_from('<H', data, offset)[0]; offset += 2
outs_size = struct.unpack_from('<H', data, offset)[0]; offset += 2
tries_size = struct.unpack_from('<H', data, offset)[0]; offset += 2
debug_info_off = struct.unpack_from('<I', data, offset)[0]; offset += 4
insns_size = struct.unpack_from('<I', data, offset)[0]; offset += 4

# 读取指令数组
insns = []
for i in range(insns_size):
insns.append(struct.unpack_from('<H', data, offset)[0])
offset += 2

code = {
'registers_size': registers_size,
'ins_size': ins_size,
'outs_size': outs_size,
'tries_size': tries_size,
'debug_info_off': debug_info_off,
'insns_size': insns_size,
'insns': insns,
}
return code, offset - code_off

def disassemble_insn(insns, addr):
"""
从 insns[addr] 开始反汇编一条指令。
返回 (mnemonic_str, next_addr)
"""
if addr >= len(insns):
return '??? (address out of range)', addr

opcode = insns[addr] & 0xFF
info = INSTRUCTION_TABLE.get(opcode)

if info is None:
# 未知 opcode: 可能为数据填充或未定义指令
return f'unknown_0x{opcode:02X}', addr + 1

mnemonic, fmt, units = info
if addr + units > len(insns):
return f'{mnemonic} (truncated)', addr

parts = [mnemonic]

# === 格式解码(仅覆盖常用格式,完整实现需参照 dalvik 字节码手册) ===
if fmt == '10x':
pass # nop, return-void — 无操作数
elif fmt == '10t':
pass # goto — 目标由 & 计算(简化处理)
elif fmt == '11x':
vAA = insns[addr] >> 8
parts.append(f'v{vAA}')
elif fmt == '11n':
vA = (insns[addr] >> 8) & 0xF
# 4 位有符号立即数
B = insns[addr] >> 12
if B & 0x8:
B -= 16
parts.append(f'v{vA}, #0x{B:X}')
elif fmt == '12x':
vA = (insns[addr] >> 8) & 0xF
vB = (insns[addr] >> 12) & 0xF
parts.append(f'v{vA}, v{vB}')
elif fmt == '20t':
# goto/16: AA (signed 16-bit)
pass
elif fmt == '21c':
vAA = insns[addr] >> 8
ref = insns[addr + 1] if units > 1 else 0
parts.append(f'v{vAA}, @0x{ref:04X}')
elif fmt == '21h':
vAA = insns[addr] >> 8
val = insns[addr + 1] << 16
parts.append(f'v{vAA}, #0x{val:08X}')
elif fmt == '21s':
vAA = insns[addr] >> 8
val = insns[addr + 1]
if val & 0x8000:
val -= 0x10000
parts.append(f'v{vAA}, #0x{val:X}')
elif fmt == '22b':
vAA = insns[addr] >> 8
vBB = insns[addr + 1] & 0xFF
# CC 为有符号 8-bit 立即数
CC = insns[addr + 1] >> 8
if CC & 0x80:
CC -= 0x100
parts.append(f'v{vAA}, v{vBB}, #0x{CC:X}')
elif fmt == '22c':
vA = insns[addr] & 0xF
vB = (insns[addr] >> 8) & 0xF
ref = insns[addr + 1]
parts.append(f'v{vA}, v{vB}, @0x{ref:04X}')
elif fmt == '22t':
vA = (insns[addr] >> 8) & 0xF
vB = (insns[addr] >> 12) & 0xF
parts.append(f'v{vA}, v{vB}, +{addr}')
elif fmt == '22x':
vAA = insns[addr] >> 8
vBBBB = insns[addr + 1]
parts.append(f'v{vAA}, v{vBBBB}')
elif fmt == '23x':
vAA = insns[addr] >> 8
vBB = insns[addr + 1] & 0xFF
vCC = insns[addr + 1] >> 8
parts.append(f'v{vAA}, v{vBB}, v{vCC}')
elif fmt == '30t':
pass # goto/32
elif fmt == '31c':
vAA = insns[addr] >> 8
ref = insns[addr + 1] | (insns[addr + 2] << 16)
parts.append(f'v{vAA}, @0x{ref:08X}')
elif fmt == '31i':
vAA = insns[addr] >> 8
val = insns[addr + 1] | (insns[addr + 2] << 16)
if val > 0x7FFFFFFF:
val -= 0x100000000
parts.append(f'v{vAA}, #0x{val:X}')
elif fmt == '31t':
pass # fill-array-data / packed-switch / sparse-switch
elif fmt == '32x':
vAAAA = insns[addr] >> 8
vBBBB = insns[addr + 1] | (insns[addr + 2] << 16)
parts.append(f'v{vAAAA}, v{vBBBB}')
elif fmt == '35c':
# 5-register invoke
count = (insns[addr] >> 8) & 0xF
regs = []
pool = [
(insns[addr + 1] >> 8) & 0xF,
insns[addr + 1] & 0xF,
(insns[addr + 2] >> 8) & 0xF,
insns[addr + 2] & 0xF,
(insns[addr] >> 12) & 0xF,
]
for i in range(count):
regs.append(f'v{pool[i]}')
ref = insns[addr + 1]
parts.append(f'{{{", ".join(regs)}}}, @0x{ref:04X}')
elif fmt == '3rc':
count = insns[addr] >> 8
vC = insns[addr + 2]
ref = insns[addr + 1]
parts.append(f'{{v{vC}..v{vC + count - 1}}}, @0x{ref:04X}')
elif fmt == '45cc':
count = (insns[addr] >> 8) & 0xF
regs = []
pool = [
(insns[addr + 1] >> 8) & 0xF,
insns[addr + 1] & 0xF,
(insns[addr + 2] >> 8) & 0xF,
insns[addr + 2] & 0xF,
(insns[addr] >> 12) & 0xF,
]
for i in range(count):
regs.append(f'v{pool[i]}')
ref = insns[addr + 1]
proto = insns[addr + 3]
parts.append(f'{{{", ".join(regs)}}}, proto@0x{proto:04X}, @0x{ref:04X}')
else:
parts.append(f'<fmt={fmt}>')

return ' '.join(parts), addr + units

def dump_bytecode(insns, strings, types, methods, fields, show_offsets=True):
"""
将指令数组 dump 为类 smali 格式的字节码列表。
返回 list of string,每条一行。
"""
lines = []
addr = 0
while addr < len(insns):
if show_offsets:
mnemonic, next_addr = disassemble_insn(insns, addr)
hex_bytes = ' '.join(f'{insns[addr + i]:04X}' for i in range(next_addr - addr))
lines.append(f' [{addr:04X}] {hex_bytes:<12} {mnemonic}')
else:
mnemonic, next_addr = disassemble_insn(insns, addr)
lines.append(f' {mnemonic}')
addr = next_addr
return lines

# ========== Main ==========
def main(path):
with open(path, 'rb') as f:
data = f.read()

header = parse_header(data)
print(f"DEX File: {path}")
print(f" Version: {header['magic'][:7].decode('ascii', errors='replace')}")
print(f" File Size: {header['file_size']} bytes")
print(f" Classes: {header['class_defs_size']}")
print(f" Methods: {header['method_ids_size']}")
print(f" Strings: {header['string_ids_size']}")
print(f" Fields: {header['field_ids_size']}")
print(f" Types: {header['type_ids_size']}")

strings = parse_strings(data, header)
types = parse_types(data, header)
fields = parse_fields(data, header)
methods = parse_methods(data, header)
classes = parse_class_defs(data, header)

print(f"\n=== Classes ===")
for i, cls in enumerate(classes):
type_desc = strings[types[cls['class_idx']]] if cls['class_idx'] < len(types) else "?"
flags = cls['access_flags']
flag_strs = []
if flags & 0x0001: flag_strs.append('public')
if flags & 0x0200: flag_strs.append('interface')
if flags & 0x0400: flag_strs.append('abstract')
if flags & 0x1000: flag_strs.append('synthetic')
if flags & 0x4000: flag_strs.append('enum')
print(f" [{i}] {type_desc} ({', '.join(flag_strs) if flag_strs else 'package-private'})")

# 解析 class_data
class_data = parse_class_data(data, header, cls['class_data_off'])
if class_data:
print(f" static_fields={class_data['static_fields_size']}, "
f"instance_fields={class_data['instance_fields_size']}")
print(f" direct_methods={class_data['direct_methods_size']}, "
f"virtual_methods={class_data['virtual_methods_size']}")

# 遍历方法并 dump 字节码(示范:只 dump 第一个 direct_method 的代码)
for meth_info in class_data['direct_methods'] + class_data['virtual_methods']:
if meth_info['code_off'] != 0:
code, consumed = parse_code_item(data, meth_info['code_off'], header['data_off'])
if code:
print(f" method_idx_diff={meth_info['method_idx_diff']}: "
f"registers={code['registers_size']}, insns={code['insns_size']} cu")
# dump 前 5 条指令作为示范
bytecode_lines = dump_bytecode(code['insns'], strings, types, methods, fields)
for line in bytecode_lines[:5]:
print(line)
if len(bytecode_lines) > 5:
print(f" ... ({len(bytecode_lines) - 5} more instructions)")

print(f"\n=== Methods ===")
for i, m in enumerate(methods):
cls_name = strings[types[m['class_idx']]] if m['class_idx'] < len(types) else "?"
meth_name = strings[m['name_idx']] if m['name_idx'] < len(strings) else "?"
print(f" [{i}] {cls_name}->{meth_name}()")

if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else 'classes.dex')

十四、DEX 文件验证机制

ART 运行时在加载每一个 DEX 文件时,会对其进行严格的完整性验证。验证逻辑的核心实现在 AOSP art/libdexfile/dex/dex_file_verifier.cc 中。理解验证机制对逆向工程有两层意义:(1) 了解修改 DEX 后哪些地方会导致加载失败;(2) 加固方案常利用验证器的特性来隐藏或保护数据。

14.1 验证阶段概览

dex_file_verifier.ccDexFileVerifier::Verify() 的实现,验证分为以下层次:

  1. Header 基础校验
  2. 区域布局校验(各 section 边界和重叠检查)
  3. 类型系统校验(各索引的交叉引用一致性)
  4. 字节码指令校验(每条方法的指令合法性)

验证器在发现第一个错误时会立即失败(即不做最大努力纠错),但会在错误信息中报告具体的失败原因和偏移。

14.2 Header 校验

验证器首先检查以下 header 字段:

  • magic:必须为 dex\n 后跟合法的版本号(035037038039040)。版本不匹配会导致立即拒绝。
  • endian_tag:必须为 0x12345678。若为 0x78563412(大端序),说明文件字节序错误(实际所有 DEX 均为小端序)。
  • header_size:必须为 0x70(112 字节)。任何其他值都会导致验证失败。
  • 所有 _off 字段:必须指向文件内的有效偏移(0 ≤ off < file_size)。
  • _size_off 一致性:例如 string_ids_off + string_ids_size * 4 ≤ file_size(每个 string_id 4 字节),且各区域不能重叠。

14.3 区域布局校验

验证器检查文件中所有区域的布局是否合法:

  • 无重叠:任何两个区域的数据范围不能重叠(例如 string_ids 区不能与 type_ids 区共享字节)。
  • 顺序一致性:区域通常按 string_ids < type_ids < proto_ids < field_ids < method_ids < class_defs < data 的顺序排列,但不强制。验证器只检查不重叠和有界。
  • map_list 自指map_off 指向的 map_list 必须包含自身(TYPE_MAP_LIST 项),且其 offset 必须与 map_off 一致。
  • map_list 涵盖了所有区域:文件中出现的每种 item 类型都应在 map_list 中有对应条目,且 sizeoffset 与 header 中的对应字段一致。

14.4 类型系统校验

验证器检查所有索引引用的有效性:

  • string_ids 索引type_id.descriptor_idxfield_id.name_idxmethod_id.name_idx 等必须指向合法的 string_id 索引(0 ≤ idx < string_ids_size)。
  • type_ids 索引field_id.class_idxmethod_id.class_idxproto_id.return_type_idx 等必须指向合法的 type_id。
  • proto_ids 索引method_id.proto_idx 必须指向合法的 proto_id。
  • 参数类型列表proto_id.parameters_off 指向的 type_list 中的每个 type_idx 必须合法。
  • 类层级一致性class_def.superclass_idx 指向的类不能是 final 类(如果该类不是接口),interfaces_off 指向的接口列表中的类型必须是 interface(ACC_INTERFACE 标志位置位)。
  • class_data 一致性class_data_off 指向的 class_data_item 中的 method_idx_diff 序列解码后,每个 method_id 的 class_idx 必须与当前类的 class_idx 一致(即方法所属类必须匹配)。

14.5 字节码指令校验

对每个 code_item(即每个非 abstract/native 方法),验证器逐条检查字节码:

  • 指令边界:每条指令的码元数必须与其操作码的定义一致。不允许一条指令的中间字节被另一条指令重新解释。
  • 操作码合法性:操作码必须在已知的 Dalvik 指令集中。遇到未定义的操作码会立即失败。
  • 寄存器引用:指令中引用的寄存器索引必须小于 registers_size。引用超出寄存器范围的指令会被拒绝。
  • 分支目标:所有跳转指令(gotoif-*packed-switchsparse-switch 等)的目标地址必须落在 insns[] 数组的有效范围内,且目标地址必须是一条指令的开头(不能跳转到某条指令的中间)。
  • 类型安全:对类型敏感的指令(如 igetiputinvoke-*),验证器检查引用的 field_id 或 method_id 是否存在、类型是否匹配。

14.6 常见验证错误及其逆向含义

错误类型 典型原因 逆向相关场景
bad magic number 文件头损坏或被替换 加固壳替换了 header;dump 时未正确对齐
invalid offset _off 超出文件范围 DEX 被截断;抽壳方案只抽走了部分数据
overlapping sections 区域数据相互覆盖 手工修改 DEX 后未更新 header 中的 size/off
bad method_idx 引用的 method_id 不存在 重建 DEX 时索引分配错误;跨 DEX 引用处理不当
bad instruction 字节码非法 代码混淆器有意插入垃圾指令;手工 patch 字节码错误
type check failure 方法签名与调用不匹配 反编译重编译过程中 method_id 表未正确重建

14.7 验证器在加固对抗中的角色

加固方案(尤其是指令抽取型加固)常常利用验证器的行为:

  • 抽取 code_item:将方法的 code_off 置零,使验证器将其视为 abstract 方法,绕过字节码校验。真正的字节码在运行时由 native 层还原。
  • 空壳 class_defs:保留 class_def 但将其 class_data_off 置零,让验证器认为类无字段/方法。真实的类数据在内存中动态注入。
  • 修改 map_list:某些加固会删除或篡改 map_list 中的条目来干扰静态分析工具(如 dexdump 依赖 map_list 遍历文件结构),但 ART 5.0+ 的验证器会严格检查 map_list 完整性。

十五、逆向实战应用

15.1 字符串提取

从 DEX 中提取所有字符串常量,用于定位敏感信息(硬编码密钥、API URL、加密盐值):

# 使用 strings 命令快速提取
strings classes.dex | head -20

# 使用 dexdump
dexdump -d classes.dex | grep -E "string|key|url|token"

# 使用上述 Python 解析器
python3 dex_parser.py classes.dex | grep -E "api|key|secret|url"

15.2 方法枚举与代码定位

smali 注入前需要找到目标方法的精确位置。通过遍历 class_defsclass_dataencoded_methodcode_item 链路可以定位任意方法的字节码。

15.3 加固检测

加固工具(如腾讯乐固、360 加固)通常会:

  • 替换 classes.dex 的内容为空壳
  • 将真实 DEX 加密后存放到 assets/ 或 lib/ 目录
  • 运行时通过 native 代码解密并动态加载

分析时可以检查 class_defs_size 是否远小于预期,或 header 中的 checksum/signature 是否有效。

15.4 多 DEX 环境下类的归属识别

在 Multi-DEX APK 中,一个关键操作是确定某个类定义在哪个 DEX 文件中。几种常用方法:

方法一:通过 method_ids 的反向查找。如果知道目标方法的方法名,可以在每个 DEX 的 method_ids 中查找对应的 name_idx,然后验证其 class_idx 是否匹配目标类。由于 method_ids 是全局去重的,出现在哪个 DEX 中该方法就属于哪个 DEX。

# 遍历所有 DEX 文件,查找包含目标 method 的 DEX
def find_method_in_dexes(dex_paths, target_class, target_method):
for dpath in dex_paths:
with open(dpath, 'rb') as f:
data = f.read()
header = parse_header(data)
strings = parse_strings(data, header)
types = parse_types(data, header)
methods = parse_methods(data, header)
for i, m in enumerate(methods):
cls = strings[types[m['class_idx']]]
meth = strings[m['name_idx']]
if target_class in cls and target_method in meth:
print(f"Found in {dpath}: method_ids[{i}] → {cls}->{meth}()")
return dpath

方法二:通过 class_defs 枚举。遍历每个 DEX 的 class_def_item 列表,解码 class_idx 获取类名。完整遍历可以建立 {类名: DEX文件名} 的映射表。

方法三:利用 baksmalibaksmali d classes.dex 输出的 smali 文件路径天然包含类名,可以直接 grep

15.5 检测字节码篡改

在逆向分析中,常常需要判断 DEX 是否被第三方修改过(如被植入广告 SDK、被注入遥测代码)。以下方法可用于检测篡改:

方法一:签名比对。重新计算原始 APK 中 DEX 的 SHA-1 签名(header 中 signature 字段的内容),与当前 DEX 对比。注意:APK 签名(V1)流程中 DEX 文件的 checksum 和 signature 会被清零后计算整个 APK 的签名——因此这里比对的是 DEX 自身的 integrity,而非 APK 签名。

方法二:结构异常检测。篡改工具可能在重建 DEX 时引入结构异常:

  • map_list 中条目数量与实际不一致
  • 某些索引指向的 string_id 为非法值(如空字符串或乱码)
  • class_data_off 指向的 class_data_item 解码出的方法数异常多或异常少

方法三:方法数/类数突变。正常 APK 的各 DEX 文件通常保持方法数相对均匀(d8 编译器的默认行为)。如果 classes.dex 仅有几个类而 classes2.dex 方法数远超 65536,说明 classes.dex 很可能被替换为壳。

方法四:字节码模式扫描。某些篡改行为会在字节码中留下特征:

  • 注入的代码通常包含特定的 URL、字符串或方法调用模式(如 System.loadLibrary 后紧跟网络请求)
  • 字节码中突然出现不属于原应用的包名前缀(如 com.google.android.gms.ads 出现在非广告类应用中)

15.6 加固壳的 DEX Header 篡改手法

加固方案在替换 DEX 时,对 header 的常见篡改手段包括:

  1. magic 字节替换:将 dex\n035\0 替换为自定义 magic(如 dex\n999\0),使标准工具无法解析。运行时由 native 代码将真实 magic 替换回内存后再交给 ART。

  2. checksum 和 signature 清零:将这两个字段清零,使文件完整性检查失效。这是最基础的手法,几乎所有壳都会使用。

  3. 区域偏移重定向:将 string_ids_offmethod_ids_off 等指向壳的自定义数据区,而非真实区域。真实的表数据被加密存储在 data 区的尾部或外部文件中。运行时由 native 层解密并在内存中重定向指针。

  4. map_list 篡改:删除或修改 map_list 中的关键条目(如 TYPE_CLASS_DATA_ITEMTYPE_CODE_ITEM),使依赖 map_list 的静态分析工具无法遍历方法代码。ART 5.0+ 的验证器检测到 map_list 不一致时会拒绝加载,因此壳必须在运行时还原。

  5. class_defs 数量虚增或虚减:虚增 class_defs_size 可干扰依赖此字段分配内存的工具(如造成 OOM);虚减则隐藏真实类。

  6. data_size 虚标:减小或增大 data_size 使 data 区范围异常,干扰依赖此字段的工具在 data 区中搜索敏感字符串。

检测手法:对比 DEX 的 file_size(header 0x1C)与实际文件大小——若两者不一致,说明 header 被篡改或文件被截断/追加。检查各 _off_size 是否合法(off + size * element_size ≤ file_size),异常者通常对应篡改点。

15.7 使用 010 Editor 进行 DEX 结构可视化分析

010 Editor(Sweetscape)是二进制文件分析利器。配合社区维护的 DEX 模板(DEXTemplate.bt),可以:

快速识别结构边界:010 Editor 的模板引擎会根据 header 中的 size/off 字段,用不同背景色标注各结构体区域(string_ids 区、type_ids 区、code_item 区等),在 hex 视图中一目了然。

交互式字段跳转:点击模板结果面板中的某个字段值(如 method_ids_off),010 Editor 会自动跳转到文件中的对应偏移位置并高亮。这在手动跟踪引用链时极其高效。

模板驱动的高亮显示:DEX 模板定义了所有结构体(header、map_list、try_item 等),在 hex 视图中选中任意字节,模板面板会自动显示其所属结构体和字段名。

如何使用 DEX 模板

  1. 下载 DEX 模板文件(通常命名为 DEXTemplate.btDalvikEX.bt),放置在 010 Editor 的模板目录下。
  2. 用 010 Editor 打开 classes.dex
  3. F5 打开”模板”面板,选择 DEX 模板后点击”运行”。
  4. 模板解析完成后,可以在结果树中逐层展开结构体,双击任意字段即可跳转到对应 hex 位置。

在加固分析中的应用

  • 加载加固后的壳 DEX,通过模板快速发现哪些 class_def.class_data_off 为零(表示无类数据——壳的特征)。
  • 对比内存 dump 出的 DEX 与磁盘上的 DEX:加载两个文件分别运行模板,在树视图中逐项对比差异,差异点通常就是壳在运行时还原的数据。

面试常考问题

Q1: DEX 的 64K 方法数限制是怎么来的?为什么不是字符串或字段限制?
A: DEX 指令使用 16 位索引引用 method_id,因此单个 DEX 文件最多可以有 2^16 = 65536 个方法引用。而 string_idsfield_ids 等虽不是每条指令都直接引用,但在 invoke-kind 指令族中,方法索引是 16 位的操作数域。字符串和字段索引在某些结构中使用 32 位,因此限制更多体现在方法数上。Multi-dex 通过将方法分散到多个 DEX 文件中绕过此限制。

Q2: MUTF-8 和 UTF-8 的区别为什么重要?
A: 三个关键差异:(1) ‘\0’ (U+0000) 在 MUTF-8 中编码为 C0 80 而非 00,保证字符串内部无空字节;(2) supplementary characters (U+10000+) 在 MUTF-8 中用 surrogate pair 分两次 3 字节编码(共 6 字节),标准 UTF-8 用一次 4 字节;(3) MUTF-8 的长度前缀是 UTF-16 码元数而非字节数。逆向时如果按标准 UTF-8 解析 MUTF-8 数据,emoji 等字符会解析出错。

Q3: 如何在 IDA/Ghidra 中定位 JNI 函数与 DEX 方法的对应关系?
A: 静态注册的 JNI 函数遵循 Java_包名_类名_方法名 命名,在 SO 的 .dynsym 符号表中直接可见。要找到对应的 DEX 方法,通过 method_ids 中的 name_idx 匹配方法名字符串,再通过 class_idx 确定类名即可。动态注册则需分析 JNI_OnLoad 中的 RegisterNatives 调用,其参数 JNINativeMethod 数组显式给出了方法名、签名和函数指针的映射。

Q4: DEX 版本 038 vs 039 的差异对逆向分析有什么实际影响?
A: 版本 038(Android 7.0/Nougat)引入了 invoke-polymorphicinvoke-custom 指令以支持 Java 8 语言特性(default methods、lambda、方法引用)。版本 039(Android 9/Pie)进一步引入了 const-method-handleconst-method-type 指令用于方法句柄支持,同时加入了 hidden API 访问限制列表。实际影响:(1) 如果使用过时的 baksmali 版本(< v2.2)反汇编 038+ 的 DEX,遇到 invoke-polymorphic 等新指令会报错或产生错误的 smali 输出;(2) 隐藏 API 列表的存在意味着直接反射调用受限的系统 API 在 039+ 设备上会触发警告或崩溃——这在 hook 框架(如 Xposed/Frida)开发中是需要特别关注的点;(3) 逆向工具(baksmali、JADX、GDA)需要持续适配新版本指令集才能正确反汇编。目前的 baksmali v2.5+ 和 JADX v1.4+ 已良好支持 040 及以下所有版本。

Q5: 加固应用中 DEX 被加密后的通用 dump 方法?
A: 所有加固方案的共同点是在运行时最终必须将 DEX 加载到 ART 中。常用 dump 方法:(1) Frida 脚本 Hook DexFile::OpenMemoryDexFile::DexFile 构造函数,在 DEX 数据被解密后直接读取内存;(2) Hook ClassLinker::LoadMethod 获取加载的类数据;(3) 利用 ART 的 ClassLoaderContext 找到已加载 DEX 的 cookie,通过 GetDexFile 系列 API 导出。关键是在 DEX 解密后、ART 加载前截获数据。


参考

  • AOSP: art/libdexfile/dex/dex_file.h — DEX 文件核心类
  • AOSP: art/libdexfile/dex/dex_file_structs.h — 所有 DEX 结构体定义
  • AOSP: art/libdexfile/dex/leb128.h — LEB128 编解码实现
  • AOSP: art/libdexfile/dex/dex_file_verifier.cc — DEX 文件验证逻辑
  • AOSP: art/dexlayout/dexlayout.cc — DEX 布局打印工具(源码级文档)
  • AOSP: dalvik/docs/dex-format.html — DEX 格式历史文档
  • AOSP: art/runtime/dex_file.cc — DEX 运行时加载实现
  • tools/dexdump/ — Android SDK 自带 DEX 分析工具源码
打赏
  • 微信
  • 支付宝

评论