PE 文件
可移植可执行 (PE) 文件格式与 Windows 操作系统上能实现代码执行的文件类型所使用,常见的拓展名有 exe、dll、sys 等。PE 格式描述了文件必须遵循的标准结构,以便定位其内容并在执行的各个阶段使用其信息。了解这种格式对于恶意软件分析人员来说尤为重要,因为检查可执行文件的 PE 内容可以提供有关文件的大量信息,可能包括文件的作用。对于我们开发恶意软件的攻击者也同样重要,因为一些免疫检测的技术需要对 PE 文件十分熟悉。
PE 文件结构
PE 格式包含多种文件类型,在最高层次可以分为 COFF 文件与 PE 文件。
通用目标文件格式 (COFF) 文件,也称为对象文件,具有 obj 文件扩展名。它由 Windows 兼容的编译器生成,将源代码转换为机器代码。该文件类型本身不可执行,但可以作为输入传递给链接器,链接器从一个或多个对象文件创建可执行文件。
PE 文件,也称为可执行文件或映像文件,这是链接器生成的文件,包含可执行代码和运行时映射到内存的数据。映像文件还包含另外两种类型,第一种是动态链接库文件,具有 dll 扩展名,包含可以被多个程序同时导入和使用的代码和数据。 尽管 DLL 文件被归类为可执行文件,但它不能独立地直接运行。第二种是可执行文件,具有 exe 扩展名,与 DLL 不同的是它可以独立运行。
PE 格式以许多文件头开始,文件头是位于数据块起始的附加数据,通常包含有关数据块的信息,例如数据块的大小、元素在数据块中的位置,以及其他属性。在 PE 格式中,文件头数据由结构体组织和定义,在 C 和 C 相关的编程语言中,结构体是由不同的元素组成的数据类型,这些元素本身可能有多种数据类型。这些元素由不同的变量名称引用,并按顺序存储在连续的内存块中。PE 格式中使用的结构在名为 winnt.h 的头文件中定义,该文件可以作为 Windows SDK 的一部分下载。
DOS 头与 DOS Stub
PE 文件的第一个头文件总是以 0x4D5A (MZ) 这 2 个字节为前缀,这 2 个字节表示 DOS 头签名,用于确认正在解析或检查的文件是有效的 PE 文件。DOS头是一个数据结构体,定义如下:
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic; // MZ 签名
WORD e_cblp;
WORD e_cp;
WORD e_crlc;
WORD e_cparhdr;
WORD e_minalloc;
WORD e_maxalloc;
WORD e_ss;
WORD e_sp;
WORD e_csum;
WORD e_ip;
WORD e_cs;
WORD e_lfarlc;
WORD e_ovno;
WORD e_res[4];
WORD e_oemid;
WORD e_oeminfo;
WORD e_res2[10];
LONG e_lfanew; // NT 头的偏移
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
其中,最重要的分别是 e_magic 与 e_lfanew,分别是小端格式的 ASCII 字符 MZ,以及从映像文件起始到 NT 头的偏移。e_lfanew 总是位于 0x3c 处,占 4 个字节。
使用 PE Bear 打开我们上一小节用 C++ 编写的 MessageBox 程序,我们发现最开始确实是 4D 5A,而在 0x3c 处,值为 0xF0。
而 DOS Stub 位于 0x40 处,DOS Stub 紧接着 DOS 头,因为 DOS 头占用 0x40 字节 (可以根据上文给出的结构体计算出总大小)。DOS Stub 包含了报错信息:该程序不能再 DOS 模式中运行。
NT 头
NT 头十分重要,其结构体如下,包含了签名、File 头、Optional 头这 3 个元素,NT 头包含了大量有关 PE 的信息。
//32 位
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
//64 位
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;
Signature
在上文,我们知道了 NT 头在 0xF0 偏移处,于是我们定位到 0xF0 处,发现签名元素 0x50450000,是填充了 2 个零字节的字符串 PE,因为该签名占用 4 字节。
File 头
File 头的结构体如下:
typedef struct _IMAGE_FILE_HEADER {
WORD Machine
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
比较重要的元素有 Machine,NumberOfSections,TimeDateStamp,SizeOfOptionalHeader,以及 Characteristics。
Machine 表示该 PE 文件所期望运行在的 CPU 架构,我们能看到该应用期望在 AMD64 架构 CPU 的系统上运行。
NumberOfSection 表示当前 PE 文件所包含的 PE 节数量,该程序有 6 个。
TimeDataStamp 表示文件创建的时间,能看到是 2023 年 6 月 19 日。
SizeOfOptionalHeader 表示 Optional 头的尺寸,这里是 240。
Characteristics 表示该二进制文件的特定属性,例如是 DLL 还是可执行的文件,这里显然是可执行的 exe 文件。
Optional 头
尽管 Optional 头名称为 Optional,但却非常重要,只是有些文件类型 (例如对象文件) 没有它。让我们查看它的结构体:
//32 位
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
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;
//64位
typedef struct _IMAGE_OPTIONAL_HEADER64 {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
ULONGLONG ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
ULONGLONG SizeOfStackReserve;
ULONGLONG SizeOfStackCommit;
ULONGLONG SizeOfHeapReserve;
ULONGLONG SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER64, *PIMAGE_OPTIONAL_HEADER64;
以及在 PE Bear 中查看 Optional Header
一些比较重要的元素如下:
Magic 描述了该映像文件是 32 位还是 64 位的,当前是 64 位的。
AddressOfEntryPoint 表示程序入口的相对虚拟内存地址 (RVA)。RVA 指的是当 PE 在内存中,距离开头(PE 的第一个字节) 的偏移。程序入口通常是 main 函数,默认在 .text 节。当前是 0x12D0,而当 PE 存在于磁盘中,入口的偏移是 6D0。这是因为 PE 文件在内存中和在磁盘中的展现出的结构有所不同。
当 PE 在磁盘以及内存中时,结构示意图如下所示:
BaseOfCode 表示当 PE 在内存中时,.text 的偏移,这里是 0x1000,而在磁盘中 .text 节的偏移为 0x400。
SizeOfCode 为 .text 节的大小
ImageBase 为当 PE 文件被载入内存时,偏好的基址,但实际不一定是该值。
SectionAlignment 是当 PE 在内存中时,校准系数的值,是 0x1000。RVA 表示在内存中距离 PE 文件开头的偏移 (虚拟地址 VA=基址+相对虚拟地址RVA),而当 PE 文件分别处于内存和磁盘中时,RVA 与偏移 (Offset) 的差异是因为 SectionAlighment 以及 FileAlignment。
FileAlignment 是当PE 文件在磁盘时,校准系数的值,是 0x200。
SizeOfImage 表示当 PE 在内存中时,整个映像取整 (校准系数 0x1000 的整数倍)后的尺寸
SizeOfHeaders 表示取整后的所有头尺寸,是校准系数 0x200 的整数倍
CheckSum:校验文件完整性的数据
NumberOfRvaAndSize 是 DataDirectory 中条目的数量。
DataDirectory 是一组 IMAGE_DATA_DIRECTORY 结构体类型的数列,每个条目有 2 个属性,VirtualAddress (但实际上是 RVA) 和 Size。这些目录包含许多在运行时可能需要的重要数据结构体条目以及函数,以方便定位到它们。
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
虽然有 NumberOfRvaAndSize 值为 16,但目前实际只有 15 个目录条目。
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7 // Architecture Specific Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT 12 // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 // Delay Load Import Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14 // COM Runtime descriptor
这些目录中,比较重要的有导出目录、导入目录、导入地址表。导出目录包含有关从可执行文件导出的函数和变量的信息。它包含导出的函数和变量的地址,其他可执行文件可以使用这些地址来访问函数和数据。导出目录通常存在于导出函数的 DLL 中,例如 user32.dll 导出了 MessageBoxW 函数。导入目录包含有关解析可执行文件从其他模块导入的符号所需的不同结构体的信息,此信息有助于可执行文件解析导入函数的地址。导入地址表 (IAT) 是 PE 中的一种数据结构,包含从其他可执行文件导入的函数的地址信息,这些地址用于访问其他可执行文件中的函数和数据。例如当前文件 从 user32.dll 导入了 MessageBoxW 函数。
PE 节
PE 节包含用于创建可执行程序的代码和数据,每个 PE 节都有一个唯一的名称,并且通常包含可执行代码、数据或资源信息。 PE 节的数量没有固定值,因为不同的编译器可以根据配置添加、删除或合并节,有些部分也可以在之后手动添加,因此 PE 节是动态的,并且 IMAGE_FILE_HEADER.NumberOfSections 有助于确定该数量。
节表紧接着 Optional 头。那么,我们怎么定位到节表呢?首先,我们在 0x3C 处找到 NT 头的偏移,当前程序是 0xF0。
NT 头的结构体如下,DWORD 占用 4 个字节,因此 File 头的偏移位于 0xF0+4=0xF4。
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
File 头的结构体如下,根据数据类型计算总尺寸:2+2+4+4+4+2+2=0x14。
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
加上目前的偏移 0xF4,我们得到 Optional 头的偏移 0x108。
根据 File 头中的 Optional 头的尺寸 0xF0,最终得到节表的偏移为 0x108+0xF0=0x1F8。
用公式计算的话,偏移如下:
Offset = PE[PE[0x3c]] + 0x18 + PE[PE[0x3c]+0x14]
节表里有多个条目,当前是 6 个,查看其结构体:
#define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME
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;
Name 表示节名,通常以 . 开头,但并非必要,非常规的节名可能引起恶意软件分析师的怀疑。VirtualSize 是一个 union 类型数据,考虑到当前文件是一个映像文件,因此表示当载入至内存时节的尺寸。节头包含了文件在内存和磁盘时的信息,VirtualAddress 表示该节的 RVA。PointerToRawData 表示当文件在磁盘时该节的偏移,当前为 0x400,是 0x200 的 2 倍。SizeOfRawData 表示的则是在磁盘时该节的尺寸,这里是 0xE00。对于 .text 节,如果 VirtualSize 的尺寸远大于 SizeOfRawData,意味着可能被 packer 打包过了。
这些节都是紧挨着一起的。
由于尺寸需要是校准系数的整数倍,原始数据之后被 0x00 填充。
Characteristics 表示节的属性,可以查看微软文档 https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#section-flags。
接下来,我们分别讨论一下一些常见节的作用:
text 包含了可执行的代码,可读可执行但不可写。
data 包含了初始化的变量,可能为全局变量和静态变量,因为这些变量可以在运行时更改,因此该节可读可写。
bss 包含了未初始化的变量,可读可写。
rdata 包含了可读的初始化数据,常量,可能为全局常量或静态变量,不可写。
edata 包含了允许其他模块导入 PE 映像导出的符号 (例如函数名、变量名等) 的信息。为了使用导入的符号,导入它的 PE 映像必须能够解析其地址。
idata 包含允许映像在运行时解析从其他模块导入到地址的符号的信息。
reloc 包含了给程序分配地址的信息。
rsrc 包含了资源信息,例如图标、字符串等。
导入与导出
接下来,我们着重讨论一下 PE 文件的导入与导出。用 PE Bear 载入一非托管 DLL,例如 user32.dll。在导出部分,我们能看到该 DLL 导出了大量的函数供其他 PE 文件所用。
导出目录的结构体如下:
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA: Export Address Table
DWORD AddressOfNames; // RVA: Export Name Pointer Table
DWORD AddressOfNameOrdinals; // RVA: Ordinal Table
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
该表的主要作用是存储其他导出相关表的位置。具体来说,AddressOfFunctions 包含导出地址表 (EAT),AddressOfNames 包含导出名称指针表 (ENPT),AddressOfNameOrdinals 包含序数表。为了能理解这些表,我们需要知道函数是如何被引用的,通过名称或者序数。
假设是通过序数来导入,序数是 EAT(通过 AddressOfFunctions 定位到) 的索引,每个条目包含函数的 RVA。
如果是使用名称来导入,过程会更加复杂一些。在 ENPT (通过 AddressOfNames 定位到) 中,每个条目都有一个索引,当在索引 i 处找到想要的函数名称的 RVA (进而在导出名称表中得到函数的名称),在序数表的索引 i 处,能得到另一个索引,记为 j。而在 EAT 的索引 j 处,最终得到了函数的 RVA。
//通过序数
//在EAT的第i处找到想要的函数
RVA = EAT[i]
//通过名称
//在ENPT的第i处找到想要的函数名称
RVA = EAT[ OT[i] ] //OT[i]=j
我们理解了导出的步骤,接下来讨论一下导入。继续以我们上个小节编写的 MessageBox C++ 程序为例。在 PE Bear 中查看导入部分信息:
图中部分是导入目录,每个条目是导入的 DLL,条目的结构体如下:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; // RVA: Import Lookup Table (ILT)
} DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk; // RVA: Import Address Table (IAT)
} IMAGE_IMPORT_DESCRIPTOR;
Name 是 DLL 名称字符串的 RVA,OriginalFirstThunk 指向特定 DLL 的 ILT (导入查询表),FirstThunk 指向 IAT。在导入的函数名被解析为地址之前,这两个表具有相同的内容,并且由每个导入函数的条目组成。当这些导入函数的地址被解析后,IAT 条目将被解析的地址覆盖。
IAT 与 ILT 的结构体如下:
typedef struct _IMAGE_THUNK_DATA64 {
union {
ULONGLONG ForwarderString;
ULONGLONG Function;
ULONGLONG Ordinal;
ULONGLONG AddressOfData;
} u1;
} IMAGE_THUNK_DATA64, *PIMAGE_THUNK_DATA64;
正如 union 数据类型所示,IMAGE_THUNK_DATA64 中的 ULONGLONG 可以用不同的方式解释。 对于可执行文件,有 2 种可能的解释,对应于函数名可以引用的两种不同方式。第 1 个是序数, 第 2 个最终可以得到函数名称,因为 AddressOfData,是 Hint/Name 表中 IMAGE_IMPORT_BY_NAME 结构体的 RVA。IMAGE_IMPORT_BY_NAME 结构体如下所示:
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
元素 Hint 是 ENPT 的索引,Name 是包含导入函数名称的字符串,被用于确认 Hint 位于 ENPT 中的正确位置,或者寻找正确的位置。因为 ENPT 中的条目会让我们得到序数,并最终得到函数的 RVA。当 RVA 被找到,IAT 表中的条目将被重写。
加载器检查 IAT 条目,并通过检查条目的最高位是否被设置来确定是按序数导入还是按名称导入。如果导入按序数进行,只需通过检查相应 DLL 的导出数据将序数解析为地址,并用该地址覆盖 IAT 条目。如果导入是按名称完成的,则首先检查条目指向的 IMAGE_IMPORT_BY_NAME 结构,然后使用 Hint 和函数名称将函数解析为地址并覆盖 IAT 条目。
查看 IAT,因为序数为空,因此是通过名称导入的。
总结
总结一下在 PE 文件中一些重要属性的位置,并根据自己需要进一步增加与完善表格的条目。
名称 | 偏移值 |
属性值 |
备注 |
PE 头 |
0 |
0x4D5A |
值固定 |
NT 头偏移 |
0x3C |
||
NT 头 |
PE [0x3C] |
0x50450000 |
值固定 |
File 头 |
PE[ PE[0x3C] ] + 0x4 |
||
节数目 |
PE[ PE[0x3C] ] + 0x6 | ||
Optional 头尺寸 |
PE[ PE[0x3C] ] + 0x14 | ||
Optional 头 |
PE[ PE[0x3C] ] + 0x18 | ||
程序入口 |
PE[ PE[0x3C] ] + 0x28 | ||
.text RVA |
PE[ PE[0x3C] ] + 0x2C | ||
映像偏好基址 |
PE[ PE[0x3C] ] + 0x34 | ||
导出目录 x86 |
PE[ PE[0x3C] ] + 0x18 + 0x60 |
32 位 |
|
导出目录 x64 |
PE[ PE[0x3C] ] + 0x18 + 0x70 |
64 位 |
|
函数名称数目 x86 |
PE[ PE[0x3C] ] + 0x78 + 0x18 |
32 位 | |
函数名称数目 x64 | PE[ PE[0x3C] ] + 0x88 + 0x18 | 64 位 | |
EAT x86 |
PE[ PE[0x3C] ] + 0x78 + 0x1C |
32 位 | |
EAT x64 | PE[ PE[0x3C] ] + 0x88 + 0x18 | 64 位 | |
ENPT x86 |
PE[ PE[0x3C] ] + 0x78 + 0x20 |
32 位 | |
ENPT x64 | PE[ PE[0x3C] ] + 0x88 + 0x18 | 64 位 | |
序数表 x86 |
PE[ PE[0x3C] ] + 0x78 + 0x24 |
32 位 | |
序数表 x64 | PE[ PE[0x3C] ] + 0x88 + 0x18 | 64 位 | |
节表 | PE[ |