PE文件结构与程序装载是掌握Windows逆向、加壳、免杀等技术的基础,本文详细记录了PE文件的基本结构,用编辑器对文件结构进行分析,并介绍程序装载的相关概念和基本过程。
参考书籍:《逆向工程核心原理》《程序员的自我修养》
这里主要介绍Windows中PE文件的基本结构,其基本结构由PE头和多个节区组成,如下图所示:
下面逐一说明各个部分的基本特征和作用:
微软在创建PE文件格式时,DOS文件正在广泛使用,为兼容DOS文件,在PE头部添加了DOS头部分,DOS头实际上是 IMAGE_DOS_HEADER
结构体,大小为64字节,定义如下所示:
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;
该结构体中有2个重要成员:
WORD e_magic
:DOS签名,固定为4D5A (“MZ”),位于DOS头的第一个部分LONG e_lfanew
:标识NT头的偏移,位于DOS头的最后一个部分可选项,大小不固定,由代码和数据组成。在64/32位Windows系统中会被识别为PE格式,此时会跨过DOS存根部分;在DOS环境中系统不能识别PE文件格式,因此按照DOS文件,此时DOS存根部分的代码会被执行。
实际为 IMAGE_NT_HEADERS
结构体,大小为248个字节,定义如下所示:
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // PE Signature (“PE”00)
IMAGE_FILE_HEADER FileHeader; // PE File Header
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // PE Optional Header
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
该结构体中成员有3个,介绍如下:
(1) DWORD Signature
:PE签名,为0x50450000 (“PE”00)
(2) IMAGE_FILE_HEADER FileHeader
:该结构体大小为20个字节,定义如下所示:
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;
IMAGE_FILE_HEADER
结构体中又包括4种重要重要成员,如下所示:
① WORD Machine
:CPU标识,每种CPU对应唯一的Machine码,常见的如:Intel386 —> 0x014c,AMD64 —> 0x8664
② WORD NumberOfSections
:节区数量,PE文件按照节区的属性划分节区
③ WORD SizeOfOptionalHeader
:标识IMAGE_OPTIONAL_HEADER32
结构体的长度
④ WORD Characteristics
:文件属性,不同属性按位向或进行组合,常见如:EXE文件 —> 0x0002,DLL文件 —> 0x2000,具体如下:
#define IMAGE_FILE_RELOCS_STRIPPED 0x0001 // Relocation info stripped from file.
#define IMAGE_FILE_EXECUTABLE_IMAGE 0x0002 // File is executable (i.e. no unresolved externel references).
#define IMAGE_FILE_LINE_NUMS_STRIPPED 0x0004 // Line nunbers stripped from file.
#define IMAGE_FILE_LOCAL_SYMS_STRIPPED 0x0008 // Local symbols stripped from file.
#define IMAGE_FILE_AGGRESIVE_WS_TRIM 0x0010 // Agressively trim working set
#define IMAGE_FILE_LARGE_ADDRESS_AWARE 0x0020 // App can handle >2gb addresses
#define IMAGE_FILE_BYTES_REVERSED_LO 0x0080 // Bytes of machine word are reversed.
#define IMAGE_FILE_32BIT_MACHINE 0x0100 // 32 bit word machine.
#define IMAGE_FILE_DEBUG_STRIPPED 0x0200 // Debugging info stripped from file in .DBG file
#define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 0x0400 // If Image is on removable media, copy and run from the swap file.
#define IMAGE_FILE_NET_RUN_FROM_SWAP 0x0800 // If Image is on Net, copy and run from the swap file.
#define IMAGE_FILE_SYSTEM 0x1000 // System File.
#define IMAGE_FILE_DLL 0x2000 // File is a DLL.
#define IMAGE_FILE_UP_SYSTEM_ONLY 0x4000 // File should only be run on a UP machine
#define IMAGE_FILE_BYTES_REVERSED_HI 0x8000 // Bytes of machine word are reversed.
(3) IMAGE_OPTIONAL_HEADER32 OptionalHeader
或 IMAGE_OPTIONAL_HEADER64 OptionalHeader
:这是PE头中最大的一个结构体,标识了程序入口点、装载地址等信息,结构体定义如下所示:
typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields.
//
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
//
// 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;
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;
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;
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
其中比较重要的成员有6个,如下所示:
① WORD Magic
:当镜像可选头为IMAGE_OPTIONAL_HEADER32
结构体时,Magic码为0x10B,镜像可选头为IMAGE_OPTIONAL_HEADER64
时,Magic码为0x20B
② DWORD AddressOfEntryPoint
:程序入口点 (EP) 的RVA值,标识程序运行代码的起始地址
③ DWORD ImageBase
:PE文件被加载到虚拟内存时的优先装载地址,一般而言使用 VB/VC++/Delphi 等开发工具编译的32位的EXE文件,其ImageBase值为0x400000,DLL文件的ImageBase值为0x10000000,这些值可以自己指定。程序载入内存后,EP的值为:ImageBase+AddressOfEntryPoint
④ DWORD SectionAlignment
/ DWORD FileAlignment
:SectionAlignment标识了PE文件节区在内存中的最小单位 (页),FileAlignment标识了节区在磁盘文件中的最小单位 (簇),PE文件在内存或磁盘中节区大小为FileAlignment或SectionAlignment值的整数倍
⑤ DWORD SizeOfImage
:PE文件在虚拟内存中所占空间的大小,一般文件在磁盘中的大小和加载到内存空间后大小是不同的
⑥ DWORD SizeOfHeaders
:PE头的大小,该值必须是FileAlignment的整数倍
⑦ IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]
:由DataDirectory结构体组成的数组,每项都有定义的值,如下所示:
// Directory Entries
#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_COPYRIGHT 7 // Copyright Directory (X86 usage)
#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
其中有2个非常重要的成员记录了导出表和导入表所在的地址,相关内容在IAT与EAT章节会详细介绍:
DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]
:导出表的RVA和大小DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]
:导入表的RVA和大小PE文件的节区按照属性划分,节区头定义了各节区的属性和访问权限,基本可以分为三类 (这是根据可执行文件在内存中装载分段来划分的,具体下面会介绍):
.text
,.data
,BSS段 .bss
.rodata
节区头是由IMAGE_SECTION_HEADER
构成的数组,结构体定义如下所示:
#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;
每个结构体对应一个节区,其中重要的成员有5个,如下所示:
(1) DWORD VirtualSize
:内存中节区所占大小
(2) DWORD VirtualAddress
:内存中节区的起始地址 (RVA)
(3) DWORD SizeOfRawData
:磁盘文件中节区所占大小
(4) DWORD PointerToRawData
:磁盘文件中节区的起始地址
(5) DWORD Characteristics
:节区属性
其中,VirtualAddress和PointerToRawData为0,RVA和磁盘文件中节区起始地址受SectionAlignment和FileAlignment影响,要和其规定的最小单位对齐,所以这两个值没有意义。VirtualSize和SizeOfRawData一般不同。节区属性标识了节区的可执行及可读写的属性,由以下两个部分OR计算组合而成:
#define IMAGE_SCN_CNT_CODE 0x00000020 // Section contains code.
#define IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 // Section contains initialized data.
#define IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080 // Section contains uninitialized data.
#define IMAGE_SCN_MEM_EXECUTE 0x20000000 // Section is executable.
#define IMAGE_SCN_MEM_READ 0x40000000 // Section is readable.
#define IMAGE_SCN_MEM_WRITE 0x80000000 // Section is writeable.
PE体由多个节区组成,节区的命名通常以.
作为开始,代表系统保留命名,常见的节区及作用如下表格所示:
节区 | 作用 | 属性 |
---|---|---|
.text | 代码段,存放程序代码 | 可执行,可读 |
.data | 数据段,存放初始化了的全局静态变量和局部静态变量 | 不可执行,可读写 |
.rdata | 只读数据段,存放只读变量和字符串常量 | 不可执行,只读 |
.bss | BSS段,存放未初始化的全局变量和局部静态变量 | 不可执行,只读 |
.pdata | 异常表段,存放异常处理程序相关的信息 | 不可执行,可读写 |
.rsrc | 资源段,存放图标,菜单,位图等资源 | 不可执行,只读 |
.reloc | 存放可执行文件的基址重定位,通常仅DLL文件须要 | 不可执行,可读写 |
.debug | 调试信息段,存放调试信息 | 不可执行,可读写 |
.init / .fini | 程序初始化与结束代码段 | 可执行,可读 |
用Hex Editor打开calc.exe (64位),与PE文件结构对应关系如下图所示:
上图基本信息已经标识出,其中注意几点:
为了使程序运行时地址空间隔离、解决程序运行地址不确定的问题,程序使用的地址实际上是虚拟地址 (Virtual Address, VA),在程序装载到物理内存时,是通过某些映射方法,将虚拟地址转换成实际的物理地址。
一个程序能看到的地址空间取决于CPU的地址总线宽度,比如32位CPU其虚拟地址空间为4GB,就好像整个程序占有整个内存空间一样。每个进程都有自己的虚拟地址空间,且每个进程只能访问自己的虚拟地址空间。注意程序 (PE文件) 本身是静态的概念,进程是动态的概念,进程是程序运行时的一个过程。
虚拟内存中的地址为虚拟地址 (Virtual Address, VA),而PE头中的地址信息大多以相对虚拟地址 (Relative Virtual Address, RVA) 的形式存在,原因在于当某些PE文件 (主要是DLL) 加载到进程虚拟空间的时,其起始地址优先加载到基地址ImageBase,但该位置很可能已经加载了其他PE文件 (DLL),此时就必须通过重定位将其加载到其他位置,使用相对地址RVA便于重定位的实现,RVA是相对于基地址而言的,如果不考虑对齐地址的影响,则 VA = RVA + ImageBase,重定位只需要改变基地址ImageBase就可以实现,无需修改程序中的RVA,程序就能正常运行。
需要区分操作系统对磁盘和内存操作的基本单位,区分以下几种关系:
注意:现在普遍使用的固态硬盘读写数据时不再以扇区为基本单位,而是以页为基本单位,页大小一般为4KB,这要与内存页的概念区分开。
PE文件从磁盘装载到物理内存启动运行,分为三个基本过程:
(1) 创建一个独立的虚拟地址空间
执行PE文件时,操作系统先创建进程,从操作系统角度看,一个进程最关键的特征是其拥有独立的虚拟地址空间,使得其有别于其他的进程。创建虚拟地址空间实际上是分配一个页目录,页目录用于记录虚拟地址空间到物理内存的映射关系。
(2) 读取PE头信息,建立虚拟空间与PE文件的映射关系
然后读取PE头信息,建立虚拟空间与PE文件的映射关系,每个节区都要完成磁盘地址 (RAW) 到虚拟内存地址 (RVA) 的映射。
(3) 把EIP的值设置为EP,启动运行
EP的值为ImageBase+AddressOfEntryPoint,程序启动运行,刚开始运行时操作系统只是建立了程序到虚拟空间的映射关系,指令和数据并没有装入内存,运行时采用 动态装载 的策略,将虚拟内存空间按页进行分割 (“页”是装载和操作的基本单位),程序运行时只将常用的代码页和数据页装载到内存中,其余不常用的页的留在磁盘,待需要时再进行动态装载。
可以看出,PE文件从磁盘装载到物理内存中间经过一层虚拟内存空间,装载中的映射关系如下图所示:
注意到上图中每个节区下方有一段空间内容为NULL,这是由于存储空间大小要和其基本操作单位的倍数对齐,不足的空间补0处理。图中假设磁盘操作的基本单位是1024个字节 (FileAlignment=0x400),内存操作的基本单位是4096个字节 (SectionAlignment=0x1000),这样就会导致PE文件从磁盘映射到内存中后,其大小发生改变。
考虑到对齐字节数的偏差,从RAW到VA的映射遵循公式:RAW = RVA - (VirtualAddress - PointerToRawData),即符号在磁盘空间的地址等于其相对虚拟地址减去其在虚拟内存空间中所在节区的起始地址与在磁盘文件中所在节区的起始地址的差值,有些拗口,但从PE文件中的地址 (RVA) 到RAW地址 (用二进制编辑器静态打开PE文件时看到的地址) 映射必须要借助这个公式,很重要。
MMU将多个进程的节区装载到内存时,为了节省内存空间、提高使用效率,会按照属性 (执行及读写权限) 对内存空间进行划分,多个具有同一属性的节区会被分到一起,作为连续的页进行管理。比如.rdata和.bss属性都是不可执行、只读,这样将多个进程的这两种节区统一划分到一整块内存空间管理,避免将单个进程或单一节区划分到内存时,由于页地址对齐造成的空间浪费。
导入地址表 (Import Address Table, IAT) 与导出地址表 (Import Address Table, IAT) 是PE文件中非常重要的部分,了解IAT与EAT首先要从导入表与导出表开始介绍:
当一个PE文件将一些函数和变量提供给其他PE文件使用时,这种行为即为符号导出,比如DLL文件将符号导出给EXE文件使用。需要导出的符号统一保存在 导出表 (Export Table) 结构中,这个结构实际上是 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 from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
该结构体不在PE头而在PE体中,但其位置信息 (RVA) 记录在了PE头OptionalHeader结构的IMAGE_DATA_DIRECTORY DataDirectory[0]
部分。导出表最后的3个成员指向的是3个数组,这3个数组是导出表中最重要的结构,下面对其进行介绍:
(1) DWORD AddressOfFunctions
:指向 导出地址表 (Export Address Table, EAT) 的起始地址,表 (数组) 中每个元素存放导出函数的RVA,元素个数即导出函数个数为NumberOfFunctions
(2) DWORD AddressOfNames
:指向 符号名表 (Name Table) 的起始地址,表 (数组) 中每个元素存放导出函数的名字,元素个数即导出函数中有名字的个数为NumberOfNames
(3) DWORD AddressOfNameOrdinals
:指向 名字序号对应表 (Name-Ordinal Table) 的起始地址,表 (数组) 中每个元素存放函数名对应的序号。其实序号是DOS时代的产物,受限于当时的硬件条件,将函数名全部载入内存是不现实的,因此采取将函数对应序号,利用序号将函数导出的方法,一个函数的序号值为其在EAT中的数组下标加上Base值 (IMAGE_EXPORT_DIRECTORY
中的的Base,缺省值为1)。为了保持兼容,每个导出函数必须有一个对应的序号值,但是可以没有函数名
EXE文件没有导出表,结构体DataDirectory[0]位置为0,在前边章节calc.exe二进制视图中已经标识出。这里以user32.dll为例,用Hex Editor标识出导出表位置以及EAT、符号名表和名字序号对应表,如下图所示:
当一个PE文件使用到了来自其他PE文件的函数或者变量,这种行为即为符号导入,比如EXE文件使用到来自DLL文件的函数。需要导入的符号以及所在的模块的信息统一保存在 导入表 (Import Table) 结构中。当PE文件被加载时,加载器其中一个任务就是将所有需要导入的函数地址确定并将导入表中的元素调整到正确地址,以实现动态链接。导入表实际上是 IMAGE_IMPORT_DESCRIPTOR
结构体数组,数组中每个元素对应一个导入DLL的信息,结构体定义如下所示:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
};
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;
DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
该结构体数组不在PE头而在PE体中,但其位置信息 (RVA) 记录在了PE头OptionalHeader结构的IMAGE_DATA_DIRECTORY DataDirectory[1]
部分。结构体中最重要的是最后1个成员 DWORD FirstThunk
,其指向1个数组,该数组即为 导入地址表 (Import Address Table, IAT),表 (数组) 中每个元素对应一个被导入符号,元素的值在不同情况下有不同的含义:
仍然以calc.exe (64位)为例,标识出导入表位置以及IAT部分,如下图所示: