PE格式,是Windows的可执行文件的格式。Windows中的 exe文件,dll文件,都是PE格式。PE 就是Portable Executable 的缩写。Portable 是指对于不同的Windows版本和不同的CPU类型上PE文件的格式是一样的,当然CPU不一样了,CPU指令的二进制编码是不一样的。只是文件中各种东西的布局是一样的。(一) --PE文件格式概述
PE文件结构的总体层次分布如下所示
--------------
|DOS MZ Header |
|--------------|
|DOS Stub |
|--------------|
|PE Header |
|--------------|
|Section Table |
|--------------|
|Section 1 |
|--------------|
|Section 2 |
|--------------|
|Section ... |
|--------------|
|Section n |
--------------
1.1 DOS Header
PE文件最开始是一个简单的 DOS MZ header,它是一个 IMAGE_DOS_HEADER 结构。有了它,一旦程序在DOS下执行,DOS就能识别出这是有效的执行体,然后运行紧随 MZ Header 之后的 DOS Stub。
1.2 DOS Stub
DOS Stub 是一个有效的 DOS 程序。当程序在DOS下运时,输出象 "This program cannot be run in DOS mode" 这样的提示。在 图1.1中就可以看到字符串 "This program cannot be run in DOS mode"。这是编译器生成的默认stub程序。你也可以通过链接选项 /STUB:filename 指定任何有效的MS-DOS可执行文件来替换它。
1.3 PE Header
紧接着 DOS Stub 的是 PE Header。它是一个 IMAGE_NT_HEADERS 结构。其中包含了很多PE文件被载入内存时需要用到的重要域。执行体在支持PE文件结构的操作系统中执行时,PE装载器将从 DOS MZ header 中找到 PE header 的起始偏移量。因而跳过了 DOS stub 直接定位到真正的文件头 PE header。
1.4 Section Table
PE Header 接下来的数组结构 Section Table (节表)。如果PE文件里有5个节,那么此 Section Table 结构数组内就有5个成员,每个成员包含对应节的属性、文件偏移量、虚拟偏移量等。图1中的节表有4个成员。
1.5 Sections
PE文件的真正内容划分成块,称之为sections(节)。Sections 是以其起始位址来排列,而不是以其字母次序来排列。通过节表提供的信息,我们可以找到这些节。图1.1所示的 explorer.exe 中有4个节。程序的代码,资源等等就放在这些节中。
二 PE文件格式中的结构及其作用
这部分内容请参考下面的几篇文章,使用工具 JIURL PEDUMP 有助于快速了解。
大家不要因此,而失望不看,本文重点在后三篇,本篇只是为了有个交代,和介绍些相关内容。
注意,在WINNT.H中,有所有PE相关结构的定义。我们用到的结构定义都来自那里。
Microsoft Portable Executable and Common Object File Format Specification
MSDN
《Windows95系统程式设计大奥秘》
第8章 PE 与COFF OBJ 档案格式
Matt Pietrek 著 侯杰译
Iczelion的PE教程
PE学习笔记(一) rivershan
PE学习笔记(二) rivershan
Inside Windows
An In-Depth Look into the Win32 Portable Executable File Format
Matt Pietrek
已经被人翻译了。
Inside Windows
An In-Depth Look into the Win32 Portable Executable File Format
Matt Pietrek
三 几个要注意的问题
3.1 文件中大量的空白
在 PE Header结构 中的 OptionalHeader 结构中的成员 FileAlignment 的值是文件中节的对齐粒度,单位是字节,这个值应该是2的n次方,范围从512到64k。如果这里的值是512,那么PE文件中的节的长度都是512字节的整数倍,内容不够的部分用0填充。比如一个PE文件的 FileAlignment 为200h(十进制512),它的第一个节在400h处,长度为100h,那么从文件400h到500h中为这一节的内容,而文件对齐粒度是200h,所以为了使这一节长度为FileAlignment的整数倍,500h到600h会被用零填充。而下一个节的开始地址为600h。用16进制编辑器打开PE文件,就可以看到这种情况,PE文件头的内容结束到第一个节开始之间的地方,每一个节中内容结束到下一节开始的地方都会有大量的空白。VC6编译链接时默认的FileAlignment为1000h(4k),可以使用链接选项 /ALIGN:number 来改变这个值。比如把4k改成512时,可以明显减小生成文件的大小。
3.2 big-endian和little-endian
PE Header中的 FileHeader 的成员 Machine 中的值,根据WINNT.H中的定义,对于 Intel CPU 应该为 0x014c。但是你用16进制编辑器打开PE文件,看到这个WORD显示的却是 4c 01 。你看到的并没有错,你看到的 4c 01 就是 0x014c,只不过由于 intel cpu 是ittle-endian,所以显示出来是这样的。对于 big-endian 和 little-endian,请看下面的例子。
比如一个整形int变量。长为四个字节。
这个变量的地址比如为n。
则这个变量的4个字节地址分别为n,n+1,n+2,n+3。
当 这个整形变量 的值为 0x12345678 时,
对于 big-endian 来说
地址n+0的那个字节中的值为 0x12
地址n+1的那个字节中的值为 0x34
地址n+2的那个字节中的值为 0x56
地址n+3的那个字节中的值为 0x78
按如下方式就会显示为
n n+1 n+2 n+3
12 34 56 78
对于 ittle-endian 来说
地址n+0的那个字节中的值为 0x78
地址n+1的那个字节中的值为 0x56
地址n+2的那个字节中的值为 0x34
地址n+3的那个字节中的值为 0x12
按如下方式就会显示为
n n+1 n+2 n+3
78 56 34 12
Intel使用的是 ittle-endian 。
一个整形 int 变量 i,的地址是&i,那么这个i的四个字节是&i,&i+1,&i+2,&i+3。
可以用这样一个程序看到。
#include <stdio.h>
#include <conio.h>
void main()
{
int i;
char* p;
p=(char*)&i;
printf("i: ");
scanf("%x",&i);
printf("\n");
printf("&i+0: %x\n",*p);
printf("&i+1: %x\n",*(p+1));
printf("&i+2: %x\n",*(p+2));
printf("&i+3: %x\n",*(p+3));
printf("\n");
printf("&i-4: %x\n",*(p-4));
printf("&i-3: %x\n",*(p-3));
printf("&i-2: %x\n",*(p-2));
printf("&i-1: %x\n",*(p-1));
printf("\n");
printf("&i+4: %x\n",*(p+4));
printf("&i+5: %x\n",*(p+5));
printf("&i+6: %x\n",*(p+6));
printf("&i+7: %x\n",*(p+7));
getch();
}
当我们输入 12345678 的时候可以看到,输出
i: 12345678
&i+0: 78
&i+1: 56
&i+2: 34
&i+3: 12
&i-4: 7c
&i-3: ffffffff
&i-2: 12
&i-1: 0
&i+4: ffffffc0
&i+5: ffffffff
&i+6: 12
&i+7: 0
正是&i,&i+1,&i+2,&i+3这四个字节中储存了i的值。
对于int,WORD,DWORD等等都要注意 big-endian 和 little-endian 。
3.3 RVA (Relative Virtual Address) 相对虚拟地址
RVA是一个简单的相对于PE载入点的内存偏移。比如,PE载入点为0X400000,那么代码节中的地址0X401000的RVA为(target address) 0x401000 - (load address)0x400000 = (RVA)0x1000.换句话说 RVA是0x1000,载入点为0X400000,那么该RVA的在内存中的实际地址就是0X401000。注意一下RVA是指内存中,不是指文件中。是指相对于载入点的偏移而不是一个内存地址,只有RVA加上载入点的地址,才是一个实际的内存地址。
3.4 三种不同的地址
PE的各种结构中,涉及到很多地址,偏移。有些是指在文件中的偏移,有的是指在内存中的偏移。一定要搞清楚,这个地址或者是偏移,是指在文件中,还是指在内存中。第一种,文件中的地址。比如用16进制编辑器打开PE文件,看到的地址(偏移)就是文件中的地址,我们使用某个结构的文件地址,就可以在文件中找到该结构。第二种,文件被整个映射到内存时,比如某些PE分析软件,把整个PE文件映射到内存中,这时是内存中的地址,如果知道某一个结构在文件中的地址的话,那么这个PE文件被映射到内存之后该结构的在内存中的地址,可以用文件中的地址加上映射内存的地址,就可以得到在该结构内存中的地址。第三种,执行PE时,PE文件会被载入器载入内存,这时经常需要的是RVA。比如知道一个结构的RVA,那么载入点加上RVA就可以得到内存中该结构的实际地址。比如,某个程序,我们用16进制编辑器打开它,看到PE Header开始在16进制编辑器显示为000000C8的地方。于是我们在16进制编辑器显示为000000FC的地方找到了OptionalHeader的ImageBase,值为400000h,那么当这个程序被执行时,如果内存中400000h处没有使用,该程序就会被载入到那里。而我用CreateFileMapping将这个PE文件映射到内存中时,可以得到块内存的地址为5505024。对于映射入内存的这个PE文件,我们就可以在内存中000000FCh+05505024h=5505120处找到这个PE的OptionalHeader的ImageBase。
3.5 几个重要结构的说明
PE Header 的 FileHeader 的 NumberOfSections:这是一个很重要的字段,用来确定文件中节的数目。
PE Header 的 OptionalHeader 的 IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16
DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]:一个IMAGE_DATA_DIRECTORY 结构数组。到目前为止这个数组的长度是固定的,有16个元素,这16个元素分别代表
#define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory
// IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // (X86 usage)
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7 // Architecture Specific Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT 12 // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 // Delay Load Import Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14 // COM Runtime descriptor
每个元素是一个IMAGE_DATA_DIRECTORY结构,IMAGE_DATA_DIRECTORY定义如下。
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
第一个字段是一个RVA,第二个字段是一个大小。
Section Table 节表紧跟在OptionalHeader之后,是一个IMAGE_SECTION_HEADER结构的数组。该数组中成员的个数由 File Header (IMAGE_FILE_HEADER) 结构中 NumberOfSections 域的域值来定。节表中的成员是IMAGE_SECTION_HEADER 结构,IMAGE_SECTION_HEADER 结构的长度固定,长40个字节。整个Section Table 的长度不固定,等于 NumberOfSections*sizeof(IMAGE_SECTION_HEADER)。IMAGE_SECTION_HEADER 结构中,
VirtualAddress:本节的RVA(相对虚拟地址)。
PointerToRawData:这是本节基于文件的偏移量。
3.6 DOS MZ Header 中的 MZ
MZ是MZ格式的主要作者 Mark Zbikowski 的名字的缩写。
(二) --PE文件中的输出函数
一般来说输出函数都是在dll中。我们将详细介绍关于输出函数的各种结构,通过一个例子来说明输出函数及其相关结构是怎么放在PE文件中的。以及如何在PE文件中找到这些东西。
一 找到输出函数在文件中位置。
1.1 得到PE Header在文件中的位置。
通过DOS Header结构的成员e_lfanew,可以确定PE Header的在文件中的位置。
1.2 得到文件中节的数目。
确定PE Header的在文件中的位置之后,就可以确定PE Header中的成员FileHeader和成员OptionalHeader在文件中的位置。根据 FileHeader 中的 成员NumberOfSections 的值,就可以确定文件中节的数目,也就是节表数组中元素的个数。
1.3 得到节表在文件中的位置。
PE Header在文件中的位置加上PE Header结构的大小就可以得到节表在文件中的开始位置。PE Header结构的大小可以由Signature的大小加上FileHeader的大小再加上FileHeader中的SizeOfOptionalHeade来确定。其实到目前为止SizeOfOptionalHeade也就是结构Optional Header的大小也是固定的,所以整个PE Header结构的大小也是固定。不过为了安全起见,还是用Signature的大小加上FileHeader的大小再加上FileHeader中的SizeOfOptionalHeade来确定比较保险。
1.4 得到输出函数在文件中的位置。
第1.2步中我们确定了文件中节的数目,第1.3步中我们确定了节表在文件中的位置。
现在来确定输出函数在文件中的位置。
取得PE Header中的Optional Header中的DataDirectory数组中的第一项,
也就是输出函数项。DataDirectory[]数组的每项都是IMAGE_DATA_DIRECTORY结构,该结构定义如下。
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
取得DataDirectory数组中的第一项中的成员VirtualAddress的值。这个值就是在内存中资源节的RVA。
如果这个RVA的值为0表示这个PE文件中没有输出函数。
然后根据节的数目,遍历节表数组。也就是从0到(节表数-1)的每一个节表项。
每个节在内存中的RVA的范围是从该节表项的成员VirtualAddress字段的值开始(包括这个值),
到VirtualAddress+Misc.VirtualSize的值结束(不包括这个值)。
我们遍历整个节表,看我们取得的输出函数的RVA,在哪个节表项的RVA范围之内。
如果在范围之内,就找到了输出函数所在节的节表项。
这个节表项中的 PointerToRawData 中的值,就是输出函数所在节在文件中的位置。这个节表项中的VirtualAddress 中的值,就是输出函数所在节在内存中的RVA。用输出函数的RVA减去输出函数所在节的RVA,就可以得到输出函数在该节内偏移。用这个偏移加上该节的在文件中的位置,就可以得到输出函数在文件中的位置。即DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress - SectionTable[i].VirtualAddress + SectionTable[i].PointerToRawData 。
这样我们就得到了输出函数在文件中开始的位置。
二 PE文件中的输出函数。
输出函数是用来给其他程序使用的。其他程序如果知道了某个输出函数的入口地址(就是实现这个函数功能的代码开始的地方),就可以转到那里去执行。一个PE文件中,如果有有输出函数,一般都不是一个。所以有一个数组来保存每个输出函数的入口地址。在PE文件中,提供两种方法,来找到某个输出函数的入口地址。第一种方法是通过入口地址数组序号,就是说知道是入口地址数组中的第几个元素,这样就可以得到里面的入口地址。第二种方法是通过函数名,通过比较函数名,然后得到对应该函数名的入口地址数组的序号,从而得到该函数名的对应函数的入口地址。为了能够通过函数名得到序号,就需要一些相关的结构。具体内容后面讲。总得来说PE文件的输出函数部分中就是这些东西。
前面我们已经得到了输出函数部分在文件中开始的位置,在输出函数部分的最开始,是一个IMAGE_EXPORT_DIRECTORY 结构,这个结构提供很多重要的信息。这个结构的后面紧跟着的是 输出函数入口地址数组 。输出函数入口地址数组之后紧跟着的是输出函数名的指针数组。输出函数名的指针数组之后紧跟着的是输出函数名对应的序号的数组。输出函数名对应的序号的数组之后紧跟着dll的名字和输出函数的名字。注意,他们之间是紧挨着的。并且顺序为IMAGE_EXPORT_DIRECTORY,输出函数入口地址的数组,输出函数名的指针的数组,输出函数名对应的序号的数组。最后是dll的名字的字符串和那些输出函数名的字符串。
先看IMAGE_EXPORT_DIRECTORY 结构,在WINNT.H中定义如下。
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
这个结构长度为40个字节,共有11个字段。
各字段含义如下:
Characteristics:一个保留字段,目前为止值为0。
TimeDateStamp:产生的时间。
MajorVersion:
MinorVersion:
Name:一个RVA,指向一个dll的名称的ascii字符串。
Base:输出函数的起始序号。一般为1。
NumberOfFunctions:输出函数入口地址的数组 中的元素个数。
NumberOfNames:输出函数名的指针的数组 中的元素个数,也是输出函数名对应的序号的数组 中的元素个数。
AddressOfFunctions:一个RVA,指向输出函数入口地址的数组。
AddressOfNames:一个RVA,指向输出函数名的指针的数组。
AddressOfNameOrdinals:一个RVA,指向输出函数名对应的序号的数组。
输出函数入口地址的数组,这个数组是一个DWORD数组,每个元素都是一个RVA,指向一个输出函数的入口地址,每个元素长4个字节。
输出函数名的指针的数组,这个数组是一个DWORD数组,每个元素都是一个RVA,指向一个输出函数名的ascii字符串,每个元素长4个字节。
输出函数名对应的序号的数组,这个数组是一个WORD数组,每个元素都是某个输出函数名函数对应的索引,这个索引是输出函数入口地址的数组的索引(已经用序号减去起始序号了),这个每个元素长2个字节。
dll名字符串和输出函数名字符串,都是ascii字符串,以空结束。一个紧挨着一个。dll名字符串的地址存在IMAGE_EXPORT_DIRECTORY 的 Name 中。输出函数名字符串 的地址存在 输出函数名的指针的数组 中。
还有要注意的是:
输出函数入口地址的数组包含着输出函数的入口点地址,一个序号减去起始序号(起始序号就是 IMAGE_EXPORT_DIRECTORY 中的 Base),用来索引这个数组。比如,起始序号为1,要找序号为1的函数的入口地址,那么该函数的入口地址为 输出函数入口地址数组[0](0是由1-1算出来的)序号为3的函数的入口地址为 输出函数入口地址数组[2](2是由3-1算出来的)。
当载入器要修正一个函数的调用,而这个函数是用序号输入的,载入器只要用序号减去起始序号,得到输出函数入口地址的数组的索引,就可以了。
当载入器要修正一个函数的调用,而这个函数是用函数名输入的,载入器比较输出函数名的指针的数组每个元素所指的函数名,比如在第3个元素中比较,发现相同。载入器就会从 输出函数名对应的序号的数组 的第三个元素的值 得到该函数的序号。用这个序号就可以在象前面那样用序号得到入口地址。输出函数名的指针的数组 和 输出函数名对应的序号的数组 有相同的元素个数(IMAGE_EXPORT_DIRECTORY 中的 NumberOfNames)。并且是有所关联的,函数名指针数组的第i个元素的序号,在序号数组的第i个元素中。输出函数名的指针的数组和输出函数名对应的序号的数组,分开成两个数组,而不是合并成一个结构体的数组(这个结构体第一个成员是指针,第二个成员是序号),是因为,那样的话数组的一个元素长6个字节,不利于对齐。
下面我们来通过一个例子,来看上面所介绍的内容。
我们的例子是Win2k中的dll文件routetab.dll。为了防止大家版本不同,本文附带了这个PE文件。
用开始讲到的寻找输出部分在文件中位置的方法,我们找到了输出部分在文件中的位置为00001460h。
由于第一个结构IMAGE_EXPORT_DIRECTORY比较长,一行方不下,所以放了三行,结构的不同成员用 / 分开。
其他每行是一个结构。可以用16进制编辑器打开附带的 routetab.dll 对照着看。
我们来算一下 Name,AddressOfFunctions,AddressOfNames,AddressOfNameOrdinals 在文件中的位置。
输出部分的开始rva(由DataDirectory[1]得到)为1e60h。输出部分在文件中的位置为1460h。
Name为rva(值从结构中可以看到是00001eec,如果你不明白为什么是00001eec而不是ec1e0000的话,请看 《JIURL PE 格式学习总结(一)》中关于 big-endian和little-endian的介绍),则Name相对于输出部分开始处的偏移为1eec-1e60。而Name在文件中的位置为Name在相对于输出部分开始的偏移加上输出部分开始处在文件中的位置。所以Name在文件中的位置为1EEC-1E60+1460=14ECh。同样方法我们可以算出, AddressOfFunctions:
1e88-1e60+1460=1488。AddressOfNames:1eb0-1e60+1460=14b0 。AddressOfNameOrdinals:1ed8-1e60+1460=14d8。 从结构中还可以看到有0000000a(十进制10)个输出函数。
00001460: {00 00 00 00 / dc 5b ec 37 / 00 00 / 00 00 / ec 1e 00 00 /
00001470: 01 00 00 00 / 0a 00 00 00 / 0a 00 00 00 / 88 1e 00 00 /
00001480: b0 1e 00 00 / d8 1e 00 00 }
(我们用大括号括起来了,IMAGE_EXPORT_DIRECTORY结构,长度为40个字节)
00001488: 41 1a 00 00 (函数入口点的RVA,长4个字节)
0000148C: 64 1a 00 00
00001490: 02 18 00 00
00001494: 02 18 00 00
00001498: 71 16 00 00
0000149C: 07 16 00 00
000014A0: 26 18 00 00
000014A4: 84 1a 00 00
000014A8: 06 17 00 00
000014AC: 5b 19 00 00
000014B0: f9 1e 00 00 (函数名的指针,长4个字节,指向 1ef9-1e60+1460=14f9)
000014B4: 02 1f 00 00
000014B8: 0e 1f 00 00
000014BC: 21 1f 00 00
000014C0: 30 1f 00 00
000014C4: 42 1f 00 00
000014C8: 4d 1f 00 00
000014CC: 5b 1f 00 00
000014D0: 6c 1f 00 00
000014D4: 81 1f 00 00
000014D8: 00 00 (索引,说明的1个函数名的函数,入口地址在 地址数组[0])
(并不是每个PE文件序号数组的第0个元素值就是0,第1个元素值就是1,ntdll.dll中就不是)
000014DA: 01 00
000014DC: 02 00
000014DE: 03 00
000014E0: 04 00
000014E2: 05 00
000014E4: 06 00
000014E6: 07 00
000014E8: 08 00
000014EA: 09 00
000014EC: 52 4f 55 54 45 54 41 42 2e 64 6c 6c 00 ROUTETAB.dll.
000014F9: 41 64 64 52 6f 75 74 65 00 AddRoute.
00001502: 44 65 6c 65 74 65 52 6f 75 74 65 00 DeleteRoute.
0000150E: 46 72 65 65 49 50 41 64 64 72 65 73 73 54 61 62 6c 65 00 FreeIPAddressTable.
00001521: 46 72 65 65 52 6f 75 74 65 54 61 62 6c 65 00 FreeRouteTable.
00001530: 47 65 74 49 50 41 64 64 72 65 73 73 54 61 62 6c 65 00 GetIPAddressTable.
00001542: 47 65 74 49 66 45 6e 74 72 79 00 GetIfEntry.
0000154D: 47 65 74 52 6f 75 74 65 54 61 62 6c 65 00 GetRouteTable.
0000155B: 52 65 66 72 65 73 68 41 64 64 72 65 73 73 65 73 00 RefreshAddresses.
0000156C: 52 65 6c 6f 61 64 49 50 41 64 64 72 65 73 73 54 61 62 6c 65 00 ReloadIPAddressTable.
00001581: 53 65 74 41 64 64 72 43 68 61 6e 67 65 4e 6f 74 69 66 79 45 76 65 6e 74 00
SetAddrChangeNotifyEvent.
0000159A: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
000015AA: ...
000015F0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
还是比较清楚的,就不再讲了。
三 遍历PE文件中的输出
根据前面的方法得到输出部分的开始地址,最开始是一个IMAGE_EXPORT_DIRECTORY,根据这个结构中的内容,可以得到,和输出相关的三个数组的开始地址,和元素个数。用for循环可以很简单的遍历。
实现遍历输出的源程序,可以参考 PEDUMP - Matt Pietrek 1995 。《Windows95系统程式设计大奥秘》附书源码中有。
(三) -- PE文件中的输入函数
关于输入部分,我们将详细介绍关于输入函数的各种结构,通过一个例子来说明输入函数及其相关结构是怎么放在PE文件中的。以及如何在PE文件中找到这些东西。
一 找到输入部分在文件中位置。
1.1 得到PE Header在文件中的位置。
通过DOS Header结构的成员e_lfanew,可以确定PE Header的在文件中的位置。
1.2 得到文件中节的数目。
确定PE Header的在文件中的位置之后,就可以确定PE Header中的成员FileHeader和成员OptionalHeader在文件中的位置。根据 FileHeader 中的 成员NumberOfSections 的值,就可以确定文件中节的数目,也就是节表数组中元素的个数。
1.3 得到节表在文件中的位置。
PE Header在文件中的位置加上PE Header结构的大小就可以得到节表在文件中的开始位置。PE Header结构的大小可以由Signature的大小加上FileHeader的大小再加上FileHeader中的SizeOfOptionalHeade来确定。其实到目前为止SizeOfOptionalHeade也就是结构Optional Header的大小也是固定的,所以整个PE Header结构的大小也是固定。不过为了安全起见,还是用Signature的大小加上FileHeader的大小再加上FileHeader中的SizeOfOptionalHeade来确定比较保险。
1.4 得到输入部分在文件中的位置。
第1.2步中我们确定了文件中节的数目,第1.3步中我们确定了节表在文件中的位置。
现在来确定输入部分在文件中的位置。
取得PE Header中的Optional Header中的DataDirectory数组中的第二项,
也就是输入部分项。DataDirectory[]数组的每项都是IMAGE_DATA_DIRECTORY结构,该结构定义如下。
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
取得DataDirectory数组中的第二项中的成员VirtualAddress的值。这个值就是在内存中资源节的RVA。
如果这个RVA的值为0表示这个PE文件中没有输入部分。
然后根据节的数目,遍历节表数组。也就是从0到(节表数-1)的每一个节表项。
每个节在内存中的RVA的范围是从该节表项的成员VirtualAddress字段的值开始(包括这个值),
到VirtualAddress+Misc.VirtualSize的值结束(不包括这个值)。
我们遍历整个节表,看我们取得的输入部分的RVA,在哪个节表项的RVA范围之内。
如果在范围之内,就找到了输入部分所在节的节表项。
这个节表项中的 PointerToRawData 中的值,就是输入部分所在节在文件中的位置。这个节表项中的VirtualAddress 中的值,就是输入部分所在节在内存中的RVA。用输入部分的RVA减去输入部分所在节的RVA,就可以得到输入部分在该节内偏移。用这个偏移加上该节的在文件中的位置,就可以得到输入部分在文件中的位置。即DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress - SectionTable[i].VirtualAddress + SectionTable[i].PointerToRawData 。
这样我们就得到了输入部分在文件中开始的位置。
二 PE文件中的输入部分。
输入部分,如果要调用别的PE文件中的输出函数,需要那些东西呢?首先需要知道所需函数在哪个文件中,比如函数 NtRaiseHardError 就在PE文件 ntdll.dll 中。所以我们需要一个文件名。而如何找到某个函数的入口地址呢,我们还需要知道该函数的函数名,或者改函数的序号,通过这两者的任一种,我们就可以找到该函数的入口地址(如果不知道为什么,请看 JIURL PE 格式学习总结(二)-- PE文件中的输出函数)。所以我们还需要函数名或者序号,这两者之一。PE文件的输入部分,有这些内容。我们还可以想到,当一个PE文件被执行的时候,它会把所用的输入函数所在的每一个文件载入内存,并且,根据函数名或者序号,获得每一个输入函数的入口地址,存放起来,在程序执行的时候使用。还有就是,一个可执行文件一般都使用好几个PE文件(通常是dll)的输出函数。所以需要有多个dll(就说成dll吧,提供输出函数的PE文件差不多都是dll,下面就按dll说)的相关信息。
前面我们已经得到了输入部分在文件中开始的位置,在输入部分的最开始,是一个IMAGE_IMPORT_DESCRIPTOR 结构数组,这个数组的最后一个元素内容全为空,标示着这个数组的结束,这个数组的每个元素,保存着一个dll的相关信息。紧跟着这个IMAGE_IMPORT_DESCRIPTOR数组的是几个紧挨着的DWORD数组, 数组的每个元素存有函数名字符串的RVA,或者直接保存序号,每个数组的最后一项为空,标示结束。这几个数组之后,紧跟着的是dll名字的字符串和各个输入函数名结构。
IMAGE_IMPORT_DESCRIPTOR 结构在WINNT.H中定义如下。
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
};
DWORD TimeDateStamp; // 0 if not bound,
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time stamp of DLL bound to (Old BIND)
DWORD ForwarderChain; // -1 if no forwarders
DWORD Name;
DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
这个结构长度为20个字节,共有5个字段。
各字段含义如下:
OriginalFirstThunk:(在WINNT.H中Characteristics这个叫法已经不对了)这里实际上保存着一个RVA,这个RVA指向一个DWORD数组,这个数组可以叫做输入查询表。每个数组元素,或者叫一个表项,保存着一个指向函数名的RVA或者保存着一个函数的序号。
TimeDateStamp:当这个值为0的时候,表明还没有bind。不为0的话,表示已经bind过了。有关bind的内容后面介绍。
ForwarderChain:
Name:一个RVA,这个RVA指向一个ascii以空字符结束的字符串,这个字符串就是本结构对应的dll文件的名字。
FirstThunk:一个RVA,这个RVA指向一个DWORD数组,这个数组可以叫输入地址表。如果bind了的话,这个数组的每个元素,就是一个输入函数的入口地址。
输入查询表,就是OriginalFirstThunk所指向的那个DWORD数组,它的每一个元素是一个DWORD值,当最高位为1时,低31位中的值,就是一个序号。当最高位为0时,这个元素的值就是一个指向一个输入函数名结构的RVA。这个数组的最后一个元素值为空,表示数组的结束。
输入函数名结构,在WINNT.H中定义如下。
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
BYTE Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
这个结构的长度不定,有两个成员。第一个成员是一个WORD类型,长2个字节,保存着输入函数的序号。第二个成员是一个ascii字符串,这个字符串是输入函数的名字。为了保证字对齐,可能会在ascii结束符\0之后再填充一个\0。比如,1b 01 4e 74 54 65 72 6d 69 6e 61 74 65 50 72 6f 63 65 73 73 00 00 ,如果不填充最后一个00的话,长度为21个字节,不是字对齐。所以要填充一个00。
输入地址表,就是FirstThunk所指向的那个DWORD数组,它的每一个元素是一个DWORD值。如果程序已经bind了的话,(判断依据是TimeDateStamp,TimeDateStamp为0则没有bind)那么这里的每个元素的值,就是一个输入函数的入口地址。如果没有bind的话,那么在本pe文件执行时,载入器会载入dll文件,获得每一个输入函数的入口地址,并填入这个输入地址表的每一项中。(这些是我猜的,大家但愿我猜对吧)这个数组的最后一个元素值为空,表示数组的结束。
bind,从上面的介绍中可以看到,如果没有bind的话,每次pe文件被执行时,载入器都要查询一遍每个函数的入口地址,所以为了优化这一点,就有了bind,把入口点直接存在输入地址表中。
载入器会载入所需要的dll。注意一下没有bind的情况下,载入器对输入部分所要做的事情。总之,在载入之后,所需的dll(根据文件名)已经都被载入到内存。并且输入地址表中的每一个元素都是一个输入函数的入口地址了。
下面我们来看一个例子,通过例子就可以明白是怎么回事了。
我们的例子是Win2k中的exe文件csrss.exe。为了防止大家版本不同,本文附带了这个PE文件。
每个结构的不同成员用 / 分开。每行是一个结构。可以用16进制编辑器打开附带的 routetab.dll 对照着看。
括号中内容为注释。
用开始讲到的寻找输入部分在文件中位置的方法,我们找到了输入部分在文件中的位置为000008DCh。
我们来计算一下第一个IMAGE_IMPORT_DESCRIPTOR中的OriginalFirstThunk,Name,FirstThunk。
输入部分所在节的开始rva(由DataDirectory[2]得到)为1000h。输入部分在节在文件中的位置为600h。
Name为rva(值从结构中可以看到是0000135e,如果你不明白为什么是0000135e而不是5e130000的话,请看 《JIURL PE 格式学习总结(一)》中关于 big-endian和little-endian的介绍),则Name相对于所在节开始处的偏移为135e-1000。而Name在文件中的位置为Name在相对于所在节开始的偏移加上所在节开始处在文件中的位置。所以Name在文件中的位置为135eh-1000h+600h=95eh。同样方法我们可以算出, OriginalFirstThunk:
1318-1000+600=918。FirstThunk:1000-1000+600=600。
000008DC: 18 13 00 00 / ff ff ff ff / ff ff ff ff / 5e 13 00 00 / 00 10 00 00
(结构IMAGE_IMPORT_DESCRIPTOR,每个代表一个dll。可以看到两个IMAGE_IMPORT_DESCRIPTOR,所以本PE文件的输入函数,是由两个dll提供的。第三个全为空,表示结束。)
000008F0: 20 13 00 00 / ff ff ff ff / ff ff ff ff / c2 13 00 00 / 08 10 00 00
(结构IMAGE_IMPORT_DESCRIPTOR)
00000904: 00 00 00 00 / 00 00 00 00 / 00 00 00 00 / 00 00 00 00 / 00 00 00 00
(全为空,表示结束IMAGE_IMPORT_DESCRIPTOR数组结束)
00000918: 44 13 00 00 (文件中的地址为1344-1000+600=944,指向一个输入函数名结构)
0000091C: 00 00 00 00 (为空,一个输入查询表结束)
00000920: 84 13 00 00 (文件中的地址为1384-1000+600=984,指向一个输入函数名结构)
00000924: 98 13 00 00 (1398-1000+600=998)
00000928: 6a 13 00 00 (136a-1000+600=96a)
0000092C: ae 13 00 00 (13ae-1000+600=9ae)
00000930: cc 13 00 00 (13cc-1000+600=9cc)
00000934: dc 13 00 00 (13dc-1000+600=9dc)
00000938: ee 13 00 00 (13ee-1000+600=9ee)
0000093C: 0e 14 00 00 (140e-1000+600=a0e)
00000940: 00 00 00 00 (为空,一个输入查询表结束)
00000944: 18 00 / 43 73 72 53 65 72 76 65 72 49 6e 69 74 69 61 6c 69 7a 61 74 69 6f 6e 00
(输入函数名结构 IMAGE_IMPORT_BY_NAME hint为18 Name为 "CsrServerInitialization.")
0000095E: 43 53 52 53 52 56 2e 64 6c 6c 00 00
(第一个IMAGE_IMPORT_DESCRIPTOR的Name指向这里"CSRSRV.dll")
0000096A: 00 01 / 4e 74 53 65 74 49 6e 66 6f 72 6d 61 74 69 6f 6e 50 72 6f 63 65 73 73 00
("NtSetInformationProcess.")
00000984: 1c 01 / 4e 74 54 65 72 6d 69 6e 61 74 65 54 68 72 65 61 64 00
00000998: 1b 01 / 4e 74 54 65 72 6d 69 6e 61 74 65 50 72 6f 63 65 73 73 00 00
000009AE: d8 00 / 4e 74 52 61 69 73 65 48 61 72 64 45 72 72 6f 72 00 00
000009C2: 6e 74 64 6c 6c 2e 64 6c 6c 00
000009CC: 0d 00 / 44 62 67 42 72 65 61 6b 50 6f 69 6e 74 00
000009DC: 4a 01 / 52 74 6c 41 6c 6c 6f 63 61 74 65 48 65 61 70 00
000009EE: 85 02 / 52 74 6c 55 6e 69 63 6f 64 65 53 74 72 69
6e 67 54 6f 41 6e 73 69 53 74 72 69 6e 67 00 00
00000A0E: 30 02 / 52 74 6c 4e 6f 72 6d 61 6c 69 7a 65 50 72 6f 63 65 73 73 50 61 72 61 6d 73 00
00000A2A: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000A3A: ...
00000600: 38 1f f8 5f
00000604: 00 00 00 00 (为空,一个输入地址表结束)
00000608: 6d f0 f8 77
0000060C: d8 c3 f8 77
00000610: a5 b7 f8 77
00000614: 38 a4 f9 77
00000618: df f9 f9 77
0000061C: 6b 97 fc 77
00000620: ec e5 f8 77
00000624: 18 2c f9 77
00000628: 00 00 00 00 (为空,一个输入地址表结束)
本例比较可惜的是,在两个输入查询表中,都是函数名结构的RVA,没有直接的序号(是序号还是RVA的判别方法为,看最高位是否为1,为1,其余部分表示序号。为0,整个字段表示RVA)。
三 遍历PE文件中的输入
用while循环,遍历IMAGE_IMPORT_DESCRIPTOR数组的每个元素(每个可以找到一个dll的信息和该dll提供的输入函数)。当某元素的值都为空时,表示遍历到了数组的最后。而对于IMAGE_IMPORT_DESCRIPTOR数组的一个元素,再用while循环,遍历IMAGE_IMPORT_DESCRIPTOR中,两个RVA所指的两个DWORD数组,输入查询表和输入地址表。判断结束的条件也是看,是否数组元素的值已经为空了。也就是while(..){..while(..){}..}这样就可获得每一个有关输入的内容。
实现遍历输入的源程序,可以参考 PEDUMP - Matt Pietrek 1995 。《Windows95系统程式设计大奥秘》附书源码中有。
(四)-- PE文件中的资源
程序所用到的各种资源,比如 bmp,cursor,menu,对话框等都存在PE文件中。
我们将详细介绍关于资源的各种结构,通过一个例子来说明资源及其相关结构是怎么放在PE文件中的。以及如何在遍历PE文件中的所有资源。我们只最终找到这些资源在文件中的位置和长度。而不具体分析某种资源的格式,比如有个BMP的资源,我们不分析BMP格式。
一 找到资源在文件中位置。
资源都放在PE文件的某个节中,该节的节表项中的PointerToRawData,就是资源节在文件中的位置。
1.1 得到PE Header在文件中的位置。
通过DOS Header结构的成员e_lfanew,可以确定PE Header的在文件中的位置。
1.2 得到文件中节的数目。
确定PE Header的在文件中的位置之后,就可以确定PE Header中的成员FileHeader和成员OptionalHeader在文件中的位置。根据 FileHeader 中的 成员NumberOfSections 的值,就可以确定文件中节的数目,也就是节表数组中元素的个数。
1.3 得到节表在文件中的位置。
PE Header在文件中的位置加上PE Header结构的大小就可以得到节表在文件中的开始位置。PE Header结构的大小可以由Signature的大小加上FileHeader的大小再加上FileHeader中的SizeOfOptionalHeade来确定。其实到目前为止SizeOfOptionalHeade也就是结构Optional Header的大小也是固定的,所以整个PE Header结构的大小也是固定。不过为了安全起见,还是用Signature的大小加上FileHeader的大小再加上FileHeader中的SizeOfOptionalHeade来确定比较保险。
1.4 得到资源节在文件中的位置。
第1.2步中我们确定了文件中节的数目,第1.3步中我们确定了节表在文件中的位置。
现在有两种方法来确定资源在文件中的位置。
第一种方法,根据节的数目,遍历节表数组。也就是从0到(节表数-1)的每一个节表项。
比较每一个节表项的Name字段,看是否等于".rsrc"。如果等于。就找到了资源节的节表项。
这个节表项中的 PointerToRawData 中的值,就是资源节在文件中的位置。
第二种方法,取得PE Header中的Optional Header中的DataDirectory数组中的第三项,
也就是资源项。DataDirectory[]数组的每项都是IMAGE_DATA_DIRECTORY结构,该结构定义如下。
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
取得DataDirectory数组中的第三项中的成员VirtualAddress的值。这个值就是在内存中资源节的RVA。
然后根据节的数目,遍历节表数组。也就是从0到(节表数-1)的每一个节表项。
每个节在内存中的RVA的范围是从该节表项的成员VirtualAddress字段的值开始(包括这个值),
到VirtualAddress+Misc.VirtualSize的值结束(不包括这个值)。
我们遍历整个节表,看我们取得的资源节的RVA,在哪个节表项的RVA范围之内。
如果在范围之内,就找到了资源节的节表项。
这个节表项中的 PointerToRawData 中的值,就是资源节在文件中的位置。
如果这个PE文件没有资源的话,DataDirectory数组中的第三项内容为0。
这样我们就得到了资源在文件中开始的位置。
二 PE文件中的资源。
我们已经得到了资源节在文件中的位置。
资源节最开始是一个IMAGE_RESOURCE_DIRECTORY结构。
在WINNT.H中定义如下。
typedef struct _IMAGE_RESOURCE_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
WORD NumberOfNamedEntries;
WORD NumberOfIdEntries;
// IMAGE_RESOURCE_DIRECTORY_ENTRY DirectoryEntries[];
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;
这个结构长度为16字节,共有6个字段。
各字段含义如下:
Characteristics: Resource flags,保留用于以后使用,目前都为0。
TimeDateStamp:资源编译器产生资源的时间。
MajorVersion:
MinorVersion:
NumberOfNamedEntries:用字符串来标示IMAGE_RESOURCE_DIRECTORY_ENTRY项的,紧跟着本结构的IMAGE_RESOURCE_DIRECTORY_ENTRY数组的成员个数。
Number of ID Entries:用整形数字来表示IMAGE_RESOURCE_DIRECTORY_ENTRY项的,紧跟着本结构的IMAGE_RESOURCE_DIRECTORY_ENTRY数组的成员个数。
IMAGE_RESOURCE_DIRECTORY后面一定会紧跟着一个IMAGE_RESOURCE_DIRECTORY_ENTRY数组。
IMAGE_RESOURCE_DIRECTORY_ENTRY结构定义如下。
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
union {
struct {
DWORD NameOffset:31;
DWORD NameIsString:1;
};
DWORD Name;
WORD Id;
};
union {
DWORD OffsetToData;
struct {
DWORD OffsetToDirectory:31;
DWORD DataIsDirectory:1;
};
};
} IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;
这个结构长度为8个字节。共有两个字段,每个字段4个字节。
根据不同情况,这两个字段的含义不一样。这个结构的定义如果看不懂的话,后面的例子一看就会明白了。
第一个字段,当第一个字段的最高位是1的时候,表示,这个DWORD的剩下31位表明一个相对于资源开始位置的偏移,这个偏移的内容是一个IMAGE_RESOURCE_DIR_STRING,用里面的字符串来标明这个IMAGE_RESOURCE_DIRECTORY_ENTRY。当第一个字段的最高位是0的时候,表示,这个DWORD的低WORD中的值作为id标明这个IMAGE_RESOURCE_DIRECTORY_ENTRY。
第二个字段,当第二个字段的最高位是1的时候,表示,还有下一层的结构。这个DWORD的剩下31位表明一个相对于资源开始位置的偏移,这个偏移的内容会是一个下一层的IMAGE_RESOURCE_DIRECTORY结构,这个请看后面的例子中的说明。
当第二个字段的最高位是0的时候,表示,已经没有下一层的结构了。这个DWORD的剩下31位表明一个相对于资源开始位置的偏移,这个偏移的内容会是一个IMAGE_RESOURCE_DATA_ENTRY结构,IMAGE_RESOURCE_DATA_ENTRY结构会说明资源的位置。
标示一个IMAGE_RESOURCE_DIRECTORY_ENTRY一般都是使用id,就是一个整数。
但是也有少数的使用IMAGE_RESOURCE_DIR_STRING来标示一个IMAGE_RESOURCE_DIRECTORY_ENTRY。
IMAGE_RESOURCE_DIRECTORY_ENTRY结构定义如下。
typedef struct _IMAGE_RESOURCE_DIR_STRING_U {
WORD Length;
WCHAR NameString[ 1 ];
} IMAGE_RESOURCE_DIR_STRING_U, *PIMAGE_RESOURCE_DIR_STRING_U;
这个结构中将有一个Unicode的字符串,是字对齐的。所有这些用来标识的IMAGE_RESOURCE_DIR_STRING都放在一起,这个结构的长度是可变的,由第一个字段Length指明后面的Unicode字符串的长度。
经过3层IMAGE_RESOURCE_DIRECTORY_ENTRY(一般是3层,也有可能更少些。第一层资源类型bmp,menu等等,第二层资源名,第三层是资源的Language。)最终会找到一个IMAGE_RESOURCE_DATA_ENTRY结构,这个结构中存有相应(某资源类型,某资源名,某资源Language)资源的位置和大小,就真正找到资源了。IMAGE_RESOURCE_DATA_ENTRY定义如下。
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
DWORD OffsetToData;
DWORD Size;
DWORD CodePage;
DWORD Reserved;
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;
这个结构长16个字节,有4个字段。
OffsetToData:这是一个内存中的RVA,要转化成文件中的位置,需要用这个值减去资源节的开始RVA,
资源节的开始RVA可以由Optional Header中的DataDirectory数组中的第三项中的VirtualAddress的值得到。
或者节表中,资源节那项中的VirtualAddress的值得到。相减之后,就可以得到相对于资源节开始的偏移。
再加上资源节在文件中的开始位置,节表中资源节那项中的PointerToRawData的值,就是资源在文件中的位置。
Size:资源的大小,以字节为单位。
CodePage:一般来说是Unicode code page。
Reserved:保留,值为0。
===================================================================
雪狼注:关于更多详细情况,可看作者本人主页,附有图和源代码
作者主页: http://jiurl.nease.net/