PE 文件格式


基础知识

PE(Portable Execute)

PE 文件是 Windows 下可执行文件的总称,常见的有*.dll*.exeOCXSYS等,事实上,一个文件是否是 PE 文件与其扩展名无关,PE 文件可以是任何扩展名。

PE 文件格式把可执行文件分成若干个数据节(section),不同的资源存放在不同的节中。

  • .text:由编译器产生,存放着二进制的机器代码,也是我们反汇编和调试的对象。
  • .data:初始化的数据块,如宏定义、全局变量、静态变量等。。
  • .idata:可执行文件所使用的动态链接库等外来函数与文件的信息。
  • .rsrc:存放程序的资源,如图标、菜单等。

初次之外,还可能出现的节包括.reloc.edata.tls.rdata等。

PE 文件的结构一般来说如下图所示:从起始位置开始依次是 DOS 头,NT 头,节表以及具体的节。(由下而上

img

虚拟内存

Windows 的内存可以被分为两个层面:物理内存和虚拟内存。其中,物理内存比较复杂,需要进入 Windows 内核级别 ring0 才能看到。通常,在用户模式下,我们用调试器看到的内存地址都是虚拟内存。

Windows 让所有进程都 “相信” 自己拥有独立的 4GB 内存空间。但是,我们计算机中那根实际的内存条可能只有 512MB。

虽然每个进程都 “相信” 自己拥有 4GB 的空间,但实际上它们运行时真正能用到的空间根本没有那么多。内存管理器只是分给进程了一片虚拟地址,让进程认为这些地址都是可以访问的。如果进程不使用这些虚拟地址,那么它们对进程来说就只是一些无形的财富;当需要进行实际的内存操作时,内存管理器才会把 “虚拟地址” 和 “物理地址” 联系起来。

PE 文件与虚拟内存之间的映射

  1. 静态反汇编工具看到的 PE 文件中某条指令的位置是相对于磁盘文件而言的,即所谓的文件偏移,我们可能还需要只读这条指令在内存中所处的地址,即虚拟内存地址(VA)。

  2. 反之,在调试时看到的某条指令的地址是虚拟内存地址,我们也经常需要回到 PE 文件中找到这条指令对应的机器码。

  • 文件偏移地址

数据在 PE 文件中的地址叫文件偏移地址,这是文件在磁盘上存放时相对于文件开头的偏移。

  • 装载基址

PE 装入内存时的基地址。默认情况下,exe 文件在内存中的基地址是 0x00400000,dll 文件是 0x10000000.这些位置可以通过修改编译选项更改。

  • 虚拟内存地址

PE 文件中的指令被装入内存后的地址。

  • 相对虚拟地址

相等虚拟地址是内存地址相当于映射基址的偏移量。

虚拟内存地址、映射基址、相对虚拟内存地址三者之间有如以下关系。

1
VA =  Image Base + RVA

在默认情况下,一般 PE 文件的 0 字节将对映到虚拟内存的 0x00400000 位置,这个地址就是所谓的装载基址(Image Base)。

文件偏移是相当于文件开始 0 字节的偏移,RVA(相对虚拟地址)则是相对于装载基址 0x00400000 处的偏移。由于操作系统在进行装载时 “基本” 保持 PE 中的各种数据结构,所以文件偏移地址和 RVA 有很大的一致性。

之所以说 “基本” 上一致是因为还有一些细微的差异。这些差异是由于文件数据的存放单位与内存数据存放单位不同而造成的。

  1. PE 文件中的数据按照磁盘数据标准存放,以 0x200 字节为基本单位进行组织。当一个数据节(section)不足 0x200 字节时,不足的地方将被 0x00 填充;当一个数据节超过 0x200 字节时,下一个 0x200 块将分配给这个节使用。因此 PE 数据节的大小永远是 0x200 的整数倍。
  2. 当代码装入内存后,将按照内存数据标准存放,并以 0x1000 字节为基本单位进行组织。类似的,不足将被补全,若超出将分配下一个 0x1000 为其所用。因此,内存中的节总是 0x1000 的整数倍。

img

下表列出的文件偏移地址和 RVA 之间的对应关系可以让你更直接地理解这种 “细微的差异”。

相对虚拟偏移量 文件偏移量
.text 0x00001000 0x0400
.rdata 0x00007000 0x6200
.data 0x00009000 0x7400
.rsrc 0x0002D 000 0x7800

那么文件偏移地址与虚拟内存地址之间的换算关系可以用下面的公式来计算。

文件偏移地址 = 虚拟内存地址(VA)−装载基址(Image Base)−节偏移 = RVA -节偏移

  • 基地址(ImageBase)

    当PE文件通过Windows载入内存后,内存中的版本称为模块(Module)。映射文件的起始地址称为基地址(ImageBase)。也就是我们常说的基址。

    IDA加载的时候,会默认以00400000作为基地址,而调试器加载的时候,基地址由操作系统决定。也就是会出现动态分析的地址和静态分析的不一样。

    我们在IDA中选择 Edit->Segments->Rebase Program进行修改。

    image-20220102135923154

  • 虚拟地址(Vitual Address,VA)

    PE文件被系统加载器映射到内存中,每个程序都有自己的虚拟空间,这个虚拟空间的内存地址称为虚拟地址(Vitual Address,VA)

  • 相对虚拟地址(Relative Vitual Address,RVA)

    RVA 就是相对虚拟偏移,就是偏移地址。假设一个 EXE 文件从 0x400000h 处载入,它的代码区块开始于 0x401000h 处,代码区块的 RVA 计算方式如如下:

    目标地址401000h - 载入地址400000h = RVA 1000h

    将一个RVA转换成真实的地址,如下:

    虚拟地址(VA)= 基地址(ImageBase)+相对虚拟地址(RVA)

  • 文件偏移地址

    FOA: 文件偏移,就是文件中所在的地址。当 PE 文件在磁盘中时,某个数据的位置相对于文件头的偏移量称为文件偏移地址(File Offset)或物理地址(RAW Offset)

MS-DOS头

DOS 头主要就是为了兼容之前的 DOS 操作系统,DOS 头后面便是 DOS Stub,这两部分构成了 MS-DOS 可执行文件的基本要素,如果 PE 文件运行在 DOS 系统中便会执行 Stub 中的代码,一般上都是 “Thisprogram cannot run in DOS mode ”。所以 DOS 没有什么可以讲的,主要就是开头两个字节 MZ 表明他是一个 PE 文件的 DOS 头开始。还有就是距离 MZ 偏移量为 0x3C 的内容便是PE头的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct _IMAGE_DOS_HEADER {
*0x00 WORD e_magic; //DOS可执行文件标记"MZ"(MS-DOS的创建者之一),5A4Dh
0x02 WORD e_cblp;
0x04 WORD e_cp;
0x06 WORD e_crlc;
0x08 WORD e_cparhdr;
0x0a WORD e_minalloc;
0x0c WORD e_maxalloc;
0x0e WORD e_ss;
0x10 WORD e_sp;
0x12 WORD e_csum;
0x14 WORD e_ip; //DOS代码入口IP
0x16 WORD e_cs; //DOS代码入口IP
0x18 WORD e_lfarlc;
0x1a WORD e_ovno;
0x1c WORD e_res[4];
0x24 WORD e_oemid;
0x26 WORD e_oeminfo;
0x28 WORD e_res2[10];
*0x3c DWORD e_lfanew; //指向PE文件头"PE",0,0。文件头的相对偏移(RVA)
};

PE文件头

紧跟着 DOS 头的是 PE 文件头(PE Header)。PE Header 是 PE 相关结构 NT 映像头(_IMAGE_NT_HEADERS)的简称。PE 装载器从_IMAGE_DOS_HEADERSe_lfanew字段里找到PEHeader的起始偏移量,用其加上基址,得到 PE 文件头的指针。

1
PNTHeader = ImageBase + dosHeader->e_lfanew

_IMAGE_NT_HEADERS

1
2
3
4
5
struct _IMAGE_NT_HEADERS {
0x00 DWORD Signature; //PE文件标识
0x04 _IMAGE_FILE_HEADER FileHeader;
0x18 _IMAGE_OPTIONAL_HEADER OptionalHeader;
};
  • _IMAGE_FILE_HEADER

    _IMAGE_FILE_HEADER指出了 _IMAGE_OPTIONAL_HEADER的大小

    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct _IMAGE_FILE_HEADER {         //映像文件头
    0x00 WORD Machine; //运行平台
    0x02 WORD NumberOfSections; //文件的区块数
    0x04 DWORD TimeDateStamp; //文件创建日期和时间
    0x08 DWORD PointerToSymbolTable; //指向符号表(用于调试)
    0x0c DWORD NumberOfSymbols; //符号表中符号的个数(用于调试)
    0x10 WORD SizeOfOptionalHeader; //_IMAGE_OPTIONAL_HEADER结构的大小
    0x12 WORD Characteristics; //文件属性
    };
  • 数据目录表

数据目录表是 PE 文件中各种数据结构的索引目录,由多个IMAGE_DATA_DIRECTORY组成的数组。PE 头的一部分。

区段表

PE文件头后面是区段表,用于描述各个区段的属性,文件至少拥有一个区段才能执行。区段表是多个相连的IMAGE_SECTION_HEADER结构组成。

  • 区段
区段名 描述
.text 代码段,里面的数据全都是代码
.data 可读写的数据段,存放全局变量或静态变量
.rdata 只读数据区
.idata 导入数据区,存放导入表信息
.edata 导出数据区,导出表信息
.rsrc 资源区段,存放程序用到的所有资源,如图表,菜单等
.bss 未初始化数据区
.crt 用于支持C++运行时库所添加的数据
.tls 存储线程局部变量
.reloc 包含重定位信息
.sdata 包含相对于可被全局指针定位的可读写数据
.srdata 包含相对于可被全局指针定位的只读数据
.pdata 包含异常表
.debug$S 包含OBJ文件中的Codeview格式符号
.debug$T 包含OBJ文件中的Codeview格式类型的符号
.debug$P 包含使用预编译头时的一些信息
.drectve 包含编译时的一些链接命令
.didat 包含延迟装入的数据
  • 导入表

导入表机制是指 PE 文件从第三方程序中导入 API 以提供本函数调用的机制。事实上 Windows 平台下所有系统提供的 API 函数都是通过导入导出表完成的。所以想要看程序调用了哪些函数就要看导入表。但是其实也可以手工来调用而不是直接调用,比如用LoadLibrary()等函数加载dll文件然后获得里面函数的指针,最终直接通过地址调用函数。

两种方式:第一是直接导入API,进行调用。第二种是加载对应的库进内存,通过地址调用(基址+偏移)。后面免杀会用到。

Windows程序没有延迟绑定机制,也就没有 PLT/GOT表,Windows程序通过导入表和导出表来调用库函数。

导入表是为了实现代码重用而设置,通过分析导入表数据,可以获得 PE文件指令中调用了多少外来函数,以及这些外来函数都存在于哪些动态链接库里等信息。Windows加载器在运行PE时,会将导入表中声明的动态链接库一并加载到进程的地址空间,并修正指令代码中调用的函数地址。在数据目录中一共有四种类型的数据与导入表数据有关:导入表、导入函数地址表、绑定导入表、延迟加载导入表。导入表通常位于 .idata段;

导入表是PE数据组织中的一个很重要的组成部分,它是为实现代码重用而设置的。通过分析导入表数据,可以获得诸如PE文件的指令中调用了多少外来函数,以及这些外来函数都存在于哪些动态链接库里等信息。Windows加载器在运行PE时会将导入表中声明的动态链接库一并加载到进程的地址空间,并修正指令代码中调用的函数地址。在数据目录中一共有四种类型的数据与导入表数据有关: 导入表、导入函数地址表、绑定导入表、延迟加载导入表。

程序中,导入表的地址通常位于.idata

  • 导出表

导出表是 PE 文件为其他应用程序提供 API 的一种函数导出方式。

导出表由名称表、函数表和序号表,后两者是必须要的,名称表则是可选的。名称表和序号表起到索引找到函数表中的函数的作用,而函数表则是记录函数地址的。然而导出表的序号顺序和函数顺序并不是对应的,在内存中的数据是被完全打乱的,函数的顺序是按照名称表来确定的。

基址重定位表

PE文件在重定位过程中会用到基址重定位表。

PE重定位

DLL/SYS

EXE

PE重定位时执行的操作

PE重定位操作原理

  • 基址重定位

数据目录表中的IMAGE_DIRECTORY_ENTRY_BASERELOC结构指向重定位目录。由于dll文件并不一定可以装载到默认的基址,所以需要进行重定位。例如在dll文件中有一条确定地址的指令,这时就需要用到重定位,通常是加载多个相同基地址的dll会用到。这里不再细述,等以后用到再详细来看。