ELF文件分析(六) 动态链接之基本设计

0x01 动态链接机制

静态链接的过程会将多个同类型的节进行合并。想象一下假设有1000个可执行文件的链接过程都涉及到一个静态库,如果我们采用静态链接,那么每一个可执行文件中都会有该静态库的节,进而产生大量的空间浪费。更糟糕的是,如果这个静态库要修改,那么意味着所有1000个可执行文件都要重新链接,这是非常恐怖的一件事。

在编程语言中如果我们遇到1000次类似的代码,我们会使用循环来处理,本质是复用循环内的代码,让每次循环都能共享这段代码。按照同样的思路,我们的动态链接就应运而生,通过复用和共享来进行空间节省和依赖分离。

note

计算机科学的重要思想:DRY(Don’t Repeat Yourself),即不要重复你自己。

有需求和实现目标了,现在最大的问题就是如何设计这样一套机制。

  • 首先可执行文件需要记录需要什么样的信息来完成重定位
  • 其次是动态链接库要设计成什么样才能用于加载和重定位
  • 最后是动态链接如何加载和重定位

本节中主要分析前两个问题

0x02 可执行文件需要记录什么信息

为了方便演示,下面展示通过一个简单的例子一一说明,可以在我的仓库中找到相关的例子。下面是段的基本情况,可以对照查看。

elf-6-1

1. 由谁来“组装”

这个部分涉及到之前程序头表中介绍的类型为PT_INTERP 的段,这个部分保存了动态链接器的路径。在例子中文件偏移是0x318。

elf-6-2

2. 需要哪些“零件”

和上面一样有个特别的段来进行保存,其类型为PT_DYNAMIC 。开始与0x2DE8,大小为0x1F0。事实上,这个段只由一个.dynamic节组成,我们很快就会去解析它。.dynamic节的内容如下图所示:

elf-6-3

3. 哪里需要”修补”

这个涉及到重定位表的功能,如果忘记重定位基本的概念可以回去看ELF文件分析(四) 重定位。文件中存在.rela.plt.rela.dyn两个关键节,这两个节会被映射到内存之中供后续动态链接器进行解析。

elf-6-4

0x03 解析.dynamic节

1
2
# readelf解析 -d 动态链接库
readelf -d ELF-file

.dynamic节本质上也是一个结构体数组,每个表项的情况如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct {
Elf32_Sword d_tag; // d_un对应的类型和含义
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un; // 由d_tag决定
} Elf32_Dyn;
extern Elf32_Dyn _DYNAMIC[];

typedef struct {
Elf64_Sxword d_tag;
union {
Elf64_Xword d_val;
Elf64_Addr d_ptr;
} d_un;
} Elf64_Dyn;
extern Elf64_Dyn _DYNAMIC[];

这个结构体的定义就可以看出其非常的灵活,d_tag决定存什么东西,而d_un决定这个东西的值是多少。动态链接需要有下面这些必备的d_tag。

info

动态链接器要完成任务,需要回答几个问题,.dynamic 节通过不同的 d_tag 条目为它提供了答案

  1. 哪些外部库?
  • DT_NEEDED: 指明了依赖的共享库名称
  1. 哪里可以找到符号信息(函数名、变量名)?
  • DT_SYMTAB: 指向动态符号表 .dynsym 的地址
  • DT_STRTAB: 指向动态字符串表 .dynstr 的地址,用于存放符号名
  • DT_GNU_HASH / DT_HASH: 指向哈希表,用于根据符号名快速在 .dynsym 中查找
  1. 哪些位置需要修改(重定位)?
  • DT_RELA: 指向重定位表 .rela.dyn 或 .rela.plt 的地址
  • DT_RELASZ: 重定位表的总大小
  • DT_RELAENT: 每个重定位表项的大小

下面是.dynamic节解析的实例。

elf-6-5

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的什么选项可以生成动态链接库,什么选项可以生成位置无关代码