PE 文件格式
记录逆向工程核心原理、加密解密、Windows PE 权威指南等书中对 PE 文件格式的部分描述,眼过千遍不如手过一遍,加强学习,同时作为备忘,初始对很多内容理解不够深刻且容易忘记,日后需多加复习和练习;
使用工具
- OllyDbg 1.10原版,简称
OD
; OD
汉化
和插件
均来自互联网;WinHex
来自互联网;- 文中特殊数字均是
HEX
,为了书写方便采用DEC
;
PE 文件格式
PE 文件是 Windows 操作系统下的可执行文件格式;
是微软在 UNIX 平台的 COFF(Common Object File Format,通用对象文件格式)基础上制作而成的;
在 Win16 平台上,可执行文件格式是 NE;
在 Win32 平台上,可执行文件格式是 PE(Portable Executable File Format,可移植的执行体);
PE 文件是指 32 位的可执行文件,也称为 PE32;
64 位的可执行文件称为 PE+ 或 PE32+,是 PE 文件的一种扩展形式,而不是 PE64;
Tips:⚠️ 为当前参数列表需重点关注
PE 文件种类
种类 | 主扩展名 |
---|---|
可执行系列 | EXE、SCR |
库系列 | DLL、OCX、CPL、DRV |
驱动程序系列 | SYS、VXD |
对象文件系列 | OBJ |
除 OBJ 文件之外的所有文件都是可执行的;
EXE 文件和 DLL 文件的区别完全是语义上的,它们使用完全相同的 PE 格式,唯一的区别就是用一个字段标识出这个文件是 EXE 还是 DLL;
DLL、SYS 文件等虽然不能直接在 Shell 中运行,但可以使用其它方法执行;
只有扩展名为 .DLL 的动态链接库才能被 Windows 操作系统自动加载;
如果文件有另外的扩展名,则必须明确地用 LoadLibrary 或 LoadLibraryEx 函数加载;
基本结构
PE 文件使用的是一个平面地址空间,所有代码和数据都合并在一起,组成一个很大的结构;
文件的内容被分割为不同的区块(Section,又称区段、节等);
区块中包含代码或数据,各个区块按页边界对齐;
区块没有大小限制,是一个连续结构;
每个区块都有它自己在内存中的一套属性,如:是否包含代码、是否只读或可读/写等;
Windows 加载器(又称 PE 装载器)遍历 PE 文件并决定文件的哪一部分被映射,这种映射方式是将文件较高的偏移地址映射到较高的内存地址中;
磁盘文件一旦被载入内存,磁盘上的数据结构布局和内存中的数据结构布局就是一致的,但数据之间的相对位置可能会改变,某项的偏移地址可能区别于原始的偏移地址;
当 PE 文件通过 Windows 加载器载入内存后,内存中的版本称为模块(Module);
映射文件的起始地址称为模块句柄(hModule),可以通过模块句柄访问内存中的其它数据结构,这个初始内存地址也称为基地址(ImageBase);
基地址的值是由 PE 文件本身设定的,按照默认设置,用 Visual C++ 建立的 EXE 文件的基地址是 00400000,DLL 文件的基地址是 10000000;
从 DOS 头到节区头是 PE 头部分,称为 PE 头,下面的节区合称 PE 体;
文件中使用偏移(offset),内存中使用 VA(Virtual Address,虚拟地址)来表示位置;
文件加载到内存时,节区的大小、位置等会发生变化;
文件的内容一般可以分为代码(.text)、数据(.data)、资源(.src)节等,分别保存;
根据所用的不同开发工具与编译选项,节区的名称、大小、个数、存储的内容等都是不同的,它们按照不同的用途,分类保存到不同的节中;
各节区头定义了各节区在文件或内存中的大小、位置、属性等;
PE 头与各节区的尾部存在一个区域,称为 NULL 填充,也就是 PE 头与各节区以 NULL 分割;
计算机中,为了提高处理文件、内存、网络包的效率,使用“最小基本单位”这一概念,PE 文件也类似;
文件 / 内存中各节区的起始位置应该在各文件 / 内存的最小单位的倍数位置上,空白区域将用 NULL 填充;
VA & RVA
在 Windows 系统中,PE 文件将被系统加载器映射到内存中;
每个程序都有自己的虚拟空间,这个虚拟空间的内存地址称为虚拟地址(Virtual Address,VA);
VA(Virtual Address,虚拟地址) 指的是进程虚拟内存的绝对地址;
RVA(Relative Virtual Address,相对虚拟地址)指的是从基准位置(ImageBase)开始的相对地址;
VA 与 RVA 之间的关系:虚拟地址(VA) = 相对虚拟地址(RVA) + 基地址(ImageBase);
PE 头内部信息大多以 RVA 形式存在;
原因在于,PE 文件(主要是 DLL)加载到进程虚拟内存的特定位置时,该位置可能已经加载了其它 PE 文件(DLL);
此时,必须通过重定位将其加载到其它空白的位置,若 PE 头信息使用的是 VA,则无法正常访问;
因此使用 RVA 来定位信息,即使发生了重定位,只要相对于基准位置的相对地址没有变化,就能正常访问到指定信息,不会出现任何问题;
32 位 Windows 系统中,各进程分配有 4GB 的虚拟内存,因此进程中 VA 值的范围是 00000000 ~ FFFFFFFF;
PE 头
PE 头由许多结构体组成;
DOS 头
每个 PE 文件都是以一个 DOS 程序开始的,有了它,一旦程序在 DOS 下执行,DOS 就能识别出这是一个有效的执行体,然后运行紧随 MZ Header 的 DOS stub(DOS 块);
DOS stub(DOS 存根) 实际上是一个有效的 EXE,在不支持 PE 文件格式的操作系统中,它将简单的显示一个错误提示;
通常把 DOS MZ 头与 DOS stub 合并称为 DOS 头;
微软创建 PE 文件格式时,广泛使用的是 DOS 文件,考虑到 PE 文件对 DOS 文件的兼容性,在 PE 头的最前面添加了一个 IMAGE_DOS_HEADER 结构体,用来扩展已有的 DOS EXE 头;
IMAGE_DOS_HEADER 结构体的大小为 64 字节,在该结构体中必须知道 2 个重要的成员:
- e_magic:DOS 签名(Signature,4D5A,对应的 ASCII 值是“MZ”),也称为 DOS MZ 头;
- e_lfanew:指示 NT 头的偏移(根据不同文件拥有可变值),是真正的 PE 文件头的相对偏移(RVA)位置,占用 4 个字节,位于从文件开始偏移 3C 字节处;
所有 PE 文件在开始部分(e_magic)都有 DOS 签名(4D5A,“MZ”);
一个名叫 Mark Zbikowski 的开发人员在微软设计了 DOS 可执行文件,MZ 取自其名字的首字母;
e_lfanew 值指向 NT 头所在位置(NT 头的名称为 IMAGE_NT_HEADERS);
NOTEPAD.EXE 的 IMAGE_DOS_HEADER 结构体:
文件开始的 2 个字节为 4D5A,e_lfanew 值为 000000E0(Intel x86 CPU,小端序标识法);
DOS 存根
DOS 存根(stub)位于 DOS 头下方,是可选项,且大小不固定,也可以说:DOS MZ 头与 NT 头之间的数据就是 DOS 存根;
DOS 存根由代码与数据混合而成;
NOTEPAD.EXE 的 DOS 存根:
NT 头(PE Header,PE 头)
NT 头的名称为 IMAGE_NT_HEADERS;
IMAGE_NT_HEADERS 结构体由 3 个成员组成,第一个成员为签名(Signature)结构体,其值为 50450000(PE00),另外两个成员分别为文件头(File Header)与可选头(Optional Header)结构体;
IMAGE_NT_HEADERS 结构体:
IMAGE_NT_HEADERS 结构体的大小为 F8,相当大,通常,标准 PE 文件的 MAGE_NT_HEADERS 结构体由 4 个字节的签名标识符和 20 个字节的基本信息头(文件头)以及 216 个字节的扩展信息头(可选头)组成;
NT 头:文件头 IMAGE_FILE_HEADER
文件头是表现文件大致属性的 IMAGE_FILE_HEADER 结构体,该结构在微软官方文档中被称为标准通用对象文件格式(Common Object File Format,COFF)头;
IMAGE_FILE_HEADER 结构体的成员:
以下成员的前 4 个非常重要,若设置不正确,将导致文件无法正常运行:(偏移量基于 PE 文件头 (IMAGE_NT_HEADERS))
偏移量 | 字段 | Size | 注释 |
---|---|---|---|
4 | Machine ⚠️ | WORD | 运行平台 |
6 | NumberOfSections ⚠️ | WORD | PE 中区块的数量 |
14 | SizeOfOptionalHeader ⚠️ | WORD | IMAGE_OPTIONAL_HEADER 结构体的长度 |
16 | Characteristics ⚠️ | WORD | 文件属性 |
8 | TimeDateStamp | DWORD | 文件创建时间 |
C | PointerToSymbolTable | DWORD | 指向符号表 |
10 | NumberOfSymbols | DWORD | 符号表中符号的数量 |
Machine:单字,每个 CPU 都拥有唯一的 Machine 码,兼容 32 位 Intel x86 芯片的 Machine 码为 14C,具体定义位于 winnt.h;
几种典型的机器类型标志:
含义 标志 常量符号 适用于任何处理器 0 IMAGE_FILE_MACHINE_UNKNOWN Intel i386 处理器或后续兼容处理器 14C IMAGE_FILE_MACHINE_I386 x64 处理器 8664 IMAGE_FILE_MACHINE_AMD64 MIPS 小尾处理器 166 IMAGE_FILE_MACHINE_R4000 ARM 小尾处理器 1C0 IMAGE_FILE_MACHINE_ARM Power PC 小尾处理器 1F0 IMAGE_FILE_MACHINE_POWERPC NumberOfSections:单字,用来指出文件中存在的节区(Section)数量,该值一定要大于 0,且当定义的节区数量与实际数量不同时,将发生运行错误;
如果想在 PE 中增加或删除节,必须变更此处的值;
SizeOfOptionalHeader:单字,NT 头的最后一个成员为 IMAGE_OPTIONAL_HEADER 结构体(也就是扩展信息头),SizeOfOptionalHeader 成员用来指出扩展信息头的长度,表示数据的大小;
Windows 的 PE 装载器需要查看 IMAGE_FILE_HEADER 的 SizeOfOptionalHeader 值,从而识别出 IMAGE_OPTIONAL_HEADER 结构体的大小;
PE32+ 格式的文件中使用的是 IMAGE_OPTIONAL_HEADER64 结构体,而不是 IMAGE_OPTIONAL_HEADER32 结构体;
IMAGE_OPTIONAL_HEADER64 与 IMAGE_OPTIONAL_HEADER32 两个结构体的尺寸不同,所以需要在 SizeOfOptionalHeader 成员中明确指出结构体的大小;
IMAGE_OPTIONAL_HEADER 的大小依赖于当前 PE 文件是 32 位还是 64 位;
对于 32 位 PE 文件,这个域通常是 00E0;
对于 64 位 PE 文件,这个域通常是 00F0;用户可以自定义 SizeOfOptionalHeader 的值,不过需要注意两点:
- 更改完成后,需要自行将文件中 IMAGE_OPTIONAL_HEADER32 的大小扩充为指定的值(一般以 0 填充);
- 扩充完成后,要维持文件中的对齐特性(保证每个节区的起始位置不变);
Characteristics:单字,用于标识文件的属性,文件是否为可运行的形态、是否为 DLL 文件等信息,具体定义位于 winnt.h;
属性位字段的含义:
特征值 常量符号 含义 0001 IMAGE_FILE_RELOCS_STRIPPED 文件中不存在重定位信息 0002 IMAGE_FILE_EXECUTABLE_IMAGE 文件可执行,如果为 0,一般是链接时出问题了 ⚠️ 0004 IMAGE_FILE_LINE_NUMS_STRIPPED 行号信息被移除 0008 IMAGE_FILE_LOCAL_SYMS_StRIPPED 符号信息被移除 0020 IMAGE_FILE_LARGE_ADDRESS_AWARE 应用程序可以处理超过 2GB 的地址 0080 IMAGE_FILE_BYTES_REVERSED_LO 处理机的低位字节是相反的 0100 IMAGE_FILE_32BIT_MACHINE 目标平台为 32 位机器 0200 IMAGE_FILE_DEBUG_STRIPPED .DBG 文件的调试信息被移除 0400 IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 如果映像文件位于可移动介质中,则先复制到交换文件中再运行 0800 IMAGE_FILE_NET_RUN_FROM_SWAP 如果映像文件位于网络中,则复制到交换文件后才运行 1000 IMAGE_FILE_SYSTEM 系统文件 2000 IMAGE_FILE_DLL 文件是 DLL ⚠️ 4000 IMAGE_FILE_UP_SYSTEM_ONLY 文件只能运行在单处理器上 8000 IMAGE_FILE_BYTES_REVERSED_HI 处理机的高位字节是相反的 TimeDateStamp:双字,用来记录编译器创建此文件的时间;
PointerToSymbolTable:双字,COFF 符号表的文件偏移位置,若没有符号表存在,将此值设置为 0;
NumberOfSymbols:双字,如果有符号表,它表示其中的符号数目;
NT 头:可选头 IMAGE_OPTIONAL_HEADER
IMAGE_OPTIONAL_HEADER 是 PE 头结构体中最大的;
可选头又分为两部分,前 10 个字段属于 COFF,用来加载和运行一个可执行文件,后 21 个字段则是通过链接器追加的,作为 PE 扩展部分,用于描述可执行文件的一些信息,供 PE 加载器加载使用;
首先在 DOS 头中找到 PE 头(NT 头)的偏移量,然后黄色表示的是签名(IMAGE_NT_SIGNATURE)结构体,红色表示 IMAGE_FILE_HEADER 结构体,黑色和蓝色表示 IMAGE_OPTIONAL_HEADER32 结构体,其中,黑色为 IMAGE_OPTIONAL_HEADER32 结构体中需要重点关注的部分(DataDirectory 成员未标注);
在 IMAGE_OPTIONAL_HEADER32 结构体中需要关注下列成员,这些成员是文件运行必需的,设置错误将导致文件无法正常运行:(偏移量基于 PE 文件头 (IMAGE_NT_HEADERS))
偏移量 | 字段 | Size | 注释 |
---|---|---|---|
18 | Magic | WORD | 标志字 |
28 | AddressOfEntryPoint | DWORD | 程序执行入口 RVA |
34 | ImageBase | DWORD | 程序默认载入基地址 |
38 | SetionAlignment | DWORD | 内存中区块的对齐值 |
3C | FileAlignment | DWORD | 文件中区块的对齐值 |
50 | SizeOfImage | DWORD | 内存中的整个 PE 映像尺寸 |
54 | SizeOfHeaders | DWORD | 整个 PE 头的大小 |
5C | Subsystem | WORD | 文件子系统 |
74 | NumberOfRvaAndSizes | DWORD | 数据目录表的项目数量 |
78 | DataDirectory | 数据目录表 |
Magic:单字,为 IMAGE_OPTIONAL_HEADER32 结构体时,Magic 码为 10B;为 IMAGE_OPTIONAL_HEADER64 结构体时,Magic 码为 20B,文件为 ROM 映像时,Magic 码为 107;
AddressOfEntryPoint:双字,持有 EP 的 RVA 值,该值指出程序最先执行的代码的起始地址,非常重要;
ImageBase:双字,进程的虚拟内存范围是 00000000 ~ FFFFFFFF(32 位系统),PE 文件被加载到如此大的内存中时,ImageBase 指出文件的优先装载地址,而 ImageBase 就是文件在内存中的首选载入地址, 如果 PE 文件是在这个地址载入的,那么加载器将跳过应用基址重定位的步骤;
EXE、DLL 文件被装载到用户内存的 00000000 ~ 7FFFFFFF 中;
SYS 文件被装载到内核内存的 80000000 ~ FFFFFFFF 中;
一般而言,使用开发工具创建的 EXE 文件,其默认 ImageBase 值为 00400000,DLL 文件的 ImageBase 值为 10000000;(可以指定为其它值)
用户可以自定义 ImageBase 的值,但取值有限:- 取值不能超出边界,即取的值必须在进程地址空间中;
- 该值必须是 64KB 的整数倍;
执行 PE 文件时,PE 装载器先创建进程,再将文件载入内存,然后把 EIP 寄存器的值设置为
ImageBase + AddressOfEntryPoint
;如图所示,AddressOfEntryPoint 为 0000739D,ImageBase 为 01000000,则 EIP 的值(也就是 EP)为:0100739D;
勘误:
#### NT 头:可选头 IMAGE_OPTIONAL_HEADER
开始位置,图中的签名(IMAGE_NT_SIGNATURE)结构体标注错了,有发现的小伙伴吗?
SetionAlignment / FileAlignment
PE 体,也就是 PE 的 Body 部分划分为若干个节区,这些节区存储着不同类别的数据;
SetionAlignment:双字,指定了节区在内存中的最小单位,每个节区被载入的地址必定是此字段指定数值的整数倍;
SetionAlignment 默认的对齐尺寸是目标 CPU 的页尺寸,对于 32 位操作系统来说,这个值是 4KB(16 进制表示为 1000),对于 64 位操作系统来说,这个值是 8KB(16 进制表示为 2000);
FileAlignment:双字,指定了节区在磁盘文件中的最小单位,组成块的原始数据必须保证从本字段的倍数地址开始;
通常情况下,Windows 会选择使用 512 字节(最大为 4KB)的簇大小(一个物理扇区的大小)来格式化分区,用 16 进制表示为 200,这就是常见的代码段、数据段等起始地址是 200 的倍数的原因;
SetionAlignment 必须大于或等于 FileAlignment,如果 SetionAlignment 被定义为小于操作系统页的大小,则 SetionAlignment 和 FileAlignment 的值必须相等;
SizeOfImage:双字,加载 PE 文件到内存时,SizeOfImage 指定了 PE 映像在虚拟内存中所占空间的大小;
映像载入内存后的总尺寸,是指载入文件从 ImageBase 到最后一个块的大小,最后一个块根据其大小向上取整;
一般而言,文件的大小与加载到内存中的大小是不同的(节区头中定义了各节装载的位置与占有内存的大小),SizeOfImage 可以比实际的值大,但不能比它小,且 SizeOfImage 必须是 SetionAlignment 的整数倍;
SizeOfHeaders:双字,用来指出整个 PE 头的大小,该值必需是 FileAlignment 的整数倍;
MS-DOS 头部、PE 文件头、节表的总尺寸,是按照文件对齐粒度对齐后的大小(含补足的 0);
第 1 节区所在位置与 SizeOfHeaders 距文件开始偏移的量相同;
Subsystem:单字,用来区分系统驱动文件(*.sys)与普通的可执行文件(*.exe,*.dll);
一个标明可执行文件所期望的子系统(用户界面类型)的枚举值,这个值只对 EXE 重要;
成员值如下:
值 常量符号 说明 0 IMAGE_SUBSYSTEM_UNKNOWN 未知的子系统 1 IMAGE_SUBSYSTEM_NATIVE 设备驱动和 Native Windows 进程 2 IMAGE_SUBSYSTEM_WINDOWS_GUI 图形接口子系统,Windows 图形用户界面 3 IMAGE_SUBSYSTEM_WINDOWS_CUI 字符子系统,Windows 字符模式(控制台) 7 IMAGE_SUBSYSTEM_POSIX_CUI POSIX 字符子系统 (控制台) 9 IMAGE_SUBSYSTEM_WINDOWS_CE_GUI Windows CE 图形界面 10 IMAGE_SUBSYSTEM_EFI_APPLICATION 可扩展固件接口(EFI)应用程序 11 IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER 带引导服务的 EFI 驱动程序 12 IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER 带运行时服务的 EFI 驱动程序 13 IMAGE_SUBSYSTEM_EFI_ROM EFI ROM 映像 14 IMAGE_SUBSYSTEM_XBOX XBOX NumberOfRvaAndSizes:双字,用来指定 DataDirectory 数组的个数;
数据目录表(DataDirectory)的项数,这个字段的值从 Windows NT 发布以来一直是 16(10),实际应用中可以取 2 ~ 16 的值;
PE 装载器通过查看 NumberOfRvaAndSizes 的值来识别数组大小;
DataDirectory:是由 IMAGE_DATA_DIRECTORY 结构体组成的数组,数组的每项都有被定义的值,指向输出表、输入表、资源块等数据;
数据目录表的每个成员占 8 个字节,分别指向相关的结构体,前 4 个字节表示地址,后 4 个字节表示大小,最后一个成员必须为 0;
序号 成员 结构 偏移量(PE / PE32+) 描述 0 Export Table ⚠️ IMAGE_DIRECTORY_ENTRY_EXPORT 78 / 88 导出表 1 Import Table ⚠️ IMAGE_DIRECTORY_ENTRY_IMPORT 80 / 90 导入表 2 Resources Table ⚠️ IMAGE_DIRECTORY_ENTRY_RESOURCE 88 / 98 资源表 3 Exception Table IMAGE_DIRECTORY_ENTRY_EXCEPTION 90 / A0 异常表 4 Security Table IMAGE_DIRECTORY_ENTRY_SECURITY 98 / A8 属性证书表 5 Base relocation Table IMAGE_DIRECTORY_ENTRY_BASERELOC A0 / B0 基地址重定位表 6 Debug IMAGE_DIRECTORY_ENTRY_DEBUG A8 / B8 调试信息表 7 Copyright IMAGE_DIRECTORY_ENTRY_COPYRIGHT B0 / C0 版权表 8 Global Ptr IMAGE_DIRECTORY_ENTRY_GLOBALPTR B8 / C8 全局指针表 9 Thread local storage (TLS) ⚠️ IMAGE_DIRECTORY_ENTRY_TLS C0 / D0 线程本地存储 10 Load configuration IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG C8 / D8 加载配置表 11 Bound Import IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT D0 / E0 绑定导入表 12 Import Address Table (IAT) ⚠️ IMAGE_DIRECTORY_ENTRY_IAT D8 / E8 导入函数地址表 13 Delay Import IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT E0 / F0 延迟导入表 14 COM descriptor IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR E8 / F8 15 保留,必须为 0 F0 / 100 系统保留 PE 文件在定位输出表、输入表和资源表等重要数据时,就是从 IMAGE_DATA_DIRECTORY 结构体开始的;
节区头 IMAGE_SECTION_HEADER 结构体数组
PE 文件中的 code(代码)、data(数据)、resource(资源)等按照属性分类存储在不同节区;
节区头中定义了各节区属性;
节区属性中有文件/内存的起始位置、大小、访问权限等;
不同内存属性的访问权限:
类别 | 访问权限 |
---|---|
code | 执行,读取权限 |
data | 非执行,读写权限 |
resource | 非执行,读取权限 |
节区头是由 IMAGE_SECTION_HEADER 结构体组成的数组,每个结构体对应一个节区,每个结构体包含它所关联区块的信息,如位置、长度、属性等,该数组的数目由 IMAGE_NT_HEADERS.IMAGE_FILE_HEADER.NumberOfSections 指出,说人话就是:节区头中结构体的的数量由 PE 头中的可选头(扩展信息头)中的 NumberOfSections 指定;
IMAGE_SECTION_HEADER 结构体中的重要成员如下:偏移量基于节区头(当前 IMAGE_SECTION_HEADER 结构体)
偏移量 | 项目 | 含义 |
---|---|---|
8 | VirtualSize | 内存中节区所占大小 |
0C | VirtualAddress | 内存中节区起始位置(RVA) |
10 | SizeOfRawData | 磁盘文件中节区所占大小 |
14 | PointerToRawData | 磁盘文件中节区起始位置 |
24 | Characteristics | 节区属性(bit OR) |
VirtualSize:双字,指出实际被使用的区块的大小,是在进行对齐处理前区块的实际大小;
VirtualAddress:双字,该块装载到内存中的 RVA,这个地址是按照内存页对齐的,它的数值总是 SectionAlignment 的整数倍;
SizeOfRawData:双字,该块在磁盘中所占的空间,在可执行文件中,该字段包含经 FileAlignment 调整的块的大小;
PointerToRawData:双字,该块在磁盘文件中的偏移,程序经编译或汇编后生成原始数据,这个字段用于给出原始数据在文件中的偏移;
如果程序装载自 PE 或 COFF 文件(而不是由操作系统载入的),这一字段将比 VirtualAddress 还重要,在这种情况下,必须完全使用线性映像的方法载入文件,所以需要在该偏移处找到块的数据,而不是 VirtualAddress 字段中的 RVA 地址;
Characteristics:双字,块属性,该字段是一组指出块属性的标志,多个标志值求或即为 Characteristics 的值;
Characteristics 的值由下列值组合而成:
定义 值 说明 IMAGE_SCN_CNT_CODE 00000020 包含代码,常与 10000000 一起设置 IMAGE_SCN_CNT_INITIALIZED_DATA 00000040 该块包含已初始化的数据 IMAGE_SCN_CNT_UNINITIALIZED_DATA 00000080 该块包含未初始化的数据 IMAGE_SCN_MEM_DISCARDABLE 02000000 该块可被丢弃,因为它一旦被载入,进程就不再需要它了
常见的可丢弃块是 .reloc(重定位块)IMAGE_SCN_MEM_SHARED 10000000 该块为共享块 IMAGE_SCN_MEM_EXECUTE 20000000 该块可执行,通常当 00000020 标志被设置时,该标志也被设置 IMAGE_SCN_MEM_READ 40000000 该块可读,可执行文件中的块总是设置该标志 IMAGE_SCN_MEM_WRITE 80000000 该块可写,如果 PE 文件中没有设置该标志,装载程序就会将内存映像页标记为可读或可执行
VirtualAddress 与 PointerToRawData 不带有任何值,分别由定义在 IMAGE_OPTIONAL_HEADER32 中的 SectionAlignment 与 FileAlignment 确定;
VirtualSize 与 SizeOfRawData 一般具有不同的大小,即磁盘文件中节区的大小与加载到内存中的节区的大小是不同的;
如果 VirtualSize 的值大于 SizeOfRawData 的值,那么 SizeOfRawData 表示来自可执行文件初始化数据的大小,与 VirtualSize 相差的字节用 0 填充;
最后,PE 规范未明确规定节区的 Name,所以可以向其中放入任何值,甚至可以填充 NULL 值,因此,节区的 Name 字段仅供参考,不能保证其百分百的用作某种信息,说人话就是:节区的名字不可信;
RVA to RAW
PE 文件加载到内存时,每个节区都要能准确完成内存地址与文件偏移间的映射,这种映射一般称为 RVA to RAW;
方法如下:
查找 RVA 所在节区;
使用简单的公式计算文件偏移(RAW)
根据 IMAGE_SECTION_HEADER 结构体,换算公式如下:
1
2RAW - PointerToRawData = RVA - VirtualAddress
RAW = RVA - VirtualAddress + PointerToRawData
IAT(Import Address Table,导入地址表)
在 PE 文件内有一组数据结构,它们分别对应于被输入的 DLL,每一个这样的结构体都给出了被输入的 DLL 的名称并指向一组函数指针,这组函数指针称为输入地址表(IAT);
每一个被引入的 API 在 IAT 里都有保留的位置,在那里它将被 Windows 加载器写入输入函数的地址,也就是调用位置;
一旦模块被载入,IAT 中将包含所要输入函数的地址;
把所有输入函数放在 IAT 中,这样,无论代码中用一个输入函数多少次,都会通过 IAT 中的同一个函数指针来完成;
DLL(Dynamic Link Library,动态链接库)
16 位的 DOS 时代不存在 DLL 这一概念,只有“库”一说,比如在 C 语言中使用 printf() 函数时,编译器会从 C 语言库中读取相应函数的二进制代码,然后插入应用程序的源代码中,也就是说,可执行文件中包含着 printf() 函数的二进制代码;
Windows 系统支持多任务,如果同时运行多个程序,而每个程序包含相同的库,将造成严重的内存浪费和磁盘浪费,因此,Windows 引入了 DLL 这一概念,描述如下:
- 不要把库包含到程序中,单独组成 DLL 文件,需要时调用即可;
- 内存映射技术使加载后的 DLL 代码、资源在多个进程中实现共享;
- 更新库时,只要替换相关的 DLL 文件即可,简单易行;
加载 DLL 的方式有两种:
- “显式链接”(Explicit Linking),程序使用 DLL 时加载,使用完毕后释放内存,必须确定目标 DLL 已经被加载,然后才寻找 API 的地址,这几乎总是通过调用 LoadLibrary 和 GetProcAddress 完成的;
- “隐式链接”(Implicit Linking),程序开始时即同时加载 DLL,程序终止时再释放占用的内存,Windows 加载器保证 PE 文件所需的任何附加的 DLL 都被载入,调用 LoadLibrary 和 GetProcAddress 的过程由 Windows 加载器完成;
IAT 提供的机制与隐式链接有关;
大多程序在调用 API 时,都并非直接调用,而是通过获取指定地址处的值来实现调用,为什么不直接调用呢?
这是由于,程序的制作者编译程序时,无法确定程序的运行环境(XP、Vista、7、10)、语言(ENG、CHS、JPN、KOR)、服务包(Service Pack),不同环境中,使用的 DLL 版本各不相同,指定的 API 的实际位置也会不同;
为了确保能在所有环境中都能正常调用 API,编译器准备并记录要保存 API 的实际地址,执行文件时,PE 装载器会将 API 的地址写入到这个实际地址;
编译器不使用直接指令调用 API 的另一个原因在于 DLL 重定向。
DLL 文件的 ImageBase 值一般为 10000000,比如程序使用了 a.dll 和 b.dll,PE 装载器先将 a.dll 装载到内存的 10000000 处,然后尝试将 b.dll 也装载到该处,但由于该地址已经装载了 a.dll,所以,PE 装载器会查找其它空白的内存空间,然后将 b.dll 装载进去,这就是 DLL 重定向,这就使得无法对实际地址进行硬编码;
还有一个原因在于:PE 头中表示地址时使用 RVA,而不是 VA;
实际操作中,无法保证 DLL 一定会被加载到 PE 头指定的 ImageBase 处,但是 EXE 文件(生成进程的主体)却能准确的加载到自身的 ImageBase 中,这是因为它拥有自己的虚拟空间;
输入函数的调用
输入函数就是被程序调用但其执行代码不在程序中的函数,这些函数的代码位于相关的 DLL 中,在调用程序中只保留相关的函数信息,如函数名、DLL 文件名等;
对磁盘上的 PE 文件来说,它无法得知这些输入函数在内存中的地址;
只有当 PE 文件载入内存后,Windows 加载器才会将相关 DLL 载入,并将调用输入函数的指令和函数实际所处的地址联系起来;
当应用程序调用一个 DLL 的代码和数据时,它正在被隐式链接到 DLL,这个过程完全由 Windows 加载器完成;
IMAGE_IMPORT_DESCRIPTOR 导入表描述符
IMAGE_IMPORT_DESCRIPTOR 结构体中记录着 PE 文件要倒入哪些库文件;
可执行文件使用来自其它 DLL 的代码或数据的动作称为输入(import);
当 PE 文件被载入时,Windows 加载器的工作之一就是定位所有被输入的函数和数据,并让正在载入的文件可以使用这些地址,这个过程是通过 PE 文件的输入表(Import Table,简称“IT”,也称导入表)完成的;
输入表中保存的是函数名和其驻留的 DLL 名等动态链接库所需的信息;
在 PE 文件头的可选映像头中,数据目录表的第 2 个成员指向输入表,即 IMAGE_NT_HEADERS.IMAGE_OPTIONAL_HEADER.DataDirectory[1],也就是 IMAGE_DIRECTORY_ENTRY_IMPORT;
输入表(数组)以 IMAGE_IMPORT_DESCRIPTOR(IID) 结构体开始,每个被 PE 文件隐式链接的 DLL 都有一个 IID,这个数组中,没有字段指出该结构体数组的项数,但它的最后一个单元是 NULL,由此可以计算出该数组的项数;
执行一个普通程序时,往往需要导入多个库,导入多少个库就存在多少个 IID 结构体,这些结构体形成了数组,且结构体数组最后以 NULL 结构体结尾;
IMAGE_IMPORT_DESCRIPTOR 结构体的成员:
项目 | 含义 |
---|---|
OriginalFirstThunk(Characteristics)⚠️ | INT 的地址(RVA) |
TimeDateStamp | 一个 32 位的时间标志,可忽略 |
ForwarderChain | 第 1 个被转向的 API 的索引,一般为 0 |
Name ⚠️ | 库名称字符串的地址(RVA) |
FirstThunk ⚠️ | IAT 的地址(RVA) |
INT:输入名称表(Import Name Table);
IAT:输入地址表(Import Address Table);
- OriginalFirstThunk(INT):双字,包含指向输入名称表(INT)的 RVA;
INT 是一个包含导入函数信息(Ordinal,Name)的结构体指针数组,只有获得了这些信息,才能在加载到进程内存的库中准确求得相应函数的起始地址;
INT 是一个 IMAGE_THUNK_DATA 结构的数组,数组中的每个 IMAGE_THUNK_DATA 结构都指向 IMAGE_IMPORT_BY_NAME 结构,数组尾部以 NULL 结束,即数组以一个内容为 0 的 IMAGE_THUNK_DATA 结构体结束; - TimeDateStamp:双字,一个 32 位的时间标志,可忽略;
- ForwarderChain:双字,这是第 1 个被转向的 API 的索引,一般为 0,在程序中引用一个 DLL 中的 API,而这个 API 又在引用其它 DLL 的 API 时使用;
- Name:双字,DLL 名字的指针,是一个以 “00” 结尾的 ASCII 字符的 RVA 地址,该字符包含输入 DLL 的名称;
Name 是一个字符串指针,它指向导入函数所属的库文件的名称; - FirstThunk(IAT):双字,包含指向输入地址表(IAT)的 RVA;
OriginalFirstThunk 与 FirstThunk 相似,它们分别指向两个本质上相同的数组 IMAGE_THUNK_DATA 结构;
两个数组中都有 IMAGE_THUNK_DATA 结构类型的元素,它是一个指针大小的联合(union);
每个 IMAGE_THUNK_DATA 元素对应于一个从可执行文件输入的函数;
两个数组的结束都是由一个值为 0 的 IMAGE_THUNK_DATA 元素表示;
IMAGE_THUNK_DATA 结构实际上是一个双字,该结构在不同时刻有不同的含义,定义如下:
1 | IMAGE_THUNK_DATA STRUCT |
当 IMAGE_THUNK_DATA 值的最高位(双字的最高位)为 1 时,表示函数以序号方式输入,这时低 31 位(或者 64 位可执行文件的低 63 位)被看成一个函数序号;
当 IMAGE_THUNK_DATA 值的最高位(双字的最高位)为 0 时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个 RVA,指向一个 IMAGE_IMPORT_BY_NAME 结构;
IMAGE_IMPORT_BY_NAME 结构仅有 1 个字大小,存储了一个输入函数的相关信息,定义如下:
1 | IMAGE_IMPORT_BY_NAME STRUCT |
- Hint:本函数在其所驻留 DLL 的输入表中的序号,该域被 PE 装载器用来在 DLL 的输出表里快速查询函数;
该值不是必需的,一些链接器将它设为 0; - Name:含有输入函数的函数名,函数名是一个 ASCII 字符串,以 NULL 结尾;
这里虽然将 Name 的大小以字节为单位进行定义,但其实它是一个可变尺寸域,由于没有更好的表示方式,只好在定义中写成 BYTE;
PE 装载器把导入函数输入至 IAT 的顺序:
1 | 1. 读取 IID 的 Name 成员,获取库名称字符串; |
以 NOTEPAD.EXE 为例,梳理思路,强化学习
在 PE 文件头的可选映像头中,数据目录表的第 2 个成员指向输入表;
数据目录表是 IMAGE_DIRECTORY_ENTRY_IMPORT,位于 IMAGE_OPTIONAL_HEADER32.DataDirectory[1];
而 IMAGE_OPTIONAL_HEADER32 位于 IMAGE_NT_HEADERS,也就是 PE 头;
使用 WinHex 打开 NOTEPAD.EXE,找到 PE 头的偏移位置(位于从文件开始偏移 3C 字节处);
找到 PE 头后,IMAGE_OPTIONAL_HEADER32 是其的第 3 个成员,所以,直接定位到第 3 个成员,然后找到 DataDirectory:
绿色是 IMAGE_NT_SIGNATURE;
黄色是 IMAGE_FILE_HEADER;
蓝色是 IMAGE_OPTIONAL_HEADER32 的其它成员,从 158 位置开始,是 DataDirectory 数组成员,也就是数据目录表成员,最后的红色则表示 DataDirectory[1];
数据目录表的每个成员占 8 个字节,分别指向相关的结构体,前 4 个字节表示地址,后 4 个字节表示大小;
所以在这里,IMAGE_DIRECTORY_ENTRY_IMPORT 的相对虚拟地址是
7604
;由于得到的地址是 RVA,所以还需要转换为 RAW,先来看看 RAW 的计算公式:RAW = RVA - VirtualAddress + PointerToRawData,这其中的 RVA 是已知的,也就是
7604
,而 VirtualAddress 和 PointerToRawData 如何得知呢?VirtualAddress 和 PointerToRawData 位于 IMAGE_SECTION_HEADER 结构体;
IMAGE_SECTION_HEADER 结构体在 PE 头的下方,所以 DataDirectory 数组之后就是 IMAGE_SECTION_HEADER 结构体,也就是 节区表(区块表);
图中选中部分是 DataDirectory 数组;
红色是第 1 个节区的部分参数,第 3 个参数说明,此节区(.text)在内存中的起始位置(VirtualAddress)是 00001000,第 5 个参数说明,此节区在磁盘文件中的偏移(PointerToRawData)是 00000400;
绿色是第 2 个节区的部分参数,第 3 个参数说明,此节区(.data)在内存中的起始位置(VirtualAddress)是 00009000,这里为什么要看第 2 节区的 VirtualAddress 呢?因为确定了第 2 节区的起始位置,也就确定了第 1 节区的结束位置,也就是范围,很显然,1000 < 7604 < 9000,位于第 1 节区(.text);
根据公式计算,第 1 节区的 RAW = 7604 - 1000 + 400,结果是
6A04
;既然已知第 1 节区的 RAW,按下
ALT + G
或者菜单栏选择位置 >> 转到偏移位置
:图中所示就是第 1 节区 IID,共有 5 个成员,分别来看一下:
第 1 个成员是 OriginalFirstThunk:INT 的地址(RVA);根据计算,其 RAW 是
6D90
;跳转到
6D90
可以看到输入名称表数组,最后一个单元为 NULL,跟随第 1 个成员,查看其名称,RVA:7A7A -> RAW:6E7A初始的 2 个字节为 Ordinal,是库中函数的固有编号,Ordinal 后面是导入函数的名称字符串
PageSetupDlgW
(末尾以 Terminating NULL[‘\0’]结尾);第 2 个成员是 TimeDateStamp,由于这里的值为 0,故忽略;
第 3 个成员是 ForwarderChain,其值也为 0,忽略;
第 4 个成员是 Name:DLL 名字的指针;RVA:7AAC -> RAW:6EAC;
可以看到,包含
PageSetupDlgW
的库文件是comdlg32.dll
;第 5 个成员是 FirstThunk(IAT):包含指向输入地址表(IAT)的 RVA;RVA:12C4 -> RAW:6C4;
选中区域即为 IAT 数组区域,对应于 cmdlg32.dll 库,与 INT 类似,由结构体指针组成,且以 NULL 结尾;
EAT(Export Address Table,导出地址表)
在 Windows 系统中,“库”是为了方便其它程序调用而集中包含相关函数的文件(DLL / SYS);
创建 DLL 时,实际上创建了一组能让 EXE 或其它 DLL 调用的函数;
程序运行时, PE 装载器会根据 DLL 文件中输出的信息修正被执行文件的 IAT;
当一个 DLL 函数能够被 EXE 或其它 DLL 文件使用时,它就被“输出了(Exported)”;
输出信息被保存在输出表中,DLL 文件通过输出表向系统提供输出函数名、序号和入口地址等信息;
EAT 是一种核心机制,它使不同的应用程序可以调用库文件中提供的函数,也就是说,只有通过 EAT 才能准确获得从相应库中导出函数的起始地址;
IMAGE_EXPORT_DESCRIPTOR
与 IAT 一样,PE 文件内特定结构体(IMAGE_EXPORT_DESCRIPTOR)保存着导出信息,且 PE 文件中仅有一个用来说明库 EAT 的 IMAGE_EXPORT_DESCRIPTOR 结构体;
输出表的主要内容是一个表格,其中包括函数名称、输出序数等,序数是指 DLL 中某个函数的 16 位数字,在所指向的 DLL 中是独一无二的;
输出表是数据目录表的第 1 个成员,指向 IMAGE_EXPORT_DESCRIPTOR(简称 IED)结构;
IMAGE_EXPORT_DESCRIPTOR 结构体的重要成员:
字段 | Size | 注释 |
---|---|---|
Name | DWORD | 模块(DLL)的真实名称 |
NumberOfFunctions ⚠️ | DWORD | 实际 Export 函数的个数 |
NumberOfNames ⚠️ | DWORD | Export 函数中具名函数的个数(ENT) |
AddressOfFunctions ⚠️ | DWORD | Export 函数地址数组(数组元素个数 = NumberOfFunction) |
AddressOfNames ⚠️ | DWORD | 函数名称地址数组(数组元素个数 = NumberOfNames) |
AddressOfNameOrdinals ⚠️ | DWORD | 序号地址数组(数组元素个数 = NumberOfNames) |
ENT:输出函数名称表(Export Name Table);
EAT:输出地址表(Export Address Table);
- Name:指向一个 ASCII 字符串的 RVA,这个字符串是与输出函数相关联的 DLL 的名字;
- NumberOfFunctions: EAT 中的条目数量,0 表示没有代码或数据被输出;
- NumberOfNames:输出函数名称表(Export Name Table,ENT),NumberOfNames 的值总是小于或等于 NumberOfFunctions 的值;
- AddressOfFunctions:EAT 的 RVA,EAT 是一个 RVA 数组,数组中的每一个非零 RVA 都对应于一个被输出的序号;
- AddressOfNames:ENT 的 RVA,ENT 是一个指向 ASCII 字符串的 RVA 数组,每一个 ASCII 字符串对应于一个通过名字输出的序号;
- AddressOfNameOrdinals:输出序数表的 RVA,这个表是字(WORD)的数组,这个表将 ENT 中的数组索引映射到相应的输出地址表条目;
设计输出表是为了方便 PE 装载器工作;
模块必须保存所有输出函数的地址,供 PE 装载器查询;
模块将这些信息保存在 AddressOfFunctions 域指向的数组中,而数组元素的数量存放在 NumberOfFunctions 域中;
如果有些函数是通过名字引出的,这些名字的 RVA 值会存放在 AddressOfNames 域指向的数组中,数组元素的数量存放在 NumberOfNames 域中,以供 PE 装载器查询;
AddressOfNames 域指向的数组中仅包含函数名,并不包含函数的地址;
AddressOfNameOrdinals 域指向的数组中包含(AddressOfNames 数组中)函数名对应的(在 AddressOfFunctions 数组中的)索引,所以,序数表和名称表的元素数量相同;
PE 装载器会通过 AddressOfNameOrdinals 域指向的数组获取 AddressOfNames 域指向的数组中的函数名在 AddressOfFunctions 域指向的数组中对应的索引,从而获取函数地址;
以 KERNEL32.DLL 为例,梳理思路,强化学习
使用 WinHex 打开 KERNEL32.DLL,找到 IMAGE_EXPORT_DESCRIPTOR,位于 IMAGE_OPTIONAL_HEADER32.DataDirectory[0];
可以看到,IMAGE_EXPORT_DESCRIPTOR 的 RVA 为
262C
,则 RAW 为1A2C
;跳转到
1A2C
:选中部分为 IMAGE_EXPORT_DESCRIPTOR 所有成员,红色部分依次是 Name、AddressOfFunctions、AddressOfNames 以及 AddressOfNameOrdinals;
首先,查看 Name,RVA:4B98 -> RAW:3F98:
可以看到,库名称为
KERNEL32.dll
(以’\0’结尾);返回
1A2C
,然后查看 AddressOfFunctions,RVA:2654 -> RAW:1A54:可以看到,第 1 个输出函数的 RAV 为:A6E4;
KERNEL32.DLL 的 ImageBase 为:7C800000,故第 1 个输出函数的实际地址(VA)为:7C800000 + A6E4 = 7C80A6E4;
使用
OD
打开 KERNEL32.DLL:发现 7C80A6E4 对应的函数名为
ActivateActCtx
;返回
1A2C
,接着查看 AddressOfNames,RVA:353C -> RAW:293C:指向函数名字符串的指针是:4BA5,则 RAW 为:3FA5:
对应的函数名为
ActivateActCtx
,与查看 AddressOfFunctions 时获取的函数名一致;返回
1A2C
,最后来看 AddressOfNameOrdinals,RVA:4424 -> RAW:3824:对应的索引值为 0,没问题;