Welcome to A!Die Software Studio |
摘要
对可执行文件的深入认识将带你深入到系统深处。如果你知道你的exe/dll里是些什么东东,你就是一个更有知识的程序员。作为系列文章的第一章,将关注这几年来PE格式的变化,同时也简单介绍一下PE格式。经过这次更新,作者加入了PE格式是如何与.NET协作的及PE文件表格(PE FILE SECTIONS),RVA,The DataDirectory,函数的输入等内容。
====================
很久以前,我给Microsoft Systems Journal(现在的MSDN)写了一篇名为“Peering Inside the PE: A Tour of the Win32 Portable Executable File Format”的文章。后来比我期望的还流行,到现在我还听说有人在用它(它还在MSDN里)。不幸的是,那篇文章的问题依旧存在,WIN32的世界静悄悄地变了好多,那篇文章已显得过期了。从这个月开始我将用这两篇文章来弥补。
你可能会问为什么我应当了解PE格式,答案依旧:操作系统的可执行文件格式和数据结构暴露出系统的底层细节。通过了解这些,你的程序将编的更出色。
当然,你可以阅读微软的文档来了解我将要告诉你的。但是,像很多文档一样,‘宁可晦涩,但为瓦全’。
我把焦点放在提供一些不适合放在正式文档里的内容。另外,这篇文章里的一些知识不见得能在官方文档里找到。
1. 裂缝的撕开
让我给你一些从1994年我写那篇文章来PE格式变化的例子。WIN16已经成为历史,也就没有必要作什么比较和说明了。另外一个可憎的东西就是用在WINDOWS 3.1 中的WIN32S,在它上面运行程序是那么的不稳定。
那时候,WINDOWS 95(也叫Chicago)还没有发行。NT还是3.5版。微软的连接器还没开始大规模的优化,尽管如此,there were MIPS and DEC Alpha implementations of Windows NT that added to the story.
那么究竟,这么些年来,有些什么新的东西出来呢?64位的WINDOWS有了它自己的PE变种,WINDOWS CE 支持各种CPU了,各种优化如DLL的延迟载入,节表的合并,动态捆绑等也已出台。
有很多类似的东西发生了。
让我们最好忘了.NET。它是如何与系统切入的呢?对于操作系统,.NET的可执行文件格式是与旧的PE格式兼容的。虽然这么说,在运行时期,.NET还是按元数据和中间语言来组织数据的,这毕竟是它的核心。这篇文章当中,我将打开.NET元数据这扇门,但不做深入讨论。
如果WIN32的这些变化都不足以让我重写这篇文章,就是原来的那些错误也让我汗颜。比如我对TLS的描述只是一带而过,我对时间戳的描述只有你生活在美国西部才行等等。还有,一些东西已是今是作非了, 我曾说过.RDATA几乎没排上用场,今天也是,我还说过.IDATA节是可读可写的,但是一些搞API拦截的人发现好像是错的。
在更新这篇文章的过程当中,我也检查了PEDUMP这个用来倾印PE文件的程序.这个程序能够在0X86和IA-64平台下编译和运行。
2. PE格式概览
微软的可执行文件格式,也就是大家熟悉的PE 格式,是官方文档的一部分。但是,它是从VAX/VMS上的COFF派生出来的,就WINDOWS NT小组的大部分是从DEC转过来的看来,这是可以理解的。很自然,这些人在NT的开发上会用他们以往的代码。
采用术语“PORTABLE EXECUTABLE”是因为微软希望有一个通用在所有WINDOWS平台上和所有CPU上的文件格式。从大的方面讲,这个目标已经实现。它适用于NT及其后代,95及其后代,和CE.
微软产生的OBJ文件是用COFF格式的。当你看到它的很多域都是用八进制的编码的,你会发现她是多么古老了。COFF OBJ文件用到了很多和PE一样的数据结构和枚举,我马上会提到一些。
64位的WINDOWS只对PE格式作了一点点改变。这个新的格式叫做PE32+。没有增加一个字段,且只删了一个字段。其他的改变就是把以前的32位字段扩展成64位。对于C++代码,通过宏定义WINDOWS的头文件已经屏蔽了这些差别。
EXE与DLL的差别完全是语义上的。它们用的都是同样一种文件格式-PE。唯一的区别就是其中有一个字段标识出是EXE还是DLL .还有很多DLL的扩展比如OCX,CPL等都是DLL.它们有一样的实体。
你首先要知道的关于PE的知识就是磁盘中的数据结构布局和内存中的数据结构布局是一样的。 载入可执行文件(比如LOADLIBARY)的首要任务就是把磁盘中的文件映射到进程的地址空间.因此像IMAGE_NT_HEADER(下面解释)在磁盘和内存中是一样的。关键的是你要懂得你怎样在磁盘中获得PE文件某些信息的, 当它载入内存时你可以一样获得,基本上是没什么不同的(即内存映射文件)。但是知道与映射普通的内存映射文件不同是很重要的。WINDOWS载入器察看PE文件才决定映射到哪里,然后从文件的开始处 往更高的地址映射,但是有的东西在文件中的偏移和在内存中的偏移会不一样。尽管如此,你也有了足够的信息把文件偏移转化成内存偏移。
当Windows载入器把PE载入内存,在内存中它称作模块(MODULE), 文件从HMODULE这个地址开始映射。记住这点:给你个HMODULE,从那你可以知道一个数据结构(IMAGE_DOS_HEADER),然后你还可以知道所有得数据结构。这个强大的功能对于API拦截特别有意义。(准确地说:对于WINDOWS CE,这是不成立的,不过这是后话)。
内存中的模块代表着进程从这个可执行文件中所需要的所有代码,数据,资源。其他部分可以被读入,但是可能不映射(如,重定位节)。还有一些部分根本就不映射,比如当调试信息放到文件的尾部的时候。有一个字段告诉系统把文件映射到内存需要多少内存。不需要的数据放在文件的尾部,而在过去,所有部分都映射。 在WINNT.H描述了PE格式。在这个文件中,几乎有所有的关于PE的数据结构,枚举,#DEFINE。当然,其它地方也有相关文档,但是还是WINNT.H说了算。
有很多检测PE文件的工具,有VISUAL STUDIO的DUMPBIN,SDK中的DEPENDS,我比较喜欢DEPENDS,因为它以一种简洁的方式检测出文件的引入引出。一个免费的PE察看器,PEBrowse,来自smidgenosoft。我的pedump也是很有用的,它和dumpbin有一样的功能。
从api的立场看,imagehlp.dll提供了读写pe文件的机制。
在开始讨论pe文件前,回顾一下pe文件的一些基本概念是有意义的。在下面几节,我将讨论:pe 节,相对虚拟地址(rva),数据目录,函数的引入。
3. PE节
PE节以某钟顺序表示代码或数据。代码就是代码了,但是却有多种类型的数据,可读写的程序数据(如全局变量),其它的节包含API的引入引出表,资源,重定位。 每个节有自己的属性,包括是否是代码节,是否只读还是可读可写,节的数据是否全局共享。
通常,节中的数据逻辑上是关联的。PE文件一般至少要有两个节,一个是代码,另一个为数据。一般还有一个其它类型的数据的节。后面我将描述各种类型的节。
每个节都有一个独特的名字。这个名字是用来传达这个节的用途的。比如,.RDATA表示一个只读节, 节的名字对于操作系统毫无意义,只是为了人们便于理解。把一个节命名为FOOBAR和.TEXT是一样有用的。微软给他们的节命名了个有特色的名字,但是这不是必需的。Borland的连接器用的是code和data。
一般编译器将产生一系列标准的节,但这没有什么不可思议的。你可以建立和命名自己的节,连接器会自动在程序文件中包含它们。在visual c++中,你能用#pragma指令让编译器插入数据到一个节中。像下面这样:
#pragma data_seg("MY_DATA")
...有必要初始化
#pragma data_seg()
你也可以对.data做同样的事。大部分的程序都只用编译器产生的节,但是有时候你却需要这样。比如建立一个全局共享节。
节并不是全部由连接器确定的,他们可以在编译阶段由编译器放入obj文件。连接器的工作就是合并所有obj和库中需要的节成一个最终的合适的节。比如,你的工程中的所有obj可能都有一个包含代码的.text节,连接器把这些节合并成一个.text节。同样对于.data等。这些主题超出了这篇文章的范围了。还有更多的规则关于连接器的。在obj文件中是专门给linker用的,并不放入到pe文件中,这种节是用来给连接器传递信息的。
节有两个关于对齐的字段,一个对应磁盘文件,另一个对应内存中的文件。Pe文件头指出了这两个值,他们可以不一样。每个节的偏移从对齐值的倍数开始。比如,典型的对齐值是0x200,那么每个节的的偏移必须是0x200的倍数。一旦载入内存,节的起始地址总是以页对齐。X86cpu的页大小为4k,al-64为8k。
下面是pedump倾印出的Windows XP KERNEL32.DLL.的.text .data节的信息:
Section Table
01 .text VirtSize: 00074658 VirtAddr: 00001000
raw data offs: 00000400 raw data size: 00074800
...
02 .data VirtSize: 000028CA VirtAddr: 00076000
raw data offs: 00074C00 raw data size: 00002400
建立一个节在文件中的偏移和它相对于载入地址的偏移相同的pe文件是可能的。在98/me中,这会加速大文件的载入。Visual studio 6.0 的默认选项 /opt:win98j就是这样产生文件的。在Visual studio.net中是否用/opt:nowin98取决于文件是否够小。
一个有趣的连接器特征是合并节的能力。如果两个节有相似兼容的属性,连接的时候就可以合并为一个节。这取决于是否用/merger开关。像下面就把.rdata和.text合并为一个节.text
/MERGE:.rdata=.text
合并节的优点就是对于磁盘和内存节省空间。每个节至少占用一页内存,如果你可以把可执行文件的节数从4减到3,很可能就可以少用一页内存。当然,这取决于两个节的空余空间加起来是否达到一页。
当你合并节事情会变得有意思,因为这没有什么硬性和容易的规则。比如你可以合并.rdata到.text,
但是你不可以把.rsrc.reloc.pdata合并到别的节。先前Visual Studio .NET允许把.idata合并,后来又不允许了。但是当发行的时候,连接器还是可以把.idata合并到别的节。
因为引入节的一部分在载入器载入时将被写入,你可能惊奇它是如何被放入一个只读节的。是这样的,在载入的时候系统会临时改变那些包含引入节的页为可读可写,初始化完成后,又恢复原来属性。
4. 相对虚拟地址
在可执行文件中,有很多地方需要指定内存地址,比如,引用全局变量时,需要指定它的地址。Pe文件尽管有一个首选的载入地址,但是他们可以载入到进程空间的任何地方,所以你不能依赖于pe的载入点。由于这点,必须有一个方法来指定地址而不依赖于pe载入点的地址。为了避免把内存地址硬编码进pe文件,提出了RVA。RVA是一个简单的相对于PE载入点的内存偏移。比如,PE载入点为0X400000,那么代码节中的地址0X401000的RVA为(target address) 0x401000 - (load address)0x400000 = (RVA)0x1000。把RVA加上PE的载入点的实际地址就可以把RVA转化实际地址。顺便说一下,按PE的说法,内存中的实际地址称为VA(VIRTUAL ADDRESS).不要忘了早点我说的PE的载入点就是HMODULE。
想对探索内存中的任意DLL吗?用GetModuleHanle(LPCTSTR)取得载入点,用你的PE知识来干活吧
5. 数据目录
PE文件中有很多数据结构需要快速定位。显然的例子有引入函数,引出函数,资源,重定位。这些东西是以一致的方式来定位的,这就是数据目录。
数据目录是一个结构数组,包含16个结构。每个元素有一个定义好的标识,如下:
// Export Directory#define IMAGE_DIRECTORY_ENTRY_EXPORT 0
// Import Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT 1
// Resource Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE 2
// Exception Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3
// Security Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY 4
// Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_BASERELOC 5
// Debug Directory
#define IMAGE_DIRECTORY_ENTRY_DEBUG 6
// Description String
#define IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7
// Machine Value (MIPS GP)
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8
// TLS Directory
#define IMAGE_DIRECTORY_ENTRY_TLS 9
// Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10
typedef struct _IMAGE_DATA_DIRECTORY {
ULONG VirtualAddress;
ULONG Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
6. 引入函数
当你使用别的DLL中的代码或数据,称为引入。当PE载入时,载入器的工作之一就是定位所有引入函数及数据,使那些地址对于载入的PE可见。具体细节在后面讨论,在这里只是大概讲一下。
当你用到了一个DLL中的代码或数据,你就暗中连接到这个DLL。但是你不必为“把这些地址变得对你的代码有效”做任何事情,载入器为你做这些。方法之一就是显式连接,这样你就要确定DLL已被载入,及函数的地址。调用LOADLIBARY和GETPROCADDRESS就可以了。
当你暗式连接DLL,LOADLIBARY和GETPROCADDRESS同样还是执行了的。只不过载入器为你做了这些。载入器还保证PE文件所需得任何附加的DLL都已被载入。比如,当你连接了KERNEL32.DLL,而它又引入了NTDLL.DLL的函数,又比如当你连接了GDI32.DLL,而它又依赖于USER32, ADVAPI32,NTDLL, 和 KERNEL32 DLLs的函数,载入器会保证这些DLL被载入及函数的决议。
暗式连接时,决议过程在PE文件在载入时就发生了。如果这时有什么问题(比如这个DLL文件找不到),进程终止。
VISUAL C++ 6.0 加入了DLL的延迟载入的特征。它是暗式连接和显式连接的混合。当你延迟载入DLL,连接器做出一些和引入标准规则DLL类似的东西,但是操作系统却不管这些东西,而是在第一次调用这个DLL中的函数的时候载入(如果还没载入),然后调用GetProcAddress取得函数的地址。
对于pe文件要引入的dll都有一个对应的结构数组,每个结构指出这个dll的名字及指向一个函数指针数组的指针,这个函数指针数组就是所谓的 IAT(IMORT ADDRESS TABLE)。每个输入函数,在IAT中都有一个保留槽,载入器将在那里写入真正的函数地址。最后特别重要一点的是:模块一旦载入,IAT中包含所要调用的引入函数的地址。
把所有输入函数放在IAT一个地方是很有意义的,这样无论代码中多少次调用一个引入函数,都是通过IAT中的一个函数指针。
让我们看看是怎样调用一个引入函数的。有两种情况需要考虑:有效率的和效率差的。最好的情况像下面这样:
CALL DWORD PTR [0x00405030]
直接调用[0x405030]中的函数,0x405030位于IAT部分。效率差的方式如下:
CALL 0x0040100C
...
0x0040100C:
JMP DWORD PTR [0x00405030]
这种情况,CALL把控制权转到一个子程序,子程序中的JMP指令跳转到位于IAT中的0x00405030,简单说,它多用了5字节和JMP多花的时间。
你可能惊讶引入函数就采用了这种方式,有个很好的解释,编译器无法区别引入函数的调用和普通函数调用,对于每个函数调用,编译器只产生如下指令:
CALL XXXXXXXX
XXXXXXXX是一个由连接器填入的RVA。注意,这条指令不是通过函数指针来的,而是代码中的实际地址。
为了因果的平衡,连接器必须产生一块代码来代替取代XXXXXXXX,简单的方法就是象上面所示调用一个JMP STUB.
那么JMP STUB 从那里来呢?令人惊异的是,它取自输入函数的引入库。如果你去察看一个引入库,在输入函数名字的关联处,你会发现与上面JMP STUB相似的指令。
接着,另一个问题就是如何优化这种形式,答案是你给编译器的修饰符,__declspec(import) 修饰符告诉编译器,这个函数来自另一个dll,这样编译器就会产生第一种指令。另外,编译器将给函数加上__imp_前缀然后送给连接器决议,这样可以直接把__imp_xxx送到iat,就不需要jmp stub了。
对于我们这有什么意义呢,如果你在写一个引出函数的东西并提供一个头文件的话,别忘了在函数前加上修饰符__declspec(import)
__declspec(dllimport) void Foo(void);
在winnt.h等系统头文件中就是这样做的。
7. PE 文件结构
现在让我们开始研究PE文件格式,我将从文件的头部开始,描述每个PE文件中都有的各种数据结构,然后,我将讨论更多的专门的数据结构比如引入表和资源,除非特殊说明,这些结构都定义在WINNT.H中。
一般地,这些结构都有32和64位之分,如IMAGE_NT_HEADERS32 ,IMAGE_NT_HEADER64等,他们基本上是一样的,除了64位的扩展了某些字段。通过#DEFINE WINNT.H都屏蔽了这些区别,选择那个数据结构取决于你要如何编译了(如,是否定义_WIN64)
The MS-DOS Header
每个PE文件是以一个DOS程序开始的,这让人想起WINDOWS在没有如此可观的使用者的早期年代。当可执行文件在非WINDOWS平台上运行的时候至少可以显示出一条信息表示它需要WINDOWS。
PE文件的开头是一个IMAGE_DOS_HEADER结构,结构中只有两个重要的字段e_magic and e_lfanew。e_lfanew指出pe file header的偏移,e_magic需要设定位0x5a4d,被#define 成IMAGE_DOS_SIGNATURE 它的ascii为’MZ’,Mark Zbikowski的首字母,DOS 的原始构建者之一。
The IMAGE_NT_HEADERS Header
这个结构是PE文件的主要定位信息的所在。它的偏移由IMAGE_DOS_HEADER的e_lfanew给出
确实有64和32位之分,但我在讨论中将不作考虑,他们几乎没有区别。
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
在一个有效的pe文件里,Signture被设为0x00004500,ascii 为’PE00’,#define IMAGE_NT_SIGNTURE 0X00004500;第二个字段是一个IMAGE_FILE_HEADER结构,它包含文件的基本信息,特别重要的是它指出了IMAGE_OPTIONAL_HEADER的大小(重要吗?);在PE文件中,IMAGE_OPTIONAL_HEADER是非常重要的,但是仍称作IMAGE_OPTIONAL_HEADER。
IMAGE_OPTIONAL_HEADER结构的末尾就是用来定位pe文件中重要信息的地址簿-数据目录,它的定义如下:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; // RVA of the data
DWORD Size; // Size of the data
};
The Section Table
紧接着IMAGE_NT_HEADERS后的就是节表,节表就是IMAGE_SECTION_HEADER的数组。IMAGE_SECTION_HEADER包含了它所关联的节的信息,如位置,长度,特征;该数组的数目由IMAGE_NT_HEADERS.FileHeader.NumberOfSections指出。具体见下图
PE中的节的大小的总和最后是要对齐的,Visual Studio 6.0中的默认值是4k,除非你使用/OPT:NOWIN98 或/ALIGN开关;在.NET中,依然用了默认的/OPT:WIN98,但是如果文件小于一特定大小时,就会采用0X200为对齐值。
.NET文档中有关于对齐的另一件有趣的事。.NET文件的内存对齐值为8K而不是普通X86平台上的4K,这样就保证了在X86平台编译的程序可以在IA-64平台上运行。如果内存对齐值为4K,那么IA-64的载入器就不能载入这个程序,因为它的页为8K。