PE文件格式的分析与构造

实验原理:

PE是一种为了便于组织数据而产生的一种文件格式。打个比方,每个程序、链接库都好比是一个图书馆,内部有大量的书籍(数据),另外哪些书是什么属性、放在哪都有明确的记录。PE文件就是图书馆,里面包含了大量的数据和数据摆放的规定。

1、下载Winhex并运行。

 2、构建空白文件。

打开之后新建一个文件,输入文件长度为1之后确定,就会看到如下界面,我们就要在这里面,把所需数据按规则填到合适的位置,最后保存成exe就可以直接运行了。

 

 3、构建DOS 头

首先计算一下DOS头的大小。DOS MZ部分是固定大小的结构体。它由30个WORD(30*2字节)和1个DWORD(4字节)组成,总共64字节,也就是40H个字节,每行16个字节的话,正好是四行。在填充文件之前,我们要让文件尺寸增加为64个字节。在已有的那个字节处点击右键,选edit->paste zero bytes,输入63(DOS MZ大小为64字节,之前已经输入过一个字节,所以增加63个字节)点确定,63个00(每4位表示一个16进制值,两个值正好就是8位,即一个字节)

 构建DOS MZ头信息

然后我们来设计一下DOS MZ中各成员的值,其中对我们有用的只有两个值:e_magic和e_lfanew。

e_magic相当于一个标志,所有PE文件都必须以“MZ”开始(MZ是Mark Zbikowski的缩写,他是DOS系统的一位主要构建者)。“MZ”的十六进制值是 4D 5A,所以我们在第一个成员对应的位置,也就是文件开始处填入4D5A。

DOS头中的另一个部分DOS STUB。

DOS STUB是在DOS模式下要执行的那段程序代码。它紧跟在DOS MZ之后,长度不固定的。所以如果没有e_lfanew成员,那我们就无法确定DOS头后面的内容从哪里开始。对于我们在windows下写的程序, DOS STUB是没有作用的,因此也可以直接全部填0。我们姑且将它长度设置为70H个字节,也就是从40H-AFH这一段,即DOS STUB大小为6FH。

确定了DOS STUB的大小,我们就可以填入e_lfanew的值了,因为DOS头后面的内容是从B0H开始的,所以e_lfanew的值为00 00 00 B0,填入文件时应该为B0 00 00 00(小端模式,请自行了解大小端机的概念,字符串按正序存入)。

DOS MZ中其余成员全部填00,完成之后如下图所示。

 4、构造PE头。

在DOS头下面,紧接着是PE头(FOA = B0H处开始)。它相当于一个PE程序的总体描述,里面记录了很多PE数据和属性信息。PE头就是一个IMAGE_NT_HEADERS结构,一般来说其大小是固定的(有些程序该部分大小不固定,这里我们不考虑)。在IMAGE_NT_HEADERS结构中,包含有三个部分:Signature、IMAGE_FILE_HEADER(文件头\标准头)、IMAGE_OPTIONL_HEADER(可选头\扩展头)。

Signature和IMAGE_FILE_HEADER信息填充

Signature类似于DOS MZ部分中的e_magic,也是一个标志,大小为4字节,值必须为PE\0\0”,所以我们在B0H – B3H这四个字节中填入50 45 00 00

随后是IMAGE_FILE_HEADER,大小为20字节,它记录了PE文件的一些全局属性。

 Machine                  WORD

表示PE文件运行所要求的CPU。对Intel平台,该值是0x014C,所以winhex中应该填入“4C 01

NumberOfSections          WORD

表示PE文件中段(也可称作节)的总数,在我们的程序中有3个段,.text(代码段)、.rdata(只读数据段)、.data(变量数据段),各个段的含义在后面会解释。所以此处值是0x0003,因此填写“03 00

SizeOfOptionalHeader    WORD

表示后面的IMAGE_OPTIONL_HEADER部分所占空间大小,我们已经知道其大小是224 byte,转换成十六进制即0x00E0。在winhex中应该填写E0 00”。

对于我们的程序,该成员二进制值为“0000 0001 0000 1111”,将其转换为十六进制形式为0x010F,因此在winhex中填“0F01

所有成员数值都确定了,将Signature和IMAGE_FILE_HEADER填好之后结果如下图:

 IMAGE_OPTIONAL_HEADER信息填充

然后是整个头部中最重要的IMAGE_OPTIONAL_HEADER,它有相当多的成员。

Magic                        WORD

表示文件的格式,值为0x010B表示.EXE文件,为0x0107表示ROM映像。对于可执行程序,应该填入“0B 01
SizeOfCode               DWORD

 表示可执行代码文件对齐(后面会介绍文件对齐和段对齐)后的长度,此值为AA AA AA AA。待代码段填完后才可确定。

AddressOfEntryPoint      DWORD

表示代码入口的RVA地址。所谓AddressOfEntryPoint实际上是Loader准备运行的PE文件中的第一条指令的RVA值。若要改变整个程序执行的流程,可以将该值指定为新的RVA,这样新RVA处的指令首先被执行。    知道其含义后,我们如何得知我们程序的入口地址呢?前面已经提到,一般在PE文件中会有个.text段,这个段通常是用来填写代码的。我们也将实现这么一个段,将我们这个程序中的所有代码指令写到此段中。所以.text段的起始地址就将是我们程序的入口地址。那么如何得到.text段的起始地址呢?在PE结构中,所有段都对应有一个段头部(就是紧跟PE头后面的段表(或称作节表)中的一项,后面会做介绍),而在段头部中将指定该段的起始地址。所以这个值要等待我们完成.text段头部后才能够得到,此处首先用AA AA AA AA填写,待完成.text段头部后再计算填写它。
BaseOfCode                     DWORD

表示可执行代码起始位置。就是.text段的首地址,为AA AA AA AA”。
ImageBase                 DWORD

表示文件映射到内存时期望被加载到的基地址。通常为0x00400000。Loader将把文件装到该虚拟地址处(建议先简单了解一下进程的虚地址空间)。若该地址区域已被其他模块占用(有时在程序运行之前,会装载一些动态链接库,有些动态链接库也会占用该地址),那PE装载器会选用其他空闲地址,但要涉及到重定位。所以我们这里的值填充为“00 00 40 00
SectionAlignment     DWORD

表示段加载后在内存中的对齐方式,即内存中节段对齐的粒度。例如,如果该值是1000h,那么每段的起始地址必须是1000h的倍数。若第一段从401000h开始,大小是10个字节,下一个段并不是从401011开始,因为要经过段对齐,那么下一段必定从402000h开始,即使401000h和402000h之间还有很多空间没被使用。因为Windows管理内存采用分页管理的方式(建议顺便学习一下分页机制,不懂也不影响后面),而每页的大小为4k,也就是1000h。一般情况下程序的内存节段对齐粒度都为0x00001000,所以我们这个值也填充为00 10 00 00
FileAlignment           DWORD

表示段在文件中的对齐方式。文件中段对齐的粒度。例如,如果该值是200h,,那么每段的起始地址必须是200h的倍数。若第一段从文件偏移量200h开始且大小是10个字节,则下一段必定位于偏移量400h处。一般情况下程序的文件节对齐粒度都为200h,所以我们将此值设为00 02 00 00

SizeOfImage                    DWORD

表示程序载入内存后占用内存字节的大小,即等于所有内容长度之和(所有头和段经过段对齐处理后的大小)。DOS头+PE头+段表处在一个段中,总长小于1000h,经过段对齐后长度为1000H。另外我们还有有3个段,因为我们的程序比较简单,可以确定每个段的长度小于1000h,被映射后同样要占1000h,所以总共占用内存的大小为1000h + 3 * 1000h = 4000h,因此此值为00 40 00 00
 SizeOfHeaders          DWORD

表示在文件中所有文件头的长度之和。DOS头+PE头+段表在文件中的大小为:64 + 112 + 4 + 20 + 224 = 424,3个段表头的总大小 3 * 40 =120(段表在后面介绍,现在只要知道段表中每个段头部大小固定为40字节,每个段有一个段头部,所有段头部组成段表)。424 + 120 = 544 byte 转化成十六进制为220h,又因为我们文件中的对齐粒度是200h,那么220h经过文件对齐后实际上要占用400h的空间,所以此值为00 04 00 00

Subsystem                WORD

表示NT子系统,可能是以下的值:

IMAGE_SUBSYSTEM_NATIVE (值为1)      不需要子系统。用在驱动程序中。

 IMAGE_SUBSYSTEM_WINDOWS_GUI(值为2)       WIN32 graphical程序

 IMAGE_SUBSYSTEM_WINDOWS_CUI(值为3)      WIN32 console程序

 IMAGE_SUBSYSTEM_OS2_CUI(值为5)     OS/2 console程序

 IMAGE_SUBSYSTEM_POSIX_CUI(值为7)       POSIX console程序。

因为我们的程序为widows图形程序,所以这里设为2,填充“02 00

NumberOfRvaAndSizes                DWORD

        该成员是下面的DataDirectory项目数量。通常为16个元素,也就是0x10,所以此值填为:10 00 00 00。 DataDirectory  是可选头中非常重要的一个成员,具体请见下面的解释。

DataDirectory           128个字节 = 16*(4*2)

      该成员定义了PE文件中出现的所有不同类型数据的目录信息。它是一个结构数组,每个数组成员都是一种数据的目录,每个数据目录都被定义为一个IMAGE_DATA_DIRECTORY结构。IMAGE_DATA_DIRECTORY定义如下: 

typedef struct _IMAGE_DATA_DIRECTORY {
              DWORD   VirtualAddress; //数据的起始RVA
              DWORD   Size; //
数据块的长度
       }

DataDirectory通常具有16个元素,也就是有16个数据目录,这16个目录分别描述了16种数据的位置和长度,每种数据的目录都存放在数组固定的位置。需要哪种数据,就可以通过DataDirectory 中对应目录中的信息来找到。那这些数据都是什么呢?为了简单起见,我们在这里只介绍一下我们需要用到的那几种数据。(16种数据的详情请见PECOFF官方文档)
       对于我们这个程序,只需关心DataDirectory中第2个元素:导入目录(Import table)和第13个元素:导入函数地址目录(Import Address table)。

这两个目录标识了我们的程序从其他模块导入的函数信息。因为我们要显示一个消息框,所以要导入user32.dll库中的MessageBoxA函数。程序要正常退出,又要导入kernel32.dll库中的ExitProcess函数(关于导入和DLL的基本概念请参考其他资料或到网搜索引擎)。导入目录和导入函数地址目录都是我们前面所说的IMAGE_DATA_DIRECTORY结构,有两个成员,第一个成员表示目录表的起始RVA地址,第二个成员表示目录表的长度。我们准备将把这两个目录所指向的表构造到.rdata段中,所以暂时先不填写。我们先都填写为:“AA AA AA AA”,“AA AA AA AA

这样我们的可选头就构造好了,注意要文件对齐,所以其余的统统添零直到地址1a7h处。填充好的字节码如下图:

 5、段表信息构造。

PE头之后是段表(节表),其中有若干个段头部,每个段头部都是一个大小为40字节的IMAGE_SECTION_HEADER结构。

每个段都有一个段头部来描述该段的信息。我们有三个段 .text(代码段)用来存代码、.rdata(只读数据段)用来存放导入信息、.data(全局变量数据段)用来放要打印的字符串。所以我们的段表中有三个段头部,因此段表长度应该是120字节。在段表后要用一个空的IMAGE_SECTION_HEADER作为段表的结束。所以段表的总长度为160字节。

.text段的构建

Name                 8个字节

表示该节的名称,我们这里的名字为.text,对应的ASKII码值应为“2E 74 65 78 74 00 00 00”。

VirtualSize                DWORD(1C 00 00 00)

表示该段被映射到内存后所占字节数。在这里是指有效字节数,而不是对齐后的大小。稍后我们将把程序的执行代码指令写入到文件中,总共有多少字节的指令需要那时计算,因此我们填写“AA AA AA AA
VirtualAddress          DWORD

表示该段映射到内存中的起始RVA,那么这个值如何得来呢?我们可以将程序的.text段设计为紧跟PE结构(DOS头、PE头、段表)后,然后整个PE头结构映射到内存后占的大小为1000h(自动对齐后),所以该RVA值为1000h,此处填写00 10 00 00”。由于我们的程序比较简单,程序的入口点就是.text段的开始位置(程序的入口地址并不一定就代码段.text的起始位置),这个时候我们已经可以完成前面遗留的BaseOfCodeAddressOfEntryPoint 的值,他们都是1000h。所以将这两个成员值”AA AA AA AA”更改为“00 10 00 00
SizeOfRawData         DWORD

表示.text段在文件中所占的大小。这里可以填写代码大小经过文件对齐后的值,由于我们的代码长度不超过200h(文件对齐),所以填写为“00 02 00 00”。这样前面可选头中余留下来的SizeOfCode值也可以确定:00 02 00 00

 PointerToRawData    DWORD

表示.text段起始位置在文件中的RVA,上面已经计算过PE头部的总长度为400h(见可选头的SizeOfHeads成员),而在PE头部之后就是.text段,所以.text段的起始位置RVA值为400h,此值填充为“00 04 00 00
Characteristics           DWORD

表示段的一些属性,比如段是否含有可执行代码、初始化数据、未初始数据,是否可写、可读等。

因为这是代码段,所以bit 5要置1,一般代码段都含有初始化数据,那么bit 6 位要置1,又因为代码段的代码可以执行的,所以bit 29位要置1,那么这3个二进制位进行或运算最终得到的二进制值为“0010 0000 0000 0000 0000 0000 0110 0000”,将其转换为十六进制值为0x20000060,所以此处应该填写“60000020”。

同理构造.rdata段和.data段

填充好这三个段头,剩下的部分用0按200h补齐。

 

 6、段数据填写。

.text段

我们程序要实现的功能是要弹出一个对话框,并在对话框内写一句HelloPE。

在可选头中的ImageBase表示文件映射到内存时期望被加载到的基地址)的值我们设的是0x00 40 00 00回忆一下我们整个文件的布局,先是PE头部,在内存中对齐后占用1000h个字节单元,随后是.text段,对齐后也占用1000h个字节单元,随后是.rdata段,同样1000h字节单元,最后是.data段,这个时候你会发现.data段装入内存后的起始地址=400000h+1000h+1000h+1000h = 403000h。因字符串就是存放在.data中,所以这样就能明白了,第二个push传入的是.data段中的某个字符串,第三个同理。

然后call函数。为什么call的是一个地址指针指向的地址呢而不是直接call函数地址呢?要解释这个问题,要从导入表开始。因为我们call的是系统函数,而不是程序自己的函数,所以在代码中我们是无法定位系统函数的地址的。为了找到系统函数的地址,在我们程序运行之前,加载器会先做一些准备工作。首先它会检查导入表,将导入表中指明的DLL映射到该程序的进程空间,然后通过导入表中指明的函数名或者函数编号,在对应的dll中找到函数的真正入口地址,并将该地址填入到导入函数地址表中。所以我们要call到真正函数的入口地址,只要找到该函数对应的导入函数地址表项的地址,该地址中存的值才是真正的函数入口地址。这就是为什么要call一个指针的原因。明白了上面这个问题,那么代码中的问号代表的含义自然也就明白了,那就是该函数对应函数地址表项项的地址。现在代码有了,我们只要把代码对应的字节码填入到.text段中就可以。至于????代表的地址,我们还是先用AA AA AA AA代替,稍后再替换。同样,代码段填充完成后要按200h对齐。

代码构造好了,总共大小1BH,因此前面的.text段表的VirtualSize成员可以填充为:1B 00 00 00 

.rdata段填充

这个段被用来存储导入表(Import Table = IT)、导入函数地址(Import Address Table = IAT)表。还记得我们之前处理过的DataDirectory结构吗?其中第2和13目录项就描述了IT和IAT的位置和大小信息。当时我们将位置和大小都预留没填,等我们构造好之后,就可以填进去了。

导入函数地址填充

在DataDirectory中的所有目录项所指向的实际内容,都仅仅根据目录项中标明的位置和大小来确定。所以,我们所需要的表的位置完全可以由我们自己来定。因为IAT数据比较简单,所以我们将它放在.rdata段的开始处,即FOA = 0x600h处,换算成RVA = 0x2000h。有多少个导入的DLL就有多少个IAT表项,每个IAT表项都是一个双字结构,表项间用双字0隔开。

根据代码段可以知道,我们的程序要用到两个函数:user32.dll中的MessageBoxA和Kernel32.dll中的ExitProcess。所以IAT中应该有两个表项,每个表项中有一个双字函数地址和一个双字0结尾。所以整个IAT大小应该是2*(4+4)= 16个字节,即0x10(即一个表项占0x08字节)所以DataDirectory中第13项的数据就可以填进去了,分别是起始地址“00 20 00 00“大小”10 00 00 00“

 至于IAT中导入函数的地址,我们不用关心,可以随便填什么,我们只要知道IAT的位置和大小,剩下的工作就交给Loader来完成了。Loader在运行程序之前会先将各函数的地址修改为真实的地址。

 我们之前两个call指令留下的地址也可以填上了,分别就是两个函数对应IAT表项的RVA+ImageBase,分别是:08 20 40 00(第二个表项偏移08字节,所以0x2008h0820+ 40 00 00 20 40 00  (2000 h即00 20+ 40 00)。

 

 导入表填充

紧跟其后的是导入表。这里我们先填好导入表的数据,然后再对着这个例子来讲解导入表的结构,程序的导入表填充如下图:

 

导入表是由若干个导入表描述符组成。程序导入的函数涉及到多少个dll就有多少个导入表描述符。导入表描述符是一个IMAGE_IMPORT_DESCRIPTOR结构,大小为20字节。

对于我们的程序,需要导入user32.dll和Kernel32.dll这两个dll。所以我们的描述符组中有两个导入表描述符。在所有导入表描述符最后,有20字节全0结构代表描述符组结束。描述符中的内容是什么呢?我们只需要关注被标为绿色的成员。我们以图中的第一个DLL的导入表描述符为例。首先是一个UNION成员,它的值是0x2054,这是一个RVA值,换算成FOA即0x654。这个值相当于一个指针,指向了一个结构,该结构的内容类似于前面介绍的IAT,我们叫它INT(Import Name Table)。INT的作用是让Loader能够根据名字找到函数的真实地址,然后填进IAT中。INT中每一个地址代表一个函数名字信息的RVA(IAT中则是函数的真实地址)。

下面我们看看第一个DLL的INT中存的是什么。根据上面的图,0x654地址里面只存了一个地址:0x205c,换算成FOA为0x65c,找到这个地方的数据:“9D 01 4D 65 73 73 61 67 65 42 6F 78 41 00(因为函数名字信息是一个字符串,所以遇到00则结束),前两个字节规定为该函数的编号,该函数编号为:0x019D。(函数的导入有两种方式:按编号导入和按名字导入,导入表描述符中的第一项是个UNION,其中Characteristics就用来表示导入方式,当该UNION最高位为0时表示这是一个RVA,表示该函数是以字符串为名字导入,如果最高位为1,则该UNION表示一个值,以该值为依据导入。但是,很多时候按编号导入时不准确,所以大多数时候是用名字导入,这里我们先不用管按编号导入的情况)剩下的部分转换成字符就是“MessageBoxA”。这样,第一个DLL的导入表描述符中的第一项的作用就描述完了。

函数调用导入过程

过程有点复杂,我们再来总结一下:首先loader找到第一个导入表描述符,取出其中前四个字节,以它为偏移找到该DLL对应的INT,然后读取INT,直到遇到 00 00 00 00则表示第一个DLLINT结束。然后根据INT中存的RVA,找到各个函数字符串名字信息。然后根据每个函数的名字信息,去该DLL中找到该函数的真实地址,填入到对应的IAT表项中。这其实差不多就是整个导入过程了。

然后看结构中第二个绿色变量:name1,它是一个RVA,指向了该DLL的名字。我们看第一个DLL的描述符,该值为:0x206A,转换成FOA为0x66A。以0x66A为文件偏移取出来的字符串为“75 73 65 72 33 32 2E 64 6C 6C”,转换成字符即“user32.dll”。这样我们就能找到该DLL的名字信息。

 

 第三个绿色成员是FirstThunk,该值指向了该DLL对应的IAT表项的起始RVA。看看第一个DLL描述符中该成员的值为:“0x2008”,换成FOA即0X608,正好就是IAT中的第二项的起始偏移。

 有了函数字符串名字信息、DLL名字信息、IAT表项起始偏移,Loader就可以完成整个导入过程了。第二个DLL的分析过程和上面完全类似。

有了导入表,我们现在可以填DataDirectory中的第二项的值了,起始RVA是“10 20 00 00”,长度是2个导入表描述符(40)、一个空导入表描述符(20),所以总长度为60个字节,换算成十六进制为0x3c,所以填入3c 00 00 00

 

 另外.rdata的长度为8FH,将其填入.rdata段表的VirtualSize中。

 完成.rdata段后,仍然需要补0对齐。

 .data段数据填充

.data段十分简单,就是用来存储前面函数所用到的字符串参数的。我们需要在.data段中构造两个字符串,根据段头部信息,该段的FOA为800h,所以,第一个起始地址为800h,根据代码中的相对偏移,第二个起始地址为800h向后偏移7个字节,即807h。

 第一个字符串是标题字符串,长为7个字节,其内容是“PeTest”  换算成ASCII码为“ 54 65 73 74 50 45 00”。第二个字符串是对话框内容,长度为8个字节,其内容为“HelloPE”,换算成ASCII码为“48 65 6C 6C 6F 50 45 00”。后面全部补0对齐。

 填充结果如下图:

 

 .data段表的VirtualSize0EH

 7、运行结果

  • 3
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值