总览图:

前置知识

  1. 虚拟地址 WIndows系统中,PE文件被系统加载器映射到内存中,每个程序都有自己的虚拟空间,该虚拟空间中的内存地址称为虚拟地址(VA)。
  2. 相对虚拟地址 PE文件虽然有一个首选的载入地址,但是其可在进程空间的任何地方载入,故需要有一种方式方式来指定内存中的具体地址。为避免PE文件中出现绝对地址故引入了相对虚拟地址(RVA),其是相对于PE文件载入地址的一个偏移量,计算公式为目标地址-载入地址=RVA
  3. 文件偏移地址 PE文件储存在磁盘里时某个数据相对于文件头的偏移量。

DOS头

PE文件都是从一个DOS程序开始的,程序在DOS下执行时系统能识别这是一个有效的执行体并运行紧随MZ headerDOS stub中的内容,即显示一个简单的错误提示。程序员也可以自行在此处实现完整的DOS程序代码。

MS-DOS头部完整结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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_magic字段的值需要设置为0x5A4D,即为字符”MZ”,是PE文件的一个标志。

e_lfanew字段是真正的PE文件头的相对偏移,其指出真正PE头的文件偏移位置,位于文件开始偏移0x3C位置处。

PE头

紧随DOS stub之后的是 PE头(PE header),其包含许多PE装载器用得到的字段。

IMAGE_NT_HEADER实际有两个版本,分别对应32位和64位,但是差别很小,此处将其忽略。

1
2
3
4
5
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // PE标识
IMAGE_FILE_HEADER FileHeader; // 标准PE头
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // 扩展PE头
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

Signature字段

其被设置为0x00004550,对应字符”PE\0\0”。此处为PE文件头的开始,也是PE文件的一个重要标志。

PE文件头

此处包含一些PE文件的基本信息,并指出了可选头的大小。

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; // 可以运行在什么样的CPU上
WORD NumberOfSections; // 表示节的数量
DWORD TimeDateStamp; // 编译器填写的时间戳
DWORD PointerToSymbolTable; // 调试相关
DWORD NumberOfSymbols; // 调试相关
WORD SizeOfOptionalHeader; // 扩展PE头的大小
WORD Characteristics; // 文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
  1. Machine
    其记录可执行文件的目标CPU类型,PE文件可在多种机器上使用,不同平台上指令的机器码不同。在intel i386上标志为0x14C,在x86_64上为0x8664
  2. NumberOfSections
    其记录区块(Section)的数目。
  3. TimeDateStamp
    表示文件的创建时间。可用_ctimegmtime函数将此值翻译为易读的字符串.
  4. PointerToSymbolTable
    COFF符号表的文件偏移位置。
  5. NumberOfSymbols
    若有COFF符号表,其表示其中的符号数目。
  6. SizeOfOptionalHeader
    表示可选头的大小,其大小于文件32/64bit有关,32位PE文件此处的值一般为0x00E0,64位则为0x00F0。
  7. Characteristics
    文件属性,有选择地通过几个值地运算得到(通过标志位形式存储)。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #define IMAGE_FILE_RELOCS_STRIPPED           0x0001  // 文件中已去除重定位信息。
    #define IMAGE_FILE_EXECUTABLE_IMAGE 0x0002 // 文件是可执行的(例如,没有未解析的外部引用)。
    #define IMAGE_FILE_LINE_NUMS_STRIPPED 0x0004 // 文件中已去除行号信息。
    #define IMAGE_FILE_LOCAL_SYMS_STRIPPED 0x0008 // 文件中已去除本地符号。
    #define IMAGE_FILE_AGGRESIVE_WS_TRIM 0x0010 // 激进地收缩工作集。
    #define IMAGE_FILE_LARGE_ADDRESS_AWARE 0x0020 // 应用程序可处理超过 2GB 的地址。
    #define IMAGE_FILE_BYTES_REVERSED_LO 0x0080 // 机器字节的低位部分是反序的。
    #define IMAGE_FILE_32BIT_MACHINE 0x0100 // 32 位字长的机器。
    #define IMAGE_FILE_DEBUG_STRIPPED 0x0200 // 调试信息已从文件中移除,存放在 .DBG 文件。
    #define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 0x0400 // 若映像位于可移动介质上,则从交换文件中复制并运行。
    #define IMAGE_FILE_NET_RUN_FROM_SWAP 0x0800 // 若映像位于网络上,则从交换文件中复制并运行。
    #define IMAGE_FILE_SYSTEM 0x1000 // 系统文件。
    #define IMAGE_FILE_DLL 0x2000 // 文件是 DLL。
    #define IMAGE_FILE_UP_SYSTEM_ONLY 0x4000 // 文件只能在单处理器 (UP) 机器上运行。
    #define IMAGE_FILE_BYTES_REVERSED_HI 0x8000 // 机器字节的高位部分是反序的。

可选头

IMAGE_OPTIONAL_HEADER64与IMAGE_OPTIONAL_HEADER结构差别不大,一下以PE32为例并标注区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic; // 一个标记字 PE32:10B PE32+:20B
BYTE MajorLinkerVersion; // 链接器版本号
BYTE MinorLinkerVersion; // 链接器版本号
DWORD SizeOfCode; // 所有代码节的总和(文件对齐后的大小),编译器填的(没用)
DWORD SizeOfInitializedData; // 包含所有已经初始化数据的节的总大小(文件对齐后的大小),编译器填的(没用)
DWORD SizeOfUninitializedData; // 包含未初始化数据的节的总大小(文件对齐后的大小),编译器填的(没用)
DWORD AddressOfEntryPoint; // 程序执行入口RVA
DWORD BaseOfCode; // 代码段地起始RVA
DWORD BaseOfData; // 数据段地起始RVA(PE32+不存在)
DWORD ImageBase; // 文件在内存中的首选载入地址
DWORD SectionAlignment; // 载入内存时的区块对齐大小
DWORD FileAlignment; // 磁盘上PE文件内的区块对其大小
WORD MajorOperatingSystemVersion; // 标识操作系统版本号,主版本号
WORD MinorOperatingSystemVersion; // 标识操作系统版本号,次版本号
WORD MajorImageVersion; // PE文件自身的版本号
WORD MinorImageVersion; // PE文件自身的版本号
WORD MajorSubsystemVersion; // 运行所需子系统版本号
WORD MinorSubsystemVersion; // 运行所需子系统版本号
DWORD Win32VersionValue; // 子系统版本的值,通常被设置为0
DWORD SizeOfImage; // 内存中整个PE文件的映射的尺寸
DWORD SizeOfHeaders; // 所有头加节表按照文件对齐后的大小,否则加载会出错
DWORD CheckSum; // 校验和,是用来判断文件是否被修改的
WORD Subsystem; // 标明可执行文件所期望的子系统:未知(0),无需子系统,例如驱动程序(1)、图形界面(2) 、控制台/DLL(3)……
WORD DllCharacteristics; // DllMain()函数何时被调用,默认值为0
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;

可使用study_PE、LordPE等工具查看这些字段信息

区块表(节表)

IMAGE_SECTION_HEADERS结构包含了其所关联的区块的信息,例如位置、长度、属性等,该数组的数目由IMAGE_SECTION_HEADERS.FileHeader.NumberOfSections指出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // ASCII字符串(节名),可自定义,只截取8个字节,可以8个字节都是名字
union { // Misc,双字,是该节在没有对齐前的真实尺寸,该值可以不准确
DWORD PhysicalAddress; // 真实宽度,这两个值是一个联合结构,可以使用其中的任何一个
DWORD VirtualSize; // 一般是取后一个
} Misc;
DWORD VirtualAddress; // 在内存中的偏移地址,加上ImageBase才是在内存中的真正地址
DWORD SizeOfRawData; // 节在文件中对齐后的尺寸
DWORD PointerToRawData; // 节区在文件中的偏移
DWORD PointerToRelocations; // 调试相关
DWORD PointerToLinenumbers; // 调试相关
WORD NumberOfRelocations; // 调试相关
WORD NumberOfLinenumbers; // 调试相关
DWORD Characteristics; // 节的属性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

Characteristics指出块属性的标志,具体标志位代表的含义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//
// Section characteristics.
// 区段特性。

// IMAGE_SCN_TYPE_REG 0x00000000 // Reserved.
// IMAGE_SCN_TYPE_DSECT 0x00000001 // Reserved.
// IMAGE_SCN_TYPE_NOLOAD 0x00000002 // Reserved.
// IMAGE_SCN_TYPE_GROUP 0x00000004 // Reserved.
#define IMAGE_SCN_TYPE_NO_PAD 0x00000008 // 保留(不填充)。
// IMAGE_SCN_TYPE_COPY 0x00000010 // Reserved.

#define IMAGE_SCN_CNT_CODE 0x00000020 // 区段包含代码。
#define IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 // 区段包含已初始化的数据。
#define IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080 // 区段包含未初始化的数据。

#define IMAGE_SCN_LNK_OTHER 0x00000100 // 保留。
#define IMAGE_SCN_LNK_INFO 0x00000200 // 区段包含注释或其他类型的信息。
// IMAGE_SCN_TYPE_OVER 0x00000400 // Reserved.
#define IMAGE_SCN_LNK_REMOVE 0x00000800 // 区段内容不会成为映像文件的一部分。
#define IMAGE_SCN_LNK_COMDAT 0x00001000 // 区段包含 COMDAT 数据。
// 0x00002000 // Reserved.
// IMAGE_SCN_MEM_PROTECTED - Obsolete 0x00004000
#define IMAGE_SCN_NO_DEFER_SPEC_EXC 0x00004000 // 为此区段重置 TLB 项中的推测异常处理位。
#define IMAGE_SCN_GPREL 0x00008000 // 区段内容可通过 GP 相对寻址。
#define IMAGE_SCN_MEM_FARDATA 0x00008000 // 远数据。
// IMAGE_SCN_MEM_SYSHEAP - Obsolete 0x00010000
#define IMAGE_SCN_MEM_PURGEABLE 0x00020000 // 可清除的。
#define IMAGE_SCN_MEM_16BIT 0x00020000 // 16 位内存。
#define IMAGE_SCN_MEM_LOCKED 0x00040000 // 锁定的。
#define IMAGE_SCN_MEM_PRELOAD 0x00080000 // 预加载。

#define IMAGE_SCN_ALIGN_1BYTES 0x00100000 // 1 字节对齐。
#define IMAGE_SCN_ALIGN_2BYTES 0x00200000 // 2 字节对齐。
#define IMAGE_SCN_ALIGN_4BYTES 0x00300000 // 4 字节对齐。
#define IMAGE_SCN_ALIGN_8BYTES 0x00400000 // 8 字节对齐。
#define IMAGE_SCN_ALIGN_16BYTES 0x00500000 // 16 字节对齐(默认对齐方式)。
#define IMAGE_SCN_ALIGN_32BYTES 0x00600000 // 32 字节对齐。
#define IMAGE_SCN_ALIGN_64BYTES 0x00700000 // 64 字节对齐。
#define IMAGE_SCN_ALIGN_128BYTES 0x00800000 // 128 字节对齐。
#define IMAGE_SCN_ALIGN_256BYTES 0x00900000 // 256 字节对齐。
#define IMAGE_SCN_ALIGN_512BYTES 0x00A00000 // 512 字节对齐。
#define IMAGE_SCN_ALIGN_1024BYTES 0x00B00000 // 1024 字节对齐。
#define IMAGE_SCN_ALIGN_2048BYTES 0x00C00000 // 2048 字节对齐。
#define IMAGE_SCN_ALIGN_4096BYTES 0x00D00000 // 4096 字节对齐。
#define IMAGE_SCN_ALIGN_8192BYTES 0x00E00000 // 8192 字节对齐。
// Unused 0x00F00000 // 未使用。

#define IMAGE_SCN_LNK_NRELOC_OVFL 0x01000000 // 区段包含扩展重定位信息。
#define IMAGE_SCN_MEM_DISCARDABLE 0x02000000 // 区段可被丢弃。
#define IMAGE_SCN_MEM_NOT_CACHED 0x04000000 // 区段不可被缓存。
#define IMAGE_SCN_MEM_NOT_PAGED 0x08000000 // 区段不可分页。
#define IMAGE_SCN_MEM_SHARED 0x10000000 // 区段可共享。
#define IMAGE_SCN_MEM_EXECUTE 0x20000000 // 区段可执行。
#define IMAGE_SCN_MEM_READ 0x40000000 // 区段可读。
#define IMAGE_SCN_MEM_WRITE 0x80000000 // 区段可写。

常见的区段介绍


区块的大小是要对齐的。有两种对齐值,一种用于磁盘文件内,另一种用于内存中。SectionAlignment定义了内存中区块的对齐值。x86系列CPU上,内存页是按4KB(0x1000)排列的,x64上是按8KB(0x2000)排列的。

而一些PE文件为了减小体积,磁盘对齐值常常是0x200等值,当其载入内存时需要将进行转换。
文件被映射到内存中时,DOS头、PE文件头和块表的偏移位置与大小均没有变化,而当各区块被映射到内存中后,其偏移位置就发生了变化。

如图,磁盘文件中.text 块起始端与文件头的偏移量为add1,映射到内存后,.text 块起始端与文件头(基地址)的偏移量为add2。同 时,.text 块与块表之间形成了一大段空隙,这部分数据全是以0填充的。在这里add1的值就是文件偏移地址(File Offset),add2的值就是相对虚拟地址(RVA)。假设它们的差值为△k,则文件偏移地址与虚拟地址的关系如下File Offset = RVA - △kFile Offset = VA - ImageBase - △k

在同一区块中,各地址的偏移量是相等的,但是不同区块的差值是不同的,这是因为各区块在内存中是以一个页边界开始的,从第1个区块的结束到第2个区块的开始(1000h对齐处)全以数据0填充,所以不同区块在磁盘与内存中的差值不同。

导入表 in .idata

导入函数是程序中调用但不存在于程序中的函数,其在程序载入内存后才从DLL文件中载入。

在PE文件中存在一组数据结构,其分别对应于被输入的DLL。每一个这样的结构都给出了被输入的DLL名称并指向一组函数指针,其被称为输入地址表IAT(Import Address Table) 。

由于编译器无法区分输入函数调用和普通函数调用,其使用同样形式的CALL指令,其将控制权交给一个子程序,然后通过子程序的JMP跳转到IAT对应的地址,这也正是IDA中会出现粉色的导入表函数的原因,同时函数将被加上__imp__的前缀。

导入表的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0表示终止的空导入描述符
DWORD OriginalFirstThunk; // 指向原始未绑定IAT的RVA(PIMAGE_THUNK_DATA类型)
} DUMMYUNIONNAME;
DWORD TimeDateStamp; // 未绑定时为0,
// 绑定时为-1,且真实时间戳存储在
// IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT中(新式绑定)
// 否则为所绑定DLL的时间戳(旧式绑定)

DWORD ForwarderChain; // 无转发器时为-1
DWORD Name; // DLL名称的RVA
DWORD FirstThunk; // 指向IAT的RVA(若已绑定则IAT包含实际地址)
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR; // 非对齐指针类型
  1. Characteristics和OriginalFirstThunk
    一个联合体,如果是数组的最后一项 Characteristics 为 0,否则 OriginalFirstThunk 保存一个 RVA,指向一个 IMAGE_THUNK_DATA 的数组(INT输入名称表),这个数组中的每一项表示一个导入函数。

  2. TimeDateStamp
    映象绑定前,这个值是0,绑定后是导入模块的一个32位时间戳。

  3. ForwarderChain
    转发链,在被程序引用的DLL里的API又引用了其他DLL的API时使用,一般为0。如果没有转发器,这个值是-1。

  4. Name
    一个 RVA,指向导入模块的名字(以00结尾的ASCII字符串),所以一个 IMAGE_IMPORT_DESCRIPTOR 描述一个导入的DLL。

  5. FirstThunk
    也是一个 RVA,也指向一个 IMAGE_THUNK_DATA 数组(IAT)。

IMAGE_THUNK_DATA的结构

1
2
3
4
5
6
7
8
9
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;

而INT和IAT内容相同的原因如下:

INT不可改写,而IAT是由PE装载器重写的,加载程序迭代搜索数组中的每个指针并用函数真正的入口替代FirstThunk指向的IMAGE_THUNK_DATA内容,这也正是IAT输入地址表名称的来源。当PE文件装载内存后准备执行时,IAT已转换为如下图所示的状态,此时输入表中的其他部分就无用了,直接依靠IAT中的函数地址就可使程序正常运行。

为了简化这一替换过程提升效率,引入了绑定输入。

PE文件载入时的绑定输入过程是Windows加载器优化DLL加载的机制。加载器首先检查导入描述符的时间戳:0表示未绑定需完整解析,-1表示新式绑定需查绑定目录,其他值为旧式绑定时间戳。接着验证绑定有效性,检查DLL时间戳(TimeDateStamp)是否匹配及基址(ASLR下常跳过),失败则回退常规解析。处理绑定导入表时直接使用预计算的函数地址填充IAT并处理转发引用。该机制优势在于避免逐个调用GetProcAddress和减少基址冲突,但现代系统因ASLR常忽略基址验证,绑定主要作用变为减少加载时间。

输出表

与导入表进行类比学习,有的DLL既有导入表又有导出表。

导出表的结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; // 未使用,总为0
DWORD TimeDateStamp; // 时间戳
WORD MajorVersion; // 主版本号(一般为0)
WORD MinorVersion; // 次版本号(一般为0)
DWORD Name; // 指向本DLL名称字符串的RVA(如"MyLib.dll")
DWORD Base; // 起始序号,通常为1。导出序号 = Base + 序号数组索引
DWORD NumberOfFunctions; // 导出函数的数量(AddressOfFunctions数组元素数)
DWORD NumberOfNames; // 按名称导出的函数数量(AddressOfNames数组元素数)
DWORD AddressOfFunctions; // 指向函数地址RVA数组的RVA
DWORD AddressOfNames; // 指向函数名称指针RVA数组的RVA
DWORD AddressOfNameOrdinals; // 指向函数序号(WORD)数组的RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

在导出表中,函数既可以通过名字导出也可以通过编号导出。导出方式如下:

按照名称导出

  1. 在AddressOfNames指向的字符串数组中,线性搜索FuncName。假设找到索引i。
  2. 从AddressOfNameOrdinals[i]获取序号值ord。
  3. 从AddressOfFunctions[ord]获取函数地址RVA。

按照编号导出
直接使用序号Ordinal,则函数地址RVA = AddressOfFunctions[Ordinal - Base]

基址重定位

基址重定位是Windows加载PE文件时的重要机制,用于解决DLL加载地址冲突问题。当DLL无法加载到其预设的基址(ImageBase)时,系统会触发重定位过程:

  1. 加载器检查DLL是否需要重定位(IMAGE_FILE_RELOCS_STRIPPED标志)

  2. 定位.reloc段,其中包含需要修正的地址偏移列表

  3. 计算实际加载地址与预设基址的差值(Delta值)

  4. 遍历重定位表,对每个标记的地址加上Delta值进行修正

现代系统因ASLR(地址空间布局随机化)强制启用重定位,每次加载地址都不同。重定位主要影响代码中的绝对地址引用,对相对地址(如RVA)无影响。此过程确保程序能正确运行,但会增加加载开销并影响共享DLL的内存效率。