ELF文件分析(六) 动态链接之基本设计
0x01 动态链接机制
静态链接的过程会将多个同类型的节进行合并。想象一下假设有1000个可执行文件的链接过程都涉及到一个静态库,如果我们采用静态链接,那么每一个可执行文件中都会有该静态库的节,进而产生大量的空间浪费。更糟糕的是,如果这个静态库要修改,那么意味着所有1000个可执行文件都要重新链接,这是非常恐怖的一件事。
在编程语言中如果我们遇到1000次类似的代码,我们会使用循环来处理,本质是复用循环内的代码,让每次循环都能共享这段代码。按照同样的思路,我们的动态链接就应运而生,通过复用和共享来进行空间节省和依赖分离。
note
计算机科学的重要思想:DRY(Don’t Repeat Yourself),即不要重复你自己。
有需求和实现目标了,现在最大的问题就是如何设计这样一套机制。
- 首先可执行文件需要记录需要什么样的信息来完成重定位
- 其次是动态链接库要设计成什么样才能用于加载和重定位
- 最后是动态链接如何加载和重定位
本节中主要分析前两个问题
0x02 可执行文件需要记录什么信息
为了方便演示,下面展示通过一个简单的例子一一说明,可以在我的仓库中找到相关的例子。下面是段的基本情况,可以对照查看。
1. 由谁来“组装”
这个部分涉及到之前程序头表中介绍的类型为PT_INTERP
的段,这个部分保存了动态链接器的路径。在例子中文件偏移是0x318。
2. 需要哪些“零件”
和上面一样有个特别的段来进行保存,其类型为PT_DYNAMIC
。开始与0x2DE8,大小为0x1F0。事实上,这个段只由一个.dynamic节组成,我们很快就会去解析它。.dynamic节的内容如下图所示:
3. 哪里需要”修补”
这个涉及到重定位表的功能,如果忘记重定位基本的概念可以回去看ELF文件分析(四) 重定位。文件中存在.rela.plt
和
.rela.dyn两个关键节,这两个节会被映射到内存之中供后续动态链接器进行解析。
0x03 解析.dynamic节
1 | # readelf解析 -d 动态链接库 |
.dynamic节本质上也是一个结构体数组,每个表项的情况如下:
1 | typedef struct { |
这个结构体的定义就可以看出其非常的灵活,d_tag决定存什么东西,而d_un决定这个东西的值是多少。动态链接需要有下面这些必备的d_tag。
info
动态链接器要完成任务,需要回答几个问题,.dynamic 节通过不同的 d_tag 条目为它提供了答案
- 哪些外部库?
- DT_NEEDED: 指明了依赖的共享库名称
- 哪里可以找到符号信息(函数名、变量名)?
- DT_SYMTAB: 指向动态符号表 .dynsym 的地址
- DT_STRTAB: 指向动态字符串表 .dynstr 的地址,用于存放符号名
- DT_GNU_HASH / DT_HASH: 指向哈希表,用于根据符号名快速在 .dynsym 中查找
- 哪些位置需要修改(重定位)?
- DT_RELA: 指向重定位表 .rela.dyn 或 .rela.plt 的地址
- DT_RELASZ: 重定位表的总大小
- DT_RELAENT: 每个重定位表项的大小
下面是.dynamic节解析的实例。
info
动态节(.dynstr .dynsym .hash等)可以用来重建部分节头表,主要因为这些是运行必需的信息,不会像节头表那样被strip掉
0x04 位置无关: 动态链接的基石
warning
下面部分的难度较大,可以结合下一节的实践内容一起理解。
为了实现共享,一个动态链接库必须满足几个条件:它本身是 ELF 文件、包含符号信息等。但最核心的一点是,它的代码必须是位置无关的 (PIC)。
1. 位置无关代码 (PIC)
共享库会被加载到内存的任意位置(地址随机化),那么它的代码如何正确地找到它需要的数据和函数呢?如果代码里写死了绝对地址,比如
mov rax, 0x401000,一旦加载地址变化,程序就会崩溃。位置无关代码(PIC)就是为了解决这个核心矛盾而设计的技术。
位置无关代码是一种机器码,它可以在内存中的任何位置正确执行,而无需修改。这是实现共享库的关键,因为同一个共享库在不同进程中可能被加载到不同的地址。如果代码依赖于绝对地址,共享库无法运行在不同的进程中。为了实现位置无关,编译必须解决下面两个问题:
- 如何访问库外部的全局变量和静态变量
- 如何调用库外部的函数
2. 全局偏移量表 (GOT)
对于访问外部全局变量,PIC 采用了一种优雅的间接寻址方案:在自己的数据段中创建一个名为全局偏移量表(GOT)的”地址簿“。这个地址簿本质上是一个指针数组,它的每一项,都将用来存放一个外部符号(全局变量或函数)的真实内存地址。
GOT表本身位于数据段,可读可写,当操作系统为每个进程创建数据段,每个进程也得到了自己的一份私有的、可修改的GOT表副本。
GOT表的设计,就是为了给位置无关代码提供一个固定的“地址簿”,代码本身通过相对寻址总能找到这个地址簿,而地址簿里的内容则由动态链接器在加载时负责填写。一般过程如下:
- 编译链接时:链接器在 .got节(位于数据段)为这个外部全局变量预留一个条目。然后,它生成一段PIC代码,这段代码通过 RIP-相对寻址找到GOT表中的这个条目。同时,链接器会创建一个重定位条目,记录下“这里需要填写某个全局变量的地址”。
- 加载时:动态链接器 在加载完所有模块后,开始处理重定位。它会找到那个全局变量的真实内存地址,然后将这个地址写入到为其预留的GOT条目中。
- 运行时:当PIC代码执行时,它会执行两步操作:
- 通过 RIP-相对寻址,读取GOT表中那个条目的内容。此时,内容已经是该变量的真实地址。
- 通过上一步获取到的真实地址,去访问那个全局变量。
3. 过程链接表 (PLT)
既然GOT表这么好用,我们能用同样的方式调用外部函数吗?比如,在加载时就把
printf 的真实地址填入 GOT,然后代码通过
call [GOT_entry_for_printf] 来调用?
技术上完全可行,但这样做有性能缺陷。 一个程序可能依赖了上百个外部函数,但在一次运行中可能只调用了其中几个。在启动时就把所有函数的地址都解析好,会严重拖慢程序的启动速度。为了优化这一点,引入了 延迟绑定 (Lazy Binding) 的思想。
过程链接表 (PLT)
就是实现延迟绑定的‘代码机关’。它位于代码段,是专门用来处理外部函数调用的。当代码中调用
printf 时,实际上是跳转到 PLT 中为 printf
准备的一小段桩代码(stub)。这段桩代码会与 GOT
表配合,检查函数地址是否已经解析。如果是第一次调用,它会触发动态链接器去寻找真实地址;如果不是,则直接跳转。这种精巧的机制我们将在下一篇文章中通过实践深入剖析。
4. PLT/GOT机制
GOT表和PLT表并不是共享库独有的,它们是解决“位置无关代码(PIC)”访问外部符号问题的通用解决方案。
| 文件类型 | 基地址 | 如何调用外部函数 | 如何访问自己的全局变量 | 如何访问外部全局变量 |
|---|---|---|---|---|
| 传统可执行文件 (non-PIE) | 固定的地址 (如 0x400000) |
PLT + GOT | 固定地址 或 RIP-相对寻址 | GOT |
| 现代可执行文件 (PIE) | 随机的 | PLT + GOT | RIP-相对寻址 | GOT |
| 共享库(.so) | 随机的 | PLT + GOT | RIP-相对寻址 | GOT |
0x05 小问题
关于动态链接
- 动态链接比静态链接好的地方,以及为什么在一些场景下会使用静态链接而非动态链接?
- gcc的什么选项可以生成动态链接库,什么选项可以生成位置无关代码