看过关于动态库的调用例子,于是决定动手做一做:
dll的对外接口声明头文件,Mydll.h:
//Mydll.h #include <stdio.h> #include <stdlib.h> #include "Public.h" #define DLL_EXPORT /*extern "c"*/ __declspec(dllexport) //导出 #define CUST_API _stdcall //标准调用 DLL_EXPORT void CUST_API DisplayVersion(TCHAR *Info); //显示版本 DLL_EXPORT int CUST_API Calc(int ia,int ib); //DLL_EXPORT int CUST_API MetiCalc(int ia,int ib); //新增加接口 //mydll.cpp #include "MyDll.h" void CUST_API DisplayVersion(TCHAR *Info) { wcscpy_s(Info,sizeof(VERSION),VERSION); //#define VERSION ver 1.0 return; } int CUST_API Calc(int ia,int ib) { return ia+ib; } int CUST_API MetiCalc(int ia,int ib) { return ia*ib; }
编译后,生成DllTest.lib 和 DllTest.dll
第一种方法:静态调用
理解:lib描述dll信息和函数入口地址,在编译时期加载到可执行程序中的。
若dll增加新API接口,新接口在使用时,必须要同时更新lib 才能使用,否则会找不到新接口函数的地址,由此可见,lib包含了描述dll 的接口描述信息。
//dlltest.h #include <iostream> #include <Windows.h> using namespace std; #pragma comment(lib,"..\\ApDll\\DllTest.lib") //加载lib库 #define DLL_EXPORT /*extern "c"*/ __declspec(dllexport) //导出 #define CUST_API _stdcall //标准调用 DLL_EXPORT void CUST_API DisplayVersion(TCHAR *Info); //dll中显示版本函数 DLL_EXPORT int CUST_API Calc(int ia,int ib); DLL_EXPORT int CUST_API MetiCalc(int ia,int ib); int _tmain(int argc, _TCHAR* argv[]) { TCHAR Version[50] = {0}; int a = 10,b=12; DisplayVersion(Version); wcout<<Version<<endl; wcout<<Calc(a,b)<<endl; } Result: ver 1.0 120
第二种方法:动态加载
首先,要定义指向动态库中所对外提供的函数类型,的函数指针。
函数指针定义的理解:
typedef void(_stdcall *FunName)(paramtypes 1,paramtypes 2);//定义指向调用类型为_stdcall,参数个数,类型如paramtypes1,paramtypes 2,返回值为void类型的函数指针
这里注意,定义函数指针时,返回值 (*pName)(参数),3个部分;
然后,LoadLibrary(Path); Path为dll所在路径,可以是system目录,也可以其他指定目录。加载成功之后会返回一个Hmodel模块句柄。
再利用这个模块句柄去,获取相应函数的地址。
函数指针调用时,不同于普通的指针,它不需要间接寻址,“*”;
用完dll之后要记得ReleaseLibrary() ;
#include <iostream> #include <Windows.h> using namespace std; typedef void (CUST_API *DisVer)(TCHAR *Info); typedef int (CUST_API *CalcOprt)(int ia,int ib); int _tmain(int argc, _TCHAR* argv[]) { TCHAR Version[50] = {0}; int a = 10,b=12; HMODULE hmodle = LoadLibrary(_T("..\\ApDll\\DllTest.dll")); //动态加载dll if(NULL == hmodle) { wcout<<"load dll failed!"<<endl; return -1; } DisVer displayVer = (DisVer)::GetProcAddress(hmodle,"DisplayVersion"); //根据模块地址,按找函数名,获取函数地址 DisplayVersion(Version); try { if (NULL == displayVer) { wcout<<_T("Load function error!")<<endl; } (displayVer)(Version); //用函数指针调用函数 wcout<<Version<<endl; } catch (...) { ; } system("pause"); return 0; }
看十,百遍,不如自己敲一遍,小小的动态库调用,也是有讲究的。
以下是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 | 可选的绑定IAT的RVA,指向这个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之后程序的加载时间加快了多少那就再好不过了:)