前言
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 源码 |
// DEX (Dalvik 寄存器式) —— 1条指令 |
DEX 文件的关键限制在于 method_ids 表。method_id_item 中 class_idx 和 proto_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 方式: |
二、DEX 文件整体结构
一个 DEX 文件的布局由一个固定大小的 header 和若干变长的数据区域组成,header 中的 size/offset 字段对提供各区域的定位信息。
2.1 顶层布局
offset 0x0000 |
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 |
3.2 magic[8] — 版本魔数
前 8 字节标识文件格式和版本:
64 65 78 0A = "dex\n" DEX 文件标识 |
常用版本号(据 AOSP art/libdexfile/dex/dex_file.h 及 dex_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-polymorphic 和 invoke-custom 指令,用于支持 Java 8 语言特性:默认方法(default methods)、lambda 表达式、方法引用(method references) |
039 |
28 (Pie) | 新增 const-method-handle 和 const-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 |
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 区总字节数,而非条目数。
3.7 link_size / link_off
用于静态链接场景。正常的 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 编码: |
4.3 Signed LEB128 (SLEB128)
有符号版本,符号位在最后一字节的 bit 6。
def read_uleb128(data, 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 |
每个条目 4 字节,指向 data 区域中的一个 string_data_item。
5.2 string_data_item
string_data_item { |
关键注意: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 字节) |
5.4 Python 解析 string_ids
def read_mutf8_string(data, offset): |
六、type_ids, proto_ids, field_ids, method_ids
6.1 type_id_item — 类型索引
struct TypeId { |
descriptor_idx 指向的字符串是 JNI 类型描述符:
I → int |
6.2 proto_id_item — 方法原型
struct ProtoId { |
shorty_idx 是压缩描述,第一字符是返回值类型,后续每字符是一个参数类型:
(ILjava/lang/String;J)V → shorty = "VLJ" |
parameters_off 如果为 0,表示无参数;否则指向一个 type_list:
struct TypeList { |
6.3 field_id_item — 字段定义
struct FieldId { |
6.4 method_id_item — 方法定义
struct MethodId { |
6.5 索引查找示例
假设要找到方法 "void com.example.App.onCreate(Bundle)": |
七、class_def_item 完整解析
class_def_item 每条 32 字节,是 DEX 中最复杂的结构体之一:
// AOSP: art/libdexfile/dex/dex_file_structs.h |
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 { |
direct_methods 包含:static 方法、private 方法、构造函数、类初始化器 <clinit>。
virtual_methods 包含:public/protected 方法、接口方法。
encoded_method 结构
encoded_method { |
注意 method_idx_diff 采用增量编码:第一个方法是其自身的 method_id 索引,后续方法是与前一方法的差值。这种编码方式利用 method_ids 按类分组的特性,显著减少空间占用。
encoded_field 结构
encoded_field { |
7.3 代码体:code_item
encoded_method.code_off 非零时,指向一个 code_item:
struct CodeItem { |
Dalvik 指令格式
指令使用 16 位码元(code unit),一条指令可能由 1~n 个码元组成。第一个码元的最低字节始终是操作码:
例子: add-int 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 { |
start_addr 和 insn_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 { |
encoded_catch_handler 结构
encoded_catch_handler { |
关键规则:
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 { |
catch-all 的编码
catch-all(即 catch (Throwable e) 或 finally 块的隐式 catch)不通过 type_idx 标识,而是通过 encoded_catch_handler.size <= 0 来标识其存在。catch-all 的目标地址紧跟在 handlers[abs(size)] 数组之后,以 uleb128 编码。
完整示例
考虑以下伪代码:
try { |
对应的二进制编码(示意):
=== try_item[0] === |
解析注意事项
在手动解析时,需注意:
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 位码元计) |
解码器逐条读取 opcode 并根据其值更新 address 和 line,从而构建 (address → line) 映射。
debug_info_item 的完整布局
debug_info_item { |
核心 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)
值在 0x0A 到 0xFF 之间的 opcode 为特殊 opcode,将地址前进和行号前进打包在同一个字节中:
adjusted_opcode = opcode - 0x0A // 得到 0 ~ 245 |
因此单字节即可编码 “前进 015 个码元,行号变化 -4+10”。
解码示例
假设某 debug_info 的 line_start = 10,opcode 流如下:
line_start = uleb128(10) |
逆向工程中的实际用途
- 恢复源码行号:逆向工具(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 { |
field_annotation 结构
field_annotation { |
method_annotation 结构
method_annotation { |
parameter_annotation 结构
parameter_annotation { |
注意: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 { |
8.3 annotation_set_item
annotation_set_item { |
8.4 annotation_item
注解的可见性决定了它是否保留在运行时、是否参与编译检查:
annotation_item { |
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 { |
8.6 完整注解引用链
从类定义到最终注解数据的完整路径:
class_def.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 { |
header 字节的位布局:
bit: 7 6 5 4 3 2 1 0 |
随后紧跟 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_BYTE:header = (0x00 << 5) | value_arg。例如值 0x2A 的编码为 header = 0x00,后跟 1 字节 0x2A。
VALUE_INT:value_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_ARRAY:header = (0x1C << 5) | 0 = 0xE0,其后紧跟一个 encoded_array:
encoded_array { |
VALUE_ANNOTATION:header = (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 |
9.5 static_values_off 的编码
class_def_item.static_values_off 指向一个 encoded_array_item,其中包含该类的 static 字段初始值。该数组的大小与 class_data_item 中 static_fields_size 一致,按 encoded_field 的顺序一一对应。
十、map_list — 自描述目录
map_list 是 DEX 文件的末尾结构,列出文件中每种 item 类型的数量和在文件中的偏移。其作用类似目录表,供 ART 运行时快速定位并校验文件完整性。
struct MapList { |
常见 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(始终加载) |
9.2 MultiDex 加载机制
Android 5.0 (API 21)+ 的 ART 运行时原生支持多个 DEX 的直接加载。对于旧版本,需要使用 androidx.multidex:multidex 支持库,通过反射调用 BaseDexClassLoader 的 dexPathList 来注入额外 DEX。
// AndroidManifest.xml 中声明 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 的主要布局变化包括:
- 共享数据段:将 header 后的多个区域合并,字段布局更密集。
- 预计算查找加速表:在文件头附近存储排序后的 method/field 列表,ART 可直接二分查找,无需遍历整个 table。
- 紧凑 ID 编码:method_id、field_id 等使用更小的位宽编码(如 8 位或 12 位而非 16 位),对于方法数较少的 DEX 可大幅节省空间。
- 去重数据引用:多个 class_def 共享相同的 class_data 时,仅存储一份数据。
12.4 逆向工程注意事项
- 工具支持:主流逆向工具对 cdex 已有良好支持。JADX(v1.0.0+)、GDA(v3.0+)可直接解析 cdex 格式,对使用者基本透明。baksmali 从 v2.3 开始支持 cdex 反汇编。
- 识别 cdex 文件:检查 DEX 文件的 magic 前 4 字节是否为
cdex(63 64 65 78)。标准 DEX 为dex\n(64 65 78 0A)。 - 转换为标准 DEX:Android SDK 提供的
dexdump和compact_dex_converter工具可以将 cdex 转换为标准 DEX 进行分析。但注意转换后的文件与原始 DEX 在字节级别不同(偏移、索引均有变化)。 - 内存 dump 注意:当从内存中 dump DEX 数据时,ART 内部可能已将 cdex 展开为标准 DEX 格式。因此内存 dump 得到的通常是标准 DEX,而非 cdex。
十三、完整 Python DEX 解析器
以下是一个可以解析真实 DEX 文件的 Python 实现(约 200 行):
#!/usr/bin/env python3 |
十四、DEX 文件验证机制
ART 运行时在加载每一个 DEX 文件时,会对其进行严格的完整性验证。验证逻辑的核心实现在 AOSP art/libdexfile/dex/dex_file_verifier.cc 中。理解验证机制对逆向工程有两层意义:(1) 了解修改 DEX 后哪些地方会导致加载失败;(2) 加固方案常利用验证器的特性来隐藏或保护数据。
14.1 验证阶段概览
据 dex_file_verifier.cc 中 DexFileVerifier::Verify() 的实现,验证分为以下层次:
- Header 基础校验
- 区域布局校验(各 section 边界和重叠检查)
- 类型系统校验(各索引的交叉引用一致性)
- 字节码指令校验(每条方法的指令合法性)
验证器在发现第一个错误时会立即失败(即不做最大努力纠错),但会在错误信息中报告具体的失败原因和偏移。
14.2 Header 校验
验证器首先检查以下 header 字段:
- magic:必须为
dex\n后跟合法的版本号(035、037、038、039、040)。版本不匹配会导致立即拒绝。 - 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中有对应条目,且size和offset与 header 中的对应字段一致。
14.4 类型系统校验
验证器检查所有索引引用的有效性:
- string_ids 索引:
type_id.descriptor_idx、field_id.name_idx、method_id.name_idx等必须指向合法的 string_id 索引(0 ≤ idx < string_ids_size)。 - type_ids 索引:
field_id.class_idx、method_id.class_idx、proto_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。引用超出寄存器范围的指令会被拒绝。 - 分支目标:所有跳转指令(
goto、if-*、packed-switch、sparse-switch等)的目标地址必须落在insns[]数组的有效范围内,且目标地址必须是一条指令的开头(不能跳转到某条指令的中间)。 - 类型安全:对类型敏感的指令(如
iget、iput、invoke-*),验证器检查引用的 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 命令快速提取 |
15.2 方法枚举与代码定位
smali 注入前需要找到目标方法的精确位置。通过遍历 class_defs → class_data → encoded_method → code_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 |
方法二:通过 class_defs 枚举。遍历每个 DEX 的 class_def_item 列表,解码 class_idx 获取类名。完整遍历可以建立 {类名: DEX文件名} 的映射表。
方法三:利用 baksmali。baksmali 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 的常见篡改手段包括:
magic 字节替换:将
dex\n035\0替换为自定义 magic(如dex\n999\0),使标准工具无法解析。运行时由 native 代码将真实 magic 替换回内存后再交给 ART。checksum 和 signature 清零:将这两个字段清零,使文件完整性检查失效。这是最基础的手法,几乎所有壳都会使用。
区域偏移重定向:将
string_ids_off、method_ids_off等指向壳的自定义数据区,而非真实区域。真实的表数据被加密存储在 data 区的尾部或外部文件中。运行时由 native 层解密并在内存中重定向指针。map_list 篡改:删除或修改
map_list中的关键条目(如TYPE_CLASS_DATA_ITEM、TYPE_CODE_ITEM),使依赖 map_list 的静态分析工具无法遍历方法代码。ART 5.0+ 的验证器检测到 map_list 不一致时会拒绝加载,因此壳必须在运行时还原。class_defs 数量虚增或虚减:虚增 class_defs_size 可干扰依赖此字段分配内存的工具(如造成 OOM);虚减则隐藏真实类。
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 模板:
- 下载 DEX 模板文件(通常命名为
DEXTemplate.bt或DalvikEX.bt),放置在 010 Editor 的模板目录下。 - 用 010 Editor 打开
classes.dex。 - 按
F5打开”模板”面板,选择 DEX 模板后点击”运行”。 - 模板解析完成后,可以在结果树中逐层展开结构体,双击任意字段即可跳转到对应 hex 位置。
在加固分析中的应用:
- 加载加固后的壳 DEX,通过模板快速发现哪些
class_def.class_data_off为零(表示无类数据——壳的特征)。 - 对比内存 dump 出的 DEX 与磁盘上的 DEX:加载两个文件分别运行模板,在树视图中逐项对比差异,差异点通常就是壳在运行时还原的数据。
面试常考问题
Q1: DEX 的 64K 方法数限制是怎么来的?为什么不是字符串或字段限制?
A: DEX 指令使用 16 位索引引用 method_id,因此单个 DEX 文件最多可以有 2^16 = 65536 个方法引用。而 string_ids、field_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-polymorphic 和 invoke-custom 指令以支持 Java 8 语言特性(default methods、lambda、方法引用)。版本 039(Android 9/Pie)进一步引入了 const-method-handle 和 const-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::OpenMemory 或 DexFile::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 分析工具源码





