3、DEX文件格式详解:DEX文件头、字符串表、类型表、方法表与类定义表的结构分析
说实话,DEX文件格式是Android逆向绕不开的一道坎。你想想看,无论你是做脱壳、做热修复,还是做代码分析,最终都要跟这个二进制格式打交道。我最早接触DEX的时候,也是对着十六进制看了半天,一头雾水。后来踩了不少坑,才慢慢摸清楚它的脾气。
今天我们就来彻底搞懂DEX文件的结构。我会带着你,从文件头开始,一步步拆解到字符串表、类型表、方法表,最后到类定义表。嗯,咱们开始吧。
3.1 DEX文件头(Header)—— 整个文件的“身份证”
每个DEX文件的开头都是一个固定大小的头部,一共0x70个字节。我习惯把它叫做“文件的身份证”,因为里面记录了文件的所有关键信息。
来看一下头部的结构定义:
typedef struct DexHeader {
u1 magic[8]; // DEX文件魔数,固定为 "dex\n035\0"
u4 checksum; // 文件校验和(Adler32算法)
u1 signature[20]; // SHA-1签名
u4 file_size; // 整个DEX文件的大小
u4 header_size; // 头部大小,固定为0x70
u4 endian_tag; // 字节序标记,固定为0x12345678
u4 link_size; // 链接段大小
u4 link_off; // 链接段偏移
u4 map_off; // map item偏移
u4 string_ids_size; // 字符串表条目数
u4 string_ids_off; // 字符串表偏移
u4 type_ids_size; // 类型表条目数
u4 type_ids_off; // 类型表偏移
u4 proto_ids_size; // 原型表条目数
u4 proto_ids_off; // 原型表偏移
u4 field_ids_size; // 字段表条目数
u4 field_ids_off; // 字段表偏移
u4 method_ids_size; // 方法表条目数
u4 method_ids_off; // 方法表偏移
u4 class_defs_size; // 类定义表条目数
u4 class_defs_off; // 类定义表偏移
u4 data_size; // 数据段大小
u4 data_off; // 数据段偏移
} DexHeader;
关键字段解读:
- magic:前8个字节,固定为
64 65 78 0A 30 33 35 00,也就是"dex\n035\0"。我见过一些加固壳会修改这个魔数来对抗dump,但本质上只是障眼法。 - checksum:从第8个字节开始到文件末尾的Adler32校验和。注意,这个字段本身不参与校验。
- signature:20字节的SHA-1签名,覆盖从第12个字节到文件末尾的区域。
- endian_tag:固定为0x12345678。如果读到的是0x78563412,说明文件是小端序——嗯,Android默认就是小端,所以一般不会变。
个人经验:我在做脱壳的时候,经常先检查header_size字段。有些加固壳会把这个值改大,用来隐藏一些数据。如果你发现header_size不是0x70,那就要警惕了。
3.2 字符串表(String IDs)—— 所有字符串的“索引簿”
字符串表说白了就是一个偏移数组。每个条目是一个4字节的uint32,指向字符串数据在DEX文件中的偏移位置。
结构很简单:
typedef struct DexStringId {
u4 string_data_off; // 指向字符串数据的偏移
} DexStringId;
字符串数据本身采用Modified UTF-8编码,以'\0'结尾。注意,它和标准的UTF-8有一点区别:\u0000被编码为0xC0 0x80,而不是0x00。为什么?因为C语言的字符串以'\0'结尾,如果字符串里包含真正的0x00,就会导致截断。这个设计很巧妙,对吧?
字符串的存储格式:
uleb128 utf16_length; // 字符串的UTF-16长度
u1[] data; // 实际的字符串数据(Modified UTF-8)
u1 null_terminator; // 结束符0x00
实际应用:字符串表是逆向分析的重要入口。比如你想找某个类名"Lcom/example/MainActivity;",就可以遍历字符串表,找到对应的偏移,然后反查它在哪些地方被引用。我经常用这个方法来定位关键代码。
3.3 类型表(Type IDs)—— 类型的“编号系统”
类型表也很简单,每个条目是一个4字节的uint32,指向字符串表中的索引。
typedef struct DexTypeId {
u4 descriptor_idx; // 指向字符串表中的类型描述符
} DexTypeId;
比如,类型描述符"Ljava/lang/String;"在字符串表中的索引是100,那么类型表中某个条目的descriptor_idx就是100。这样,整个DEX文件里所有用到String类型的地方,都通过这个索引来引用。
类型描述符的规则:
- 基本类型:B(byte)、C(char)、D(double)、F(float)、I(int)、J(long)、S(short)、Z(boolean)、V(void)
- 引用类型:Lpackage/name/ClassName; 注意末尾的分号不能少
- 数组类型:[I 表示int[],[[Ljava/lang/String; 表示String[][]
我曾经踩过的坑:有一次分析一个混淆过的DEX,发现类型表里有很多奇怪的描述符,比如"Laa;"、"Lbb;"。一开始以为是错误,后来才发现是混淆器把类名缩短了。所以,不要轻易认为类型表里的数据是“脏数据”,它可能只是被混淆了。
3.4 方法表(Method IDs)—— 方法的“三要素”
方法表稍微复杂一点,每个条目包含三个索引:所属类、方法原型、方法名。
typedef struct DexMethodId {
u2 class_idx; // 所属类的类型索引
u2 proto_idx; // 方法原型的索引
u4 name_idx; // 方法名的字符串索引
} DexMethodId;
这里要注意,class_idx和proto_idx都是2字节,而name_idx是4字节。为什么?因为类和方法原型的数量通常不会超过65535个,但方法名可能非常多。这个设计很务实。
方法原型(Proto IDs)的结构:
typedef struct DexProtoId {
u4 shorty_idx; // 短签名的字符串索引(如"ILV")
u4 return_type_idx; // 返回类型的类型索引
u4 parameters_off; // 参数列表的偏移(指向TypeList)
} DexProtoId;
举个例子,方法public int add(int a, int b)的方法ID会是这样:
- class_idx → "Lcom/example/Calculator;"
- proto_idx → 返回类型"int",参数列表"int,int"
- name_idx → "add"
我的习惯:在分析恶意软件时,我通常会先扫描方法表,找那些名字看起来可疑的方法,比如"a"、"b"这种单字母方法名,或者"decrypt"、"checkLicense"这种敏感名字。这能帮我快速定位关键逻辑。
3.5 类定义表(Class Defs)—— 类的“完整档案”
类定义表是DEX文件里最复杂的部分之一。每个条目描述了一个类的完整信息。
typedef struct DexClassDef {
u4 class_idx; // 本类的类型索引
u4 access_flags; // 访问标志(public、final、abstract等)
u4 superclass_idx; // 父类的类型索引
u4 interfaces_off; // 接口列表偏移
u4 source_file_idx; // 源文件名(字符串索引)
u4 annotations_off; // 注解偏移
u4 class_data_off; // 类数据偏移(字段、方法等)
u4 static_values_off; // 静态变量初始值偏移
} DexClassDef;
access_flags的常见取值:
| 标志 | 值 | 说明 |
|---|---|---|
| ACC_PUBLIC | 0x1 | public类 |
| ACC_FINAL | 0x10 | final类 |
| ACC_SUPER | 0x20 | 使用invokespecial指令 |
| ACC_INTERFACE | 0x200 | 接口 |
| ACC_ABSTRACT | 0x400 | 抽象类 |
| ACC_ANNOTATION | 0x2000 | 注解类 |
| ACC_ENUM | 0x4000 | 枚举类 |
class_data_off指向的数据结构:
// 类数据(Class Data)
struct DexClassData {
DexClassDataHeader header; // 字段和方法数量
DexField* static_fields; // 静态字段
DexField* instance_fields; // 实例字段
DexMethod* direct_methods; // 直接方法(构造方法、私有方法等)
DexMethod* virtual_methods; // 虚方法(可被重写的方法)
};
struct DexClassDataHeader {
uleb128 static_fields_size;
uleb128 instance_fields_size;
uleb128 direct_methods_size;
uleb128 virtual_methods_size;
};
关键点:类定义表是脱壳的核心。很多加固壳会把真正的类数据(class_data_off指向的内容)加密或压缩,只保留一个空壳。脱壳的本质就是找到原始数据,修复class_data_off,然后重建DEX文件。
3.6 整体结构图
下面我用一张SVG图来展示DEX文件的整体结构,方便你理解各个表之间的关系:
3.7 实战:如何手动解析一个DEX文件
光说不练假把式。我建议你找个简单的DEX文件,用010 Editor或者Hex Fiend打开,对照着上面的结构一步步看。
步骤是这样的:
- 先找到文件头,确认magic是"dex\n035\0"。
- 读取string_ids_off和string_ids_size,跳到字符串表位置。
- 遍历字符串表,读出每个偏移,然后跳到对应位置读取字符串内容。
- 用同样的方法解析类型表、方法表、类定义表。
- 最后,根据class_data_off找到类数据,解析出字段和方法。
一个小技巧:我写了一个Python脚本,用struct.unpack来解析DEX文件。你可以试试看,手动解析一遍之后,对DEX的理解会深很多。代码很简单,核心就是按照上面的结构体定义,用unpack把二进制数据拆开。
注意:有些DEX文件可能包含多个DEX段(multi-dex),比如classes.dex、classes2.dex等。每个DEX文件都是独立的,需要单独解析。另外,Android 8.0之后引入了Compact DEX(CDEX)格式,它的头部和标准DEX略有不同,但核心思想是一样的。
好了,DEX文件的结构就讲到这里。你可能会觉得内容有点多,但说实话,这些知识是逆向工程师的“基本功”。我当年也是反复看了好几遍,又动手解析了几个文件,才真正掌握。别急,慢慢来。
公众号:蓝海资料掘金营,微信deep3321