PE文件格式(四)

JMP DWORD PTR [XXXXXXXX]
指令处。这个 JMP 指令(译注1)通过一个在 .idata 中的DWORD变量间接的转移控制。 .idata 块的DWORD包含操作系统函数入口的实际地址。在对这进行一会儿回想之后,我开始理解为什么DLL调用用这种方式来实现。通过一个位置传送所有的对一个给定的DLL函数的调用,载入器不需要改变每个调用DLL的指令。所有的PE载入器必须做的是把目标函数的正确地址放到 .idata 的一个 DWORD 中。不需要改变任何call指令。在NE文件中就不同了,每个段都包含一个需要应用到这个段上的一个修正表。如果这个段把一个给定的DLL函数调用了20次,载入器必须把这个函数的地址写入到这个段的每个调用指令中。PE方法的缺点是你不能用一个DLL函数的真实地址来初始化一个变量。比如,你要考虑这样的情况:
  FARPROC pfnGetMessage = GetMessage;
将把GetMessage的地址存到变量 pfnGetMessage 中。在16位Windows中,这可以工作,但在Win32中不能。在Win32中,变量pfnGetMessage最终存储的是我前面提到的JMP DWORD PTR [XXXXXXXX] 替换指示(译注2)。如果你想通过函数指针调用一个函数,事情也会如你所预料的一样。但是,如果你想读取 GetMessage 开始的字节,你将不能如愿(除非你自己做跟在 .idata 指针后的工作)。后面我将会返回到这个话题上--在导入表的讨论中。
译注1:英文 thunk,正统的计算机专业术语为"形实转换程序",类似宏(macro)替换,故我将它译为"替换指示",指在具体指令中xxxxxxxx 被替换,后面出现的替换指示同。
译注2:现在的编译器如VC6以上等等,产生的导入函数调用代码不再是先来一个相对Call指令到 jmp [xxxx] 处,然后再到 xxxx 处(真正的导入函数入口),而是用了一种效率更高,也更容易让人理解的方式:call [xxxx] 。以前用那种间接的方式多是为兼容编译器。但是现在仍有一些编译器,如MASM,直到版本7.0,还是用前面那种间接的方式,从这里也可以看出微软对ASM的态度了。
虽然 Borland 可以让编译器输出的代码块名为 .text ,但它是选择 NAME 作为默认的段名。为了确定PE文件中的块名,Borland 的连接器(TLINK32.EXE)从OBJ文件中取出段名并把它截断为8字符(如果有必要)。
当块名的不同只是一个小问题时,Borland  PE 文件怎样链接到其它模块就是一个重要的不同。就像我在 .text 的描述中提到的,所有到OBJ的调用通过一个JMP DWORD PTR [XXXXXXXX]替换指示。在微软系统下,这条指令通过一个导入库到达 .text 块。因为库管理器(LIB32)当你链接外部DLL时才创建导入库(和这个替换指示),连接器自己不需要"知道"怎样生成这这个替换指示。导入库实际上只不过是链接到这个PE文件的一些更多的代码和数据。
Borland 处理导入函数的系统只是一个简单的16位NE文件方式扩展。Borland 连接器使用的导入库实际上只不过是一个函数名连同它所在的DLL名的列表。于是TLINK32就有责任确定外部DLL的修正,并生为它成一个适当的JMP DWORD PTR [XXXXXXXX] 替换指示 。TLINK32把这个替换指示存储在它创建的名为 .icode 块中。正像 .text 是默认的代码块,.data 块是已初始化数据的归宿。这些数据包含编译时初始化的全局和静态局部变量。它还包括文字字符串。连接器把从OBJ/LIB文件得来的所有 .data 块组合到EXE文件的一个 .data 块中。局部变量载入到一个线程的堆栈中,在 .data 或 .bss 中不占空间。
.bss 块是存储未初始化的全局和静态局部变量的地方。连接器把 OBJ/LIB 文件中的所有 .bss 块链接到EXE文件的一个 .bss 块中。在块表中,.bss 块的RawDataOffset 域置为0 ,表示这个块在文件中不占用任何空间。TLINK 不产生这个块。代替的,它扩展 DATA 块的虚拟尺寸(virtual size)。
.CRT 块是微软 C/C++ 运行时库利用的另一个已初始化数据的块(从名字)。我不能理解为什么这些数据不放在 .data 中。(译注)译注:从CRT的字面意思看,应该是"C Run Time",即C运行时库。
.rsrc 块这个模块的所有资源。在Windows NT的早期,16位RC.EXE输出的RES文件是微软的PE连接器不能识别的格式。CVTRES 程序把这种格式的RES文件转换成COFF格式的OBJ文件,把资源数据放在 OBJ 的 .rsrc 块中。连接器就可以把这个资源OBJ当作另一个OBJ来链接了,允许连接器"知道"关于资源的特殊东西。微软最近发布的更多连接器可以直接处理RES文件。
.idata 块包含关于这个模块从其它DLL导入的函数(和数据)的信息(译注)。这个块和NE文件的模块引用表是等价的。一个关键的不同是PE文件导入的每个函数都明确的列在这个块中。为找到NE文件中的等价信息,你必须去挖掘这个段生鲜数据的结尾的重定位信息。
译注:现在许多编译器产生的EXE文件都没有这个块,然而ImportTable并不是没有了,代替的,ImportTable仅由DataDirectory[1]指示,一般指向.text块或.data块中。
.edata 块是这个PE文件导出到其它模块的函数和数据的列表。它的NE文件等价物是条目表的联合,驻留名表,和非驻留名表,和16位Windows不一样,很少有理由从一个EXE文件导出一些东西,所以你通常只在DLL中看到 .edata 块。当使用微软的工具时,.edata 块中的数据通过EXP文件来到PE文件中。换种方法,连接器不为它自己生成这个信息。代替的,它依赖库管理器(LIB32)来扫描OBJ文件,并创建EXP文件,连接器要把它要链接的模块的列表加入其中。是的,好!这些麻烦的EXP文件实际上只是扩展名不同的OBJ文件而已。
.reloc 块保持一个基本重定位表。基本重定位是一个对一条指令或已初始化的变量值的调整,如果载入器不能把这个文件载入到连接器假定的位置,这就是很重要的了。如果载入器能把这个映像载入到连接器建议(prefer)的基地址,载入器就完全忽略这个块的重定位信息。如果你愿意冒险,并且希望载入器可以始终把这个映像载入到假定的基址,你可以通过 /FIXED 选项告诉链接器去除这个信息。这样可以在可执行文件中节省空间,但会导致这个可执行文件在其它的Win32实现中不能工作。比如,假定你为Windows NT建立了一个EXE文件,并且把基址设为 0x10000 。如果你让连接器去除重定位信息,这个EXE文件在Windows95下将不能运行,因为在这里地址0x10000已被系统使用了。
注意编译器生成的JMP和CALL指令是很重要的,首选它使用相对偏移量的版本,而非32位平坦段中的真实偏移量版本。如果映像需要被载入非连接器假定的基址处,这些指令不需要改变,因为它使用的是相对寻址。结果就是,并不需要你想象的那么多的重定位。重定位通常只需要使用指向一些数据的32位偏移。举个例子,让我们看一下,你有如下的全局变量声明:
 int i;
 int *ptr = &i; 
如果连接器假定一个0x10000的映像基址,变量i的地址将最终是一个特定值如0x12004 。在用来存放指针"ptr"的内存中,连接器将写进0x12004 ,因为这是变量 i 的地址。如果载入器由于某种原因决定把这个文件载入基址0x70000处,变量i的地址将是0x72004 。.reloc 块是映像中的一些内存位置的列表,这些内存位置在连接时连接器假定的载入地址和实际需要的载入地址是不同的,这个因素需要考虑。
当你使用编译器指令 __declspec(thread) 时,你定义的数据不在 .data 和 .bss 块种。它最终在 .tls 块中,这个块指示"线程局部存储",并且和Win32的TlsAlloc函数族相联系。处理 .tls 块时,内存管理器设置页表以便进程在任何时刻切换线程时,都有一个新的物理内存页集映射到 .tls 块的地址空间。这就允许线程内的全局变量。在大部分情况下,利用这种机制,比基于线程分配内存并把其指针存在一个 "TlsAlloc 过的"(注:原文TlsAlloc'ed)槽(注:原文Slot)中要容易的多。
不幸的是,有一点需要注意--必须深入研究.tls 块和 __declspec(thread) 的变量。在WindowsNT 和Windows95 中,如果DLL是被载入库动态载入的,这种线程局部存储机制将不能在这个DLL中工作。然而在EXE中或一个隐含载入的DLL中,一切都工作正常。如果你不隐含链接到这个DLL ,但需要按线程的数据,你必须会到过去并使用 TlsAlloc 和 TlsGetValue 这种原始方式来设置线程动态内存分配。虽然 .rdata 块通常在 .data 和 .bss 块之间,你的程序一般看不见并使用这些块中的数据。.rdata 块至少在两种东西中使用。第一,在微软连接器生成的EXE中,.rdata 块存放调试目录,这只在EXE文件中出现。(在 TLINK32 的 EXE 中,调试目录在名为 ".DEBUG"的块中)。调试目录是一个IMAGE_DEBUG_DIRECTORY结构数组。这些结构保持存储在文件中的变量的类型,尺寸,和位置的调试信息。三种主要的调试信息类型显示如下:CodeView?, COFF,和 FPO,表9显示了PEDUMP输出的一个典型的调试目录。
表 7   一个典型的调试目录
Type Size Address FilePtr Charactr TimeDate Version
COFF 000065C5 00000000 00009200 00000000 2CF8CF3D 0.00
??? 00000114 00000000 0000F7C8 00000000 2CF8CF3D 0.00
FPO 000004B0 00000000 0000F8DC 00000000 2CF8CF3D 0.00
CODEVIEW 0000B0B4 00000000 0000FD8C 00000000 2CF8CF3D 0.00

 

调试目录不必在 .rdata 块的开始找到。为找到调试目录表的开始,使用数据目录的第七个条目(IMAGE_DIRECTORY_ENTRY_DEBUG)的RVA。数据目录在文件的PE首部结尾部分。为确定微软连接器生成的调试目录的条目数,用调试目录的尺寸(在数据目录条目的尺寸域)除以一个IMAGE_DEBUG_DIRECTORY结构的尺寸即可。TLINK32产生一个简单的数目,通常是1 。PEDUMP示例程序描述了这一点。
.rdata 域的另一个有用的部分是"描述串"。如果你在程序的DEF文件中指定一个DESCRIPTION条目,这个指定的描述串就出现在 .rdata 块中。在NE格式中,描述串总是非驻留名表的第一个条目。描述串是用来保持一个描述这个文件的有用的文本串的。不幸的是,我还没找到一条便捷的途径来得到它。我看到有些描述串在PE文件的调试目录之前,在另一些文件中它在调试目录之后。我找不到得到这个描述串的一致的方法(或甚至这种方法根本就不存在)。
.debug$S 和 .debug$T 块只出现在 OBJ 中。他们保存 CodeView 调试符号和类型信息。这些块名是从以前16位编译器($$SYMBOLS 和 $$TYPE)使用的段名继承来的。.debug$T 块的唯一用途是保持包含工程中所有OBJ的CodeView信息的PDB文件的路径。连接器从PDB中读取并且使用它来创建CodeView信息的组成部分,这些CodeView信息放置在PE文件的结尾。
.drectve 块只出现在OBJ文件中。它包含用文本表示的连接器命令。比如,在我用微软编译器编译的任一OBJ中,下面的字符串都出现在 .drectve 块中:
 -defaultlib:LIBC -defaultlib:OLDNAMES
当你在程序中用 __declspec(export) 时,编译器简单的把等价的命令行输出到 .drectve 块中(例如:"-exprot:MyFunction")。
在玩弄 PEDUMP 的过程中,我不时的遇到其它块。例如,在Window95的KERNEL32.DLL中,有LOCKCODE和LOCKDATA块。大概这是一种特殊的页处理方法,是为了避免缺页(译注)。
译注:缺页,在页式内存管理中,一条指令访问的虚拟内存未映射到物理内存中,此时将发生缺页中断,关于缺页中断,请参阅操作系统相关书籍。
从这里学到两个教训。第一:不要以为有约束而只使用编译器或汇编器提供的标准块。如果由于某种原因你需要一个分开的块,不要犹豫,自己去创建!在C/C++编译器中,使用 #pragma code_seg 和 #pragma data_seg 。在汇编语言中,只不过是创建一个名字和和标准块不同的32位的段(将成为一个块)。如果使用TLINK32 ,你必须使用一个不同的类,或者关掉代码段包装(packing)。其它要记住的东西是使用非标准块名你将会更透彻的理解特殊PE文件的意图和实现。

 

5 PE文件的导入表
前面,我描述了函数调用怎样到一个外部DLL中而不直接调用这个DLL 。代替的,在执行体中的 .text 块中(如果你用Borland C++ 就是 .icode 块),CALL指令到达一条JMP DWORD PTR [XXXXXXXX]
指令处。JMP指令寻找的地址把控制转移到实际的目标地址。PE文件的 .idata 会包含一些必要的信息,这些信息是载入器用来确定目标函数的地址以及在执行体映像中去修正他们的。
.idata 块(或称导入表,我更喜欢这样叫)开始于一个IMAGE_IMPORT_DESCRIPTOR数组。每个DLL都有一个PE文件隐含链接上的IMAGE_IMPORT_DESCRIPTOR。没有指定这个数组中结构的数目的域。代替的,这个数组的最后一个元素是一个全NULL的IMAGE_IMPORT_DESCRIPTOR 。IMAGE_IMPORT_DESCRIPTOR的格式显示在表8 。
表 8  IMAGE_IMPORT_DESCRIPTOR Format
DWORD Characteristics
在一个时刻,这可能已是一个标志集。然而,微软改变了它的涵义并不再糊涂地升级WINNT.H 。这个月实际上是一个指向指针数组的偏移(RVA)。其中每个指针都指向一个IMAGE_IMPORT_BY_NAME结构。

 

DWORD TimeDateStamp
指示这个文件的创建时间。

 

DWORD ForwarderChain
这个域联系到前向链。前向链包括一个DLL函数向另一个DLL转送引用。比如,在WindowsNT中,NTDLL.DLL就出现了的一些前向的它向KERNEL32.DLL导出的函数。应用程序可能以为它调用的是NTDLL.DLL中的函数,但它最终调用的是KERNEL32.DLL中的函数。这个域还包含一个FirstThunk数组的索引(即刻描述)。用这个域索引得函数会前向引用到另一个DLL 。不幸的是,函数怎样前向引用的格式没有文档,并且前向函数的例子也很难找。

 

DWORD Name
这是导入DLL的名字,指向以NULL结尾的ASCII字符串。通用例子是KERNEL32.DLL和USER32.DLL 。

 

PIMAGE_THUNK_DATA FirstThunk
这个域是指向IMAGE_THUNK_DATA联合的偏移(RVA)。几乎在任何情况下,这个域都解释为一个指向的IMAGE_IMPORT_BY_NAME结构的指针。如果这个域不是这些指针中的一个,那它就被当作一个将从这个被导入的DLL的导出序数值。如果你实际上可以从序数导入一个函数而不是从名字导入,从文档看,这是不清楚的。
IMAGE_IMPORT_DESCRIPTOR 的一个重要部分是导入的DLL的名自和两个IMAGE_IMPORT_BY_NAME指针数组。在EXE文件中,这两个数组(由Characteristics域和FirstThunk域指向)是相互平行的,都是以NULL指针作为数组的最后一个元素。两个数组中的指针都指向 IMAGE_IMPORT_BY_NAME 结构。表3以图形显示了这种布局。表12显示了PEDUMP对一个导入表的输出。

 

 
图 3. 两个平行的指针数组
表 9. 一个EXE文件的导入表
GDI32.dll
  Hint/Name Table: 00013064
  TimeDateStamp:   2C51B75B
  ForwarderChain:  FFFFFFFF
  First thunk RVA: 00013214
  Ordn  Name
    48  CreatePen
    57  CreateSolidBrush
    62  DeleteObject
   160  GetDeviceCaps
    //  Rest of table omitted...

 

  KERNEL32.dll
  Hint/Name Table: 0001309C
  TimeDateStamp:   2C4865A0
  ForwarderChain:  00000014
  First thunk RVA: 0001324C
  Ordn  Name
    83  ExitProcess
   137  GetCommandLineA
   179  GetEnvironmentStrings
   202  GetModuleHandleA
    //  Rest of table omitted...

 

  SHELL32.dll
  Hint/Name Table: 00013138
  TimeDateStamp:   2C41A383
  ForwarderChain:  FFFFFFFF
  First thunk RVA: 000132E8
Ordn  Name
    46  ShellAboutA

 

  USER32.dll
  Hint/Name Table: 00013140
  TimeDateStamp:   2C474EDF
  ForwarderChain:  FFFFFFFF
  First thunk RVA: 000132F0
  Ordn  Name
    10  BeginPaint
    35  CharUpperA
    39  CheckDlgButton
    40  CheckMenuItem
 
    //  Rest of table omitted...
PE文件的导入表的每一个函数有一个 IMAGE_IMPORT_BY_NAME 结构。IMAGE_IMPORT_BY_NAME结构非常简单,看上去是这样:
 WORD    Hint;
 BYTE    Name[?];
第一个域是导入函数的导出序数的最佳猜测。和NE文件不同,这个值不是必须正确的。于是,载入器指示把它当作一个进行二分查找的建议开始值。下一个是导入函数的名字的ASCIIZ字符串。
为什么有两个平行的指针数组指向结构IMAGE_IMPORT_BY_NAME ?第一个数组(由Characteristics域指向的)单独的留下来,并不被修改。经常被称作提名表。第二个数组(由FirstThunk域指向的)将被PE载入器覆盖。载入器在这个数组中迭代每个指针,并查找每个IMAGE_IMPORT_BY_NAME结构指向的函数的地址。载入器然后用找到的函数地址覆盖这个指向IMAGE_IMPORT_BY_NAME结构的指针。JMP DWORD PTR [XXXXXXXX] 替换指示中的 [XXXXXXXX] 表示 FirstThunk 数组的一个条目。因为由载入器覆盖的这个指针数组实际上保持所有导入函数的地址,叫做"导入地址表"。
对Borland用户,上面的描述有点别扭。由TLINK32产生的PE文件缺少其中一个数组。在这样一个执行体中,IMAGE_IMPORT_DESCRIPTOR(提名数组)中Characteristics域的是0 。于是,仅有的由FirstThunk域(导入地址表)指向的数组在PE文件中就是必须的了。故事到这里应该结束了,除非在我写PEDUMP时深入一个有趣的问题中。在优化上无止境的探索,微软在WindowsNT中"优化"了系统DLL(KERNEL32.DLL等等)的thunk数组。在这个优化中,这个数组中的指针不再指向IMAGE_IMPORT_BY_NAME结构,它们已经包含了导入函数的地址。换句话说,载入器不需要去查找函数的地址并用导入函数的地址覆盖thunk数组(译注)。对希望这个数组包含指向IMAGE_IMPORT_BY_NAME结构的指针的PEDump程序,这导致了一个问题。你可能正在思考,"但是,Matt ,为什么呢不顺便使用提名表数组?"这可能是一个完美的解决方案,除非提名表数组在Borland文件中不存在。PEDUMP处理所有这些情况,但是代码理所当然的就有些杂乱。
译注: 这就是 Bound Import,关于Bound Import,请参阅:
Matt Pietrek "Inside Windows An In-Depth Look into the Win32 Portable Executable File Format, Part 2 " From MSDN Magazine March 2002 on Internet
URL :http://msdn.microsoft.com/msdnmag/issues/02/03/PE2/PE2.asp
因为导入地址表在一个可写的块中,拦截一个EXE或DLL对另一个DLL的调用就相对容易。只需要修改适当地导入地址条目去指向希望拦截的函数。不需要修改调用者或被调者的任何代码。
注意微软产生的PE文件的导入表并不是完全被连接器同步的,这一点很有趣。所有对另一个DLL中的函数的调用的指令都在一个导入库中。当你连接一个DLL时,库管理器(LIB32.EXE或LIB.EXE)扫描将要被连接的OBJ文件并且创建一个导入库。这个导入库完全不同于16位NE文件连接器使用的导入库。32位库管理器产生的导入库有一个.text块和几个.idata$块。导入库中的.text块包含 JMP [XXXX] 的替换指示,这个替换指示在OBJ文件的符号表中有一个名字来存储它。这个符号名对将从DLL中导出的所有函数名都是唯一的(例如:_Dispatch_Message@4)。导入库中的一个.idata$块包含一个从其中引用的替换指示(译注:即JMP [XXXX]中的XXXX)。另一个.idata$块有一个导入函数名之前的提示序号(hint ordinal)的空间。这两个域就组成了IMAGE_IMPORT_BY_NAME结构。当你晚连接一个使用导入库的PE文件时,导入库的块被加到连接器需要处理的在OBJ文件中的你的块的列表中。一旦导入库中的这个替换指示的名字和和要导入的函数名相同,连接器就假定这个替换指示就是这个导入函数,并修正对这个导入函数,使其指向这个替换指示。导入库中的这个替换指示在本质上就被当作这个导入函数本身了。
除了提供一个导入函数替换指示的代码部分,导入库还提供PE文件的.idata块(或称导入表)的片断。这些片断来自于库管理器放入导入库中的不同的.idata$块。简而言之,连接器实际上不知道出现在不同的OBJ文件中的导入函数和普通函数之间的不同。连接器只是按照它的边框调整规则去建立并结合块,于是,所有的事情就自然顺理成章了。
6 术语
生鲜数据:原文"RawData",意指未加工过的数据,即原原本本从磁盘上读入而未经过任何改动的数据。
替换指示:原文"thunk",本质上是一条指令,这条指令中有浮动的地址域。如文中的 jmp [xxxx],其中xxxx是一个浮动地址(floating address),或称可重定位地址(relocatable address)。
OBJ文件:Object文件,即编译器编译产生的目标文件,这种文件只有在(和LIB)连接之后,才能形成可执行文件。
LIB文件:库文件,这种文件中包含一些二进制的代码(数据)及其符号,一般情况下,用到LIB中的哪个符号,连接器连接时,关于那个符号的二进制代码(数据)才会放入最终的执行体中。
RES文件:Widows资源文件,由RC.EXE编译。
EXE文件:不用多说Windows下的可执行文件,这类文件一般有导入表(Import Table)。有少数这类文件有导出表(Export Table)。
DLL文件:Dinamic Link Library ,即动态连接库,用来向其它执行体导出函数(或数据等)。
 

你可能感兴趣的:(PE文件格式(四))