PE文件结构详解以及内存粒度对齐问题

  要想学习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调试信息段调试器使用的符号表、调试目录、源码路径等
.CRTC 运行库初始化段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的内部实现原理。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值