要想学习Windows文件系统的相关知识,PE文件一定是首当其冲的,在接下来的内容中,我会详细介绍PE文件的结构,和PE文件中最令人头疼的问题,也就是文件粒度和内存粒度的对齐问题。
一.PE文件是什么
PE文件,全称Portable Executable,是Windows文件系统下定义了文件在磁盘上的结构以及被Windows加载到内存中的方式,是Windows平台的核心文件格式之一。类似于其他的文件格式,比如常见的MP3,MP4和JPG等,PE文件也有后缀,常见是三种:.exe;.sys;.dll分别是可执行文件,内核驱动文件,动态链接库。这三者的结构都是类似的,当然在位数不同时,内部的结构也会发生一些变化。
二.推荐查看结构的软件
此处推荐一个用于查看PE文件结构的软件CFF explorer,由于PE文件的内部结构化对齐非常严密,可以使用该软件来查看PE文件的对应结构和16进制数据(而且还支持修改Patch)。直接在官网就可以下载到:点击进入官网 我们大致说一下使用方法:直接将对应的PE文件拖入该软件就可以对软件进行分析,通过不同的选项卡进入对应的模块查看
1. 这就是将文件拖入软件后的左侧栏,可以清晰的查看到对应的结构

2. 这是以0为起点加上偏移后的整个16进制数据
三.PE文件
此处不过多赘述此软件的用法,如有兴趣可自行查阅相关资料。
三.PE文件的结构
现在我们进入学习的重点,PE文件的整个结构学习,让我们按照内存偏移从上到下来整理一遍。
手写一遍流程:[Dos头][Dos Stub][Nt头][节表][节]
①DOS头:64字节
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header
WORD e_magic; // 魔数,必须是 'MZ' = 0x5A4D
WORD e_cblp; // 最后一页的字节数(通常无关紧要)
WORD e_cp; // 文件页数(每页512字节)
WORD e_crlc; // 重定位项数量(.EXE中才用)
WORD e_cparhdr; // 头部大小(以16字节段为单位)
WORD e_minalloc; // 程序运行时所需最小额外段数
WORD e_maxalloc; // 程序运行时所需最大额外段数
WORD e_ss; // 初始堆栈段偏移(段地址)
WORD e_sp; // 初始堆栈指针
WORD e_csum; // 校验和(几乎总为 0)
WORD e_ip; // 初始指令指针
WORD e_cs; // 初始代码段(段地址)
WORD e_lfarlc; // 重定位表地址
WORD e_ovno; // 覆盖编号(用于覆盖加载)
WORD e_res[4]; // 保留
WORD e_oemid; // OEM ID
WORD e_oeminfo; // OEM 信息
WORD e_res2[10]; // 保留
LONG e_lfanew; // NT 头(PE 头)偏移地址
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
整个DOS头一般常看的有两个结构,分别是第一个和最后一个 即:e_magic和e_lfanew。第一个又被叫做魔数,用来辩别这是一个PE文件,永远是5A4D,转换过来就是MZ,一般分析PE文件都是先检查这个数。第二个是偏移,使用DOS头的基地址加上偏移就可以寻址到NT头,进行下一步的分析。
在DOS头之后有一段DOS Stub,基本没什么用可以忽略,占用几百字节左右。
②NT头:4+20+224/240=248/264字节
NT头包含了三个部分:签名;文件头;可选头。同时也是区分32位和64位的。
typedef struct _IMAGE_NT_HEADERS32 {
DWORD Signature; // "PE\0\0" (0x00004550) ---4字节
IMAGE_FILE_HEADER FileHeader; // 文件头 ---20字节(平台架构,节的数量,可选头数量)
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // 可选头(实际上是必须的) ---224字节
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature; // "PE\0\0" ---4字节
IMAGE_FILE_HEADER FileHeader; // 文件头 ---20字节
IMAGE_OPTIONAL_HEADER64 OptionalHeader; // 可选头(64位结构) ---240字节
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;
从结构体中可以看到,第一个成员Signature固定为PE\0\0永远占据4个字节不改变。第二成员为文件头,32位和64位结构一样,内部存储的信息为:
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; //0x014c代表x86,0x8664代表64位平台(AMD)
WORD NumberOfSections; //文件中节的数量(.text,.data)
DWORD TimeDateStamp; //时间戳,生成文件的事件
DWORD PointerToSymbolTable; //
DWORD NumberOfSymbols; //
WORD SizeOfOptionalHeader; //可选头的大小(32位224,64位240)
WORD Characteristics; //文件属性(0x2代表exe,0x2000代表Dll,0x1000代表sys)
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
内部存储了节的数量,文件属性等结构。然后就是最重要的,也是区分32位和64位的可选头结构:
typedef struct _IMAGE_OPTIONAL_HEADER32 {
WORD Magic; // 0x10B表示32位
BYTE MajorLinkerVersion; // 链接器主版本号
BYTE MinorLinkerVersion; // 链接器次版本号
DWORD SizeOfCode; // 所有代码段大小
DWORD SizeOfInitializedData; // 初始化数据大小
DWORD SizeOfUninitializedData; // 未初始化数据大小
DWORD AddressOfEntryPoint; // 程序入口(RVA)
DWORD BaseOfCode; // 代码段RVA
DWORD BaseOfData; // 数据段RVA
DWORD ImageBase; // 映像默认加载地址(如 0x00400000)
DWORD SectionAlignment; // 内存中节对齐
DWORD FileAlignment; // 文件中节对齐
...
IMAGE_DATA_DIRECTORY DataDirectory[16]; // 数据目录数组(导入表、导出表等)
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
此处限于篇幅只放置32位的可选头结构,64位有些许变化,但是总体上差距不大。此处最重要的就是最后一个成员 IMAGE_DATA_DIRECTORY DataDirectory[16]即数据目录数组,该数组存储着16个重要的表结构的大小和RVA偏移(后面会说),我们先看这16个表分别是哪16个:
| 索引 | 名称 | 用途
| -- | ------------------------- | -------------- |
| 0 | Export Table | 导出表(DLL 导出函数)
| 1 | Import Table | 导入表(DLL 引入函数)
| 2 | Resource Table | 资源节(图标、菜单等)
| 3 | Exception Table | 异常处理信息
| 4 | Certificate Table | 签名证书
| 5 | Base Relocation Table | 重定位表
| 6 | Debug Directory | 调试信息
| 7 | Architecture | 保留(一般不用)
| 8 | GlobalPtr | 全局指针(保留)
| 9 | TLS Table | 线程局部存储
| 10 | Load Config Table | 加载配置表
| 11 | Bound Import | 绑定导入表
| 12 | IAT(Import Address Table) | 导入地址表
| 13 | Delay Import Descriptor | 延迟导入表
| 14 | CLR Runtime Header | .NET 运行头(托管程序)
| 15 | Reserved | 保留
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // 数据的RVA
DWORD Size; // 数据的大小
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
一般分析PE文件,都会从这16个表开始分析数据,第一个是导出函数表,第二个是导入函数表等。每一个表是一个结构,存储着偏移和表的大小,并不存储真正的数据,真正的数据还在后面的节中
③Section头(节表):描述PE文件中节的性质,40*n
也是一个数组,由多个IMAGE_SECTION_HEADER结构体组成的数组,每一个用于描述一个节。
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[8]; // 节名称(如 .text)
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress; // 加载到内存中的地址(RVA)
DWORD SizeOfRawData; // 节在文件中所占的大小(按文件对齐)
DWORD PointerToRawData; // 节在文件中的偏移
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics; // 节属性(代码、数据、只读等)
} IMAGE_SECTION_HEADER;
每一个结构体就对应了一个节的信息,包括偏移和大小等。
④节Section:存储着所有的数据(包括Data Directories的数据)
看一下基本的节都有哪些:
| 节名称 | 用途说明 | 通常包含的内容 | 偏移 |
|---|---|---|---|
.text | 代码段 | 编译后的程序机器码(函数、主程序、DLL 入口等) | 0x1000 |
.data | 数据段 | 已初始化的全局变量、静态变量 | 0x3000 |
.bss | 未初始化数据段(部分编译器使用) | 未初始化的全局变量(在内存中初始化为 0) | |
.rdata | 只读数据段 | 字符串常量、只读全局变量、导出/导入表等 | 0x2000 |
.idata | 导入表段(有时与 .rdata 合并) | Import Table, Import Address Table (IAT) | |
.edata | 导出表段(可选) | Export Table(DLL 的导出函数表) | |
.reloc | 重定位表段 | Address relocation information(重定位项) | |
.rsrc | 资源段 | 图标、菜单、对话框、版本信息等资源数据 | 0x4000 |
.tls | 线程局部存储段 | TLS 回调函数、初始化数据等 | |
.debug | 调试信息段 | 调试器使用的符号表、调试目录、源码路径等 | |
.CRT | C 运行库初始化段 | CRT 初始化/反初始化函数指针表 | |
.pdata | 异常处理表(64位) | 结构化异常处理(SEH)表 | |
.sdata | 小型数据段 | 小结构体或小全局变量 | |
.xdata | 异 |
文件在磁盘中时,每个节的大小是0x200(文件粒度),多的补0。文件被加载到虚拟内存后,每个节按照0x1000(内存粒度)对齐,多的补0。
四.PE文件的重要知识点
下面总结一下PE文件的一些需要注意的地方:
1.32位和64位有区别的位置:Nt头中的可选头数据大小不一样。注意Data Directories[16]的大小一样,节表的大小也一样
2.内存粒度和文件粒度有区别的位置:节表数据段之后,两者开始产生区别(从第一个节开始)
3.不论32位还是64位,偏移的存储都是DWORD四个字节
五.文件粒度和内存粒度
当我们在写程序需要使用别的dll中的导出函数时,就会使用LoadLibrary函数将对应的dll模块导入到我们程序的进程虚拟空间中来,此时,该PE文件就会发生膨胀(从磁盘加载到内存中):从节表之后发生节的膨胀,开始的DOS,NT和节表头按照原来位置不改变直接加载到进程虚拟空间中。由于DOS头大小+NT头大小+节表的大小一般都不超过0x1000,所以加载后直接按照0x1000对齐,第一个节一般偏移就是0x1000开头
OptionalHeader.SectionAlignment //内存粒度对齐,一般是0x1000
OptionalHeader.FileAlignment //文件粒度对齐,一般是0x200
当然我们可以直接从结构中读取处对应的粒度大小。区分完文件粒度和内存粒度后,我们再区分一对概念:FOA(文件偏移)和RVA(内存偏移),结合文件粒度和内存粒度来理解,就是FOA是在磁盘中使用的偏移,RVA是加载到内存中后使用的偏移。PE文件已经加载好了两套不同的偏移,都存储在对应的结构中:
IMAGE_SECTION_HEADER.VirtualAddress //节在文件中的起始地址(RVA空间)
IMAGE_SECTION_HEADER.PointerToRawData //节在文件中的起始地址(FOA空间)
IMAGE_SECTION_HEADER.SizeOfRawData //节在文件中实际占用大小(FileAlignment对齐)
IMAGE_SECTION_HEADER.Misc.VirtualSize //节在内存中实际占用大小(SectionAlignment 对齐)
可以通过上面的数据,来完成文件偏移向内存偏移的转化。
六.偏移问题(FOA->RVA)
其实非常简单,本质上就是两套偏移的转化,就是一个公式:
RVA = FOA - PointerToRawData + VirtualAddress
VA = ImageBase + RVA
//FOA是文件中的偏移
//PointerToRawData是节在文件中的偏移
//VirtualAddress是节在内存中的起始RVA
因为文件偏移和内存偏移:节头位置和节内数据位置的相对值是不会改变的:

如图,在.xxxx的节中有一个数据叫v1,而不论是在内存粒度对齐下还是文件粒度对齐下,v1和节头的位置都只差了0x20偏移。所以上面的公式就不难理解了。
七.总结
PE文件的内容还有很多,我们只是说了一个大概的结构,内部还有很多的细节问题我们放在下一期再说,我们下一期说导出表的结构和GetProcAddress的内部实现原理。


被折叠的 条评论
为什么被折叠?



