Linux动态链接_基础知识

Tuesday, March 14, 2023
本文共5350字
11分钟阅读时长

⚠️本文是作者P3troL1er原创,首发于https://peterliuzhi.top/principle/linux%E5%8A%A8%E6%80%81%E9%93%BE%E6%8E%A5_%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/。商业转载请联系作者获得授权,非商业转载请注明出处!

Honesty is the first chapter in the book of wisdom. — Thomas Jefferson

本文是笔者阅读《俞甲子, 石凡, 潘爱民 - 程序员的自我修养_ 链接、装载与库-电子工业出版社 (2009)》的学习笔记~

如有错漏,欢迎指正~~

动态链接的原因

  1. 节省空间。有些不同程序都会用到的共享的库不需要装载两次进内存
  2. 方便更新。发布者更新库后,只需要替换原本的库文件,使用者不需要重新编译程序
  3. 制作插件。程序可以有选择地动态链接某些共享库,决定使用哪些功能,不使用哪些功能。

共享对象在动态链接的时候装载在哪里

  1. 固定装载地址。手工规划可执行文件在虚拟空间的地址和共享对象在虚拟空间的地址,这种做法叫做静态共享库,现在很少用。
  2. 装载时重定位。相对于链接时的链接时重定位,这种重定位在装载时才进行重定位(Windows下又叫基址重置),产生共享对象时在gcc后面加上-shared启用。但是这种方法不能让共享对象在进程内共享,失去了节省空间的一大优势。
  3. 地址无关代码。让共享对象的代码与具体的虚拟地址无关,这样共享对象就能在进程间共享,产生共享对象时在gcc后面加上-shared -fPIC启用(默认情况下gcc为可执行程序的代码段生成地址无关代码(PIC代码))。

装载时重定位是共享对象主动改变自己的地址,而地址无关代码是用到共享对象的可执行文件主动去找共享对象。

地址无关代码

几种不同的调用

模块内部调用或跳转

直接进行相对地址调用(或基于寄存器相对调用)。这里可能会出现全局符号介入的问题。

模块内部数据访问

使用函数__i686.get_pc_thunk.cx函数将下一条指令的地址赋给ecx寄存器,然后对这条指令的地址加上一定的偏移就可以得到数据地址。

这么做的原因是数据访问必须需要绝对地址,然而地址无关代码难以确定绝对地址,因此通过代码段和数据段之间的偏移来计算得到绝对地址

模块间数据访问

使用GOT(Global Offset Table,全局偏移表)表来间接引用。具体来说,链接器会在装载的时候将变量的地址填充入GOT表中(它存放在数据段,可以被修改)。我们在编译的时候确定指令与GOT之间的偏移,这样就可以转化为模块内数据访问了。

特别的,数据的GOT表放在.got段

如何确定数据是在可执行文件的其他目标文件还是在共享对象中?

因为只有链接的时候编译器才会知道这件事的答案,而编译的时候就要确定这个数据是外部的还是内部的(因为需要建立GOT表等),所以ELF默认将这种不知道是外部的还是内部的全部看作外部的,这样最保险。

在一个模块中更改全局变量会不会影响源共享对象的全局变量

不会。因为共享对象的数据段部分在每个进程中都有独立的副本。

但同时也有方法实现多进程共享数据段,这种方法叫做"共享数据段",在Windows的DLL中就可以使用这种方法,但是这种方法极为危险,必须小心形式。

同时,一个进程可能有多个线程,每个线程能否有自己的数据段副本呢?答案是可以的,这种副本又叫做线程私有存储

为什么对数据的处理和对函数的处理不一样?

因为数据需要在不改变函数流程的情况下获取值(不然性能代价就太大了),而且也有更改值的需求

数据段的地址无关性

由于数据段可能有指针,所以会存在绝对地址引用,比如:

static int a;
static int *p = a

这里p就是一个绝对地址引用。

但是变量a的地址会随着共享对象的装载地址改变,那应该怎么办呢?

由于每一个进程都有一个数据段的副本,因此对数据段来说,可以尽情使用装载时重定位,而不用担心地址无关性的"初衷"——节省空间。

模块间调用、跳转

与模块间数据访问一样,将目标函数的地址放在GOT表中间接调用

特别的,函数的GOT表放在.got.plt段

延迟绑定(PLT)

如果我们在链接器进行动态链接的时候将动态库的每一个函数都放进GOT表,必然会减慢程序的启动速度。

比如,在程序运行的过程中,由于if语句等的分支控制,有一些函数可能不会用到,但是如果我们提前就将其装进了GOT表中,就平白造成了性能损耗。因此我们可以想一种办法让程序在调用到这个函数的时候再将函数调用地址放进GOT表。这对变量也是一样的。

除此之外,将加载模块外函数的时间分散到程序执行的过程中,给人的错觉也是"程序更快了"。

ELF借助PLT(Procedure Linkage Table,过程链接表)实现延迟链接。每个外部函数在PLT中都有一个项,而调用函数并不直接通过GOT跳转,而是通过PLT项来进行跳转。

比如我们有一个叫bar的外部函数,因此bar@plt的实现类似于:

文内图片

如果之前调用过bar函数,那么第一句的jmp会直接跳转到GOT表中保存的绝对地址处;如果没有调用过,那么bar@GOT处放的就是下一条指令push n的地址,也就是说,程序会跳转回来。

然后接下来的push n会将n入栈,这个n是bar这个符号在重定位表.rel.plt中的下标,这样我们就知道需要绑定的是哪个函数;然后又将模块的ID入栈,这样我们就知道使用的是哪个共享对象;最后调用_dl_runtime_resolve函数进行绑定,前面入栈的都是这个函数的参数。

而PLT的真正实现会稍微复杂一点。.got.plt的前三项有特殊的意义:

  • 第一项是.dynamic的地址
  • 第二项是link_map对象的地址
  • 第三项是_dl_runtime_resolve()的地址

而PLT的实现依赖他们。比如我们来看x64下的实现:

文内图片

然后我们看看.got.plt现在的值:

文内图片

可以看到,第二项和第三项在运行之前都是0,说明他们是在运行时才能确定的

而link_map结构体的定义如下(定义在/usr/include/link.h),由此可见,这就是ELF表示"模块ID"的一种方式:

文内图片

动态链接相关结构

因为靠可执行文件一个人肯定是完成不了动态链接这个艰巨的任务的,所以我们需要动态链接器来帮助我们执行一些动态链接相关的操作,再把控制权传给可执行程序。而动态链接器想要正常的工作,就势必需要一些信息,而这些信息需要保存在可执行文件中。

.interp 段

就是interpreter段,解释器段,定义了动态链接器的路径:

文内图片

就是一个字符串,这个ld-linux-x86-64.so.2是一个软链接,指向当前使用的动态链接器(这样当更新库的时候这个字符串不用更改就可以指向最新的动态链接器)

动态链接器在Linux下是glibc的一部分,属于系统库级别,往往版本号需要与glibc版本号一致

.dynamic段

保存了动态链接器所需要的基本信息,如:

  • 依赖于哪些共享对象
  • 动态链接符号表的位置
  • 动态链接重定位表的位置
  • 共享对象初始化代码的地址
  • ……

文内图片

通过这个段我们还能得到程序对共享库的依赖,可以利用ldd命令来查看:

文内图片

.dynsym段 动态符号表

类似于.symtab段,为了表示动态链接模块之间的符号导入导出关系,ELF使用动态符号表的段来保存这些信息(这个段往往叫做.dynsym),同时类似于.strtab保存符号名称,.dynstr用于保存动态符号字符串表

很多时候动态链接的模块同时拥有.dynsym和.symtab两个段,.symtab保存了所有符号,而.dynsym只保存了动态的

文内图片

文内图片

文内图片

.rel段 动态链接重定位表

一个共享对象需要重定向的原因是导入符号的存在。因为共享对象可能依赖于其他对象,因此从这些对象中导入的符号不管是静态链接的还是动态链接的最终都需要修正。

就算一个共享对象是PIC的,它的代码段不需要重定位,它的数据段也需要重定位,因为数据段可能存在绝对地址引用,更别说GOT表存在于数据段中了

因此,类似于静态链接中的.rel.data和.rel.text段,动态链接中也分别有.rel.dyn(数据段)和.rel.plt(代码段):

文内图片

为什么每个程序的libc地址都不一样?

因为操作系统将同一物理地址的libc(PTC的)映射到不同程序的虚拟空间上,由于每一个程序的虚拟空间都可能不同,所以libc的基址也可能不同

但是这并不意味着libc在这个进程上产生了一个副本,这只是操作系统的一种虚构的映射关系

为什么每一次运行程序,libc的基址都不一样?

因为现代操作系统为了提高系统的安全性,采用了ASLR(Address Space Layout Randomization)技术。ASLR技术会在每次程序加载到内存时,随机地选择一个基地址来加载程序的代码和数据段,这样可以使得攻击者很难确定程序的准确地址,从而减少了攻击的可能性。

注意,ASLR是操作系统级别的保护,而PIE依赖于编译器的优化

ASLR Executable PLT Heap Stack Shared libraries
0 × × × × ×
1 × × ×
2 × ×
2+PIE

动态链接的步骤和实现

步骤基本上分为三步:

  1. 启动动态链接器本身
  2. 装载所需要的共享对象
  3. 重定位和初始化

第一步——启动动态链接器本身

动态链接器使用一段精巧的代码处理自身的重定位工作,这段启动代码称为自举代码。

动态链接器的入口就是自举代码的入口,操作系统将控制权交给动态链接器后,自举代码就开始运行,首先它找到自己的GOT表,得到.dynamic的地址,然后就可以获得动态链接器本身的重定位表和符号表,从而进行自身的重定位

事实上动态链接器的自举代码会用到模块内部的函数以及全局变量等

文内图片

我们查看源代码会发现,自举代码确实调用了其他函数,也确实使用了全局变量

我们可以使用objdump看一下:

文内图片

文内图片

第二步——装载共享对象

完成基本自举后,动态链接器将可执行文件和动态链接器本身的符号表合并到一个符号表中,称之为全局符号表

然后动态链接器可以从可执行文件中.dynamic的DT_NEEDED类型的入口中得知可执行文件依赖的共享对象,然后(广度/深度)搜索共享对象并装载

全局符号介入

当一个符号需要被加入全局符号表中时,如果符号名已经存在,则后加入的符号被忽略。

因此我们可以通过LD_PRELOAD来覆盖本来会执行的函数

导致PIC共享对象出现的问题

如果PIC对象内部的符号在装载这个共享对象之前已经出现过了,那么它就会被忽略,那么这个对内部符号的调用就会出现问题。因此编译器只能把这些符号当作模块外部函数。

这样性能就会出现问题,所以我们可以人工地定义这个函数为static,这样这个内部函数就不会被覆盖,就会执行模块内部调用

第三步——重定位和初始化

重定位

加载共享对象后需要对数据段进行重定位(比如PLT/GOT表,绝对地址引用等)

初始化

如果共享对象中有.init段,动态链接器就会执行.init段中的代码进行初始化。

同样的,如果有.fini段,在退出进程时会执行.fini中的代码进行清理。

在做完这些工作之后,动态链接器就把控制权交给程序来执行了

Linux下动态链接器的实现

  1. 共享库和可执行文件没有什么区别,只要有一个程序入口,操作系统把控制权转交给这个入口,代码就能执行。Linux下和Windows下的动态链接器既是共享库也是可执行文件,可以通过一定的方式执行。
  2. Linux下的动态链接器本身是glibc的一部分,_start函数是它的入口,然后_start调用_dl_start进行自举
  3. 动态链接器获取操作系统转交的控制权后,执行一定的操作,再将控制权转交给可执行程序。
  4. 动态链接器本身是静态链接的,因为如果它是动态链接的,又谁来帮他处理这种依赖关系呢?
  5. 动态链接器一般是PIC的,但是不一定
  6. 动态链接器在装载入内存时操作系统才为它选择合适的装载地址

显示运行时链接(动态加载)

在Linux上和Windows上我们都可以通过一定的API实现显示运行时链接。

简而言之,就是在需要这个共享库的时候加载共享库,然后获取它的符号,不想要用的时候就将其卸载。

这些事情都要通过动态链接器实现,这就是全局符号表中要包含动态链接器的符号的原因。

这里介绍一下Linux下的API。

dlopen()

void * dlopen (const char *file, int mode)

加载一个库,这个库的搜索顺序如下:

  1. 搜索LD_LIBRARY_PATH中的路径
  2. 搜索/etc/ld.so.cache中的路径
  3. 搜索/lib/、usr/lib(这一步a.out反过来)

有意思的是,如果我们的file参数为0,函数将返回全局符号表的句柄,因此我们可以查找全局符号表中的任何一个符号并执行

flag参数指动态加载的方式,可以使用RTLD_LAZY表示延迟绑定,也可以使用RTLD_NOW表示立即导入,他们都可以通过或操作或上一个RTLD_GLOBAL来表示是否和全局符号表合并(建议使用立即导入以立即发现动态加载是否有问题)

如果有.init还会执行.init

dlsym()

void * dlsym (void *handle, const char *name)

handle是dlopen返回的句柄,name是符号的名字

  • 如果是函数则返回函数的地址
  • 如果是变量返回变量的地址
  • 如果是常量返回常量的值
  • 没找到返回NULL

dlsym()会以打开的共享对象为节点,广度优先搜索它依赖的所有对象,直到找到符号为止,这称为依赖序列。

如果常量的值刚好是NULL或者0呢?

使用dlerror()函数捕获错误

dlerror()

char * dlerror (void)

成功返回NULL,不成功返回错误信息

dlclose()

int dlclose (void *handle)

handle是dlopen返回的句柄。

当每次调用dlopen时,对这个模块的计数器加一;调用dlclose时减一。当减到0时就卸载这个模块。

注意,dlopen是先载入符号再调用.init,但是dlclose会先执行.fini中的代码再去除符号,有点类似于栈。

如有错漏,欢迎指正~~