13.PE文件格式

1. PE文件格式介绍

PE文件是Windows操作系统下使用的可执行文件格式。它是微软在UNIX平台的COFF(Common Object File Format),通用对象文件格式)基础上制作而成的。最初(正如Portable这个单词所代表的那样)设计用来提高程序在不同操作系统上的移植性,但实际上这种文件格式仅用在Windows系列的操作系统下。
PE文件是指32位的可执行文件,也称为PE3264位的可执行文件称为PE+或PE32+,是PE(PE32)文件的一种扩展形式(请注意不是PE64)。 

2. PE文件格式

种类 主拓展名
可执行系列 exe、scr
驱动程序系列 sys、vxd
库系列 dll、ocx、cpl、drv
对象文件系列 obj

严格地说,OBJ(对象)文件之外的所有文件都是可执行的。DLL、SYS文件等虽然不能直接在Shell(Explorer.exe)中运行,但可以使用其他方法(调试器、服务等)执行。

根据PE正式规范,编译结果OBJ文件也视为PE文件。但是OBJ文件本身不能以任何形式执行,在代码逆向分析中几乎不需要关注它

然后我们用010打开记事本,
图中是 notepad.exe 文件的起始部分,也是PE文件的头部分(PEheader)。notepad.exe文件运行需要的所有信息就存储在这个PE头中。如何加载到内存、从何处开始运行、运行中需要的DLL有哪些、需要多大的栈/堆内存等,大量信息以结构体形式存储在PE头中。
换言之,学习PE文件格式就是学习PE头中的结构体
Pasted image 20250313130611

2.1. 基本结构

从DOS头(DOSheader)到节区头(Section header)是PE头部分,其下的节区合称PE体。文件中使用偏移(offset),内存中使用VA(VirtualAddress,虚拟地址)来表示位置。文件加载到内存时,情况就会发生变化(节区的大小、位置等)。

文件的内容一般可分为代码(.text)、数据(.data)、资源(.rsrc)节,分别保存。

根据所用的不同开发工具(VB/VC++/Delphi/etc)与编译选项,节区的名称、大小、个数、存储的内容等都是不同的。最重要的是它们按照不同的用途分类保存到不同的节中。

Pasted image 20250313132029

各节区头定义了各节区在文件或内存中的大小、位置、属性等。

PE头与各节区的尾部存在一个区域,称为NULL填充(NULLpadding)。计算机中,为了提高处理文件、内存、网络包的效率,使用“最小基本单位”这一概念,

PE文件中也类似。文件/内存中节区的起始位置应该在各文件/内存最小单位的倍数位置上,空白区域将用NULL填充(看图,可以看到各节区起始地址的截断都遵循一定规则)。

2.2. VA&RVA

  • VA指的是进程虚拟内存的绝对地址
  • RVA(RelativeVirtualAddress,相对虚拟地址) 指从某个基准位置(ImageBase)开始的相对地址。
  • VA与RVA满足下面的换算关系: RVA+ImageBase=VA

PE头内部信息大多以RVA形式存在原因在于,PE文件(主要是DLL)加载到进程虚拟内
存的特定位置时,该位置可能已经加载了其他PE文件(DLL)。此时必须通过重定位(Relocation)将其加载到其他空白的位置,若PE头信息使用的是VA,则无法正常访问。因此使用RVA来定位信息,即使发生了重定位,只要相对于基准位置的相对地址没有变化,就能正常访问到指定信息,不会出现任何问题。

32位WindowsOS中,各进程分配有4GB的虚拟内存,因此进程中VA值的范围是00000000~FFFFFFFF
64位是 0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF(16EB)

3. PE头

PE头由许多结构体组成

3.1. DOS头

微软创建PE文件格式时,人们正广泛使用DOS文件,所以微软充分考虑了PE文件对DOS文件的兼容性。其结果是在PE头的最前面添加了一个 IMAGE_DOS_HEADER 结构体,用来扩展已有的DOS EXE头。
如图
Pasted image 20250313132930
IMAGE_DOS_HEADER 结构体的大小为 0x40个字节。 主要由两个重要成员

  • e_magic:DOS签名(signature,4D5A=>ASCI值“MZ”)。
  • e_lfanew:指示NT头的偏移(根据不同文件拥有可变值)。
    Pasted image 20250313133433

    这里的NT头偏移是 000000E0,因为Intel系列的CPU以逆序存储数据,这称为小端序标识法

如果我们修改这两处任意一个值,都会发现程序无法运行(因为根据PE规范,它已经不是PE文件了)
Pasted image 20250313134056

3.2. DOS存根

DOS存根(stub)在DOS头下方,是个可选项,且大小不固定(即使没有DOS存根,文件也能正常运行)。DOS存根由代码与数据混合而成
Pasted image 20250313134207
其中40-4D处的16进制是一串16位的汇编指令,32位的windowsOS中不会运行此命令(由于被识别为PE文件,所以完全忽视该代码)
如果在DOS环境中运行记事本,或者使用调试器运行,就会输出 This program cannot be run in DOS mode 然后就退出

3.3. NT头

NT头 IMAGE_NT_HEADERS 结构体由3个成员组成

  • 签名(Signature)结构体,其值为50450000h(“PE”00)
  • 文件头(FileHeader)结构体
  • 可选头(OptionalHeader)结构体
    Pasted image 20250313135141

    32位NT头结构体大小为 0xF8 248字节 (4 + 20 + 224)
    64位NT头结构体大小为0x108 264字节(4 + 20 + 240)

3.3.1. NT头:文件头

Pasted image 20250313143804
文件头 IMAGE_FILE_HEADERS 结构体中有如下4种重要成员(若它们设置不正确,将导致文件无法正常运行)。

1.Machine
每个CPU都拥有唯一的Machine码,兼容32位Intelx86芯片的Machine码为14C。
Pasted image 20250313135749
Pasted image 20250313135833

2.NumberOfSections
前面提到过,PE文件把代码、数据、资源等依据属性分类到各节区中存储。
NumberOfSections 用来指出文件中存在的节区数量。该值一定要大于0,且当定义的节区数量与实际节区不同时,将发生运行错误。

3 .SizeOfOptionalHeader
IMAGE_NT_HEADER结构体的最后一个成员为IMAGE_OPTIONAL_HEADER32结构体。
SizeOfOptionalHeader成员用来指出IMAGE_OPTIONAL_HEADER32结构体的长度

IMAGE_OPTIONAL_HEADER32 结构体由C语言编写而成,故其大小已经确定。但是Windows的PE装载器需要查看 IMAGE_FILE_HEADERSizeOfOptionalHeader 值,从而识别出 IMAGE_OPTIONAL_HEADER32 结构体的大小。

PE32+格式的文件中使用的是 IMAGE_OPTIONAL_HEADER64 结构体,而不是 IMAGEOPTIONAL_HEADER32 结构体。2个结构体的尺寸是不同的,所以需要 SizeOfOptionalHeader 成员中明确指出结构体的大小。

借助 IMAGE_DOS_HEADERe_lfanew 成员与 IMAGE_FILE_HEADERSizeOfOptionalHeader 成员,可以创建出一种脱离常规的PE文件(PEPatch)(也有人称之为“麻花”PE文件)。

4.CHaracteristics
该字段用于标识文件的属性,文件是否是可运行的形态、是否为DLL文件等信息,以bitOR形式组合起来。
以下是定义在 winnt.h 文件中的Characteristics值(请记住0002h与2000h这两个值)
Pasted image 20250313140519

另外,PE文件中Characteristics的值可以不是0002h(不可执行的),比如类似*.obj的object文件及resourceDLL文件等。

5. 不影响文件运行的 TimeDateStampA 成员
该成员的值不影响文件运行,用来记录编译器创建此文件的时间。但是有些开发工具(VB、VC++)提供了设置该值的工具,而有些开发工具(Delphi)则未提供(且随所用选项的不同而不同)
Pasted image 20250313140846

3.3.2. NT头:可选 头

Pasted image 20250313143733
IMAGE_OPTIONAL_HEADER32 是PE头结构体中最大的。
IMAGE_OPTIONAL_HEADER32 结构体中需要关注下列成员。这些值是文件运行必需的,设置错误将导致文件无法正常运行。

1.Magic
IMAGE_OPTIONAL_HEADER32 结构体时,Magic码为 10B
IMAGE_OPTIONALHEADER64 结构体时,Magic码为 20B

2 .AddressOfEntryPoint
AddressOfEntryPoint持有EP的RVA值。该值指出程序最先执行的代码起始地址,相当重要。

3 .ImageBase
进程虚拟内存的范围是0~FFFFFFFF(32位系统)。PE文件被加载到如此大的内存中时,ImageBase指出文件的优先装人地址。

EXE、DLL文件被装载到用户内存的0~7FFFFFFF中,SYS文件被载人内核内存的80000000~FFFFFFFF中。一般而言,使用开发工具(VB/VC++/Delphi)创建好EXE文件后,其ImageBase的值为00400000,DLL文件的ImageBase值为10000000(当然也可以指定为其他值)。执行PE文件时,PE装载器先创建进程,再将文件载人内存,然后把EIP寄存器的值设置为ImageBase+AddressOfEntryPoint。

4. SectionAlignment, FileAlignment
PE文件的Body部分划分为若干节区,这些节存储着不同类别的数据。FileAlignment指定了节区在磁盘文件中的最小单位,而SectionAlignment则指定了节区在内存中的最小单位(一个文件中,FileAlignment与SectionAlignment的值可能相同,也可能不同)。
磁盘文件或内存的节区大小必定为FileAlignment或SectionAlignment值的整数倍。

5 . SizeOfImage
加载PE文件到内存时,SizeOfImage指定了PEImage在虚拟内存中所占空间的大小。一般而言,文件的大小与加载到内存中的大小是不同的(节区头中定义了各节装载的位置与占有内存的大小)

6 .SizeOfHeader
SizeOfHeader用来指出整个PE头的大小。该值也必须是FileAlignment的整数倍。第一节区所在位置与SizeOfHeader距文件开始偏移的量相同。

7 .Subsystem
该Subsystem值用来区分系统驱动文件(*.sys)与普通的可执行文件(*.exe,*.dll)。Subsystem成员可拥有的值如表所示。
Pasted image 20250313141539

8 .NumberOfRvaAndSizes
NumberOfRvaAndSizes用来指定DataDirectory(IMAGE_OPTIONAL_HEADER32结构体的最后一个成员)数组的个数。虽然结构体定义中明确指出了数组个数为IMAGE_NUMBEROF_DIRECTORY_ENTRIES(16),但是PE装载器通过查看NumberOfRvaAndSizes值来识别数组大小,换言之,数组大小也可能不是16。

9 .DataDirectory
DataDirectory是由IMAGE_DATA_DIRECTORY结构体组成的数组,数组的每项都有被定义的值。 其中最重要的是 EXPORT/IMPORT/RESOURCE、TLSDirection。特别需要注意的是IMPORT与EXPORT Directory(导出表与导入表)
Pasted image 20250313142002

3.4. 节区头

节区头是由IMAGE_SECTION_HEADER结构体组成的数组,每个结构体对应一个节区。
Pasted image 20250313143711
节区头中定义了各节区属性。看节区头之前先思考一下:前面提到过,PE文件中的code(代码)、data(数据入、resource(资源)等按照属性分类存储在不同节区,设计PE文件格式的工程师们之所以这样做,一定有着某些好处。

如可以保证程序的安全性。若把code与data放在一个节区中相互纠缠(实际上完全可以这样做)很容易引发安全问题,即使忽略过程的烦琐。
假如向字符串data写数据时,由于某个原因导致溢出(输人超过缓冲区大小时),那么其下的code(指令)就会被覆盖,应用程序就会崩溃。因此,PE文件格式的设计者们决定把具有相似属性的数据统一保存在一个被称为“节区”的地方,然后需要把各节区属性记录在节区头中(节区属性中有文件/内存的起始位置、大小、访问权限等)。
Pasted image 20250313142351

3.4.1. VirtualAddress与PointerToRawData

Pasted image 20250313143643
VirtualAddress与PointerToRawData不带有任何值,分别由(定义在IMAGE_OPTIONAL_HEADER32中的)SectionAlignment与FileAlignment确定。

VirtualSize与SizeOfRawData一般具有不同的值,即磁盘文件中节区的大小与加载到内存中的节区大小是不同的。

3.4.2. Name字段

Pasted image 20250313143652
Name成员不像C语言中的字符串一样以NULL结束,并且没有“必须使用ASCI值”的限制。PE规范未明确规定节区的Name,所以可以向其中放人任何值,甚至可以填充NULL值。所以节区的Name仅供参考,不能保证其百分之百地被用作某种信息(数据节区的名称也可叫做.code)。

4. RVA to RAW

讲解PE文件时经常出现“映像”(Image)这一术语,希望各位牢记。PE文件加载到内存时,文件不会原封不动地加载,而要根据节区头中定义的节区起始地址、节区大小等加载。因此,磁盘文件中的PE与内存中的PE具有不同形态。将装载到内存中的形态称为“映像”以示区别,使用这一术语能够很好地区分二者。

理解了节区头后,下面继续讲解有关PE文件从磁盘到内存映射的内容。
PE文件加载到内存时,每个节区都要能准确完成内存地址与文件偏移间的映射。这种映射一般称为RVA to RAW
方法如下。
(1)查找RVA所在节区。
(2)使用简单的公式计算文件偏移(RAW)。
根据IMAGE_SECTION_HEADER结构体,换算公式如下:

4.1. 练习题-计算notepad.exe的文件与内存间的映射关系

Pasted image 20250316135823

VA:进程虚拟内存的绝对地址
RVA:相对虚拟地址(从某个基准位置(ImageBase)开始的相对地址)
ImageBase:基准位置
RVA+ImageBase=VA
从图中可知
Pasted image 20250316143605

4.1.1. Q1: RVA相对虚拟地址=5000时,文件偏移 RAW 是多少

首先查看RVA所在的节区
RVA 5000处于第一个节区.text(假设imageBase为 01000000
使用公式计算

4.1.2. Q2: RVA=13314时,FIle Offset=?

13314处于第三个节区 .rsrc
公式计算

4.1.3. RVA=ABA8时,FIle Offset=?

ABA8处于第二给节区 .data

计算结果为 RAW=97A8,但是该偏移在第三个节区(.rsrc)。RVA在第二个节区,而RAW在第三个节区,这显然是错误的。该情况表明“无法定义与RVA(ABA8)相对应的RAW值”。出现以上情况的原因在于,第二个节区的 VirtualSize 值要比 SizeOfRawData 值大。
即:RVA(Relative Virtual Address,相对虚拟地址)和 RAW(文件偏移)必须在同一个节(Section)内,才能正确计算出 RAW 值

5. IAT 导入地址表

IAT(Import Address Table,导人地址表)。IAT保存的内容与Windows操作系统的核心进程、内存、DLL结构等有关。
换句话说,只要理解了IAT,就掌握了Windows操作系统的根基。简言之,IAT是一种表格,用来记录程序正在使用哪些库中的哪些函数

5.1. DLL 动态链接库

16位的DOS时代不存在DLL这一概念,只有“库”(Library)一说。比如在C语言中使用 printf() 函数时,编译器会先从C库中读取相应函数的二进制代码,然后插人(包含到)应用程序。也就是说,可执行文件中包含着 printf 函数的二进制代码。WindowsOS支持多任务,若仍采用这种包含库的方式,会非常没有效率。Windows操作系统使用了数量庞大的库函数(进程、内存、窗口、消息等)来支持32位的Windows环境。同时运行多个程序时,若仍像以前一样每个程序运行时都包含相同的库,将造成严重的内存浪费(当然磁盘空间的浪费也不容小觑)。因此,WindowsOS设计者们根据需要引入了DLL这一概念,DLL描述如下

  • 不要把库包含到程序中,单独组成DLL文件,需要时调用即可。
  • 内存映射技术使加载后的DLL代码、资源在多个进程中实现共享。
  • 更新库时只要替换相关DLL文件即可,简便易行。

加载DLL的两种方式

  • 显式链接:程序使用DLL的时候加载,使用完后就释放内存
  • 隐式链接:程序开始时即一同加载DLL,程序终止时再释放占用的内存。(IAT提供的机制与此有关)

使用OD打开 notepad.exe,然后查看IAT,比如我们要查看程序如何调用位于 kernel32.dllCreateFileW() 函数
所有模块间的引用中找到对应的函数
Pasted image 20250316161651
调用 CreateFileW() 函数时并非是直接调用,而是通过获取 01001104 地址处的值来实现的(所有API都采用这种方式)
Pasted image 20250316161911
地址 01001104notepad.exe.text 节区的内存区域(更确切说是IAT内存区域)。此处的值是 756833E0,而地址 756833E0 即是加载到进程内存中的 CreateFileW() 函数(位于 Kernel32.dll 库中)的地址。

Question

这里为什么不直接使用call 756833E0指令调用函数,这样不是更方便,更好吗?

事实上,notepad.exe程序的制作者编译(生成)程序时,并不知道notepad.exe程序要运行在哪种Windows(9X、2K、XP、Vista、7)、哪种语言(ENG、JPN、KOR等)、哪种服务包(ServicePack)下。上面列举出的所有环境中,kernel32.dll的版本各不相同,CreateFileWO函数的位置(地址)也不相同。
为了确保在所有环境中都能正常调用CreateFileW函数,编译器准备了要保存CreateFileW函数实际地址的位置(01001104),并仅记下CALL DWORD PTR DS[1004404]形式的指令。执行文件时,PE装载器将CreateFileWO函数的地址写到01001104位置。

另一个原因:DLL重定位 DLL文件的ImageBase值一般为10000000。比如某个程序使用a.dll与b.dll时,P装载器先把a.dll装载到内存的10000000ImageBase)处,然后尝试把b.dll也装载到该处。但是由于该地址处已经装载了a.dll,所以PE装载器查找其他空白的内存空间(ex:3E000000),然后将b.dll装载进去。
这就是所谓的DLL重定位,它使我们无法对实际地址硬编码。
另一个原因在于,PE头中表示地址时不使用VA,而是RVA

实际操作中无法保证DLL一定会被加载到PE头内指定的ImageBase处。但是EXE文件(生成进程的主体)却能准确加载到自身的ImageBase中,因为它拥有自己的虚拟空间。

5.2. IMAGE_IMPORT_DESCRIPTOR

IMAGE_IMPORT_DESCRIPTOR结构体中记录着PE文件要导人哪些库文件。

Import:导入,向库提供服务(函数)。
Export:导出,从库向其他PE文件提供服务(函数)。

执行一个普通程序时往往需要导人多个库,导人多少库就存在多少个 IMAGE_IMPORT_DESCRIPTOR 结构体,这些结构体形成了数组,且结构体数组最后以NULL结构体结束。IMAGE_IMPORT_DESCRIPTOR 中的重要成员如表所示(拥有全部RVA值)。

项目 含义
OriginalFirstThunk INT的地址(RVA)
Name 库名称字符串的地址(RVA)
FirstThunk IAT的地址(RVA)
  • PE头中提到的“Table”即指数组。
  • INT(导入名称表) IAT(导入地址表)
  • INT与IAT是长整型(4个字节数据类型)数组,以NULL结束(未另外明确指出大小)。
  • INT中各元素的值为IMAGE_IMPORT_BY_NAME结构体指针(有时IAT也拥有相同的值)。
  • INT与IAT的大小应相同。

5.3. 使用notepad.exe练习

5.3.1. IMAGE_IMPORT_DESCRIPTOR结构体数组

首先明白 IMAGE_IMPORT_DESCRIPTOR 结构体数组存在于PE文件的PE体中,但是查找起位置的信息在PE头中。但查找其位置的信息在PE头中
IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress 的值即是 IMAGE_IMPORT_DESCRIPTOR 结构体数组的起始地址(RVA值)。
IMAGE_IMPORT_DESCRIPTOR 结构体数组也被称为 IMPORT Directory Table(只有了解上述全部称谓,与他人交流时才能没有障碍)

IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress 结构体的值如图,(第一个4字节为虚拟地址,第二个4字节为Size成员)
Pasted image 20250316164600

整理一下 IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress 结构体数组的信息

偏移 说明
00000158 00000000 RVA of EXPORT Directory
0000015C 00000000 size of EXPORT Directory
00000160 00007604 RVA of IMPORT Directory
00000164 000000C8 size of IMPORT Dirctory
00000168 0000BOO0 RVA of RESOURCE Directory
00000016C 00008304 size of RESOURCE Directory

Pasted image 20250316165054
这里我们可以计算一下 RVA=7604 的文件偏移 RAW
图中可以知道,.text 节区的VA是 0400h SIZE是 7800H
Pasted image 20250316165937

根据公式进行计算

Warning

这里可能有问题。书中给的答案是6A04h,但是我用010Editor看的第一个节区的VA是400h 有问题。
这里可以直接用010看对应的RAW
Pasted image 20250316172610

转到6a04处,这些都是 IMAGE_IMPORT_DESCRIPTOR 结构体数组,图中就是结构体数组的第一个元素
Pasted image 20250316172714

5.3.2. notepad.exe文件的第一个IMAGE_IMPORT_DESCRIPTOR结构体

IMAGE_IMPORT_DESCRIPTOR 结构体数组的第一个结构为例,看一下其结构体的成员
Pasted image 20250316183141

文件偏移 成员 RVA RAW
6A04 OriginalFirstThunk(INT) 00007990 00006D90
6A08 TimeDateStamp FFFFFFFF -
6A0C ForwarderChain FFFFFFFF -
6A10 Name 00007AAC 00006EAC
6A14 FirstThunk(IAT) 000012C4 000006C4
5.3.2.1. 库名称 Name
  • Name是一个字符串指针,它指向导人函数所属的库文件名称。
    Pasted image 20250316183706
5.3.2.2. OriginalFirstThunk(INT)
  • INT是一个包含导人函数信息(Ordinal,Name)的结构体指针数组。只有获得了这些信息,才能在加载到进程内存的库中准确求得相应函数的起始地址
    我们跟踪 OriginalFirstThunk 成员(RVA:7990->RAW:6D90)

    图中就是INT (导入名字表)
    Pasted image 20250316190318
    可以看到,INT由地址数组形式组成 数组尾部以 null 结束 ,每个地址值分别指向 IMAGE_IMPORT_BY_NAME 结构体。 跟踪数组第一个值 7A7A ,进入对应的地址。可以看到导入的API函数的名称字符串
    Pasted image 20250316190747
5.3.2.3. IMAGE_IMPORT_BY_NAME

RVA:7A7A 即为 RAW: 6E7A
文件偏移 6E7A 最初的2个字节值(000F)为Ordinal,是库中函数的固有编号。Ordinal的后面为函数名称字符串 PageSetupDlgW(同C语言一样,字符串末尾以 TerminatingNULL['\O'] 结束)。

如图,INT是 IMAGE_IMPORT_BY_NAME 结构体指针数组,数组的第一个元素指向函数的 Ordinal000F 函数的名称为 PageSetupDlgW
Pasted image 20250316191320

5.3.2.4. FirstThunk-IAT (Import Address Table)

IAT的RVA: 12C4 即为RAW:6C4
图中文件偏移 6C4~6EB 区域即为IAT数组区域,对应于 comdlg32.dIl 库。它与INT类似,由结构体指针数组组成,且以NULL结尾。IAT的第一个元素值被硬编码为76324906,该值无实际意义,notepad.exe文件加载到内存时准确的地址值会取代该值。
Pasted image 20250316191736

作者的话
  • 其实我的系统(WindowsXPSP3)中,地址76324906即是comdlg32.dll!PageSetupDlgW函数的准确地址值。但是该文件在Windows7中也能顺利运行。运行notepad.exe进程时,PE装载器会使用相应API的起始地址替换该值。
  • 微软在制作服务包过程中重建相关系统文件,此时会硬编入准确地址(普通的DLL实际地址不会被硬编码到IAT中,通常带有与INT相同的值)。
  • 另外,普通DLL文件的ImageBase为10000000,所以经常会发生DLL重定位。但是Windows系统DLL文件(kernel32/user32/gdi32等)拥有自身固有的ImageBase,不会出现DLL重定位。
5.3.2.5. OD查看notepad.exe的IAT

Pasted image 20250316192107 notepad.exeImageBase 的值为 01000000comdlg32.dll.PageSetupDlgW 函数的IAT地址为 010012C4,其值为 75B46730,它是API准确的起始地址值。

进入 75B46730 地址中。可以看到这里就是 comdlg32.dllPageSetupDlgW 函数的起始位置。
Pasted image 20250316192426

6. EAT

Windows操作系统中,“库”是为了方便其他程序调用而集中包含相关函数的文件(DLL/SYS)。Win32API是最具代表性的库,其中的 kernel32.dll 文件被称为最核心的库文件。
EAT是一种核心机制,它使不同的应用程序可以调用库文件中提供的函数。也就是说,只有通过EAT才能准确求得从相应库中导出函数的起始地址。与前面讲解的IAT一样,PE文件内的特定结构体(IMAGEEXPORT_DIRECTORY)保存着导出信息,且PE文件中仅有一个用来说明库EAT的IMAGE_EXPORT_DIRECTORY结构体。

用来说明IAT的 IMAGE_IMPORT_DESCRIPTOR 结构体以数组形式存在,且拥有多个成员。这样是因为PE文件可以同时导入多个库。

可以在PE文件的PE头中查找到 IMAGE_EXPORT_DIRECTORY 结构体的位置。IMAGE_OPTIONAL_HEADER32.DataDirectory[O].VirtualAddress 值即是 IMAGE_EXPORT_DIRECTORY 结构体数组的起始地址(也是RVA的值)。

图中就是 kernel32.dll 文件的 IMAGE_OPTIONAL_HEADER32.DataDirectory[0](第一个4字节为VirtualAddress,第二个4字节为Size成员
Pasted image 20250316193243

下表为 kernel32.dll 文件的 DataDirectory 数组 -Export

偏移 说明
00000160 00000000 loader flags
00000164 00000010 number of directories
00000168 0000262C RVA of EXPORT Directory
0000016C 00006D19 size of EXPORT Directory
00000170 00081898 RVA of IMPORT Directory
00000174 00000028 size of IMPORT Directory

Pasted image 20250316194111
RVA为262C 则 文件偏移就是 1A2C

6.1. IMAGE_EXPORT_DIRECTORY 结构体

IMAGE_EXPORT_DIRECTORY 是一个 结构体。它是 PE(Portable Executable)文件格式中用于描述导出表(Export Table)的数据结构。每个 PE 文件(如 DLL 或 EXE)的导出表通常只有一个 IMAGE_EXPORT_DIRECTORY 结构体实例。

下面是其中的一些重要成员

项目 含义
NumberOfFunctions 实际 Export 函数的个数
NumberOfNames Export 函数中具名的函数个数
AddressOfFunctions Export 函数地址数组(数组元素个数=NumberOfFunctions)
AddressOfNames 函数名称地址数组(数组元素个数=NumberOfNames)
AddressOfNameOrdinals Ordinal 地址数组(数组元素个数=NumberOfNames)

从库中获得函数地址的API为 GetProcAddress() 函数。该API引用EAT来获取指定API的地址。GetProcAddress() API拥有函数名称,下面讲解它如何获取函数地址。理解了这一过程,就等于征服了EAT。

GetProcAddress()操作原理

(1)利用AddressOfNames成员转到“函数名称数组”。
(2)“函数名称数组”中存储着字符串地址。通过比较(strcmp)字符串查找指定的函数名称(此时数组的索引称为name_index)。
(3)利用AddressOfNameOrdinals成员,转到orinal数组。
(4)在ordinal数组中通过name_index查找相应ordinal值。
(5)利用AddressOfFunctions成员转到“函数地址数组”(EAT)。
(6)在“函数地址数组”中将刚刚求得的ordinal用作数组索引,获得指定数的起始地址。

Pasted image 20250316220743
此图描述的是kernel32.dll文件的情形。kernel32.dll中所有导出函数均有相应名称,AddressOfNameOrdinals数组的值以index=ordinal的形式存在。但并不是所有的DLL文件都如此。导出函数中也有一些函数没有名称(仅通过ordinal导出),AddressOfNameOrdinals数组的值为index!=ordinal。所以只有按照上面的顺序才能获得准确的函数地址。

6.2. 使用 kernel32.dll 练习

下面进行从 kernel32.dll 文件的EAT中查找 AddAtomW 函数。上面我们计算出 kernel32.dllIMAGE_EXPORT_DIRECTORY 结构体的RAW为 1A2C。利用010查看,我们直接跳到对应的位置
Pasted image 20250316222342
上图就是 IMAGE_EXPORT_DIRECTORY 结构体对应的区域,如下是此结构体各个成员的属性

文件偏移 成员 RAW
1A2C Characteristics 00000000 -
1A30 TimeDateStamp 48025BE1 -
1A34 MajorVersion 0000 -
1A36 MinorVersion 0000 -
1A38 Name 00004B8E 3F8E
1A3C Base 00000001 -
1A40 NumberOfFuctions 000003B9 -
1A44 NumberOfNames 000003B9 -
1A48 AddressOfFunctions 00002654 1A54
1A4C AddressOfNames 00003538 2938
1A50 AddressOfNameOrdinals 0000441C 381C

6.2.1. 函数名称的数组

看表得到 AddressOfNames 的成员值为 RVA=3538 计算出 RAW=2938。使用010查看对应的地址
Pasted image 20250316225042
此处为4字节RVA组成的数组。数组的元素个数为 NumberOfNames = 3B9 个。逐一跟随所有的RVA值即可发现函数名称字符串。

6.2.2. 查找指定函数名称

要查找的函数名称字符串为“AddAtomW”,只要在上图中找到RVA数组第三个元素的值 (RVA:4BB3-RAW:3FB3) 即可。
Pasted image 20250316225136
进人相应地址就会看到“AddAtomW”字符串,如图所示。此时“AddAtomW”函数名即是上图数组的第三个元素,数组索引为2。
Pasted image 20250316225213

6.2.3. Ordinal 数组

下面查找“AddAtomW”函数的Ordinal值。AddressOfNameOrdinals 成员的值为 RVA:441C→RAW:381C。(ordinal数组中的各元素大小为2个字节)。
Pasted image 20250316225303

6.2.4. ordinal

6.2.2. 查找指定函数名称 中求得的 index值(2) 应用到 6.2.3. Ordinal 数组 中的Ordinal数组即可求得 Ordinal(2)

6.2.5. 函数地址数组 EAT

最后查找 AddAtomW 的实际函数地址。AddressOfFunctions 成员的值为 RVA:2654一RAW:1A54
这部分即为4字节函数地址RVA数组,它就是 Export 函数的地址。
Pasted image 20250316225525

6.2.6. AddAtomW函数地址

为了获取“AddAtomW”函数的地址,将求得的Ordinal用作数组的索引I,得到 RVA=000326D9

我们在 6.2.2. 查找指定函数名称 中获取到了 AddAtomW 函数在 Ordinal数组 中的索引是2,然后查看 Ordinal 数组Oridinal(2) 的值 0002。然后在上图中查看0002号索引位置的值获取其RVA
Pasted image 20250316230229
这里得到 RVA=000326D9

OD查看 kernel32.dllImageBase = 7c800000
Pasted image 20250316231049
AddAtomW 函数的实际地址 VA7c800000+326D9 = 7c8326d9
然后OD验证一下
Pasted image 20250316231305
这里就是要找的 AddAtomW 函数。以上就是在DLL文件中查找 Export 函数地址的方法。与使用 GetProcAddress() API获取指定函数地址的方法一致

7. 高级PE

IAT/EAT相关内容是运行时压缩器(Run-timePacker、反调试、DLL注人、API钩取等多种中高级逆向主题的基础知识。
希望各位多训练使用010Editor、铅笔、纸张逐一计算IAT/EAT的地址,再找到文件/内存中的实际地址。虽然要掌握这些内容并不容易,但是由于其在代码逆向分析中占有重要地位,所以只有掌握它们,才能学到高级逆向技术。

7.1. Patched PE

顾名思义,PE规范只是一个建议性质的书面标准,查看各结构体内部会发现,其实有许多成员并未被使用。事实上,只要文件符合PE规范就是PE文件,利用这一点可以制作出一些脱离常识的PE文件
PatchedPE指的就是这样的PE文件,这些PE文件仍然符合PE规范,但附带的PE头非常具有创意(准确地说,PE头纠缠放置到各处)。代码逆向分析中,PatchedPE涉及的内容宽泛而有深度,详细讲解须另立主题。
这里只介绍一点,但是足以颠覆前面对PE头的常规理解(但仍未违反PE规范)。

在下列网站制作一个名为“tiny pe”的最小PE文件。
http://blogs.securiteam.com/index.php/archives/675 (已经过期了)
它是正常的PE文件,大小只有411个字节。其IMAGE_NT_HEADERS结构体大小只有248个字节,从这一点来看,的确非常小。其他人也不断加人挑战,现在已经出现了304个字节的PE文件。有人访问上面网站后受到了刺激,制作了一个非常极端、非常荒唐的PE文件,在下列网址中可以看到。

http://www.phreedom.org/solar/code/tinype/ (过期了)
进人网站后可以下载一个97字节的PE文件,它可以在WindowsXP中正常运行。并且网站记录了PE头与tinype的制作过程,认真阅读这些内容会有很大帮助(需要具备一点汇编语言的知识)。希望各位全部下载并逐一分析,技术水平必有显著提高。

这里给一些参考项目

8. 小结

  • PE规范只是一种标准规范而已(有许多内容未使用)
  • 现在已知关于PE头的认识中有些是错误的(除tinype外,会出现更多操作PE头的创意技巧)
作者的QA

Q.前面的讲解中提到,执行文件加载到内存时会根据Imagebase确定地址,那么2个notepad程序同时运行时Imagebase都是10000000,它们会侵占彼此的空间区域,不是这样吗?
A.生成进程(加载到内存)时,OS会单独为它分配4GB大小的虚拟内存。虚拟内存与实际物理内存是不的。同时运行2个notepad时,各进程分别在自身独有的虚拟内存空间中,所以它们彼此不会重叠。这是由Os来保障的。因此,即使它们的Imagebase一样也完全没问题。
Q.不怎么理解“填充”(padding)这一概念。
A.相信会有很多人想了解PE文件的“填充”这一概念,就当它是为了对齐“基本单位”而添加的“饶头”。“基本单位”这个概念在计算机和日常生活中都常见。比如,保管大量的橘子时并不是单个保管,而是先把它们分别放入一个个箱子中,然后再放入仓库。这些箱子就是“基本单位”。并且,说橘子数量时也很少说几个橘子,而说几箱橘子,这样称呼会更方便。橘子箱数增加很多时,就要增加保管仓库的数量。此时不会再说几箱橘子,而是说“几仓库的橘子”。事实上,这样保管橘子便于检索,查找时只要说出“几号仓库的几号箱子的第几个橘子”即可。也就是说,保存大量数据时成“捆”保管,整理与检索都会变得更容易。这种“基本单位”的概念也被融入计算机设计,还被应用到内存、硬盘等。各位一定听说过硬盘是用“扇区”这个单位划分的吧?同样,“基本单位(大小)的概念也应用到了PE文件格式的节区。即使编写的代码(编译为机器语言)大小仅有100d字节,若节区的基本单位为1000d(400h)字节,那么代码节区最小也应该为1000d。其中100个字节区域为代码,其余900个字节区域填充着NULL(O),后者称为NULL填充区域。内存中也使用“基本单位”的概念(其单位的大小比普通文件要略大一些)。那么PE文件中的填充是谁创建的呢?在开发工具(VC++/VB等)中生成PE文件时由指定的编译选项确定。
Q.经常在数字旁边见到字母“h”,它是什么单位?
A.数字旁边的字母“h”是Hex的首字母,表示前面的数字为十六进制数。另外,十进制数用d(Decimal)八进制数用o(Octal)二进制数用b(Binary)标识。
Q.如何只用HexEditor识别出DOS存根、IMAGE_FILE_HEADER等部分呢?
A.根据PE规范,IMAGE_DOS_HEADER的大小为40个字节,DOS存根区域为40~PE签名区域。紧接在PE签名后的是IMAGE_FILE_HEADER,且该结构体的大小是已知的,所以也可以在HexEditor中表示出来。也就是说,解析PE规范中定义的结构体及其成员的含义,即可区分出各组成部分(多看几次就熟悉了)。
Q.IMAGE_FILE_HEADER的TimeDateStamp值为Ox47918EA2,在PEView中显示为2008/01/19,05:46:10UTC,如何才能这样解析出来呢?
A.使用C语言标准库中提供的ctimeO函数,即可把4个字节的数字转换为实际的日期字符串。
Q.PE映像是什么?
A.PE映像这一术语是微软创建PE结构时开始使用的。一般是指PE文件运行时加载到内存中的形态。PE头信息中有一个SizeOfImage项,该项指出了PE映像所占内存的大小。当然,这个大小与文件的大小不一样。PE文件格式妙处之一就在于,其文件形态与内存形态是不同的。
Q.不太明白EP这一概念
A.EP地址是程序中最早被执行的代码地址。CPU会最先到EP地址处,并从该处开始依次执行指令。
Q.用PEView打开记事本程序(notepad.exe)后,发现各节区的起始地址、大小等与示例中的不同,为什么会这样呢?
A.notepad.exe文件随OS版本的不同而不同(其他所有系统文件也如此)。换言之,不同版本的OS下,系统文件的版本也是不同的。微软可能修改了代码、更改了编译选项,重新编译后再发布。
Q.对图13-9及其下面的Quiz不是很理解。如何知道RVA5000包含在哪个节区呢?
A.图13-9是以节区头信息为基础绘制的。图(或节区头信息)中的.text节区是指VA01001000~01009000区域,转换为RVA形式后对应于RVA1000~9000区域(即减去Imagebase值的01000000)。由此可知,RVA5000包含在.text节区中。
Q.讲解节区头成员VirtualAddress时提到,它是内存中节区头的起始地址(RVA),VirtualAddress不就是VA吗?为什么要叫RVA呢?
A.“使用RVA值来表示节区头的成员VirtualAddress”,这样理解就可以了。节区头结构体(IMAGE_SECTION_HEADER)的VirtualAddress成员与虚拟内存地址(VA,VirtualAddress)用的术语相同才引起这一混乱。此处“VirtualAddress成员指的是虚拟内存中相应节区的起始地址,它以RVA的形式保存”,如此理解即可。
Q.查看某个文件时,发现其IMAGE_IMPORT_DESCRIPTOR结构体的OriginalFirstThunk成员为NULL,跟踪FirstFThunk成员,看到一个实际使用的API的名称字符串数组(INT)。跟踪FirstThunk应该看到的是IAT而不是INT,这是怎么回事呢?
A.PE装载器无法根据OriginalFirstThunk查找到API名称字符串数组(INT)时,就会尝试用FirstThunk查找。本来FirstThunk含义为IAT,但在实际内存中被实际的API函数地址覆盖掉了(此时INT与IAT虽然是相同区域,但仍然能够正常工作)。
Q.使用Windows7的notepad.exe测试,用PEView打开后,IAT起始地址为01001000,而用OllyDbg查看时IAT出现在00831000地址处。请问这是怎么回事呢?
A.这是由WindowsVista、7中使用的ASLR技术造成的。请参考第41章。
Q.EAT讲解中提到的Ordinal究竟是什么?不太理解。
A.把Ordinal想成导出函数的固有编号就可以了。有时候某些函数对外不会公开函数名,仅公开函数的固有编号(Ordinal)。导入并使用这类函数时,要先用Ordinal查找到相应函数的地址后再调用。比如下面示例(1)通过函数名称来获取函数地址,示例(2)则使用函数的Ordinal来取得函数地址。
示例(1) pFunc=GetProcAddress(“TestFunc”);
示例(2) pFunc=GetProcAddress(5);