delay load深入分析

来源:互联网 发布:天猫淘宝内部优惠劵 编辑:程序博客网 时间:2024/06/06 04:34

上一篇文章中通过一个实例分析了import table/IAT及调用外部函数的工作原理。在本文中我们还是将通过一个实例分析一下delay load的工作原理。

 

以下是delay load最基本的一些知识:

 

我们知道,dll的的载入有两种最基本的方法:隐式加载和显式加载。所谓隐式加载就是上一篇文章中介绍的方法,通过PE的输入表在进入入口函数之前将dll加载到内存空间。显式加载就是用LoadLibrary和GetProAddress的方法在需要的时候将dll加载到进程空间。这两种方法都是我们最常用的。那什么叫delay load呢?

 

被指定为delay load的dll只有当需要的时候才会真正载入进程空间,也就是说如果没有位于该dll中的函数被调用该dll将不被加载。而这个加载过程正是由LoadLibrary和GetProcAddress完成的,当然这一切对程序员是透明的。

 

这样做的好处是什么?毫无疑问,程序的启动速度快了,因为很多dll在启动的时候可能还没有使用到。甚至有些dll可能在整个生命周期中都未曾使用到,这样用delay load的话这个dll就不需占用任何内存。

 

那么如何使用delay load的呢?你需要做两件事情:

 

1. 在cpp的开头加上#pragma comment(lib, "DelayImp.lib"),如果你使用vs也可以在input里面加上这么一项。稍后会解释。

2. 在编译的时候加上:/link /DELAYLOAD:xxx.dll,如果使用vs只要在项目属性中找到delay load这一项,加上dll的名字。

(网上很多把#pragma comment(linker, "/DELAYLOAD:xxx.dll")加到cpp中,经证实,这种做法不可行。只能在命令行中作为参数使用)

 

现在让我利用现有的知识分析一下用delay load之后跟之前产生了什么变化:

1. 该dll相关的输入表肯定没了。毫无疑问,否则程序启动的时候还是会被无辜的加载。

2. 需要有什么字段记录dll的加载地址和函数的地址吧?否则每次调用都要LoadLibrary+GetProcAddress岂不是太不智能了?

3. 谁来调用LoadLibrary+GetProcAddress以及填充这些字段么?看来在链接的时候肯定要嵌入一下代码。那嵌入的代码哪里来?还记得之前的#pragma comment(lib, "DelayImp.lib")么?对了,就是这里来。

 

接下来我们就用一个最简单的例子来分析整个过程,让我再一次体会到了一个道理:作为程序员不学汇编真是寸步难行啊。在此之前我们再来回想一些PE结构中有什么跟delay load相关的东西么?对了!data directory中有一项IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT就是维护了所有跟delay load相关的信息。

 

我们先看一下对应的ImgDelayDescr结构体:

 

大小成员描述

DWORD   
grAttrs
这个结构的属性。目前唯一被定义的旗标是dlattrRva,表明这个结构中的字段应该被认为是RVA,而不是虚地址
RVA
rvaDLLName
指向一个被输入的DLL的名称的RVA。这个字符串被传递给LoadLibrary
RVA
rvaHmod
指向一个HMODULE大小的内存位置的RVA。当延迟装入的DLL被装入内存后,它的模块句柄(hModule)被保存在这个地方
RVA
rvaIAT
指向这个Dll的输入地址表的RVA,它与常规的IAT的格式相同
RVA
rvaINT
指向这个DLL的输入名称表的RVA,它与常规的INT表格式相同
RVA
rvaBoundIAT
可选的绑定IATRVA,指向这个DLL的输入地址表的绑定拷贝,它与常规的IAT表的格式相同,目前,这个IAT的拷贝并不是实际的绑定,但是这个特征可能会加到绑定程序的未来版本中
RVA
rvaUnloadIAT
原始IAT的可选拷贝的RVA。它指向这个DLL的输入地址表的未绑定拷贝。它与常规的IAT表的格式相同,通常设为0
DWORD
dwTimeStamp  
延迟装入的输入DLL的时间/日期戳,通常设为0

 

看着是不是有点眼熟?对了,跟IMAGE_IMPORT_DESCRIPTOR有点像:都有dll name,INT,IAT,在继续往下看之前请确保对INT/IAT有基本的了解,没有还不是很清楚的话建议先看一下之前的一片文章。

 

到此为止,基本的知识介绍完毕,在给出我们的例子之前先介绍一下我使用的工具:ollydbg+stud_pe,ollydbg是一款强大的汇编级调试器,暂时属于摸索阶段,网上有不少教程,但是看懂这些教程本身需要一定的功力。stud_pe是一款很小的PE分析器,通过它可以很快的定位到PE的任意一部分,是初学PE的利器。接下来我的很多截图都源自这两个工具。

 

我们的例程尽量精简,又最好能覆盖所有的delay load知识:

当然您还需要一个delayLoad.dll,这个dll只需要导出两个函数export1+export2,函数的参数我们也省去了,加上不必要的参数只会增加汇编代码的复杂性,对我们的分析没有任何帮助。至于如何创建这个delayLoad.dll就不用我再具体说了吧,如果您还不会,建议你补补基础知识了哈~

 

编译+链接:cl sample.cpp  /link /DELAYLOAD:delayload.dll

 

开始研究汇编之前我们先看一下sample.exe中ImgDelayDescr现在是什么情况:

我们先看一下最重要的几项(如何通过上面的virtual address获得文件中对应的内容不再介绍,参见上一篇文章):

rvaDLLName: 64 65 6C 61 79 4C 6F 61 64 2E 64 6C 6C (delayload.dll)

rvaIAT: 34 10 40 00 19 10 40 00(按照正常推理:这两项将用于保存函数地址)

rvaINT: 72 7A 00 00 68 7A 00 00

             72 7A 00 00:00 00 65 78 70 6F 72 74 31(..export1)

             68 7A 00 00:00 00 65 78 70 6F 72 74 32(..export2)
rvaINT与import table中的INT用法完全一样:每一项都指向了一个IMAGE_IMPORT_BY_NAME,前两个字节表示Hint(具体用途请查阅PE结构,对本无无用),后面的直接表示函数名,用ASCII码表示。

rvaIAT与import table中的IAT有点不同,import table的IAT在程序加载之前跟INT指向相同的内容,而这里确不是。另外import table的IAT在加载之前获得所有导入函数的地址并更新,而rvaIAT则会在函数被调用到的时候进行更新。那么在程序加载之前rvaIAT中的值是什么意义呢?别急,马上就知道了。

 

接下来用ollydbg打开sample.exe,找到入口函数,开始我们的体力活T_T:

00031000   55                          PUSH EBP
00031001   8BEC                      MOV EBP,ESP
00031003   FF15 209B0300       CALL DWORD PTR DS:[39B20]     //export1
00031009   FF15 209B0300       CALL DWORD PTR DS:[39B20]     //export1
0003100F   FF15 249B0300        CALL DWORD PTR DS:[39B24]     //export2
00031015   33C0                      XOR EAX,EAX
00031017   5D                          POP EBP
00031018   C3                          RETN
00031019   B8 249B0300          MOV EAX,test.00039B24
0003101E   E9 00000000          JMP test.00031023

00031023   51                          PUSH ECX
00031024   52                          PUSH EDX
00031025   50                          PUSH EAX

00031026   68 1C7A0300          PUSH test.00037A1C
0003102B   E8 0E000000          CALL test.0003103E
00031030   5A                          POP EDX
00031031   59                          POP ECX
00031032   FFE0                      JMP EAX

00031034   B8 209B0300          MOV EAX,test.00039B20

00031039   E9 E5FFFFFF           JMP test.00031023

0003103E  // 先不关心这里的代码

 

我们以第一个export1的调用为例:CALL DWORD PTR DS:[39B20]

39B20是什么?30000是加载地址(exe不是会加载到400000上么?为什么用ollydbg调试的时候会加载到30000的位置?不解...经测试ollydbg每次exe的加载地址都会随机变化),那么我们需要的其实是9B20,往上看看rvaIAT的值正是9B20!也就是rvaIAT中的第一项。我们之前的疑问马上要解开了,我们看到rvaIAT中第一项对应的数据是401034(1034),也就是说这个调用其实就是CALL 31034:

00051034   B8 209B0300         MOV EAX,test.00039B20

00051039   E9 E5FFFFFF          JMP test.00031023 

结合00031023中的代码我们已经可以推出如下结论:

1. 每一个rvaIAT项中保存了一个地址,该地址位于代码段中,由CALL DWORD PTR DS:[XXX]跳转进入到该代码段。(XXX是rvaIAT中某一项的地址)

2. 由CALL DWORD PTR DS:[XXX]跳转进入的代码段具有统一的格式:

    1. 将该rvaIAT项的地址保存在EAX中。

    2. 跳转到某一个地址(本程序中31023)

    3. 在该地址中调用一个函数(本程序中3103E,代码暂时未给出,将在之后的介绍中详细分析),该函数将完成delay load的所有工作,并修改rvaIAT中的对应项使之拥有正确的函数地址。

    4. 调用完该函数后JMP EAX,这个时候rvaIAT中对应的项已经有正确的函数地址了。

 

接下来我们重点研究3103E中的代码:

 

 

以上基本是针对第一次调用DLL中的函数的情况。注释已经写的很清楚了,虽然还有不少地方没有彻底搞清楚,但是核心的部分已经一目了然。正当我打算继续研究未明白的代码时,突然发现原来微软提供了这部分的源代码T_T: (delayhlp.cpp)

 

对比汇编,我们看看我们得到了什么新的信息:

1. RaiseException的最后一个参数指向了一个DelayLoadInfo. 可以在异常过滤器中获取相应的信息.

2. 之前一直很困惑我们的39B4C和终于知道什么用途了!是系统提供给我们的一个Hook:__pfnDliNotifyHook2,我们可以在自己的代码中定义这个函数,由系统在特定的时候调用。与此同时系统还提供了一个Hook:__pfnDliFailureHook2,在汇编代码中对应的是39B48. 这两个函数的用途将在后面介绍。

3. 还记得000311DC附近一系列的条件跳转么?这个是用来判断是否存在绑定信息的,如果一切正常的话就直接用绑定的地址,不需要GetProcessAdress了。为了确保这个绑定的地址还能正确使用,需要进行一系列的条件判断:

    1. rvaBoundIAT & dwTimeStamp都不为0

    2. IMAGE_NT_SIGNATURE & 时间戳一样 & 加载地址跟首选加载地址一致

    那么绑定的加载地址哪里来?记得rvaBoundIAT么?这里分析汇编比cpp更简单: MOV EBX,DWORD PTR DS:[ECX+EAX],其中EAX是rvaBouldIAT的地址,ECX是偏移(该函数存放地址到rvaIAT首地址的偏移)

4. 如果DLL加载失败或者函数地址寻找失败,程序不会崩溃,而是会引发异常供开发者处理。

5. 如果该DLL可能会被unload(使用__FUnloadDelayLoadedDLL2),那么我们需要准备一些数据结构:new ULI(pidd);

 

到现在为止,基本上我们已经非常清楚delay load的工作原理的,那么再让我们思考一下当第二次调用export1时发生了什么事情呢?还是调用CALL DWORD PTR DS:[39B20],但是此时39B20已经存放了正确的export1地址,以后再使用到这个函数的话就可以直接使用了!

 

 

再从头回顾一下,还有没有什么内容没有介绍到:

1. __pfnDliNotifyHook2 & __pfnDliFailureHook2

2. unload...

3. 如何让绑定工作起来?显然在这个例子中没有绑定。

 

接下来的工作我们针对上面的三个方面一一介绍:

 

__pfnDliNotifyHook2 & __pfnDliFailureHook2

 

上面的例子再清楚不过,接下来我们结合__delayLoadHelper2的实现看看我们能为delayHook自定义什么行为:

1. dliStartProcessing: 如果在这里就获得了函数地址,直接跳到__delayLoadHelper2的最后。

2. dliNotePreLoadLibrary: LoadLibrary之前. 这个时候我们可以自己找到DLL的地址并返回,如果返回0,由__delayLoadHelper2调用LoadLibrary.

3. dliNotePreGetProcAddress: 在收到这个flag的时候我们可以自己获得函数地址. 如果返回0,则由__delayLoadHelper2负责.

4. dliNoteEndProcessing: 所有操作都结束了准备从__delayLoadHelper2返回。

 

__pfnDliFailureHook2的用法相似:

1. dliFailLoadLib: 当__delayLoadHelper2调用LoadLibrary出错的时候. 再这里我们可以继续尝试Load这个DLL或者做一些错误处理。

2. dliFailGetProc: 当__delayLoadHelper2调用GetProcAddress失败. 同理.

 

UNLOAD

默认情况下延迟加载的DLL不具备unload功能. 什么意思呢?

1. FreeLibrary无论如何不能用. 因为FreeLibrary不会清理函数地址. 当下一次调用该DLL中的函数的可以就会导致异常访问。

2. 既然不能FreeLibrary那也没办法unload的了。默认情况下就是这个样子的。

 

当然微软不会这样傻,你可以在delayhlp.cpp中找到一个名为__FUnloadDelayLoadedDLL2的函数,就是专门用来unload延迟加载的DLL的,但是要把它加到自己的程序中需要一个链接开关:/delay:unload. 如果没有设定这个开关,那么调用__FUnloadDelayLoadedDLL2什么也不会做。除此之外当然要加上#include<delayimp.h>&#include<windows.h>保证编译能够通过。

 

我们再来看一下__FUnloadDelayLoadedDLL2做了什么?

1. 遍历所有的ImgDelayDescr, 找到相同名字的DLL对应的ImgDelayDescr

2. 如果该ImgDelayDescr对应的rvaUnloadIAT不为0,那么将rvaUnloadIAT中的数据覆盖rvaIAT中的。

 

提出一个小问题:rvaUnloadIAT存放了什么?有兴趣的读者可以自己尝试一下,其实不需要尝试我们也应该可以想明白。因为unload之后我们还是可以调用该DLL中的函数进行延迟加载,那么覆盖之后的rvaIAT必须和初始时(从未调用过该DLL中的函数)rvaIAT中的数据一致。也就是说,在程序未加载之前,rvaUnloadIAT中存放了一份rvaIAT的拷贝。

 

绑定

关于绑定,还有一大堆的内容可以介绍。因为不是本文的重点,我们就简单的介绍一下:

 

我们知道,一般情况下,从一个dll导入一个函数的话这个函数的地址是在加载时获得并填入IAT中的。这样势必导致加载时间变长。绑定所要做的事情就是将这个工作提前。那么就有一个问题了,dll加载的地址是不定的,如何得到正确的函数地址呢?其实绑定有个前提条件,就是dll的加载地址一定要跟PE中定义的加载地址一致,绑定才会有效。否则,还是会在加载的时候通过INT重新获得函数名及函数地址。除此之外,还需要做一系列的判断,比如dll的时间戳,因为重新编译过后的dll地址可能都变了,之前的绑定也是无效的。

 

那么如何绑定呢?微软提供了一个名为bind的工具。用法如下BIND -u sample.exe delayLoad.dll

 

运行bind的命令后我们可以看到data directory中有一项变化了:IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT. 如果不是延迟加载的dll相信运行这个命令以后绑定就启作用了。可惜的是在这个例子中因为使用了delay load, 我尝试着使用BIND进行绑定,却没有成功(ImgDelayDescr中的时间戳没有改变,所以在比较时间戳的时候失败了,结果还是通过GetProcAddress取得了函数地址)。具体原因不知道,暂时先告一个段落。

 

说到加载地址,还有一个工具不得不提,就是rebase, rebase的作用就是调整dll的首选加载地址,使得每个dll都能加载到首选地址上,这样就达到了一定程度的优化。通常微软建议的做法是先运行rebase再运行bind,这样能保证bind后都是有效地。这里相关的内容还有不少,有兴趣的读者可以自己再找找资料研究一下,如果能再写个程序测试一下bind, rebase之后程序的加载时间加快了多少那就再好不过了:)