(一)介绍PE 格式
本系列主要是参考英文原本ARTeam的PE File Format Tutorial并加以注解,以最简洁的语言来阐述PE格式,帮助大家快速入门。在开始之前,请确定您懂得C语言,至少是基本数据类型、数组和结构体,以及会WinHEX的基本使用方法。任何错误都欢迎指出,感激不尽!
(一)介绍PE 格式
PE格式(
Portable Executable,可移植可执行文件)是原生的Win32文件格式,任何的32位应用程序(Executable)包括.NET、动态链接库(DLL)、控件(OCX)以及控制面板程序(CPL)均为PE格式,其实NT内核ntoskrnl.exe也是PE格式。
我们研究
PE格式的目的有:一是给应用程序添加代码功能,比如注册机代码注入(Keygen Injecton);二是手动给EXE脱壳。
现在很多程序都加“壳”,一来是减少映像文件尺寸,二来可以保护程序文件不被轻易修改。被加壳的
EXE一般它的输入表(import table)与数据区段(.data)都被加密过,并且被插入了一段脱壳的代码,当程序执行时会先执行这段代码给内存中的数据解密,接着会修复输入表和各个区段,然后再跳到原始入口点执行。
原文中使用的范例程序是
delphi写的进制转换程序,本文中我使用的是VC9编写的一个很简单的数组求和程序,在debug模式下编译。这并不影响我们学习PE格式。
首先两个概念,
PE文件被加载器加载到内存后被称为一个模块(module),存储模块首地址的称为模块句柄(handle of module),简写为HMODULE。
然后我们看一下
PE格式的总体结构(这张图MS已经遍布全球了):
PICTURE MISSING
前
4块我们后面会细讲,就区段来说(Section),一个PE文件至少要有2个区段,代码区段用来存储程序代码,以及数据区段用来存储各种数据。NT为PE预定义了9个区段:.text,.bss,.rdata,.data,.rsrc,.edata,.idata,.pdata,.debug。一个PE既可以选择其中的几个区段,也可以自己定义额外的区段满足特别需要。
一般来说,
.text是代码区段,.data、.rdata、.bss是数据区段,.rsrc是资源区段,.edata与.idata分别是输出输入表区段,.debug是调试信息区段。这些名字(.text, .data…)实际上是给程序员看着方便的,执行程序时,系统会完全忽略它们。
最后一点,就是
PE文件在硬盘中的结构顺序和当它们被加载到内存中后的顺序并非一样,记录硬盘上排列的是File Alignment域(域是含有特定信息的二进制片段),记录内存中排列的是Section Alignment域,这既是PE加载器调整的结果,也是虚拟内存机制的作用。关于Windows虚拟内存机制,请参考相关资料,或者我的另一篇文章。就是有一点要注意的是,PE文件中的每一个区段总是在一个新的页面中存放的。
(二)DOS MZ header
一个
PE程序总是以一个64字节的DOS header结构开头,目的就是为了如果程序在DOS中运行,DOS会识别它为正确的EXE并进而运行DOS stub,它的作用就是输出字符串”This program cannot run in DOS mode.”然后退出。
下面就是
DOS Header在C语言中的结构表示(来自MinGW的winnt.h)
typedef
struct _IMAGE_DOS_HEADER {
WORD e_magic; 签名
MZ (4Dh 5Ah)// Magic DOS
WORD e_cblp; 文件最后一页的字节数//
WORD e_cp; 文件中的页数//
WORD e_crlc; 重定位的个数//
WORD e_cparhdr; 段落中头的长度//
WORD e_minalloc; 额外段落需要的最小分配值//
WORD e_maxalloc; 额外段落需要的最大分配值//
WORD e_ss; 初始
SS寄存器值//
WORD e_sp; 初始
SP寄存器值//
WORD e_csum; 校验和//
WORD e_ip; 初始
IP值//
WORD e_cs; 初始
CS值//
WORD e_lfarlc; 重定位表在文件中的地址//
WORD e_ovno; 覆盖数量//
WORD e_res[4]; 保留字//
WORD e_oemid; 标识符// OEM
WORD e_oeminfo; 信息// OEM
WORD e_res2[10]; 保留字//
LONG e_lfanew; 的起始偏移量// PE header
} IMAGE_DOS_HEADER,*PIMAGE_DOS_HEADER;
首先声明,
WORD表示unsigned short int(2bytes), 而LONG和后面会出现的DWORD表示unsigned int(4 bytes),其定义符合C99标准。
我们只关心其中两个成员,
e_magic是Magic Number,它总是等于0x4d5a,也就是MZ这两个字符,这是为了几纪念Mark Zbikowsky,MS-DOS设计者之一。
e_lfanew指向的是
PE header,这才是PE文件真正的开始,Win32EXE加载器会读取其中的地址并找到PE header,DOS stub因此被跳过。我们可以来实践一下下,用WinHEX打开我们的范例程序testPE.exe。
PICTURE MISSING
可以看到前两个
byte,确实是MZ表示一个合法的EXE文件,图中的前四行(0h~3Fh)共64字节即为IMAGE_DOS_HEADER结构,e_lfanew是最后四个字节,从图中我们可以读出,位于3Ch~3Fh的依次是D8,00,00,00,因为机器中整型存放遵循高位在高地址处的原则,所以实际的值是00,00,00,D8,我们顺势找到D8h,马上就发现了PE两个字,从这里开始便是真正的PE header。那么位于40h到D7h 的东西从PE结构图就可以看出是DOS stub,其实就是一段汇编代码,我们就不管了。
(三)PE header
PE header对应C中的IMAGE_NT_HEADER32结构,如下:
typedef
struct _IMAGE_NT_HEADERS {
DWORD Signature;
PE Signature PE.. (50h 45h 00h 00h)//
IMAGE_FILE_HEADER FileHeader;
PE的文件头信息//
IMAGE_OPTIONAL_HEADER OptionalHeader; 可选头//
} IMAGE_NT_HEADERS32,*PIMAGE_NT_HEADERS32;
再是真实
PE文件的截图:
PICTURE MISSING
(一)中我们已经得到
PE header的起始地址D8h,直到DBh,共4个字节,就是结构中的Signature成员,数据为50h 45h 00h 00h,对应值为00 00 45 50h(注,以后凡说到值,均指按高位在高地址的原则倒过来后的实际值,不再说明),对应winnt.h中的常数IMAGE_NT_SIGNATURE,如果Signature等于454Ch表示是IMAGE_VXD_SIGNATURE,即Win3.X中的Virtual Device Driver;等于454Eh表示IMAGE_OS2_SIGNATURE,即OS2的程序等等,请自行查阅winnt.h。
IMAGE_NT_HEADERS32结构只有三个成员,比较简单,但是展开后两个成员的结构体
IMAGE_FILE_HEADER, IMAGE_OPTIONAL_HEADER,就会让人绝倒了。。。我们首先看一下IMAGE_FILE_HEADER结构:
typedef
struct _IMAGE_FILE_HEADER {
WORD Machine; 支持机器的类型//
WORD NumberOfSections; 区段的个数//
DWORD TimeDateStamp; 连接器创建
EXE时的日期时间//
DWORD PointerToSymbolTable; 旧文件中,
COFF符号表地址,没有是0 //
DWORD NumberOfSymbols;
COFF符号表中符号的个数//
WORD SizeOfOptionalHeader; 的长度
(32位EXE中是224字节) //
Optional header
WORD Characteristics; 见下面的说明//
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
结合图,分析如下:
Machine值为
014Ch,对应IMAGE_FILE_32BIT_MACHINE常数,即32位机器,其他值请查阅winnt.h。
接着两个字节表示
NumberOfSections区段的个数,值为0007h,也就是我们的testPE.exe有7个区段。
SizeOfOptionalHeader位于
ECh,EDh,值为00E0h,即后面的Optional Header结构总长为224字节。
再看一个
Characteristics,表示这个PE文件的类型,图中位于EEh和EFh,值为0102h,是使用二进制位来标记的,二进制为100000010 = IMAGE_FILE_32BIT_MACHINE | IMAGE_FILE_EXECUTABLE_IMAGE,表示该文件是32位的EXE文件,如果它的值是2XXXh,则表示是一个DLL,具体还请查阅winnt.h。(唉。。。这句话打的好累啊,以后就说自行查阅了)
(四)Optional header
特地把Optional header放一篇文章,是因为它比较复杂与庞大,也因为它比较重要。
老样子,先是IMAGE_OPTIONAL_HEADER结构:
typedef
struct _IMAGE_OPTIONAL_HEADER {
WORD Magic; 又是
Magic//
BYTE MajorLinkerVersion; 链接器主要版本//
BYTE MinorLinkerVersion; 链接器次要版本//
DWORD SizeOfCode; 所有代码区段的长度之和//
DWORD SizeOfInitializedData; 同上//
DWORD SizeOfUninitializedData; 同上//
// 代码起始执行处,对
DLL可选,没有是0
DWORD AddressOfEntryPoint;
DWORD BaseOfCode; 载入内存后代码的起始地址,相对
ImageBase而言//
DWORD BaseOfData; 载入内存后代码的起始地址,相对
ImageBase而言//
DWORD ImageBase; 期望的内存中映像载入地址//
DWORD SectionAlignment; 内存中区段排列基数//
DWORD FileAlignment; 磁盘上区段排列基数//
// OS最低主要版本号
WORD MajorOperatingSystemVersion;
// OS最低次要版本号
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion; 映像文件的主要版本号//
WORD MinorImageVersion; 映像文件的次要版本号//
WORD MajorSubsystemVersion; 子系统的主要版本号//
WORD MinorSubsystemVersion; 子系统的次要版本号//
DWORD Win32VersionValue; 由编译器定义,
PE规定这里是保留位置 //
// 加载到内存中的映像文件所占内存的大小,一定是
SectionAlignment的整数倍
DWORD SizeOfImage;
DWORD SizeOfHeaders; 所有头与区段表的长度之和//
// 映像文件校验和,仅仅针对内核模式驱动和一些系统
DLL
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;
为了方便比对,我把前面那张
PE截图再贴一下:
PICTURE MISSING
_IMAGE_OPTIONAL_HEADER占用从
0F0h~14Fh,也就是图中第三行开始总共六行。
同样,下面挑选一些有用的结构成员讲一下。
Magic。位置
F0~F1,值为010Bh。Winnt.h 为我们定义了如下三个常数:
#define IMAGE_NT_OPTIONAL_HDR32_MAGIC 0x10b
#define IMAGE_NT_OPTIONAL_HDR64_MAGIC 0x20b
#define IMAGE_ROM_OPTIONAL_HDR_MAGIC 0x107
也就是符合第一个,表示
32位程序。最后一个是ROM,指刷在ROM里面的程序,反正我从来没见过。
MajorLinkerVersion
。也就是接下去的一个字节,图中是
09h,说明我们的连接器版本是9.0(VC9),MinorLinkerVersion同理。
SizeOfCode。值为
00 00 36 00,即13824字节,指所有代码区段的长度和。
SizeOfInitializedData。值为
00 00 42 00,即16896字节,是所有已初始化数据的长度和。
AddressOfEntryPoint。截图中的位置,
100h~103h,就是右边有个x开始的那里,别找不到咯~值为00 01 10 78,程序将从地址为00011078h处的指令开始执行,也就是俗称的程序入口点。经过加壳的EXE通常入口点处先是一段解密程序,完了才会跳到原始入口点(OEP)真正执行。
BaseOfCode
与BaseOfData分别指代码区段与数据区段的基地址,自己看看是哪里?
ImageBase
。指最希望把
PE文件载入到地址空间中的ImageBase地址开始的地方,如果这个地址已经被占用,那只好换地方了,但99%的情况下,ImageBase指向的地址肯定是空闲的。
SectionAlignment
与FileAlignment。位置为
110~117,值分别为00 00 10 00和00 00 02 00。指的是任意区段的起始地址必须是SectionAlignment(或FileAlignment)中值的整数倍,不同的是,前者指在内存中,后者指在文件中。在内存中起始地址是1000h,即4096的整数倍,是因为Windows中一个虚拟内存页面的大小就是4096字节,这样就可以保证,一个新的区段一定会被分配到一个新的页面中,而不会出现两个区段在一个页面中的情况。同样,在磁盘上必须是200h,即512的整数倍,是因为磁盘一个扇区的大小就是512字节。
SizeOfImage。
PE文件被载入内存后所占所有内存空间的总和(包括各个区段)。位置在128~12B,值为00 01 B0 00,即110592字节,合108KB。告诉你,这个程序文件只有正好30KB大小,那又怎么会在内存中变大了呢?原因就是上面说的,在内存中区段地址是按4096的整数倍分配的,而在磁盘上是按512的整数倍,这样就必定会造成很多未使用的内存空间浪费,使占用变大。但是,这也是出于性能优化的角度考虑,没什么好说的。
SizeOfHeaders。所有的头的长度总和,像什么
DOS header,Optional header等等,再加上区段表(Section Table,最先的结构图里有)的长度,就是这个值。这里是1024字节,正好1K。注意:这里的Size指文件中(或磁盘中的)Size,而上面一个指内存中的,别混了。
Subsystem。位置
134h~135h,值为00 03,对着下面的常数,发现是Windows控制台程序(Windows CUI)。
#define IMAGE_SUBSYSTEM_UNKNOWN
0
#define IMAGE_SUBSYSTEM_NATIVE 1
#define IMAGE_SUBSYSTEM_WINDOWS_GUI
2
#define IMAGE_SUBSYSTEM_WINDOWS_CUI
3
#define IMAGE_SUBSYSTEM_OS2_CUI
5 /* Not in PECOFF v8 spec */
#define IMAGE_SUBSYSTEM_POSIX_CUI
7
#define IMAGE_SUBSYSTEM_NATIVE_WINDOWS 8
/* Not in PECOFF v8 spec */
#define IMAGE_SUBSYSTEM_WINDOWS_CE_GUI 9
#define IMAGE_SUBSYSTEM_EFI_APPLICATION
10
#define IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER
11
#define IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER 12
#define IMAGE_SUBSYSTEM_EFI_ROM
13
#define IMAGE_SUBSYSTEM_XBOX
14
DllCharacteristics。
DLL文件特征,只对作为DLL时有效。值为81 40,计算方法仍然是使用二进制位来标记。这里原文中在胡扯,后来查了MSDN,如下
#define IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE
0x0040
// 允许重定位
#define IMAGE_DLL_CHARACTERISTICS_FORCE_INTEGRITY
0x0080
// 强制完整性检查
#define IMAGE_DLL_CHARACTERISTICS_NX_COMPAT
0x0100
// 开启数据执行保护DEP
#define IMAGE_DLLCHARACTERISTICS_NO_ISOLATION
0x0200
// 不被孤立
#define IMAGE_DLLCHARACTERISTICS_NO_SEH
0x0400
// 关闭结构化异常处理
#define IMAGE_DLLCHARACTERISTICS_NO_BIND
0x0800
// 不被绑定
#define IMAGE_DLLCHARACTERISTICS_WDM_DRIVER
0x2000
// 这是个WDM Driver
#define IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE 0x8000
//此映像能得知终端服务
由此,我们的这个程序是
IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE | IMAGE_DLL_CHARACTERISTICS_NX_COMPAT | IMAGE_DLL_CHARACTERISTICS_DYNAMIC_BASE,虽然这个参数没什么意义。
DataDirectory。它是一个
IMAGE_DATA_DIRECTORY类型的数组,共有IMAGE_NUMBEROF_DIRECTORY_ENTRIES个元素,一般IMAGE_NUMBEROF_DIRECTORY_ENTRIES = 16。每一个元素都指向PE文件内的一个很重要的数据结构,具体IMAGE_DATA_DIRECTORY的结构,我们下回讨论。
(五)IMAGE_DATA_DIRECTORY结构
上次文章因为实在太长,所以不得不把这么简单的一点内容单独成文。回顾一下,上次先说到PE header,然后说到其中的成员Optional header,它的最后一个结构成员DataDirectory,是一个含有16个IMAGE_DATA_DIRECTORY类型元素的数组,每一个元素都指向PE文件内的一个很重要的数据结构。
首先列出
IMAGE_DATA_DIRECTORY结构:(简单的结构就不注释了)
typedef
struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY;
VirtualAddress。是所指向的数据结构的虚拟地址。
Size。这个数据结构的大小。
这一个结构占8个字节,
16项一共占128字节。
另外序号与目录的对应关系在
winnt.h中定义如下:
#define IMAGE_DIRECTORY_ENTRY_EXPORT
0
#define IMAGE_DIRECTORY_ENTRY_IMPORT
1
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION
3
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4
#define IMAGE_DIRECTORY_ENTRY_BASERELOC
5
#define IMAGE_DIRECTORY_ENTRY_DEBUG
6
#define IMAGE_DIRECTORY_ENTRY_COPYRIGHT
7
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR
8
#define IMAGE_DIRECTORY_ENTRY_TLS
9
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG
10
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11
#define IMAGE_DIRECTORY_ENTRY_IAT
12
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
14
最后15是预留位置。
下图便是
testPE.exe的DataDirectory,我们选其中几项来看看。
PICTURE MISSING
图中其实很整齐地帮我们划分了每一个目录表项,每一行有明显分开的两组数据,每一组长即为8字节,也就是一个表项,这样看起来就方便很多。
第一个表项,全零,由于对应的IMAGE_DIRECTORY_ENTRY_EXPORT,意味着
testPE.exe没有DLL输出表(Export Table)。
第二个是输入表
(Import Table),显然不为0(不然还叫Windows程序啊),推断出VirtualAddress = 00 01 80 00,Size = 00 00 00 3C,有空自己挑个EXE去看一下去,以后我们会再遇到的,现在跳过。
其实每一个表项都有挺实在的意思,看最后一个单词就都明白了。到这,
PE header部分就全部结束了。下次讲的是区段表(Section Table)。
(六)区段与区段表
前面花了很大的力气终于把复杂的PE header讲完了,在PE header紧接着的就是区段表(Secton Table),所谓的紧接着,就是一个字节也不差的意思,^_^
还记得么?我们在很前面讲File header时,里面一个成员就是NumberOfSections,就是区段的个数,是7个,那么对应的区段表中的表项也应当有7项。
每一个区段表项的结构都如下所示:
typedef
struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; 名字,没的说吧?//
union {
DWORD PhysicalAddress; 物理地址,保护模式下根本用不到//
DWORD VirtualSize; 区段的大小(内存中)//
} Misc;
DWORD VirtualAddress;
// 区段的虚拟内存地址
DWORD SizeOfRawData;
// 在硬盘上该区段的大小,是FileAlignment的整数倍
DWORD PointerToRawData; 文件中该区段的偏移//
DWORD PointerToRelocations;
// 属于该区段的重定位表项的偏移,没有就是0
DWORD PointerToLinenumbers;
// 文件中的行号表项,没有就是0
WORD NumberOfRelocations;
// 重定位的个数,EXE是0个
WORD NumberOfLinenumbers; 行号表项的个数,没有是
0//
DWORD Characteristics; 下详//
} IMAGE_SECTION_HEADER,*PIMAGE_SECTION_HEADER;
其中
IMAGE_SIZEOF_SHORT_NAME在winnt.h中定义等于8,即区段名Name最多8个字符。
第二个成员
Misc是一个共用体。我们只需要关注
VirtualSize成员即可,也就是加载器分配给该Section的内存大小,是SectionAlignment的倍数。
VirtualAddress和
PointerToRawData分别表示内存和磁盘中的该段的起始地址,所以非常重要!
最后一个
Characteristics是该段的一个重要属性,其值的含义及相关常数定义也非常复杂,具体的可以到MSDN上搜索IMAGE_SECTION_HEADER,然后点Characteristics成员,就会有详解,这里就不列出了,在分析下面的实例时,会提到一些其常见的含义。
好,下面我打算结合真实的区段表,逐步把每个常用区段都过一遍,其实内容很少,别紧张哈~
下图是我截取的
testPE.exe的区段表
PICTURE MISSING
计算下
IMAGE_SECTION_HEADER的长度=40字节,也就是图中的两行半长度,比较整齐好认,下文就不再标出它们的位置了。
.bss是指存放未初始化数据的区段,包括所有的
static变量,尽管没有出现在截图中,但比较重要,不得不提。首先请一定注意这里的未初始化与C语言中的未初始化概念!C语言的未初始化指不为堆栈变量提供初值,而变量又会在内存中随机位置出现,产生随机的无意义的初值。这里的未初始化,即指全部填0。已初始化的概念一致。
.textbss是
MS的Linker往PE中写入的区段。在正常的PE程序里,可以看到,它在文件中长度(RawData,原始数据)总是0,地址也是0,但在进程的虚拟地址空间中却占了1000h个字节。Characteristics为
E0 00 00 A0 = IMAGE_SCN_MEM_EXECUTE|IMAGE_SCN_MEM_WRITE|IMAGE_SCN_CNT_CODE|IMAGE_SCN_CNT_UNINITIALIZED_DATA,意为可写可执行的代码段,不进行初始化。其实这个区段的出现是因为重定位映像基址比较困难,所以想通过这个空段来起到等同于重定位的作用。我分析下来,
.textbss应该属于一种特殊的.text区段,代码段基地址BaseOfCode得到的是10000h,而.text起始地址是11000h,其差值1000h正好是.textbss的virtualsize。
.text。不用说的重要,放程序的代码。图中内存占用大小为
00 00 34 A4,内存中地址为00 01 10 00,区段的文件大小为00 00 36 00,区段在文件的起始偏移为00 00 04 00,当中几个不管了,特征值Characteristics为
60 00 00 20,查阅MSDN,得到IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_READ | IMAGE_SCN_CNT_CODE,意为该段是代码段,可以被执行,可以被读取。
.rdata,存放只读数据,比如字符串字面值,常量以及调试目录信息等。图中该区段在内存中,大小为
00 00 1C 39,地址为00 01 50 00;在文件中,大小为00 00 1E 00,地址为00 00 3A 00;Characteristics是40 00 00 40,对应IMAGE_SCN_CNT_INITIALIZED_DATA | IMAGE_SCN_MEM_READ,已初始化且可读,但因没有IMAGE_SCN_MEM_WRITE故不可写。
.data,数据区段,除了上面的
static变量,还有存储在堆栈段的自动变量,其他所有变量都在.data里,典型的有各类全局变量。图中该区段在内存中,大小为00 00 05 AC,地址为00 01 70 00;在文件中,大小为00 00 02 00,地址为00 00 58 00。Characteristics是C0 00 00 40,由MSDN,IMAGE_SCN_MEM_WRITE | IMAGE_SCN_MEM_READ | IMAGE_SCN_CNT_INITIALIZED_DATA,可读可写且被初始化过,C语言默认全局变量初始化为0。
.idata,所有输入的
DLL函数信息。图中内存中,大小 00 00 08 4D,起始地址00 01 80 00,文件中,文件中,大小 00 00 0A 00,偏移00 00 5A 00。Characteristics同.data。我们会在稍后详细解析import table。
.edata这里没有,是输出函数用的。
.rsrc。
PE的资源区段,包括程序所有资源信息,像什么图标、图片、AVI、字符串表、热键表、对话框、版本信息等等,你想放什么就有什么。图中内存中大小是00 00 0C 09,地址为00 01 90 00,文件中,大小是00 00 0E 00,地址为00 00 64 00。Characteristics同.rdata,意味着内存中资源区段是只读的。顺带提一下,编辑PE的资源,我个人比较喜欢eXescope,尽管原文中作者推荐resource hacker,各有所好吧!
.reloc。重定位区段,又称基址重定位表,每一个表项仅仅描述了一个需要被加上基址差(期待的基址与实际的基址,
EXE是0)的地址(一个4kb页面的地址)。就拿原文的例子好了,EXE假设它的映像会被载入到0x10000地址处,然后它有个字符串起始偏移是0x14002。但由于某种原因,EXE被PE加载器赶到0x60000处去了,那么显然如果再以0x14002访问字符串就是大大的错误。解决办法是,在重定位区段里告诉系统,0x14002这个地址需要被加上一个基址的差值,这里明显等于0x60000-0x10000=0x50000,而字符串相对偏移是
0x14002-0x10000=0x4002,所以新的字符串地址是
0x64002,这样就天下太平了。
图中
.reloc区段在内存中大小是00 00 04 51,地址为00 01 A0 00,文件中,大小是00 00 06 00,地址为00 00 72 00。Characteristics为42 00 00 40,IMAGE_SCN_CNT_INITIALIZED_DATA | IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_DISCARDABLE,最后一个参数的意思是,如果需要的话,重定位区段可以被PE加载器无视而不被加载。通常EXE的这个区段是被无视的,但DLL由于无法确定会被系统映射到地址空间的何处,所以这个区段是很关键的。
区段这个部分就讲到这里,下一篇将讲述下输出区段
(Export Section)。
(七)输出区段(Export Section)
首先介绍一下DLL相关内容。根据Win32 Programmer's Reference所述(自己翻译的):
“在Windows
中,动态链接库(DLL)
是含有函数与数据的模块。一个DLL
在运行时期由其调用模块加载,当DLL
被加载时,它会被映射到主叫进程的地址空间中去。
DLL
内有两类函数:输出(exported)
与内部(internal)
。输出函数是要被其他模块调用的函数,内部函数仅供DLL
内部使用。尽管DLL
可以输出数据,但通常数据也仅作内部使用。
DLL
可以使应用程序模块化,并且多个应用程序可以共享同一份内存中的DLL
,因此也节省系统资源。Win32 API
是被实现为一组DLL
的,所以任何使用Win32 API
的应用程序一定使用DLL
。”
DLL 中函数
(也常被叫做符号
Symbols
)可以通过两种方式输出:通过名字与通过序号。一个序号通常是一个字大小的一个数,它在一个DLL中唯一标识一个函数,注意,这个序号仅在同一个DLL中唯一,不同DLL间序号不不唯一!
如果说函数通过名字输出,那么当其他模块要调用它时,可以在GetProcAddress中或使用它的名字或使用它的序号来指定,GetProcAddress函数则会返回被调用函数的地址。注意:使用名字的话,它的拼写与大小写必须与源DLL的模块定义文件(.DEF)中的一模一样,并且序号可以不从1开始,如果GetProcAddress找不到对应函数,就会返回NULL。具体GetProcAddress信息请参考Win32 Programmer’s Reference,或者MSDN。
之所以GetProcAddress可以得到DLL的输出信息,是因为DLL中定义了输出目录表(Export Directory),还记得我们在(五)中讲的IMAGE_DATA_DIRECTORY数组吗?它的第0个元素就描述了Export Directory的虚拟地址与大小,这样我们就可以通过这些信息找到PE中对应的输出目录表的全部信息。
输出目录表是用IMAGE_EXPORT_DIRECTORY这个结构定义的。
typedef
struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions;
DWORD AddressOfNames;
DWORD AddressOfNameOrdinals;
} IMAGE_EXPORT_DIRECTORY,*PIMAGE_EXPORT_DIRECTORY;
我们只关心其中几个成员:
Name。模块的内部名称,如果
DLL文件的名字被用户改了,那么PE加载器会使用这个内部名称。
Base。前面说的序号的起始编号。比如一个函数的序号是
4,起始序号Base是2,就表示该函数位于输出地址列表第三个元素,输出地址列表中默认首元素对应序号1,而不是序号0。序号的起始数字可以在
.DEF文件中定义。
NumberOfFunctions。顾名思义,就是该
DLL输出函数的个数。
NumberOfNames。就是通过名字输出的函数个数。
AddressOfFunctions。输出地址列表(
Export Address Table,EAT,其实就是一个地址数组)的首地址。
AddressOfNames。输出名字列表
(Export Name Table, ENT)的首地址。
AddressOfNameOrdinals。输出序号列表
(Export Ordinal Table, EOT)的首地址。该数组的元素都是16-bit(一个字)长度的整数。
我们看到
IMAGE_EXPORT_DIRECTORY其实主要就是指向了三个数组。现在我们再看下怎么通过函数的名字来找到对应的函数地址。
假设
Base = 3, 且有以下的表格:
AddressOfNames - ENT
|
AddressOfNameOrdinals - EOT
|
Name1
|
3
|
Name2
|
4
|
Name3
|
5
|
…
|
…
|
AddressOfFunctions - EAT
索引
|
地址
|
1
|
0x400042
|
2
|
0x400156
|
3
|
0x401256
|
4
|
0x400520
|
5
|
0x401452
|
…
|
…
|
比如我们传给
GetProcAddress的名字是”Name3”,之后系统会先查找ENT这张表格,发现找到了,然后平行地看过去,发现在EOT中对应的序数是5,那么说明该函数的地址在EAT的第5个位置(从1开始数),取出地址0x401452,任务就完成了。
如果只通过序号来查找函数地址那就很方便,只要读取
EOT,然后直接在EAT里索引就可以了。但这样的做法一不利于记忆,二不利于维护与扩展(因为序号一变就得改许多用户源代码)。
下面看一种新的情况,如果一个输出函数尽管出现在了
EAT中,但没有出现在ENT与EOT中,那该怎么办呢?那就只能通过排除法了,也就是要满足在EAT中而不在ENT与EOT中。
好了,最后一个事儿了,就是输出转送(
Export Forwarding)。就比如我调用了kernal32.dll里的HeapAlloc函数,它本身并没有实现这个函数,而是把我的调用请求转送到了ntdll.dll的RtlAllocHeap这个函数,这就是DLL输出转送,同样可以通过修改.DEF文件在链接时期进行。输出转送的引入,一来可以隔离通用的Win32 API与内核支持函数,达到屏蔽底层差异的目的,二来就是支持了操作系统的模式转换,划清用户模式与内核模式的界限。
具体反应在输出目录表中是这样的,那个
AddressOfNames指向的表格中,本来存的都是函数的名字,现在就换成“模块名.函数名”,比如上面的例子就是”NTDLL. RtlAllocHeap”,因此如果你看到了类似这样的名字,就说明这个函数调用被转送了。
这次的文章没有图片实例,是因为咱们的
testPE.exe没有输出表,而输出表本身也比较简单,我就懒得再去编一个DLL再提取16进制数据了,所以就抱歉啦!其实研究方法和前面几篇中的一模一样,就是先通过data directory找到输出表,然后读入IMAGE_EXPORT_DIRECTORY结构,得到三张表的地址,然后按上面的查表方法就可以了。
OK!本文就到这。(八)将讲的是输入区段(
The Import Section)。
(八)输入区段(The Import Section)
上次讲了输出区段,还是比较简单的,但输入区段内容就稍稍多了点,保持耐心阿,这是本系列关于PE格式部分的最后一讲啦!
输入区段(Import Section),包含了所有从DLL中引用的函数的信息。与输出区段类似,这些信息是由几个数据结构描述的,其中最重要的是输入目录表(Import Directory)与输入地址表(Import Address Table),在一些应用程序中,还会有Bound_Import和Delay_Import,Delay_Import不那么重要,就忽略了,Bound_Import后面介绍。
PE加载器的任务就是把DLL映射到进程的地址空间,并且找到每个函数的地址。
DLL中函数的地址并不是总是固定不变的,而随着DLL版本更新变化,为了解决这个问题,PE引入了输入地址表(IAT),这样地址只要在DLL的IAT里更新一次,其他地方(尤其客户方)由于是间接访问DLL函数代码,就不用发生改变。IAT其实就是一个指针表,每一个指针指向一个函数的地址。
我们先来看一下输入目录表的结构:
typedef
struct _IMAGE_IMPORT_DESCRIPTOR {
_ANONYMOUS_UNION union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
} DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR,*PIMAGE_IMPORT_DESCRIPTOR;
一个输入目录表项对应一个
DLL,如果我们的EXE引用了100个DLL,就会有100个IMAGE_IMPORT_DESCRIPTOR结构。PE文件中没有字段可以指明输入目录表一共有多少个项,但默认最后一个项的字段是全0。
如同输出目录表一样,你可以在
data directory第1个元素中得到输入表的地址与大小,我们testPE.exe的输入表起始虚拟地址是00 01 80 00,大小3C,单个输入表项大小是10字节,所以表示testPE.exe引用了3个DLL。
下面逐个讲下每一个成员:
Characteristics。原先是存放一系列标志用的,现在已经废弃。
OriginalFirstThunk。由于是共用体,所以这个匿名共用体仅表示
OriginalFirstThunk,用来指向IMAGE_THUNK_DATA结构数组,稍后会描述。
TimeDateStamp。除了应用程序被绑定(下详),值为
-1,否则总是0。
ForwarderChain。用于旧式风格的绑定,现在已经废弃。
Name。指向
ASCII字符串,是DLL的名字。
FirstThunk也指向一个
IMAGE_THUNK_DATA结构数组,其实OriginalFirstThunk那个数组的拷贝,当函数被绑定输入(Bound Import)后,它就被替换成函数的实际地址,这也就是为什么前面要有一个OriginalFirstThunk(原FirstThunk)的原因。
自然,下一步我们就要关心下
IMAGE_THUNK_DATA到底是个怎样的东西。
typedef
struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString;
DWORD Function;
DWORD Ordinal;
DWORD AddressOfData;
} u1;
} IMAGE_THUNK_DATA32,*PIMAGE_THUNK_DATA32;
可以看到这个结构里只有一个是共用体成员,说明
u1仅表示四个DWORD成员之一。
ForwarderString没有任何意义,无视。
在文件中,它要么表示函数的序号
(Ordinal),且一般从8开始,要么表示IMAGE_IMPORT_BY_NAME(下详)结构的指针(AddressOfDataIMAGE_THUNK_DATA32数组实际上就变成了输入地址表(IAT)。)。当被载入到内存中后,它会被替换成输入函数的实际地址(Function),就是说由FirstThunk指向的
typedef
struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
BYTE Name[1];
} IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME;
Hint。就是该函数在
DLL中输出地址表(EAT)中的索引号,目的是方便PE加载器查阅目标DLL的EAT,所以名字就叫“暗示”(Hint)。此值可选,一些linker设置它为0。
Name。这个字段比较奇怪,尽管从
C定义中可以看到,结构里实际只包含一个字节而已,但是Name这样的定义,却预示着这是一个字符串,也就是从字符串第二个字节开始就出现在了结构外面,直到遇到\0结束符。它既没有采用DWORD类型来指向一个字符串的首地址,也没有开辟固定的长度来存储字符串,这是比较不符合常识的地方。
好,稍稍总结一下。一个
DLL对应一个IMAGE_IMPORT_DESCRIPTOR,然后创建两个一模一样的IMAGE_THUNK_DATA32数组,第一个由OriginalFirstThunk指向,第二个由FirstThunk指向。由于一个函数对应一个IMAGE_THUNK_DATA32,所以该DLL有几个函数,两个IMAGE_THUNK_DATA32数组就都有几个元素,刚开始都分别指向同一个IMAGE_IMPORT_BY_NAME结构,且两个IMAGE_THUNK_DATA32结构数组都以一个null DWORD表示结束。
我们常称
FirstThunk指向的IMAGE_THUNK_DATA32结构数组为输入地址表(IAT),而把OriginalFirstThunk指向的数组称为输入名称表(Import Name Table)或输入查询表(Import Lookup Table)。
生成两个
IMAGE_THUNK_DATA32结构数组的目的是比较显然的。因为FirstThunk指向的那个,在被加载时,PE加载器会以实际函数地址替代其中的元素,即FirstThunk指向的数组中的元素不再保存IMAGE_IMPORT_BY_NAME结构的地址,而是转而保存函数地址。但IMAGE_IMPORT_BY_NAME结构数组本身并没有被删除(并且始终只有一个该数组!),仍然由OriginalFirstThunk指向。这样万一要查找DLL的函数名称等信息,就可以去OriginalFIrstThunk指向的数组找到。
看到这,你会发现输入表,或者说输入区段并不简单等同于输出地址表
(IAT),查看Data Directory,你会发现第12个元素就是IAT,而并没有EAT。当然其实PE加载器并没有把它当作IAT指针,而只是把存储IAT的虚拟内存页面标记为可读可写而已,因为IAT是被载入到只读区域的,在载入时,先临时把页面设置为可读写,等输入表初始化完成后,再改回原先的受保护属性。
在调用
DLL函数时,还涉及到一个性能优化问题。假设你要调用的函数所在的Thunk Data 的地址是00405030h,当前程序运行到0040100Ch。
那么比较高效的方式是:
0040 100C CALL DWORD PTR [00405030]
比较弱智的方式的:
0040 100C CALL [00402200]
…
0040 2200 JMP DWORD PTR [00405030]
(注:这里我解释下加了
DWORD PTR的区别,表示00405030指向的内存区块是DWORD类型,并且会“返回”区块的代表的值,这其实是一种间接寻址的方式)
产生后一种的原因是,编译器本身是无法区别模块内的普通函数调用和位于别的模块的外部函数调用,因而产生同一类型代码
CALL [XXXXXXXX]。
对于普通函数,
XXXXXXXX就是函数的执行地址,而对于DLL函数,XXXXXXXX处实际保存的是一个指针,这个指针指向的地方才是真正的函数执行地址。但CALL指令里的XXXXXXXX只能是实际代码的地址,因此用在DLL函数上就会出错,于是链接器就想到了这么个笨拙的办法,用一段中间跳转代码来替换,造成了空间和时间资源的双重浪费。
解决办法是,通过加上
_declspec(dllimport)修饰符,告诉编译器这个函数是在DLL中,那么编译器就会直接产生CALL DWORD PTR [XXXXXXXX]之类的代码。不加这个修饰符,你的程序就会充满着特定的JMP语句而变得臃肿。
第(七)讲提到过,一些函数可能只能通过
EAT索引号来查找,就是那些出现在EAT但不出现在ENT与EOT中的函数。大家可能也注意到了,IMAGE_THUNK_DATA32中的Ordinal字段是DWORD类型的,但Ordinal属性本身只需要一个WORD即可,于是M$这样做,定义了一个常量IMAGE_ORDINAL_FLAG32 = 80000000h,如果一个函数的索引号是1234h,且是那种只能查EAT表调用的函数,那么他的Ordinal字段值就是80001234h,这样PE加载器就会很容易识别出来。
绑定输入
(Bound Imports)。当PE文件被加载到内存中时,加载器会先检查输入表然后把需要的DLL载入到地址空间中去。接着它会遍历FirstThunk指向的数组,并用输入函数的实际地址去替换每一个元素的内容,这个步骤可能会花一部分时间。但如果程序员(或者链接器)可以完全得知函数的地址,就可以直接把数组中的元素替换为地址,节省相当多的时间。这种方法就称为绑定(Binding)。
M$采用一个
bind.exe的程序(VS2008的在Windows SDK 6.0A里面),可以把IMAGE_THUNK_DATA32结构的内容都静态替换成地址。这样在载入DLL时,加载器会先检查这些地址是否正确合法,比如DLL版本是否符合(很早前就提过了DLL版本更新导致IAT的产生),如不符合或者DLL需要被重定位,加载器就会去遍历OriginalFirstThunk指向的数组(也就是INT),去计算新的地址。所以尽管INT对EXE是可选的,但是没有INT,就无法进行绑定。
最后要提到的,也是最开始提到的,一个或许不是很重要的数据结构:绑定输入目录表
(The Bound Import Directory)。它包含了可以让加载器判断绑定的地址是否合法的信息。描述它的数据结构是IMAGE_BOUND_IMPORT_DESCRIPTOR,目录表就是这种结构的数组,每一项都对应一个被绑定过的DLL。先看下这个结构:
typedef
struct _IMAGE_BOUND_IMPORT_DESCRIPTOR {
DWORD TimeDateStamp;
WORD OffsetModuleName;
WORD NumberOfModuleForwarderRefs;
} IMAGE_BOUND_IMPORT_DESCRIPTOR,*PIMAGE_BOUND_IMPORT_DESCRIPTOR;
TimeDateStamp。这个成员必须和要引用的
DLL的文件头信息相吻合,否则就会加载器去手动计算新IAT,这种情况一般发生在DLL版本不同时或者DLL映像被重定位时。
OffsetModuleName。包含了以第一个
IMAGE_BOUND_IMPORT_DESCRIPTOR为基址,DLL名称字符串(ASCII且以null结束)的偏移(非RVA)。
NumberOfModuleForwarderRefs。是紧接着本结构后的另一个
IMAGE_BOUND_FORWARDER_REF结构数组的元素个数。
typedef
struct _IMAGE_BOUND_FORWARDER_REF {
DWORD TimeDateStamp;
WORD OffsetModuleName;
WORD Reserved;
} IMAGE_BOUND_FORWARDER_REF,*PIMAGE_BOUND_FORWARDER_REF;
这个结构与
IMAGE_BOUND_IMPORT_DESCRIPTOR好像一样嘛。。。噢就最后一个字是保留的。这个结构数组干什么用的?你一定已经注意到ModuleForwarder这个词了,还记得我们在输出表中讲到的,函数的输出转送么?就是一个函数自己不实现而是把调用请求转发给另一个DLL中的函数。这里的IMAGE_BOUND_FORWARDER_REF结构就是用来记录接受转发的另一个DLL的校验信息,如果这个DLL还有输出转送,那么在该DLL中也有IMAGE_BOUND_FORWARDER_REF结构描述第三个DLL的校验信息。
本文完。
(九)总结
好了,如果你确实按进程看到了这里,那么我首先要钦佩你的毅力,感谢你的支持,同时也恭喜你已经对PE格式有了比较全面的了解。回想起来,其实PE格式的理解并不很困难,只是比较复杂,也容易搞混结构关系,所以我写了这第九篇,把PE格式的总体结构按研究的顺序给出,让这个轮廓清晰地出现在脑中。
好,如果我们拿到一个EXE,想要去分析它的格式,我们可以按下面的步骤进行:
一。首先是DOS MZ Header,对应C结构IMAGE_DOS_HEADER,其中第一个成员
e_magic用于判断是否为合法的EXE,而通过最后一个成员e_lfanew可以找到PE Header的位置。
二。找到
PE Header,对应C结构为IMAGE_NT_HEADERS32。第一个成员Signature可以验证是否为合法的PE文件。第二个成员结构IMAGE_FILE_HEADER FileHeader可以得到PE的类型和区段的个数。通过IMAGE_OPTIONAL_HEADER OptionalHeader可以访问附加头。
三。继续查看
OptionalHeader,可以得到一些代码或数据的长度及虚拟地址,还有一个比较有用的Data Directory目录,里面可以查到输出表和输入表等数据结构的地址和长度。
四。紧接着就是区段表
(Section Table),可以找到EXE中所有段的信息。
五。按照段表分别找到对应段的所有数据。
六。访问
.edata或者输出表可以得到函数输出信息,表项结构为IMAGE_EXPORT_DIRECTORY
七。访问
.idata段,可以得到DLL引用的所有信息。子步骤为:1。遍历所有IMAGE_IMPORT_DESCRIPTOR数组,每个元素对应一个DLL;2。找到每个元素中的成员,访问两个FirstThunk指向的IMAGE_THUNK_DATA32结构数组;3。其中每个元素要么按IMAGE_IMPORT_BY_NAME结构访问,要么当作Ordinal访问;4。如果EXE还有绑定输入目录表(Bound Import Table),则可以接着去访问它,元素结构为IMAGE_BOUND_IMPORT_DESCRIPTOR,对于有输出转送的DLL,还可以进而访问IMAGE_BOUND_FORWARDER_REF结构数组
八。访问数据段(?)、代码段
(反汇编器)、资源段(资源编辑器)可用及其他的所有区段,这个就不属于PE格式范围了。
九。如果这个
EXE被加过壳,则在正常访问前,还需要将它先脱壳。
好了,我的水平就只能把
PE格式为你介绍到这,进一步的研究就靠你自己啦,最后说一声,感谢大家的支持!