Windows动态链接_基础知识
Wednesday, March 15, 2023
本文共2710字
6分钟阅读时长
⚠️本文是作者P3troL1er原创,首发于https://peterliuzhi.top/principle/windows%E5%8A%A8%E6%80%81%E9%93%BE%E6%8E%A5_%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/。商业转载请联系作者获得授权,非商业转载请注明出处!
Continuous effort - not strength or intelligence - is the key to unlocking our potential.
— Winston Churchill
本文是笔者阅读《俞甲子, 石凡, 潘爱民 - 程序员的自我修养_ 链接、装载与库-电子工业出版社 (2009)》的学习笔记~
如有错漏,欢迎指正~~
基本介绍
Windows下的DLL相当于Linux下的共享对象(so),它实际上和EXE是一个概念,都是PE格式文件,只是PE文件头中有一个符号位标记它是DLL还是EXE
Windows广泛使用了DLL,甚至内核中都大量使用了DLL
进程地址空间和内存管理
每个进程有自己单独的地址空间,而每个DLL在每个进程中都有一个副本
DLL中没有地址无关性(只是在某些情况下能被多个进程共享),它使用基址重置来加载DLL
基地址和相对地址RVA
起始地址就是基地址,偏移就是RVA
数据段
每个数据段都有一个单独的数据段,但是Windows允许数据段在多个进程内共享,只是不太安全。
常见的做法是一个私有数据段,一个公有数据段,需要共享的再放在公有数据段中。
导出符号
ELF默认导出所有函数,但是DLL默认不导出。因此如需导出,需要对需要导出的符号进行特殊定义。
- 使用__declspec(dllexport)修饰一个符号来说明它是要导出的
- 使用.def文件定义哪些符号需要导出
导出表
导出表是一个IMAGE_EXPORT_DIRECTORY类型的结构体:
最后三项分别指向三个数组:
- 导出地址表 EAT,存放各个导出函数的RVA
- 符号名表,保存导出函数的名称
- 名字序号对应表,用于表示函数名和函数序号的对应关系
什么是函数序号
这是个历史遗留问题。因为以前的内存很小,保存不了那么长的函数名,因此用函数序号代替。
为了向后兼容,现在仍然保存序号机制。一个DLL可以没有函数名,但是不能没有序号,甚至函数名是用来查找序号的。
但是只有序号的可维护性极低,因为一旦增减了函数,那么序号就会发生变化,已有的应用程序就会出现不兼容。因此在现在的条件下,为了那么一点内存放弃函数名只保留序号得不偿失。
EXP文件
(不是pwn里的exp~)
链接器会遍历两次DLL,第一次遍历会收集所有的导出符号信息并建立导出表,然后将这个表放在EXP文件的.edata段里面(EXP也是一个标准PE/COFF目标文件)
第二遍就把EXP文件和其他目标文件一起链接,获取.edata段的信息。但在最终的DLL中,一般不会保留.edata而是会将其合并到.rdata中
导出重定向
意思就是把其他DLL的符号重定向到本DLL的符号表中,但是导出表中保存的它的RVA指向的不是函数而是一个字符串,用来描述这个符号在哪个DLL,叫什么。
比如我们可以使用.def文件定义:
因此它在导出表中的RVA指向的字符串就是NTDLL.RtAllocHeap
导入符号
对导出的符号,如果我们想使用,我们需要显式地定义这个符号为导入符号:
常见的做法是把它放在一个头文件里面,连同库一起提供给使用者
在DLL制作者生成DLL时,如果使用静态加载,那么同时会生成一个用于描述DLL的导出符号的.lib静态库,它包含了可执行程序所需的导入符号和一部分桩代码(称为胶水代码)。
比如我们要生成一个Math.dll:
显式运行时链接(动态加载)
这时候就不一定需要.lib文件,也不一定需要__declspec(dllexport)来定义符号。但是,这样就无法使用GetProcAddress获取到导出符号,只能加载和卸载DLL(但是可以通过DLL注入等其他技巧获取到符号地址),因此,默认情况下,动态加载仍然需要使用__declspec(dllexport)或者.def文件。
使用WINAPI中的LoadLibrary、GetProcAddress、FreeLibrary来动态加载一个DLL,他们的功能类似于Linux glibc中的dlopen、dlsym、dlclose
导入表
导入表是一个IMAGE_IMPORT_DECRIPTOR类型的结构体:
最后一项FirstThunk指向导入地址数组,即IAT(对应EAT),它的元素在不同情况下有不同含义:
- 在重定位和符号解析之前,它表示相对应的导入符号的符号名或者序号(如果这个元素的最高位为1,那么剩下的位表示序号;反之指向一个IMAGE_IMPORT_BY_NAME结构体用以代表符号名)
- 当Windows完成链接后,它表示导入符号的真实地址
导入表还有一个成员,也就是它的第一项OriginalFirstThunk,指向导入名称表,即INT,它和IAT一模一样,但是它是用于DLL绑定的
延迟载入
类似于ELF中的延迟绑定,但是它是通过链接器添加的桩代码来实现的。
当某个外部符号被第一次调用时,桩代码就会调用GetProcAddress来查找地址,因此可以把它看作是静态加载和动态加载的杂交体。
导入函数的调用
模块内部的函数直接call它的地址,模块外的call [该函数在IAT中的项]。这种方法类似于GOT表。
DLL没有地址无关性,它使用基址重置和装载时重定位
DLL没有全局符号介入的问题(可以通过DLL名.函数名来定位一个函数)
为了确定函数是模块内的还是模块外的,就要用到__declspec(dllimport)
但是在__declspec关键字诞生以前,微软还有另一种处理方法,就是直接call xxx:
对外部函数来说,会call桩代码,然后桩代码再转到IAT中的相应项。编译器在产生导入表时,会对同一个函数x生成两个符号定义:
x
指向这个符号的桩代码
__imp__x
指向这个符号在IAT中的项
现在的MSVC同时支持两种方式,但是建议还是使用__declspec(dllimport),这样可以少一句指令的执行。
DLL优化
基址重置(重定基地址)
Windows的DLL没有地址无关性,它会在每次装载的时候重定位。
那如果准备加载的DLL的基址已经被占用了呢?答案是动态地更改加载的DLL基址,这就是基址重置。
比如DLL基地址的默认值为0x10000000,假设当b.dll装载的时候,发现0x10000000已经有一个a.dll占用了,那它就会把基址设为其他值。
确定了基地址就会对dll进行重定位,包括数据段中的绝对地址引用。
显而易见的,这样启动速度会很慢,但是运行速度比PIC快一点,因为不用通过GOT间接查找
指定dll基址
我们可以指定dll在装载的时候的初始基地址
editbin /REBASE:BASE:0x10020000 xxx.dll
导入函数绑定
由于dll不是PIC的,每次运行都是一样的,那么我们就可以提前进行符号解析。
我们可以提前进行符号解析,将导入函数绑定:
需要注意的是,如果依赖的dll更新(通过时间戳和校验和判断),那么仍然会重新进行符号解析
也就是说,导入函数绑定至少不会让程序更慢。
扫码阅读此文章
点击按钮复制分享信息
点击订阅