CSAPP-简单理解链接

37 阅读12分钟

链接是将各种代码与数据片段收集并组合成一个单一文件的过程。

下面我们举个简单的例子:

void test();

int a = 9;
static int aa = 1;

int main() {
    int b = 10;
    test();
    return 0;
}

我们使用 gcc -c 选项将 main.c 编译成 main5.o 文件。接下来就由链接器来处理 main5.o 文件。

在继续之前,我们需要先了解一下符号相关的内容。

符号

符号分为多种类型,具体可以看 elf.c 文件下的定义:

#define STB_LOCAL 0  /* Local symbol */
#define STB_GLOBAL 1  /* Global symbol */
#define STB_WEAK 2  /* Weak symbol */
#define STB_NUM  3  /* Number of defined types.  */
#define STB_LOOS 10  /* Start of OS-specific */
#define STB_GNU_UNIQUE 10  /* Unique symbol.  */
#define STB_HIOS 12  /* End of OS-specific */
#define STB_LOPROC 13  /* Start of processor-specific */
#define STB_HIPROC 15  /* End of processor-specific */

我们暂时先主要学习两种,正所谓二八定义,掌握这两种已经能够搞定大部分知识了:全局符号(STB_GLOBAL) 与 本地符号(STB_LOCAL)。

在上面的代码中,我们声明了一个 test 函数,但是没有定义 test 函数,它是一个全局符号。同样的 main 也是一个全局符号。变量 a 也是一个全局符号,全局符号就是能够让其他模块引用的符号

一个函数与变量被 static 修饰后,它就成了一个本地符号,本地符号只能在模块内引用。变量 aa 是一个本地符号。

变量 b 是一个局部变量,不是一个符号,因为静态链接发生在编译阶段,它根本不关系局部变量,局部变量在栈上分配。

我们可以使用 readelf -s  命令来查看符号表里面的内容,确定一下(main5.o 是经过编译器编译后的 .o 文件):

可以看到,Bind 那一项里面,main 与 test 函数是 GLOBAL 的,a 也是GLOBAL 的,aa是 LOCAL 的。

从上图可以看到,一个符号除了Bind信息,还有Type信息。Type分为很多种:

#define STT_NOTYPE 0  /* Symbol type is unspecified */
#define STT_OBJECT 1  /* Symbol is a data object */
#define STT_FUNC 2  /* Symbol is a code object */
#define STT_SECTION 3  /* Symbol associated with a section */
#define STT_FILE 4  /* Symbol's name is file name */
#define STT_COMMON 5  /* Symbol is a common data object */
#define STT_TLS  6  /* Symbol is thread-local data object*/
#define STT_NUM  7  /* Number of defined types.  */
#define STT_LOOS 10  /* Start of OS-specific */
#define STT_GNU_IFUNC 10  /* Symbol is indirect code object */
#define STT_HIOS 12  /* End of OS-specific */
#define STT_LOPROC 13  /* Start of processor-specific */
#define STT_HIPROC 15  /* End of processor-specific */

根据二八定律,我们暂时只学习前三种。

STT_NOTYPE,什么叫做没有类型呢?其实就是该符号只声明却没有定义,上面的 test 函数就是这样。

STT_OBJECT,这个就是一个变量类型,变量符号的 Type 是 object。

STT_FUNC,这个就是一个函数类型,函数符号的 Type 是 func。

还有一个 ndx 信息,这个指的是该符号对应的内容会被分配到哪个section。

比如,变量aa,它的 ndx 是 3,而 3 指的是 .data,所以 aa 的值储存在 .data 区域。一般来说,数据都储存在 .data 与 .bss。

一个区域可以搞定,为啥要分为 .data 与 .bss 两个区域呢?

是因为优化,优化的本质就是提升性能的同时增加复杂度。.bss 储存的是 0 值,比如未初始化的静态变量等,会放到 .bss 区域。由于 .bss 储存的是 0 值,所以在 elf 文件里面就不用给他分配真正的空间,减少文件大小。

函数会被分配到 .text 区域,main 的 ndx 是 1。

test 未定义,所以分配到 UND 区域。

多重符号的解析

有了符号的知识之后,我们现在来看,链接器要做什么工作?

一般我们的工程会有多个源文件,每个源文件编译之后会生成对应的可重定位目标文件。而链接器的输入就是这些可重定位目标文件。每个可重定位目标文件里面都有很多符号,链接器需要根据下面的3个原则来处理他们:

  1. 不允许有多个同名的强符号。
  2. 如果有一个强符号和多个弱符号同名,选择强符号。
  3. 如果有多个弱符号同名,任意选择一个。

什么是强符号与弱符号?

只有全局符号才分强弱。本地符号是由编译器处理,还没到链接阶段。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。

从强符号的定义就可以看出来,链接器不会处理函数中的符号,那么显然是由编译器处理完了,从而可以得出编译器不支持函数嵌套。

理解上面的3个原则,需要举几个例子,先看原则一:

// main.c

int a = 9;

int main() {
    return 0;
}

// sub.c

int a = 10;

main.c 与 sub.c 里面都定义了一个a变量,a都是全局符号,且初始化过,所以他们都是强符号。编译 main.c 与 sub.c 时是没有问题的,但是链接就会报错:

再看原则二:

// main.c

int a = 9;

int main() {
    return 0;
}

// sub.c

int a;

与第一个例子不一样的地方在于, sub.c 里面只是声明了 a 变量,未定义,那么它就是一个弱符号。强符号与弱符号冲突,我们选择强符号。这个时候链接就不会出问题:

由于要生成可执行文件,还需要很多东西(比如调用 main 的 _start 函数)。这里只是简单链接,所以报了一个警告,忽略他。

再看原则三:

// main.c

int a;
void test();

int main() {
    a = 8;
    test();
    return 0;
}

// sub.c

int a;
void test();

main.c 与 sub.c 的 a 都是弱符号,test 也都是弱符号。在链接时,发现如下报错:

这是因为,没有使用到的符号会被编译器优化掉。符号一旦被使用到,那么在链接完成之后,就不能存在未定义的符号。所以 test 会报错。但是 a 它有一个特殊性,这种情况叫做 tentative,可以理解为量子定义。

it will have a value of 0 only if some other file doesn't explicitly give it a different value. The outcome depends on what else we link this file against.

也就是说,如果链接器发现其他的文件里面没有同名且值不为 0 的强符号,它会被赋值为默认值 0 ,最后放到 .bss 区域。

还有一个需要注意的地方,编译器在遇到 int a 这样的定义时,发现它未定义,会将它放到 common 区域,留给链接器来处理,链接器会决定 a 是进入 .bss 还是 .data。

写一个简单的静态链接器

只要理解了上面的链接原则,基本上就可以自己写一个简单的静态链接器了。其实代码也不多,因为 zhao-yang-min 大佬设计出来了 txt 格式的 elf 文件,处理起来很简单,也便于理解。

首先,我们看看标准的 elf 文件的大致格式:

这个格式框架非常的简单,Header 是 elf 文件的文件头,文件头里面描述了 Section Header Table 的位置,我们找到 Section Header Table,里面是一个一个的 Entry,每个 Entry 里面描述了对应的 Section 的一些信息,我们就又可以找到 Section 的内容。

基于这样的框架,我们设计出来的 text 格式的 elf 文件也差不多类似。

对于如下的源文件:

unsigned long long sum(unsigned long long *a, unsigned long long n);
unsigned long long array[2] = {0x123400000xabcd};
unsigned long long bias = 0xf00000000;
unsigned long long main()
{
    unsigned long long val = sum(array2);
    return val;
}

产生的 text 格式的 elf 文件如下:

// elf 文件的行数,不包括注释
25

// section header table 的行数
4

// section header table
// section 名,section adderess,section起始行,section占据的行数
.text,0x0,6,10
.data,0x0,16,3
.symtab,0x0,19,4
.rel.text,0x0,23,2

// .text ,就是函数的指令
push   %rbp
mov    %rsp,%rbp
sub    $0x10,%rsp
mov    $0x2,%esi
lea    0x0000000000000000(%rip),%rdi    // 14 <main+0x14>
callq  0x0000000000000000   // <main+0x19>, place holder for sum
mov    %rax,-0x8(%rbp)
mov    -0x8(%rbp),%rax
leaveq
retq

// .data 就是储存变量值
0x0000000012340000  // array[0]
0x000000000000abcd  // array[1]
0x0000000f00000000  // bias

// .symtab 符号表
// 符号名,bind,type,st_shndex,符号在section内的偏移行数,符号占据的行数
array,STB_GLOBAL,STT_OBJECT,.data,0,2
bias,STB_GLOBAL,STT_OBJECT,.data,2,1
main,STB_GLOBAL,STT_FUNC,.text,0,10
sum,STB_GLOBAL,STT_NOTYPE,SHN_UNDEF,0,0

// .rel.text 重定位信息
// r_row,r_col,type,sym,r_addend
4,7,R_X86_64_PC32,0,-4
5,7,R_X86_64_PLT32,3,-4

对于 sum.c 源文件:

unsigned long long bias; // global, object, common
// global, function, text
unsigned long long sum (unsigned long long *a, unsigned long long n)
{
    unsigned long long i, s = 0;
    for (i = 0; i < n; ++ i)
    {
        s += a[i];
    }
    return s + bias;
}

我们得到的 elf 文件如下:

// count of effective lines
30

// count of section header table lines
3

// begin of section header table
// sh_name,sh_addr,sh_offset,sh_size
.text,0x0,5,22
.symtab,0x0,27,2
.rel.text,0x0,29,1

// .text section
push   %rbp
mov    %rsp,%rbp
mov    %rdi,-0x18(%rbp)
mov    %rsi,-0x20(%rbp)
movq   $0x0,-0x8(%rbp)
movq   $0x0,-0x10(%rbp)
jmp    3// <sum+0x3d>
mov    -0x10(%rbp),%rax
lea    0x0(,%rax,8),%rdx
mov    -0x18(%rbp),%rax
add    %rdx,%rax
mov    (%rax),%rax
add    %rax,-0x8(%rbp)
addq   $0x1,-0x10(%rbp)
mov    -0x10(%rbp),%rax
cmp    -0x20(%rbp),%rax
jb     1// <sum+0x1e>
mov    0x0000000000000000(%rip),%rdx // referencing bias
mov    -0x8(%rbp),%rax
add    %rdx,%rax
pop    %rbp
retq

// .symtab
// st_name,bind,type,st_shndex,st_value,st_size
sum,STB_GLOBAL,STT_FUNC,.text,0,22
bias,STB_GLOBAL,STT_OBJECT,COMMON,8,8

// .rel.text
// r_row,r_col,type,sym,r_addend
17,7,R_X86_64_PC32,1,-4     // bias

我们先不管 elf 文件里面的其他信息,只看符号表:

无论是从源码,还是符号表中,都可以看到 main.c 里面使用了 sum.c 中定义的 sum 符号,sum.c 里面使用了 main.c 里面定义的 bias 符号。

这里出现了6个符号,但是实际上逻辑上只有4个符号,所以链接器需要搞定这个问题,根据前面所说的3原则,我们需要将这两个符号表进行合并,最终得到:

但是需要注意的是,并不能简单的进行合并,因为我们的 section 也是要合并的,比如对于两个文件的 .text,合并后变成了:

根据不同的链接顺序,这两个 .text 的上下位置会有变化。所以我们在合并符号表的时候需要处理 st_value 这个变量,也就是计算该符号在新的 section 中的偏移量。

重定位

到这里,静态链接器的实现就已经完成了百分之七八十了,剩下还有一个重定位。重定位信息放在了 .rel.text 和 .rel.data 中。由名可知,是代码与变量的重定位信息。

重定位要解决的问题就是,编译器在生成目标文件时,只是使用了一个占位符来代替了外部符号的引用,比如我们看一下 main.c 编译成 mian.o 后对于 sum 函数的引用:

0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 83 ec 10             sub    $0x10,%rsp
   8:   be 02 00 00 00          mov    $0x2,%esi
   d:   48 8300 00 00 00    lea    0x0(%rip),%rdi        # 14 <main+0x14>
  14:   e8 00 00 00 00          callq  19 <main+0x19>
  19:   48 89 45 f8             mov    %rax,-0x8(%rbp)
  1d:   48 845 f8             mov    -0x8(%rbp),%rax
  21:   c9                      leaveq 
  22:   c3                      retq

0xd地址处,使用 bias 变量时,是用的值是 0x0。

0x14地址处,调用 sum 函数的时候,call 指令后面跟得是 00 00 00 00。

也就是说对于这些符号的引用,都是没有使用真实地址的,这肯定是不对的,所以就需要在链接的时候计算真实地址,然后写到 .text 段与 .data 段中。

我们看看重定位项目内容:

  1. 前面的两项,指定的是需要重定位的位置,比如,第4行,第7列。
  2. R_X86_64_PC32 是重定位的方式。
  3. 0 表示的是符号表的第几项,比如,第0项是 bias,那么就是需要重定位到 bias 符号的真实位置。
  4. -4 是 addend。因为流水线的关系,pc 总是指向下一条指令,所以需要做一个减法。

那么这些重定位项,就描述了 .text 与 .data 的哪些位置需要重定位,以及重定位到哪个符号。

因为 R_X86_64_PC32 与 R_X86_64_PLT32 的处理方式基本是一样的,所以我们只介绍 R_X86_64_PC32 。使用这种方式计算出来的地址是与真实的运行地址无关的,所以也叫相对引用。

我们拿 sum 的重定位举例,假设.text中需要重定位的位置在 r_pos,由于 r_pos 与 sum 都在 .text 段,那么其实就可以计算出 r_pos 与 sum 之间的相对偏移,然后加上 addend 即可,计算出偏移之后,写入到 r_pos 位置。.data中的偏移也是类似的计算,由于 .data 与 .text 距离固定,所以相互引用的计算也差不多。

所以,最终,我们得到的 .text 如下:

可以看到,之前的 00 值,被替换成了一个相对偏移,pc 执行到这些位置,就会计算出符号引用的位置,从而执行正确的逻辑。

动态链接

动态链接会更蛋疼一些,所以我们只简单说一下,got 与 plt 就算了。

同样的,我们看一个简单的例子:

#include <stdio.h>

int main() {
    printf("%s\n""hello");
    return 0;
}

看看main函数的汇编代码:

000000000000063a <main>:
 63a:   55                      push   %rbp
 63b:   48 89 e5                mov    %rsp,%rbp
 63e:   48 83900 00 00    lea    0x9f(%rip),%rdi        # 6e4 <_IO_stdin_used+0x4>
 645:   e8 c6 fe ff ff          callq  510 <puts@plt>
 64a:   b8 00 00 00 00          mov    $0x0,%eax
 64f:   5d                      pop    %rbp
 650:   c3                      retq   
 651:   66 2e 0f 184 00 00    nopw   %cs:0x0(%rax,%rax,1)
 658:   00 00 00 
 65b:   0f 144 00 00          nopl   0x0(%rax,%rax,1)

0x645,发现 printf 函数被替换成了 puts 函数,这个现象比较奇怪,不过我们跳过。此函数的地址在 0x510 ,我们看看 0x510 是什么:

0000000000000510 <puts@plt>:
 510:   ff 25 ba 0a 20 00       jmpq   *0x200aba(%rip)        # 200fd0 <puts@GLIBC_2.2.5>
 516:   68 00 00 00 00          pushq  $0x0
 51b:   e9 e0 ff ff ff          jmpq   500 <.plt>

这个时候就会发现 0x510 地址不是 puts 函数的指令代码,而是一个跳转指令。

为什么要这样设计呢?就不能像重定位一样将 0x645 的位置直接改成 puts 函数的地址吗?

讨论这个问题之前,我们需要搞明白共享库的设计目的,有什么是静态链接搞不定的,非要搞一个动态链接?它的一个主要目的:允许多个正在运行的进程共享内存中相同的库代码。我们看一下程序运行时内存映像:

进程中的so区域是在 stack 区域与 heap 区域中间的,而且是通过 mmap的方式映射到当前进程中。多个进程共享一个共享库的代码段,但是每个进程有自己的读写数据块。

由于我们并不知道so会mmap到具体的哪个地址,所以我们需要设计一些额外的结构来储存对共享库的函数与变量的引用。

共享库的编译需要加入 -fpic 选项,这样会生成位置无关的代码。编译器在生成 so 文件的时候会做一些特殊处理。

比如,对全局变量进行引用,它会在 .data 段开始的地方创建一个叫做 got 的东西,如下图,是一个 共享库引用自身变量的一个例子:

在代码段里面,对于全局变量 addcnt 的引用会被编译器替换为 GOT[3] 的地址,而 GOT[3] 的地址里面储存的就是 addcnt 变量的真实地址。由于 .text 与 .data 的距离固定,所以这里比较好计算出偏移,这个偏移(0x2008b9)就是位置无关代码。

通过这种间接的方式,在加载 so 的时候,动态链接器会重定位GOT中的表项,使其包含正确的绝对地址。

那么 PLT 又是干啥的?当然还是一种优化。因为一个典型的应用只会使用其中很少的一部分。将函数地址的解析推迟到它实际被调用的地方,就能避免动态链接器在加载时进行成百上千个其实不需要的重定位。

所以对于函数的调用,编译器将 call 指令后面的地址,替换为了 plt 项目的地址。走到 plt 项目中后,比如上面的 puts 函数:

0000000000000510 <puts@plt>:
 510:   ff 25 ba 0a 20 00       jmpq   *0x200aba(%rip)        # 200fd0 <puts@GLIBC_2.2.5>
 516:   68 00 00 00 00          pushq  $0x0
 51b:   e9 e0 ff ff ff          jmpq   500 <.plt>

第一条指令,跳转到 plt 对应的 got 表项中:

[22] .got
       PROGBITS               PROGBITS         0000000000200fb8  0000000000000fb8  0
       0000000000000048 0000000000000008  0                 8
       [0000000000000003]: WRITE, ALLOC

这里可以看到,got 从 0x200fb8 开始,大小为 0x48,0x200fd0 为其中一个表项。

如果该函数没有调用过,表项里面储存的就是  puts@plt 的下一个指令的地址,也就是 0x516,然后就调用动态链接器定位 puts 函数真正的地址,找到后回填到 got 中。下次再调用这个函数的时候,got就不会跳转到 plt ,而是跳转到真正的函数地址执行了。

所以,plt 里面其实就是储存了很多模板指令,先跳转到 got,如果 got 又跳转回来了,后面就是调用动态链接器的指令,然后回填 got。

回到我们的问题,为啥不像重定位那样直接更改调用者的代码段。我个人觉得如果直接更改,就会出现多种处理方式,比如对共享库的调用是一种逻辑,共享库之间的调用又是一种逻辑,因为共享库的代码段是共享的,改了会影响很多地方,复制一份就又回到了原点。

而设计一个结构GOT/PLT就可以解决这些问题了。

Full RELRO

使用 GDB 调试生成的可执行文件时,想看一下 GOT 的表项的更改是否符合我们上面的分析。

源码如下:

#include <stdio.h>

int main() {
    printf("%s\n""hello");
    return 0;
}

发现了一个很奇怪的问题,就是当我使用下面的命令进行编译:

gcc main.c -o main7.o -fno-pie -no-pie

生成的可执行文件在动态链接的时候是符合预期的。

但是当我不添加选项时:

gcc main.c -o main8.o

生成的可执行文件,GOT 表项中 printf 函数的地址在main函数刚执行的时候,就已经是 printf 函数的真实地址了,这个与我们上面的分析完全不符合。查看了可执行文件的内容,发现它还是有 plt 表等内容,只不过是程序一运行起来,GOT 表项就填充了。

邮件请教了大佬后,得知原因是该可执行文件默认开启了 Full RELRO,意思就是GOT表只读,此时在程序运行的时候就不能再往里面写入其他地址了,可以一定程度上防止溢出覆盖GOT表函数地址的安全问题。但这也就意味着必须提前把外部函数的地址写入PLT表中,在printf@plt处下内存访问断点可以发现Full RELRO下的行为是在main函数执行之前已经进行了动态链接的操作。

有个工具叫Checksec,可以用来检测程序的安全机制启用情况。

参考文章

hujiekang.top/2022/08/12/…