程序员的自我修养阅读笔记-动态链接
前前后后看了俩遍了,但是总感觉过一会就会忘,于是决定重要的部分写个博客加深记忆。因为基本是以自己的话来解释,如果文章描述又不对的地方请读者斧正。长期更新,直到写完为止。
动态链接
为什么要动态链接
内存和磁盘空间
静态链接占用的内存太多了,十分浪费空间。因为我们知道,静态链接会在预编译的时候将#include后跟着的文件名对应的文件进行填充。假设所有库都是静态库,一个计算机的很多进程都会用到相同的库,如果100个进程都用这个静态库,那内存里就会有100个该静态库的副本(在对应进程里),实在是比较浪费空间。
程序开发和发布
如果更新的是静态链接的库,那整个项目都得重新链接一遍,这在项目代码量和复杂度很大的情况下是难以想象的,无论是时间还是性能要求方面。
动态链接
动态链接是运行时链接,假设a进程运行,运行时会根据依赖关系首先找对应的.o文件,随后加载到内存,如果所有依赖的文件都有才会进行链接的操作,和静态的链接基本相同。而如果另一个进程也用到了a进程所用到的动态链接库,不需要再生成一个对应的库的副本,因为该库已经加载到内存中,直接取用即可。程序开发同理,只需要下载更新的库替换旧库,运行时会调用,省去重新链接的时间
程序可扩展性和兼容性
动态链接可以用来开发插件,因为可以运行时动态加载各种程序模块
兼容性是因为可以作为一个操作系统和程序的中间层,消除不同平台的差异
动态链接的基本实现
linux的动态链接库又叫共享对象,以.so做扩展名,windows中动态链接库以.dll结尾
程序被装载的时候,动态链接器会将程序需要的所有动态链接库装载到进程的地址空间中,并将所有未决议的符号和对应的动态链接库进行绑定,之后再进行重定位
程序和动态链接库的链接过程是由动态链接器而非静态链接器ld完成的。动态链接实际上就是把链接的过程从装载前延后到了装载时。而因为是运行时链接,所以和静态链接已经提前链接完毕可以直接运行相比,启动速率肯定会慢一些,不过和内存的节省和更新和兼容性这些相比,简直不值一提。何况还有一个技术叫延迟绑定,就是用到的时候再绑定。
简单动态链接例子
流程图如下,可以看到lib.c文件首先通过编译,汇编后生成了一个lib.o文件,然后和c运行时库链接后生成了lib.so的共享对象文件。这相当于是前戏,然后我们进行编程,用program1.c经过正常编译生成.o文件,但是此时不会像正常的静态链接一样,将lib.o文件和program1.o文件链接到一起生成一个新的程序。lib.o文件并未链接进来,但是却使用到了共享对象lib.so。
首先,静态链接时,有些用到外部模块的符号是需要进行重定位的。此类符号在如果定义在静态目标模块,就是进行二步链接法。而如果是定义在共享对象的话,链接器会标记该符号为动态链接符号,先不进行重定位,然后装载的时候再进行重定位
而链接器如何知道该符号是哪个类型?Lib.so文件会保留所有的符号信息,而它会丢进链接器里面进行链接。我们都知道链接的第二步是符号解析和重定位,所以在符号解析的阶段链接器就会知道要重定位的符号在哪种模块。
动态链接程序运行时地址空间分布
静态链接的程序运行时只有本身需要进行映射到内存,而动态链接的程序不一样
可以看到,首先program1和libso都是正常的映射,但是进程内存空间里还有其他的文件,比如libc-2.6.1.so,是c语言运行库,还有ld-2.6.1.so,这个是动态链接器来的。由此可以看出动态链接器实际上是作为一个共享对象映射到对应的进程内存空间,系统运行program1前会先运行动态链接器,执行完毕后再把控制权给program1
地址无关代码
共享对象在编译时不能假设自己在进程虚拟空间内的位置。避免不同模块地址重叠与人工管理地址分配的繁琐。
装载时重定位
如果想要共享对象在任意位置加载,我们必须想好地址引用的问题。模块内相对地址不用管,因为共享对象映射是整体进行映射,代码间相对位置肯定不会变。但是绝对地址需要改变,如果装载地址是a, 那么装载后需要遍历所有重定位表,所有用到绝对地址的地方都要重定位,也就是加上a。
之前的在链接的第二阶段的重定位我们叫它链接时重定位,现在这个是装载时重定位,又叫基址重置,很好理解
但是这种方法会改变共享对象的代码,而我们知道一个共享对象可能会映射到很多进程中,而不同进程的映射后的base是不一样的,但是共享对象就一份,所以这种方法会引发错误,于是接下来请出ctf中最常见的地址无关代码PIC(其实最常见的是PIE)
地址无关代码
原理就是将装载时重定位需要的修改共享指令部分提取出来放到数据段里,因为不同进程数据段是不一样的,这样就能满足共享对象代码不改变,但是实现任意地址装载的目的。
共享模块的地址引用类型分为四种
- 模块内部的函数调用,跳转
- 模块内部的数据访问
- 模块外部的函数调用,跳转
- 模块外部的数据访问
模块内部调用与跳转
是最简单的。因为在一个模块里,所以相对位置是不变的,直接用相对地址就可以成功寻址,实现地址无关。不过其实有一种问题叫共享对象的全局符号引用,还没看到那里,后面再说
模块内部数据访问
我们知道程序中有着代码段和数据段,也是直接找相对地址就可以定位,不过中间相隔的页可能多了些罢了。不过数据的访问操作相对麻烦一些,因为数据访问没有相对pc的寻址,不过可以通过call函数然后进去就执行mov ecx,esp就可以将下一位指令的地址放到ecx中了,然后加偏移即可
模块间的数据访问
比模块内部的访问麻烦,因为我们知道动态链接时模块在装载的时候才被加载进来,而在那之后我们才能去确定实际地址。又因为上面所说的,运用动态链接的优势,代码是不能改了,可是数据段是可以改的,每个进程独自维护一份。这里linux用到了GOT(全局偏移表)(PWN手很熟悉了),是指向这些变量的指针数组。如果是全局变量,装载的时候会填充GOT的项,每一项对应着一个全局变量的地址。这个地址,和上一步的模块内部的数据访问的原理差不多,装载的时候计算指令和数据之间的偏移,然后计算出加载完毕后的真正的数据地址填充GOT。
访问的时候,程序先找到GOT表,然后在GOT中找对应的项,对应的项的值就是变量的地址
模块间调用与跳转
和数据访问差不多,只不过数据的地址换成了函数的地址
地址无关代码小结
-fpic和-fPIC
-fpic和fPIC功能相同,都是gcc的参数,指示gcc生成地址无关代码,不过pic生成的代码要小一些,可是pic生成的在某些平台会有类似于全局符号数目之类的限制,所以一般用fPIC
区分dso是否是PIC
1 | readelf -d foo.so | grep TEXTREL |
如果有输出就不是PIC,因为TEXTREL是代码段的重定位表,存在这种东西代表是静态链接那种,所以肯定不是PIC
PIC和PIE
so文件的地址无关叫PIC,可执行文件的地址无关文件叫PIE
共享模块的全局变量问题
上面所说的四种情况,没有包括一种,就是模块内部的全局变量引用
这种情况,编译的时候,编译器无法分辨这个global是本模块中定义的还是其他共享模块中定义的。就是无法分辨这个是模块内调用还是模块间调用。如果这是定义在可执行文件里,可执行文件没使用PIE,会用正常的mov操作来赋值到global的地址。因为可执行文件运行时并不会进行代码重定位,所以这个地址定位得在链接的时候进行。而这里是作为模块内的全局变量嘛,链接器会在创建可执行文件时,在bss段创一个global文件的副本。可是这个global文件在定义它的共享模块中已经有个副本了,一个变量存在于俩地址,这样肯定是不对的。
那么解决方法是什么呢?编译器一不做二不休,直接把所有使用到该变量的指令都指向可执行文件中的副本。共享库在编译的时候,默认定义在模块内部的全局变量都当成其他模块的全局变量.用GOT存储变量的地址,然后操作和之前的模块间数据访问差不多了。如果全局变量在可执行文件中有副本,got项就会指向该副本。如果在其他模块中初始化了,就会把初始化的值复制到主模块的对应副本的地址处.要是主模块里没定义,got项就指向当前模块的副本地址
如果c文件是共享对象的一部分,直接全部当跨模块方式生成代码.因为模块内和外没区别,如果是模块内的话,因为可能会被可执行文件引用,如果引用了指令就得指向可执行文件中的副本,所以还是都统一口径了
Q&A
下面的第一个问题如果这章好好学了应该是没问题的,可以检验一下()
数据段地址无关性
上面的操作保证了代码的地址无关,可是数据段也可能发生这种需要地址无关的情况。如上方的代码,p指向的是a的地址,是一个绝对地址。而装载时,变量a的地址会被改变。不过由于数据段是每个进程都有自己的副本,所以可以用到装载时重定位。如果共享对象的数据段中有绝对地址引用,编译器和链接器就会产生一个对应的重定位表R_386_RELATIVE,然后如果动态链接器装载该共享对象的时候发现有这种重定位表,就会对对应的位置的数据进行重定位
延迟绑定(PLT)
学pwn的入门的题目就需要用到got,plt这俩,现在学一下原理
实际上就是第一次用到啥其他模块的函数就加载对应的函数,没用到就先不加载。
此处的_dl_runtime_resolve函数是用来进行地址绑定工作的。这段的原理就是本来没有延迟绑定的话got最开始就被全部填满对应地址了,而有plt之后,会先访问plt,然后执行第一个jmp *(bar@got),如果是第一次访问的话这个bar@got就是下一个指令push n的地址,相当于jmp到下一个地址。然后会push进n和moduleID,n是bar这个符号在rel.plt重定位表的下标,moduleID是模块的id。然后调用地址绑定的函数将bar的地址绑定到got中。以后再调用的时候直接第一条jmp指令就执行bar了,不会走后面的流程。
elf把got拆成俩项,got存储了全局变量引用的地址,got.plt存的都是函数的引用地址。且got.plt前三项是特殊的。
- 第一项存储的是.dynamic的地址
- 第二项存储的是模块的id
- 第三项存储的是dl_runtime_resolve的地址
二三项好理解,一个模块对应的这俩是不会变的,直接用就可以了,动态链接器会在装载共享对象的时候进行初始化。第一个之后会说
之后每一项存的都是外部函数的引用了
plt和got.plt不是一个东西,注意
plt存的就是上面的图里的代码,不过最后两个指令只有第1项有,因为这俩在一个模块里是不会变的。节省空间
plt里面都是地址无关的代码,所以可以和代码段一样合并为可读可执行的一个segment
动态链接相关结构
装载的时候,动态静态链接的刚开始的流程是一样的,都是先读取可执行文件的文件头,检验合法性,随后读取可执行文件的program header然后获得segment的地址和长度和属性,随后映射到进程的虚拟空间的相关位置。不过静态链接的时候,映射完毕后操作系统直接把控制权给可执行文件,而动态链接时不行。因为这个时候只是映射完了可执行文件的内存,共享对象还没装载呢。
此时,操作系统会先把控制权交给动态链接器ld.so文件,这个也是个共享对象文件,也有入口点,然后用ld.so把其他的共享对象装载到内存里后,再把控制权转交给可执行文件
interp段
保存了当前机器的动态链接器的路径,大部分都是软链接。
dynamic段
动态链接elf的最重要的一个结构
1 | typedef struct{ |
可以看成是动态链接下的文件头,和静态的不一样的是,这里表示的值都是动态链接相关
动态符号表
保存的是动态连接的符号的信息,段名是dynsym,symtab保存的是所有符号的信息,包括dynsym的所有符号
相对的,静态链接需要辅助的表,比如保存符号名字的字符串表(符号表结构的第一个元素,st_name,对应的值是strtab的下标),动态链接同样有个字符串表叫做dynstr
动态链接重定位表
共享对象需要重定位,因为有可能有导入函数。而我们知道共享对象在运行时才装载,所以静态的重定位肯定不行了。
动态链接中,如果一个共享对象不是用PIC,那它肯定是装载时重定位的。如果是用PIC,它仍然需要重定位,因为虽然代码段地址无关,但是数据段里的got里存储的是符号的绝对地址,装载的时候基址改变肯定这里的绝对地址也得进行重定位。同样前面提到的static的那个指针存储的也是绝对地址,也需要进行重定位
动态链接重定位相关结构
动态链接的重定位表有.rel.dyn和.rel.plt,前者是对数据引用的修正,修正位置位于got和数据段,后者是对函数引用的修正,修正位置位于got.plt
重定位入口的类型和静态链接的也不一样。比静态的那俩简单,总共有R_386_RELATIVE R_386_GLOB_DAT R_386_JUMP_SLOT这三种
R_386_GLOB_DAT R_386_JUMP_SLOT这俩的意思是直接填入对应符号的地址,前者是对应got,后者是对应got.plt。以R_386_JUMP_SLOT为例,当动态链接器需要重定位的时候,它会先在全局符号表里找函数对应的地址,然后直接填到got.plt对应偏移的位置。
还有一种R_386_RELATIVE,实际上就是rebasing,基址重置。简单来说就是直接加上共享对象装载到内存时对应的地址作为偏移就行了。
动态链接的步骤和实现
动态链接分为三步:启动动态链接器本身,装载共享对象,然后重定位和初始化
动态链接器自举
动态链接器实际上也是一个共享对象,而动态链接器一般是把别的共享对象装载进内存,可是它首先得把自己装载到内存之中。注意,动态链接器不能依赖其他共享对象,而且动态链接器的全局和静态变量的重定位由它自己完成。第二个条件对应的代码,就叫做自举。
自举代码的逻辑,首先获得got,然后got的第一个入口保存了dynamic段的地址。上面提到,dynamic段相当于动态链接的文件头,存储了各种动态链接所需要的段的信息。程序通过这个dynamic段获得重定位表和符号表的地址,然后根据重定位表项找到所有要重定位的地方然后进行重定位,完成自举。
动态链接器的自举代码也不能用函数调用,因为前面说过,共享对象的PIC的内部模块的函数调用统一当成外部模块函数调用。
装载共享对象
动态链接器装载共享对象,是通过dynamic段里的DT_NEEDED的类型入口确定共享对象是否需要装载的。链接器会把所有要装载的共享对象列举出来然后放到一个装载集合中,随后取出一个,查看文件头和dynamic段,将代码段和数据段映射到内存中,然后查看是否dynamic段是否还依赖了其他的共享对象,有的话继续放到装载集合,直到所有的共享对象都被装载。整个装载的算法一般是广度优先的算法
全局符号引用的意思是,如果引用了两组共享对象,里面都有一个名字叫a的函数,实现的方式却不一样,最后可执行文件如果调用了这俩组共享对象,同时用了两组共享对象的a函数,最后a函数的内容是第一次被装载的有a函数的共享对象里的a的实现。后面装载的抛弃掉,先到先得。总结就是一个共享对象里的符号被另一个共享对象里的同名全局符号覆盖了。
重定位和初始化
linux内核执行execve的时候不关心elf是否可执行(文件头e_type是ET_EVEC还是ET_DYN),它只是简单的按照program header里的描述将其装载然后把控制权给它入口地址。如果有interp就是动态链接器的e_entry,没有就是elf的e_entry
Q&A
- 动态链接器是静态还是动态链接
静态,因为它不能依赖其他的共享对象
- 动态链接器必须是PIC吗
不一定,可以是可以不是,但是一般是PIC最好,一是因为节省内存,可以被多个进程使用。二是因为不用重定位代码段
- 装载地址是多少?
和一般的共享对象都是0,是无效的地址,装载时由内核选择合适的装载地址
显式运行时链接
运行时加载,可以自由的在程序运行时安装或卸载对应模块
动态库的加载用到四个动态链接器提供的api
dlopen
第一个参数是想打开的文件的地址,下面我不想记,但是还是放到这里
如果filename传入0,会返回全局符号表的句柄
第二个参数flag有两种,一种是RTLD_LAZY代表延迟绑定,RTLD_NOW代表模块被加载的时候就完成所有绑定工作,这俩必选一个
dlopen还会再加载模块的时候执行模块的初始化代码。动态链接器加载模块的时候会执行init段的代码,dlopen差不多,完成装载映射重定位后就会执行init段代码然后返回。
dlsym
第一个参数就是上面的dlopen的返回的句柄,第二个参数是想要获得的符号的字符串
如果symbol对应的是函数,返回函数地址,如果是变量返回变量地址,如果是常量返回常量的值
dlerror
很稀松平常的检验函数,来检测上面俩操作和dlclose操作是否正确执行
dlclose
卸载已经安装的模块。卸载时,先执行finit段的代码,然后把相应的符号从符号表里面去除,取消进程空间和模块映射关系,然后关闭模块文件
总结
把个人感觉重要的部分都用自己的理解说明并记录下来了,确实比看一遍理解更加深刻,毕竟是自己总结的东西,之后如果发现有错误会进行完善。