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文件的整体结构,方便你理解各个表之间的关系:

DEX文件整体结构 DEX文件头 (Header) — 0x70字节 字符串表 (String IDs) — 每个条目4字节,指向字符串数据 类型表 (Type IDs) — 每个条目4字节,指向字符串表索引 方法表 (Method IDs) — 每个条目8字节(2+2+4) 类定义表 (Class Defs) — 每个条目32字节,指向类数据 数据段 (Data Section) — 包含字符串数据、类数据、代码指令等

3.7 实战:如何手动解析一个DEX文件

光说不练假把式。我建议你找个简单的DEX文件,用010 Editor或者Hex Fiend打开,对照着上面的结构一步步看。

步骤是这样的:

  1. 先找到文件头,确认magic是"dex\n035\0"。
  2. 读取string_ids_off和string_ids_size,跳到字符串表位置。
  3. 遍历字符串表,读出每个偏移,然后跳到对应位置读取字符串内容。
  4. 用同样的方法解析类型表、方法表、类定义表。
  5. 最后,根据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