3rsh1
/single dog/college student/ctfer
3rsh1's Blog

PE文件格式

pe文件由四部分组成:

dos头
pe文件头
节表
节数据
https://masterxsec.github.io/2017/05/02/PE%E6%96%87%E4%BB%B6%E7%BB%93%E6%9E%84/%E7%BB%93%E6%9E%84%E6%80%BB%E8%A7%88.png

更详细:

https://thunderjie.github.io/2019/03/27/PE%E7%BB%93%E6%9E%84%E8%AF%A6%E8%A7%A3/1.jpg

dos部分

主要是为了兼容以前的dos系统,可以分为dos mz文件头(IMAGE_DOS_HEADER)和dos块(DOS Stub)组成。pe文件的第一个字节位于dos头中。

dos头结构如下:

typedef struct _IMAGE_DOS_HEADER {      // DOS .EXE header
    WORD   e_magic;                     // Magic number
    WORD   e_cblp;                      // Bytes on last page of file
    WORD   e_cp;                        // Pages in file
    WORD   e_crlc;                      // Relocations
    WORD   e_cparhdr;                   // Size of header in paragraphs
    WORD   e_minalloc;                  // Minimum extra paragraphs needed
    WORD   e_maxalloc;                  // Maximum extra paragraphs needed
    WORD   e_ss;                        // Initial (relative) SS value
    WORD   e_sp;                        // Initial SP value
    WORD   e_csum;                      // Checksum
    WORD   e_ip;                        // Initial IP value
    WORD   e_cs;                        // Initial (relative) CS value
    WORD   e_lfarlc;                    // File address of relocation table
    WORD   e_ovno;                      // Overlay number
    WORD   e_res[4];                    // Reserved words
    WORD   e_oemid;                     // OEM identifier (for e_oeminfo)
    WORD   e_oeminfo;                   // OEM information; e_oemid specific
    WORD   e_res2[10];                  // Reserved words
    LONG   e_lfanew;                    // File address of new exe header
  } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

需要关注的是两个字段,其余的字段用0填充并不会影响程序的正常运行:

  1. e_magic,标识pe指纹的一部分。
  2. e_lfarlc,用以寻找pe文件头部分。

e_lfanew指向PE文件头,表示的是pe文件头到文件起始地址的便宜,我们可以通过它来寻找PE文件头,而DOS块的部分自然就是PE文件头和DOS MZ文件头中间的部分,这部分是由链接器所写入的,可以随意进行修改,并不影响程序的运行

pe文件头

pe文件头标志,标准pe头,拓展pe头组成( PE表头内含的重要信息包括程序代码和资料区域的大小位置、适用的操作系统、堆栈(stack)的最初大小等等。 )。

  1. pe文件头标志,4字节,固定,50 40 00 00,用来判断文件是否为pe文件的标识。。

  2. 标准pe头,20字节,主要记录该文件的一些调试信息。

  3. 拓展pe头,扩展PE头在32位和64位系统上大小是不同的,在32位系统上有224个字节,16进制就是0xE0。文件在装入内存时,主要的参照的就是该结构。文件在从源代码进行编译的时候也主要是对该部分进行填写。并在生成的时候依照该结构的一些字段的设置。

pe文件头:

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;                        //PE文件头标志 => 4字节
    IMAGE_FILE_HEADER FileHeader;           //标准PE头 => 20字节
    IMAGE_OPTIONAL_HEADER32 OptionalHeader; //扩展PE头 => 32位下224字节(0xE0) 64位下240字节(0xF0)
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

标准pe头:

typedef struct _IMAGE_FILE_HEADER {
    WORD    Machine;                //可以运行在什么平台上 任意:0 ,Intel 386以及后续:14C x64:8664
    WORD    NumberOfSections;       //节的数量
    DWORD   TimeDateStamp;          //编译器填写的时间戳
    DWORD   PointerToSymbolTable;   //调试相关
    DWORD   NumberOfSymbols;        //调试相关
    WORD    SizeOfOptionalHeader;   //标识扩展PE头大小
    WORD    Characteristics;        //文件属性 => 16进制转换为2进制根据哪些位有1,可以查看相关属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

拓展pe头:

typedef struct _IMAGE_OPTIONAL_HEADER {
    //
    // Standard fields.
    //

    WORD    Magic;                      //PE32: 10B PE64: 20B
    BYTE    MajorLinkerVersion;
    BYTE    MinorLinkerVersion;
    DWORD   SizeOfCode;                 //所有含有代码的区块的大小 编译器填入 没用(可改)
    DWORD   SizeOfInitializedData;      //所有初始化数据区块的大小 编译器填入 没用(可改)
    DWORD   SizeOfUninitializedData;    //所有含未初始化数据区块的大小 编译器填入 没用(可改)
    DWORD   AddressOfEntryPoint;        //程序入口RVA
    DWORD   BaseOfCode;                 //代码区块起始RVA
    DWORD   BaseOfData;                 //数据区块起始RVA

    //
    // NT additional fields.
    //

    DWORD   ImageBase;                      //内存镜像基址(程序默认载入基地址)
    DWORD   SectionAlignment;               //内存中对齐大小
    DWORD   FileAlignment;                  //文件中对齐大小(提高程序运行效率)
    WORD    MajorOperatingSystemVersion;
    WORD    MinorOperatingSystemVersion;
    WORD    MajorImageVersion;
    WORD    MinorImageVersion;
    WORD    MajorSubsystemVersion;
    WORD    MinorSubsystemVersion;
    DWORD   Win32VersionValue;
    DWORD   SizeOfImage;                    //内存中整个PE文件的映射的尺寸,可比实际值大,必须是SectionAlignment的整数倍
    DWORD   SizeOfHeaders;                  //所有的头加上节表文件对齐之后的值
    DWORD   CheckSum;                       //映像校验和,一些系统.dll文件有要求,判断是否被修改
    WORD    Subsystem;                      
    WORD    DllCharacteristics;             //文件特性,不是针对DLL文件的,16进制转换2进制可以根据属性对应的表格得到相应的属性
    DWORD   SizeOfStackReserve;
    DWORD   SizeOfStackCommit;
    DWORD   SizeOfHeapReserve;
    DWORD   SizeOfHeapCommit;
    DWORD   LoaderFlags;
    DWORD   NumberOfRvaAndSizes;
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; //数据目录表,结构体数组
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

需要注意的几个字段:

  1. AddressOfEntryPoint,就是准备运行的pe文件的第一个指令的RVARVA:全名是Relative Virtual Address,翻译过来是“相对虚拟地址”,例如 0x1000. 虚拟地址0x00401000RVA就是0x1000RVA = 虚拟地址-ImageBase
  2. 拓展pe头的最后一个字段是一个结构体IMAGE_DATA_DIRECTORY
  3. 程序的真正入口点 = ImageBase + AddressOfEntryPoint

可选头中定义了如下重要信息:

  • 所有含代码的节的总大小
  • 所有含已初始化数据的节的总大小
  • 所有含未初始化数据的节的大小
  • 程序执行入口RVA
  • 代码的节的起始RVA
  • 数据的节的起始RVA
  • 程序的建议装载地址
  • 内存中的节的对齐粒度
  • 文件中的节的对齐粒度
  • 内存中整个PE映像尺寸
  • 所有头+节表的大小
  • 导出表
  • 导入表
  • 资源
  • 重定位表
  • 调试信息
  • 版权信息
  • 导入函数地址表

节表

整体为40字节。PE代码和数据的结构数据,指示代码段在哪里,数据段在哪里等。节是PE文件中代码或数据的基本单元。节表中定义了所有节的属性,节表是由IMAGE_SECTION_HEADER结构组成的数组,每个结构用来描述一个节。

结构的排列顺序和它们描述的节在文件中的排列顺序是一致的。全部有效结构的最后以一个空的IMAGE_SECTION_HEADER结构作为结束。所以节表中所有的IMAGE_SECTION_HEADER的数量是:节数+1。

节表总是被存放在紧接在PE文件头的地方,(注意:不是文件本身的头部,由于程序的dos块的大小并不是固定的,所以导致PE头相对于mz头的偏移会变化。 所以我们在计算偏移的时候,对于PE头后面的数据都习惯用相对于PE头的偏移,PE头大小是固定的的。)

IMAGE_SECTION_HEADER结构:

typedef struct _IMAGE_SECTION_HEADER {
    BYTE    Name[IMAGE_SIZEOF_SHORT_NAME]; //ASCII字符串 可自定义 只截取8个字节
    union {                                //该节在没有对齐之前的真实尺寸,该值可以不准确
            DWORD   PhysicalAddress;
            DWORD   VirtualSize;
    } Misc;
    DWORD   VirtualAddress;                //内存中的偏移地址
    DWORD   SizeOfRawData;                 //节在文件中对齐的尺寸
    DWORD   PointerToRawData;              //节区在文件中的偏移
    DWORD   PointerToRelocations;
    DWORD   PointerToLinenumbers;
    WORD    NumberOfRelocations;
    WORD    NumberOfLinenumbers;
    DWORD   Characteristics;               //节的属性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

值得注意的是扩展PE头中的 FileAlignment 以及 SizeOfHeaders 这两个成员,SizeOfHeaders 表示所有的头加上节表文件对齐之后的值,对齐的大小参考的就是 FileAlignment 成员,如果所有的头加上节表的大小为320,FileAlignment 为 200,那么 SizeOfHeaders 大小就为 400,因为是根据FileAlignment 对齐的,这种对齐虽然牺牲了空间,但是可以提高程序运行效率。

节数据

常见的节数据:

.text:代码段,存放的是源代码汇编或者编译之后产生的机器指令。

.data:初始化数据段,包含编译过程中初始化了的变量和字符串。

.idata:输入表,包含其他外来dll的函数和数据信息,也就是输入表,也有人称之为导入表。
.rsrc:资源数据块,包含模块的全部资源数据,如图标、菜单、位图等。
.reloc:重定位表,用于保存基址的重定位表。即当装载程序不能按照链接器所指定的地址装载文件时,需要对指令或已经初始化的变量进行调整,该块中也包含了调整过程中所需要的一些数据,如果装载能够正常则忽略此段中的数据。若加载的是DLLSYS文件,且在ImageBase位置处已经加载了其他DLL/SYS文件,那么PE装载器就会将其加载到其他未被占用的空间。这就涉及了PE文件重定位问题,PE重定位是指PE文件无法加载到ImageBase位置,而被加载到其他地址时发生的一系列处理行为。
.edata:导出表,是pe文件的输出表,以供其他模块使用,并不是每个pe文件都有此数据段,因为有的文件并不需要输出一些函数,该数据段常见于动态链接库文件中。
.radata:存放调试目录、说明字符串,该数据块并不常见主要是用于存放一些调试信息。

导出表(Import Table)和导入表是靠 IMAGE_DATA_DIRECTORY 这个结构体数组来寻找的,IMAGE_DATA_DIRECTORY 的结构如下:

typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;
    DWORD   Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

前 8 位是导出表的内容。

导入表

导入表由一系列的IMAGE_IMPORT_DESCRIPTOR结构组成,结构的数量取决于程序要使用的DLL文件的数量,每个结构对应一个DLL文件,例如,如果一个PE文件从10个不同的DLL文件中引入了函数,那么就存在10个IMAGE_IMPORT_DESCRIPTOR结构来描述这些DLL文件,在所有这些结构的最后,由一个内容全为0的IMAGE_IMPORT_DESCRIPTOR结构作为结束。

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;            // 0 for terminating null import descriptor
        DWORD   OriginalFirstThunk;         // RVA 指向 INT (PIMAGE_THUNK_DATA结构数组)  指向被调函数名的指针数组的指针
    } DUMMYUNIONNAME;
    DWORD   TimeDateStamp;                  // 0 if not bound,
                                            // -1 if bound, and real date\time stamp
                                            //     in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
                                            // O.W. date/time stamp of DLL bound to (Old BIND) 时间日期记录,无实际意义,可忽略

    DWORD   ForwarderChain;                 // -1 if no forwarders 正向连接索引
    DWORD   Name;                           //RVA指向dll名字,以0结尾
    DWORD   FirstThunk;                     // RVA 指向 IAT (PIMAGE_THUNK_DATA结构数组)
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

可以看到,OriginalFirstThunkFirstThunk 指向的内容分别是 INTIAT,但实际上 INTIAT 的内容是一样的,所以他们指向的内容是一样的,只是方式不同而已,下图可以完美的解释:

https://thunderjie.github.io/2019/03/27/PE%E7%BB%93%E6%9E%84%E8%AF%A6%E8%A7%A3/10.jpg

但是上图只是PE文件加载前的情况,PE文件一旦运行起来,就会变成下图的情况:

https://thunderjie.github.io/2019/03/27/PE%E7%BB%93%E6%9E%84%E8%AF%A6%E8%A7%A3/11.jpg

我们还需要了解的结构体是 IMAGE_THUNK_DATAIMAGE_IMPORT_BY_NAME。结构如下:

typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint; //可能为空,编译器决定,如果不为空,是函数在导出表的索引
    BYTE    Name[1]; //函数名称,以0结尾
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

#include "pshpack8.h"                       // Use align 8 for the 64-bit IAT.

typedef struct _IMAGE_THUNK_DATA64 {
    union {
        ULONGLONG ForwarderString;  // 指向一个转向者字符串的RVA
        ULONGLONG Function;         // 被输入的函数的内存地址
        ULONGLONG Ordinal;          // 被输入API的序数值
        ULONGLONG AddressOfData;    // 指针指向 IMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA64;
typedef IMAGE_THUNK_DATA64 * PIMAGE_THUNK_DATA64;

#include "poppack.h"                        // Back to 4 byte packing

typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // PBYTE 
        DWORD Function;             // PDWORD
        DWORD Ordinal;
        DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

导出表

主要是DLL文件使用,因为普通的可执行文件不需要导出函数等,结构:

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;    //未使用,总是为0
    DWORD   TimeDateStamp;      //文件的产生时刻
    WORD    MajorVersion;       //未使用,总是为0
    WORD    MinorVersion;       //未使用,总是为0
    DWORD   Name;                   // 指向文件名的RVA
    DWORD   Base;                   // 导出函数起始序号
    DWORD   NumberOfFunctions;      // 所有导出函数的个数
    DWORD   NumberOfNames;          // 以函数名导出的函数个数
    DWORD   AddressOfFunctions;     // 指针指向导出函数地址表RVA
    DWORD   AddressOfNames;         // 指针指向导出函数名称表RVA
    DWORD   AddressOfNameOrdinals;  // 指针指向导出函数序号表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

可以看到导出表里面最后还有三个表,这三个表可以让我们找到函数真正的地址,在编写PE格式解析器的时候可以用到,AddressOfFunctions 是函数地址表,指向每个函数真正的地址,AddressOfNamesAddressOfNameOrdinals 分别是函数名称表和函数序号表,我们知道DLL文件有两种调用方式,一种是用名字,一种是用序号,通过这两个表可以用来寻找函数在 AddressOfFunctions 表中真正的地址。

nName字段:这个字段是一个RVA值,指向一个定义了模块名称的字符串。这个字符串说明了模块的原始文件名,比如说即使Kernel32.dll文件被改名为Ker.dll,仍然可以从这个字符串中的值得知它被编译时的文件名是Kernel32.dll

重定位表

当PE文件被装载到虚拟内存的另一个地址中的时候,也就是载入时不将默认的值作为基地址载入,链接器登记的地址是错误的,需要我们用重定位表来调整,重定位表在数据目录项的第 6 个结构,结构如下

typedef struct _IMAGE_BASE_RELOCATION {
    DWORD   VirtualAddress; // 重定位数据的开始 RVA 地址
    DWORD   SizeOfBlock;    // 重定位块的长度
//  WORD    TypeOffset[1];  // 重定位项数组
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCTION;

重定位表有许多个,以八个字节的 0 结尾。

一般exe文件没有,dll文件是有的,在dll文件加载不到自己想要的地址的时候,可能会根据重定位表进行dll文件内绝对地址的修改。

https://thunderjie.github.io/2019/03/27/PE%E7%BB%93%E6%9E%84%E8%AF%A6%E8%A7%A3/
https://masterxsec.github.io/2017/05/02/PE%E6%96%87%E4%BB%B6%E7%BB%93%E6%9E%84/

发表评论

textsms
account_circle
email

3rsh1's Blog

PE文件格式
pe文件由四部分组成: dos头 pe文件头 节表 节数据 更详细: dos部分 主要是为了兼容以前的dos系统,可以分为dos mz文件头(IMAGE_DOS_HEADER)和dos块(DOS Stub)组成。pe文件…
扫描二维码继续阅读
2021-03-13