Skip to main content

PE文件格式详解

定义

我们知道所有文件都是一些连续的数据组织起来的,不同类型的文件肯定组织形式也各不相同;

PE文件格式便是一种文件组织形式,它是32位Window系统中的可执行文件EXE以及动态连接库文件DLL的组织形式。

为什么我们双击一个EXE文件之后它就会被Window运行,而我们双击一个DOC文件就会被Word打开并显示其中的内容;这说明文件中肯定除了存在那些文件的主体内容(比如EXE文件中的代码,数据等,DOC文件中的文件内容等)之外还存在其他一些重要的信息。

这些信息是给文件的使用者看的,比如说EXE文件的使用者就是Window,而DOC文件的使用者就是Word。Window可以根据这些信息知道把文件加载到地址空间的那个位置,知道从哪个地址开始执行;加载到内存后如何修正一些指令中的地址等等。那么PE文件中的这些重要信息都是由谁加入的呢?是由编译器和连接器完成的,针对不同的编译器和连接器通常会提供不同的选项让我们在编译和联结生成PE文件的时候对其中的那些Window需要的信息进行设定;当然也可以按照默认的方式编译连接生成Window中默认的信息。例如:WindowNT默认的程序加载基址是0x40000;你可以在用VC连接生成EXE文件的时候使用选项更改这个地址值。在不同的操作系统中可执行文件的格式是不同的,比如在Linux上就有一种流行的ELF格式;当然它是由在Linux上的编译器和连接器生成的,所以编译器、连接器是针对不同的CPU架构和不同的操作系统而涉及出来的。在嵌入式领域中我们经常提到交叉编译器一词,它的作用就是在一种平台下编译出能在另一个平台下运行的程序;例如,我们可以使用交叉编译器在跑Linux的X86机器上编译出能在Arm上运行的程序。

程序是如何运行起来的:

一个程序从编写出来到运行一共需要那些工具,他们都对程序作了些什么呢?里面都涉及哪些知识需要学习呢?先说工具:编辑器-》编译器-》连接器-》加载器;首先我们使用编辑器编辑源文件;然后使用编译器编译程目标文件OBJ,这里面涉及到编译原理的知识;连接器把OBJ文件和其他一些库文件和资源文件连接起来生成EXE文件,这里面涉及到不同的连接器的知识,连接器根据OS的需要生成EXE文件保存着磁盘上;当我们运行EXE文件的时候有Window的加载器负责把EXE文件加载到线性地址空间,加载的时候便是根据上一节中说到的PE文件格式中的哪些重要信息。然后生成一个进程,如果进程中涉及到多个线程还要生成一个主线程;此后进程便开始运行;这里面涉及的东西很多,包括:PE文件格式的内容;内存管理(CPU内存管理的硬件环境以及在此基础上的OS内存管理方式);模块,进程,线程的知识;只有把这些都弄清楚之后才能比较清楚的了解这整个过程。下面就让我们先来学习PE文件格式吧。

PE文件的总体结构:

下图便是PE文件的一个总体结构:注意,图2是在图1的基础上进一步细化了,不过图2的顺序是从下向上代表文件的从头到尾的顺序。

DOS MZ Header
DOS stub
PE header
Section table
Section 1
Section 2
Section ...
Section n

图一

图2

我们的EXE文件在磁盘上就是按照上面的格式顺序存储的,当运行的时候它就很容易被加载器加载到线性地址空间;但是在线性空间中和在磁盘上不同,在线性空间中各个部分不一定是占据连续的线性地址空间。下面对PE文件格式的介绍就按照上图中对从头到尾对每个部分进行介绍。好的,今天刚去医院回来有些累了,就先写到这儿吧。

嗯,不行,还有几个重要而又基础的概念需要在这儿先澄清一下,否则后面就会出乱子了。

几个重要的基本概念:

1)节:PE文件的真正内容划分成块,称之为sections(节)。每节是一块拥有共同属性的数据,比如代码/数据、读/写等。我们可以把PE文件想象成一逻辑磁盘,PE header 是磁盘的boot扇区,而sections就是各种文件,每种文件自然就有不同属性如只读、系统、隐藏、文档等等。 值得我们注意的是 ---- 节的划分是基于各组数据的共同属性: 而不是逻辑概念。重要的不是数据/代码是如何使用的,如果PE文件中的数据/代码拥有相同属性,它们就能被归入同一节中。不必关心节中类似于"data", "code"或其他的逻辑概念: 如果数据和代码拥有相同属性,它们就可以被归入同一个节中。(节名称仅仅是个区别不同节的符号而已,类似"data", "code"的命名只为了便于识别,惟有节的属性设置决定了节的特性和功能)如果某块数据想付为只读属性,就可以将该块数据放入置为只读的节中,当PE装载器映射节内容时,它会检查相关节属性并置对应内存块为指定属性。下面是常见的节名及作用:

节名作用
.arch最初的构建信息(Alpha Architecture Information)
.bss未经初始化的数据
.CRTC运行期只读数据
.data已经初始化的数据
.debug调试信息
.didata延迟输入文件名表
.edata导出文件名表
.idata导入文件名表
.pdata异常信息(Exception Information)
.rdata只读的初始化数据
.reloc重定位表信息
.rsrc资源
.text.exe或.dll文件的可执行代码
.tls线程的本地存储器
.xdata异常处理表

注意:上面已经说过了“节的划分是基于各组数据的共同属性: 而不是逻辑概念。重要的不是数据/代码是如何使用的,如果PE文件中的数据/代码拥有相同属性,它们就能被归入同一节中” 所以上面表中列出的节并不一定单独成节,也就是说即使存在上面表中的某一节,在节表(section table)(后面会讲到)中也不一定就有于之对应的项,因为它可能和别的具有共同属性的节共同组成了一节。比如 .idata 可以和 .text 合成一节而命名为 .text,而在节表中只有和 .text 对应的项。这也就是后面的optional header中数据目录(DataDirectory)存在的作用,因为很多有用的节被合并了,因此加载器无法通过节表来定位它们,所以这就是数据目录(DataDirectory)发挥作用的时候了(具体作用后面会讲到)。

2)虚拟地址:虚拟地址即程序中使用的地址,也就是从程序员的角度看到的地址,有时也叫逻辑地址;通常使用段地址:偏移量的形式表示,不过在32位系统中使用的是平坦(Flat)内存模式,所以我们可以不用管段地址,只考虑32位的偏移量即可,认为32位的偏移量就是虚拟地址,这样一来程序员就可以认为他是在一个段中写程序,这个段的大小是232 = 4G的容量,当然这部分地址空间是程序和OS共享的,程序员可以利用的大约有2G(具体可以参考Win98和WinNT的内存布局);所以我们平时在写程序申请内存的时候实际上申请的就是这2G的线性地基空间,由于所有的4G线性地址空间都被OS作为资源来管理(这4G的线性地址空间是通过页表来表现出来的,OS分配线性地址空间給进程也就是分配相应的页表給进程),所以我们无论用什么方式使用内存最终都是转换为OS为我们分配线性地址空间,至于分配的线性地址空间又如何被映射为真正的物理内存完全是有OS负责的(更详细资料参见“Windows 内存管理”),程序员不必操心。

3)相对虚拟地址:「相对虚拟地址(Relative VirtualAddress,RVA)」即相对于上面的基地址的偏移量。PE 文件中的许多字段内容都是以RVA 表示,一个RVA 是某一资料项的offset(偏移)值-- 从文件被映像进来的起点(即基地址)算起。举个例子,我们说Windows加载器把一个PE 文件映像到虚拟地址空间的0x400000 处,如果此image 有一个表格开始于0x401464,那么这个表格的RVA 就是0x1464:虚拟地址0x401464 - 基地址0x400000 = RVA 0x1464只要把RVA 加上基地址,RVA 就可以被转换为一个有用的指针。在PE文件中大多数地址多是RVA 而 RVA只有当PE文件被PE装载器装入内存后才有意义。 如果我们直接将文件映射到内存而不是通过PE装载器载入,那么我们就不能直接使用那些RVA。必须先将那些RVA转换成文件偏移量,RVAToOffset函数就起到这个作用。

4)基地址:「基地址(base address)」是一个重要概念,用来描述被映像到内存中的EXE 或DLL 的起始地址。为了方便,Windows NT 和Windows 95 都以模块的基地址做为模块的instance handle(HINSTANCE,实例句柄)。Windows95加载器把一个PE 文件映像到虚拟地址空间的0x400000 处;而WindowNT加载器把一个PE 文件映像到虚拟地址空间的0x10000 处 。

5)文件偏移量:文件中的地址与内存中表示不同,它是用偏移量(File offset)来表示的,文件中的第一个字节的偏移量是0,后面的字节依次递增。在SoftICE和W32Dasm下显示的地址值是内存线性地址,或称之为虚拟地址(Virual Address,VA)。而十六进制工具里,如:Hiew、Hex Workshop等显示的地址就是文件地址,称之为偏移量(File offset) 或物理地址(RAW offset,注意这个物理地址不是内存寻址中说到的物理地址 )。

6)模块:「模块(module)」一词表示一个EXE 或DLL 被加载内存后的程序代码、数据和资源(就是被加载到内存后的EXE或DLL整体,包括代码、数据和资源,而不是说代码、数据、资源分别都是模块)。除了程序代码和数据是你的程序直接使用的之外,模块还内含一些支持性数据,Windows 用它来决定程序代码和数据放在内存的什么地方,在Win32,这些信息保留在PE头部(即图1中的PE header,实际上它是一个IMAGE_NT_HEADERS 结构)中。

7)逻辑地址:见“虚拟地址”

8)线性地址:线性地址是由虚拟地址(逻辑地址)转换来的,转换需要CPU和OS共同合作来完成;里面涉及到全局描述符表GDT和局部描述符表LDT;不过由于32位的Window系统采用flat内存模式,所以我们可以认为虚拟地址就是线性地址,即我们可以认为逻辑地址中的32位偏移量就是线性地址。

9)物理地址:即最终发往地址总线上的地址,它对应着实际的物理内存,在32位的Window存储管理中它是通过页表由线性地址转换出来的。

10)实际地址:即“物理地址”。

其中前面的6个概念是学习PE文件格式需要知道的,后面的几个主要在内存管理里面提到,在这里为了便于区别一起列了出来。

PE文件格式详解(二)――PE格式总览

上一节我们已经了解了PE文件格式的作用和其总体结构,从这节开始我们就开始按照上一节中的总体结构从上到下来解析PE文件各个部分的具体结构和作用,当然我不会对每个部分的每一个字段都详细描述它的作用,因为讲解PE文件格式的资料很多,讲解的都很详细,所以我在这里只是按照程序执行的线索和基本原理把那些最重要的字段讲解一下,为了让我们对PE文件格式有个比较清楚的宏观认识,在具体讲解每一部分之前先让我们大概了解一下各部分的作用。

1.DOS MZ header 和 DOS Stub:

如果在DOS下执行PE格式文件就会执行后面的DOS Stub,显示字符串"This program cannot run in DOS mode",如果在Window下执行PE格式文件,PE加载器就会根据DOS MZ header中的最后一个域 e_lfnew跳过DOS Stub直接转到PE Header , DOS MZ header 和 DOS Stub的贡献仅此而已。

2. PE Header:

当加载器跳到PE Header后,根据里面的各个域首先检查这是不是有效的PE文件格式,能否在当前的CPU架构下运行,优先加载基址是多少,一共有几个节(section),这是一个EXE文件还是DLL文件等总体信息,有了这些总体信息之后加载器就会跳到下面的Section table。

3.Section table:

有了上面从PE Header获得的总体信息后,加载器并不能准确的加载文件,因为要准确的加载文件,加载器还需要一些关于每一节的更具体的信息,比如:每一节在磁盘文件上的起始位置、大小,应该被加载的线性地址空间的哪一部分,这一节是代码还是数据,读写属性如何等等。所有这些信息都保存在Section table里面,Section table是一个结构数组,数组里面的每一个结构对应PE文件中的一个节。PE加载器就会遍历这个结构数组把PE文件的每一节准确的加载到线性地址空间。(这里还要注意两点:一是PE加载器把PE文件的每一节加载到线性地址空间并不是说把磁盘上的文件调入物理内存;而只是为它分配线性地址空间,分配线性地址空间意味着申请本进程需要的页表,并把相应的信息添入页表中。线性地址空间也可以看作是一种资源,它是通过页表来体现的,当一个页表被添入相应的信息被占用之后那么这个页表对应的那块线性地址空间也就被分配出去了。需要注意的另一点是PE加载器对每一节采用文件映射的方式把相应的磁盘文件映射到内存,而不是把整个PE文件采用文件映射的方法把磁盘文件映射到内存。更具体的解释我会在“Windows 内存管理”中提到。)

4.Sections:

PE文件最后的部分就是各个节了,比如.text , .data , .idata等等,各种节的作用后面会有一个简要介绍。

思考一下 :既然加载器不一定把程序加载到PE头中指定的优先加载基址,那么如果在没有加载到PE头中指定的优先加载基址的情况下,指令中的地址是不是都要依次修改呢?首先我们要明确的一点是程序指令中的地址分两大类,其中一类是在编译过程就可以确定的,这类地址采用的是相对虚拟地址(RVA),所以即使程序没有被加载到希望的基址这些地址也无需修改。另一类地址是编译过程和连接过程都无法确定的,比如那些引用外部库的函数地址,因为外部库之后在被加载器加载后里面的函数地址才能确定下来,所以程序中的这类地址要在程序被加载后进行修改。那么编译器和连接器对这类无法确定的地址是如何处理的呢?加载器又是根据什么如何来对它们进行修改的呢?个人感觉PE文件格式学习中这一部分内容有些繁杂,所以希望大家读后面各节的时候最好时常思考一下这两个问题。从下一节开始我们将对PE文件的各个部分作更为详尽的讲解。重点部分会放在对上面两个问题的解决上。

PE文件格式详解(三)――DOS Header & PE Header

上一节中我们对PE文件的各个部分的作用有了一个总体的认识,从这节起我们会对PE文件的每个部分作更进一步的解释,当然别忘记了上一节中我提出的两个问题。

1.DOS MZ header 和 DOS Stub:

​ 所有 PE文件(甚至32位的 DLLs) 必须以一个简单的 DOS MZ header 开始。我们通常对此结构没有太大兴趣。有了它,一旦程序在DOS下执行,DOS就能识别出这是有效的执行体,然后运行紧随 MZ header 之后的 DOS stub。DOS stub实际上是个有效的 EXE,在不支持 PE文件格式的操作系统中,它将简单显示一个错误提示,类似于字符串 "This program requires Windows" 或者程序员可根据自己的意图实现完整的 DOS代码。通常我们也不对 DOS stub 太感兴趣: 因为大多数情况下它是由汇编器/编译器自动生成。通常,它简单调用中断21h服务9来显示字符串"This program cannot run in DOS mode"。在Window95下运行32位程序的时候这个部分并不会被加载器映射的线性地址空间,当Win32 加载器把一个PE 文件映像到内存,内存映像文件(memory mapped file)的第一个字节对应到DOS Stub 的第一个字节。WINNT.H 为DOS stub 表头DOS MZ header定义了一个结构,第一个域 e_magic ,被称为魔术数字,它被用于表示一个MS-DOS兼容的文件类型,其作用类似于PE header中的Signature域,所有MS-DOS兼容的可执行文件都将这个值设为0x5A4D,表示ASCII字符MZ。MS-DOS头部之所以有的时候被称为MZ头部,就是这个缘故。还有许多其它的域对于MS-DOS操作系统来说都有用,但是对于Windows NT来说,这个结构中只有一个有用的域——最后一个域 e_lfnew ,PE头部就是由它定位的。循此我们将非常容易找到PE头部,它是一个相对偏移值(或说是RVA),指向真正的PE头部(PE header)。为了获得指针,你必须为RVA 加上image 的基地址:

pNTHeader = dosHeader + dosHeader->e_lfanew;

有了这个指向PE Header的指针我们就可以取得很多有用的信息了,既然我们研究的是PE文件格式,因此PE Header才是我们研究的重点。总之,DOS MZ header和 DOS Stub之间的关系相当于PE header和 EXE或者DLL之间的关系。

2.PE Header:

​ PE header 是PE相关结构 IMAGE_NT_HEADERS 的简称,其中包含了许多PE装载器用到的重要域。当我们更加深入研究PE文件格式后,将对这些重要域耳目能详。执行体在支持PE文件结构的操作系统中执行时,PE装载器将从 DOS MZ header 中找到 PE header 的起始偏移量。因而跳过了 DOS stub 直接定位到真正的文件头 PE header。PE头部整个是个IMAGE_NT_HEADERS 结构,定义于WINNT.H。这个结构正是Windows 95 的module database(“模块”的概念在第一节中说过了,操作系统就是利用这个结构感知“模块”的存在、获得“模块”的信息等;这个结构我会在以后的“模块”学习当中提及)。每一个被载入的EXE 或DLL 都以一个IMAGE_NT_HEADERS 结构表现出来。此结构有一个DWORD 和两个子结构:

DWORD Signature;

IMAGE_FILE_HEADER FileHeader;

IMAGE_OPTIONAL_HEADER OptionalHeader;

(1) 对于PE格式的文件Signature 字段内容应该是ASCII 的PE\0\0。

(2) IMAGE_FILE_HEADER FileHeader:

Field nameMeanings
Machine该文件运行所要求的CPU。对于Intel平台,该值是IMAGE_FILE_MACHINE_I386 (14Ch)。我们尝试了LUEVELSMEYER的pe.txt声明的14Dh和14Eh,但Windows不能正确执行。看起来,除了禁止程序执行之外,本域对我们来说用处不大。
NumberOfSections文件的节数目。如果我们要在文件中增加或删除一个节,就需要修改这个值。
TimeDateStamp文件创建日期和时间。我们不感兴趣。
PointerToSymbolTable用于调试。
NumberOfSymbols用于调试。
SizeOfOptionalHeader指示紧随本结构之后的 OptionalHeader 结构大小,必须为有效值。
Characteristics关于文件信息的标记,比如文件是exe还是dll。

IMAGE_FILE_HEADER结构比较简单,也比较容易理解,在此不做过多的解释;简言之,只有三个域对我们有一些用: Machine, NumberOfSections 和 Characteristics。通常不会改变 Machine 和Characteristics 的值,但如果要遍历节表就得使用 NumberOfSections。

(3) 比较复杂也更有趣的是第三个东东即:IMAGE_OPTIONAL_HEADER,现在我们学习 IMAGE_NT_HEADERS 中的最后成员optional header 结构,它包含了PE文件的逻辑分布信息。该结构共有31个域,一些是很关键,另一些不太常用。这里只介绍那些真正有用的域。

FieldMeanings
AddressOfEntryPointPE装载器准备运行的PE文件的第一个指令的RVA。若您要改变整个执行的流程,可以将该值指定到新的RVA,这样新RVA处的指令首先被执行。
ImageBasePE文件的优先装载地址。比如,如果该值是400000h,PE装载器将尝试把文件装到虚拟地址空间的400000h处。字眼"优先"表示若该地址区域已被其他模块占用,那PE装载器会选用其他空闲地址。
SectionAlignment内存中节对齐的粒度。例如,如果该值是4096 (1000h),那么每节的起始地址必须是4096的倍数。若第一节从401000h开始且大小是10个字节,则下一节必定从402000h开始,即使401000h和402000h之间还有很多空间没被使用。
FileAlignment文件中节对齐的粒度。例如,如果该值是(200h),,那么每节的起始地址必须是512的倍数。若第一节从文件偏移量200h开始且大小是10个字节,则下一节必定位于偏移量400h: 即使偏移量512和1024之间还有很多空间没被使用/定义。
MajorSubsystemVersionMinorSubsystemVersionwin32子系统版本。若PE文件是专门为Win32设计的,该子系统版本必定是4.0否则对话框不会有3维立体感。
SizeOfImage内存中整个PE映像体的尺寸。它是所有头和节经过节对齐处理后的大小。
SizeOfHeaders所有头+节表的大小,也就等于文件尺寸减去文件中所有节的尺寸。可以以此值作为PE文件第一节的文件偏移量。
SubsystemNT用来识别PE文件属于哪个子系统。 对于大多数Win32程序,只有两类值: Windows GUI 和 Windows CUI (控制台)。
SizeOfStackReserve线程初始堆栈的保留大小。
SizeOfStackCommit一开始即被提交(committed)给线程初始堆栈的内存数量。
SizeOfHeapReserve保留给最初的process heap 的虚拟内存数量。
SizeOfHeapCommit一开始即被提交(committed)给process heap 的内存数量。
DataDirectoryIMAGE_DATA_DIRECTORY 结构数组。每个结构给出一个重要数据结构的RVA,比如引入地址表等。

上面表格里最难理解的也是很重要的一个域是最后一个,即:DataDirectory;它是一个结构数组,它一共包含16个元素即共含16个结构;每一个结构对应于一个section(注意这里的section是按照第一节中按作用进行划分的section,不是最终生成的PE文件中包含的节),结构中的两个域分别描述了该section的 RVA 和 SIZE; 这样一来加载器就能够通过这个数组迅速在image 中找到特定的section,后面讲到的导入表,引出表都要用到这个数组中相应的元素,到时候还会有进一步的解释。

PE文件格式详解(四)――Section Table(节表)

到本节为止,我们已经学了许多关于 DOS header 和 PE header 的知识。接下来就该轮到 section table(节表)了。节表其实就是紧挨着 PE header 的一结构数组,它的作用我们在前面已经说过了。该数组成员的数目由 file header (IMAGE_FILE_HEADER) 结构中 NumberOfSections 域的域值来决定。节表结构又命名为 IMAGE_SECTION_HEADER。我们把它的主要成员列表如下:

FieldMeanings
Name1事实上本域的名称是"name",只是"name"已被MASM用作关键字,所以我们只能用"Name1"代替。这儿的节名长不超过8字节。记住节名仅仅是个标记而已,我们选择任何名字甚至空着也行,注意这里不用null结束。命名不是一个ASCIIZ字符串,所以不用null结尾。
VirtualAddress本节的RVA(相对虚拟地址)。PE装载器将节映射至内存时会读取本值,因此如果域值是1000h,而PE文件装在地址400000h处,那么本节就被载到401000h。
SizeOfRawData经过文件对齐处理后节尺寸,PE装载器提取本域值了解需映射入内存的节字节数。(译者注: 假设一个文件的文件对齐尺寸是0x200,如果前面的 VirtualSize域指示本节长度是0x388字节,则本域值为0x400,表示本节是0x400字节长)。
PointerToRawData这是节基于文件的偏移量,PE装载器通过本域值找到节数据在文件中的位置。
Characteristics包含标记以指示节属性,比如节是否含有可执行代码、初始化数据、未初始数据,是否可写、可读等。

好的,现在PE文件的前半部分结构我们已经了解的差不多了,下面就让我们模拟一下加载器加载PE文件的过程吧:

加载器加载PE文件的主要步骤:

\1. 当PE文件被执行,PE装载器检查 DOS MZ header 里的 PE header 偏移量。如果找到,则跳转到 PE header。

\2. PE装载器检查 PE header 的有效性。如果有效,就跳转到PE header的尾部。

\3. 紧跟 PE header 的是节表。PE装载器读取其中的节信息,并采用文件映射方法将这些节映射到内存,同时付上节表里指定的节属性。

\4. PE文件映射入内存后,PE装载器将处理PE文件中类似 import table(引入表)逻辑部分。

加载器检查PE文件有效性步骤总结如下:

\1. 首先检验文件头部第一个字的值是否等于 IMAGE_DOS_SIGNATURE,是则 DOS MZ header 有效。

\2. 一旦证明文件的 DOS header 有效后,就可用e_lfanew来定位 PE header 了。

\3. 比较 PE header 的第一个字的值是否等于 IMAGE_NT_HEADER。如果前后两个值都匹配,那我们就认为该文件是一个有效的PE文件。

现在我们已知晓 IMAGE_SECTION_HEADER 结构,再来模拟一下 PE装载器的工作吧:

\1. 读取 IMAGE_FILE_HEADER 的 NumberOfSections域,知道文件的节数目。

\2. SizeOfHeaders 域值作为节表的文件偏移量,并以此定位节表。

\3. 遍历整个结构数组检查各成员值。

\4. 对于每个结构,我们读取PointerToRawData域值并定位到该文件偏移量。然后再读取SizeOfRawData域值来决定映射内存的字节数。将VirtualAddress域值加上ImageBase(基地址)域值等于节起始的虚拟地址。然后就准备把节映射进内存,并根据Characteristics域值设置属性。

\5. 遍历整个数组,直至所有节都已处理完毕。

遍历节表的步骤:

\1. PE文件有效性校验。

\2. 定位到 PE header 的起始地址。

\3. 从 file header 的 NumberOfSections域获取节数。

\4. 通过两种方法定位节表: ImageBase+SizeOfHeaders 或者 PE header的起始地址+ PE header结构大小。 (节表紧随 PE header)。如果不是使用文件映射的方法,可以用SetFilePointer 直接将文件指针定位到节表。节表的文件偏移量存放在 SizeOfHeaders域里。(SizeOfHeaders 是 IMAGE_OPTIONAL_HEADER(PE header) 的结构成员) //定位节表位置

\5. 处理每个 IMAGE_SECTION_HEADER 结构。( 这是结构数组)

好的,到此为止我们已经清楚了加载器加载PE文件的大部分过程,但是别忘了我们的问题,现在问题还没有解决,要解决这个问题就好弄清楚后面两节:Import Table和Export Table,这两节是最重要的当然也是最复杂的。

PE文件格式详解(五)――Improt Table(引入表)

这节即将学习的Import Table和下节的Export Table关系密切,两者联合起来就可以解决我们开始提出的问题。在说明Import Table和Export Table的作用之前先让我们明白编译器是如何处理我们调用外部库函数的。在PE 文件中,当你调用另一模块中的函数(例如USER32.DLL 中的GetMessage),编译器制造出来的CALL 指令并不会把控制权直接传给DLL 中的函数,而是传给一个JMP DWORD PTR [XXXXXXXX] 指令,后者也位于.text 中。JMP 指令跳到一个地址去,此地址储存在.idata 的一个DWORD之中。这个DWORD 内含该函数的真正地址(函数进入点),如图1 所示

​ 图1

那么,这样做有什么好处呢?试想一下,如果CALL指令后面跟的直接就是DLL中的函数地址,那么加载器就需要修补每一个调用DLL 的指令。而现在PE 载入器需要做的,就只是把DLL 函数的真实地址放到.idata 的那个DWORD 之中,根本就没有程序代码需要修补。嗯,现在比较清除了,加载器首先要知道所加载的程序调用了哪些DLL的哪些函数,然后找出这些函数的地址,把他们添入到.idata 的那些DWORD 之中。那么加载器如何知道所加载的程序调用了哪些DLL的哪些函数,这就是Import Table的作用;加载器又是如何找出这些函数的地址呢,这又是Export Table的作用。现在两者的作用都很清除了,剩下的关键问题就是PE加载其如何利用这两个东东来完成上面的任务,完成了这个任务也就解决了我们开始提出的问题。这节我们先讨论前半部分,也就是加载器如何利用Import Table找出所加载的程序调用了哪些DLL的哪些函数。

首先,最基本的是加载器如何找到Import Table呢?这就要利用前面我们提到的数据目录(Data Directory),它是Option Header结构中的最后一个域。Data Directory 是一个 IMAGE_DATA_DIRECTORY 结构数组,共有16个成员,每个成员包含了一个重要数据结构的信息(RVA 和 大小),并且这些重要数据结构的信息在IMAGE_DATA_DIRECTORY 结构数组中的位置是固定的,这样加载器就很容易找到需要的信息了。这就好比你有一个书架每一层都放着不同种类的书籍,并且它们始终固定,即:第一层始终放小说,第二层始终放散文…等等,当你需要散文的时候你就可以毫不犹豫的去拿第二层上的书就可以了。下面的表就是IMAGE_DATA_DIRECTORY 结构数组的布置情况:(第0,1,12三项是和我们这两节解决问题有关系的;第5项在最后一节中用到;第9项在线程中用到)。

MemberInfo inside
0Export symbols
1Import symbols
2Resources
3Exception
4Security
5Base relocation
6Debug
7Copyright string
8Unknown
9Thread local storage (TLS)
10Load configuration
11Bound Import
12Import Address Table (IAT)
13Delay Import
14COM descriptor

如果您还记得节表可以看作是PE文件各节的根目录的话,也可以认为 Data Directory 是存储在这些节里的逻辑元素的根目录。明确点,Data Directory 包含了PE文件中各重要数据结构的位置和尺寸信息。Data Directory的每个成员都是 IMAGE_DATA_DIRECTORY 结构类型的,其定义如下所示:

IMAGE_DATA_DIRECTORY STRUCT

VirtualAddress dd ?

isize dd ?

IMAGE_DATA_DIRECTORY ENDS

VirtualAddress 实际上是数据结构的相对虚拟地址(RVA)。比如,如果该结构是关于import symbols的,该域就包含指向IMAGE_IMPORT_DESCRIPTOR 数组的RVA。 isize 含有VirtualAddress所指向数据结构的字节数。

待续....

接上文...

现在我们知道如何找到引入表了。Data Directory数组第二项的VirtualAddress包含引入表地址。引入表实际上是一个 IMAGE_IMPORT_DESCRIPTOR 结构数组。每个结构包含PE文件引入函数的一个相关DLL的信息。比如,如果该PE文件从10个不同的DLL中引入函数,那么这个数组就有10个成员。该数组以一个全0的成员结尾。下面详细研究结构组成:

IMAGE_IMPORT_DESCRIPTOR STRUCT

union

Characteristics dd ?

OriginalFirstThunk dd ?

ends

TimeDateStamp dd ?

ForwarderChain dd ?

Name1 dd ?

FirstThunk dd ?

IMAGE_IMPORT_DESCRIPTOR ENDS

结构第一项是一个union子结构。事实上,这个union子结构只是给 OriginalFirstThunk 增添了个别名,您也可以称其为"Characteristics"。 该成员项是指向一个DWORD 数组的RVA。数组里的每一个DWORD 事实上是一个IMAGE_THUNK_DATA union。那么, IMAGE_THUNK_DATA又是什么呢? 每个IMAGE_THUNK_DATA union对应于一个输入函数,这个DWORD(即IMAGE_THUNK_DATA union)的内容视文件被加载与否(即:加载前后内容不同,原因后面会解释),以及函数被以名称输入或以序号输入而定(即:输入方式不同,内容不同)。以名称输入是比较共同的方式。当一个函数以序号输入,EXE 文件的IMAGE_THUNK_DATA DWORD 中的最高位(0x80000000)设立。例如,考虑一个IMAGE_THUNK_DATA,其值为0x80000112,放在GDI32.DLL 数组中。表示这IMAGE_THUNK_DATA 将输入GDI32.DLL 中的第112号输出(exported)函数。如果函数以名称输入,IMAGE_THUNK_DATA DWORD 就内含一个RVA(Relative Virtual Address),指向IMAGE_IMPORT_BY_NAME 结构,由于一个输入函数对应一个IMAGE_THUNK_DATA union,而当函数以名称输入的时候IMAGE_THUNK_DATA又指向一个IMAGE_IMPORT_BY_NAME 结构,所以此时一个输入函数对应一个IMAGE_IMPORT_BY_NAME 结构。现在让我们看看IMAGE_IMPORT_BY_NAME 结构里面有些什么东东,我们希望里面存有一个引入函数的相关信息。奥,原来它真的如我们所愿:

IMAGE_IMPORT_BY_NAME STRUCT

Hint dw ?

Name1 db ?

IMAGE_IMPORT_BY_NAME ENDS

Hint 指示本函数在其所驻留DLL的引出表中的索引号。该域被PE装载器用来在DLL的引出表里快速查询函数。

Name1 含有引入函数的函数名。函数名是一个ASCIIZ字符串。现在请看这里: 假设程序中调用了N个输入函数,那么就对应着有N个 IMAGE_IMPORT_BY_NAME 结构,我们收集起这些结构的RVA 放在IMAGE_THUNK_DATA结构中组成一个数组,并以0结尾,然后再将此数组的RVA放入 OriginalFirstThunk。 这样一来我们就可以利用OriginalFirstThunk这条线把从某一个DLL中调用的函数全部給揪出来。

奥,刚才我们被IMAGE_IMPORT_BY_NAME 结构给中断了一下,现在让我们返回IMAGE_IMPORT_DESCRIPTOR结构,继续看里面的其他域。为了和上面的讨论保持连贯性先让我们来看一下最后一个域FirstThunk,FirstThunk 与 OriginalFirstThunk 非常相似,也是一个RVA,它也是指向一个 IMAGE_THUNK_DATA 结构数组(当然这是另外一个IMAGE_THUNK_DATA 结构数组,不过这个IMAGE_THUNK_DATA结构数组和OriginalFirstThunk指向的IMAGE_THUNK_DATA结构数组内容是完全一样的)。好的让我们理顺一下:现在有几个 IMAGE_IMPORT_BY_NAME 结构,同时您又创建了两个结构数组,并同样存入指向那些 IMAGE_IMPORT_BY_NAME 结构的RVA,这样两个数组就包含相同数值了(可谓相当精确的复制啊)。最后您决定将第一个数组的RVA赋给 OriginalFirstThunk,第二个数组的RVA赋给 FirstThunk,这样一切都很清楚了。如果你对上面一堆的RVA和结构数组的关系感到头晕的话那么请往下看,或许下面这个图解能让你清醒一些:

OriginalFirstThunkIMAGE_IMPORT_BY_NAMEFirstThunk
||
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
...
IMAGE_THUNK_DATA
--->
--->
--->
--->
--->
--->
Function 1
Function 2
Function 3
Function 4
...
Function n
<---
<---
<---
<---
<---
<---
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
IMAGE_THUNK_DATA
...
IMAGE_THUNK_DATA

现在您应该明白我的意思。不要被IMAGE_THUNK_DATA这个名字弄糊涂: 当以名字引入函数时,它仅是指向 IMAGE_IMPORT_BY_NAME 结构的RVA。 如果将 IMAGE_THUNK_DATA 字眼想象成RVA,就更容易明白了。OriginalFirstThunk 和 FirstThunk 所指向的这两个数组大小取决于PE文件从DLL中引入函数的数目。比如,如果PE文件从kernel32.dll中引入10个函数,那么IMAGE_IMPORT_DESCRIPTOR 结构的 Name1域包含指向字符串"kernel32.dll"的RVA,同时每个IMAGE_THUNK_DATA 数组有10个元素。

下一个问题是: 为什么我们需要两个完全相同的数组? 为了回答该问题,我们需要了解当PE文件被装载到内存时,第一个数组(由Characteristics 指向)从不被修改,这样一来所以若还反过头来查找引入函数名,PE装载器还能找寻到。第二个数组(由FirstThunk 指向)则被载入器改写。载入器一一检阅每一个IMAGE_THUNK_DATA ,然后进一步通过其他结构找出引入函数的地址。然后用引入函数真实地址来替代由FirstThunk指向的 IMAGE_THUNK_DATA 数组里的元素值。因此当PE文件准备执行时,上图已转换成:(这也证实了前面所说的IMAGE_THUNK_DATA结构的内容不仅和函数是按序号或者名字引入有关,而且和文件是否加载有关。由于此时FirstThunk指向的IMAGE_THUNK_DATA数组已经被修改,所以它和IMAGE_IMPORT_BY_NAME数组的关系也被切断。)

稍早我曾经说过对DLL 函数的调用会导至一个JMP DWORD PTR [XXXXXXXX] 指令。[XXXXXXXX] 事实上参考到FirstThunk 数组中的一个元素。由于这个IMAGE_THUNK_DATA 数组内容已被加载器改写为输入函数的地址,所以它又被称做Import Address Table(IAT),由于这块数据区的重要性,为了查找方便在数据目录(Data Directory)中有一项(第12项)就是来描述它的。下面是一个更具体的图例: 

图2

下面让我们看看IMAGE_IMPORT_DESCRIPTOR 结构中剩下的几个域吧:

ForwarderChain:这个字段关系到所谓的forwarding(转交),意味一个DLL 函数再参考(调用、利用)另一个DLL,由于涉及的东西比较复杂,所以不做进一步介绍。

Name1:是一个指向DLL名字的RVA,即指向DLL名字的指针,也是一个ASCIIZ字符串。

有了上面的知识之后假设我们要列出某个PE文件的所有引入函数,可以照着下面步骤走:

\1. 校验文件是否是有效的PE。

\2. 从 DOS header 定位到 PE header。

\3. 获取位于 OptionalHeader 数据目录地址。

\4. 转至数据目录的第二个成员提取其VirtualAddress值。//import

\5. 利用上值定位第一个 IMAGE_IMPORT_DESCRIPTOR 结构。

\6. 检查 OriginalFirstThunk值。若不为0,顺着 OriginalFirstThunk 里的RVA值转入那个RVA数组。若 OriginalFirstThunk 为0,就改用FirstThunk值。有些连接器生成PE文件时会置OriginalFirstThunk值为0,这应该算是个bug。不过为了安全起见,我们还是检查 OriginalFirstThunk值先。

\7. 对于每个数组元素,我们比对元素值是否等于IMAGE_ORDINAL_FLAG32。如果该元素值的最高二进位为1, 那么函数是由序数引入的,可以从该值的低字节提取序数。

\8. 如果元素值的最高二进位为0,就可将该值作为RVA转入 IMAGE_IMPORT_BY_NAME 数组,跳过 Hint 就是函数名字了。

\9. 再跳至下一个数组元素提取函数名一直到数组底部(它以null结尾)。现在我们已遍历完一个DLL的引入函数,接下去处理下一个DLL。

\10. 即跳转到下一个 IMAGE_IMPORT_DESCRIPTOR 并处理之,如此这般循环直到数组见底。(IMAGE_IMPORT_DESCRIPTOR 数组以一个全0域元素结尾)。

这一节已经够长了,也已经把Import Table解释的比较清楚了,但是我们的问题解决了吗?没有,我们的目的是要找出程序中引入的所有函数,并且把它们的真实地址找出来然后添入FirstThunk指向的 IMAGE_THUNK_DATA 数组里,那么我们这节解决了哪些问题呢?奥,原来我们已经可以知道程序中引入了哪些DLL,以及这些DLL中的哪些函数了,下面的任务就是要找出这些函数对应的地址了。

PE****文件格式详解(六)――Exprot Table(引出表)

这节我们来解决上一节遗留下来的问题:寻找引入函数的真实地址,那么去哪里找呢?当PE装载器执行一个程序,它将相关DLL都装入该进程的地址空间。然后根据主程序的引入函数信息,查找相关DLL中的真实函数地址来修正主程序。PE装载器搜寻的是DLL中的引出函数,这些引出函数的信息正是放在Exprot Table(引出表)中。

//引出表放的是函数的实际地址

DLL/EXE要引出一个函数给其他DLL/EXE使用,有两种实现方法: 通过函数名引出或者仅仅通过序数引出(这恰好对应相应的两种引入方式)。比如某个DLL要引出名为"GetSysConfig"的函数,如果它以函数名引出,那么其他DLLs/EXEs若要调用这个函数,必须通过函数名,就是GetSysConfig。另外一个办法就是通过序数引出。什么是序数呢? 序数是唯一指定DLL中某个函数的16位数字,在所指向的DLL里是独一无二的。例如在上例中,DLL可以选择通过序数引出,假设是16,那么其他DLLs/EXEs若要调用这个函数必须以该值作为GetProcAddress调用参数。这就是所谓的仅仅靠序数引出。不过通常不提倡仅仅通过序数引出函数这种方法,这会带来DLL维护上的问题。一旦DLL升级/修改,程序员无法改变函数的序数,否则调用该DLL的其他程序都将无法工作。

现在我们开始学习引出结构。象引出表一样,可以通过数据目录找到引出表的位置。这儿,引出表是数据目录的第一个成员,又可称为IMAGE_EXPORT_DIRECTORY。该结构中共有11 个成员,常用的列于下表。

上面也许无法让您完全理解引出表,下面的简述将助您一臂之力。

引出表的设计是为了方便PE装载器工作。首先,模块必须保存所有引出函数的地址以供PE装载器查询。模块将这些信息保存在AddressOfFunctions域指向的数组中,而数组元素数目存放在NumberOfFunctions域中。 因此,如果模块引出40个函数,则AddressOfFunctions指向的数组必定有40个元素,而NumberOfFunctions值为40。现在如果有一些函数是通过名字引出的,那么模块必定也在文件中保留了这些信息。这些名字的RVA存放在一数组中以供PE装载器查询。该数组由AddressOfNames指向,NumberOfNames包含名字数目。考虑一下PE装载器的工作机制,它知道函数名,并想以此获取这些函数的地址。至今为止,模块已有两个数组: 函数名字数组和函数地址数组,但两者之间还没有联系的纽带。因此我们还需要一些联系函数名及其地址的东东。先让我们想一下,如果我们要按照名字查找一个函数的地址,那么如果我们能知道该函数地址在函数地址数组中的索引就好了,好,那么我们现在就是需要一个根据函数名字找到该函数地址在函数地址数组中的索引的这么一个东东。   有了这个东东PE装载器在名字数组中找到匹配名字的同时,也就获取了指向地址表中对应元素的索引。 那么索引存储在哪里呢?原来这些索引保存在由AddressOfNameOrdinals域指向的另一个数组(最后一个)中。由于该数组是起了联系名字和地址的作用,所以其元素数目必定和名字数组相同,比如,每个名字有且仅有一个相关地址,反过来则不一定: 每个地址可以有好几个名字来对应。因此我们给同一个地址取"别名"。(也就是说名字数组的大小应该大于或者等于地址数组的大小)为了起到连接作用,名字数组和索引数组必须并行地成对使用,譬如,索引数组的第一个元素必定含有第一个名字的索引,这样一来一旦我们在名字数组中的第一个位置找到了匹配的函数名,那么我们就可以从索引数组的第一个位置取出一个索引值,用这个索引值去地址数组中取出函数地址,以此类推。 具体可以参考下面的图示:(注意图中的双箭头只是表示一一对应的关系,而不是指针的指向关系。)(这就形成了一个一一映射的关系);

下面是一个更具体的图示:

//使用索引输出的函数应该是遍历整个数组后得到的结果,这样就增加了寻找时间

下面举一两个例子说明问题。如果我们有了引出函数名并想以此获取地址,可以这么做:

\1. 定位到PE header。

\2. 从数据目录读取引出表的虚拟地址。

\3. 定位引出表获取名字数目(NumberOfNames)。

\4. 并行遍历AddressOfNames和AddressOfNameOrdinals指向的数组匹配名字。如果在AddressOfNames 指向的数组中找到匹配名字,从AddressOfNameOrdinals 指向的数组中提取索引值。例如,若发现匹配名字的RVA存放在AddressOfNames 数组的第77个元素,那就提取AddressOfNameOrdinals数组的第77个元素作为索引值。如果遍历完NumberOfNames 个元素,说明当前模块没有所要的名字。

\5. 从AddressOfNameOrdinals 数组提取的数值作为AddressOfFunctions 数组的索引。也就是说,如果值是5,就必须读取AddressOfFunctions 数组的第5个元素,此值就是所要函数的RVA。

现在我们在把注意力转向IMAGE_EXPORT_DIRECTORY 结构的nBase成员。您已经知道AddressOfFunctions 数组包含了模块中所有引出符号的地址。当PE装载器索引该数组查询函数地址时,让我们设想这样一种情况,如果程序员在.def文件中设定起始序数号为200,这意味着AddressOfFunctions 数组至少有200个元素,甚至这前面200个元素并没使用,但它们必须存在,因为PE装载器这样才能索引到正确的地址。这种方法很不好,所以又设计了nBase 域解决这个问题。如果程序员指定起始序数号为200,nBase 值也就是200。当PE装载器读取nBase域时,它知道开始200个元素并不存在,这样减掉一个nBase值后就可以正确地索引AddressOfFunctions 数组了。有了nBase,就节约了200个空元素。

注意nBase并不影响AddressOfNameOrdinals数组的值。尽管取名"AddressOfNameOrdinals",该数组实际包含的是指向AddressOfFunctions 数组的索引,而不是什么序数啦。讨论完nBase的作用,我们继续下一个例子。假设我们只有函数的序数,那么怎样获取函数地址呢,可以这么做:

\1. 定位到PE header。

\2. 从数据目录读取引出表的虚拟地址。

\3. 定位引出表获取nBase值。

\4. 减掉nBase值得到指向AddressOfFunctions 数组的索引。

\5. 将该值与NumberOfFunctions作比较,大于等于后者则序数无效。

\6. 通过上面的索引就可以获取AddressOfFunctions 数组中的RVA了。 //这里解释了nBase的作用,这样可以加快速度

可以看出,从序数获取函数地址比函数名快捷容易。不需要遍历AddressOfNames 和 AddressOfNameOrdinals 这两个数组。然而,综合性能必须与模块维护的简易程度作一平衡。

总之,如果想通过名字获取函数地址,需要遍历AddressOfNames 和 AddressOfNameOrdinals 这两个数组。如果使用函数序数,减掉nBase值后就可直接索引AddressOfFunctions 数组。

如果一函数通过名字引出,那在GetProcAddress中可以使用名字或序数。但函数仅由序数引出情况又怎样呢? 现在就来看看。"一个函数仅由序数引出"意味着函数在AddressOfNames 和 AddressOfNameOrdinals 数组中不存在相关项。记住两个域,NumberOfFunctions 和 NumberOfNames。这两个域可以清楚地显示有时某些函数没有名字的。函数数目至少等同于名字数目,没有名字的函数通过序数引出。比如,如果存在70个函数但AddressOfNames数组中只有40项,这就意味着模块中有30个函数是仅通过序数引出的。现在我们怎样找出那些仅通过序数引出的函数呢?这不容易,必须通过排除法,比如,AddressOfFunctions 的数组项在AddressOfNameOrdinals 数组中不存在相关指向,这就说明该函数RVA只通过序数引出。

到此为止故事好像结束了,因为我们知道应用程序中都引入了哪些函数,也能找到它们对应的地址了,但是我们从地址数组中找到的地址是不是直接添入FirstThunk指向的IMAGE_THUNK_DATA数组呢?先让我们看看地址数组中的函数地址是什么吧,奥,原来这些地址是一些RVA,这些RVA是相对于DLL的ImageBase而言的,然而程序执行时需要的指令地址是进程地址空间的地址,所以加载器要把这些RVA转换成进程地址空间的地址,其实这很容易,只需要把RVA和DLL的ImageBase相加即可转换成进程地址空间的地址,然后把这些地址添入FirstThunk指向的IMAGE_THUNK_DATA数组。到此为止我们的问题已经解决了。这一节也就圆满结束了。下一节我们将对剩下的一些其他PE格式相关知识简要介绍一下简要作个总结这个PE格式学习笔记就算结束了

PE文件格式详解(七)――PE 文件的基底重定位(Base Relocations)

这节是最后一节了,其实PE格式里面还有很多东西,比如资源,也是挺复杂的一个东东,不过我对它不感兴趣,写点儿自己感兴趣的东东吧――PE 文件的基底重定位(Base Relocations)。前面我们说过了每个模块有一个优先加载地址ImageBase,这个值是连接器给出的,因此连接器生成指令中的地址时是在假设模块被加载到ImageBase的前提之下生成的,这样一来一旦模块没有按照预期的加载到ImageBase,那么程序中的指令就需要修改。下面是一个例子:假设有一个可执行文件,基地址是0x400000。在这个image 偏移位置0x2134 处是一个指针,指向一个字符串。字符串始于实际地址0x404002 处,所以指针内容应该是0x404002。你可以把文件加载,但是加载器决定把它映像到实际地址0x600000处。连接器假设的基地址和实际加载的起始地址之间的差额称为delta。此例之delta 为0x20000。整个image 的位置提高了0x20000,其中的字符串当然也是(现在应该是0x604002)。所以指向字符串的指针就错误了,delta 应该加到指针值中。为了让Windows 加载器有能力做这样的调整,可执行文件内含许多个「基底重定位资料项」,给那些存放指针的位置(本例为0x2134)使用。加载器必须把delta 加到各个位址上。本例之中加载器应该把0x20000 加给原来的指针值(0x404002),并将结果0x604002写回原处。下图显示这个过程:

图1

上面出现了一个名词「基底重定位资料项」,加载器就是利用它才知道如果模块没有按照预期的位置被加载,那么哪些指令需要修改的。因此这节我们研究的重点将是「基底重定位资料项」。首先加载器也是通过数据目录来定位「基底重定位资料项」的。然后让我们来看看「基底重定位资料项」的真实面目吧。「基底重定位资料项」被包装为一系列连续区段,长短不一。每一个区段描述image 中的一个4K page (也就是一页)的重定位信息(因为每页中需要重地位的指令数目不同,所以区段的长短不一)。它们以一个IMAGE_BASE_RELOCATION 结构做为开始,格式如下:

DWORD VirtualAddress

此一字段内含这些个「基底重定位资料项」的起始RVA 值。每一个「基底重定位资料项」的偏移位置(即下面的TypeOffset域的低12位,它是指令相对它所在页的第一条指令的偏移)必须加上此值才能够构成一个真正的RVA,指向「基底重定位资料项」。

DWORD SizeOfBlock

结构大小,再加上所有跟随在后的「基底重定位数据项」(都是WORDs)。为了决定区块中的「基底重定位资料项」的个数,先把此值减去IMAGE_BASE_RELOCATION 结构大小(8 个字节),再除以2(WORD 大小)。如果此字段为44,就表示有18 个「基底重定位资料项」:

( 44 - sizeof(IMAGE_BASE_RELOCATION) ) / sizeof(WORD) = 18

WORD TypeOffset

这并不是单独一个WORD,事实上它是一个WORDs 数组。数组元素个数可由上一个式子计算获得。每一个WORD 的最底部12 个位代表「基底重定位数据项」偏移位置,但必须再加上「基底重定位数据项」区块表头的VirtualAddress 字段值。最高的4个位是「基底重定位数据项」的型态。对于在Intel CPUs 中执行的PE 文件,你将看到两种型态:

0(IMAGE_REL_BASED_ABSOLUTE):此一「基底重定位数据项」无意义,只是用来充数而已,使所有「基底重定位数据项」的总共大小成为DWORD 的倍数。

3(IMAGE_REL_BASED_HIGHLOW):把delta 值加到欲计算的RVA 值去。另外还有其它型态,在WINNT.H 中定义。它们大部份是给i386 以外的CPU 使用。

下面列出一些「基底重定位数据项」,请注意其中的RVA 值已经被IMAGE_BASE_RELOCATION 结构中的VirtualAddress 字段校正过了。

Virtual Address: 00001000 Size: 0000012C

00001032 HIGHLOW

0000106D HIGHLOW

000010AF HIGHLOW

000010C5 HIGHLOW

// Rest of chunk omitted...

Virtual Address: 00002000 Size: 0000009C

000020A6 HIGHLOW

00002110 HIGHLOW

00002136 HIGHLOW

00002156 HIGHLOW

// Rest of chunk omitted...

Virtual Address: 00003000 Size: 00000114

0000300A HIGHLOW

0000301E HIGHLOW

0000303B HIGHLOW

0000306A HIGHLOW

// Rest of chunk omitted...

通过上面的实例我们可以看出相邻区段的Virtual Address整好相差0x1000,也就是4k,这证实了上面所说的“每一个区段描述image 中的一个4K page (也就是一页)的重定位信息”同时我们还可以看到第一个区段的Virtual Address是00001000,这整好是通常的.text段的RVA。

现在问题基本明了了,一旦模块没有加载到预期的位置,那么加载器就会根据「基底重定位数据项」去修正哪些修要修正的指令,这样程序就可以正确执行了 。

参考资料:

1.《Windows 95 系统程序设计大奥秘》-第8章PE 与COFF OBJ 文件格式

2.《Iczelion的PE教程》

3.《PE文件格式详解》