PE总结 – DOS文件头、PE文件头、节表和表详解

原文地址:http://www.blogfshare.com/pe-header-one.html

PE(Portable Executeable File Format,可移植的执行体文件格式),使用该格式的目标是使链接生成的EXE文件能在不同的CPU工作指令下工作。

可执行文件的格式是操作系统工作方法的真实写照。Windows操作系统中可执行程序有好多种,比如COM、PIF、SCR、EXE等,这些文件的格式大部分都继承自PE。其中,EXE是最常见的PE文件,动态链接库(大部分以dll为扩展名的文件)也是PE文件。

PE格式是Windows下最常用的可执行文件格式,在DOS时代COM文件是最早的也是结构最简单的可执行文件,COM文件中仅包含可执行代码,没有附带任何“支持性”数据,所以,第一句执行指令必须安排在文件头部:再就是没有重定位的信息,这样代码中不能有跨段操作数据的指令,造成代码和数据,甚至包括堆栈只能限制在同一个64KB的段中,由于这个原因,DOS系统中又定义了一种可执行文件—EXE文件,EXE文件在代码的前面加了一个文件头,文件头中包括各种说明数据,如文件入口,堆栈位置,重定位表等,操作系统根据文件头的信息将代码部分装入内存,根据重定位表修正代码,最后在设置好堆栈后从文件头中指定的入口开始执行。
当Windows3.X出现的时候,可执行文件中出现了32位代码,程序运行时转到保护模式之前需要在实模式下做一些初始化,这样实模式的16位代码必须和32位代码一起放在可执行文件中,旧的DOS可执行文件格式无法满足需要,所以Windows3.X执行文件使用新的LE格式的可执行文件(Linear executable/线性可执行文件),Window9x中的VxD程序也是使用LE格式,因为这些驱动程序中也同时包括16位和32位代码。
而在Windows 9x,Windows NT,Windows 2000下,纯32位可执行文件都使用微软设计的一种新格式——PE格式(Portable Executable File Format/可移值的执行体)。

PE文件的基本结构如图示:

一、DOS ME头IMAGE_DOS_HEADER

IMGAE_DOS_HEADER的具体定义如下:

  1. IMAGE_DOS_HEADER STRUCT
  2. +00h WORD e_magic // Magic DOS signature MZ(4Dh 5Ah) DOS可执行文件标记
  3. +02h WORD e_cblp // Bytes on last page of file
  4. +04h WORD e_cp // Pages in file
  5. +06h WORD e_crlc // Relocations
  6. +08h WORD e_cparhdr // Size of header in paragraphs
  7. +0ah WORD e_minalloc // Minimun extra paragraphs needs
  8. +0ch WORD e_maxalloc // Maximun extra paragraphs needs
  9. +0eh WORD e_ss // intial(relative)SS value DOS代码的初始化堆栈SS
  10. +10h WORD e_sp // intial SP value DOS代码的初始化堆栈指针SP
  11. +12h WORD e_csum // Checksum
  12. +14h WORD e_ip // intial IP value DOS代码的初始化指令入口[指针IP]
  13. +16h WORD e_cs // intial(relative)CS value DOS代码的初始堆栈入口 CS
  14. +18h WORD e_lfarlc // File Address of relocation table
  15. +1ah WORD e_ovno // Overlay number
  16. +1ch WORD e_res[4] // Reserved words
  17. +24h WORD e_oemid // OEM identifier(for e_oeminfo)
  18. +26h WORD e_oeminfo // OEM information;e_oemid specific
  19. +29h WORD e_res2[10] // Reserved words
  20. +3ch LONG e_lfanew // Offset to start of PE header 指向PE文件头
  21. IMAGE_DOS_HEADER ENDS

第一个字段e_magic被定义成字符“MZ”作为识别标志,后面的一些字段指明了入口地址、堆栈位置和重定位表位置等。

对于PE文件来说,有用的是最后的e_lfanew字段,这个字段指出了真正的PE文件头在文件中的位置,这个位置总是以8字节为单位对齐的。

从图中我们可以看到e_lfanew的值为000000E8,也就是说000000E8处是我们的PE文件头的位置。

二、PE文件头

PE文件头是由IMAGE_NT_HEADERS结构定义的:

  1. IMAGE_NT_HEADERS STRUCT
  2. +0h DWORD Signature PE文件标识
  3. +4h IMAGE_FILE_HEADER FileHeader
  4. +18h IMAGE_OPTIONAL_HEADER32 OptionalHeader
  5. IMAGE_NT_HEADERS ENDS

PE文件头的第一个双字是一个标志,它被定义为00004550,也就是字符P E加上两个0,这也是PE这个称呼的由来。从名称来看似乎后面的这个PE文件表头结构是可选的,但实际上这个名称是名不符实的,因为它总是存在于每个PE文件中。

1.IMAGE_FILE_HEADER结构

  1. IMAGE_FILE_HEADER STRUCT
  2. +04h WORD Machine; // 运行平台
  3. +06h WORD NumberOfSections; // 文件的区块数目
  4. +08h DWORD TimeDateStamp; // 文件创建日期和时间
  5. +0Ch DWORD PointerToSymbolTable; // 指向符号表(主要用于调试)
  6. +10h DWORD NumberOfSymbols; // 符号表中符号个数(同上)
  7. +14h WORD SizeOfOptionalHeader; // IMAGE_OPTIONAL_HEADER32 结构大小
  8. +16h WORD Characteristics; // 文件属性
  9. IMAGE_FILE_HEADER ENDS

为大家详细解释各个成员的含义和用法:

①Machine:可执行文件的目标CPU类型。

更多定义参见Windows.inc文件。

②NumberOfSection: 区块的数目。(注:区块表是紧跟在 IMAGE_NT_HEADERS 后边的)

③TimeDataStamp: 表明文件是何时被创建的。

这个值是自1970年1月1日以来用格林威治时间(GMT)计算的秒数,这个值是比文件系统(FILESYSTEM)的日期时间更加精确的指示器。

④PointerToSymbolTable: COFF 符号表的文件偏移位置,现在基本没用了。

⑤NumberOfSymbols: 如果有COFF 符号表,它代表其中的符号数目,COFF符号是一个大小固定的结构,如果想找到COFF 符号表的结束位置,则需要这个变量。

⑥SizeOfOptionalHeader: 紧跟着IMAGE_FILE_HEADER 后边的数据结构(IMAGE_OPTIONAL_HEADER)的大小。(对于32位PE文件,这个值通常是00E0h;对于64位PE32+文件,这个值是00F0h )。

⑦Characteristics: 文件属性,有选择的通过几个值可以运算得到。( 这些标志的有效值是定义于 winnt.h 内的 IMAGE_FILE_** 的值,具体含义见下表。普通的EXE文件这个字段的值一般是 0100h,DLL文件这个字段的值一般是 210Eh。)多种属性可以通过 “或运算” 使得同时拥有!

2.IMAGE_OPTIONAL_HEADER32结构

  1. IMAGE_OPTIONAL_HEADER32 STRUCT
  2. +18h WORD Magic; // 标志字, ROM 映像(0107h),普通可执行文件(010Bh)
  3. +1Ah BYTE MajorLinkerVersion; // 链接程序的主版本号
  4. +1Bh BYTE MinorLinkerVersion; // 链接程序的次版本号
  5. +1Ch DWORD SizeOfCode; // 所有含代码的节的总大小
  6. +20h DWORD SizeOfInitializedData; // 所有含已初始化数据的节的总大小
  7. +24h DWORD SizeOfUninitializedData; // 所有含未初始化数据的节的大小
  8. +28h DWORD AddressOfEntryPoint; // 程序执行入口RVA
  9. +2Ch DWORD BaseOfCode; // 代码的区块的起始RVA
  10. +30h DWORD BaseOfData; // 数据的区块的起始RVA
  11. +34h DWORD ImageBase; // 程序的首选装载地址
  12. +38h DWORD SectionAlignment; // 内存中的区块的对齐大小
  13. +3Ch DWORD FileAlignment; // 文件中的区块的对齐大小
  14. +40h WORD MajorOperatingSystemVersion; // 要求操作系统最低版本号的主版本号
  15. +42h WORD MinorOperatingSystemVersion; // 要求操作系统最低版本号的副版本号
  16. +44h WORD MajorImageVersion; // 可运行于操作系统的主版本号
  17. +46h WORD MinorImageVersion; // 可运行于操作系统的次版本号
  18. +48h WORD MajorSubsystemVersion; // 要求最低子系统版本的主版本号
  19. +4Ah WORD MinorSubsystemVersion; // 要求最低子系统版本的次版本号
  20. +4Ch DWORD Win32VersionValue; // 莫须有字段,不被病毒利用的话一般为0
  21. +50h DWORD SizeOfImage; // 映像装入内存后的总尺寸
  22. +54h DWORD SizeOfHeaders; // 所有头 + 区块表的尺寸大小
  23. +58h DWORD CheckSum; // 映像的校检和
  24. +5Ch WORD Subsystem; // 可执行文件期望的子系统
  25. +5Eh WORD DllCharacteristics; // DllMain()函数何时被调用,默认为 0
  26. +60h DWORD SizeOfStackReserve; // 初始化时的栈大小
  27. +64h DWORD SizeOfStackCommit; // 初始化时实际提交的栈大小
  28. +68h DWORD SizeOfHeapReserve; // 初始化时保留的堆大小
  29. +6Ch DWORD SizeOfHeapCommit; // 初始化时实际提交的堆大小
  30. +70h DWORD LoaderFlags; // 与调试有关,默认为 0
  31. +74h DWORD NumberOfRvaAndSizes; // 下边数据目录的项数,这个字段自Windows NT 发布以来一直是16
  32. +78h IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; // 数据目录表
  33. IMAGE_OPTIONAL_HEADER32 ENDS

事实上,这个结构中的大部分字段都不重要,大家可以从注释中理解它们的含义,我将比较重要的字段在下边跟大家详细讲解:

①AddressOfEntryPoint字段

指出文件被执行时的入口地址,这是一个RVA地址。如果在一个可执行文件上附加了一段代码并想让这段代码首先被执行,那么只需要将这个入口地址指向附加的代码就可以了。

②ImageBase字段

指出文件的优先装入地址。也就是说当文件被执行时,如果可能的话,Windows优先将文件装入到由ImageBase字段指定的地址中,只有指定的地址已经被**模块使用时,文件才被装入到**地址中。链接器产生可执行文件的时候对应这个地址来生成机器码,所以当文件被装入这个地址时不需要进行重定位操作,装入的速度最快,如果文件被装载到**地址的话,将不得不进行重定位操作,这样就要慢一点。

对于EXE文件来说,由于每个文件总是使用独立的虚拟地址空间,优先装入地址不可能被**模块占据,所以EXE总是能够按照这个地址装入,这也意味着EXE文件不再需要重定位信息。对于DLL文件来说,由于多个DLL文件全部使用宿主EXE文件的地址空间,不能保证优先装入地址没有被**的DLL使用,所以DLL文件中必须包含重定位信息以防万一。因此,在前面介绍的 IMAGE_FILE_HEADER 结构的 Characteristics 字段中,DLL 文件对应的 IMAGE_FILE_RELOCS_STRIPPED 位总是为0,而EXE文件的这个标志位总是为1。

在链接的时候,可以通过对link.exe指定/base:address选项来自定义优先装入地址,如果不指定这个选项的话,一般EXE文件的默认优先装入地址被定为00400000h,而DLL文件的默认优先装入地址被定为10000000h。

③SectionAlignment 字段和 FileAlignment字段

SectionAlignment字段指定了节被装入内存后的对齐单位。也就是说,每个节被装入的地址必定是本字段指定数值的整数倍。而FileAlignment字段指定了节存储在磁盘文件中时的对齐单位。

④Subsystem字段

指定使用界面的子系统,它的取值如表所示。这个字段决定了系统如何为程序建立初始的界面,链接时的/subsystem:**选项指定的就是这个字段的值,在前面章节的编程中我们早已知道:如果将子系统指定为Windows CUI,那么系统会自动为程序建立一个控制台窗口,而指定为Windows GUI的话,窗口必须由程序自己建立。界面子系统的取值和含义如下:

⑤DataDirectory字段

这个字段可以说是最重要的字段之一,它由16个相同的IMAGE_DATA_DIRECTORY结构组成,虽然PE文件中的数据是按照装入内存后的页属性归类而被放在不同的节中的,但是这些处于各个节中的数据按照用途可以被分为导出表、导入表、资源、重定位表等数据块,这16个IMAGE_DATA_DIRECTORY结构就是用来定义多种不同用途的数据块的IMAGE_DATA_DIRECTORY结构的定义很简单,它仅仅指出了某种数据块的位置和长度。

  1. IMAGE_DATA_DIRECTORY STRUCT
  2. VirtualAddress DWORD ? ;数据的起始RVA
  3. isize DWORD ? ;数据块的长度
  4. IMAGE_DATA_DIRECTORY ENDS

数据目录列表的含义如下:

在PE文件中寻找特定的数据时就是从这些IMAGE_DATA_DIRECTORY结构开始的,比如要存取资源,那么必须从第3个IMAGE_DATA_DIRECTORY结构(索引为2)中得到资源数据块的大小和位置;同理,如果要查看PE文件导入了哪些DLL文件的哪些API函数,那就必须首先从第2个IMAGE_DATA_DIRECTORY结构得到导入表的位置和大小。

好了,我们继续接着上一篇来讲解节表和表。

三、节表和节

1.首先我们先来了解Windows是如何将PE文件映射到内存的。

在执行一个PE文件的时候,windows 并不在一开始就将整个文件读入内存的,而是采用与内存映射文件类似的机制。也就是说,windows 装载器在装载的时候仅仅建立好虚拟地址和PE文件之间的映射关系。当且仅当真正执行到某个内存页中的指令或者访问某一页中的数据时,这个页面才会被从磁盘提交到物理内存,这种机制使文件装入的速度和文件大小没有太大的关系。

但是要注意的是,系统装载可执行文件的方法又不完全等同于内存映射文件。当使用内存映射文件的时候,系统对“原著”相当忠实,如果将磁盘文件和内存映像比较的话,可以发现不管是数据本身还是数据之间的相对位置的都是完全相同的。而我们知道,在装载可执行文件的时候,有些数据在装入前会被预处理,如重定位等,正因此,装入以后,数据之间的相对位置可能发生微妙的变化。

Windows 装载器在装载DOS部分、PE文件头部分和节表(区块表)部分是不进行任何特殊处理的,而在装载节(区块)的时候则会自动按节(区块)的属性做不同的处理。

①内存页的属性:

对于磁盘映射文件来说,所有的页都是按照磁盘映射文件函数指定的属性设置的。但是在装载可执行文件时,与节对应的内存页属性要按照节的属性来设置。所以,在同属于一个模块的内存页中,从不同节映射过来的的内存页的属性是不同的。

②节的偏移地址:

节的起始地址在磁盘文件中是按照 IMAGE_OPTIONAL_HEADER32 结构的 FileAlignment 字段的值进行对齐的,而当被加载到内存中时是按照同一结构中的 SectionAlignment 字段的值对其的,两者的值可能不同,所以一个节被装入内存后相对于文件头的偏移和在磁盘文件中的偏移可能是不同的。

注意,节事实上就是相同属性数据的组合!当节被装入到内存中的时候,相同一个节所对应的内存页都将被赋予相同的页属性, 事实上,Windows 系统对内存属性的设置是以页为单位进行的,所以节在内存中的对齐单位必须至少是一个页的大小。(对于32位操作系统来说,这个值一般是4KB==1000H; 对于64位操作系统这个值一般是8KB==2000H)。节在磁盘中就没有最小4K的限制,为了减少磁盘文件的大小,文件对齐的单位一般要小于内存对齐的单位(FileAlignment的值一般为200h,一个扇区),这样,在磁盘中就不必为每个节最后的零头数据补足4KB的大小了。

③节的尺寸:

对节的尺寸的处理主要分为两个方面:

第一个方面,正如刚刚我们所讲的,由于磁盘映像和内存映像中节对齐存储单位的不同而导致了长度扩展不同(填充的0数量不同嘛~);

第二个方面,是对于包含未初始化数据的节的处理问题。既然是未初始化,那么没有必要为其在磁盘中浪费空间资源,但在内存中不同,因为程序一运行,之前未初始化的数据便有可能要被赋值初始化,那么就必须为他们留下空间。

④不进行映射的节:

有些节并不需要被映射到内存中,例如.reloc节,重定位数据对于文件的执行代码来说是透明的,无作用的,它只是提供Windows 装载器使用,执行代码根本不会去访问到它们,所以没有必要将他们映射到物理内存中。

2.节表

PE文件中所有节的属性都被定义在节表中,节表由一系列的IMAGE_SECTION_HEADER结构排列而成,每个结构用来描述一个节,结构的排列顺序和它们描述的节在文件中的排列顺序是一致的。全部有效结构的最后以一个空的IMAGE_SECTION_HEADER结构作为结束,所以节表中IMAGE_SECTION_HEADER结构数量等于节的数量加一。节表总是被存放在紧接在PE文件头的地方。

另外,节表中 IMAGE_SECTION_HEADER 结构的总数总是由PE文件头 IMAGE_NT_HEADERS 结构中的 FileHeader.NumberOfSections 字段来指定的。

  1. IMAGE_SECTION_HEADER STRUCT
  2. BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 8个字节的节区名称
  3. union Misc
  4. DWORD PhysicalAddress;
  5. DWORD VirtualSize; //节区的尺寸
  6. ends
  7. DWORD VirtualAddress; // 节区的 RVA 地址
  8. DWORD SizeOfRawData; // 在文件中对齐后的尺寸
  9. DWORD PointerToRawData; // 在文件中的偏移量
  10. DWORD PointerToRelocations; // 在OBJ文件中使用,重定位的偏移
  11. DWORD PointerToLinenumbers; // 行号表的偏移(供调试使用地)
  12. WORD NumberOfRelocations; // 在OBJ文件中使用,重定位项数目
  13. WORD NumberOfLinenumbers; // 行号表中行号的数目
  14. DWORD Characteristics; // 节属性如可读,可写,可执行等
  15. IMAGE_SECTION_HEADER ENDS

真正有用的几个字段说明如下:

①Name1:区块名。这是一个由8位的ASCII 码名,用来定义区块的名称。多数区块名都习惯性以一个“.”作为开头(例如:.text),这个“.” 实际上是不是必须的。值得我们注意的是,如果区块名达到8 个字节,后面就没有0字符了。并且前边带有一个“$” 的区块名字会从连接器那里得到特殊的待遇,前边带有“$” 的相同名字的区块在载入时候将会被合并,在合并之后的区块中,他们是按照“$” 后边的字符的字母顺序进行合并的。
每个区块的名称都是唯一的,不能有同名的两个区块。但事实上节的名称不代表任何含义,他的存在仅仅是为了正规统一编程的时候方便程序员查看方便而设置的一个标记而已。所以将包含代码的区块命名为“.Data” 或者说将包含数据的区块命名为“.Code” 都是合法的。
当我们要从PE 文件中读取需要的区块时候,不能以区块的名称作为定位的标准和依据,正确的方法是按照 IMAGE_OPTIONAL_HEADER32 结构中的数据目录字段结合进行定位。

②Virtual Size:对表对应的区块的大小,这是区块的数据在没有进行对齐处理前的实际大小。

③Virtual Address:该区块装载到内存中的RVA 地址。这个地址是按照内存页来对齐的,因此它的数值总是 SectionAlignment 的值的整数倍。

④PointerToRawData:指出节在磁盘文件中所处的位置。这个数值是从文件头开始算起的偏移量。

⑤SizeOfRawData:该区块在磁盘中所占的大小,这个数值等于VirtualSize字段的值按照FileAlignment的值对齐以后的大小。

依靠上面4个字段的值,装载器就可以从PE文件中找出某个节(从PointerToRawData偏移开始的SizeOfRawData字节)的数据,并将它映射到内存中去(映射到从模块基地址偏移VirtualAddress的地方,并占用以VirtualSize的值按照页的尺寸对齐后的空间大小)。

⑥Characteristics:该区块的属性。该字段是按位来指出区块的属性(如代码/数据/可读/可写等)的标志。

3.RVA和文件偏移的转换

RVA 是相对虚拟地址(Relative Virtual Address)的缩写,顾名思义,它是一个“相对地址”。PE 文件中的各种数据结构中涉及地址的字段大部分都是以 RVA 表示的。

RVA 是当PE 文件被装载到内存中后,某个数据位置相对于文件头的偏移量。举个例子,如果 Windows 装载器将一个PE 文件装入到 00400000h 处的内存中,而某个区块中的某个数据被装入 0040**xh 处,那么这个数据的 RVA 就是(0040**xh – 00400000h )= **xh,反过来说,将 RVA 的值加上文件被装载的基地址,就可以找到数据在内存中的实际地址。

很明显,DOS 文件头、PE 文件头和区块表的偏移位置与大小均没有变化。而各个区块映射到内存后,其偏移位置就发生了变化。

当处理PE 文件时候,任何的 RVA 必须经过到文件偏移的换算,才能用来定位并访问文件中的数据,但换算却无法用一个简单的公式来完成,事实上,唯一可用的方法就是最土最笨的方法:
步骤一:循环扫描区块表得出每个区块在内存中的起始 RVA(根据IMAGE_SECTION_HEADER 中的VirtualAddress 字段),并根据区块的大小(根据IMAGE_SECTION_HEADER 中的SizeOfRawData 字段)算出区块的结束 RVA(两者相加即可),最后判断目标 RVA 是否落在该区块内。
步骤二:通过步骤一定位了目标 RVA 处于具体的某个区块中后,那么用目标 RVA 减去该区块的起始 RVA ,这样就能得到目标 RVA 相对于起始地址的偏移量 RVA2.
步骤三:在区块表中获取该区块在文件中所处的偏移地址(根据IMAGE_SECTION_HEADER 中的PointerToRawData 字段), 将这个偏移值加上步骤二得到的 RVA2 值,就得到了真正的文件偏移地址。


发表评论

电子邮件地址不会被公开。 必填项已用*标注