PE 文件¶
约 4298 个字 232 行代码 2 张图片 预计阅读时间 24 分钟
PE 文件格式¶
PE 文件(Portable Executable)是 Windows 操作系统下使用的可执行文件格式。其中,32 位可执行文件称 PE32,64 位的可执行文件称 PE+ 或 PE32+。
| 种类 | 主拓展名 |
|---|---|
| 可执行文件 | EXE, SCR |
| 库系列 | DLL, OCX, CPL, DRV |
| 驱动程序系列 | SYS, VXD |
| 对象文件系列 | OBJ |
严格来讲,除了 OBJ 文件外所有文件都是可执行的,尽管它被视为 PE 文件
一个 32-bit 的 PE 文件结构大致如下:
接下来选用普通的 Hello World PE 文件进行演示
DOS Header¶
DOS 头和 DOS 存根是微软为了充分考虑 PE 文件对于 DOS 文件的兼容性而设置的,其中 DOS 头的结构体如下:
结构体 IMAGE_DOS_HEADER 大小为 40B,其中需要注意两个成员:
e_magic: DOS 签名,固定为4D5A,即 ASCII 值MZe_lfanew: NT 头IMAGE_NT_HEADER相对于文件起始处的偏移
注意是小段序,因此此处 e_lfanew 的值为 00000080
DOS Stub 位于 DOS Header 下方,是一个可选项,且大小不固定。DOS 存根由代码与数据混合而成,其中 40-4D 区域是一串 16 位的汇编指令。
当程序运行在 32-bit 环境时,OS 不会运行该命令;当在 DOS 环境(16-bit)尝试运行 PE 文件时,则该段指令被执行,作用是输出 "This program cannot be run in DOS mode" 并退出。
利用该特性可以书写在 MS-DOS 和 Windows 环境中都能运行的程序
NT Header¶
NT 头的结构体如下:
Signature 是 PE 文件签名,固定为 50450000,对应 ASCII 值 PE\0\0
第二个成员 FileHeader 也被称为 COFF 头(标准通用文件格式头),表示该文件的大致属性,其结构为:
Machine: CPU 的机器码,各类具体值可见 PE 格式 - Win32 apps- 其中 Intel 386 或更高版本的处理器和兼容的处理器的机器码为
0x014c
- 其中 Intel 386 或更高版本的处理器和兼容的处理器的机器码为
NumberOfSections: 节区数量- 定义的节区数和实际节区数不同时,将发生运行时错误
TimeDataStamp: 低 32 位表示从 1970 年 1 月 1 日 00:00 到文件创建时经过的秒数PointerToSymbolTable: 符号表的文件偏移- 如果不存在符号表,则为 0
NumberOfSymbols: 符号表中的符号数量SizeOfOptionalHeader: 可选头OptionalHeader的大小- 在 32-bit 机器上默认是
0x00E0,在 64-bit 机器上默认是0x00F0
- 在 32-bit 机器上默认是
Characteristics: 文件属性,通过 bit OR 方式组合,一些常见的文件属性标志如下:
其中需要记住 0002 (exe) 和 2000 (dll)
可选头在 PE32 和 PE32+ 不同,我们仅介绍 IMAGE_OPTIONAL_HEADER32 的结构:
我们关注以下几个关键成员:
Magic: 魔数- 为
IMAGE_OPTIONAL_HEADER32时为10B - 为
IMAGE_OPTIONAL_HEADER64时为20B
- 为
AddressOfEntryPoint: 入口点函数相对于映像文件加载基址的偏移(RVA)- 对于可执行文件,这是启动地址
- 对于设备驱动,这是初始化函数地址
ImageBase: 映像文件优先被载入的虚拟地址,必须为 64KB 的整数倍- 应用程序的默认值为
0x00400000 - DLL 的默认值为
0x10000000- 因此多个 DLL 被载入时,PE Loader 一定会调整载入地址
- 应用程序的默认值为
SectionAlignment: 内存中的节对齐粒度- 默认值与系统的页大小相等
- 该值必须不小于
FileAlignment
FileAlignment: 映像文件中原始数据的对齐粒度- 范围在 \(2^9\) 到 \(2^{16}\) 之间,默认为 \(2^9\)
- 如果
SectionAlignment值小于系统页大小,则FileAlignment必须与该值设置相同
SizeOfImage: 指定在虚拟地址空间占据空间的大小SizeOfHeader: 整个 PE 头的大小,并且按照FileAlignment对齐- 那么第一节区就位于偏移
SizeOfHeader处
- 那么第一节区就位于偏移
Subsystem: 运行映像文件所需的子系统,最常用的值有:1: Driver 文件,即系统驱动(如 ntfs.sys)2: GUI 文件,即窗口应用程序(如 notepad.exe)3: CUI 文件,即控制台应用程序(如 cmd.exe)
NumberOfRvaAndSizes: 数据目录结构的个数,默认为0x00000010,即 16 个DataDirectory[]: 数据目录,结构体IMAGE_DATA_DIRECTORY如下:
其中默认的 16 个数据目录含义为:
这里需要关注 EXPORT, IMPORT, RESOURCE, TLS,它们是 PE 头中非常重要的部分
Section Header¶
节区头中定义了各节区的属性。节区头的结构体大小均为 40B,其定义如下:
我们需要关注以下几个重要成员:
VirtualSize: 内存中节区的大小VirtualAddress: 内存中节区的起始地址(RVA)SizeOfRawData: 磁盘文件中节区大小PointerToRawData: 磁盘中节区起始位置Characteristics: 节区属性,通过 bit OR 方式组合,一些常见的节区属性标志如下:
IAT¶
Import Address Table 用于记录程序正在使用库中的哪些函数。为了学习 IAT 的相关知识,我们需要先了解 DLL 的概念。
DLL 是一种动态库,加载 DLL 的方式有显式链接和隐式链接两种,分别对应着“使用时才加载”和“一开始就全部加载”。IAT 的机制与后者相关。
例如对于 hello.exe 中,调用 MessageBoxA API 的汇编语句,我们可以看到它采用间接调用地址 0x3471BC 处的值 0x771ED760 来实现。其中地址 0x3471BC 位于 IAT 内存区域,而 0x771ED760 即加载到进程中的 MessageBoxA 函数地址(位于 USER32 库中)。
由此,我们可以固定使用 0x3471BC 作为该函数的调用入口,而在 [0x3471BC] 处放置该函数实际被载入地址,以此来适应不同版本的 DLL 以及 DLL 重定位。
EXE 文件通常直接被加载到自身 ImageBase 处,但 DLL 不能保证
IAT 使用 IMAGE_IMPORT_DESCRIPTOR 作为结构体:
每导入一个库,就会创建一个 IMAGE_IMPORT_DESCRIPTOR 结构体,这些结构体构成了数组,并且最后以一个 NULL 结构体结尾。
实际在被 PE Loader 装载进内存时,IAT 的内容会被修正为函数入口地址:
我们之前学过,NT Header 中的可选头中,DataDirectory[1] 记录了 IAT 的 RVA 和 Size。对于我的 hello.exe,IAT 的 RVA 为 0x7000,Size 为 1280。
根据 Section Header,我们找到了 7000h 应位于节区 .idata 中,它的 VirtualAddress(RVA) 为 7000h,PointerToRawData 为 3000h,节区大小 VirtualSize 为 500h。因此计算可得 IAT 表在磁盘中的地址(RAW)为:
我们尝试追踪 3000h 处第一个库的 IMAGE_IMPORT_DESCRIPTOR 结构体:
| 磁盘偏移 | 成员 | RVA | RAW | 文件内位置 |
|---|---|---|---|---|
| 3000h | OriginalFirstTrunk | 00007064 | 3064 | ![]() |
| 3004h | TimeDataStamp | 00000000 | - | |
| 3008h | ForwarderChain | 00000000 | - | |
| 300Ch | Name | 0000742C | 342C | ![]() |
| 3010h | FirstTrunk | 00007114 | 3114 | ![]() |
对于 Name,我们找到其 RAW 位置的库名称为 libgcc_s_dw2-1.dll。
我们的确看到了文件中 INT 和 IAT 各项值均相同。我们进一步跟踪 INT 的第一个表项值 71C4 所对应的 RAW 值 31C4 处,可以发现导入的 API 函数名称为 register_frame_info:
EAT¶
Export Address Table 是库文件让不同的应用程序准确计算其导出函数的起始地址的关键。与 IAT 不同,一个 PE 文件中仅有一个 IMAGE_EXPORT_DESCRIPTOR 结构体,而不是一个数组。
这是因为 PE 文件可以导入多个库,但只能作为一个库导出
IMAGE_EXPORT_DESCRIPTOR 结构体的定义如下:
API GetProcAddress() 用于从库的 EAT 中获取指定 API 地址,其具体的操作原理为:
- 利用
AddressOfNames成员转到“函数名称数组” - “函数名称数组”中存储着字符串地址,通过比较字符串查找指定的函数名称,此时数组的索引为
name_index - 利用
AddressOfNameOrdinals成员转到“序号数组” - 通过
name_index在“序号数组”中找到相应的 ordinal 值 - 利用
AddressOfFunctions成员转到 EAT - 通过 ordinal 值获取指定函数的起始地址
GetProcAddress() 即接受函数名,也接受函数序号
pFunc = GetProcAddress("TestFunc");pFunc = GetProcAddress(4);
NT Header 中的可选头中,DataDirectory[0] 记录了 EAT 的 RVA 和 Size。对于我本机的 kernel32.dll,EAT 的 RVA 为 0xA3070,Size 为 0xEB68。
查询 Section Header 后,得到 EAT 位于节区 .rdata 中,它的 VirtualAddress 为 86000h,VirtualSize 为 36D1Ah,PointerToRawData 为 86000h。
经过换算,RVA 0xA3070 对应了 RAW 0xA3070,我们在该地址处寻找 IMAGE_EXPORT_DIRECTORY 结构体:
| 磁盘偏移 | 成员 | 值 | RAW |
|---|---|---|---|
| 0A3070h | Characteristics | 00000000 | - |
| 0A3074h | TimeDataStamp | 4D2D2813 | - |
| 0A3078h | MajorVersion | 0000 | - |
| 0A307Ah | MinorVersion | 0000 | - |
| 0A307Ch | Name | 000A7260 | 0xA7260 |
| 0A3080h | Base | 00000001 | - |
| 0A3084h | NumberOfFunctions | 00000694 | - |
| 0A3088h | NumberOfNames | 00000694 | - |
| 0A308Ch | AddressOfFunctions | 000A3098 | 0xA3098 |
| 0A3090h | AddressOfNames | 000A4AE8 | 0xA4AE8 |
| 0A3094h | AddressOfNameOrdinals | 000A6538 | 0xA6538 |
首先查看 Name 处的内容,的确是 KERNEL32.dll:
然后查看函数名称数组 AddressOfNames,每 4B 对应了一个函数名称的 RVA,总共 0x694 个函数:
我们以函数 ActivateActCtx 为例,它位于地址 A:72D9 处,即函数名称数组的第三个:
接下来我们查询 AddressOfNameOrdinals 数组,一个 ordinal 是 2B,因此得到函数 ActivateActCtx 的 ordinal 为 0002:
最后查询 AddressOfFunctions,得到 ordinal=2 处的 RVA 为 00037D20:
该 dll 文件的 ImageBase 为 180000000h,因此函数 ActivateActCtx 理论上应位于地址 180037D20h 处。实际运行中,加载器可能会将其重定位到别的基址上,例如如下 00007FF96EB00000,则加上 RVA 偏移后的地址为 00007FF96EB37D20,验证正确:
我使用的是路径 System32 下的 kernel32.dll,要使用 x64dbg 进行调试
如果使用 32-bit 调试器(例如 ollydbg 和 x32dbg),则系统会自动重定向加载 SysWOW64 下的 kernel32.dll,因此会出现与计算不符的情况。
运行时压缩¶
Run Time Packer 针对 PE 文件进行了压缩,在运行时压缩文件运行时在内存中解压缩并执行。运行时压缩文件无需额外的解压程序,其内部就包含了解压所需的程序,其文件大小更小,但是每次运行均需调用解码程序。
另一方面,压缩器还可用于隐藏 PE 文件内的代码及资源
为了保护 PE 文件免受代码逆向分析,在 PE 压缩器的基础上又诞生了 PE 保护器(Protector)。它不仅对文件进行了运行时压缩,还应用了多种防止代码逆向分析的技术,例如反调试、反模拟、代码混乱、多态代码、垃圾代码、调试器监控等。
经保护器处理后的代码可能比原始文件还大一些,调试起来非常困难
接下来我们使用压缩器 upx 进行测试,对 hello.exe 进行压缩:
可以看见文件大小从 105239 变为了 57623,节区数从 18 变为 3
三个节区名分别为 UPX0, UPX1, .rsrc,并且第一个节区 UPX0 的 SizeOfRawData 的值为 0,即为空节区。该空节区用来存放解压后的代码,而解压缩程序和压缩的源代码都位于第二个节区 UPX1 中。
使用 OllyDbg 调试压缩后的 PE 程序,其 EntryPoint 处代码为:
其首先将所有 32-bit 寄存器保存到栈中,然后分别为 esi 和 edi 赋值 UPX1 的起始地址和 UPX0 的起始地址。
略过中间的解压步骤,我们最终可以看见接近末尾有段代码从栈中弹出了所有保存的寄存器,然后无条件跳转到了 OEP 处:
解压涉及解压缩代码、恢复源代码 call/jmp 类指令的跳转地址、设置 IAT 等循环
事实上,UPX 的解压缩代码始终位于 pushad 和 popad 之间,为了更快找到程序的 OEP,我们可以在开头执行完 pushad 后观察栈顶的地址,并在该地址设置硬件断点。
OllyDbg 中,具体的设置方法为在左下角的 Dump 界面中跳转到栈顶地址,右键菜单中选择“断点->硬件访问->Byte”,然后继续运行程序即可。
硬件断点会停在访问该地址的指令的下一条指令
基址重定位¶
通常对于 DLL、SYS 文件,若其被加载时 ImageBase 处已经被占据,那么 PE Loader 会将其加载到其它未被占用的空间,这就是 PE 文件重定位。
Windows Vista 版本后引入了 ASLR 机制,每次运行 EXE 文件时,其也会被加载到随机地址,从而增强了系统安全性。
PE Loader 装载时的重定位主要是对应用程序中硬编码的地址进行修正,具体修正公式即为:
其中最关键的是找到哪些地址是被硬编码的,查找过程会使用内部的 Relocation Table,它通常在编译/链接过程中就被提供。
我们能看到 PE Header 中的 DataDirectory[5] 即为重定位表的 RVA 和 Size。继续以我的 hello.exe 为例,它的基址重定位的 VirtualAddress 为 B000h,Size 为 248h。
查找 Section Header,找到该 RVA 位于节区 .reloc 中,该节区的 VirtualAddress 为 B000h,PointerToRawData 为 4000h。计算可得重定位表的 RAW 为 4000h。
基址重定位表每个表项的结构 IMAGE_BASE_RELOCATION 如下:
| RVA | 数据 | 注释 |
|---|---|---|
| 4000h | 00001000 | VirtualAddress |
| 4004h | 00000154 | SizeOfBlock |
| 4008h | 3018 | TypeOffset |
| 400Ah | 3020 | TypeOffset |
| 400Ch | 302A | TypeOffset |
| 400Eh | 3034 | TypeOffset |
| ... | ||
| 4152h | 3FF2 | TypeOffset |
| 4154h | 00002000 | VirtualAddress |
| ... |
其中 TypeOffset 字段,前 4 位表示 Type,后 12 位表示 Offset。对于 PE,Type 通常为 3(IMAGE_REL_BASED_HIGHLOW);对于 PE+,Type 值通常为 A(IMAGE_REL_BASED_DIR64)。
我们以该表项的第一个 TypeOffset 为例,其真实 RVA 为 1000h + 18h = 1018h,我们在 OllyDbg 中查看 RVA 1018h 处是否存在一个硬编码的地址:
那么 RVA 1018h 就相当于地址 791018h,查看该地址处的汇编指令:
由于指向的是地址,而不是指令,因此 791018h 处就是指令 cmp word ptr [0x790000], 0x5A4D 中的 0x7900,验证正确。
重定位表也以一个 NULL 结构体结束
内嵌补丁¶
对于加密文件、运行时解压缩文件等难以直接修改指定代码的文件,常常通过内嵌补丁(Inline Code Patch)的方式打补丁,其中插入并运行的代码被称为洞穴代码(Code Cave)
下载示例文件unpackme#1.aC.exe,我们的任务要求是更改第一个对话框中的文字。
用调试器运行该文件,其直接在 EP 处调用解密函数(004010E9):
我们进入解密函数,发现其传入一个参数 004010F5 给下一层解密函数(0040109B)并嵌套调用:
接着进入该解密函数,可以看到开头有段解密循环,对 004010F5 处的数据进行与 0x44 的异或运算,总共执行 0x154 次,即该循环操作的对象是 004010F5 - 00401248 处的数据。
完成该操作后,继续将 004010F5 作为参数调用下一层解密函数(004010BD),我们接着进入该函数:
该函数一眼即可见有两处解密循环。其中第一处循环对 00401007 - 00401085 处的数据进行了与 0x7 的异或运算;第二处循环对 004010F5 - 00401248 处的数据进行了与 0x11 的异或运算。最后恢复 eax 并返回。
返回后,继续将 004010F5 作为参数调用下一层解密函数(00401039):
不过该函数并没有对数据部分进行操作,而是对解压出来的数据进行 CrC 冗余校验,如果校验不通过则会报错。
最后,来到解压后的主函数附近,看到程序传递给 MessageBox 和 Dialog 的两个参数的地址分别为 0040110A 和 00401123,这也是我们想要修改的数据:
根据之前的分析,这两个数据都位于二次加密的区域,我们很难直接对加密后的数据进行修改,因此采用内嵌补丁的方式。
综合来看,该程序的运行方式如下:
其中只有 [B] 会被校验,我们可以尝试修改 [A] 中的 JMP 40121E 为跳转到我们自己的补丁代码处。
为此,我们需要寻找一个空白区域作为我们的洞穴代码书写出。观察 Section Header,可以看到第一个节区 .text 占据了 RVA 1000 - 2000,占据了 RAW 400 - 800,但是实际使用的 VirtualSize 只有 280。因此,文件中 680 - 800 这一段是空白的,我们就选择在这里书写。
我们现在调试器中对该段进行书写,观察得程序被加载到基址 400000,因此我们希望书写的空白段位于 401280 - 401400 处。
然后,我们要修改 401083 处的 jmp 40121E 为 jmp 401280:
由于区域 [A] 需要经过异或 7 的加密解密处理,我们也对其进行处理后得到加密后的机器码 EE FF 06:
后面的 0000 并不在加密范围内,只加密到 401085
完成上述修改后,在右键菜单中选择“复制到可执行文件->所有修改”即可得到补丁完的可执行文件,执行效果如下:


































