DLL随笔
简要记了一下《程序员的自我修养》第九章的笔记,之前读的时候跳过了windows的内容,现在重新捡起简要记一下,做了不必要的删减和自作聪明的增添
DLL简介
windows下的dll和exe文件都是有PE格式的二进制文件,但其不同在于PE文件头中有一个符号位表示其是EXE还是DLL,并且DLL文件的后缀也不一定是.dll,还有可能是.ocx(OCX控件包含ActiveX控制的库)或者.CPL(控制面板程序),.DRV(旧式的系统驱动程序)。
ELF文件可以实现运行时加载动态链接,在windows下也有类似的机制,比如ActiveX技术就基于此
进程地址空间和内存管理
在远古的windows版本下,程序运行方式还不能被称为进程,所有的应用程序共享同一个地址空间,也就是可以随意访问DLL的内容,不过太过久远就随笔一记即可
后来32位windows开始支持进程拥有独立的地址空间,一个DLL在不同的进程中拥有不同的私有数据副本,类似ELF的共享对象,但ELF中是地址无关代码机制,因此可以多进程共享一份代码,但DLL代码并非代码无关,因此只在某些情况下可以被多进程间共享
基地址和RVA
PE中有两个较为常见的概念:基地址(Base Address),相对地址(RVA,Relative Virtual Address)
所谓基地址,就是当PE文件被装载时,进程地址空间的起始地址,而对于PE文件来说,他都有一个优先装载的基地址,即PE文件头的Image Base
常见的EXE文件,Image Base一般值是:0x400000,对于DLL而言,该值一般为: 0x10000000
windows在装在DLL时,会尝试把它装载到由Image Base指定的虚拟地址,而若改地址区域已被其他模块占用,那么PE装载器会选用其他空闲地址,而相对地址是一个地址相对改地址的偏移,即offset
如PE被装进0x1000000,那么RVA为0x1000的地址就是0x1001000
Address = Base Address + RVA(offset)
DLL共享数据段
在win32下,windows系统提供了一系列API实现进程间通信(IPC),其中有一种方法是使用DLL来实现进程间通信
正常情况下,每个DLL的数据段在各个进程中都是独立的,每个进程都拥有自己的副本,但是windows允许DLL的数据段设置成共享的,任何进程都可以共享该DLL的同一份数据段,而有一个常见的做法是将一些需要进程间共享的变量分离出来,放到另一个数据段中,然后将该段设置成进程间可共享的,即一部分私有,一部分共享,但这也是极其危险的
下面的内容可能会大量采取书上的原文,文字内容较多
DLL的简单例子
对于DLL的创建和使用而言,最基本的概念是导出(Export)表。在ELF中是默认导出共享库所有的全局符号的,也就是说默认共享库中所有的全局函数和变量在默认情况下都可以被其他模块使用,但在DLL中,我们需要显式的导入我们需要的符号,否则其默认不导出所有符号,而我们在程序中使用导出的符号时,这个过程被称为导入。
MSVC(Microsoft Visual C++)提供了一系列的C/C++拓展来指定符号的导入导出,对于一些支持windows平台的编译器,比如Intel C++,GCC windows版等都支持这种拓展
我们可以通过"__declspec"
属性关键字来修饰某个函数或变量,当我们使用"__delspec(dllexport)"
时就表示该符号是从本DLL导出的符号,而如果使用的是"__declspec(dllimport)"
则表示其似从别的DLL中导入的符号
在c++中,如果希望导入或导出的符号符号C语言的符号修饰规则,则必须在前面加上
external "c"
来防止C++对其进行符号修饰
除了使用"__declspec"
属性关键字来指定导入导出符号之后,我们也可以使用”.def”文件来生命导入导出符号,该拓展名的文件类似于ld链接器的链接叫本文件,可以当作link链接器的输入文件,用以控制链接过程,其中的IMPORT或EXPORTS段可以用来声明导入导出符号,该方法不止适用于C/C++,还适用于其他语言。
创建DLL
简单写一下例子即可:
1 |
|
编译可以使用vs(MSCV)自带的cl,加/LDd参数表示Debug版,不加参数就生成EXE文件,而使用/LD则表示生成RELEASE版的DLL
1 |
|
可以看到一共生成了四个文件:test.dll,test.obj.test.exp,test.lib文件
- test.obj是编译的目标文件
- test.dll就是我们用的dll文件
- test.lib是一组目标文件的集合
- test.exp文件为链接器在创建DLL文件时的临时文件
然后用dumpbin工具来查看生成的DLL文件
1 |
|
可以看到该DLL中有三个导出函数并且可以看到他们的相对地址
使用DLL
程序使用DLL的过程其实就是引入DLL中的导出函数和符号的过程,即导入过程
对于从其他DLL导入的符号,我们需要使用"__declspec(dllimport)"
显式的声明某个符号为导入符号
例如:
1 |
|
编译时我们可以使用如下命令编译
1 |
|
lib文件中并不真正包含dll的代码和数据,他被用来描述dll的到处符号,包含了myTest.o链接test.dll时所需要的导入符号以及一部分”桩代码”,也被称为”胶水代码”,以便将程序与DLL黏在一起,而我们的test.lib这样的文件也被称为导入库
如下为MSVC静态库链接过程
1 |
|
使用模块定义文件
声明DLL中某个函数是导出函数的办法有两种,除了之前使用的"__declspec(dllexport)"
,另一种是采用模块定义(.def)文件声明
.def文件在MSVC链接过程中与链接脚本(link script)文件在ld连接过程中的作用类似,用于控制链接过程,为链接器提供有关链接程序的到处符号,属性以及一些其他信息
假设我们删除了test.c中所有的"__declspec(dllexport)"
,创建一个 test.def文件,内容如下:
1 |
|
然后用如下方式编译test.c
1 |
|
用这种方法也有一些在编译上的优势,比如MSCV默认对C语言的函数代码使用"_cdecl"
调用规范,而这种情况下对函数不做任何符号修饰,但是一旦我们使用其他的函数调用规范,MSCV就会对符号名进行修饰,比如使用"_stdcall"
调用的函数Add就会被修饰成”Add@16”,前面以`”“开头,后面以
“@n”结尾,n表示函数调用时参数所占堆栈空间的大小。使用.def文件可以将导出函数重新命名,比如当Add函数采用
“__stdcall”`时,我们可以使用如下的.def文件:
1 |
|
我们使用这个.def生成.dll文件时,可以看到
1 |
|
这种方式相当于起了一个别名,由于Windows的API经常采用"WINAPI"
这种方式进行声明,但其本质是一个被定义为"__stdcall"
的宏。
微软以DLL的形式提供windows的API,而每个API的导出函数又以"__stdcall"
的形式被声明,但我们并未在windows的API中看到过_Add@16这种形式的命名方式,由此可见其也是采取了这种导出函数重命名的方式。
关于DLL导出还有如此一篇文章可以作参考:https://www.jianshu.com/p/1af030b26bb2
DLL显式运行时链接
与ELF类似,DLL也是支持运行时加载的,Windows提供了3个API:
LoadLibrary
(或者LoadLibraryEx
),该函数用于装载一个DLL到进程的地址空间,其功能与dlopen
类似GetProcAddress
用于查找某个符号的地址,与dlsym类似FreeLibrary
用于卸载某个已加载的模块,与dclose类似
如以下的例子:
1 |
|
符号导出导入表
导出表
当一个PE需要将一些函数或变量提供给其他PE文件使用时,我们就将这种行为称为符号导出(Symbol Exporting)
ELF导出的符号保存在".dynsym"
段中,而Windows下,其导出的概念也是类似的,所遇到处的符号被击中存放在了被称为导出表(Export Table)
的结构中.,也可以简单的将其理解为一个符号名与符号地址的映射关系
PE头中有一个叫做DataDirectory
的结构数组,他一共有十六个元素,每一个元素中保存的是一个地址和长度,其中第一个元素就是导出表的结构的地址和长度
导出表为定义在"winnt.h"
的一个叫IMAGE_EXPORT_DIRECTORY
的结构体
1 |
|
我们重点关注的是最后三个变量,AddressOfFunctions
,AddressOfNames
,以及AddressOfNameOrdinals
- AddressOfFunctions:
导出地址表EAT
,存放了各个导出函数的RVA - AddressOfNames:
函数名表
,保存了导出函数的名字,其内以ASCII值排序,以便动态采用二分查找的方式 - AddressOfOrdinals:
序号对应表
,具体在下面介绍
序号
这也是一个来自远古的遗物,由于最原始的windows内存太小了,如果存储名字的话实在是太大了,因此人们采用序号的形式来导出函数
那么什么是序号呢?
一个导出函数的序号就是函数在EAT中的地址下标加一个Base值(也就是IMAGE_EXPORT_DIRECTORY中的Base,默认该值为1)
比如Mul的RVA是0x1040s,他在EAT中的下标为1,加一个Base值其序号就是2了,那么导入的时候也是同理,只需要减一个Base值然后按下标寻找就好了
但是由于我们的DLL经常改变,序号的形式就会有着诸多的不便,因此如今基本都不使用序号来导入函数了,而是直接使用函数名即可,而由于Windows是向后兼容的,因此序号也并未被抛弃,导出函数可以没有名字,但是一定会有一个序号
那么系统如何确定符号名表和EAT表的关系呢,这就需要通过第三个表即序号对应表来做映射了,比如程序导入了Add函数,那么首先链接器在函数名表中二分查找Add函数,然后在名字序号对应表中找到其序号,减去Image值,最后去EAT中按下标取出地址即可
link链接器也提供了一种导出符号的方式,比如:
1
2
> link test.obj /DLL /EXPORT:_Add
>
而如果是使用"__declspec(dllexport)"
扩展,其实际是用目标文件的编译器指示来实现的(PE目标文件的”.drectve”段),对于之前的例子而言,它其实保存了3个"/EXPORT"
参数
1 |
|
EXP文件(下面内容几乎完全摘抄于原文)
在创建DLL的同时我们也会得到一个EXP文件,它其实是链接器创建DLL时的临时文件,链接器在创建DLL时与静态链接一样采用两遍扫描过程,DLL一般都有到处符号,链接器在第一遍时遍历所有的目标文件并且收集所有导出符号信息并且创建DLL的导出表
为了方便起见,链接器会把这个导出表放到一个临时的目标文件叫做".edata"
的段中,这个目标文件就是EXP文件,也就是说它其实是PE/COFF的目标文件,只不过其后缀不是.obj而是.exp而已
第二遍时他就会把这个EXP文件当做普通目标文件一样,与其他输入的目标文件连接在一起并输出DLL,此时EXP文件中的".edata"
段就会被输出到DLL文件中并称为导出表,不过一般不会保留".edata"
段而是将其存在".rdata"
中
导出重定向
DLL有被称为导出重定向(Export Forwarding)的机制,也就是将某个导出符号重定向到另一个DLL中
比如XP系统中,"KERNEL32.DLL"
的HeapAlloc
函数被重新定向到了"NTDLL.DLL"
中的RtlAllocHeap
函数,重定向也可以使用.def模块定义文件,比如
1 |
|
其机制就是如果发现导出表中的RVA指向了一个导出表,那么就意味着该符号呗重定向了
导入表
程序中使用来自DLL的函数和变量就被称为符号导入
在ELF中,".rel.dyn"
和".rel.plt"
两个段分别保存了该模块所需要导入的变量和函数的符号以及所在的模块等信息
而".got"
和".got.plt"
则保存着这些变量和函数的真正地址
而Windows下也有类似延迟绑定的机制,他的名字更为直接,他被称为导入表(Import Table)
当某个PE文件被加载时,Windows加载器的其中一个任务就是将所有需要导入的函数地址确定并将导入表中的元素调整到正确的地址,以实现动态链接的过程
dumpbin也可以查看导入表
1 |
|
上面显示的一部分函数是由于在构建Windows DLL时,还链接了支持DLL运行的基本运行库,而这个需要Kernel32.dll,所有就有了这些函数
在Windows下,系统的足昂再起会确保任何一个模块的依赖条件都被得到满足,比如Windows程序都会依赖于KERNEL32.DLL,而KERNEL32.DLL又会依赖于NTDLL.DLL
那么Windows加载时就会确保这两个都被加载,以此类推,如果动态链接过程中某个被依赖的模块无法正确加载,那么系统将会提示错误(比如缺少某个DLL)
在PE文件中,导入表的结构是IMAGE_IMPORT_DESCRIPTOR的结构体数组,而每一个该结构都对应一个被导入的DLL,该结构体也被定义在"Winnt.h"
中:
1 |
|
结构体中的FirstThunk
指向一个导入地址数组(Import Address Table),IAT是导入表中最重要的结构,IAT中每个元素对应一个被导入的符号,元素的值在不同的情况下有不同的含义。
在动态链接器刚完成映射还没有开始重定位和符号解析时,IAT中的元素值表示相对应的导入符号的序号或者是符号名。
当Windows的动态链接器在完成该模块的链接时,元素值会被动态链接器改写成该符号的真正地址,从这一点看,导入地址数组与ELF中的GOT表非常类似。
我们可以通过看导入地址数组的元素最高位来判断其中包含的是导入符号的序号还是符号的名字,比如32位的PE文件,如果最高位被置为1,那么低32位就是导入符号的序号值,如果没有,那么元素的值是指向一个叫做IMAGE_IMPORT_BY_NAME
结构的RVA。
IMAGE_IMPORT_BY_NAME
由一个WORD值
和一个字符串
组成,这个WORD值被称为"Hint"
值,所谓"Hint"
值,其实就是导入符号中最有可能的序号值,而后面的字符串是符号名,当使用符号名导入时,动态链接器会先使用"Hint"
值的提示去定位该符号在目标导出表中的位置,如果刚好是需要的符号,那么就直接命中,而如果没有,就使用二分法来进行符号查找。
在IMAGE_IMPORT_DESCRIPTOR
结构中,还有一个指针OriginalFirstThrunk指向一个叫做导入名称表(Import Name Table),简称INT
表,这个表与IAT
表一样,但INT
被用于DLL绑定
,它用来存放绑定符号的地址。
Windows的动态链接器会在装载模块的时候,改写导入表中的IAT,但PE的导入表一般是只读的,往往位于".rdata"
这样的段中,他可以改写的原因是动态链接库也是内核的一部分,因此可以修改PE装载以后的任意一个部分,包括其内容和页面属性,Windows的做法是在装载的时候将导入表所在的位置的页改为可读写,而一旦IAT被改写完成,再将这些页面设置回只读属性.
延迟载入(Delayed Load)
这种载入方式有点像隐式装载和显式装载的混合体,当我们链接一个支持延迟载入的DLL时,链接器会产生与浦普通DLL非常类似的数据,但操作系统会忽略这些数据
当延迟载入的API第一次被调用时,由链接器添加的特殊的桩代码就会启动,这个桩代码负责对DLL的装载工作,然后这个桩代码通过调用GetProcAddress
来找到被调用的API的地址,MSVC也做了一些优化使得该方式与普通方式载入的DLL速度相差无几
导入函数的调用
如果在PE的模块中需要调用一个导入函数,如果使用ELF GOT机制就是使用一个简介调用指令,比如:
1 |
|
PE DLL的地址无关性
如果ELF调用者本身的模块是地址无关的,那么通过GOT跳转之前,需要计算目标函数地址在GOT表中的位置,然后再间接跳转,以此来实现地址无关
但PE DLL 的代码段并不是地址无关的
PE使用一种叫做重定基地址的方法来解决进程空间中地址冲突的问题
但这种方式也有一定的问题,因为编译器无法判断一个函数是本模块内部的,还是从外部导入的
因为PE没有类似ELF的共享对象有全局符号介入的问题,所以对于模块内部的全局函数调用,编译器产生的都是直接调用指令
为了使编译器可以区分函数从内部导入还是该模块内部定义的,MSVC引入了拓展属性"__declspec(dllimport)"
,也就意味着一旦一个函数被如此声明了,编译器就会知道他是从外部导入的,来以此产生相应的指令形式
1 |
|
而在"__declspec"
关键字引入之前,对于导入函数的调用,编译器并不区分导入函数和导出函数,统一的产生直接调用的指令,但链接器会将导入函数的目标地址导向一小段桩代码(Stub),由桩代码在将控制权交给IAT中的真正地址,实现如下:
1 |
|
对于调用函数而言,其只是产生一般形式的指令"CALL XXXX"
,直到链接时才会将这个地址重定位到一段桩代码,即那条JMP指令处,然后这条JMP指令才通过IAT间接跳转到导入函数
但链接器一般是不会产生指令的,因此刚刚所说的桩代码其实是存在产生DLL文件时的LIB文件(导入库)中的
编译器在产生导入库时,同一个导出函数会产生两个符号的定义,比如foo
函数会有foo
和__imp_foo
两个符号,而这两个符号的区别在于,foo
指向foo
函数的桩代码,而__imp_foo
指向foo
函数在IAT中的位置,因此当我们使用"__declspec(dllimport)"
来声明foo导入函数时,编译器就会在该倒入函数前加上前缀"__imp__"
,以确保跟导入库中的"__imp__foo"
能够正确链接;如果不使用"__declsepc(dllimport)"
,编译器就会产生一个正常的foo符号引用一遍和导入库中的foo符号定义相链接。
如今的编译器两种导入方式都支持,但最好使用"__declspec(dllimport)"
来声明导入符号
DLL 优化
由于DLL的代码段和数据段不是地址无关的,那么就意味着它默认需要被装载到由ImageBase指定的目标地址中,如果目标地址被占用就会Rebase,而频繁的重定位也会使得程序启动速度太慢
而且动态链接过程中导入函数的符号在运行时需要被逐个解析,这个过程中不可避免的是符号字符串的比较和查找过程,而这个过程仍然是非常耗时的,这也是影响DLL性能的一个原因之一。
太长不看版,符号查找和Rebase会使得程序启动时间巨长
重定基地址(Rebasing)
Windwos PE采用 装载时重定位 的方法来解决地址冲突问题,在DLL模块装载时,如果目标地址被占用操作系统就会分配一块新的内存,而因为DLL代码段不是地址无关的,DLL中所有涉及到绝对地址的引用也都进行重定位.
但这样的重定位也有一点特殊,因为只需要加一个和固定值的差值即可,比如一个DLL的基地址是0X1000,而如果其代码中有如此的指令:
1 |
|
那么假设0x1100是该模块中一个变量foo的地址,也就意味着该变量的RVA是0x100,但装载时如果0x1000被占用了,那么就会重定一个新的基地址,比如0x2000,因此此时foo的地址其实是0x2100,所以指令应该被改为:
1 |
|
也就是说所有的需要重定位的地方只需要加上一个原基地址和现在加载的基地址的差值即可,这种方式速度比一般的重定位要快
PE文件的重定位信息被放在了".reloc"
段,我们可以从PE文件头中的DataDirectory
里得到重定位段的信息,但EXE文件默认不会有重定位段,因为EXE是进程运行时第一个被装载进虚拟内存的,这也就意味着它不会被人抢占基地址,但DLL虽然可以使用"/FIEXED"
来禁止产生重定位信息,但可能会导致装载失败
缺点:
用空间换时间
因为一个DLL被多个进程共享时该DLL会被进程装载到不同的位置,因此每一个进程都会有一个单独的DLL代码段的副本,而且当需要被重定基地址的代码段需要被换出时,需要被写到交换空间中
改变默认基地址
这里直接拿书上的例子了
模块 | 起始地址 | 结束地址 |
---|---|---|
main.exe | 0x00400000 | 0x00410000 |
foo.dll | 0x10000000 | 0x10010000 |
bar.dll | 0x10010000 | 0x10020000 |
bar.dll的基地址被重定位到了0x10010000,那么为了优化速度,我们可以采取改变默认基地址的方式,将其基地址修改成0x10010000
MSCV的链接器就提供了这样的修改功能:
1 |
|
要注意的是我们改变默认基地址需要时64K的倍数,上面参数中的0X10000是限定DLL可以占用空间的最大长度
除了链接时可以修改意外,MSCV也提供一个叫editbin
的工具(早期为rebase.exe),该工具可以修改已有的DLL的基地址,如:
1 |
|
系统DLL
由于windwos内部会有很多系统的DLL,比如"kernel.dll"
,"ntdll.dll"
,"shell32.dll"
,"user32.dll"
,"msvcrt.dll"
等,系统会在进程空间中专门划出一块0x70000000~0x80000000的区域,以此映射系统DLL,Windows在安装的时候就会把地址分配给这些DLL,从而在装载时就不需要进行过重定基址了
序号
序号标示被导出函数地址在DLL导出表的位置
一般而言仅供内部使用的导出函数只有序号没有函数名,这也外部使用者就无法推测他的含义和使用方法
Windwos API的函数名虽然是不变的,但他的序号却在各个windows版本中不停变化,也就意味着我们导入windows api的时候不能使用序号的方式来导入
导入函数绑定
当程序运行时,所有被依赖的DLL都会被装载,而且一系列的导入导出符号依赖关系都会被重新解析,大多数情况下,这些DLL都会以同样的顺序被装在到同样的内存地址,也就意味着他们的导出符号地址不变,因此我们可以通过绑定导入函数的方法来优化DLL的性能,这种方式被称为 DLL绑定(DLL Binding)
我们可以使用MSCV提供的工具来达到我们想要达到的目的:
1 |
|
其实现方法就是editbin对被绑定的程序的导入符号进行遍历查找,找到之后就把符号的运行时的目标地址写入到被绑定程序的导入表内,我们之前所说的INT表就是用于此,它用于存放绑定符号的地址。
C++与动态链接
这一部分就暂时省略了
推荐阅读:<<COM本质论>>>
COM即组件对象模型
DLL HELL
早期的时候,由于Windows缺乏一种有效的DLL版本控制机制,而频繁的更新导致经常发生兼容性的问题,因此人们将其称其为DLL噩梦(DLL HELL)
产生原因:
- 旧版本的DLL替代新版本的DLL引起的
- 新版的DLL中的函数无意发生改变时引起的(因为完全的”向下”兼容并不可能)
- 新版DLL的安装引入一个新BUG
解决方法
静态链接(Static linking)
终极方法,但会丧失使用动态链接的好处
防止DLL覆盖
windows下可以使用windows文件保护(Windows File Protection简称WFP)技术来缓解,他可以阻止未经授权的应用程序覆盖系统DLL
第三方应用程序不能覆盖操作系统DLL文件,除非他们的安装程序捆绑了Windows更新包,或者他们的安装程序运行时禁止了WFP服务
避免DLL冲突
解决不同的程序依赖相同DLL不同版本的方法就是让每一个程序有自己的一份DLL,并且将不同版本放到应用程序的文件夹中
.NET下DLL HELL的解决方法
.NET框架中,一个程序集(Assembly)有两种类型:应用程序程序(EXE可执行文件)集以及库程序(DLL动态链接库)集,一个程序集包括一个或多个文件,所以需要一个
清单文件
来描述程序集,这个清单文件被称为Manifest文件
Manifest
描述了程序集的名字,版本号以及程序集的各种资源,同时也描述了该程序集的运行所依赖的资源,包括DLL以及其他资源文件Manifest
是一个XML的描述文件,每个DLL有自己的manifest文件,每个应用程序也有自己的Manifest,对于应用程序而言,manifest文件可以和可执行文件在同一目录下,也可以作为资源嵌入到可执行文件的内部(Embed Manifest)XP以前不考虑该文件,直接去system32目录下查找该可执行文件所依赖的DLL,而XP以后会先读取程序集的清单文件,获得该可执行文件需要调用的DLL列表,操作系统再根据DLL的清单文件去寻找对应的DLL来调用
至此做了一个简单的记录,其中大段文字直接摘抄于原书,每一次看这本书都有不同的惊喜,真是深感自己太弱了
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!