PE文件结构
PE( Portable Execute)文件是Windows下可执行文件的总称,常见的有 DLL,EXE,OCX,SYS 等。其文件的结构一般来说:从起始位置开始依次是 DOS头
,NT头
,节表
以及 具体的节
。如下图所示。
PE文件执行顺序
- 检查DOS头:当一个PE文件被执行时,Windows的PE装载器首先会检查DOS头(DOS Header)。
DOS头的结构中包含一个指向PE头(PE Header)的偏移量
。PE头是PE文件格式的核心,包含了关于文件的基本信息。 - 跳转到PE头:如果找到有效的PE头偏移量,装载器会跳转到该位置,开始解析PE头。
- 验证PE头:在PE头中,装载器会检查PE文件的有效性,包括检查“PE\0\0”标识符、机器类型、时间戳等信息。如果PE头有效,装载器将继续处理。
- 跳转到PE头尾部:装载器会跳过PE头的内容,直接跳转到PE头的尾部,接下来会读取节表(Section Table)。
- 读取节表:节表包含了所有节段的信息,如节名、虚拟地址、大小、读写权限等。装载器会遍历节表,获取每个节段的相关信息。
- 文件映射:Windows使用文件映射机制将PE文件的节段映射到进程的虚拟地址空间。
- 设置节段属性:在映射节段到内存时,装载器会根据节表中指定的属性(如可读、可写、可执行)来设置每个节段的内存保护属性。
- 处理导入表:映射完成后,装载器会继续处理PE文件中的导入表(Import Table)。导入表包含了程序依赖的其他模块(DLL)的信息,以及所需的导出函数。装载器会根据导入表加载所需的DLL,并解析其中的函数地址。
- 执行入口点:最后,装载器会找到程序的入口点(Entry Point),并开始执行程序的代码。
PE文件存储加载差异
其实就是PE文件在硬盘中与在内存中的差异
假设有一个PE文件,其中一个代码节的大小为3KB,另一个数据节的大小为2KB。在加载到内存时,操作系统可能会为代码节分配一个完整的4KB页,随后为数据节分配另一个完整的4KB页。这将导致在内存中产生3KB的空洞。下面给一个图解释:
文件映射
PE文件字段详解
DOS头
DOS头由MZ文件头和Dos Stub两部分组成。无论是32位或64位可执行文件,其文件的头部必定是IMAGE_DOS_HEADER
。
MZ头
IMAGE_DOS_HEADER
结构体,其大小占64个字节。
1 | typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header |
我们随便找一个exe看看效果
e_magic:值是一个常数 0x4D5A(小端序),用文本编辑器查看该值位对应的ASCII字符串是‘MZ’,可执行文件必须都是’MZ’开头。
e_lfanew:用来表示 DOS头之后的 NT头相对文件起始地址的偏移量。这可太重要了,有了这个我们就可以根据偏移计算出NT头的信息,然后读取节表的信息。
DOS stub
dos存根,在IMAGE_DOS_HEADER和IMAGE_NT_HEADERS之间存在一DOS存根。PE文件是运行在32位或64位操作系统下的。其功能是当该EXE运行在16位环境下,输出一段文字:“This program cannot be run in DOS mode”,然后并退出该进程。
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 | typedef struct IMAGE_NT_HEADERS{ |
这三个结构其实就对应了PE签名,PE文件头,PE可选头
Signature
将文件标识为 PE 映像的 4 字节签名。字节为“PE\0\0”。这个字段是PE文件的标志字段,通常设置成00004550h,其ASCII码为PE00,这个字段是PE文件头的开始,前面的DOS_HEADER结构中的字段e_lfanew字段就是指向这里。
FileHeader
IMAGE_FILE_HEADE:共20字节的数据
1 | typedef struct _IMAGE_FILE_HEADER { |
OptionalHeader
IMAGE_OPTIONAL_HEADER32:是一个可选的机构,实际上IMAGE_FILE_HEADER
结构不足以定义PE文件属性,因此可选映像头中定义了更多的数据。 总共224个字节,最后128个字节为数据目录(Data Directory)
1 | typedef struct _IMAGE_OPTIONAL_HEADER { |
DataDirectory:数据目录,这是一个数组。
- VirtualAddress:是一个RVA。
- Size:是一个大小。
1 | typedef struct _IMAGE_DATA_DIRECTORY { |
DataDirectory数组的每一项的内容如下,都是C语言的宏定义。
1 |
|
导出表
是 Windows PE(Portable Executable)文件格式中的一个重要数据结构,主要用于描述一个动态链接库(DLL)所提供的可供外部调用的函数和数据
。
导入表
①导入表(Import Table):是Windows可执行文件中的一部分,导入表的地址是由DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress提供, 它记录了一个程序或DLL所需调用的外部函数(或API)的名称,以及这些函数在哪些动态链接库(DLL)中可以找到
当程序需要调用某个函数时,它必须知道该函数的名称和所在的DLL文件名,并将DLL文件加载到进程的内存中。导入表就是告诉程序这些信息的重要数据结构。
- Import Lookup Table:通常被称为ILT,
记录了程序需要调用的外部函数的名称,每个名称以0结尾
。如果使用了API重命名技术,这里的名称就是修改过的名称。 - Import Address Table:通常被称为IAT,
记录了如何定位到程序需要调用的外部函数,即每个函数在DLL文件中的虚拟地址
。在程序加载DLL文件时,IAT中的每一个条目都会被填充为实际函数在DLL中的地址。如果DLL中的函数地址发生变化,程序会重新填充IAT中的条目。这个IAT与免杀是有很密切的关系的,如我们后续的一项技术:动态API调用。 - Import Directory Table:通常被称为IDT,
记录了DLL文件的名称、ILT和IAT在可执行文件中的位置等信息
。
ALL IN ALL:ILT记录API名称,IAT记录API在DLL的虚拟地址,IDT记录了DLL文件的名称、ILT和IAT的位置。
②导入表的DESCRIPTOR的定义:
1 | typedef struct _IMAGE_IMPORT_DESCRIPTOR { |
- DUMMYUNIONNAME:
- Characteristics: 如果该值为 0,表示这是导入描述符的终止标志(即没有更多的导入描述符)
- OriginalFirstThunk: 如果该值不为 0,表示原始未绑定的
ILT
的 RVA(相对虚拟地址),指向IMAGE_THUNK_DATA
结构,包含导入的函数名称或序号,这个数组中的每一项表示一个导入函数。
- TimeDateStamp:映象绑定前,这个值是0,绑定后是导入模块的时间戳。
- ForwarderChain:转发链,如果没有转发器,这个值是 -1 。
- Name:一个 RVA,指向导入模块的名字,所以一个 IMAGE_IMPORT_DESCRIPTOR 描述一个导入的DLL。
- FirstThunk:该字段是一个 RVA,指向
IAT
。如果绑定,则 IAT 包含实际的函数地址。操作系统将使用这个地址来调用导入的函数。也指向一个IMAGE_THUNK_DATA
数组。
IMAGE_THUNK_DATA
的定义
1 | typedef struct _IMAGE_THUNK_DATA32 { |
OriginalFirstThunk
主要用于在程序加载时
查找导入的函数。它提供了函数的原始信息(名称或序号),允许操作系统在运行时解析这些函数的地址。FirstThunk
当程序调用导入的函数时
,它使用FirstThunk
中的地址进行调用。这个字段在程序运行时被填充为实际的函数地址,是由PELoader负责的。OriginalFirstThunk
和FirstThunk
他们指向的不是同一个IMAGE_THUNK_DATA
数组。OriginalFirstThunk
指向的IMAGE_THUNK_DATA
数组包含导入信息,在这个数组中只有Ordinal
和AddressOfData
是有用的,因此可以通过OriginalFirstThunk
查找到函数的地址。FirstThunk
则略有不同,在PE文件加载以前或者说在导入表未处理以前,他所指向的数组与OriginalFirstThunk
中的数组虽不是同一个,但是内容却是相同的,都包含了导入信息,而在加载之后,FirstThunk
中的Function
开始生效,他指向实际的函数地址,因为FirstThunk
实际上指向 IAT 中的一个位置。
PE文件加载之前,或者说导入表未处理之前:
PE文件加载之后,或者说导入表未处理之后:
ALL IN ALL:
- 导入表的地址是由
DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress
提供 - 每一个被导入的DLL对应一个
IMAGE_IMPORT_DESCRIPTOR
,而IMAGE_IMPORT_DESCRIPTOR
是导入表的表项 IMAGE_IMPORT_DESCRIPTOR
包含两个IMAGE_THUNK_DATA
数组,数组中的每一项对应一个导入函数- 加载前
OriginalFirstThunk
与FirstThunk
的数组都指向名字信息,加载后FirstThunk
数组指函数在DLL的虚拟地址(VA)。 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节的一个数据目录中。
步骤如下:
- 获取重定位表:通过
IMAGE_NT_HEADERS
结构中的DataDirectory
字段获取重定位表的地址和大小。 - 计算偏移量:计算实际加载地址与原始基址之间的差异(
delta
)。 - 遍历重定位表:检查每个重定位块,获取需要重定位的地址,并根据重定位类型进行调整。
- 处理不同的重定位类型:根据需要处理不同类型的重定位(如
IMAGE_REL_BASED_HIGHLOW
、IMAGE_REL_BASED_ABSOLUTE
等)。
线程上下文
在现代操作系统中,线程(thread)作为CPU调度的基本单位,每次调度就是线程上下文的切换。线程上下文就是表示线程信息的一系列东西,包括各种变量、寄存器以及进程的运行的环境。这样,当进程被切换后,下次再切换回来继续执行,能够知道原来的状态
特征 | PCB | PEB |
---|---|---|
定义 | 进程控制块,用于管理进程的状态和信息。 | 进程环境块,存储进程运行时环境信息。 |
作用 | 主要用于进程调度和状态管理。 | 提供进程执行所需的环境和模块信息。 |
存在形式 | 每个进程都有一个 PCB,存在于内存中。 | 每个进程都有一个 PEB,存在于内存中。 |
组成部分 | 包含 PID、状态、程序计数器、寄存器等。 | 包含进程参数、图像基址、已加载模块等。 |
使用场景 | 操作系统在调度进程时使用。 | 操作系统和进程在运行时访问环境信息。 |
一些应用如下:
在设置 上下文劫持注入
中我们通过进程上下文获取 EIP/RIP
的值,这样可以直接劫持cpu的 EIP/RIP
寄存器,使其直接指向我们的恶意代码;或者在 进程镂空注入
中,我们可以获取 EBX/RDX
寄存器的值,根据 Windows 的调用约定,PEB 的地址通常存储在 EBX/RDX
寄存器中,进一步根据PEB获取特定进程的映像基址(ImageBase)
在注入中常用寄存器的调用约定:
1 | // x64 注入 |
PEB
这里详细说说PEB(Process Environment Block,进程环境控制块)
。
PEB:是Windows操作系统中用于管理进程的一个数据结构。它包含了与进程相关的所有信息,比如进程的环境变量、进程的状态、内存管理信息、句柄信息等。其中非常关键的信息就是进程的映像基址。
作用:我们利用 PEB 可以完成很多事情,比如说 动态获取 api
,进程伪装
,反调试
等等。