PE文件格式

PE,可移植的可执行文件,文件是Windows操作系统中用于可执行文件(如EXE)、动态链接库(DLLs)以及其他文件类型(如OCX、SYS、COM等)的标准格式。

划分:一个PE文件分为了两大块,分别是文件头和很多个sections组成的节区。

其中文件头里面又包含了这个PE文件的一系列的属性信息以及节区的一些索引信息。而节区则包含了这个文件中的程序指令,数据,导入、导入表等等一系列的内容。

一、文件头

文件头:分别是DOS部分、PE文件头、可选头、数据目录、节表。

1、DOS头与实模式遗留程序

在Windows NT之前的Windows系统是基于dos操作系统内核, 为了兼容dos系统上可执⾏⽂件,windows NT在设计可执⾏⽂件格式时保兼容了之前的格式,这一部分是为了兼容dos操作系统遗留下来的设计。

其实微软为了确保新的Windows应用程序,在老旧的dos系统上至少能够显示一些有意义的信息,而不是直接运行不起来或者直接系统崩溃, 所以PE文件最开始的地方包含了一段兼容dos操作系统的内容,这部分内容具体展开又分为两块。

一块是定长的DOS头,一块是不定长的DOS stub存根或者叫做残留部分

DOS部分(64个字节)

前面的DOS头长度是64个字节,是一个标准的结构体。定义是这样的,因为是上古DOS的遗留产物,没有必要每个字段都去研究,重点来看看首尾的两个字段 。

第一个字段也就是整个文件开始的两个字节,是固定的4D5A对应的ASCII字符是MZ,这两个字符也是早期微软编译器的作者名字的缩写。

这两个字符通常也是我们判断一个文件是不是一个PE文件的重要参考之一

在内存中去搜索PE文件的场景,比如有些木马的dll会注入到进程中,我们做检测的话就需要扫描整个进程的地址空间去检查内存中有没有疑似的dll文件。这个时候呢判断mz标志就是一个最基本的操作。

然后是最后一个字段,这是一个4字节的整数,它只是了Windows PE文件的头部在文件中的偏移位置。

注意,这里说的是PE头,而不是整个PE文件的头,这个概念还是有点儿区别。

Windows操作系统在加载PE文件执行的时候,就是直接读取这个偏移值,然后从文件的这个位置开始解析PE文件真正的头部。

dos实模式残留程序

它是在调用21号中断的9号功能向屏幕输出一个字符串,然后调用4号功能退出程序。

2、PE文件头、NT头

PE文件头:这才是一个PE文件,真正的头部。PE实际上是在一个更大的文件头里面,这个头叫做NT头。

NT头分为三个部分。前面是4个字节的标识符,然后是PE头,最后是可选头。

(1)、标识符 (4个字节)

前面4个字节的标识,它固定是50450000,也就是ASCII字符的PE,和文件头部的MC标志一样,也是我们常常判断一个文件是否是有效PE文件的另一个判断依据。

(2)、PE头(20个字节)

PE文件头,它的长度是20个字节,包含了一些重要的信息。

1、运行的平台,比如是x86架构还是x64架构 2、节的数量,这里记录了PE文件后面的节区部分中节的数量。 3、文件创建的时间。 4、指向符号表的指针5、可选择的长度,用来指示接下来的可选头部分的长度,但这种设计是用来应对,将来可选头如果有扩展长度发生变化,而通过这里的长度字段就能够兼容处理。 6、文件的属性,这是一个word,也就是两个字节,它里面是有一系列的比特位构成的一个字段。每一个比特位呢都有不同的含义,比如区分这是一个exe还是一个dll文件。

(3)、可选头(224个字节)

224个字节。不要被这个名字欺骗了,虽然名字叫做可选的。但是对于可执行文件exe和动态链接库dll绝对是必不可少的,这里面有PE文件大量的重要信息

这里面的字段就太多了,我们重点来关注其中几个字段

1.IamgeBase

这个字段是表示这个PE文件要被加载到进程中的哪个地址 对于exe文件呢,这个值一般默认是0x00400000,对于一个动态链接库文件这个值一般是x10000000。可以通过VC++里面去设置修改,并不是说一定要是这个地址。

同时还要注意,这个只只是一个建议值。实际上加载的地址很可能不是这个值。 一方面比如dll文件默认如果都是这个字,那一个进程往往会加载很多个dll,那不就冲突了吗?所以如果这个值加载不了的话,那操作系统也会另外选择来加载。 另一方面更重要的是啊,因为固定的加载基地址很容易被黑客计算出内存地址,从而进行攻击,所以现在高版本的操作系统都支持ASLR的安全机制,也就是地址空间加载随机化,每一次加载的地址都随机化了,让黑客很难提前计算出地址来攻击。以前没有这个机制的时候写攻击程序非常容易,一大片中招。

2.AddressOfPoint

这个指的是文件的执行入口地址,注意这个地址是一个相对地址。相对的参考地址就是上面的ImageBase。

对于可执行文件这就是起始地址,进程开始运行的时候,就从这个地址开始去执行指令,对于动态链接库,这也是初始化函数的地址。如果这是一个c/从++编写的exe程序,那这个入口地址是不是就是我们写的main函数呢?

3、DllCharacteristics

这个字段和PE文件里面的Characteristics字段非常类似,也是由一系列的比特位组成,每一个比特位都描述了这个文件的一个属性,比如后续操作系统引入的一些安全机制,像是刚才提到的地址空间加载随机化ALSR数据执行保护DEP控制类保护CFG等等,如果这个PE文件支持这些安全机制,就会有对应的比特位标识。这样操作系统在加载的时候就会应用对应的安全机制,比如有ALSR的话,就会随机加的它。 那是否有这些标志位呢?是由编译器在编译生成这个文件的时候写入了。我们可以通过编译器的编译选项来控制是否启用。

可选头中与对齐相关的字段

4、FireAlignment

这个字段指的是原始PE文件中各个部分的对齐大小,默认是硬盘扇区大小512个字节

文件的头部以及每个节都需要按照这个大小来进行对齐,如果小于512个字节,剩下的这需要使用00来填充,凑够510个字节。换算成16进制就是0x200,所以大家用16进制编辑器打开一个PE文件的话,就可以看到他的所有节的开始地址都是0x200的整数倍

6、SectionAligenment

这个字段指的是文件被加载到内存中以后各部分的对齐大小。默认操作系统的一个内存页面的大小,x86架构下默认就是4kb,转换成16进制,也就是0x1000

所以如果你用调试器在进程的内存中查看一个PE文件,可以看到它的所有节的开始地址都是0x100的整数倍。就因为这两个对齐值的不同了,导致硬盘上的PE文件加载到内存中以后占据的空间会变大

3、数据目录 (8个字节,16项)

在可选头的最后一部分是一个数组,这就是数据目录

在一个PE文件中呢,除了指令和数据还有很多重要的内容。

比如这个文件可能要依赖一些其他动态链接库中的函数,那就需要一个表来登记这些信息,这叫导入表。
再比如一个动态链接库文件,自己要导出一些函数给其他程序使用,那也需要一个表来告诉别人自己里面有哪些函数可供调用,他们的名字和地址分别是什么?这就是导出表

类似于这样的内容,还有很多,那这些重要的内容分别在PE文件的哪个地方?统一登记在这个叫数据目录的数组中。

这个数组的每一项都是一个IMAGE_DATA_DIRECTORY的结构。这个结构是有两个成员,分别是虚拟地址和大小。同样这里的地址也是一个相对地址,这个数组的长度不是固定的,而是有可能会发生变化的。

目前的Windows中呢一般是16,也就是说这个数组记录了16种数据的位置和大小。

这16种数据分别是 导出表导入表资源表异常表 证书表重定位表调试信息表特定与体系结构的数据 全局指针表线程本地存储表加载配置表绑定导入表导入地址表延迟导入表com描述符表,最后呢还保留了一个暂时没使用的表项

4、节表

在可选头的后面,紧接着的是文件头部的最后一部分。

这里又是一个表,名字叫做节表,PE文件是以为单位,在组织各种代码和数据。

在正文部分依次排列,在文件头部这里设了一个表,用来索引这些节的信息。

表中的每一项都是一个固定的结构,长度是40个字节,描述了文件的正文部分的一个节的信息。

包括节的名字,节加载的地址,这里的地址同样还是相对于IamgeBase相对地址。还有节在PE文件中的大小,节的一些属性信息等等,在PE文件中节的名字一般都是以.开头。然后总长度不超过8个字节,这是由上面这个结构的定义所决定的。

上面提到在文件头的最后一部分有一个节表,表项的数量是PE头中的字段NumberOfSections决定的。表中的每一项都描述了一个节的内容,包括这个节的名字,位置,大小等一些其他属性。通过遍历这个表呢就能把一个PE文件中的所有节的信息给打印出来了,下面呢是一些常见的节的一些作用

.text : 代码节(VC) 
.code : 代码节(VB/Delphi) 
.data : 数据节(⼀般存放已初始化的全局变量,静态变量) 
.rdata : 只读数据节(⼀般存放只读数据,如常量字符串,C++虚表) 
.idata : 输⼊数据表(⼀般⽤来存放IAT和导⼊表) 
.bss : 通常是指⽤来存放程序中未初始化的全局变量、静态变量 
.textbss : 节中同时包含代码和未初始化全局变量、静态变量 
.rsrc : 资源节 
.reloc : 重定位表
VirtualAddress

VirtualAddress节的相对虚拟地址RVA

PointToData

PointToData节在文件中的偏移

有了这两个信息,我们在研究PE文件中出现的所有RVA都可以定位到它具体在哪个节、在文件中的哪个地方、加载了内存后在哪个地方,这里面的计算呢就有些繁琐,简单了解一下即可。

PE文件后续的众多数据结构就一直在于这两个概念打交道,一定要清楚当前说的地址是哪种情况。

总结:

最开始是dos实模式残留部分。这一部分又分为定长的64个字节的头和一段不定长的实模式编程序。 在dos定长头部的最后4个字节,指示了NT头部在文件中的偏移,这个NT头里面又分为三个部分,分别是一个ASCII符为PE的标识字段、一个PE文件头和一个可选头,在可选头的尾部又有一个数据目录的数组。 目前这个数组有16项,记录了PE文件中一些重要信息所在的位置和大小,文件头的最后一部分是节表,每一项是40个字节,描述了正文部分一个节的基本信息。

二、PE文件加载到内存的过程

一是PE文件加载到内存的过程,看看加载到内存中的PE文件和磁盘上的PE文件有什么区别。第二是节区了一些重要的内容,比如导入表,导出表等等。

那学习这个有什么用呢?我们在做逆向的时候,可能会遇到一些恶意软件,病毒,木马等会去修改PE文件的导入、导出表,从而实现代码的注入或者劫持执行流程。如果你不知道导入、导出表的结构,可能就看不懂这些恶意程序是在干嘛。

1.节表

上面提到在文件头的最后一部分有一个节表,表项的数量是PE头中的字段NumberOfSections决定的。表中的每一项都描述了一个节的内容,包括这个节的名字,位置,大小等一些其他属性。通过遍历这个表呢就能把一个PE文件中的所有节的信息给打印出来了,下面呢是一些常见的节的一些作用。

.text : 代码节(VC) 
.code : 代码节(VB/Delphi) 
.data : 数据节(⼀般存放已初始化的全局变量,静态变量) 
.rdata : 只读数据节(⼀般存放只读数据,如常量字符串,C++虚表) 
.idata : 输⼊数据表(⼀般⽤来存放IAT和导⼊表) 
.bss : 通常是指⽤来存放程序中未初始化的全局变量、静态变量 
.textbss : 节中同时包含代码和未初始化全局变量、静态变量 
.rsrc : 资源节 
.reloc : 重定位表

2、可选头中与对齐相关的字段

Windows操作系统在加载PE文件到内存中时,因为有一个页面对齐的缘故,操作系统在加载PE文件到内存的时候,会给这个PE文件一定程度上"注水",使得内存中的PE文件会比硬盘上的文件要一些。

在可选头中还有一些字段就跟这个对齐有关系。

(1)FireAlignment

这个字段指的是原始PE文件中各个部分的对齐大小,默认是硬盘扇区大小512个字节

文件的头部以及每个节都需要按照这个大小来进行对齐,如果小于512个字节,剩下的这需要使用00来填充,凑够510个字节。换算成16进制就是0x200,所以大家用16进制编辑器打开一个PE文件的话,就可以看到他的所有节的开始地址都是0x200的整数倍

(2)SectionAligenment

这个字段指的是文件被加载到内存中以后各部分的对齐大小。默认操作系统的一个内存页面的大小,x86架构下默认就是4kb,转换成16进制,也就是0x1000

所以如果你用调试器在进程的内存中查看一个PE文件,可以看到它的所有节的开始地址都是0x100的整数倍。就因为这两个对齐值的不同了,导致硬盘上的PE文件加载到内存中以后占据的空间会变大。

3、FOA与RVA

FireAlignment和SectionAligenment他们之间的对应关系

在研究PE文件格式的时候,经常会碰到两个概念,相对虚拟地址RVA文件偏移地址FOA就与这个对齐值有关系。RVA说的是PE文件加载到内存中以后,相对于PE文件加载基地址的偏移量。而FOA则指的是文件中的某个地址相对于PE文件头部的偏移量,如果PE文件加载到内存后,对其力度也是在文件中是一样的话,那这两个值就是相等的。

但偏偏这两个字不相等,就会存在一个转换关系,对于文件头部的这些内容,FOA和RVA都是一样的。但是从第一个节的内容开始,情况就开始不同了,比如在文件中第一个节的第一个字节距离文件起始的偏移FOA一般就是0x400,之所以不是0x200是因为头部的大小已经超过了这个值,那对于对齐下来就是0x400,而在内存中第一个节的第一个字节距离PE文件加载基地址的偏移RVA就是0x1000

那这里就需要一个转换,Windows的加载器是如何转换的呢?注意,这可不是一个简单的线性计算就能完成,因为你没法预计每一个节的大小,如果每一个节的大小都是一样的,那算起来就简单。但现实情况并不是这样,有些节可能有多个页面,有些节可能就只有一个页面。这个时候呢直接用一个简单的线性关系来转换是行不通的,需要有地方把这两个关系给记录起来。

而记录这个信息的地方就在上面提到的节表里面。

4、节的头部信息中关于节的位置信息

节表中的每一项都描述了一个节的信息,包括名字,属性等等,这里面呢其实还有两个字段。

(1)VirtualAddress

VirtualAddress节的相对虚拟地址RVA

(2)PointToData

PointToData节在文件中的偏移

有了这两个信息,研究PE文件中出现的所有RVA都可以定位到它具体在哪个节、在文件中的哪个地方、加载了内存后在哪个地方,这里面的计算呢就有些繁琐,简单了解一下即可。

PE文件后续的众多数据结构就一直在于这两个概念打交道,一定要清楚当前说的地址是哪种情况。

5、静态库和动态库

首先是导入表,我们在编程开发的时候啊总是免不了的要引入其他的库函数,引入的方式有两种:静态库和动态库

静态库通过编译链接以后,库和程序本身都编译成同一个PE文件。

而动态库则不同,动态库也是一个PE文件,通过动态链接的形式被程序调用。

导入表和导出表

什么是动态链接呢?这就与导入表和导出表有重要的关系了。首先一个动态库文件是需要被其他程序或者其他动态库使用的,它需要向外界表明自己有哪些函数可供外界调用,他们的名字和地址分别是什么,这就是导出表

作为使用的一方呢?使用者PE文件也需要一个表格,来告诉Windows的加载器自己依赖于哪些动态库的哪些函数?这就是导入表

Windows的加载器在加载一个PE文件的时候就会去解析它的导入表,把它需要依赖的动态库全部加载进来。如果一旦发现某个动态库找不到,那PE文件加载宣告失败,这个时候呢就会弹出一个dll缺失的错误消息。

导入表

数据目录里面登记了PE文件中一些重要数据的结构的位置和大小,这其中呢就有导入表

导入表的信息登记在数据目录第二项,也就是数据目录这个数组下标为1的项。

通过这里偏移地址就可以在PE文件中找到导入表的具体位置,导入表的每一项是一个叫做导入描述符的结构体

描述了一个动态链接库的信息,程序引入了多少个不同的动态链接库,这里就有多少项。

那作为一个描述引入外部动态链接库的结构体,这个数据结构里面应该有哪些内容呢?假如你来设计这个结构体的话,你觉得应该有哪些信息?
首先要有这个动态链接库的名字,有了名字操作系统才知道去加载谁。
其次还要有程序引入了这个动态链接库,出了哪些函数?要有一个函数名字的列表。
那最简单的设计就是动态链接库的名字,函数的名字全部都包进这个结构体里面来。
​
但这样一来就会有一个问题,动态链接库文件的名字啊有长有短,该设置多少合适呢?
弄短了的话不够用,弄长了又用不完,浪费。
另外引入的函数的数量也完全不一样。有时候可能就用了一个函数,有时候呢又可能引用了很多函数,那这个结构体里面到底该设置多少个函数名字的空间呢?
那在这些都不确定的情况下,这个结构体的大小也就没办法确定
​
Windows采用了一种巧妙的设计。把动态编辑库的名字引入的函数名字全部剥离了出来专门找地方存储,而这个结构体呢就只保留一个指针,这样就可以把不确定的因素给消除。这个描述引入动态链接库的结构体大小就是一致的了

这个结构里的大小固定是20个字节

如何知道导入表里面有多少项呢?PE文件的头部里面是没有一个字段来界定它的,想要知道导入表中的表项数量,可以通过遍历这个表,直到最后一项全是0,表就结束了,就能知道总共有多少项。所以导入表的最后一项一定有一个内容都是0的表项。但其实这种说法不太准确,根据Windows PE权威指南这本书里面的介绍。

只要这个结构体中的name字段为0,就表示导入表结束了,并不需要全部字段都是0。

导入表中的字段结构,感受一下Windows PE文件是如何通过导入表来引入外部函数的

1、OriginalFrstThunk

导入描述符结构体中的第一个字段OriginalFrstThunk就是一个指针

这和我们在c语言编程里面说的指针不一样,这是一个相对虚拟地址,它指向的地方又是一个表格

这个表一般叫做INT导入名字表,表格中的每一项都是一个IMAGE_SEND_DATA的结构体。

typeof dtuct _IMAGE_SEND_DATA32{
    union{
        DWORD ForwarderString; //转发字符串的RVA
        DWORD Function;        //被导入函数的地址
        DWORD Ordinal;         //被导入函数的序号
        DWORD AddressOdData;   //被导入函数名称的RVA
    } u1;
} IMAGE_SEND_DATA32, *PIMAGE_THUNK_DATA32; //32位

它实际上是一个4字节的字段,将导入表和导出表设计用到的4个字节相关的数据全部都放在了一起,然后用一个联合体来包起来,以便在不同的场景中根据意义的不同使用不同的字段。

INT(导入名字表)又是一个指针指向了另一个表中的一个表项,这个表项的结构是IMAGE_BY_NAME

typeof struct _IMAGE_BY_NAME{
    WORD Hint;    //需导入的函数序号
    CHAR Name[1]; //需导入的函数名称(不定长且以\0结尾)
} IMAGE_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

里面就是一个函数的名字和它所在的dll导出表中的索引,这里的名字呢是这个Name字段使用了不定长的数组结构设计,以零的出现标志字符串结束。

2、Name

导入表表项的Name字段,这个字段就是要导入dll文件的名字

这里也不存储名字字符串本身,而是这是一个指针指向了真正存储文件名字的地方,同样他还是一个相对虚拟地址RVA

截止到现在通过导入表描述符可以分析出一个PE文件引入了哪些外部动态链接库,以及分别引用的函数名字是什么,但还有一个问题没有解决,这些函数的地址在哪里呢?
假如程序通过call令去调用,该跳到哪里去执行呢?要解决这个问题啊,还得弄一张表。把所有导入函数的地址存起来,这个表就是导入地址表IAT。

导入地址表IAT的地址也登记在了文件头的数据目录中,它的索引位置是12,导入地址表的结构就很简单粗暴了,直接把所有要导入的动态链接库中的引用的函数地址都挨个码在一起,然后动态链接库之间用一个4字节的00来分割。

3、FirstThunk

现在的IAT和INT是两个完全独立的表。该怎么知道哪个函数的地址存在哪里呢?还需要一部把两个表关联起来。

PE文件就增加了一个措施,回到导入描述符的最后一个字段,FirstThunk,又是一个指针,它指向的就是导入地址表IAT,并且约定啊INT中的每一项都和IAT一一对应。

这就是PE文件结构中的导出表的双桥结构,通过乔一INT你可以找到应用函数的名称和编号,通过桥二IAT你可以找到对应的函数的地址。

这个导入地址表里面的地址是怎么知道的呢?

只有对应导入的dll加载到内存以后,它里面的函数地址才能确定,实际上当PE文件加载到内存之前,这个IAT里面的表现和前面的INT一样,也是指向的是函数的名字和编号列表,也就是在这时导入表描述符的两个桥最后殊途,同归指向了同一个地方。

而当PE文件开始加载的时候,操作系统的加载器开始遍历导入表,依次加载它依赖的每一个动态链接库,并通过GetProcAddress函数,依次获取INT指向的表中的每一个函数地址,然后将这些获取到的地址填入到IAT对应的表项中,如此一来,IAT里面就拥有了所有外部引入函数的地址。然后程序里面调用的时候只需要使用间接指针调用,就能通过这个地址跳过去执行了。

导出表

刚才提到PE文件加载的时候会通过GetProcAddress函数获取外部引入dll中函数的地址

将其填充的IAT中,那这个GetProcAddress Win32 API函数又是如何工作的呢?它是怎么找到这些函数的例子呢?

这就要涉及到与导入表相反的另外一个数据结构导出表。前面都是一个PE文件,要引入外部DLL的函数,需要使用导入表将引入哪些dll和哪些函数进行登记。而作为一个动态链接库dll文件,它也需要向外界声明自己有哪些函数导出可供外部调用,要登记这些信息的数据结构就是导出表

要注意,导出表和导入表并不是非此即彼,他们可以同时存在,比如一个DLL文件它既引用了另外一个DLL文件中的函数,同时呢它自己也导出函数给别人用,这样在这种情况下,这个dll文件同时就会存在导入表和导出表两个结构。

数据结构

接下来看一下这个导数表的数据结构,在导入表中,因为一个PE文件可能引用到很多个不同的文件。所以导入表表项可能就会有多个,每一个描述一个dll。

而导出表这不同它的作用,是描述当前PE文件有哪些函数需要导出,所以导出表的结构不是一个数组,它就只有一项

1、Name

和导入表描述符中的name字段类似,它也是一个指针指向的一个文件名,这是这个PE文件最初的文件名,一个PE文件被创建以后可能会被重命名,指向的就是它最开始的名字。

2、NumberOfFunctions

这个字段记录了当前PE文件中导出函数的总数。是这其中以NumberOfNames方式导出的那一部分的总数

3、NumberOfNames

指示当前PE文件以函数名字导出的函数的个数

那什么叫以函数名字导出呢?注意,这里要补充一个知识,PE文件中不止可以通过一个名字导出一个函数,也可以通过函数的编号导出。这也是为什么前面导入表的时候,INT和IAT指向的地方是一个编号加名字配对的组合表项,因为导入的时候也同样可以不通过名字来进行,而是通过一个编号。

4、AdressOfFunctions

这是指向了当前PE文件所有导出函数的地址表的指针,表中的数量就是前面的NumberOfFunctions。

5、AdressOfNames

也是一个指针指向的还是一个,这个表里面的都是所有名字方式导出的函数的函数名,字符串地址。表的数量就是前面的NumberOfNames。

6、AdressOfNamesOrdinals

现在全部要导出的函数地址表有了,函数的名字表也有了。那怎么知道具体某个名字的函数的地址是哪一个呢?还需要一个表来建立一个映射标记

这就是导出表结构中的最后一个字段,AdressOfNamesOrdinals,这还是一个指针。指向的也还是一个表格,表格里面的每一项都和上面AdressOfNames指向的表格里的每一项一一对应。记录AdressOfFunctions表里面每个函数在所有导出函数地址表中的索引值

7、Base

PE文件支持以编号的方式导出函数。

那这个编号该从多少开始呢?并不是中的从0开始,而是从Base开始

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值