PE结构

认识PE文件

什么是PE文件

PE(Portable Executable)文件是Windows操作系统上的一种可执行文件格式。它包含了可执行代码、数据、资源和元数据,以及其他必要的信息,例如程序入口点、导入表、导出表和重定位表等等。PE文件是Windows操作系统中所有可执行文件的标准格式,包括应用程序、动态链接库(DLL)、驱动程序等等

可执行文件在不同的操作系统平台有不同的结构

  • Windows平台:PE(Portable Executable)

  • Linux平台:ELF(Executable and Linking Format)

常见的PE文件后缀名有EXE, DLL,SYS等

PE指纹

可以通过PE指纹来识别该文件是否为PE文件,首先用notepad打开此文件并查看其16进制格式,可以发现开头的ASCII码是"MZ",然后观察偏移量为3C的位置,其硬编码为F8,对应的ASCII码为?

image-20220721224009828

然后再找到偏移量为F8的位置,发现一个5045,其对应的ASCII码为PE

image-20220721224128979

以下是通过识别PE指纹来判断PE文件的python代码

PE结构大体图

PE 文件结构图

PE结构

PE结构概述图

由下图可得,PE结构主要分为四部分,分别有DOS部分、PE文件头、节表以及节数据

image-20220723110003206

DOS部分

DOS MZ文件头

PE文件的DOS头包含了一个DOS MZ头,用于兼容早期的MS-DOS操作系统。DOS头还包含了一个指向PE头的偏移量,通过该偏移量可以找到PE文件头

在以下结构体代码中的成员只需记住两个,最开始的和最末尾的成员, e_magice_lfanew是构成PE指纹的重要成员,不能被修改

image-20220723113228197

DOS块

DOS块则是DOS部分的一部分,它是DOS MZ头之后的一段数据,包括了DOS程序的执行代码和数据。DOS块通常由一些启动代码、程序入口点、代码段和数据段等组成,用来实现DOS程序的初始化和执行。

需要注意的是,虽然PE文件包含了DOS部分,但在Windows操作系统中,PE文件不需要执行DOS块中的代码。当Windows打开一个PE文件时,它会直接定位到PE头的位置,然后将PE头和节表加载到内存中,并执行PE头中指定的程序入口点,从而启动PE文件的执行

PE文件头(NT头)

PE头又分为标识PE头、标准PE头、扩展PE头

PE文件一些重要信息通常存储在标准PE头和扩展PE头中

如下是PE文件头的结构体代码,分别有32位和64位的

标识PE头

标识PE头通常占2个字节,用来标识该文件为PE文件

image-20220723114454075

标准PE头

标准PE头通常占20个字节,包括了PE文件的基本属性和信息,例如文件类型、机器架构、入口点、标志、段信息等等。其结构体代码如下所示:

扩展PE头

扩展PE头通常占224个字节,以下结构体成员中有几个成员要重点注意

如果一个可执行文件它的dos部分+pe头+节表的共同大小为332,其FileAlignment(文件对齐)的值为200, 那么SizeOfHeaders的值应该为文件对齐的整数倍,所以SizeOfHeaders的值为400

节表

节表包含了PE文件中所有的节信息,每个节都包括了一个段的名称、大小、属性和数据等信息

节表是由一系列IMAGE_SECTION_HEADER结构的元素组成的结构体数组, 每个元素占40个字节

以下是IMAGE_SECTION_HEADER结构体的代码

这里探讨一下PointerToRelocations指针指向的重定位条目,也就是IMAGE_RELOCATION结构体,其代码如下所示:

节数据

节数据是PE文件中实际的二进制数据,它包含了PE文件中所有的代码、数据和资源等信息。每个节的数据都位于PE文件中的一个连续的段中

PE文件中,不同的节用于存储不同类型的数据,通常包括以下几个节:

  • .data节:用于存储PE文件中的静态数据,它是可读写的,通常包含全局变量、静态变量、已初始化的数据等等。

  • .rdata节:用于存储PE文件中的只读数据,它是只读的,通常包含字符串、常量、只读数据等等。

  • .text节:用于存储PE文件中的可执行代码,它是可执行的,通常包含程序的主要代码,例如函数、指令等等

  • .rsrc节:用于存储PE文件中的资源,包括图标、位图、字符串、菜单等等,通常是只读的。

  • .reloc节:reloc节用于存储PE文件中的重定位信息,它包含了需要被修改的地址和偏移量等信息,通常是只读写的。

  • .tls节:用于存储PE文件中的线程本地存储(Thread Local Storage,TLS)相关信息,包括TLS索引、初始化回调函数等等, 通常是可读写的。

RVA转FOA

什么是RVA和FOA

PE文件有两种状态, 一种是在文件中的状态,另外一种是在内存中展开的状态

若我们运行了一个PE文件且知道了某个全局变量在内存中的地址, 那么该如何通过这个地址来算出此全局变量在文件状态下的地址是多少呢?

  • RVA(relative Virtual Address), 又称为相对虚拟偏移,简单来说就是在内存状态下的偏移地址

  • FOA(File Ofseet Address), 又称为文件偏移地址, 就是在文件状态下的偏移地址

下图是PE文件在文件对齐和内存对齐状态下的映像结构图。这里文件对齐值是200,内存对齐的值是1000。内存对齐后的映像分布有个明显的拉伸

image-20220723213138018

计算方法

1.求RVA的值

RVA = 全局变量在内存中的地址 - ImageBase(基址)

2.判断RVA是否位于PE头中或者内存对齐是否等于文件对齐

  • 如果RVA位于PE头中,则RVA = FOA

  • 如果文件对齐 = 内存对齐,则 RVA = FOA

  • 如果不在则进行下述操作

3.判断RVA位于哪个节

假设rva位于X节中,也就是说X节.VirtualAddress <= RVA <= X节.VirtualAddress+X节内存对齐后的大小

差值 = RVA - X节.VirtualAddress

4.得出FOA

FOA = X节.PointerToRawData(X节在文件中的地址) + 差值

C++代码

节的操作

若要对PE文件填充自己的代码,然后发现字节空间不足,这时候就需要对节表进行操作

此处主要讲节的三种操作,分别为扩大节新增节合并节

扩大节

对文件的节表进行扩大,为了方便操作,只需对最后一个节进行扩大OK了

此处演示扩大1000(16进制)个字节的操作

所需工具

  • Winhex

  • CFF Explorer

实例演示

首先将可执行文件拖入到CFF Explorer工具,查看其PE属性,例如文件对齐与内存对齐的值,此处内存对齐的值是1000,文件对齐的值是200

img

再查看最后一个节(rsrc)的Virtual Size(节的实际大小)和Raw Size(节文件对齐后的大小),此处rsrc节的Virtual Size值为1E0, Raw Size的值为200, 两者取最大值200

再取200与1000(内存对齐)的最小公倍数,结果为1000

1000再加上1000(需扩大的字节数), 结果为2000

随后将Virtual SizeRaw Size的值都修改为2000

节内存对齐所增加的字节 = 节内存对齐后的大小(1000) - 节文件对齐后的大小(200), 计算结果为E00, 这个值在后面用winhex工具添加字节的操作中要用到

img

转到PE扩展头查看SizeOfimage(整个PE文件内存对齐后的大小),此处值为9000

将其修改成9000+1000(扩大的字节数), 即修改成10000

img

将文件保存后拖入到winhex中打开,将光标移动至最后一个字节,执行如下图操作

img
img

要插入的字节数 = E00(节内存对齐增加的字节) + 1000(需扩大的字节) = 1E00, 此处需换算成10进制,即为7680

img

然后winhex保存文件, 执行文件,若程序成功运行则说明扩大节操作成功

新增节

扩大节扩出来的空白区还需要考虑节的权限问题,若扩大出来的节没有执行代码的权限,那么还要修改整个节的权限属性,节的权限属性由节表结构体的最后一个成员Characteristics决定,但是新增的节可以自己设置权限,所以相比扩大节而言还是方便挺多的

所需工具

  • winhex

  • CFE Explorer

实例演示

将可执行文件拖入CFE Explorer中,找到段头部(节表属性)

img

在最后一个节的后面新增节, 以此方便后续的操作。对着最后一个节鼠标右键,为了研究原理,这里选择add section(Header Only), 意思为仅添加节表头,若不想自己添加节数据,可以选择add section(File data)

img

对节表进行修改, 如下表格所示:

节的成员
含义
修改后的值

Name

节的名字

.fuck

Virtual Size

节的实际大小

1000(要新增节的大小)

Virtual Address

节在内存中的偏移地址

9000(上一个节.Virtual Address + 上一个节内存对齐后的大小)

Raw Size

节文件对齐后的大小

1000(新增节的大小)

Raw Address

节在文件中的偏移地址

4E00(上一个节.RawAdress + 上一个节.Raw Size)

Characteristics

节的属性

0(即可读可写可执行)

img

切换到PE扩展头,修改SizeOfimage的值为10000(原先值9000+新增的字节1000)

img

文件保存后拖入到Winhex,新增4096个字节(16进制的1000),这里就不演示了,上面扩大节的操作已经演示过了

合并节

新增节有一个前提条件,最后一个节后面至少要有40个字节的空白区

若前提条件不满足, 那只能合并节,即将多个节合并成一个节,多余的空间就可以用于新增节

所需工具

  • Winhex

  • CFE Explorer

内存对齐

为了进行后面的合并节,需要先将所有的节进行内存对齐

内存对齐的意思是令节文件对齐后的大小和内存对齐后的大小相等

这里我们要将所有的节合并,所以全部节表都要进行内存对齐

只需修改节表三个成员值,分别是Virtual Size、Raw Size、Raw Address

此处演示的PE文件内存对齐值为1000

首先是第一个text节,将Virtual Size与Raw Size的值修改成内存对齐后的大小, 即修改成3000

==注意:== 由于是第一个节, 所以Raw Address的值就不用修改

img

第二个是rdata节,同样将Virtual Size与Raw Size的值修改成内存对齐后的大小

Raw Address = text节.Raw Address(400) + text节.Raw Size(3000)

img
img

后面节的操作依次按上述操作来进行,下图是所有节内存对齐前和内存对齐后的对比

img
img

计算出每个节的末尾地址, 随后在每个节的末尾地址填充字节

首先计算text节的内存对齐所增加的大小,称之为差值

差值 = 节内存对齐后的大小(3000) - 节文件对齐后的大小(2E00) = 200

text节的末尾地址 = 下一个节.RawAddress(3400) - 差值(200) = 3200

将程序拖入winhex, 点击工具栏的转到偏移量,输入text节的末尾地址, 随后光标会跳转到text节的末尾地址处

img
img

右键编辑插入0字节,插入512(十六进制的200,即差值)个字节

img

根据以上的步骤来完成对后面节的内存对齐, 此处要注意最后一个节的内存对齐

例如这里最后一个节的差值为E00, 不用计算它的节末尾地址, 直接将光标拖到文件末尾, 插入3584(E00的十进制)个字节即可

合并演示

内存对齐后再将可执行文件拖入CFE Explorer, 只需修改第一个节的属性

Vitrual SizeRaw Size的值修改成所有节内存对齐后大小的总和(3000+2000+1000+1000+1000=8000), 即修改成8000

characteristics(节权限)的值修改成所有节的characteristics值相互异或运算后的结果, 即修改为E0000060, 这样修改的目的是令第一个节拥有所有节表的权限属性

img
img

再将其他的节表头删掉,只留下一个text节表

img
img

将PE头里的NumberOfSections(节表个数)的值修改成1

img

后面程序执行成功则代表节合并成功

导出表与导入表

通常来讲exe文件只有导入表而没有导出表,而dll文件既有导入表也有导出表

导出表

什么是导出表

代码重用机制提供了重用代码的动态链接库, 它会向调用者说明库里的哪些函数是可以被别人使用的, 而这些说明的信息便组成了导出表

简单来说,PE文件提供一些函数给其他PE文件调用,这些函数都记录在导出表里面

导出表结构体代码

定位导出表的地址

导出表的地址和大小是由PE扩展头的最后一个成员NumberOfRvaAndSizes所决定的,这个成员是个结构体数组,对应结构体类型为_IMAGE_DATA_DIRECTORY

将可执行程序拖入CFFE工具查看,特别要注意的是这个导出表的地址是RVA(即表示在内存状态下的偏移地址),若想知道其在文件状态下的起始地址, 需将RVA转换成FOA

img

这里就不转换成FOA了,毕竟工具已经为我们转换好了。切换至输出目录, 即导出表,Characteristics成员的偏移值就是导出表的FOA, 此处导出表的起始地址为1BD20

img

导出表里的三张表

导出表里有三张表,分别是==导出函数地址表==、==导出函数名称表==、==导出函数序号表==

这三张表有着紧密的联系,如下图所示,导出表有三个导出函数,分别是Add、Sub、Div

若某个PE文件想调用这个导出表的Add函数,会通过查询名称表找到对应的下标;随后通过下标在序号表中找到对应的序号;再通过序号来对应地址表里的下标,便可查询到函数的地址

img

上述方式演示的是通过函数名称来导出,还有另外一种导出方式是通过函数的序号,每个导出函数都分配了一个唯一的序号,然后通过序号来找到函数的地址。这种方式的好处减小了文件的大小,但是使用起来不够直观,调用者需要知道函数的具体序号

将可执行程序拖入CFFE工具里查看,可以发现,这三张表之间的对应关系工具已经为我们排列好了

img

导入表

什么是导入表

任何PE文件还会调用哪些PE文件都会记录在导入表里。

因为PE文件可能要依赖多个模块,所以通常一个PE文件会有多张导入表。

什么是INT表和IAT表

INT表的全称是Import Name Table,中文翻译为“导入名称表”。在PE文件中,导入名称表是导入表(Import Table)的一个子部分,用于存储PE文件中导入函数的名称。导入名称表通常包含了一个函数名称的列表,每个名称对应着一个被导入的函数。当PE文件被加载到内存中时,Windows操作系统会使用导入名称表中的名称来查找相应的函数,并将函数的地址存储到IAT(Import Address Table)表, IAT表的全称为“导入地址表”

导入表的结构体代码

IMAGE_IMPORT_DESCRIPTOR结构用于描述一个导入的DLL及其相关的导入信息

IMAGE_THUNK_DATA32是一个共用体结构,当条目的最高位为1时,则表示函数是以序号的方式导入的,剩余的位就是函数的序号;当条目的最高位为0时,则表示函数是通过名称导入的,剩余的位表示指向函数名称字符串的RVA(偏移量)

IMAGE_IMPORT_BY_NAME 结构用于描述导入的函数名称以及序号

PE加载前后的导入表变化

如下是PE文件加载前的流程图,导入表中的Name成员指向DLL的名称,OriginalFirstThunkFirstThunk都指向了IMAGE_THUNK_DATA结构数组(也就是INT表)

这里要导入四个函数,前三个是通过函数名称导入的,其IMAGE_THUNK_DATA结构的值是一个RVA(指向函数名称);最后一个是通过函数序号导入的,其IMAGE_THUNK_DATA结构的值是80000010h,这个值的最高位是1,去掉最高位后的值是10h,转换成十进制是16,也就是说函数的序号是16

img

如下是PE文件加载后的流程图,可以发现FirstThunk用作导入地址表(IAT)。

加载器会解析OriginalFirstThunk指向的导入名称表,找到每个导入函数的实际地址,然后将这些地址填充到FirstThunk指向的表中。因此,一旦PE文件完全加载到内存中,FirstThunk会指向实际的函数地址,应用程序就可以直接通过这个表来调用外部函数

img

用PE工具查看

使用CFEE工具打开dll文件,切换到导入目录,这里记录着所有导入表以及其属性

请添加图片描述

单击其中一个模块, 即可查询此模块提供的函数名以及地址

请添加图片描述

重定位表

什么是重定位表

重定位表是PE文件结构中的一个部分,主要负责在PE文件(如可执行文件或DLL)加载到非预定的基址时对其中的地址引用进行修正。

当我们说PE文件在内存中有一个“基址”时,我们是指它预期在内存中的起始地址。但是,由于各种原因(例如其他模块已经占用了该地址),PE文件可能无法加载到其预定的基址。在这种情况下,操作系统的加载器需要将其加载到一个不同的地址,然后修正PE文件内部所有的地址引用,使它们指向正确的位置。重定位表提供了这些地址修正所需的信息

重定位表的定位

重定位表,与导出表和导入表相似,都是存储在扩展PE头中的DataDirectory数组内。具体来说,DataDirectory数组中的每个成员都对应一张表。其中,DataDirectory[5]正是指向重定位表的。

重定位表的结构

结构定义代码

重定位表的结构体代码如下所示:

重定位项的结构如下所示:

结构解释

  • 重定位块:重定位表实际上由一系列重定位块组成。每个重定位块都对应于PE文件中的一个特定内存页面(4KB)

  • 每个块的结构

    • IMAGE_BASE_RELOCATION结构标志着每个重定位块的开始。该结构主要包含了块的大小和起始地址。

    • 随后是一个数组,其中的每个条目都表示需要进行重定位的偏移量。

  • 条目的类型和偏移:每个条目都是16位,其中高4位描述了重定位的类型,低12位描述了在当前块中的偏移。

image-20210410113358120

重定位表数据项

重定位表块中最后一个是一个数组,数组的每一项数据的大小均为2字节,其中高4位描述了重定位的类型,低12位描述了在当前块中的偏移。以下表格表示高四位的作用:

常量符号
含义

0

IMAGE_REL_BASED_ABSOLUTE

无意义,仅作对齐作用

1

IMAGE_REL_BASED_HIGH

需要修正的是地址的高16位

2

IMAGE_REL_BASED_LOW

需要修正的是地址的低16位

3

IMAGE_REL_BASED_HIGHLOW

使用完整的32位修正地址

4

IMAGE_REL_BASED_HIGHADJ

修正一个带有高16位的地址,并将下一个条目用于低16位

5

IMAGE_REL_BASED_MIPS_JMPADDR

用于某些特定的MIPS处理器

6

RESERVED

未使用

7

RESERVED

未使用

8

IMAGE_REL_BASED_MIPS_JMPADDR16

用于某些特定的MIPS处理器

9

IMAGE_REL_BASED_IA64_IMM64

用于IA64处理器的特定场景

10

IMAGE_REL_BASED_DIR64

对于64位系统,使用完整的64位修正地址

然而在实际的PE文件中,通常我们只能看到0和3这两种情况(对应二进制为0000和0011), 也就是说这一项要么对齐要么修正

假设IMAGE_BASE_RELOCATIONVirtualAddress值为0x1000,一个重定位条目的低12位为0x10。那么在此块中需要修正的偏移量为0x10,于是需要修正的虚拟地址偏移就是0x10100x1000 + 0x10

如果在处理文件内容时(文件状态下),需要将VirtualAddress转换为文件偏移量(FOA)。例如此处FOA值为0x400, 那么需要修正的文件地址偏移是0x4100x400 + 0x10

image-20231011155538419

转到0x410的位置,可以发现其指向的地址是0x59B4B0,而此地址就是需纠正的地址

image-20231011155744012

通过重定位表获取到需要纠正的地址后,那么该如何去纠正这个地址呢?

首先需计算出基址偏移量,即程序实际加载到的基地址与预设基地址(PE扩展头的ImageBase值)之间的差值。如果程序能够加载到其预设的基地址,这个偏移量就是零,没有必要进行任何重定位

随后将需纠正的地址(此处是0x59B4B0)加上基址偏移量后的结果写回原来的地址(0x410)

最后更新于