免杀基础知识之PE相关数据结构

PE文件结构

PE( Portable Execute)文件是Windows下可执行文件的总称,常见的有 DLL,EXE,OCX,SYS 等。其文件的结构一般来说:从起始位置开始依次是 DOS头NT头节表 以及 具体的节。如下图所示。

image-20250503013427428

PE文件执行顺序

  1. 检查DOS头:当一个PE文件被执行时,Windows的PE装载器首先会检查DOS头(DOS Header)。DOS头的结构中包含一个指向PE头(PE Header)的偏移量。PE头是PE文件格式的核心,包含了关于文件的基本信息。
  2. 跳转到PE头:如果找到有效的PE头偏移量,装载器会跳转到该位置,开始解析PE头。
  3. 验证PE头:在PE头中,装载器会检查PE文件的有效性,包括检查“PE\0\0”标识符、机器类型、时间戳等信息。如果PE头有效,装载器将继续处理。
  4. 跳转到PE头尾部:装载器会跳过PE头的内容,直接跳转到PE头的尾部,接下来会读取节表(Section Table)。
  5. 读取节表:节表包含了所有节段的信息,如节名、虚拟地址、大小、读写权限等。装载器会遍历节表,获取每个节段的相关信息。
  6. 文件映射:Windows使用文件映射机制将PE文件的节段映射到进程的虚拟地址空间。
  7. 设置节段属性:在映射节段到内存时,装载器会根据节表中指定的属性(如可读、可写、可执行)来设置每个节段的内存保护属性。
  8. 处理导入表:映射完成后,装载器会继续处理PE文件中的导入表(Import Table)。导入表包含了程序依赖的其他模块(DLL)的信息,以及所需的导出函数。装载器会根据导入表加载所需的DLL,并解析其中的函数地址。
  9. 执行入口点:最后,装载器会找到程序的入口点(Entry Point),并开始执行程序的代码。

PE文件存储加载差异

其实就是PE文件在硬盘中与在内存中的差异

假设有一个PE文件,其中一个代码节的大小为3KB,另一个数据节的大小为2KB。在加载到内存时,操作系统可能会为代码节分配一个完整的4KB页,随后为数据节分配另一个完整的4KB页。这将导致在内存中产生3KB的空洞。下面给一个图解释:

image-20250503155932432

文件映射

image-20250503160042184

PE文件字段详解

DOS头

DOS头由MZ文件头和Dos Stub两部分组成。无论是32位或64位可执行文件,其文件的头部必定是IMAGE_DOS_HEADER

MZ头

IMAGE_DOS_HEADER 结构体,其大小占64个字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;
//一个word是对应2个字节,该结构体大小为64个字节,因为里面定义了两个word型数组。

我们随便找一个exe看看效果

e_magic:值是一个常数 0x4D5A(小端序),用文本编辑器查看该值位对应的ASCII字符串是‘MZ’,可执行文件必须都是’MZ’开头。

image-20250503160551907

e_lfanew:用来表示 DOS头之后的 NT头相对文件起始地址的偏移量。这可太重要了,有了这个我们就可以根据偏移计算出NT头的信息,然后读取节表的信息。

image-20250503160812865

DOS stub

dos存根,在IMAGE_DOS_HEADER和IMAGE_NT_HEADERS之间存在一DOS存根。PE文件是运行在32位或64位操作系统下的。其功能是当该EXE运行在16位环境下,输出一段文字:“This program cannot be run in DOS mode”,然后并退出该进程。

image-20250503160907558

NT头

PE Header:是PE相关结构NT映像头(IMAGE_NT_HEADER)的简称,其中包含许多PE装载器用到的重要字段。执行体在支持PE文件结构的操作系统中执行时,PE装载器将从IMAGE_DOS_HEADER结构中的e_lfanew字段里找到PE Header的起始偏移量,加上基址得到PE文件头的指针。 ★★★ NTHeader = ImageBase + dosHeader->e_lfanew

1
2
3
4
5
typedef struct IMAGE_NT_HEADERS{  
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
}IMAGE_NT_HEADERS,*PIMAGE_NT_HEADERS;

这三个结构其实就对应了PE签名,PE文件头,PE可选头

Signature

将文件标识为 PE 映像的 4 字节签名。字节为“PE\0\0”。这个字段是PE文件的标志字段,通常设置成00004550h,其ASCII码为PE00,这个字段是PE文件头的开始,前面的DOS_HEADER结构中的字段e_lfanew字段就是指向这里

image-20250503161238916

FileHeader

IMAGE_FILE_HEADE:共20字节的数据

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_FILE_HEADER {  
WORD Machine; //运行平台
WORD NumberOfSections; //该PE文件中有多少个节,也就是节表中的项数。
DWORD TimeDateStamp; //文件创建日期和时间
DWORD PointerToSymbolTable; //指向符号表(用于调试)
DWORD NumberOfSymbols; //符号表中符号个数(用于调试)
WORD SizeOfOptionalHeader; //IMAGE_OPTIONAL_HEADER32结构大小
WORD Characteristics; //文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
OptionalHeader

IMAGE_OPTIONAL_HEADER32:是一个可选的机构,实际上IMAGE_FILE_HEADER结构不足以定义PE文件属性,因此可选映像头中定义了更多的数据。 总共224个字节,最后128个字节为数据目录(Data Directory)

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

DataDirectory:数据目录,这是一个数组。

  • VirtualAddress:是一个RVA。
  • Size:是一个大小。
1
2
3
4
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

DataDirectory数组的每一项的内容如下,都是C语言的宏定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#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
// IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // (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

导出表

是 Windows PE(Portable Executable)文件格式中的一个重要数据结构,主要用于描述一个动态链接库(DLL)所提供的可供外部调用的函数和数据

导入表

①导入表(Import Table):是Windows可执行文件中的一部分,导入表的地址是由DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress提供, 它记录了一个程序或DLL所需调用的外部函数(或API)的名称,以及这些函数在哪些动态链接库(DLL)中可以找到

当程序需要调用某个函数时,它必须知道该函数的名称和所在的DLL文件名,并将DLL文件加载到进程的内存中。导入表就是告诉程序这些信息的重要数据结构。

  1. Import Lookup Table:通常被称为ILT记录了程序需要调用的外部函数的名称,每个名称以0结尾。如果使用了API重命名技术,这里的名称就是修改过的名称。
  2. Import Address Table:通常被称为IAT记录了如何定位到程序需要调用的外部函数,即每个函数在DLL文件中的虚拟地址。在程序加载DLL文件时,IAT中的每一个条目都会被填充为实际函数在DLL中的地址。如果DLL中的函数地址发生变化,程序会重新填充IAT中的条目。这个IAT与免杀是有很密切的关系的,如我们后续的一项技术:动态API调用
  3. Import Directory Table:通常被称为IDT记录了DLL文件的名称、ILT和IAT在可执行文件中的位置等信息

ALL IN ALL:ILT记录API名称,IAT记录API在DLL的虚拟地址,IDT记录了DLL文件的名称、ILT和IAT的位置。

②导入表的DESCRIPTOR的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA to original unbound IAT (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;
DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
  1. DUMMYUNIONNAME
    • Characteristics: 如果该值为 0,表示这是导入描述符的终止标志(即没有更多的导入描述符)
    • OriginalFirstThunk: 如果该值不为 0,表示原始未绑定的 ILT 的 RVA(相对虚拟地址),指向 IMAGE_THUNK_DATA 结构,包含导入的函数名称或序号,这个数组中的每一项表示一个导入函数。
  2. TimeDateStamp:映象绑定前,这个值是0,绑定后是导入模块的时间戳。
  3. ForwarderChain:转发链,如果没有转发器,这个值是 -1 。
  4. Name:一个 RVA,指向导入模块的名字,所以一个 IMAGE_IMPORT_DESCRIPTOR 描述一个导入的DLL。
  5. FirstThunk:该字段是一个 RVA,指向 IAT。如果绑定,则 IAT 包含实际的函数地址。操作系统将使用这个地址来调用导入的函数。也指向一个 IMAGE_THUNK_DATA 数组。

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;
  • OriginalFirstThunk 主要用于在 程序加载时 查找导入的函数。它提供了函数的原始信息(名称或序号),允许操作系统在运行时解析这些函数的地址。
  • FirstThunk程序调用导入的函数时,它使用 FirstThunk 中的地址进行调用。这个字段在程序运行时被填充为实际的函数地址,是由PELoader负责的。
  • OriginalFirstThunkFirstThunk 他们指向的不是同一个 IMAGE_THUNK_DATA 数组。OriginalFirstThunk 指向的 IMAGE_THUNK_DATA 数组包含导入信息,在这个数组中只有 OrdinalAddressOfData 是有用的,因此可以通过 OriginalFirstThunk 查找到函数的地址。FirstThunk则略有不同,在PE文件加载以前或者说在导入表未处理以前,他所指向的数组与 OriginalFirstThunk 中的数组虽不是同一个,但是内容却是相同的,都包含了导入信息,而在加载之后,FirstThunk 中的 Function 开始生效,他指向实际的函数地址,因为FirstThunk 实际上指向 IAT 中的一个位置。

PE文件加载之前,或者说导入表未处理之前:

image-20250503164646112

PE文件加载之后,或者说导入表未处理之后:

image-20250503164702124

ALL IN ALL

  1. 导入表的地址是由DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress提供
  2. 每一个被导入的DLL对应一个 IMAGE_IMPORT_DESCRIPTOR,而 IMAGE_IMPORT_DESCRIPTOR 是导入表的表项
  3. IMAGE_IMPORT_DESCRIPTOR 包含两个 IMAGE_THUNK_DATA 数组,数组中的每一项对应一个导入函数
  4. 加载前 OriginalFirstThunkFirstThunk 的数组都指向名字信息,加载后 FirstThunk 数组指函数在DLL的虚拟地址(VA)。
  5. IMAGE_IMPORT_BY_NAME 是一个结构体。相应函数的 IMAGE_IMPORT_BY_NAME 组合成一个数组,用于存放函数名称,支持名称导入函数地址。AddressOfData 表示了相应函数的 IMAGE_IMPORT_BY_NAME 在整个PE文件的偏移量是多少。即 pImgImportByName = (PIMAGE_IMPORT_BY_NAME)(pebase + pOriginalFirstThunk->u1.AddressOfData);

重定位

程序在加载之前,按照规定应该要占据这个地址,但是出于某种原因,现在这个地址不能给程序用了,程序必须转移到别的地址,这使得所有这些嵌入的地址无效。为了解决这个加载问题,一个包含所有这些需要调整的嵌入地址的列表被存储在PE文件的一个专门表中,称为重定位表(Relocation Table)。这个表位于.reloc节的一个数据目录中。

步骤如下:

  1. 获取重定位表:通过 IMAGE_NT_HEADERS 结构中的 DataDirectory 字段获取重定位表的地址和大小。
  2. 计算偏移量:计算实际加载地址与原始基址之间的差异(delta)。
  3. 遍历重定位表:检查每个重定位块,获取需要重定位的地址,并根据重定位类型进行调整。
  4. 处理不同的重定位类型:根据需要处理不同类型的重定位(如 IMAGE_REL_BASED_HIGHLOWIMAGE_REL_BASED_ABSOLUTE 等)。

线程上下文

在现代操作系统中,线程(thread)作为CPU调度的基本单位,每次调度就是线程上下文的切换。线程上下文就是表示线程信息的一系列东西,包括各种变量寄存器以及进程的运行的环境。这样,当进程被切换后,下次再切换回来继续执行,能够知道原来的状态

特征 PCB PEB
定义 进程控制块,用于管理进程的状态和信息。 进程环境块,存储进程运行时环境信息。
作用 主要用于进程调度和状态管理。 提供进程执行所需的环境和模块信息。
存在形式 每个进程都有一个 PCB,存在于内存中。 每个进程都有一个 PEB,存在于内存中。
组成部分 包含 PID、状态、程序计数器、寄存器等。 包含进程参数、图像基址、已加载模块等。
使用场景 操作系统在调度进程时使用。 操作系统和进程在运行时访问环境信息。

一些应用如下:

在设置 上下文劫持注入 中我们通过进程上下文获取 EIP/RIP 的值,这样可以直接劫持cpu的 EIP/RIP 寄存器,使其直接指向我们的恶意代码;或者在 进程镂空注入 中,我们可以获取 EBX/RDX 寄存器的值,根据 Windows 的调用约定,PEB 的地址通常存储在 EBX/RDX 寄存器中,进一步根据PEB获取特定进程的映像基址(ImageBase)

在注入中常用寄存器的调用约定:

1
2
3
4
5
6
7
8
9
// x64 注入
ctx.Rcx = 入口点地址
ctx.Rdx = PEB地址
ctx.Rip = 入口点

// x86 注入
ctx.Eax = 入口点地址
ctx.Ebx = PEB地址
ctx.Eip = 入口点

PEB

这里详细说说PEB(Process Environment Block,进程环境控制块)

PEB:是Windows操作系统中用于管理进程的一个数据结构。它包含了与进程相关的所有信息,比如进程的环境变量、进程的状态、内存管理信息、句柄信息等。其中非常关键的信息就是进程的映像基址

作用:我们利用 PEB 可以完成很多事情,比如说 动态获取 api进程伪装反调试 等等。

参考文章

宝藏:https://oneday.gitbook.io/onedaybook/mian-sha/wen-ding-mian-sha-zhi-lu/di-yi-zhang-ji-chu/1pe-de-xiang-guan-shu-ju-jie-gou#id-2.4-pe-wen-jian-zi-duan-xiang-jie