PEPEPEPE文件
详解
摘要
WindowsNT 3.1引入了一种名为 PE文件格式的新可执行文件格式。PE文件格式的规范
包含在了 MSDN 的 CD 中( Specs and Strategy, Specifications, Windows NT File Format
Specifications),但是它非常之晦涩。
然而这一的文档并未提供足够的信息,所以开发者们无法很好地弄懂 PE格式。本文旨
在解决这一问题,它会对整个的 PE文件格式作一个十分彻底的解释,另外,本文中还带有
对所有必需结构的描述以及示范如何使用这些信息的源码示例。
为了获得 PE文件中所包含的重要信息,我编写了一个名为 PEFILE.DLL 的动态链接库,
本文中所有出现的源码示例亦均摘自于此。这个 DLL和它的源代 码都作为 PEFile示例程序
的一部分包含在了 CD中(译注:示例程序请在 MSDN中寻找,本站恕不提供),你可以在
你自己的应用程序中使用这个 DLL; 同样,你亦可以依你所愿地使用并构建它的源码。在
本文末尾,你会找到 PEFILE.DLL的函数导出列
和一个如何使用它们的说明。我觉得你会
发现这些函 数会让你从容应付 PE文件格式的。
介绍
Windows操作系统家族最近增加的WindowsNT为开发环境和应用程序本身带来了很大
的改变,这之中一个最为重大的当属 PE文件格式了。新的 PE文件格式主要来自于UNIX操
作系统所通用的 COFF 规范,同时为了保证与旧版本 MS-DOS及 Windows操作系统的兼容,
PE文件格式也保留了MS-DOS 中那熟悉的MZ 头部。
在本文之中,PE文件格式是以自顶而下的顺序解释的。在你从头开始研究文件内容的
过程之中,本文会详细讨论 PE文件的每一个组成部分。
许多单独的文件成分定义都来自于 MicrosoftWin32 SDK开发包中的 WINNT.H文件,
在这个文件中你会发现用来描述文件头部和数据目录等各种成分的结构类型定义。但是,在
WINNT.H中缺少对 PE文 件结构足够的定义,在这种情况下,我定义了自己的结构来存取文
件数据。你会在 PEFILE.DLL 工程的 PEFILE.H 中找到这些结构的定义,整套的 PEFILE.H
开发文件包含在 PEFile 示例程序之中。
本文配套的示例程序除了 PEFILE.DLL 示例代码之外,还有一个单独的 Win32示例应用
程序,名为 EXEVIEW.EXE。创建这一示例目的有二: 首先,我需要测试 PEFILE.DLL 的函
数,并且某些情况要求我同时查看多个文件;其次,很多解决 PE文件格式的工作和直接观
看数据有关。例如,要弄懂 导入地址名称表是如何构成的,我就得同时查看.idata段头部、
导入映像数据目录、可选头部以及当前的.idata段实体,而 EXEVIEW.EXE就 是查看这些信
息的最佳示例。
闲话少叙,让我们开始吧。
PEPEPEPE文件结构
PE文件格式被组织为一个线性的数据流,它由一个MS-DOS 头部开始,接着是一个是
模式的程序残余以及一个 PE文件标志,这之后紧接着 PE文件头和可选 头部。这些之后是
所有的段头部,段头部之后跟随着所有的段实体。文件的结束处是一些其它的区域,其中是
一些混杂的信息,包括重分配信息、符号表信息、行号 信息以及字串表数据。我将所有这些
成分列于图1。
图1.PE1.PE1.PE1.PE文件映像结构
从 MS-DOS文件头结构开始,我将按照 PE文件格式各成分的出现顺序依次对其进行讨
论,并且讨论的大部分是以示例代码为基础来示范如何获得文件的信息 的。所有的源码均摘
自 PEFILE.DLL 模块的 PEFILE.C 文件。这些示例都利用了 Windows NT最酷的特色之一——
内存映射文件,这一特色允许用户使用一个简单的指针来存取文件中所包含的数据,因此所
有的示例都使用了内存映射文件来存取 PE文件 中的数据。
注意:请查阅本文末尾关于如何使用 PEFILE.DLL 的那一段。
MS-DOSMS-DOSMS-DOSMS-DOS头部////实模式头部
如上所述,PE文件格式的第一个组成部分是 MS-DOS 头部。在 PE文件格式中,它并
非一个新概念,因为它与 MS-DOS 2.0以来就已有的MS-DOS 头部是完全一样的。保留这个
相同结构的最主要原因是,当你尝试在 Windows 3.1以下或MS-DOS 2.0以上的系统下装载一
个文件的时候,操作系统能够读取这个文件并明白它是和当前系统不相兼容的。换句话说,
当你在MS-DOS 6.0下运行一个 WindowsNT 可执行文件时,你会得到这样一条消息:“This
program cannot be run in DOS mode.”如果 MS-DOS头部不是作为 PE 文件格式的第一部分的
话,操作系统装载文件的时候就会失败,并提供一些完全没用的信息,例如: “The name
specified is not recognized as an internal or external command, operable program or batch file.”
MS-DOS头部占据了 PE文件的头64个字节,描述它内容的结构如下:
//WINNT.H
typedef struct _IMAGE_DOS_HEADER { // DOS的.EXE头部
USHORT e_magic; // 魔术数字
USHORT e_cblp; // 文件最后页的字节数
USHORT e_cp; // 文件页数
USHORT e_crlc; // 重定义元素个数
USHORT e_cparhdr; // 头部尺寸,以段落为单位
USHORT e_minalloc; // 所需的最小附加段
USHORT e_maxalloc; // 所需的最大附加段
USHORT e_ss; // 初始的 SS值(相对偏移量)
USHORT e_sp; // 初始的 SP值
USHORT e_csum; // 校验和
USHORT e_ip; // 初始的 IP值
USHORT e_cs; // 初始的 CS 值(相对偏移量)
USHORT e_lfarlc; // 重分配表文件地址
USHORT e_ovno; // 覆盖号
USHORT e_res[4]; // 保留字
USHORT e_oemid; // OEM 标识符(相对 e_oeminfo)
USHORT e_oeminfo; // OEM 信息
USHORT e_res2[10]; // 保留字
LONG e_lfanew; // 新 exe头部的文件地址
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
第一个域 e_magic,被称为魔术数字,它被用于表示一个 MS-DOS 兼容的文件类型。所有
MS-DOS 兼容的可执行文件都将这个值设为0x5A4D, 表示 ASCII字符 MZ。MS-DOS 头部
之所以有的时候被称为 MZ头部,就是这个缘故。还有许多其它的域对于MS-DOS 操作系统
来说都有用,但是对于 Windows NT来说,这个结构中只有一个有用的域——最后一个域
e_lfnew,一个4字节的文件偏移量,PE文件头部就是由它定位的。对于 Windows NT 的 PE
文件来说,PE文件头部是紧跟在MS-DOS 头部和实模式程序残余之后的。
实模式残余程序
实模式残余程序是一个在装载时能够被 MS-DOS运行的实际程序。对于一个 MS-DOS
的可执行映像文件,应用程序就是从这里执行的。对于 Windows、OS/2、WindowsNT 这些
操作系统来说,MS-DOS 残余程序就代替了主程序的位置被放在这里。这种残余程序通常什
么也不做,而只是输出一行文本,例如:“This program requires Microsoft Windows v3.1 or
greater.”当然,用户可以在此放入任何的残余程序,这就意味着你可能经常看到像这样的东西:
“You can''t run a WindowsNT application on OS/2, it''s simply not possible.”
当为 Windows 3.1构建一个应用程序的时候,链接器将向你的可执行文件中链接一个名
为 WINSTUB.EXE 的默认残余程序。你可以用一个基于 MS-DOS 的有效程序 取代
WINSTUB,并且用 STUB模块定义语句指示链接器,这样就能够取代链接器的默认行为。为
Windows NT开发的应用程序可以通过使用-STUB:链接器选项来实现。
PEPEPEPE文件头部与标志
PE文件头部是由MS-DOS头部的 e_lfanew域定位的,这个域只是给出了文件的偏移量,
所以要确定 PE头部的实际内存映射地址,就需要添加文件的内存映射基地址。例如,以下
的宏是包含在 PEFILE.H源文件之中的:
//PEFILE.H
#define NTSIGNATURE(a) ((LPVOID)((BYTE *)a + \
((PIMAGE_DOS_HEADER)a)->e_lfanew))
在处理 PE文件信息的时候,我发现文件之中有些位置需要经常查阅。既然这些位置仅仅是
对文件的偏移量,那么用宏来实现这些定位就比较容易,因为它们较之函数有更好的表现。
请注意这个宏所获得的是 PE文件标志,而并非 PE 文件头部的偏移量。那是由于自
Windows与 OS/2的可执行文件开始, .EXE 文件都被赋予了目标操 作系统的标志。对于
Windows NT的 PE文件格式而言,这一标志在 PE文件头部结构之前。在 Windows和 OS/2
的某些版本中,这一标志是文件头的第一个字。同样,对于 PE文件格 式,Windows NT 使
用了一个DWORD 值。
以上的宏返回了文件标志的偏移量,而不管它是哪种类型的可执行文件。所以,文件头
部是在 DWORD标志之后,还是在 WORD标志处,是由这个标志是否 Windows NT文件标
志所决定的。要解决这个问题,我编写了 ImageFileType函数(如下),它返回了映像文件的
类型:
//PEFILE.C
DWORD WINAPI ImageFileType (LPVOID lpFile)
{
/* 首先出现的是DOS 文件标志 */
if (*(USHORT *)lpFile == IMAGE_DOS_SIGNATURE)
{
/* 由DOS 头部决定 PE文件头部的位置 */
if (LOWORD (*(DWORD *)NTSIGNATURE (lpFile)) ==
IMAGE_OS2_SIGNATURE ||
LOWORD (*(DWORD *)NTSIGNATURE (lpFile)) ==
IMAGE_OS2_SIGNATURE_LE)
return (DWORD)LOWORD(*(DWORD *)NTSIGNATURE (lpFile));
else if (*(DWORD *)NTSIGNATURE (lpFile) ==
IMAGE_NT_SIGNATURE)
return IMAGE_NT_SIGNATURE;
else
return IMAGE_DOS_SIGNATURE;
}
else
/* 不明文件种类 */
return 0;
}
以上列出的代码立即告诉了你 NTSIGNATURE 宏有多么有用。对于比较不同文件类型并且返
回一个适当的文件种类来说,这个宏就会使这两件事变得非常简单。WINNT.H之中定义的四
种不同文件类型有:
//WINNT.H
#define IMAGE_DOS_SIGNATURE 0x5A4D // MZ
#define IMAGE_OS2_SIGNATURE 0x454E // NE
#define IMAGE_OS2_SIGNATURE_LE 0x454C // LE
#define IMAGE_NT_SIGNATURE 0x00004550 // PE00
首先,Windows的可执行文件类型没有出现在这一列表中,这一点看起来很奇怪。但是,在
稍微研究一下之后,就能得到原因了:除了操作系统版本规范的不 同之外,Windows的可执
行文件和 OS/2的可执行文件实在没有什么区别。这两个操作系统拥有相同的可执行文件结
构。
现在把我们的注意力转向 Windows NT PE文件格式,我们会发现只要我们得到了文件
标志的位置,PE文件之后就会有4个字节相跟随。下一个宏标识了 PE文件的头部:
//PEFILE.C
#define PEFHDROFFSET(a) ((LPVOID)((BYTE *)a + \
((PIMAGE_DOS_HEADER)a)->e_lfanew + \
SIZE_OF_NT_SIGNATURE))
这个宏与上一个宏的唯一不同是这个宏加入了一个常量 SIZE_OF_NT_SIGNATURE。不幸的
是,这个常量并未定义在 WINNT.H之中,于是我将它定义在了 PEFILE.H 中,它是一个
DWORD的大小。
既然我们知道了 PE文件头的位置,那么就可以检查头部的数据了。我们只需要把这个
位置赋值给一个结构,如下:
PIMAGE_FILE_HEADER pfh;
pfh = (PIMAGE_FILE_HEADER)PEFHDROFFSET(lpFile);
在这个例子中,lpFile表示一个指向可执行文件内存映像基地址的指针,这就显出了内存映射
文件的好处:不需要执行文件的 I/O,只需使用指针 pfh就能存取文件中的信息。PE文件头
结构被定义为:
//WINNT.H
typedef struct _IMAGE_FILE_HEADER {
USHORTMachine;
USHORTNumberOfSections;
ULONG TimeDateStamp;
ULONG PointerToSymbolTable;
ULONG NumberOfSymbols;
USHORTSizeOfOptionalHeader;
USHORTCharacteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
#define IMAGE_SIZEOF_FILE_HEADER 20
请注意这个文件头部的大小已经定义在这个包含文件之中了,这样一来,想要得到这个结构
的大小就很方便了。但是我觉得对结构本身使用 sizeof 运算符(译 注:
为“function”)更
简单一些,因为这样的话我就不必记住这个常量的名字 IMAGE_SIZEOF_FILE_HEADER,而
只需要记住 结构 IMAGE_FILE_HEADER的名字就可以了。另一方面,记住所有结构的名字
已经够有挑战性的了,尤其在是这些结构只有WINNT.H中才有的 情况下。
PE文件中的信息基本上是一些高级信息,这些信息是被操作系统或者应用程序用来决
定如何处理这个文件的。第一个域是用来表示这个可执行文件被构建的目标机 器种类,例如
DEC(R)Alpha、MIPS R4000、Intel(R) x86或一些其它处理器。系统使用这一信息来在读取这
个文件的其它数据之前决定如何处理它。
Characteristics域表示了文件的一些特征。比如对于一个可执行文件而言,分离调试文
件是如何操作的。调试器通常使用的方法是将调试信息从 PE文件中分离,并保存到一个调
试文件(.DBG)中。要这么做的话,调试器需要了解是否要在一个单独的文件中寻找调试信
息,以及这个文件是否已经将调试 信息分离了。我们可以通过深入可执行文件并寻找调试信
息的方法来完成这一工作。要使调试器不在文件中查找的话,就需要用到
IMAGE_FILE_DEBUG_STRIPPED这个特征,它表示文件的调试信息是否已经被分离了。这
样一来,调试器可以通过快速查看 PE文件的头部 的方法来决定文件中是否存在着调试信息。
WINNT.H定义了若干其它表示文件头信息的标记,就和以上的例子差不多。我把研究
这些标记的事情留给读者作为练习,由你们来看看它们是不是很有趣,这些标记位于
WINNT.H中的 IMAGE_FILE_HEADER 结构之后。
PE文件头结构中另一个有用的入口是 NumberOfSections域,它表示如果你要方便地提
取文件信息的话,就需要了解多少个段——更明确一点来 说,有多少个段头部和多少个段实
体。每一个段头部和段实体都在文件中连续地排列着,所以要决定段头部和段实体在哪里结
束的话,段的数目是必需的。以下的函 数从 PE文件头中提取了段的数目:
PEFILE.C
int WINAPI NumOfSections(LPVOID lpFile)
{
/* 文件头部中所表示出的段数目 */
return (int)((PIMAGE_FILE_HEADER)
PEFHDROFFSET (lpFile))->NumberOfSections);
}
如你所见,PEFHDROFFSET 以及其它宏用起来非常方便。
PEPEPEPE可选头部
PE可执行文件中接下来的224个字节组成了 PE可选头部。虽然它的名字是“可选头部”,
但是请确信:这个头部并非“可选”,而是“必需”的。OPTHDROFFSET 宏可以获得指向可选头
部的指针:
//PEFILE.H
#define OPTHDROFFSET(a) ((LPVOID)((BYTE *)a + \
((PIMAGE_DOS_HEADER)a)->e_lfanew + \
SIZE_OF_NT_SIGNATURE + \
sizeof(IMAGE_FILE_HEADER)))
可选头部包含了很多关于可执行映像的重要信息,例如初始的堆栈大小、程序入口点的位置、
首选基地址、操作系统版本、段对齐的信息等等。IMAGE_OPTIONAL_HEADER结构如下:
//WINNT.H
typedef struct _IMAGE_OPTIONAL_HEADER {
//
// 标准域
//
USHORTMagic;
UCHAR MajorLinkerVersion;
UCHAR MinorLinkerVersion;
ULONG SizeOfCode;
ULONG SizeOfInitializedData;
ULONG SizeOfUninitializedData;
ULONG AddressOfEntryPoint;
ULONG BaseOfCode;
ULONG BaseOfData;
//
// NT附加域
//
ULONG ImageBase;
ULONG SectionAlignment;
ULONG FileAlignment;
USHORTMajorOperatingSystemVersion;
USHORTMinorOperatingSystemVersion;
USHORTMajorImageVersion;
USHORTMinorImageVersion;
USHORTMajorSubsystemVersion;
USHORTMinorSubsystemVersion;
ULONG Reserved1;
ULONG SizeOfImage;
ULONG SizeOfHeaders;
ULONG CheckSum;
USHORTSubsystem;
USHORTDllCharacteristics;
ULONG SizeOfStackReserve;
ULONG SizeOfStackCommit;
ULONG SizeOfHeapReserve;
ULONG SizeOfHeapCommit;
ULONG LoaderFlags;
ULONG NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY
DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER, *PIMAGE_OPTIONAL_HEADER;
如你所见,这个结构中所列出的域实在是冗长得过分。为了不让你对所有这些域感到厌烦,
我会仅仅讨论有用的——就是说,对于探究 PE文件格式而言有用的。
标准域
首先,请注意这个结构被划分为“标准域”和“NT 附加域”。所谓标准域,就是和UNIX
可执行文件的 COFF 格式所公共的部分。虽然标准域保留了 COFF 中定义的名字,但是
Windows NT仍然将它们用作了不同的目的——尽管换个名字更好一些。
·Magic。我不知道这个域是干什么的,对于示例程序 EXEVIEW.EXE 示例程序而言,这
个值是0x010B或267(译注:0x010B为.EXE,0x0107为 ROM映像,这个信息我是从 eXeScope
上得来的)。
·MajorLinkerVersion、MinorLinkerVersion。表示链接此映像的链接器版本。随 Window NT
build 438配套的 WindowsNT SDK包含的链接器版本是2.39(十六进制为2.27)。
·SizeOfCode。可执行代码尺寸。
·SizeOfInitializedData。已初始化的数据尺寸。
·SizeOfUninitializedData。未初始化的数据尺寸。
·AddressOfEntryPoint。在标准域中,AddressOfEntryPoint域是对 PE文件格式来说最为
有趣的了。这个域表示应用程 序入口点的位置。并且,对于系统黑客来说,这个位置就是导
入地址表(IAT)的末尾。以下的函数示范了如何从可选头部获得 WindowsNT 可执行映像的
入口点。
//PEFILE.C
LPVOID WINAPI GetModuleEntryPoint(LPVOID lpFile)
{
PIMAGE_OPTIONAL_HEADER poh;
poh = (PIMAGE_OPTIONAL_HEADER)OPTHDROFFSET(lpFile);
if (poh != NULL)
return (LPVOID)poh->AddressOfEntryPoint;
else
return NULL;
}
·BaseOfCode。已载入映像的代码(“.text”段)的相对偏移量。
·BaseOfData。已载入映像的未初始化数据(“.bss”段)的相对偏移量。
WindowsWindowsWindowsWindows NTNTNTNT附加域
添加到WindowsNT PE文件格式中的附加域为WindowsNT特定的进程行为提供了装载
器的支持,以下为这些域的概述。
·ImageBase。进程映像地址空间中的首选基地址。Windows NT的 Microsoft Win32 SDK
链接器将这个值默认设为0x00400000,但是你可以使用-BASE:linker开关改变这个值。
·SectionAlignment。从 ImageBase开始,每个段都被相继的装入进程的地址空间中。
SectionAlignment 则规定了装载时段能够占据的最小空间数量——就是说,段是关于
SectionAlignment对齐的。
Windows NT虚拟内存管理器规定,段对齐不能少于页尺寸(当前的 x86平台是4096字
节),并且必须是成倍的页尺寸。4096字节是 x86链接器的默认值,但是它可以通过-ALIGN:
linker 开关来设置。
·FileAlignment。映像文件首先装载的最小的信息块间隔。例如,链接器将一个段实体(段
的原始数据)加零扩展为文件中最接近的 FileAlignment边界。早先提及的2.39版链接器将映
像文件以0x200字节的边界对齐,这个值可以被强制改为512到65535这么多。
·MajorOperatingSystemVersion。表示 Windows NT操作系统的主版本号;通常对Windows
NT 1.0而言,这个值被设为1。
·MinorOperatingSystemVersion。表示Windows NT操作系统的次版本号;通常对Windows
NT 1.0而言,这个值被设为0。
·MajorImageVersion。用来表示应用程序的主版本号;对于 Microsoft Excel 4.0而言,这
个值是4。
·MinorImageVersion。用来表示应用程序的次版本号;对于 Microsoft Excel 4.0而言,这
个值是0。
·MajorSubsystemVersion。表示 Windows NTWin32子系统的主版本号;通常对于Windows
NT 3.10而言,这个值被设为3。
·MinorSubsystemVersion。表示Windows NT Win32子系统的次版本号;通常对于Windows
NT 3.10而言,这个值被设为10。
·Reserved1。未知目的,通常不被系统使用,并被链接器设为0。
·SizeOfImage。表示载入的可执行映像的地址空间中要保留的地址空间大小,这个数字
很大程度上受 SectionAlignment的影响。例 如,考虑一个拥有固定页尺寸4096字节的系统,
如果你有一个11个段的可执行文件,它的每个段都少于4096字节,并且关于65536字节边界对
齐,那 么 SizeOfImage域将会被设为11 * 65536 = 720896(176页)。而如果一个相同的文件
关于4096字节对齐的话,那么 SizeOfImage域的结果将是11 * 4096 = 45056(11页)。这只是
个简单的例子,它说明每个段需要少于一个页面的内存。在现实中,链接器通过个别地计算
每个段的方法来决定 SizeOfImage确切的值。它首先决定每个段需要多少字节,并且最后将
页面总数向上取整至最接近的 SectionAlignment边界,然后总数 就是每个段个别需求之和了。
·SizeOfHeaders。这个域表示文件中有多少空间用来保存所有的文件头部,包括MS-DOS
头部、PE文件头部、PE可选头部以及 PE段头部。文件中所有的段实体就开始于这个位置。
·CheckSum。校验和是用来在装载时验证可执行文件的,它是由链接器设置并检验的。
由于创建这些校验和的算法是私有信息,所以在此不进行讨论。
·Subsystem。用于标识该可执行文件目标子系统的域。每个可能的子系统取值列于
WINNT.H的 IMAGE_OPTIONAL_HEADER结构之后。
·DllCharacteristics。用来表示一个 DLL映像是否为进程和线程的初始化及终止包含入口
点的标记。
·SizeOfStackReserve、SizeOfStackCommit、SizeOfHeapReserve、 SizeOfHeapCommit。
这些域控制要保留的地址空间数量,并且负责栈和默认堆的申请。在默认情况下,栈和堆都
拥有1个页面的申请值以及16个 页面的保留值。这些值可以使用链接器开关-STACKSIZE:与
-HEAPSIZE:来设置。
·LoaderFlags。告知装载器是否在装载时中止和调试,或者默认地正常运行。
·NumberOfRvaAndSizes。这个域标识了接下来的 DataDirectory数组。请注意它被用来
标识这个数组,而不是数组中的各个入口数字,这一点非常重要。
·DataDirectory。数据目录表示文件中其它可执行信息重要组成部分的位置。它事实上就
是一个 IMAGE_DATA_DIRECTORY结构的数组,位于可选头部结构的末尾。当前的 PE文
件格式定义了16种可能的数据目录,这之中的11种现在在使用中。
数据目录
WINNT.H之中所定义的数据目录为:
//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
// 机器值(MIPS GP)
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8
// TLS目录
#define IMAGE_DIRECTORY_ENTRY_TLS 9
// 载入配置目录
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10
基本上,每个数据目录都是一个被定义为 IMAGE_DATA_DIRECTORY的结构。虽然数据目
录入口本身是相同的,但是每个特定的目录种类却是完全唯一的。每个数据目录的定义在本
文的以后部分被描述为“预定义段”。
//WINNT.H
typedef struct _IMAGE_DATA_DIRECTORY{
ULONG VirtualAddress;
ULONG Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
每个数据目录入口指定了该目录的尺寸和相对虚拟地址。如果你要定义一个特定的目录的话,
就需要从可选头部中的数据目录数组中决定相对的地址,然后使用虚拟 地址来决定该目录位
于哪个段中。一旦你决定了哪个段包含了该目录,该段的段头部就会被用于查找数据目录的
精确文件偏移量位置。
所以要获得一个数据目录的话,那么首先你需要了解段的概念。我在下面会对其进行描
述,这个讨论之后还有一个有关如何定位数据目录的示例。
PEPEPEPE文件段
PE文件规范由目前为止定义的那些头部以及一个名为 “段”的一般对象组成。段包含了
文件的内容,包括代码、数据、资源以及其它可执行信息,每个段都有一个 头部和一个实体
(原始数据)。我将在下面描述段头部的有关信息,但是段实体则缺少一个严格的文件结构。
因此,它们几乎可以被链接器按任何的方法组织,只要 它的头部填充了足够能够解释数据的
信息。
段头部
PE文件格式中,所有的段头部位于可选头部之后。每个段头部为40个字节长,并且没
有任何的填充信息。段头部被定义为以下的结构:
//WINNT.H
#define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
UCHAR Name[IMAGE_SIZEOF_SHORT_NAME];
union {
ULONG PhysicalAddress;
ULONG VirtualSize;
} Misc;
ULONG VirtualAddress;
ULONG SizeOfRawData;
ULONG PointerToRawData;
ULONG PointerToRelocations;
ULONG PointerToLinenumbers;
USHORTNumberOfRelocations;
USHORTNumberOfLinenumbers;
ULONG Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
你如何才能获得一个特定段的段头部信息?既然段头部是被连续的组织起来的,而且没有一
个特定的顺序,那么段头部必须由名称来定位。以下的函数示范了如何从一个给定了段名称
的 PE映像文件中获得一个段头部:
//PEFILE.C
BOOL WINAPI GetSectionHdrByName(LPVOID lpFile, IMAGE_SECTION_HEADER *sh, char
*szSection)
{
PIMAGE_SECTION_HEADER psh;
int nSections = NumOfSections (lpFile);
int i;
if ((psh = (PIMAGE_SECTION_HEADER)SECHDROFFSET(lpFile))
!= NULL)
{
/* 由名称查找段 */
for (i = 0; i < nSections; i++)
{
if (!strcmp(psh->Name, szSection))
{
/* 向头部复制数据 */
CopyMemory((LPVOID)sh, (LPVOID)psh,
sizeof(IMAGE_SECTION_HEADER));
return TRUE;
}
else
psh++;
}
}
return FALSE;
}
这个函数通过 SECHDROFFSET 宏将第一个段头部定位,然后它开始在所有段中循环,并将
要寻找的段名称和每个段的名称相比较,直到找到了正确的那一 个为止。当找到了段的时候,
函数将内存映像文件的数据复制到传入函数的结构中,然后 IMAGE_SECTION_HEADER 结
构的各域就能够被直接存取 了。
段头部的域
·Name。每个段都有一个8字符长的名称域,并且第一个字符必须是一个句点。
·PhysicalAddress或 VirtualSize。第二个域是一个 union 域,现在已不使用了。
·VirtualAddress。这个域标识了进程地址空间中要装载这个段的虚拟地址。实际的地址
由将这个域的值加上可选头部结构中的 ImageBase 虚拟地址得到。切记,如果这个映像文件
是一个DLL,那么这个DLL 就不一定会装载到 ImageBase要求的位置。所以一旦这个文件被
装载进入了一个进 程,实际的 ImageBase值应该通过使用 GetModuleHandle来检验。
·SizeOfRawData。这个域表示了相对 FileAlignment的段实体尺寸。文件中实际的段实体
尺寸将少于或等于 FileAlignment的整倍数。一旦映像被装载进入了一个进程的地址空间,段
实体的尺寸将会变得少于或等于 FileAlignment 的整倍数。
·PointerToRawData。这是一个文件中段实体位置的偏移量。
·PointerToRelocations、PointerToLinenumbers、NumberOfRelocations、
NumberOfLinenumbers。这些域在 PE格式中不使用。
·Characteristics。定义了段的特征。这些值可以在WINNT.H及本光盘(译注:MSDN的
光盘)的 PE格式规范中找到。
值 定义
0x00000020 代码段
0x00000040 已初始化数据段
0x00000080 未初始化数据段
0x04000000 该段数据不能被缓存
0x08000000 该段不能被分页
0x10000000 共享段
0x20000000 可执行段
0x40000000 可读段
0x80000000 可写段
定位数据目录
数据目录存在于它们相应的数据段中。典型地来说,数据目录是段实体中的第一个结构,
但不是必需的。由于这个缘故,如果你需要定位一个指定的数据目录的话,就需要从段头部
和可选头部中获得信息。
为了让这个过程简单一点,我编写了以下的函数来定位任何一个在WINNT.H之中定义
的数据目录。
// PEFILE.C
LPVOID WINAPI ImageDirectoryOffset(LPVOID lpFile,
DWORD dwIMAGE_DIRECTORY)
{
PIMAGE_OPTIONAL_HEADER poh;
PIMAGE_SECTION_HEADER psh;
int nSections = NumOfSections(lpFile);
int i = 0;
LPVOID VAImageDir;
/* 必须为0到(NumberOfRvaAndSizes-1)之间 */
if (dwIMAGE_DIRECTORY >= poh->NumberOfRvaAndSizes)
return NULL;
/* 获得可选头部和段头部的偏移量 */
poh = (PIMAGE_OPTIONAL_HEADER)OPTHDROFFSET(lpFile);
psh = (PIMAGE_SECTION_HEADER)SECHDROFFSET(lpFile);
/* 定位映像目录的相对虚拟地址 */
VAImageDir = (LPVOID)poh->DataDirectory
[dwIMAGE_DIRECTORY].VirtualAddress;
/* 定位包含映像目录的段 */
while (i++ < nSections)
{
if (psh->VirtualAddress <= (DWORD)VAImageDir &&
psh->VirtualAddress +
psh->SizeOfRawData > (DWORD)VAImageDir)
break;
psh++;
}
if (i > nSections)
return NULL;
/* 返回映像导入目录的偏移量 */
return (LPVOID)(((int)lpFile +
(int)VAImageDir. psh->VirtualAddress) +
(int)psh->PointerToRawData);
}
该函数首先确认被请求的数据目录入口数字,然后它分别获取指向可选头部和第一个段头部
的两个指针。它从可选头部决定数据目录的虚拟地址,然后它使用这个值 来决定数据目录定
位在哪个段实体之中。如果适当的段实体已经被标识了,那么数据目录特定的位置就可以通
过将它的相对虚拟地址转换为文件中地址的方法来找 到。
预定义段
一个Windows NT的应用程序典型地拥有9个预定义段,它们
是.text、.bss、.rdata、.data、.rsrc、.edata、.idata、.pdata 和.debug。一些应用程序不需要所
有的这些段,同样还有一些应用程序为了自己特殊的需要而定义了更多的段。这种做法与
MS-DOS和 Windows 3.1中的代码段和数据段相似。事实上,应用程序定义一个独特的段的
方法是使用标准编译器来指示对代码段和数据段的命名,或者使用名称段编译器选项-NT
——就和Windows 3.1中应用程序定义独特的代码段和数据段一样。
以下是一个关于Windows NT PE文件之中一些有趣的公共段的讨论。
可执行代码段,.text.text.text.text
Windows 3.1和Windows NT之间的一个区别就是 WindowsNT默认的做法是将所有的
代码段(正如它们在Windows 3.1中所提到的那样)组成了一个单独的段,名为“.text”。既
然Windows NT使用了基于页面的虚拟内存管理系统,那么将分开的代码放入不同的段之中
的做法就不太明智了。因此,拥有一个大的代码段对于操作系统和应用程序开发者来 说,
都是十分方便的。
.text段也包含了早先提到过的入口点。IAT亦存在于.text段之中的模块入口点之前。
(IAT在.text段之中的存在非常有意义,因为这个表事 实上是一系列的跳转指令,并且它
们的跳转目标位置是已固定的地址。)当Windows NT的可执行映像装载入进程的地址空间
时,IAT就和每一个导入函数的物理地址一同确定了。要在.text段之中查找 IAT,装载器只
用将模块的入口点定 位,而 IAT恰恰出现于入口点之前。既然每个入口拥有相同的尺寸,
那么向后退查找这个表的起始位置就很容易了。
数据段,.bss.bss.bss.bss、.rdata.rdata.rdata.rdata、.data.data.data.data
.bss段表示应用程序的未初始化数据,包括所有函数或源模块中声明为 static的变量。
.rdata段表示只读的数据,比如字符串文字量、常量和调试目录信息。
所有其它变量(除了出现在栈上的自动变量)存储在.data段之中。基本上,这些是应
用程序或模块的全局变量。
资源段,.rsrc.rsrc.rsrc.rsrc
.rsrc段包含了模块的资源信息。它起始于一个资源目录结构,这个结构就像其它大多
数结构一样,但是它的数据被更进一步地组织在了一棵资源树之中。以下的
IMAGE_RESOURCE_DIRECTORY结构形成了这棵树的根和各个结点。
//WINNT.H
typedef struct _IMAGE_RESOURCE_DIRECTORY {
ULONG Characteristics;
ULONG TimeDateStamp;
USHORTMajorVersion;
USHORTMinorVersion;
USHORTNumberOfNamedEntries;
USHORTNumberOfIdEntries;
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;
请看这个目录结构,你将会发现其中竟然没有指向下一个结点的指针。但是,在这个结构中
有两个域NumberOfNamedEntries 和 NumberOfIdEntries 代替了指针,它们被用来表示这个
目录附有多少入口。附带说一句,我的意思是目录入口就在段数据之中的目录后边。有名称
的入口按字母升序出现,再往后是按数值升序排列的 ID入口。
一个目录入口由两个域组成,正如下面 IMAGE_RESOURCE_DIRECTORY_ENTRY结
构所描述的那样:
// WINNT.H
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
ULONG Name;
ULONG OffsetToData;
}IMAGE_RESOURCE_DIRECTORY_ENTRY, PIMAGE_RESOURCE_DIRECTORY_ENTRY;
根据树的层级不同,这两个域也就有着不同的用途。Name域被用于标识一个资源种类,或
者一种资源名称,或者一个资源的语言 ID。OffsetToData与常常被用来在树之中指向兄弟结
点——即一个目录结点或一个叶子结点。
叶子结点是资源树之中最底层的结点,它们定义了当前资源数据的尺寸和位置。
IMAGE_RESOURCE_DATA_ENTRY结构被用于描述每个叶子结点:
// WINNT.H
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
ULONG OffsetToData;
ULONG Size;
ULONG CodePage;
ULONG Reserved;
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;
OffsetToData和 Size这两个域表示了当前资源数据的位置和尺寸。既然这一信息主要是在应
用程序装载以后由函数使用的,那么将 OffsetToData作为一个相对虚拟的地址会更有意义
一些。——幸甚,恰好是这样没错。非常有趣的是,所有其它的偏移量,比如从目录入口到
其它目录 的指针,都是相对于根结点位置的偏移量。
要更清楚地了解这些内容,请参考图 2。
图 2.2.2.2.一个简单的资源树结构
图 2描述了一个非常简单的资源树,它包含了仅仅两个资源对象:一个菜单和一个字
串表。更深一层地来说,它们各自都有一个子项。然而,你仍然可以看到资源树有多么复杂
——即使它像这个一样只有一点点资源。
在树的根部,第一个目录有一个文件中包含的所有资源种类的入口,而不管资源种类
有多少。在图 2中,有两个由树根标识的入口,一个是菜单的,另一个是字串表的。如果文
件中拥有一个或多个对话框资源,那么根结点会再拥有一个入口,因此,就有了对话框资源
的另一个分支。
WINUSER.H中标识了基本的资源种类,我将它们列到了下面:
//WINUSER.H
/*
* 预定义的资源种类
*/
#define RT_CURSOR MAKEINTRESOURCE(1)
#define RT_BITMAP MAKEINTRESOURCE(2)
#define RT_ICON MAKEINTRESOURCE(3)
#define RT_MENU MAKEINTRESOURCE(4)
#define RT_DIALOG MAKEINTRESOURCE(5)
#define RT_STRING MAKEINTRESOURCE(6)
#define RT_FONTDIR MAKEINTRESOURCE(7)
#define RT_FONT MAKEINTRESOURCE(8)
#define RT_ACCELERATOR MAKEINTRESOURCE(9)
#define RT_RCDATAMAKEINTRESOURCE(10)
#define RT_MESSAGETABLE MAKEINTRESOURCE(11)
在树的第一层级,以上列出的MAKEINTRESOURCE 值被放置在每个种类入口的Name处,
它标识了不同的资源种类。
每个根目录的入口都指向了树中第二层级的一个兄弟结点,这些结点也是目录,并且
每个都拥有它们自己的入口。在这一层级,目录被用来以给定的种类标识每一个资源种类。
如果你的应用程序中有多个菜单,那么树中的第二层级会为每个菜单都准备一个入口。
你可能意识到了,资源可以由名称或整数标识。在这一层级,它们是通过目录结构的
Name域来分辨的。如果如果Name域最重要的位被设置了,那么其它的 31个位就会被用作
一个到 IMAGE_RESOURCE_DIR_STRING_U结构的偏移量。
// WINNT.H
typedef struct _IMAGE_RESOURCE_DIR_STRING_U {
USHORTLength;
WCHAR NameString[1];
} IMAGE_RESOURCE_DIR_STRING_U, *PIMAGE_RESOURCE_DIR_STRING_U;
这个结构仅仅是由一个 2字节长的 Length 域和一个UNICODE 字符 Length组成的。
另一方面,如果Name域最重要的位被清空,那么它的低 31位就被用于表示资源的整
数 ID。图 2示范的就是菜单资源作为一个命名的资源,以及字串表作为一个 ID资源。
如果有两个菜单资源,一个由名称标识