一文读懂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_magic
与e_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头:
由图可知e_lfanew
的值是000000F8
,则00000040
到000000F4
的内容便为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
,
根据图片所示,我们可以得出各值:
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
结构体数据组成的,数组中的每一项都有定义,详细如下:
但我们一般只需要关心几个常见的即可,导出表、导入表、资源表、TLS表。详细的我们放到后面再讲。
整个扩展头内容如图所示。
根据图片例子,我们把常见的成员变量值列举出来如下:
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
:节区属性。
实例:
如图所示,可知有四个节区,外加一个全为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的地址查看如下:
OriginalFirstThunk - INT (Import Name Table)
第一个成员变量为OriginalFirstThunk
,它是INT的起始地址,换句话说,就是INT是一个包含导入函数信息的结构体指针数组,每个数组的元素都指向一个IMAGE_IMPORT_BY_NAME
的结构体,并以全为NULL的元素结束。根据上图我们知道第一个元素的值为F6284(RVA)->换成RAW则为F5284。来到F5284这个地址.如下。由图可知INT数组长度为5。(以就代表着从这个库文件里面导入了5个函数)
第一个值为F6648->RAW为F5648。来这这个地址。
要看懂这个结构得先看下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
另外,我们也可以通过工具验证确实从SHLWAPI.dll导入了5个函数。
FirstThunk- IAT (Import Address Table)
由IMAGE_IMPORT_DESCRIPTOR
结构体可知其最后一个成员为FirstThunk
。根据上面的图可知,第一数组元素的FirstThunk
值为C55F4, RAW为:C45F4。我们来到这个地址处:
第一个数组元素值为F6648.如上便是IAT数组区域,对应于SHLWAPI.dll,数组长度也刚好是5。转为RAW则为F5648。来这个地址,发现他们指向同一个函数。
既然指向同一个地址,为啥需要两个去索引,这是因为需要区分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地址是多少?
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。