文章目录
-
- 第1课:为什么需要数据目录?
- 第2课:数据目录在PE文件里的位置
- 第3课:一个数据目录条目的结构
- 第4课:标准数据目录条目索引表(16项)
- 第5课:几个最重要的数据目录项深入说明
-
- [5.1 导出表(索引0)](#5.1 导出表(索引0))
- [5.2 导入表(索引1)](#5.2 导入表(索引1))
- [5.3 资源表(索引2)](#5.3 资源表(索引2))
- [5.4 基址重定位表(索引5)](#5.4 基址重定位表(索引5))
- [5.5 IAT(索引12)](#5.5 IAT(索引12))
- [5.6 延迟导入表(索引13)](#5.6 延迟导入表(索引13))
- 第6课:如何从数据目录的RVA找到磁盘文件中的实际数据?
- 第7课:一个具体的例子
- 第8课:数据目录的总览表格
数据目录就像PE文件的"索引"或"总目录",它告诉你所有重要表格都藏在哪里。
第1课:为什么需要数据目录?
PE文件里有很多关键的数据结构:导出函数表、导入函数表、资源(图标、对话框等)、重定位表、调试信息等等。这些表的位置和大小不是固定的,它们由编译器/链接器在生成PE文件时决定。
如果没有一个统一的"总目录",操作系统加载器或分析工具就不知道该去哪里找这些表。它不能靠猜,也不能用固定的偏移,因为每次编译后的布局都可能不同。
所以,PE文件在头部预留了一个数组,这个数组的每个槽位专门登记一种特定表格的位置(RVA)和大小 。这就是数据目录(Data Directory)。
第2课:数据目录在PE文件里的位置
要理解数据目录的位置,我们需要先认识PE文件的宏观结构。一个PE文件大致由以下几部分按顺序组成:
| 部分 | 通俗叫法 | 作用 |
|---|---|---|
| DOS头 | DOS Header / MZ头 | 让文件在DOS下能显示"This program cannot be run in DOS mode" |
| PE签名 | PE Signature | 标识这是一个PE文件 (PE\0\0) |
| COFF文件头 | File Header | 记录机器类型、节数量等基础信息 |
| 可选头 | Optional Header | 虽然叫"可选",但在EXE/DLL里是必须的。记录入口点、基址、对齐值、数据目录等极其重要的信息 |
| 节表 | Section Table | 描述每个节(如.text, .data)在文件和内存中的位置、大小、属性 |
| 节数据 | Sections | 真正的代码和数据 |
数据目录就是可选头(Optional Header)的最后一部分。 可选头是一个很大的结构体,它的末尾定义了一个数组,专门存放各目录项的位置和大小。在C语言定义中,它长这样:
c
typedef struct _IMAGE_OPTIONAL_HEADER {
// ... 前面有很多字段,如入口点、基址、节对齐等 ...
DWORD NumberOfRvaAndSizes; // 数据目录项的个数
IMAGE_DATA_DIRECTORY DataDirectory[IMPORTANT_NUMBER];
} IMAGE_OPTIONAL_HEADER;
NumberOfRvaAndSizes 这个字段告诉你后面跟了多少个数据目录条目。虽然标准定义了16个常见的目录槽位,但这个数字理论上可以让操作系统知道实际登记了几个。通常这个值是16,但即使在文件里只定义了少数几个,加载器也会根据这个值来读取。
第3课:一个数据目录条目的结构
每个数据目录条目都是一个非常简单、固定大小的结构体,总共只有8个字节:
| 字段名 | 字节数 | 含义 |
|---|---|---|
VirtualAddress |
4 | 该目录项所指向的数据结构的起始 RVA (相对虚拟地址) |
Size |
4 | 该数据结构的大小(字节数) |
如果某一个目录项没有被使用,它的 VirtualAddress 和 Size 都会被设置为 0。例如,一个没有导出任何函数的EXE文件,其导出表对应的数据目录项就是全零。
这里的 VirtualAddress 是 RVA,也就是相对于文件加载到内存后的基址的偏移。在解析文件时,我们通常需要把RVA转换成文件内的实际偏移(FOA),才能读到真正的数据。关于RVA和文件偏移的转换,我们在后面第6课详细说。
第4课:标准数据目录条目索引表(16项)
Windows PE格式预定义了16个数据目录槽位,每个槽位都有固定的用途。下面是最完整的表格,每一个都值得记住索引号:
| 索引 | 目录名称 | 含义 | 通俗解释 |
|---|---|---|---|
| 0 | Export Table | 导出表 | 我这个DLL提供了哪些函数给别人用? |
| 1 | Import Table | 导入表 | 我要用到其他DLL的哪些函数? |
| 2 | Resource Table | 资源表 | 图标、菜单、对话框、字符串等资源都在这里 |
| 3 | Exception Table | 异常表 | 用于异常处理(如x64的展开数据) |
| 4 | Certificate Table | 安全证书表 | 数字签名、安全证书(这个比较特殊,是直接文件偏移而不是RVA) |
| 5 | Base Relocation Table | 基址重定位表 | 就是我们上次详细讨论的那个重定位表! |
| 6 | Debug | 调试信息 | 存放调试符号信息(如CodeView, PDB路径) |
| 7 | Architecture | 架构特定数据 | 极少用,通常为0 |
| 8 | Global Ptr | 全局指针 | 用于某些体系结构,通常为0 |
| 9 | TLS Table | 线程局部存储表 | 每个线程有自己的变量副本,这里放初始化数据 |
| 10 | Load Config Table | 加载配置表 | 存放安全配置,如SEH防护、Guard CF等 |
| 11 | Bound Import | 绑定导入表 | 提高DLL加载速度的预绑定信息 |
| 12 | IAT | 导入地址表 | 导入函数地址的实际存放处,运行时被填充 |
| 13 | Delay Import Descriptor | 延迟导入表 | 直到函数第一次被调用时才加载DLL |
| 14 | COM Descriptor | CLR运行时头 | .NET程序的数据,指向CLR头 |
| 15 | Reserved | 保留,必须为0 | 未使用 |
注意:有些目录项看起来很相似,比如导入表(索引1)和IAT(索引12),它们的区别在于:导入表描述的是"我要从哪个DLL导入什么函数",而IAT是运行时"这些导入函数的入口地址存放在哪块内存"。两者配合工作。
第5课:几个最重要的数据目录项深入说明
我们挑选其中与程序加载和逆向最密切的几个,稍微展开。
5.1 导出表(索引0)
DLL通过导出表告诉外界:"我可以提供这些函数"。导出表里记录了函数名、序号和函数的RVA。当你调用 GetProcAddress 时,系统就是在查询这个表。
5.2 导入表(索引1)
EXE或DLL通过导入表声明:"我需要从这些DLL里调用这些函数"。它是一个结构数组,每个结构描述一个DLL,以及要从该DLL导入的一系列函数。加载器在加载程序时,会根据导入表去加载依赖的DLL,并找到函数的实际地址,填入IAT。
5.3 资源表(索引2)
程序里嵌入的图标、位图、对话框模板、版本信息等都放在资源表。资源表是一个树形结构,按类型、名称、语言分层组织。
5.4 基址重定位表(索引5)
这就是上一讲花大量篇幅分析的表。它记录了当程序没有加载到预设基址时,哪些位置需要加上偏移修正。它的数据目录项给出整个重定位表的RVA和大小,操作系统通过这个条目找到它。
5.5 IAT(索引12)
IAT (Import Address Table) 是导入地址表。虽然在导入表里指定了要调用哪些函数,但函数的真实地址直到DLL加载后才知道。加载器把解析得到的函数地址填到 IAT 中。程序代码通过间接调用 IAT 中的地址来调用导入函数。IAT 也是一个数据目录项,因为有些保护或优化工具会直接引用它。
5.6 延迟导入表(索引13)
为了加快程序启动速度,可以把某些DLL标记为"延迟加载"。这样只有在该DLL的函数被实际调用时,加载器才去加载DLL并填入IAT。这个表的结构与导入表类似,但由延迟加载辅助函数处理。
第6课:如何从数据目录的RVA找到磁盘文件中的实际数据?
数据目录项给的 VirtualAddress 是内存加载后的RVA,并非文件内的偏移。但当我们静态分析一个PE文件(比如用十六进制编辑器打开查看)时,需要把RVA转换成文件偏移(File Offset 或 FOA)。转换的核心依据是节表(Section Table)。
节表里面记录了每个节在文件中的起始偏移(PointerToRawData)和在内存中的起始RVA(VirtualAddress),以及节的大小。转换的大致思路如下:
- 确定目标 RVA 属于哪个节。
找到满足节内存起始RVA <= 目标RVA < 节内存起始RVA + 节内存对齐后的大小的那个节。 - 计算该 RVA 在节内的偏移:
节内偏移 = 目标RVA - 节内存起始RVA。 - 文件偏移 =
节的PointerToRawData + 节内偏移。
如果RVA不在任何节内(比如落在PE头或者节间隙中),或者节的 PointerToRawData 为0,则表示该数据在文件中可能不存在,或者仅存在于内存展开时。
这就是为什么只靠数据目录的RVA还不能直接读文件,需要配合节表。
第7课:一个具体的例子
假设我们想找到一个DLL的重定位表。步骤如下:
- 在PE文件头的末尾,找到可选头(Optional Header),读取其最后一个部分的数据目录数组。
- 定位到索引5的元素(因为基址重定位表是第6个,索引从0开始)。
- 读到
VirtualAddress = 0x0002B000,Size = 0x000000A0。 - 通过节表转换:发现 RVA
0x2B000落在.reloc节中,该节的内存起始RVA为0x2B000,文件偏移为0x1A800。 - 因为 RVA 正好等于节的起始RVA,所以节内偏移为0,文件偏移也为
0x1A800。 - 从文件偏移
0x1A800开始,读取0xA0字节,就得到了整个重定位表的原始二进制数据。 - 然后按照上一讲的块结构去解析。
可以看出,数据目录就是一个指向宝藏的"藏宝图",它不直接包含数据,而是给出确切的位置和大小。
第8课:数据目录的总览表格
为了让你快速回忆,我整理一个关于数据目录本身的核心总结表:
| 问题 | 答案 |
|---|---|
| 在哪里? | 在PE可选头(Optional Header)的末尾部分 |
| 由什么组成? | NumberOfRvaAndSizes 个 IMAGE_DATA_DIRECTORY 结构,每个8字节 |
| 每个结构包含什么? | VirtualAddress (4字节RVA) + Size (4字节) |
| 默认多少个? | 通常为16个,涵盖各种标准表 |
| 未使用的条目如何表示? | VirtualAddress 和 Size 均为 0 |
| 目录表本身有大小吗? | 是的,通常占用 16 × 8 = 128 字节,在可选头之内 |
| 如何从RVA找到文件位置? | 利用节表将RVA转换成文件偏移(FOA) |
| 最重要的目录项是哪几个? | 导出表(0)、导入表(1)、资源(2)、重定位(5)、IAT(12)等 |
有了数据目录这张总清单,我们才能系统地访问PE文件内的一切关键信息。