一文读懂PE格式

0x01 前言

PE文件是指windows系统下使用的可执行文件格式,它是微软在unix平台的COFF基础上制作而成的。PE文件一般指32位的可执行文件,也称为PE32。64位的可执行文件称为PE+或者PE32+(不是PE64),是PE32的一种扩展形式。

0x01 简介

要了解PE文件,首先要知道PE格式,那么什么是PE格式呢,既然是一个格式,那肯定是需要遵循的一定的定理。其实PE格式就是各种结构体的结合,这些结构体都定义在在WinNT.h这个头文件中。

PE文件整体结构
一个PE文件大致可分为以下几个部分:

  • DOS部分
  • PE文件头
  • 节区头(节表)
  • 节数据(块数据)
  • 调试信息

从DOS头到节区头是PE头,其下的是PE体。文件中使用偏移(Offset), 内存中使用VA(Virtual Address)来表示位置。

VA指的是进程虚拟内存的绝对地址,RVA(Relative Virtual Address)指从某个基准位置(ImageBase)开始的相对地.址。两者存在以下换算关系:

RVA +ImageBase = VA

PE头内部的信息大多以RVA的形式存在

0x02 PE头

0x02.1 DOS头

DOS头结构体大小为40个字节,其中只需要熟悉两个成员变量:e_magice_lfanew,前者是DOS签名,固定为MZ,取自微软开发人员Mark Zbikowski首字母。后者则是指示NT头的偏移位置(IMAGE_NT_HEADERS),除了这两个成员,其他成员全部用0填充都不会影响程序正常运行。IMAGE_DOS_HEADER定义如下

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;

变量e_lfanew固定偏移位置3C处,从此后到e_lfanew所指向的偏移位置为DOS Stud(中文一般翻译为DOS存根),在win32中未使用。在16位系统中运行便输出一个this programe cannot be urn in DOS mode 就退出了。
如下图框中的内容便是DOS头:
20210511142524

由图可知e_lfanew的值是000000F8,则00000040000000F4的内容便为DOS Stud

0x02.2 PE文件头

PE文件头由PE文件头标志,标准PE头,扩展PE头三部分组成

0x02.2.1 IMAGE_NT_HEADERS

上一节我们有讲到DOS头中的e_lfanew指向IMAGE_NT_HEADERS,
我们先看一下这个结构体在winnt.h中的定义:

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;  //固定为'004550', 字符:"PE"
    IMAGE_FILE_HEADER FileHeader;//标准PE头 => 20字节
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;//扩展PE头 => 32位下224字节(0xE0) 64位下240字节(0xF0)
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS;

由上可知IMAGE_NT_HEADERS定义了3个成员变量,

  • Signature固定为字符PE
  • FileHeader指向一个为IMAGE_FILE_HEADER的结构体;
  • 在32位下OptionalHeader指向一个IMAGE_OPTIONAL_HEADER32的结构体。在64位下,OptionalHeader指向一个IMAGE_OPTIONAL_HEADER64的结构体。

0x02.2.2 IMAGE_FILE_HEADER

我们一个一个分析,先看下IMAGE_FILE_HEADER的定义:

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;

IMAGE_FILE_HEADER里面大概有4个重要的成员变量需要掌握,如果这些变量的值设置不正确,程序便不能正常运行。

  • Machine:每个CPU都有一个唯一的Machine码,兼容32位Intel x86芯片的machine码是14c。其它Machine码可在winnt.h中查看。
  • NumberOfSections: 指示文件中存在的节区数量, 此值是一定要大于0,且当定义的节区数与实际的节区数不一样时,将发生运行错误。
  • SizeOfOptionalHeader:标识IMAGE_NT_HEADERS第三个成员变量OptionalHeader的大小。
  • Characteristics:标识文件属性,是否是dll,是否可执行等信息。

如下图便是FileHeader,
20210511145711

根据图片所示,我们可以得出各值:

Machine=014c; 
NumberOfSections =0004; 	
TimeDateStamp=4ade5203; 		
PointerToSymbolTable=00000000;  
NumberOfSymbols=00000000; 		
SizeOfOptionalHeader=00e0;   
Characteristics=010f;        

0x02.2.2 IMAGE_OPTIONAL_HEADER

扩展PE头在32位和64位系统上大小是不同的,在32位系统上有224个字节,16进制就是0xE0,与上面的SizeOfOptionalHeader也能对上。

IMAGE_OPTIONAL_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;

部分成员变量说明:

  • ImageBase:指出文件被加载到内存应该被优先加载的内存地址,exe,dll文件被装载到用户内存的0~7fffffff中,sys文件被载入内存的80000000~ffffffff中,一般情况下,exe会被装载到00400000,dll文件的ImageBase值为10000000

  • NumberOfRvaAndSizes: 用来指定DataDirectory数组的个数,在winnt.h中IMAGE_NUMBEROF_DIRECTORY_ENTRIES被明确定义为16,但PE loader一般会通过此值来识别DataDirectory的大小,也就是说DataDirectory的长度不一定都是16。

  • DataDirectory是由IMAGE_DATA_DIRECTORY结构体数据组成的,数组中的每一项都有定义,详细如下:

20210511153038

但我们一般只需要关心几个常见的即可,导出表、导入表、资源表、TLS表。详细的我们放到后面再讲。

整个扩展头内容如图所示。
20210511151700

根据图片例子,我们把常见的成员变量值列举出来如下:

Magic=010b
AddressOfEntryPoint=0x0005ec27
BaseOfCode=1000
BaseOfData=8b000
ImageBase=0x00400000
SectionAlignment=1000
FileAlignment=1000
SizeOfImage=dd000
SizeOfHeader=1000
NumberOfRvaAndSizes=10

还需要知道的是,程序的真正入口点 = ImageBase + AddressOfEntryPoint。

0x03.节区头

节区头由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;

节区头的数量由IMAGE_NT_HEADERS结构中的FileHeader.NumberOfSections 字段来指定的。同时也以一个全部为空的IMAGE_SECTION_HEADER结构体作为结束,节区头总是被存放在紧接在PE文件头的地方。
在节区头中,我们一般只需要了解以下几个:

  • Name :非必须以NULL结束,也未限制只能使用ASCII,可放入任何值
  • VirtualSize: 内存中节区所占大小
  • VirtualAddress: 内存中节区起始地址(RVA)
  • SizeOfRawData: 磁盘文件中节区所占大小
  • PointerToRawData:磁盘文件中节区起始位置
  • Charateristics:节区属性。

实例:
20210511162620

如图所示,可知有四个节区,外加一个全为0的节区。

0x04.导入表

先看如何定位导入表起始地址,在IMAGE_OPTIONAL_HEADER头中的最后一个成员变量,我们有提到其是一个包含16个元素的IMAGE_DATA_DIRECTORY数组,其中第2个元素就是导入表的起始位置。

先看IMAGE_DATA_DIRECTORY的定义

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

即我们可以通过IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress访问到导入表的起始地址。

再来看一下定义导入表的结构体:

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;

一个程序导入了多少个库就有多少个IMAGE_IMPORT_DESCRIPTOR结构体,这些结构体组成一个数组,且结构体数组以一个全为NULL的结构体作为结束。所以被称为IMPORT Directory Table.其中比较重要的成员变量如下(以下地址值全为RVA)

  • OriginalFirstThunk:指向INT(导入名称表 、Improt Name Table)的地址,以全NULL结束。
  • Name:库名称字符串的地址
  • FirstThunk: 指向IAT(导入地址表、Import Address Table)的地址,以全NULL结束。

举个例子,随便找个程序,
IMAGE_OPTIONAL_HEADER32.DataDirectory[1].VirtualAddress的值为9C 5A 0F 00 即RVA=F5A9C, 换算成RAW则为F4A9C(换算方法见附)。
我们转到F4A9C的地址查看如下:
20210511193313

OriginalFirstThunk - INT (Import Name Table)
第一个成员变量为OriginalFirstThunk,它是INT的起始地址,换句话说,就是INT是一个包含导入函数信息的结构体指针数组,每个数组的元素都指向一个IMAGE_IMPORT_BY_NAME的结构体,并以全为NULL的元素结束。根据上图我们知道第一个元素的值为F6284(RVA)->换成RAW则为F5284。来到F5284这个地址.如下。由图可知INT数组长度为5。(以就代表着从这个库文件里面导入了5个函数)

20210511194111
第一个值为F6648->RAW为F5648。来这这个地址。
20210511194234
要看懂这个结构得先看下IMAGE_IMPORT_BY_NAME的定义

typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;
    CHAR   Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

前两个字节是库中函数的固有编号,后面的则是一个字符数组,以00结束。所以这个导入的函数则是ColorHLSToRGB.

Name
根据IMAGE_IMPORT_DESCRIPTOR的结构可知,第四个成员则为Name,其值为F666A,换成RAW为F566A,我们转到这个地址看下,可知其导入的是SHLWAPI.dll
20210511193438

另外,我们也可以通过工具验证确实从SHLWAPI.dll导入了5个函数。
20210511194918

FirstThunk- IAT (Import Address Table)

IMAGE_IMPORT_DESCRIPTOR结构体可知其最后一个成员为FirstThunk。根据上面的图可知,第一数组元素的FirstThunk值为C55F4, RAW为:C45F4。我们来到这个地址处:
20210511195756
第一个数组元素值为F6648.如上便是IAT数组区域,对应于SHLWAPI.dll,数组长度也刚好是5。转为RAW则为F5648。来这个地址,发现他们指向同一个函数。
20210511200218

既然指向同一个地址,为啥需要两个去索引,这是因为需要区分PE加载前还是加载后。如果是加载前,那个IAT跟INT一样,都可以找到依赖的函数名称,如果是加载后。也就是在内存中的话, 那么IAT表保存的就是函数的地址。

PELoader把导入函数输入至IAT的步骤

  • 读取IID的Name成员,获取库名称字符串(eg:kernel32.dll)
  • 装载相应库: LoadLibrary("kernel32.dll")
  • 读取IID的OriginalFirstThunk成员,获取INT地址
  • 逐一读取INT中数组的值,获取相应IMAGE_IMPORT_BY_NAME地址(RVA)
  • 使用IMAGE_IMPORT_BY_NAME的Hint(ordinal)或Name项,获取相应函数的起始地址:GetProcAddress("GetCurrentThreadld")
  • 读取IID的FirstThunk(IAT)成员,获得IAT地址
  • 将上面获得的函数地址输入相应IAT数组值
  • 重复以上步骤4~7,知道INT结束(遇到NULL)

0x5导出表

导出表由结构体IMAGE_EXPORT_DIRECTORY定义。在PE文件中,IMAGE_EXPORT_DIRECTORY结构体数组的起始位置由IMAGE_OPTIONAL_HEADER32.DataDirectory[0].VirtualAddress给出。
我们先看下IMAGE_EXPORT_DIRECTORY的定义:

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;					// 指针指向该导出表文件名字符串
    DWORD   Base;					// 导出函数起始序号
    DWORD   NumberOfFunctions;		// 实际导出函数的个数
    DWORD   NumberOfNames;			// 以函数名字导出的函数个数
    DWORD   AddressOfFunctions;     // 指针指向导出函数地址表RVA
    DWORD   AddressOfNames;         // 指针指向导出函数名称表RVA
    DWORD   AddressOfNameOrdinals;  // 指针指向导出函数序号表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

函数查找过程 - GetProAddress工作原理

  • 利用AddressOfNames成员转到“函数名称数组”
  • “函数名称数组”中存储字符串地址。通过比较字符串,查找指定的函数名称(此时数组的索引称为name_index)
  • 利用AddressOfNameOrdinals成员,转到orinal数组
  • 在orinal数组中通过name_index查找相应的值
  • 利用AddressOfFunction成员转到“函数地址数组”
  • 在“函数地址数组”中将刚刚求得的ordinal用作数组索引,获得指定的函数起始地址。

有了导入表的基础,则理解导出表就很简单了,这里就不再举例了。

0x6 附

1.RVA to RAW

转换方法如下:
1).查找RVA所在节区
2).使用如下公司计算。
RAW - PointerToRawData = RVA-VirtualAddress

RAW = RVA-VirtualAddress + PointerToRawData

原理:
在内存中或者在文件中,一个地址相对与该地址所在的区段的偏移大小是固定的。换句话说,它们距离这一个节区开头的距离都是相同的。所以我们可以先算出相对于其所在区段的偏移大小,再加上对应区段的基地址。
举个例子,如图,假如我们要算 RVA a7bd0的raw地址是多少?
20210511173455
1、首先我们确定a7bd0在哪个Virtual Address中(因为是rva,所以当成内存地址看)。可知其在.rdata段,
2、rdata段的基地址是8b000,a7bd0相对首地址相差a7bd0-8b000=1cbd0.
3、再加上rdata段在文件中的首地址8b000(Raw Address).1cbd0+8b000=a7bd0
4、所以rva的a7bd0在文件中的偏移是a7bd0。

posted @ 2021-05-16 17:34  Hslim  阅读(971)  评论(3编辑  收藏  举报