前言
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位系统运行。