DEX文件解析:从文件头到Class Def的完整旅程

说实话,DEX文件解析是逆向工程里最基础也最绕不开的一关。我记得刚入行那会儿,对着二进制数据一头雾水,后来慢慢摸清了门道,才发现DEX的结构设计其实挺巧妙的。今天我就带你从头到尾捋一遍,把DEX文件头、字符串表、类型表、方法表还有Class Def结构这些核心概念讲明白。

DEX文件头结构:一切从这里开始

每个DEX文件的开头都是一个固定大小的文件头,一共0x70个字节。嗯,这个数字你得记住,因为后面很多解析都要用到它。

文件头里最重要的几个字段,我列个表给你看:

偏移 大小 字段名 说明
0x00 8字节 magic 魔数,固定为"dex\n035\0"
0x08 4字节 checksum 文件校验和,用于完整性检查
0x0C 20字节 signature SHA-1签名
0x20 4字节 file_size 文件总大小
0x2C 4字节 string_ids_off 字符串表偏移
0x38 4字节 type_ids_off 类型表偏移
0x44 4字节 method_ids_off 方法表偏移
0x5C 4字节 class_defs_off 类定义表偏移

关键点:文件头里的这些偏移量,就像地图上的坐标。你拿到一个DEX文件,第一步就是读文件头,然后根据这些偏移跳到对应的数据区。

我个人习惯,解析DEX文件时先校验magic和checksum。有一次我在分析一个被篡改的APK时,发现magic被改成了"dex\n039\0",明显是有人手动改了版本号想绕过检测。这种小把戏,一看就露馅了。

字符串表:DEX里的"字典"

字符串表说白了就是一个索引数组。每个条目是一个偏移量,指向真正的字符串数据。为什么这么设计?你想想看,一个类名、方法名可能在DEX里出现几十次,每次都存一份完整的字符串太浪费了。所以DEX把字符串集中管理,其他地方只存一个索引号。

字符串表的结构很简单:

// 每个条目4字节,指向字符串数据的偏移
struct string_id_item {
    uint32_t string_data_off;  // 字符串数据的偏移
};

// 字符串数据格式
// 前1-5字节是UTF-8编码的字符串长度(变长编码)
// 后面跟着字符串内容,以'\0'结尾

小技巧:解析字符串时要注意变长编码。我见过不少新手直接读4字节当长度,结果解析出来的字符串全是乱码。正确的做法是先读一个字节,判断最高位是否为1,如果是就继续读下一个字节,直到最高位为0为止。

我曾经在分析一个混淆过的DEX时,发现字符串表里全是无意义的字母组合。但仔细一看,有些字符串其实是Unicode编码的,只是被混淆工具转成了字节序列。嗯,这种坑踩过一次就记住了。

类型表:给类型编号

类型表其实就是一个索引数组,每个条目指向字符串表中的一个字符串。说白了,类型就是字符串的别名。比如字符串表里第5个字符串是"Ljava/lang/String;",那么类型表里某个条目值为5,就表示这个类型是java.lang.String。

struct type_id_item {
    uint32_t descriptor_idx;  // 指向字符串表的索引
};

为什么要有类型表?直接存字符串不好吗?你想想看,一个类定义里要引用父类、接口、字段类型、方法参数类型……如果每次都存完整的描述符字符串,那文件得膨胀成什么样。用索引就高效多了。

方法表:记录所有方法

方法表是DEX里最核心的数据结构之一。每个方法条目包含三个信息:所属类、方法名、方法签名。

struct method_id_item {
    uint16_t class_idx;       // 所属类的类型索引
    uint16_t proto_idx;       // 方法原型索引(参数和返回值)
    uint32_t name_idx;        // 方法名的字符串索引
};

这里要注意,方法表里存的是方法的"声明",不是"实现"。方法的实际代码在另一个叫"代码区"的地方,通过Class Def结构才能找到。

注意:方法表里的class_idx指向的是类型表,不是直接指向类定义。所以你要先解析类型表,再解析方法表,顺序不能乱。

我记得有一次逆向一个加固过的APK,发现方法表里全是native方法,一个Java实现都没有。当时我就知道,这肯定是用了JNI加固,真正的代码在so文件里。这种时候,光看DEX就不够了,得去分析so文件。

Class Def结构:把一切串起来

Class Def结构是DEX文件的"目录",它告诉你每个类有哪些字段、哪些方法、继承自哪个父类、实现了哪些接口。

struct class_def_item {
    uint32_t class_idx;           // 本类的类型索引
    uint32_t access_flags;        // 访问标志(public、private等)
    uint32_t superclass_idx;      // 父类的类型索引
    uint32_t interfaces_off;      // 接口列表偏移
    uint32_t source_file_idx;     // 源文件名(字符串索引)
    uint32_t annotations_off;     // 注解偏移
    uint32_t class_data_off;      // 类数据偏移(字段、方法的具体信息)
    uint32_t static_values_off;   // 静态变量初始值偏移
};

这里最关键的字段是class_data_off,它指向一个叫"class_data"的结构,里面包含了这个类的所有字段和方法的具体信息。

class_data的结构是这样的:

struct class_data_item {
    uint32_t static_fields_size;   // 静态字段数量
    uint32_t instance_fields_size; // 实例字段数量
    uint32_t direct_methods_size;  // 直接方法数量(构造方法、私有方法等)
    uint32_t virtual_methods_size; // 虚方法数量(可被重写的方法)
    // 后面跟着字段和方法的具体数据
};

知识体系总览

说了这么多,我画个图帮你理清思路。DEX文件的解析顺序是从文件头开始,然后根据偏移量依次解析各个表,最后通过Class Def把所有的信息串联起来。

DEX文件解析流程 DEX文件头 字符串表 类型表 方法表 引用 引用 Class Def 结构 字段信息 方法信息 注解信息

从上图你可以看到,DEX文件头是整个解析的入口。它告诉你字符串表、类型表、方法表、Class Def分别在哪里。然后字符串表和类型表之间是引用关系,类型表和方法表之间也是引用关系。最后,Class Def结构把所有这些信息整合起来,告诉你每个类里有什么字段、什么方法。

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

说了这么多理论,我们来点实际的。假设你手头有一个DEX文件,想手动解析它,该怎么做?

  1. 第一步:读文件头。从偏移0x00开始读0x70个字节,校验magic和checksum。
  2. 第二步:解析字符串表。根据string_ids_off找到字符串表,遍历所有条目,读出每个字符串。
  3. 第三步:解析类型表。根据type_ids_off找到类型表,每个条目是一个索引,指向字符串表。
  4. 第四步:解析方法表。根据method_ids_off找到方法表,每个条目包含类索引、原型索引、名称索引。
  5. 第五步:解析Class Def。根据class_defs_off找到类定义表,遍历每个类,读取它的字段和方法信息。

实用工具:如果你不想手动写解析代码,可以用010 Editor配合DEX模板,或者用Python的dexparser库。我个人习惯先用010 Editor看个大概,再用Python写脚本做批量分析。

我曾经在分析一个恶意APK时,发现它的DEX文件里Class Def的数量和实际类数量对不上。仔细一查,原来是在class_data_off指向的位置做了手脚,插入了额外的虚假方法。这种手法在恶意软件里挺常见的,目的就是让分析工具解析出错。

总结

DEX文件解析说白了就是跟着偏移量走,从文件头开始,一步步把各个表解析出来,最后通过Class Def结构把所有信息串起来。这个过程看起来步骤多,但其实逻辑很清晰。你只要记住:文件头是入口,偏移量是路标,各个表是数据,Class Def是目录

嗯,掌握了这些,你就已经迈出了DEX逆向的第一步。后面还有指令解析、混淆对抗、加固脱壳等等更深的坑等着你。不过别急,一步一步来,先把基础打牢。


公众号:蓝海资料掘金营,微信deep3321