• QQ
  • nahooten@sina.com
  • 常州市九洲新世界花苑15-2

黑客安全

PE文件结构详解(二)导入表,延迟导入表,重定

原创内容,转载请注明原文网址:http://homeqin.cn/a/wenzhangboke/jishutiandi/heikeanquan/2018/1101/152.html

PE文件结构详解(二)可执行文件头的最后展示了一个数组,PE文件结构详解(三)PE导出表中解释了其中第一项的格式,本篇文章来揭示这个数组中的第二项:IMAGE_DIRECTORY_ENTRY_IMPORT,即导入表。

也许大家注意到过,在IMAGE_DATA_DIRECTORY中,有几项的名字都和导入表有关系,其中包括:IMAGE_DIRECTORY_ENTRY_IMPORT,IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT,IMAGE_DIRECTORY_ENTRY_IAT和IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT这几个导入都是用来干什么的,他们之间又是什么关系呢?听我慢慢道来。

 

  • IMAGE_DIRECTORY_ENTRY_IMPORT就是我们通常所知道的导入表,在PE文件加载时,会根据这个表里的内容加载依赖的DLL,并填充所需函数的地址。
  • IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT叫做绑定导入表,在第一种导入表导入地址的修正是在PE加载时完成,如果一个PE文件导入的DLL或者函数多那么加载起来就会略显的慢一些,所以出现了绑定导入,在加载以前就修正了导入表,这样就会快一些。
  • IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT叫做延迟导入表,一个PE文件也许提供了很多功能,也导入了很多其他DLL,但是并非每次加载都会用到它提供的所有功能,也不一定会用到它需要导入的所有DLL,因此延迟导入就出现了,只有在一个PE文件真正用到需要的DLL,这个DLL才会被加载,甚至于只有真正使用某个导入函数,这个函数地址才会被修正。
  • IMAGE_DIRECTORY_ENTRY_IAT是导入地址表,前面的三个表其实是导入函数的描述,真正的函数地址是被填充在导入地址表中的。

举个实际的例子,看一下下面这张图:

 

 

这个代码调用了一个RegOpenKeyW的导入函数,我们看到其opcode是FF 15 00 00 19 30气质FF 15表示这是一个间接调用,即call dword ptr [30190000] ;这表示要调用的地址存放在30190000这个地址中,而30190000这个地址在导入地址表的范围内,当模块加载时,PE 加载器会根据导入表中描述的信息修正30190000这个内存中的内容。

那么导入表里到底记录了那些信息,如何根据这些信息修正IAT呢?我们一起来看一下导入表的定义:

 



  • typedef struct _IMAGE_IMPORT_DESCRIPTOR {
  •     union {
  •         DWORD   Characteristics;            // 0 for terminating null import descriptor
  •         DWORD   OriginalFirstThunk;         // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
  •     } DUMMYUNIONNAME;
  •     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;
  • typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

使用RtlImageDirectoryEntryToData并将索引号传1,会得到一个如上结构的指针,实际上指向一个上述结构的数组,每个导入的DLL都会成为数组中的一项,也就是说,一个这样的结构对应一个导入的DLL。

 

Characteristics和OriginalFirstThunk:一个联合体,如果是数组的最后一项Characteristics为0,否则OriginalFirstThunk保存一个RVA,指向一个IMAGE_THUNK_DATA的数组,这个数组中的每一项表示一个导入函数。

TimeDateStamp:映象绑定前,这个值是0,绑定后是导入模块的时间戳。

ForwarderChain:转发链,如果没有转发器,这个值是-1。

Name:一个RVA,指向导入模块的名字,所以一个IMAGE_IMPORT_DESCRIPTOR描述一个导入的DLL。

FirstThunk:也是一个RVA,也指向一个IMAGE_THUNK_DATA数组。
既然OriginalFirstThunk与FirstThunk都指向一个IMAGE_THUNK_DATA数组,而且这两个域的名字都长得很像,他俩有什么区别呢?为了解答这个问题,先来认识一下IMAGE_THUNK_DATA结构:

 

  • typedef struct _IMAGE_THUNK_DATA32 {
  •     union {
  •         DWORD ForwarderString;      // PBYTE 
  •         DWORD Function;             // PDWORD
  •         DWORD Ordinal;
  •         DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
  •     } u1;
  • } IMAGE_THUNK_DATA32;
  • typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;

ForwarderString是转发用的,暂时不用考虑,Function表示函数地址,如果是按序号导入Ordinal就有用了,若是按名字导入AddressOfData便指向名字信息。可以看出这个结构体就是一个大的union,大家都知道union虽包含多个域但是在不同时刻代表不同的意义那到底应该是名字还是序号,该如何区分呢?可以通过Ordinal判断,如果Ordinal的最高位是1,就是按序号导入的,这时候,低16位就是导入序号,如果最高位是0,则AddressOfData是一个RVA,指向一个IMAGE_IMPORT_BY_NAME结构,用来保存名字信息,由于Ordinal和AddressOfData实际上是同一个内存空间,所以AddressOfData其实只有低31位可以表示RVA,但是一个PE文件不可能超过2G,所以最高位永远为0,这样设计很合理的利用了空间。实际编写代码的时候微软提供两个宏定义处理序号导入:IMAGE_SNAP_BY_ORDINAL判断是否按序号导入,IMAGE_ORDINAL用来获取导入序号。

 

这时我们可以回头看看OriginalFirstThunk与FirstThunk,OriginalFirstThunk指向的IMAGE_THUNK_DATA数组包含导入信息,在这个数组中只有Ordinal和AddressOfData是有用的,因此可以通过OriginalFirstThunk查找到函数的地址。FirstThunk则略有不同,在PE文件加载以前或者说在导入表未处理以前,他所指向的数组与OriginalFirstThunk中的数组虽不是同一个,但是内容却是相同的,都包含了导入信息,而在加载之后,FirstThunk中的Function开始生效,他指向实际的函数地址,因为FirstThunk实际上指向IAT中的一个位置,IAT就充当了IMAGE_THUNK_DATA数组,加载完成后,这些IAT项就变成了实际的函数地址,即Function的意义。还是上个图对比一下:

 

上图是加载前。


 

上图是加载后。

 

最后总结一下:

 

  • 导入表其实是一个IMAGE_IMPORT_DESCRIPTOR的数组,每个导入的DLL对应一个IMAGE_IMPORT_DESCRIPTOR。
  • IMAGE_IMPORT_DESCRIPTOR包含两个IMAGE_THUNK_DATA数组,数组中的每一项对应一个导入函数。
  • 加载前OriginalFirstThunk与FirstThunk的数组都指向名字信息,加载后FirstThunk数组指向实际的函数地址。

PE文件结构详解(四)PE导入表讲了一般的PE导入表,这次我们来看一下另外一种导入表:延迟导入(Delay Import)。看名字就知道,这种导入机制导入其他DLL的时机比较“迟”,为什么要迟呢?因为有些导入函数可能使用的频率比较低,或者在某些特定的场合才会用到,而有些函数可能要在程序运行一段时间后才会用到,这些函数可以等到他实际使用的时候再去加载对应的DLL,而没必要再程序一装载就初始化好。

这个机制听起来很诱人,因为他可以加快启动速度,我们应该如何利用这项机制呢?VC有一个选项,可以让我们很方便的使用到这项特性,如下图所示:

 

 

在这一项后面填写需要延迟导入的DLL名称,连接器就会自动帮我们将这些DLL的导入变为延迟导入。

现在我们知道如何使用延迟导入了,那这个看上去很厉害的机制是如何实现的呢?接下来我们来探索一番。在IMAGE_DATA_DIRECTORY中,有一项为IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT,这一项便延迟导入表,IMAGE_DATA_DIRECTORY.VirtualAddress就指向延迟导入表的起始地址。既然是表,肯定又是一个数组,每一项都是一个ImgDelayDescr结构体,和导入表一样,每一项都代表一个导入的DLL,来看看定义:

 



  • typedef struct ImgDelayDescr {


  •     DWORD           grAttrs;        // attributes


  •     RVA             rvaDLLName;     // RVA to dll name


  •     RVA             rvaHmod;        // RVA of module handle


  •     RVA             rvaIAT;         // RVA of the IAT


  •     RVA             rvaINT;         // RVA of the INT


  •     RVA             rvaBoundIAT;    // RVA of the optional bound IAT


  •     RVA             rvaUnloadIAT;   // RVA of optional copy of original IAT


  •     DWORD           dwTimeStamp;    // 0 if not bound,


  •                                     // O.W. date/time stamp of DLL bound to (Old BIND)


  • } ImgDelayDescr, * PImgDelayDescr;


  • typedef const ImgDelayDescr *   PCImgDelayDescr;

grAttrs:用来区分版本,1是新版本,0是旧版本,旧版本中后续的rvaxxxxxx域使用的都是指针,而新版本中都用RVA,我们只讨论新版本。

 

rvaDLLName:一个RVA,指向导入DLL的名字。

rvaHmod:一个RVA,指向导入DLL的模块基地址,这个基地址在DLL真正被导入前是NULL,导入后才是实际的基地址。

rvaIAT:一个RVA,表示导入函数表,实际上指向IAT,在DLL加载前,IAT里存放的是一小段代码的地址,加载后才是真正的导入函数地址。

rvaINT:一个RVA,指向导入函数的名字表。

rvaUnloadIAT:延迟导入函数卸载表。

dwTimeStamp:延迟导入DLL的时间戳。

定义知道了,那他是怎么被处理的呢?前面提到了,在延迟导入函数指向的IAT里,默认保存的是一段代码的地址,当程序第一次调用到这个延迟导入函数时,流程会走到那段代码,这段代码用来干什么呢?请看一个真实的延迟导入函数的例子:

 



  • .text:75C7A363 __imp_load__InternetConnectA@32:        ; InternetConnectA(x,x,x,x,x,x,x,x)


  • .text:75C7A363                 mov     eax, offset __imp__InternetConnectA@32


  • .text:75C7A368                 jmp     __tailMerge_WININET

 

这段代码其实只有两行汇编,第一行把导入函数IAT项的地址放到eax中,然后用一个jmp跳转走,那么他跳转到哪里了呢?我们继续跟踪:

 

 



  • __tailMerge_WININET proc near          


  • .text:75C6BEF0                 push    ecx


  • .text:75C6BEF1                 push    edx


  • .text:75C6BEF2                 push    eax


  • .text:75C6BEF3                 push    offset __DELAY_IMPORT_DESCRIPTOR_WININET


  • .text:75C6BEF8                 call    __delayLoadHelper


  • .text:75C6BEFD                 pop     edx


  • .text:75C6BEFE                 pop     ecx


  • .text:75C6BEFF                 jmp     eax


  • .text:75C6BEFF __tailMerge_WININET endp

 

其中最重要的是push了一个__DELAY_IMPORT_DESCRIPTOR_WININET,这个就是上文中看到的ImgDelayDescr结构,他的DLL名字是wininet.dll。之后,CALL了一个__delayLoadHelper,在这个函数里,执行了加载DLL,查找导出函数,填充导入表等一系列操作,函数结束时IAT中已经是真正的导入函数的地址,这个函数同时返回了导入函数的地址,因此之后的eax里保存的就是函数地址,最后的jmp eax就跳转到了真实的导入函数中。

这个过程很完美,也很灵巧,但是如果仔细观察就会发现什么地方有点不对劲,你发现了吗?__delayLoadHelper的参数中只有IAT项的偏移和整个模块的延迟导入描述__DELAY_IMPORT_DESCRIPTOR_WININET,但是参数中并没有要导入函数的名字。也许你说,名字在__DELAY_IMPORT_DESCRIPTOR_WININET的名字表中,是的,那里确实有名字,但是别忘了,那是个表,里面存的是所有要从该模块导入的函数名字,而不是“当前”这个被调用函数的函数名。或许你觉得参数中应该有个索引号,用来表示名字列表中的第几项是即将被导入的那个函数的名字,不幸的是我们也没有看到参数中有这样的信息存在,那Windows执行到这里是如何得到名字的呢?MS在这里使用了一个巧妙的办法:__DELAY_IMPORT_DESCRIPTOR_WININET中有一项是rvaIAT,前面提到了,这里实际上就是指向了IAT,而且是该模块第一个导入函数的IAT的偏移,现在我们有两个偏移,即将导入的函数IAT项的偏移(记作RVA1)和要导入模块第一个函数IAT项的偏移(记作RVA0),(RVA1-RVA0)/4 = 导入函数IAT项在rvaIAT中的下标,rvaINT中的名字顺序与rvaIAT中的顺序是相同的,所以下标也相同,这样就能获取到导入函数的名字了。有了模块名和函数名,用GetProcAddress就可以获取到导入函数的地址了。

上述流程用一张图来总结一下:


 

最后还有两点要提醒大家:

延迟导入的加载只发生在函数第一次被调用的时候,之后IAT就填充为正确函数地址,不会再走__delayLoadHelper了。

延迟导入一次只会导入一个函数,而不是一次导入整个模块的所有函数。

 

PE文件结构详解(六)重定位

 

前面两篇 PE文件结构详解(四)PE导入表 和 PE文件结构详解(五)延迟导入表 介绍了PE文件中比较常用的两种导入方式,不知道大家有没有注意到,在调用导入函数时系统生成的代码是像下面这样的:

 

在这里,IE的iexplorer.exe导入了Kernel32.dll的GetCommandLineA函数,可以看到这是个间接call,00401004这个地址的内存里保存了目的地址,根据图中显示的符号信息可知,00401004这个地址是存在于iexplorer.exe模块中的,实际上也就是一项IAT的地址。这个是IE6的exe中的例子,当然在dll中如果导入其他dll中的函数,结果也是一样的。这样就有一个问题,代码里call的地址是一个模块内的地址,而且是一个VA,那么如果模块基地址发生了变化,这个地址岂不是就无效了?这个问题如何解决?

答案是:Windows使用重定位机制保证以上代码无论模块加载到哪个基址都能正确被调用。听起来很神奇,是怎么做到的呢?其实原理并不很复杂,这个过程分三步:

1.编译的时候由编译器识别出哪些项使用了模块内的直接VA,比如push一个全局变量、函数地址,这些指令的操作数在模块加载的时候就需要被重定位。

2.链接器生成PE文件的时候将编译器识别的重定位的项纪录在一张表里,这张表就是重定位表,保存在DataDirectory中,序号是 IMAGE_DIRECTORY_ENTRY_BASERELOC。

3.PE文件加载时,PE 加载器分析重定位表,将其中每一项按照现在的模块基址进行重定位。

以上三步,前两部涉及到了编译和链接的知识,跟本文的关系不大,我们直接看第三步,这一步符合本系列的特征。

在查看重定位表的定义前,我们先了解一下他的存储方式,有助于后面的理解。按照常规思路,每个重定位项应该是一个DWORD,里面保存需要重定位的RVA,这样只需要简单操作便能找到需要重定位的项。然而,Windows并没有这样设计,原因是这样存放太占用空间了,试想一下,加入一个文件有n个重定位项,那么就需要占用4*n个字节。所以Windows采用了分组的方式,按照重定位项所在的页面分组,每组保存一个页面其实地址的RVA,页内的每项重定位项使用一个WORD保存重定位项在页内的偏移,这样就大大缩小了重定位表的大小。

有了上面的概念,我们现在可以来看一下基址重定位表的定义了:

 



  • typedef struct _IMAGE_BASE_RELOCATION {


  •     DWORD   VirtualAddress;


  •     DWORD   SizeOfBlock;


  • //  WORD    TypeOffset[1];


  • } IMAGE_BASE_RELOCATION;


  • typedef IMAGE_BASE_RELOCATION UNALIGNED * PIMAGE_BASE_RELOCATION;

VirtualAddress:页起始地址RVA。

 

SizeOfBlock:表示该分组保存了几项重定位项。

TypeOffset:这个域有两个含义,大家都知道,页内偏移用12位就可以表示,剩下的高4位用来表示重定位的类型。而事实上,Windows只用了一种类型IMAGE_REL_BASED_HIGHLOW  数值是 3。

好了,有了以上知识,相信大家可以很容易的写出自己修正重定位表的代码,不如自己做个练习验证一下吧。

 

最后,还是总结一下,哪些项目需要被重定位呢?

1.代码中使用全局变量的指令,因为全局变量一定是模块内的地址,而且使用全局变量的语句在编译后会产生一条引用全局变量基地址的指令。

2.将模块函数指针赋值给变量或作为参数传递,因为赋值或传递参数是会产生mov和push指令,这些指令需要直接地址。

3.C++中的构造函数和析构函数赋值虚函数表指针,虚函数表中的每一项本身就是重定位项,为什么呢?大家自己考虑一下吧,不难哦~

原文请查看《《《《常州网黑客编程培训培训-PE_2》》》》

上篇:上一篇:PE文件结构详解(一)基本概念
下篇:下一篇:常州微信开发-BackTrack5正确在虚拟机上网配置