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文件头是整个解析的入口。它告诉你字符串表、类型表、方法表、Class Def分别在哪里。然后字符串表和类型表之间是引用关系,类型表和方法表之间也是引用关系。最后,Class Def结构把所有这些信息整合起来,告诉你每个类里有什么字段、什么方法。
实战:如何手动解析一个DEX文件
说了这么多理论,我们来点实际的。假设你手头有一个DEX文件,想手动解析它,该怎么做?
- 第一步:读文件头。从偏移0x00开始读0x70个字节,校验magic和checksum。
- 第二步:解析字符串表。根据
string_ids_off找到字符串表,遍历所有条目,读出每个字符串。 - 第三步:解析类型表。根据
type_ids_off找到类型表,每个条目是一个索引,指向字符串表。 - 第四步:解析方法表。根据
method_ids_off找到方法表,每个条目包含类索引、原型索引、名称索引。 - 第五步:解析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