程序员的自我修养--链接、装载与库笔记:Windows下的动态链接
Windows下的PE的動(dòng)態(tài)鏈接與Linux下的ELF動(dòng)態(tài)鏈接相比,有很多類似的地方,但也有很多不同的地方。
1. DLL簡(jiǎn)介
DLL即動(dòng)態(tài)鏈接庫(kù)(Dynamic-Link Library)的縮寫,它相當(dāng)于Linux下的共享對(duì)象。Windows系統(tǒng)中大量采用了這種DLL機(jī)制,甚至包括Windows的內(nèi)核的結(jié)構(gòu)都很大程度依賴于DLL機(jī)制。Windows下的DLL文件和EXE文件實(shí)際上是一個(gè)概念,它們都是有PE格式的二進(jìn)制文件,稍微有些不同的是PE文件頭部中有個(gè)符號(hào)位表示該文件是EXE或是DLL,而DLL文件的擴(kuò)展名不一定是.dll,也有可能是別的比如.ocx(OCX控件)或是.CPL(控制面板程序)。
DLL的設(shè)計(jì)目的與共享對(duì)象有些出入,DLL更加強(qiáng)調(diào)模塊化,即微軟希望通過DLL機(jī)制加強(qiáng)軟件的模塊化設(shè)計(jì),使得各種模塊之間能夠松散地組合、重用和升級(jí)。所以我們?cè)赪indows平臺(tái)上看到大量的大型軟件都通過升級(jí)DLL的形式進(jìn)行自我完善,微軟經(jīng)常將這些升級(jí)補(bǔ)丁積累到一定程度以后完成一個(gè)軟件更新包(Service Packs)。比如我們常見的微軟Office系列、Visual Studio系列、Internet Explorer甚至Windows本身也通過這種方式升級(jí)。另外,ELF的動(dòng)態(tài)鏈接可以實(shí)現(xiàn)運(yùn)行時(shí)加載,使得各種功能模塊能以插件的形式存在。在Windows下,也有類似ELF的運(yùn)行時(shí)加載,這種技術(shù)在Windows下被應(yīng)用的更加廣泛,比如ActiveX技術(shù)就是基于這種運(yùn)行時(shí)加載機(jī)制實(shí)現(xiàn)的。
進(jìn)程地址空間和內(nèi)存管理:Windows支持進(jìn)程擁有獨(dú)立的地址空間,一個(gè)DLL在不同的進(jìn)程中擁有不同的私有數(shù)據(jù)副本,就像ELF共享對(duì)象一樣。在ELF中,由于代碼段是地址無關(guān)的,所以它可以實(shí)現(xiàn)多個(gè)進(jìn)程之間共享一份代碼,但是DLL的代碼卻并不是地址無關(guān)的,所以它只是在某些情況下可以被多個(gè)進(jìn)程間共享。
基地址和RVA:PE里面有兩個(gè)很常用的概念就是基地址(Base Address)和相對(duì)地址(RVA, Relative Virtual Address)。當(dāng)一個(gè)PE文件被裝載時(shí),其進(jìn)程地址空間中的起始地址就是基地址。對(duì)于任何一個(gè)PE文件來說,它都有一個(gè)優(yōu)先裝載的基地址,這個(gè)值就是PE文件頭中的Image Base。Windows在裝載DLL時(shí),會(huì)先嘗試把它裝載到由Image Base指定的虛擬地址;若該地址區(qū)域已被其它模塊占用,那PE裝載器會(huì)選用其它空閑地址。而相對(duì)地址就是一個(gè)地址相對(duì)于基地址的偏移。
DLL共享數(shù)據(jù)段:Windows系統(tǒng)提供了一系列API可以實(shí)現(xiàn)進(jìn)程間的通信。其中有一種方法是使用DLL來實(shí)現(xiàn)進(jìn)程間通信。正常情況下,每個(gè)DLL的數(shù)據(jù)段在各個(gè)進(jìn)程中都是獨(dú)立的,每個(gè)進(jìn)程都擁有自己的副本。但是Windows允許將DLL的數(shù)據(jù)段設(shè)置成共享的,即任何進(jìn)程都可以共享該DLL的同一份數(shù)據(jù)段。當(dāng)然很多時(shí)候比較常見的做法是將一些需要進(jìn)程間共享的變量分離出來,放到另外一個(gè)數(shù)據(jù)段中,然后將這個(gè)數(shù)據(jù)段設(shè)置成進(jìn)程間可共享的。也就是說一個(gè)DLL中有兩個(gè)數(shù)據(jù)段,一個(gè)進(jìn)程間共享,另外一個(gè)私有。當(dāng)然這種進(jìn)程間共享方式也產(chǎn)生了一定的安全漏洞,因?yàn)槿我庖粋€(gè)進(jìn)程都可以訪問這個(gè)共享的數(shù)據(jù)段,那么只要破壞了該數(shù)據(jù)段的數(shù)據(jù)就會(huì)導(dǎo)致所有使用該數(shù)據(jù)段的進(jìn)程出現(xiàn)問題。
DLL的簡(jiǎn)單例子:在ELF中,共享庫(kù)中所有的全局函數(shù)和變量在默認(rèn)情況下都可以被其它模塊使用,也就是說ELF默認(rèn)導(dǎo)出所有的全局符號(hào)。但是在DLL中情況有所不同,我們需要顯示地”告訴”編譯器我們需要導(dǎo)出某個(gè)符號(hào),否則編譯器默認(rèn)所有符號(hào)都不導(dǎo)出(Export)。當(dāng)我們?cè)诔绦蛑惺褂?/strong>DLL導(dǎo)出的符號(hào)時(shí),這個(gè)過程被稱為導(dǎo)入(Import)。
Microsoft Visual C++(MSVC)編譯器提供了一系列C/C++的擴(kuò)展來指定符號(hào)的導(dǎo)入導(dǎo)出,對(duì)于一些支持Windows平臺(tái)的編譯器比如Intel C++、GCC Windows版(MinGW GCC, Cygwin GCC)等都支持這種擴(kuò)展。我們可以通過”__declspec”屬性關(guān)鍵字來修飾某個(gè)函數(shù)或者變量,當(dāng)我們使用”__declspec(dllexport)”時(shí)表示該符號(hào)是從本DLL導(dǎo)出的符號(hào),”__declspec(dllimport)”表示該符號(hào)是從別的DLL導(dǎo)入的符號(hào)。在C++中,如果你希望導(dǎo)入或者導(dǎo)出的符號(hào)符合C語(yǔ)言的符號(hào)修飾規(guī)范,那么必須在這個(gè)符號(hào)的定義之前加上extern “C”,以防止C++編譯器進(jìn)行符號(hào)修飾。
除了使用”__declspec”擴(kuò)展關(guān)鍵字指定導(dǎo)入導(dǎo)出符號(hào)之外,我們也可以使用”.def”文件來聲明導(dǎo)入導(dǎo)出符號(hào)。”.def”擴(kuò)展名的文件是類似于ld鏈接器的鏈接腳本文件,可以被當(dāng)作link鏈接器的輸入文件,用于控制鏈接過程。”.def”文件中的IMPORT或者EXPORTS段可以用來聲明導(dǎo)入導(dǎo)出符號(hào),這個(gè)方法不僅對(duì)C/C++有效,對(duì)其它語(yǔ)言也有效。
創(chuàng)建DLL:假設(shè)我們的一個(gè)DLL提供3個(gè)數(shù)學(xué)運(yùn)算的函數(shù),分別是加(Add)、減(Sub)、乘(Mul),它的源代碼如下(Math.c):
__declspec(dllexport) double Add(double a, double b)
{return (a + b);
}__declspec(dllexport) double Sub(double a, double b)
{return (a - b);
}__declspec(dllexport) double Mul(double a, double b)
{return (a * b);
}
使用MSVC(VS2013)的編譯器cl.exe進(jìn)行編譯,打開cmd,執(zhí)行命令及結(jié)果結(jié)果如下圖所示:參數(shù)/LDd表示生成Debug版的DLL,不加任何參數(shù)則表示生成EXE可執(zhí)行文件;我們可以使用/LD來編譯生成Release版的DLL。編譯的結(jié)果生成了”Math.dll”、”Math.obj”、”Math.exp”和”Math.lib”這4個(gè)文件。
我們可以通過dumpbin工具查看DLL的導(dǎo)出符號(hào),執(zhí)行命令及結(jié)果如下圖所示:可以看到DLL有3個(gè)導(dǎo)出函數(shù)以及它們的相對(duì)地址。關(guān)于dumpbin工具的方法可參考:https://blog.csdn.net/fengbingchun/article/details/43956673
使用DLL:程序使用DLL的過程其實(shí)是引用DLL中的導(dǎo)出函數(shù)和符號(hào)的過程,即導(dǎo)入過程。對(duì)于從其它DLL導(dǎo)入的符號(hào),我們需要使用”__declspec(dllimport)”顯示地聲明某個(gè)符號(hào)為導(dǎo)入符號(hào)。這與ELF中的情況不一樣,在ELF中,當(dāng)我們使用一個(gè)外部模塊的符號(hào)的時(shí)候,我們不需要額外聲明該變量是從其它共享對(duì)象導(dǎo)入的。以下是使用Math.dll的例子,文件TestMath.c的內(nèi)容如下:
#include <stdio.h>__declspec(dllimport) double Sub(double a, double b);int main(int argc, char** argv)
{double result = Sub(3.0, 2.0);printf("Result = %f\n", result);return 0;
}
執(zhí)行命令及結(jié)果如下圖所示:使用編譯器cl.exe將TestMath.c編譯成TestMath.obj,然后使用鏈接器link.exe將TestMath.obj和Math.lib鏈接在一起產(chǎn)生一個(gè)可執(zhí)行文件TestMath.exe。在最終鏈接時(shí),我們必須把與DLL一起產(chǎn)生的Math.lib與TestMath.obj鏈接起來,形成最終的可執(zhí)行文件。在靜態(tài)鏈接的時(shí)候,”.lib”文件是一組目標(biāo)文件的集合,在動(dòng)態(tài)鏈接里面這一點(diǎn)仍然沒有錯(cuò),但是Math.lib中并不真正包含Math.c的代碼和數(shù)據(jù),它用來描述Math.dll的導(dǎo)出符號(hào),它包含了TestMath.obj鏈接Math.dll時(shí)所需要的導(dǎo)入符號(hào)以及一部分”樁”代碼,又被稱作”膠水”代碼,以便于將程序與DLL粘在一起。像Math.lib這樣的文件又被稱為導(dǎo)入庫(kù)(Import Library)。
使用模塊定義文件:聲明DLL中的某個(gè)函數(shù)為導(dǎo)出函數(shù)的辦法有兩種,一種就是”__declspec(dllexport)”擴(kuò)展;另外一種就是采用模塊定義(.def)文件聲明。實(shí)際上.def文件在MSVC鏈接過程中的作用與鏈接腳本文件(Link Script)文件在ld鏈接過程中的作用類似,它是用于控制鏈接過程,為鏈接器提供有關(guān)鏈接程序的導(dǎo)出符號(hào)、屬性以及其它信息。不過相比于ld的鏈接腳本文件,.def文件的語(yǔ)法簡(jiǎn)單的多,而且功能也更少。
將Math.c中的所有”__declspec(dllexport)”去掉改名為Math2.c文件,然后創(chuàng)建一個(gè)Math2.def文件,內(nèi)容如下:
LIBRARY Math
EXPORTS
Add
Sub
Mul
使用以下命令行來編譯Math2.c,執(zhí)行結(jié)果如下圖所示:這樣編譯器(更準(zhǔn)確地講是link.exe鏈接器)就會(huì)使用Math2.def文件中的描述產(chǎn)生最終輸出文件。使用.def文件來描述DLL文件的導(dǎo)出屬性好處:首先,我們可以控制導(dǎo)出符號(hào)的符號(hào)名。很多時(shí)候,編譯器會(huì)對(duì)源程序里面的符號(hào)進(jìn)行修飾,比如C++程序里面的符號(hào)經(jīng)過編譯器的修飾以后,都變得面目全非。除了C++程序以外,C語(yǔ)言的符號(hào)也有可能被修飾,比如MSVC支持幾種函數(shù)的調(diào)用規(guī)范”__cdecl”、”__stdcall”、”__fastcall”,默認(rèn)情況下MSVC把C語(yǔ)言的函數(shù)當(dāng)作”__cdecl”類型,這種情況下它對(duì)該函數(shù)不進(jìn)行任何符號(hào)修飾。但是一旦我們使用其它的函數(shù)調(diào)用規(guī)范時(shí),MSVC編譯器就會(huì)對(duì)符號(hào)名進(jìn)行修飾,比如使用”__stdcall”調(diào)用規(guī)范的函數(shù)Add就會(huì)被修飾成”_Add@16”,前面以”_”開頭,后面以”@n”結(jié)尾,n表示函數(shù)調(diào)用時(shí)參數(shù)所占堆棧空間的大小。使用.def文件可以將導(dǎo)出函數(shù)重新命名。當(dāng)一個(gè)DLL被多個(gè)語(yǔ)言編寫的模塊使用時(shí),采用這種方法導(dǎo)出一個(gè)函數(shù)往往會(huì)很有用。我們經(jīng)常看到Windows的API都采用”WINAPI”這種方式聲明,而”WINAPI”實(shí)際上是一個(gè)被定義為”__stdcall”的宏。微軟以DLL的形式提供Windows的API,而每個(gè)DLL中的導(dǎo)出函數(shù)又以這種”__stdcall”的方式被聲明。與ld的鏈接控制腳本類似,使用.def文件的另外一個(gè)優(yōu)勢(shì)是它可以控制一些鏈接的過程。在.def文件中除了支持”LIBRARY”、”EXPORTS”等關(guān)鍵字以外,還支持諸如”HEAPSIZE”、“NAME”、”SECTIONS”、”STACKSIZE”、”VERSION”等關(guān)鍵字,通過這些關(guān)鍵字可以控制輸出文件的默認(rèn)堆大小、輸出文件名、各個(gè)段的屬性、默認(rèn)堆棧大小、版本號(hào)等。
DLL顯示運(yùn)行時(shí)鏈接:與ELF類似,DLL也支持運(yùn)行時(shí)鏈接,即運(yùn)行時(shí)加載。Windows提供了3個(gè)API為:(1). LoadLibrary(或者LoadLibraryEx),這個(gè)函數(shù)用來裝載一個(gè)DLL到進(jìn)程的地址空間,它的功能跟dlopen類似;(2). GetProcAddress,用來查找某個(gè)符號(hào)的地址,與dlsym類似;(3). FreeLibrary,用來卸載某個(gè)已加載的模塊,與dlclose類似。
2. 符號(hào)導(dǎo)出導(dǎo)入表
導(dǎo)出表:當(dāng)一個(gè)PE需要將一些函數(shù)或變量提供給其它PE文件使用時(shí),我們把這種行為叫做符號(hào)導(dǎo)出(Symbol Exporting),最典型的情況就是一個(gè)DLL將符號(hào)導(dǎo)出給EXE文件使用。EFL將導(dǎo)出的符號(hào)保存在”.dynsym”段中,供動(dòng)態(tài)鏈接器查找和使用。在Windows PE中,符號(hào)導(dǎo)出的概念也是類似,所有導(dǎo)出的符號(hào)被集中存放在了被稱作導(dǎo)出表(Export Table)的結(jié)構(gòu)中。事實(shí)上導(dǎo)出表從最簡(jiǎn)單的結(jié)構(gòu)上來看,它提供了一個(gè)符號(hào)名與符號(hào)地址的映射關(guān)系,即可以通過某個(gè)符號(hào)查找相應(yīng)的地址。基本上這些每個(gè)符號(hào)都是一個(gè)ASCII字符串。符號(hào)名可能跟相應(yīng)的函數(shù)名或者變量名相同,也可能不同,因?yàn)橛蟹?hào)修飾這個(gè)機(jī)制存在。
PE文件頭中有一個(gè)叫做DataDirectory的結(jié)構(gòu)數(shù)組,這個(gè)數(shù)組共有16個(gè)元素,每個(gè)元素中保存的是一個(gè)地址和一個(gè)長(zhǎng)度。其中第一個(gè)元素就是導(dǎo)出表結(jié)構(gòu)的地址和長(zhǎng)度。導(dǎo)出表是一個(gè)IMAGE_EXPROT_DIRECTORY的結(jié)構(gòu)體,它被定義在”winnt.h”中,如下所示:
// Export Format
typedef struct _IMAGE_EXPORT_DIRECTORY {DWORD Characteristics;DWORD TimeDateStamp;WORD MajorVersion;WORD MinorVersion;DWORD Name;DWORD Base;DWORD NumberOfFunctions;DWORD NumberOfNames;DWORD AddressOfFunctions; // RVA from base of imageDWORD AddressOfNames; // RVA from base of imageDWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
導(dǎo)出表結(jié)構(gòu)中,最后的3個(gè)成員指向的是3個(gè)數(shù)組,這3個(gè)數(shù)組是導(dǎo)出表中最重要的結(jié)構(gòu),它們是導(dǎo)出地址表(EAT, Export Address Table)、符號(hào)名表(Name Table)和名字序號(hào)對(duì)應(yīng)表(Name-Ordinal Table)。EAT存放的是各個(gè)導(dǎo)出函數(shù)的RVA。符號(hào)名表(或函數(shù)名表)保存的是導(dǎo)出函數(shù)的名字,這個(gè)表中,所有的函數(shù)名是按照ASCII順序排序的,以便于動(dòng)態(tài)鏈接器在查找函數(shù)名字時(shí)可以速度更快(可以使用二分法查找)。
序號(hào)(Ordinals):一個(gè)導(dǎo)出函數(shù)的序號(hào)就是函數(shù)在EAT中的地址下標(biāo)加上一個(gè)Base值(也就是IMAGE_EXPORT_DIRECTORY中的Base,默認(rèn)情況下它的值是1)。比如,Mul的RVA為0x1020,它在EAT中的下標(biāo)是1,加上一個(gè)Base值1,Mul的導(dǎo)出序號(hào)為2。如果一個(gè)模塊A導(dǎo)入了Math.dll中的Add,那么它在導(dǎo)入表中將不保存”Add”這個(gè)函數(shù)名,而是保存Add函數(shù)的序號(hào),即1。當(dāng)動(dòng)態(tài)鏈接器進(jìn)行鏈接時(shí),它只需要根據(jù)模塊A的導(dǎo)入表中保存的序號(hào)1,減去Math.dll的Base值,得到下標(biāo)0,然后就可以直接在Math.dll的EAT中找到Add函數(shù)的RVA。使用序號(hào)導(dǎo)入導(dǎo)出的好處是明顯的,那就是省去了函數(shù)名查找過程,函數(shù)名表也不需要保存在內(nèi)存中了。使用序號(hào)導(dǎo)入導(dǎo)出的最大問題是一個(gè)函數(shù)的序號(hào)可能會(huì)變化。假設(shè)某一次更新中,Math.dll里面添加了一個(gè)函數(shù)或者刪除了一個(gè)函數(shù),那么原先函數(shù)的序號(hào)可能會(huì)因此發(fā)生變化,從而導(dǎo)致已有的應(yīng)用程序運(yùn)行出現(xiàn)問題。一種解決的方案是,由程序員手工指定每個(gè)導(dǎo)出函數(shù)的序號(hào),比如我們指定Add的導(dǎo)出序號(hào)為1,Mull為2,Sub為3,以后加入函數(shù)則指定一個(gè)與其它函數(shù)不同的唯一的序號(hào),如果刪除一個(gè)函數(shù),那么保持現(xiàn)有函數(shù)的序號(hào)不變。這種手工指定函數(shù)導(dǎo)出序號(hào)的方法可以通過鏈接器的.def文件實(shí)現(xiàn)。由程序員手工維護(hù)導(dǎo)出序號(hào)的方法在實(shí)際操作中頗為麻煩。于是現(xiàn)在的DLL基本都不采用序號(hào)作為導(dǎo)入導(dǎo)出的手段,而是直接使用符號(hào)名。雖然現(xiàn)在的DLL導(dǎo)出方式基本都是使用符號(hào)名,但是實(shí)際上序號(hào)的導(dǎo)出方式仍然沒有被拋棄。為了保持向后兼容性,序號(hào)導(dǎo)出方式仍然被保留,相反,符號(hào)名作為導(dǎo)出方式是可選的。一個(gè)DLL中的每一個(gè)導(dǎo)出函數(shù)都有一個(gè)對(duì)應(yīng)唯一的序號(hào)值,而導(dǎo)出函數(shù)名卻是可選的,也就是說一個(gè)導(dǎo)出函數(shù)肯定有一個(gè)序號(hào)值(序號(hào)值是肯定有的,因?yàn)楹瘮?shù)在EAT的下標(biāo)加上Base就是序號(hào)值),但是可以沒有函數(shù)名。
名字序號(hào)對(duì)應(yīng)表?yè)碛信c函數(shù)名表一樣多數(shù)目的元素,每個(gè)元素就是對(duì)應(yīng)的函數(shù)名表中的函數(shù)名所對(duì)應(yīng)的序號(hào)值。實(shí)際上它就是一個(gè)函數(shù)名與序號(hào)的對(duì)應(yīng)關(guān)系表。
link.exe鏈接器提供了一個(gè)”/EXPORT”的參數(shù)可以指定導(dǎo)出符號(hào),如下圖結(jié)果所示:表示在產(chǎn)生Math2.dll時(shí)導(dǎo)出符號(hào)Add。
另外一種導(dǎo)出符號(hào)的方法是使用MSVC的__declspec(dllexport)擴(kuò)展,它實(shí)際上是通過目標(biāo)文件的編譯器指示來實(shí)現(xiàn)的。對(duì)于Math.obj來說,它實(shí)際上在”.drectve”段中保存了3個(gè)”/EXPORT”參數(shù),用于傳遞給鏈接器,告知鏈接器導(dǎo)出相應(yīng)的函數(shù),如下圖所示:
EXP文件:在創(chuàng)建DLL的同時(shí)也會(huì)得到一個(gè)EXP文件,這個(gè)文件實(shí)際上是鏈接器在創(chuàng)建DLL時(shí)的臨時(shí)文件。鏈接器在創(chuàng)建DLL時(shí)與靜態(tài)鏈接時(shí)一樣采用兩遍掃描過程,DLL一般都有導(dǎo)出符號(hào),鏈接器在第一遍時(shí)會(huì)遍歷所有的目標(biāo)文件并且收集所有導(dǎo)出符號(hào)信息并且創(chuàng)建DLL的導(dǎo)出表。為了方便起見,鏈接器把這個(gè)導(dǎo)出表放到一個(gè)臨時(shí)的目標(biāo)文件叫做”.edata”的段中,這個(gè)目標(biāo)文件就是EXP文件,EXP文件實(shí)際上是一個(gè)標(biāo)準(zhǔn)的PE/COFF目標(biāo)文件,只不過它的擴(kuò)展名不是.obj而是.exp。在第二遍時(shí),鏈接器就把這個(gè)EXP文件當(dāng)作普通目標(biāo)文件一樣,與其它輸入的目標(biāo)文件鏈接在一起并且輸出DLL。這時(shí)候EXP文件中的”.edata”段也就會(huì)被輸出到DLL文件中并且成為導(dǎo)出表。不過一般現(xiàn)在鏈接器很少會(huì)在DLL中單獨(dú)保留”.edata”段,而是把它合并到只讀數(shù)據(jù)段”.rdata”中。
導(dǎo)出重定向:DLL有一個(gè)很有意思的機(jī)制叫做導(dǎo)出重定向(Export Forwarding),就是將某個(gè)導(dǎo)出符號(hào)重定向到另外一個(gè)DLL。比如在Windows XP系統(tǒng)中,KERNEL32.DLL中的HeapAlloc函數(shù)被重新定向到了NTDLL.DLL中的RtlAllocHeap函數(shù),調(diào)用HeapAlloc函數(shù)相當(dāng)于調(diào)用RtlAllocHeap函數(shù)。導(dǎo)出重定向的實(shí)現(xiàn)機(jī)制也很簡(jiǎn)單,正常情況下,導(dǎo)出表的地址數(shù)組中包含的是函數(shù)的RVA,但是如果這個(gè)RVA指向的位置位于導(dǎo)出表中(我們可以得到導(dǎo)出表的起始RVA和大小),那么表示這個(gè)符號(hào)被重定向了。被重定向了的符號(hào)的RVA并不代表該函數(shù)的地址,而是指向一個(gè)ASCII的字符串,這個(gè)字符串在導(dǎo)出表中,它是符號(hào)重定向后的DLL文件名和符號(hào)名。
導(dǎo)入表:如果我們?cè)谀硞€(gè)程序中使用到了來自DLL的函數(shù)或者變量,那么我們就把這種行為叫做符號(hào)導(dǎo)入(Symbol Importing)。在ELF中,”.rel.dyn”和”.rel.plt”兩個(gè)段中分別保存了該模塊所需要導(dǎo)入的變量和函數(shù)的符號(hào)以及所在的模塊等信息,而”.got”和”.got.plt”則保存著這些變量和函數(shù)的真正地址。Windows中也有類似的機(jī)制,它的名稱更為直接,叫做導(dǎo)入表(Import Table)。當(dāng)某個(gè)PE文件被加載時(shí),Windows加載器的其中一個(gè)任務(wù)就是將所有需要導(dǎo)入的函數(shù)地址確定并且將導(dǎo)入表中的元素調(diào)整到正確的地址,以實(shí)現(xiàn)動(dòng)態(tài)鏈接的過程。我們可以使用dumpbin來查看一個(gè)模塊依賴于哪些DLL,又導(dǎo)入了哪些函數(shù),如下圖所示:可以看到Math.dll從Kernel32.dll中導(dǎo)入了諸如GetCurrentThreadId、GetCommandLineA等函數(shù)。在Math.c里面沒有用到這些函數(shù),怎么會(huì)出現(xiàn)在導(dǎo)入列表之中?這是由于我們?cè)跇?gòu)建Windows DLL時(shí),還鏈接了支持DLL運(yùn)行的基本運(yùn)行庫(kù),這個(gè)基本運(yùn)行庫(kù)需要用到Kernel32.dll,所以就有了這些導(dǎo)入函數(shù)。
在Windows中,系統(tǒng)的裝載器會(huì)確保任何一個(gè)模塊的依賴條件都得到滿足,即每個(gè)PE文件所依賴的文件都將被裝載。比如一般Windows程序都會(huì)依賴于Kernel32.dll,而Kernel32.dll又會(huì)導(dǎo)入NTDLL.DLL,即依賴于NTDLL.DLL,那么Windows在加載該程序時(shí)確保這兩個(gè)DLL都被加載。Windows將會(huì)保證這些依賴關(guān)系的正確,并且保證所有的導(dǎo)入符號(hào)都被正確地解析。在這個(gè)動(dòng)態(tài)鏈接過程中,如果某個(gè)被依賴的模塊無法正確加載,那么系統(tǒng)將會(huì)提示錯(cuò)誤(我們經(jīng)常會(huì)看到那種”缺少某個(gè)DLL”之類的錯(cuò)誤),并且終止運(yùn)行該進(jìn)程。
在PE文件中,導(dǎo)入表是一個(gè)IMAGE_IMPORT_DESCRIPTOR的結(jié)構(gòu)體數(shù)組,每一個(gè)IMAGE_IMPORT_DESCRIPTOR結(jié)構(gòu)對(duì)應(yīng)一個(gè)被導(dǎo)入的DLL。這個(gè)結(jié)構(gòu)體被定義在”winnt.h”中,如下所示:
// Import Format
typedef struct _IMAGE_IMPORT_BY_NAME {WORD Hint;CHAR Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;typedef struct _IMAGE_IMPORT_DESCRIPTOR {union {DWORD Characteristics; // 0 for terminating null import descriptorDWORD 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 forwardersDWORD Name;DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
結(jié)構(gòu)體中的FirstThunk指向一個(gè)導(dǎo)入地址數(shù)組(Import Address Table),IAT是導(dǎo)入表中最重要的結(jié)構(gòu),IAT中每個(gè)元素對(duì)應(yīng)一個(gè)被導(dǎo)入的符號(hào),元素的值在不同的情況下有不同的含義。在動(dòng)態(tài)鏈接器剛完成映射還沒有開始重定位和符號(hào)解析時(shí),IAT中的元素值表示相對(duì)應(yīng)的導(dǎo)入符號(hào)的序號(hào)或者是符號(hào)名;當(dāng)Windows的動(dòng)態(tài)鏈接器在完成該模塊的鏈接時(shí),元素值會(huì)被動(dòng)態(tài)鏈接器改寫成該符號(hào)的真正地址,從這一點(diǎn)看,導(dǎo)入地址數(shù)組與ELF中的GOT非常類似。
如何判斷導(dǎo)入地址數(shù)組的元素中包含的是導(dǎo)入符號(hào)的序號(hào)還是符號(hào)的名字?我們可以看這個(gè)元素的最高位,對(duì)于32為的PE來說,如果最高位被置1,那么低31位值就是導(dǎo)入符號(hào)的序號(hào)值;如果沒有,那么元素的值是指向一個(gè)叫做IMAGE_IMPORT_BY_NAME結(jié)構(gòu)的RVA。IMAGE_IMPORT_BY_NAME是由一個(gè)WORD和一個(gè)字符串組成,那個(gè)WORD值表示”Hint”值,即導(dǎo)入符號(hào)最有可能的序號(hào)值,后面的字符串是符號(hào)名。當(dāng)使用符號(hào)名導(dǎo)入時(shí),動(dòng)態(tài)鏈接器會(huì)先使用”Hint“值的提示去定位該符號(hào)在目標(biāo)導(dǎo)出表中的位置,如果剛好是所需要的符號(hào),那么就命中;如果沒有命中,那么就按照正常的二分查找方式進(jìn)行符號(hào)查找。
在IMAGE_IMPORT_DESCRIPTOR結(jié)構(gòu)中,還有一個(gè)指針OriginalFirstThrunk指向一個(gè)數(shù)組叫做導(dǎo)入名稱表(Import Name Table),簡(jiǎn)稱INT。這個(gè)數(shù)組跟IAT一模一樣,里面的數(shù)值也一樣。
Windows的動(dòng)態(tài)鏈接器會(huì)在裝載一個(gè)模塊的時(shí)候,改寫導(dǎo)入表中的IAT,這一點(diǎn)很像ELF中的.got。其區(qū)別是,PE的導(dǎo)入表一般是只讀的,它往往位于”.rdata”這樣的段中。對(duì)于一個(gè)只讀的段,動(dòng)態(tài)鏈接器是怎么改寫它的呢?解決方法是這樣的,對(duì)于Windows來說,由于它的動(dòng)態(tài)鏈接器其實(shí)是Windows內(nèi)核的一部分,所以它可以隨心所欲地修改PE裝載以后的任意一部分內(nèi)容,包括內(nèi)容和它的頁(yè)面屬性。Windows的做法是,在裝載時(shí),將導(dǎo)入表所在的位置的頁(yè)面改成可讀寫的,一旦導(dǎo)入表的IAT被改寫完畢,再將這些頁(yè)面設(shè)回至只讀屬性。從某些角度來看,PE的做法比ELF要更加安全一些,因?yàn)镋LF運(yùn)行程序隨意修改.got,而PE則不允許。
延遲載入(Delayed Load):Visual C++ 6.0開始引入了一個(gè)叫做延遲載入的新功能,這個(gè)功能有點(diǎn)類似于隱式裝載和顯示裝載的混合體。當(dāng)你鏈接一個(gè)支持延遲載入的DLL時(shí),鏈接器會(huì)產(chǎn)生與普通DLL導(dǎo)入非常類似的數(shù)據(jù)。但是操作系統(tǒng)會(huì)忽略這些數(shù)據(jù)。當(dāng)延遲載入的API第一次被調(diào)用時(shí),由鏈接器添加的特殊的樁代碼就會(huì)啟動(dòng),這個(gè)樁代碼負(fù)責(zé)對(duì)DLL的裝載工作。然后這個(gè)樁代碼通過調(diào)用GetProcAddress來找到被調(diào)用API的地址。另外MSVC還做了一些額外的優(yōu)化,使得接下來的對(duì)該DLL的調(diào)用速度與普通方式載入的DLL的速度相差無異。
導(dǎo)入函數(shù)的調(diào)用:如果在PE的模塊中需要調(diào)用一個(gè)導(dǎo)入函數(shù),仿照ELF GOT機(jī)制的一個(gè)辦法就是使用一個(gè)簡(jiǎn)潔調(diào)用指令。IAT相當(dāng)于GOT(不考慮PLT的情況下)。
PE DLL的代碼段并不是地址無關(guān)的。那么PE是如何解決裝載時(shí)模塊在進(jìn)程空間中地址沖突的問題的呢?事實(shí)上它使用了一種叫做重定基地址的方法。
為了使得編譯器能夠區(qū)分函數(shù)是從外部導(dǎo)入的還是模塊內(nèi)部定義的,MSVC引入了擴(kuò)展屬性”__declspec(dllimport)”,一旦一個(gè)函數(shù)被聲明為”__declspec(dllimport)”,那么編譯器就知道它是外部導(dǎo)入的,以便于產(chǎn)生相應(yīng)的指令形式。在”__declspec”關(guān)鍵字引入之前,微軟還提供了另外一個(gè)方法來解決這個(gè)問題。在這種情況下,對(duì)于導(dǎo)入函數(shù)的調(diào)用,編譯器并不區(qū)分導(dǎo)入函數(shù)和導(dǎo)出函數(shù),它統(tǒng)一地產(chǎn)生直接調(diào)用的指令。但是鏈接器在鏈接時(shí)會(huì)將導(dǎo)入函數(shù)的目標(biāo)地址導(dǎo)向一小段樁代碼(Stub),由這個(gè)樁代碼再將控制權(quán)交給IAT中的真正目標(biāo)地址。
編譯器在產(chǎn)生導(dǎo)入庫(kù)時(shí),同一個(gè)導(dǎo)出函數(shù)會(huì)產(chǎn)生兩個(gè)符號(hào)的定義,比如對(duì)于函數(shù)foo來說,它在導(dǎo)入庫(kù)中有兩個(gè)符號(hào),一個(gè)是foo,另外一個(gè)是__imp__foo。這兩個(gè)符號(hào)的區(qū)別是,foo這個(gè)符號(hào)指向foo函數(shù)的樁代碼,而__imp__foo指向foo函數(shù)在IAT中的位置。所以當(dāng)我們通過”__declspec(dllimport)”來聲明foo導(dǎo)入函數(shù)時(shí),編譯器在編譯時(shí)會(huì)在該導(dǎo)入函數(shù)前加上前綴”__imp__”,以確保跟導(dǎo)入庫(kù)中的”__imp__foo”能夠正確鏈接;如果不使用”__declspec(dllimport)”,那么編譯器將產(chǎn)生一個(gè)正常的foo符號(hào)引用,以便于跟導(dǎo)入庫(kù)中的foo符號(hào)定義相鏈接。現(xiàn)在的MSVC編譯器對(duì)于以上兩種導(dǎo)入方式都支持,即程序員可以通過”__declspec(dllimport)”來聲明導(dǎo)入函數(shù),也可以不使用。
3. DLL優(yōu)化
DLL的代碼段和數(shù)據(jù)段本身并不是地址無關(guān)的,也就是說它默認(rèn)需要被裝載到由ImageBase指定的目標(biāo)地址中。如果目標(biāo)地址被占用,那么就需要裝載到其它地址,便會(huì)引起整個(gè)DLL的Rebase。這對(duì)于擁有大量DLL的程序來說,頻繁的Rebase也會(huì)造成程序啟動(dòng)速度減慢。動(dòng)態(tài)鏈接過程中,導(dǎo)入函數(shù)的符號(hào)在運(yùn)行時(shí)需要被逐個(gè)解析。在這個(gè)解析過程中,免不了會(huì)涉及到符號(hào)字符串的比較和查找過程,這個(gè)查找過程中,動(dòng)態(tài)鏈接器會(huì)在目標(biāo)DLL的導(dǎo)出表中進(jìn)行符號(hào)字符串的二分查找。即使是使用了二分查找法,對(duì)于擁有DLL數(shù)量很多,并且有大量導(dǎo)入導(dǎo)出符號(hào)的程序來說,這個(gè)過程仍然是非常耗時(shí)的。這兩個(gè)原因可能會(huì)導(dǎo)致應(yīng)用程序的速度非常慢,因?yàn)橄到y(tǒng)需要在啟動(dòng)程序時(shí)進(jìn)行大量的符號(hào)解析和Rebase工作。
重定基地址(Rebasing):PE的DLL中的代碼段并不是地址無關(guān)的,也就是說它在被裝載時(shí)有一個(gè)固定的目標(biāo)地址,這個(gè)地址也就是PE里面所謂的基地址(Base Address)。默認(rèn)情況下,PE文件將被裝載到這個(gè)基地址。一個(gè)進(jìn)程中,多個(gè)DLL不可以被裝載到同一個(gè)虛擬地址,每個(gè)DLL所占用的虛擬地址區(qū)域之間都不可以重疊。Windows PE采用了一種與ELF不同的辦法,它采用的是裝載時(shí)重定位的方法。在DLL模塊裝載時(shí),如果目標(biāo)地址被占用,那么操作系統(tǒng)就會(huì)為它分配一塊新的空間,并且將DLL裝載到該地址。因?yàn)镈LL的代碼段不是地址無關(guān)的,DLL中所有涉及到絕對(duì)地址的引用該怎么辦呢?答案是對(duì)于每個(gè)絕對(duì)地址引用都進(jìn)行重定位。當(dāng)然,這個(gè)重定位過程有些特殊,因?yàn)?strong>所有這些需要重定位的地方只需要加上一個(gè)固定的差值,也就是說加上一個(gè)目標(biāo)裝載地址與實(shí)際裝載地址的差值。事實(shí)上,由于DLL內(nèi)部的地址都是基于基地址的,或者是相對(duì)于基地址的RVA。那么所有需要重定位的地方都需要加上一個(gè)固定差值。所以這個(gè)重定位的過程相對(duì)簡(jiǎn)單一點(diǎn),速度也要比一般的重定位要快。PE里面把這種特殊的重定位過程又被叫做重定基地址。PE文件的重定位信息都放在了”.reloc”段,我們可以從PE文件頭中的DataDirectory里面得到重定位段的信息。重定位段的結(jié)構(gòu)跟ELF中的重定位結(jié)構(gòu)十分類似。對(duì)于EXE文件來說,MSVC編譯器默認(rèn)不會(huì)產(chǎn)生重定位段,也就是默認(rèn)情況下,EXE是不可以重定位的,不過這也沒有問題,因?yàn)镋XE文件是進(jìn)程運(yùn)行時(shí)第一個(gè)裝入的虛擬空間的,所以它的地址不會(huì)被人搶占。而DLL則沒那么幸運(yùn)了,它們被裝載的時(shí)間是不確定的,所以一般情況下,編譯器都會(huì)給DLL文件產(chǎn)生重定位信息。當(dāng)然也可以使用”/FIXED”參數(shù)來禁止DLL產(chǎn)生重定位信息,不過那樣可能會(huì)造成DLL的裝載失敗。
改變默認(rèn)基地址:前面的重定基地址過程實(shí)際上是在DLL文件裝載時(shí)進(jìn)行的,所以又叫做裝載時(shí)重定位。MSVC的鏈接器提供了指定輸出文件的基地址的功能,可以在鏈接時(shí)使用link.exe命令中的”/BASE”參數(shù)指定基地址。這個(gè)基地址必須是64K的倍數(shù),如果不是64K的倍數(shù),鏈接器將發(fā)生錯(cuò)誤。除了在鏈接時(shí)可以指定DLL的基地址以外,MSVC還提供了一個(gè)叫做editbin.exe的工具,這個(gè)工具可以用來改變已有的DLL的基地址。
系統(tǒng)DLL:由于Windows系統(tǒng)本身自帶了很多系統(tǒng)的DLL,比如kernel32.dll、ntdll.dll、shell32.dll等,這些DLL基本上是Windows的應(yīng)用程序運(yùn)行時(shí)都要用到的。Windows系統(tǒng)就在進(jìn)程空間中專門劃出一塊區(qū)域,用于映射這些常用的系統(tǒng)DLL。Windows在安裝時(shí)就把這塊地址分配給這些DLL,調(diào)整這些DLL的基地址使得它們相互之間不沖突,從而在裝載時(shí)就不需要進(jìn)行重定基址了。
序號(hào):一個(gè)DLL中每一個(gè)導(dǎo)出的函數(shù)都有一個(gè)對(duì)應(yīng)的序號(hào)(Ordinal Number)。一個(gè)導(dǎo)出函數(shù)甚至可以沒有函數(shù)名,但它必須有一個(gè)唯一的序號(hào)。另一方面,當(dāng)我們從一個(gè)DLL導(dǎo)入一個(gè)函數(shù)時(shí),可以使用函數(shù)名,也可以使用序號(hào)。序號(hào)標(biāo)示被導(dǎo)出函數(shù)地址在DLL導(dǎo)出表中的位置。一般來說,那些僅供內(nèi)部使用的導(dǎo)出函數(shù),它只有序號(hào)沒有函數(shù)名,這樣外部使用者就無法推測(cè)它的含義和使用方法,以防止誤用。對(duì)于大多數(shù)Windows API函數(shù)來說,它們的函數(shù)名在各個(gè)Windows版本之間是保持不變的,但是它們的序號(hào)是在不停地變化的。所以,如果我們導(dǎo)入Windows API的話,絕對(duì)不能使用序號(hào)作為導(dǎo)入方法。在產(chǎn)生一個(gè)DLL文件時(shí),我們可以在鏈接器的.def文件中定義導(dǎo)出函數(shù)的序號(hào)。一般情況下并不推薦使用序號(hào)作為導(dǎo)入導(dǎo)出的手段。
導(dǎo)入函數(shù)綁定:DLL綁定(DLL Binding)方法可以使用editbin.exe工具對(duì)EXE或DLL進(jìn)行綁定,執(zhí)行命令及結(jié)果如下圖所示:editbin.exe對(duì)被綁定的程序的導(dǎo)入符號(hào)進(jìn)行遍歷查找,找到以后就把符號(hào)的運(yùn)行時(shí)的目標(biāo)地址寫入到被綁定程序的導(dǎo)入表內(nèi)。INT數(shù)組就是用來保存綁定符號(hào)的地址的。
導(dǎo)致DLL綁定地址失效的情況:一種情況是,被依賴的DLL更新導(dǎo)致DLL的導(dǎo)出函數(shù)地址發(fā)生變化;另外一種情況是,被依賴的DLL在裝載時(shí)發(fā)生重定基址,導(dǎo)致DLL的裝載地址與被綁定時(shí)不一致。Windows提供了相應(yīng)的機(jī)制來保證綁定地址失效時(shí),程序還能夠正確運(yùn)行。對(duì)于第一種情況的失效,PE的做法是這樣的,當(dāng)對(duì)程序進(jìn)行綁定時(shí),對(duì)于每個(gè)導(dǎo)入的DLL,鏈接器把DLL的時(shí)間戳(Timestamp)和校驗(yàn)和(Checksum,比如MD5)保存到被綁定的PE文件的導(dǎo)入表中。在運(yùn)行時(shí),Windows會(huì)核對(duì)將要被裝載的DLL與綁定時(shí)的DLL版本是否相同,并且確認(rèn)該DLL沒有發(fā)生重定基址,如果一切正常,那么Windows就不需要再進(jìn)行符號(hào)解析過程了,因?yàn)楸谎b載的DLL與綁定時(shí)一樣,沒有發(fā)生變化;否則Windows就忽略綁定的符號(hào)地址,按照正常的符號(hào)解析過程對(duì)DLL的符號(hào)進(jìn)行解析。
事實(shí)上,Windows系統(tǒng)所附帶的程序都是與它所在的Windows版本的系統(tǒng)DLL綁定的。除了在編譯時(shí)可以綁定程序,另外一個(gè)綁定程序的很好的機(jī)會(huì)是在程序安裝的時(shí)候,這樣至少在DLL升級(jí)之前,這些”綁定”都是有效的。當(dāng)然,綁定過程會(huì)改變可執(zhí)行文件本身,從而導(dǎo)致了可執(zhí)行文件的校驗(yàn)和變化,這對(duì)于一些經(jīng)過加密的,或者是經(jīng)過數(shù)字簽名的程序來說可能會(huì)有問題。
4. C++與動(dòng)態(tài)鏈接
5. DLL HELL
DLL跟ELF類似也有版本更新時(shí)發(fā)生不兼容的問題。三種可能的原因?qū)е铝薉LL Hell(DLL噩夢(mèng))的發(fā)生:(1).使用舊版本的DLL替代原來一個(gè)新版本的DLL而引起。(2).由新版DLL中的函數(shù)無意發(fā)生改變而引起。(3).由新版DLL的安裝引入一個(gè)新BUG。
解決DLL Hell的方法:
(1). 靜態(tài)鏈接(Static linking):在編譯產(chǎn)生應(yīng)用程序時(shí)使用靜態(tài)鏈接的方法鏈接它所需要的運(yùn)行庫(kù),從而避免使用動(dòng)態(tài)鏈接。這樣,在運(yùn)行應(yīng)用程序時(shí)候就不需要依賴DLL了。然而,它會(huì)喪失使用動(dòng)態(tài)鏈接帶來的好處。
(2). 防止DLL覆蓋(DLL Stomping):在Windows中,DLL的覆蓋問題可以使用Windows文件保護(hù)(Windows File Protection,簡(jiǎn)稱WFP)技術(shù)來緩解。它能阻止未經(jīng)授權(quán)的應(yīng)用程序覆蓋系統(tǒng)的DLL。第三方應(yīng)用程序不能覆蓋操作系統(tǒng)DLL文件,除非它們的安裝程序捆綁了Windows更新包,或者在它們的安裝程序運(yùn)行時(shí)禁止了WFP服務(wù)(當(dāng)然這是一件非常危險(xiǎn)的事情)。
(3). 避免DLL沖突(Conflicting DLLs):解決不同應(yīng)用程序依賴相同DLL不同版本的問題一個(gè)方案就是,讓每個(gè)應(yīng)用程序擁有一份自己依賴的DLL,并且把DLL的不同版本放到該應(yīng)用程序的文件夾中,而不是系統(tǒng)文件夾中。當(dāng)應(yīng)用程序需要裝載DLL時(shí)候,首先從自己的文件夾下尋找所需要的DLL,然后再到系統(tǒng)文件中尋找。
(4). .NET下DLL Hell的解決方案:在.NET框架中,一個(gè)程序集(Assembly)有兩種類型:應(yīng)用程序程序(也就是exe可執(zhí)行文件)集以及庫(kù)程序(也就是DLL動(dòng)態(tài)鏈接庫(kù))集。一個(gè)程序集包括一個(gè)或多個(gè)文件,所以需要一個(gè)清單文件來描述程序集。這個(gè)清單文件叫做Manifest文件。Manifest文件描述了程序集的名字、版本號(hào)以及程序集的各種資源,同時(shí)也描述了該程序集的運(yùn)行所依賴的資源,包括DLL以及其它資源文件等。Manifest是一個(gè)XML的描述文件。每個(gè)DLL有自己的manifest文件,每個(gè)應(yīng)用程序也有自己的Manifest。對(duì)于應(yīng)用程序而言,manifest文件可以和可執(zhí)行文件在同一目錄下,也可以是作為一個(gè)資源嵌入到可執(zhí)行文件的內(nèi)部(Embed Manifest)。在XP以后的操作系統(tǒng),在執(zhí)行可執(zhí)行文件時(shí)則會(huì)首先讀取程序集的manifest文件,獲得該可執(zhí)行文件需要調(diào)用的DLL列表,操作系統(tǒng)再根據(jù)DLL的manifest文件去尋找對(duì)應(yīng)的DLL并調(diào)用。
GitHub:https://github.com/fengbingchun/Messy_Test
總結(jié)
以上是生活随笔為你收集整理的程序员的自我修养--链接、装载与库笔记:Windows下的动态链接的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 程序员的自我修养--链接、装载与库笔记:
- 下一篇: 程序员的自我修养--链接、装载与库笔记: