携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第6天
前言
PE文件头是PE(PE文件头+PE文件体)的一部分,PE常常用来记录和标识PE信息,包括PE入口地址(OEP),节表(Section Table),
一般,PE文件头由以下几部分组成:
- DOS头
- PE头
- 节表
DOS头
:主要用来标识PE文件和PE文件运行的环境。
PE头
:主要记录PE的信息,包括拓展PE入口地址(OEP)
节表
:主要记录PE文件各种起始地址,比如.text代码段地址,.data/.rdata起始地址等
3.1 PE的数据组织方式
PE的数据组织是一种数据管理技术,但笔者更愿意把它看成是一种艺术。希望读者在学习完PE格式后,不仅能把握PE格式本身,还能够从中学会使用数据结构管理数据的方式,当大家理解其他格式的文件(如音视频格式、数据库文件格式、图片格式等)时会有所启发。
1993年,第一个 Windows NT操作系统诞生,到现在有将近18年的时间。目前,使用PE作为可执行文件格式的Windows操作系统已经更换了很多版本。对于操作系统来说,其结构的变化、新特性的添加、文件存储格式的转换,以及内核的重新定位等,都发生了翻天覆地的变化,而这些变化对PE格式的影响却不大。由于PE有较好的数据组织方式和数据管理算法,面对如此多的变化却依然能保持其一贯的优雅和优越。
简言之,PE的数据组织是大量的字节码与数据结构的有机融合。字节码是一些毫无意义的数字,而数据结构却为这些数字赋予了人类可以理解的精准含义。
举例:书库和书
书库汇编定义数据结构
BookStore STRUCT
Name db 8 dup (0);书库的名字
Address dd ?;书库所在地址
Count dd ?;书库中的藏书量
BookStore ENDS
书库实例化:
name1 db '书库一 ',0,
lib1 BookStore <?>
mov ebx,lib1
assume ebx:ptr BookStore
invoke MemCopy,addr name1,[ebx].Name;为书库命名
mov eax ,123456h;确定书库的位置
mov [ebx].Address, eax
mov eax , 2;指定书库的藏量
mov [ebx].Count , eax
assume ebx : nothing
如上所示,通过定义变量lib1,实例化一个 BookStore结构,然后通过赋值语句为该结构中的每个字段指定不同的值。
书库字节码
书库定义好后,管理员通过书库名找到书库一,再找到书库一的地址,和该书库的书的数量。
由于要管理书籍,有引入了书的数据结构。于是抽象出书库和书的数据结构。
书的数据结构
新的书库和书的结构:
;书库的定义:
Bookstore STRUCT
Name db 8 dup (0);书库的名字
Address dd ?;书库所在地址
Count dd ?;书库中的藏书量
BookStore ENDS
;书的定义:
Book STRUCT
Name db 50 dup (0);书的名字
Contents dd ?;书字节码所在地址
Book ENDS
实例化代码:
name1 db '书库一', 0
name2 db '《windows PE权威指南》',0
lib1 Bookstore <?>
book1 Book <?>
book2 Book <?>
bookArray dd offset book1:指向第一本书
dd offset book2;指向第二本书
dd 0;结束
assume ebx : ptr Book
invoke MemCopy , addr name1, [ebx].Name;为书命名
mov eax,234567h;确定书字节码的位置
mov [ebx].Address,eax
assume ebx : nothing
. . . .. .;此处省略了对book2的定义
mov ebx , lib1
assume ebx:ptr BookStore
invoke MemCopy, addr name1,[ebx].Name;为书库命名
mov eax ,offset bookArray;确定书库的位置
mov [ebx].Address , eax
mov eax , 2;指定书库的藏书量
mov [ebx].Count , eax
assume ebx ; nothing
当我们增加了书的结构定义后,书库一中的Address就有了一个明确的含义。它指向了地址数组bookArray的起始位置,该数组中的每个地址都是一个指向Book的双字地址。从这个意义上讲,bookArray是书库一的字节码。
书的字节码
可以简单地套用Windows操作系统管理文件的组织方式: 目录类似于这里的BookStore, 文件类似于这里的Book, 而文件存储在 硬盘上的字节码为Book的字节码。 也可以用其他任意一种文件格式来分析,大部分文件格式的数据组织方式基本上是一样的。笔者将这种信息组织方式称为“头部+身体”。
3.2 与PE有关的基本概念
3.2.1 地址
PE中涉及的地址有四类,它们分别是:
- 虚拟内存地址(VA)
- 相对虚拟内存地址(RVA)
- 文件偏移地址(FOA)
- 特殊地址
要想了解这些概念,需要先简单地了解一下32位环境下Windows对内存的管理,以及分页机制的原理。
扩展阅读:32位环境下的Windows内存管理 32位CPU的寻址能力为4GB(即2”个字节),但有些用户的物理内存达不到这个值。于是操作系统和CPU的内存管理单元共同作用,为用户提供了虚拟内存的管理机制。即分页机制。该机制可以让用户感觉自己好像在使用4GB的内存。 分页机制的基本原理是: 操作系统假设一个进程独立拥有4GB内存,按照某个固定的大小(如 4KB)将这4GB空间分成N(1M)个页。在某一时刻,所有这些页只有一部分和物理内存是对应的(所以这种机制允许物理内存比4GB小)。没有物理内存对应的页面被标记为脏(dirty)的页面,一般存储在一个名为“交换文件”的磁盘文件中。 在Windows XP系统中,交换文件为pagefile.sys,它位于系统盘的根目录,是一个系统隐藏文件。当系统需要读取未在内存中的数据时,这部分数据会将内存中不经常读写的页交换出内存,而把要读取的、位于交换文件中的页换进内存。 通过这种存取机制可以让一个进程拥有比实际内存大得多的内存。利用这种机制管理的内存称为虚拟内存。
1.虚拟内存地址
用户的PE文件被操作系统加载进内存后,PE对应的进程支配了自己独立的4GB虚拟空间。在这个空间中定位的地址称为虚拟内存地址(Virtual Address,VA),所以虚拟内存地址的范围是00000000h ~0FFF FFFFh。 在PE中,进程本身的VA被解释为:进程的基地址+相对虚拟内存地址。
2.相对虚拟内存地址
一个进程被操作系统加载到虚拟内存空间后,其相关的动态链接库也会被加载。这些同时加载到进程地址空间的文件称为模块。每一个模块在加载时都会有一个基地址,也就是预先告诉操作系统:它会占用4GB空间的哪个部分(即从哪里开始存储该模块)。不同模块的基地址一般是不同的,如果两个模块的基地址相同,就由操作系统来决定这两个模块在虚拟空间中的具体位置。
相对虚拟内存地址(Reverse Virtual Address ,RVA)是相对于基地址的偏移,即RVA是虚拟内存中用来定位某个特定位置的地址,该地址的值是这个特定位置距离某个模块基地址的偏移量,所以说RVA是针对某个模块而存在的。
如图3-4所示,假设模块2的基地址为0x01000000,而模块2中的某个位置距离模块⒉的基地址偏移为400h,那么值0x00000400就是模块2中某个位置的RVA,而值0x01000400是该位置的VA。记住,RVA是相对于模块而言的,VA是相对于整个地址空间而言的。
注意: RVA与具体模块相关,它有一个范围,该范围从模块的开始到模块结束,脱离开这个范围的RVA是无效的,称为越界。越界的RVA地址没有任何意义。
3.文件偏移地址
文件偏移地址(File Offset Address,FOA)和内存无关,它是指某个位置距离文件头的偏移。
4.特殊地址
在PE结构中还有一种特殊地址,其计算方法并不是从文件头算起,也不是从内存的某个模块的基地址算起,而是从某个特定的位置算起。这种地址在PE结构中很少见,如在资源表里就出现过这样的地址。
3.2.2 指针
- PE数据结构中的指针的定义:如果数据结构中某个字段存储的值为一个地址,那么这个字段就是一个指针。 例如,在3.1节数据组织方式的实例中,BookStore.Address是一个指针,Book.Address也是一个指针。
- 有时候,你还会遇到一个指针指向了另一个指针的情况。比如,在第10章中,加载配置信息中的数据结构加载配置目录(IMAGE_LOAD_CONFIG_DIRECOTRY)的字段SEHandlerTable的值是一个VA,但该指针所指的位置是一个RVA,该RVA指向了安全的SEH处理函数的Handler。所以,在数据结构中,可能会碰到指针和地址叠加使用的情况,大家需要引起重视。
3.2.3 数据目录
Windows下的可执行文件是PE中的一种,这种文件中除了包含代码及数据段的相关数据以外,还包含许多与文件执行有关的其他数据,比如引用外部函数的信息、PE程序的图标、内部导出函数等,这些数据可能会随着操作系统新特性的出现而增加。
数据目录:PE中有一个数据结构称为数据目录,其中记录了所有可能的数据类型。 这些类型中,目前已定义的有15种,包括:
- 导出表、
- 导入表、
- 资源表、
- 异常表、
- 属性证书表、
- 重定位表、
- 调试数据、
- Architecture、
- Global Ptr、
- 线程局部存储(TLS)、
- 加载配置表、
- 绑定导入表、
- IAT、
- 延迟导入表
- CLR运行时头部。
3.2.4 节(Section)(也有称块、段)
无论是结构化程序设计,还是面向对象程序设计,都提倡程序与数据的独立性,因此,程序中的代码和数据通常是分开存放的。为了保证程序执行的安全,保障内核的稳定,Windows操作系统通常对不同用途的数据设置不同的访问权限。比如,代码段(.text)中的字节码在程序运行的时候,一般不允许用户进行修改,数据段(.data)则允许在程序运行过程中读和写,常量(.)只能读等。Windows操作系统在加载可执行程序时,会为这些具有不同属性的数据分别分配标记有不同属性的页面(当然,相同属性的数据可能会被放到同一个页面中),以确保程序运行时的安全。正是基于这个原因,PE中才出现了所谓的节的概念。
节(Section)就是存放不同类型数据(比如代码、数据、常量、资源等)的地方,不同的节具有不同的访问权限。节是PE文件中存放代码或数据的基本单元。例如,一个目标文件中的所有代码可以组合成单个节,或者每个函数独占一个 (上命.个书中的所有原始数据必须加文件开销,但是链接器在链接代码时会有更大的选择余地。一个节中的所有原始数据必须 被加载到连续的内存空间中。
从操作系统加载角度来看,节是相同属性数据的组合。与数据目录不同的是,尽管有些数据类型不同,分别属于不同的数据目录,但由于其访问属性相同,便被归类到同一个节中这个节最终可能会占用一个或多个页面;但无论有多少个,所有相关页面均会被赋予相同的页属性。这些属性包括只读、只写、可读、可写等。 汇编语言中以“.”开头的一些伪指令其实就是在声明不同的数据类型。比如“.data”声明的是初始化的数据,“.data?”声明的是未初始化的数据,“.code”声明的是可执行的代码等。Windows操作系统在装载PE文件时会对这些数据执行抛弃、合并、新增、复制等操作这些不同的操作交叉组合导致了内存中的节和文件中的节会出现很大的不同。例如“.data?的数据在磁盘中不存在,但在内存中存在,而“.reloc”重定位表数据却恰恰相反。
3.2.5 对齐
对齐这个概念并非只在PE结构中出现,许多文件格式都会有对齐的要求。有的对齐是为了美观,有的对齐则是为了效率。PE中规定了三类对齐:
- 数据在内存中的对齐( 内存对齐)
- 数据在文件中的对齐(文件对齐)
- 资源文件中资源数据的对齐。(资源对齐)
- 内存对齐 由于Windows 操作系统对内存属性的设置以页为单位,所以通常情况下,节在内存中的对齐单位必须至少是一个页的大小。对32位的Windows XP系统来说,这个值是4KB( 1000h),而对于64位操作系统来说,这个值就是8KB (2000h)。
- 文件对齐 相对来说,节在磁盘文件中的对齐尺寸没有那么严格。为了提高磁盘利用率,通常情况下,定义的节在文件中的对齐单位要远小于内存对齐的单位﹔通常会以一个物理扇区的大小作为对齐粒度的值,即512字节,十六进制表示为200h。这就是我们在第1章中看到数据段、代码段等起始地址都是200h的倍数的原因了。 出于节约资源的考虑,操作系统允许节在内存和文件中的对齐尺寸不一致。这就直接造成了PE 在文件中和在内存中的大小也会不一致。通常情况下,PE在内存中的尺寸要比在文件中的尺寸大。用户可以自己定义这些对齐的值。
注意:如果内存对齐被定义为小于操作系统页的大小,则文件对齐和内存对齐的值必须一致!
- 资源对齐 资源文件中,资源字节码部分一般要求以双字(4个字节)方式对齐,在资源表部分(详见本书第7章)我们会详细讲解。
3.2.6 Unicode字符串
Unicode是继ASCII字符编码后的另一种新型字符编码。严格意义上讲,ASCII 码的每个字符使用7位表示,Unicode(UTF-16)则使用全16位表示一个字符。Unicode字符串中的每个字符均为双字节,所以又称为宽字符串。
由于Unicode兼容ASCII字符,所以被大多数程序所支持,如Windows内核。Unicode的前128个字符码(十六进制,Ox0000~Ox007F)同ASCII码具有同样的字节值。比如,字母“a”的Unicode编码是0x0061,而“a”的ASCII编码是0x61。虽然占用的字节数不一样,但是两者的值是一样的。接下来的128个Unicode字符(代码为0x0080~Ox0OFF)是ISO 8859-1对ASCII码的扩展。中国、日本和韩国的象形文字(总称为CJK)占用了0x3000~Ox9FFF的代码。如“汉”字的Unicode编码是6C49h(其GB码为0BABAh)。
本书所有的程序都使用一个字节来表示字符串中的字符,称为ANSI字符串。PE格式中涉及字符串的部分均采用ANSI字符串。然而,在资源表中,对菜单名、对话框标题等的描述则全部使用Unicode字符串。所以,在读取这些资源的字符串时,首先需要使用一些API函数实现从宽字符集到窄字符集的转换。
注意:Unicode字符串不像ANSI字符串那样,保证用字符“\0”结束;如果开发者在程序设计时以字符“\0”作为Unicode字符串结尾的判断条件,就可能发生错误。
在汇编语言中,Unicode字符串被定义为一个结构体,它的定义如下:
由于我们无法保证Unicode字符串结尾一定是“\0”,所以在结构体中,字段Length定义了字符串的长度。一个安全的字符串还必须限定字符的总长度,这由MaximumLength 来实现。
3.3 PE文件结构
3.3.1 16位系统下的PE结构
在16位系统下,PE结构可以大致划分为两部分:DOS头和冗余数据
如上图所示,在16位系统下,PE的四部分内容被重新组合成两部分——可以在16位系统下运行的DOS头和冗余数据。把Windows下的PE文件存储到DOS系统并运行,它就是DOS系统下的一个EXE文件。
DOS头分为两部分,DOS MZ头和 DOS Stub(即指令字节码)。
大部分情况下,这些指令实现的功能都非常简单,根本不会涉及重定位信息。再往后的PE头和PE数据区可以看做是16位系统下的可执行文件的冗余数据。
1.DOS MZ头
在Windows的PE格式中,DOS MZ头的定义如下:
如上所示,加粗部分在16位系统下是没有定义的。由于其开始的标志字为“MZ”(Mark Zbikowski,他是DOS操作系统的开发者之一),所以称它为“DOS MZ头”。
下面来看第Ⅰ章提到的HelloWorld.exe的字节数据,HelloWorld中的DOS头如下所示:
注意: 这部分内容在源程序HelloWorld.asm中是找不到相应的定义语句的。因为DOS MZ头部分的字节码(包括DOS Stub程序字节码)的添加是由链接程序link.exe自动实现的。
2.DOS Stub
DOS Stub 特点:
由于DOS Stub的大小不固定,因此DOS头的大小也是不固定的。DOS Stub部分是该程序在DOS系统下运行的指令字节码。
主要作用: 用来兼容DOS系统。当我们的程序运行在DOS系统的时候,就会运行DOS存根中的代码,代码内容就是输出一段字符串告诉用户,这个程序不能在16位系统运行。
3.3.2 32位系统下的PE结构
在16位系统中,PE头和PE数据部分被当成是冗余数据; 在32位系统中,刚好相反,即 DOS头成为冗余数据。
所谓冗余,是针对DOS头不参与32位系统运行过程而言的。尽管该部分不参与运行,但也不能把这些数据从PE结构中除去。
因为在DOS MZ头中有一个字段非常重要,即IMAGE_DOS_HEADER.e_lfanew,没有它操作系统就定位不到标准的PE头部,可执行程序也就会被操作系统认为是非法的PE映像。
所以经常也用它定位PE头起始地址。
1.定位标准PE头
由于DOS Stub的长度不固定,导致了DOS头也不是一个固定大小的数据结构。那么,在Windows PE中,既然把DOS头放在了PE的起始位置,
如何去定位后面的标准PE头所在的位置呢?
字段e_lfanew即起这个作用。该字段的值是一个相对偏移量,绝对定位时需要加上DOS MZ头的基地址。也就是说,通过以下公式可以得出PE头的绝对位置:
对比本书1.6节中代码清单1-2列出的HelloWorld.exe的字节码,可以看到该位置是以ASCII字符“PE”开头的,所以,
通过字段 IMAGE_DOS_HEADER.e_lfanew的值可以定位到PE头的起始位置。
2.PE文件结构
如上图所示,32位系统下的PE文件结构被划分为5个部分,包括:
- DOS MZ头
- DOS Stub
- PE头
- 节表
- 节内容
节表(Section Table)和节内容()两部分其实就是图3-5中所示的PE数据区。DOS MZ头的大小是64个字节,PE头的大小是456个字节(由于数据目录表项不一定是16个,所以准确地说,PE头也是一个不能确定大小的结构,该结构的实际大小由字段IMAGE_FILE_HEADER.SizeOfOptionalHeader来确定)。
节表的大小之所以不固定,是因为每个PE中节的数量是不固定的。每个节的描述信息则是个固定值,共40个字节,节表是由不确定数量的节描述信息组成的,其大小等于节的数量×40,节的数量由字段IMAGE_FILE_HEADER.NumberOfSections来定义。DOS Stub和节内容都是大小不确定的。
节表是PE中所有节的目录,每个目录都是一个“BookStore”,其字节码就是节内容。它按照目录里的指针指向的地址,分别将节的字节码在文件空间中排列起来,从而组成了一个完整的PE文件。PE文件头部等于DOS头+PE头。
3.3.3 程序员眼中的PE结构
如图所示,一个标准的PE文件一般由四大部分组成:
- DOS头
- PE头 ( IMAGE_NT_HEADERS)
- 节表(多个IMAGE_SECTION_HEADER结构)
- 节内容(具体数据段)
其中,PE头的数据结构最为复杂。简单来说,PE头包含:
- 4个字节的标识符号( Signature)
- 20个字节的基本头信息(IMAGE_FILE_HEADER)
- 216个字节的扩展头信息(IMAGE_OPTIONAL_HEADER32)
说明如果按照“头部+身体”的信息组织方式来看:
- PE文件头部 = DOS头+PE头+节表
- PE文件身体=节内容
节内容中会出现各种不同的数据结构,如导入表、导出表、资源表、重定位表等,关于这些数据的组织方式会在后面的章节中陆续接触到。
总结对比
1.PE不同位数特点
PE类型 | 相同 | 不同 |
---|---|---|
16位PE | ||
32位PE |
3.4 PE文件头部解析
3.4.1 DOS MZ头——IMAGE_DOS_HEADER
IMAGE_DOS_HEADER具体定义:
3.4.2 DOS stub
DOS MZ头的下面是DOS Stub。
整个DOS Stub是一个字节块,其内容随着链接时使用的链接器不同而不同,PE中并没有与之对应的相关结构。
3.4.3 PE头标识——Signature
紧跟在DOS Stub后面的是PE头标识Signature。
与大部分文件格式的头部结构一样,PE头部信息中有一个四字节的标识,
该标识位于指针IMAGE DOS_HEADER.e_Ifanew指向的位置。
其内容固定,对应于ASCⅡ码的字符串“PE\O\0”
。
3.4.4 标准PE头——IMAGE_FILE_HEADER
标准PE头IMAGE FILE HEADER紧跟在PE头标识后,即位于IMAGE DOS HEADER的e lfanew值+4的位置。
由此位置开始的20个字节为数据结构标准PE头IMAGE FILE HEADER的内容。
该结构在微软的官方文档中被称为标准通用对象文件格式(Common Object File Format,,COFF)头
。
作用
:
它记录了PE文件的全局属性
如该PE文件运行的平台、PE文件类型(是EXE文件还是DLL文件)、文件中存在的节的总数等
其详细定义如下:
注意:
该结构常用于判断PE文件是EXE类型还是DLL类型
,不但可以通过该结构得到PE文件中节的总量
,还可以当成对节区信息进行遍历操作时的循环次数
。
3.4.5 扩展PE头——IMAGE_OPTIONAL_HEADER32
定义:
作用:
- 文件执行时的
入口地址OEP
、 - 文件被操作系统装入内存后的默认基地址
Image_base
- 节在磁盘和内存中的对齐单位等信息均可以在此结构中找到。
注意: 对该结构中的某些数值的随意改动可能会造成PE文件的加载或运行失败。
3.4.6 PE头——IMAGE_NT_HEADERS
广义的PE头(即由PE头标识、标准PE头、拓展PE头三部分的组成):
其定义如下:
与DOS头一样,PE头开始也是一个标志,用一个双字的“PEO0”来命名,这也是PE头的由来。
Windows头文件winnt.h中IMAGE_NT_HEADERS
结构体
3.4.7 数据目录项——IMAGE_DATA_DIRECTORY
IMAGE OPTIONAL HEADER32(扩展PE头)结构的最后一个字段为DataDirectory。
该字段定义了PE文件中出现的所有不同类型的数据的目录信息
。
如前所述,应用程序中的数据被按照用途分成很多种类:
- 导出表、
- 导入表、
- 资源、
- 重定位表等。
在内存中,这些数据被操作系统以页为单位组织起来,并赋以不同的访问属性; 在文件中,这些数据也同样被组织起来,按照不同类别分别存放在文件的指定位置。
作用
:该结构就是用来描述这些不同类别的数据在文件(和内存)中的位置及大小的
,因为这个字段比较重要
从Windows NT3.1操作系统开始到现在,该数据目录中定义的数据类型一直是16
种。
PE中使用了一种称作“数据目录项——IMAGE DATA DIRECTORY”的数据结构来定义每种数据。
该结构只有两个字段,结构具体定义如下:
Windows c++中:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
两个字段依次为VirtualAddress和isize。
如图所示,总的数据目录一共由16个相同的IMAGE DATA DIRECTORY结构连续排列在一起组成。
在Windows头文件winnt.h中 属于拓展PE头结构体
IMAGE_OPTIONAL_HEADER
的成员DataDirectory
:
这16个元组的数组每一项均代表PE中的某一个类型的数据:
如果想在PE文件中寻找特定类型的数据,就需要从该结构开始。
比如: 要想查看PE中都调用了哪些动态链接库的函数? 则需要从数据目录表的第2个元素(数组编号为1)的IMAGE DATA DIRECTORY结构 获取导入表在文件中的起始位置和大小,然后再根据VirtualAddress地址指向的位置找到导入表相关的字节码。
这种信息组织方式正是本章最开始介绍的“头部+身体”的数据组织方式。
3.4.8 节表项——IMAGE_SECTION_HEADER
PE头IMAGE NT HEADERS后紧跟着节表。
它由许多个节表项(IMAGE SECTION HEADER
)组成,每个节表项记录了PE中与某个特定的节有关的信息,如节的属性、节的大小、在文件和内存中的起始位置等。
节表中节的数量由字段IMAGE FILE HEADER, NumberOfSections来定义。节表项的数据结构详细定义如下:
Windows 头文件winnt.h中
IMAGE_SECTION_HEADER
结构体:
节表后面就是节的内容。截至节表,PE文件头部涉及的所有数据结构已经全部介绍完毕。