PE文件概述

Windows操作系统的PE文件(Portable Executable)和Linux操作系统下的ELF文件都是可执行文件的一种,均以UNIX平台的COFF(Common Object File Format,通用对象文件格式)制作而来.

PE文件是32位的可执行文件,也称为PE32,后来的64位可执行文件称为PE+或PE32+,是PE的扩展.


PE文件有如下几种格式:

种类 主扩展名 种类 主扩展名
可执行系列 EXE,SCR 驱动程序类型 SYS,VXD
库系列 DLL,OCX,CPL,DRV 对象文件类型 OBJ

严格来说,除了OBJ文件,其他的格式都是可执行的(部分可以以调试,服务等特殊方式运行)


注:在文章结尾有一张PE文件的示意图.

PE文件分析

我们首先使用010 Editor进行分析,分析样例使用Windows XP SP3操作系统下的notepad记事本程序(32位).

基本结构

需要明确的一点是,PE文件是可执行文件,这意味着其需要载入到内存中,PE文件在磁盘和内存中的结构是不同的.

下图展示了二者的差异:

image-20231119132011163

DOS头节区头是PE头部分,下面的合称为PE体.

为了定位PE中的数据,在文件中使用偏移(offset),在内存中使用VA(虚拟地址)进行定位.从图中可以看出,PE加载到内存中后,节区的大小和位置会发生变化,而不是原封不动地载入内存.

计算机中,为了提高处理文件、内存,网络包的效率,使用"最小基本单位"这一概念,PE文件中也类似.各节区的起始位置都在文件/内存最小单位的倍数位置处,空白的部分使用NULL进行填充.

VA和RVA

VA指的是进程虚拟内存的绝对地址,RVA(Relative Virtual Address)则为相对地址,即相对基址的偏移.

PE头内部的信息大多为RVA,由于在载入内存时,该处可能已经加载有其他数据,所以需要重定向到其他位置,因此,只要能够保证RVA不变,根据不同的VA基地址,都可以正确找到各个数据.

换算公式如下:

RVA+ImageBase=VA

32位操作系统中,各进程有4GB的虚拟内存,所以VA范围从00000000~FFFFFFFF.

PE头

PE头包含各种结构体,保存着PE文件的各种信息.

DOS头-IMAGE_DOS_HEADER

PE头创建之时,DOS文件正在被广泛使用,理所当然地PE文件对DOS文件进行了兼容,方法就是在PE头最前面加上一个DOS头.

image-20231119134323201

该结构体占用40字节,捡关键的说:

e_magic成员:DOS签名(4D5A即为ASCII"MZ")

image-20231119134551674

e_lfanew成员:该成员的值指向后续的NT头所在位置

image-20231119134748524

(注意小端序存储)

DOS存根

该部分可选,有无均可,不影响程序运行,大小不固定.

image-20231119140245195

其中40到4D区域为16位的汇编指令,这里用于输出一个错误提示,即告诉用户该程序不能够在DOS模式下运行.

合理运用DOS存根可以产生一个既可以在DOS下运行,还可以在win32下运行的程序.

NT头-IMAGE_NT_HEADERS

image-20231119140618249

该结构体大小为F8,非常大.

Signature:值为50450000的ASCII"PE"00的签名

FileHeader:文件头

OptionalHeader:可选头

文件头-IMAGE_FILE_HEADER

该结构体存储了文件的大致属性.其中的几个十分重要,错误设置将导致文件无法正常运行.

image-20231119140936456

Machine:CPU的Machine码,兼容Intel x86芯片的Machine码为14C,其余还有:

NumberOfSections:指出节区数,如果与实际不符,则会运行错误.

SizeOfOptionalHeader:尽管NT头的最后一个成员(IMAGE_OPTIONAL_HEADER32结构体)的大小已经定义,但是windows的PE装载器需要查看SizeOfOptionalHeader的值,以确定IMAGE_OPTIONAL_HEADER32结构体的大小.

Characteristics:根据该成员来识别文件的属性-是否可运行,是否为DLL文件等.使用bit OR形式组合起来.

值如下:(记住0002h和2000h这两个值)

image-20231119141939285 image-20231119141955018

可选头-IMAGE_OPTIONAL_HEADER32

该头是PE头中最大的一个.

image-20231119142126868 image-20231119142200511

重要的成员:

Magic:为IMAGE_OPTIONAL_HEADER32时,值为10B;为IMAGE_OPTIONAL_HEADER64时,值为20B.

AddressOfEntryPoint:存有EP(代码入口点)的RVA值.十分重要.

ImageBase:指出最先被装载的地址.

EXE,DLL文件被装载到0~7FFFFFFF;SYS文件被装载到80000000~FFFFFFFF.

SectionAlignment,FileAlignment:指定最小单位.

SizeOfImage:指出PE Image在虚拟内存中所占空间的大小.一般和文件中不同.

SizeOfHeader:指出整个PE头的大小.

Subsystem:用来区分系统驱动文件和普通的可执行文件.

image-20231119144701127

NumberOfRvaAndSizes:用来指定DataDirection数组的个数(?).

DataDirectory:各种表项,例如导入表和导出表等.每个元素都对应一种表项.

image-20231119144842497

节区头-IMAGE_SECTION_HEADER

节区头定义了各个节区的属性.PE文件将各种数据存储在不同的节区.而且不同的节区会有不同的权限:

image-20231119145151510

节区头是由IMAGE_SECTION_HEADER结构体组成的数组,每个结构体对应一个节区:

image-20231119150357510

结构体如下:

image-20231119150425129

重要的属性有如下几个:

image-20231119150459036

这些属性定位了后续PE体中对应的一个节区,例如.text节区.

RVA to RAW

这是一个最基本的转换,由于PE磁盘文件与其载入到内存的镜像文件并不完全一致,所以需要将进行RAW与RVA之间的转换.

首先查找RVA所在的节区,然后找到该节区的起始地址Virtual Address,注意这里的Virtual Address仍然是RVA(?).

然后找到PointerToRawData,就可以做差了.公式如下:

image-20231119151217154

含义即为:文件中该位置(RAW)到节区起点的偏移(两者之差) 与 内存映像中位置(RVA)到本节区起点的偏移(两者之差) 是相等的.

IAT-导入地址表

Windows过去并没有DLL,只有库(Library)这一概念,这导致每一个程序要调用某一个库代码,都要进行包含,这就导致大量的空间浪费(每个使用该库的程序都有其一本副本).

现在,引入了DLL这一概念,可执行文件直接加载该DLL即可.在内存中只有一个DLL的代码.

加载DLL的方式有两种,一种是"显式链接",即使用时进行加载,使用后释放内存;另一种是"隐式链接",程序开始即加载,运行结束后释放,这种方式就与IAT有关.


PE文件提供了IAT内存区域,这里是编译器指定的一些内存,文件执行时,PE装载器将DLL中某些函数的实际地址(运行时确认)写入到这个位置,在程序代码中,访问一个库函数并不会将其硬编码到代码中,而是以IAT内存区域中某个内存中存储的地址值去进行call,这个地址值即为PE装载器在启动程序时确认的地址.这样实现由2个原因:

  1. 之所以这样间接调用,是因为由于操作系统版本的不同,软件版本的不同,各个DLL中函数的实际地址并不相同,为了保证准确,将获取库函数实际地址的任务交给了PE装载器,在运行时确认当前DLL中库函数的地址.

  2. 另一方面,DLL的地址也并不是绝对的,例如某程序使用a.dll和b.dll,PE装载器将a.dll装载到10000000(ImageBase)处,然后尝试将b.dll也装载到此处,但是发现a.dll已经装载在此,理所当然地,便会将b.dll装载到其他位置.

    这就是所谓的DLL重定向,我们无法在编写应用程序的时候就绝对确定一个DLL在内存中的位置.

因此,实际上编译器需要指定程序中的一个内存空间,供PE装载器在执行时将正确的地址写入这个内存空间,在需要调用某个函数时,就从这个内存空间中读取载入的地址,然后进行call调用.

IMAGE_IMPORT_DESCRIPTOR结构体

该结构体中记录着PE文件要导入哪些库文件.

image-20231119153635416

每一个导入的库都会对应这样的一个结构体,组成结构体数组,最终以一个NULL填充的结构体结束:

image-20231119155203589

有时候,INT数组与IAT数组指向同一个位置(如下图),但是很多情况下并不是这样的.

image-20231119155313272

PE装载器将导入函数写入IAT的顺序如下:

image-20231119155713902

查找IMAGE_IMPORT_DESCRIPTOR结构体数组

以notepad为例.

该数组并不在PE头中,而是在PE体中,不过,查找其位置的信息存储在PE头中.

在PE头的IMAGE_OPTIONAL_HEADER32中,DataDirectory[1].VirtualAddress的值即为IMAGE_IMPORT_DESCRIPTOR结构体数组的起始地址.

另外,IMAGE_IMPORT_DESCRIPTOR结构体数组也称为IMPORT Directory Table.


查看notepad的DataDirectory数组如下:

image-20231119160754825

找到对应的RVA(7604),根据公式转换为RAW(6A04),从文件中找到该位置:

image-20231119161113837

上图就是找到的数组,当前定位到其第一个元素,也就是第一个导入的dll库.

我们以这个元素举例(comdlg32.dll),展开分析(010Editor帮我们进行了分析):

image-20231119161248116

  1. 库名称Name:Name成员存储了一个RVA(7AAC),转换为RAW(6EAC),跳转过去就能找到这个库名字符串:

image-20231119161532401

  1. OriginalFirstThunk - INT:INT为一个包含导入函数信息的结构体指针数组.根据这个数组的信息才能够找到对应函数的地址(参考后面EAT的内容).

    跟踪该地址(同样计算RAW),得到:

    image-20231119161942301

    这里的每一个地址都指向一个IMAGE_IMPORT_BY_NAME结构体(如下所示).

    根据每一个元素,例如第一个0x00007A7A(注意小端序),转为RAW为6E7A,继续跟踪可找到第一个函数名:

    image-20231119162158565

    这里的前2个字节(000F)为Ordinary,为库中函数的固有编号.

  2. FirstThunk - IAT:IAT即为Import Address Table

    IAT的RVA:12C4–>RAW:6C4,跟踪过去得:

    image-20231119162745029

    这里的第一个元素被硬编码为76344906,但无实际意义,运行时会被准确的地址值覆盖.

EAT-导出地址表

与普通的应用程序不同,库文件(DLL,SYS)是为方便其他程序调用而集中包含了相关函数的文件.Win32 API是最具代表性的库,其中的kernal32.dll最为核心.

为了获取库文件中的函数信息,库必须使用EAT机制,用来求得库中各函数的地址.PE文件中,仅有一个IMAGE_EXPORT_DIRECTORY结构体来说明库EAT,而不是向IAT那样的数组,因为IAT可以导入多个库,而一个库只能导出自己.

IMAGE_EXPORT_DIRECTORY结构体

image-20231121172638880

重要成员如下:

image-20231121173202469

下面是kernel3.dll的整个EAT结构:

image-20231121175433331

对照结构体声明和kernel32.dll的EAT结构,下面是寻找API的整个过程.从库中获得函数地址的API为GetProcAddress()函数,其引用EAT来获取指定API的地址.

image-20231121175814535

总结来说就是,从函数名称数组中找到该函数名称的字符串,根据该字符串的下标name_index去查找ordinal数组,对应位置的元素值为ordinal,最后,在函数地址数组中以ordinal为下标找到函数的起始地址.


实际上,有些函数可能并没有函数名称(仅通过ordinal导出),可以通过从ordinal减去IMAGE_EXPORT_DIRECTORY.Base后得到的值作为函数地址数组的索引去查找函数地址.

查找IMAGE_EXPORT_DIRECTORY结构体

以Windows XP SP3的kernel32.dll为例.

和IAT同样,在NT头的DataDirectory数组中找到EAT的位置:

image-20231121172422745

跳转到0x1A2C即可找到EAT:

image-20231121172558254

接下来就可以根据前面说的步骤去查找某个具体的函数了.

总结

PE文件是Windows系统的可执行文件格式,了解PE文件结构才能够进一步学习更深的逆向技术.

后续还会看到各种PE文件的变体,例如被特殊的压缩程序进行压缩,用于非正常行为的程序(例如病毒等).

这是一张PE文件结构的示意图:

5427d0b798683a769cbf51d2c84b3664