在介绍 PLT/GOT 之前,先以一个简单的例子引入,各位请看以下代码:

#include <stdio.h>

void print_banner(){
    printf("Welcome to World of PLT and GOT\n");
}

int main(void){
    print_banner();
    return 0;
}

经过编译与链接后,可执行文件中 print_banner 函数的汇编指令是怎样的呢?我猜应该与下面的汇编类似:

080483cc <print_banner>:
 80483cc:    push %ebp
 80483cd:    mov  %esp, %ebp
 80483cf:    sub  0x8,80483d2:sub0x8, %esp
 80483d2:    sub  0xc, %esp
 80483d5:    push 0x80484a880483da:call<printf函数的地址>80483df:add0x80484a8  
 80483da:    call <printf函数的地址>
 80483df:    add 0x10, %esp
 80483e2:    nop
 80483e3:    leave
 80483e4:    ret

print_banner 函数内调用了 printf 函数,而 printf 函数位于 glibc 动态库内,所以在编译链接阶段,链接器无法知知道进程运行起来之后 printf 函数的加载地址。故上述的 <printf函数的地址> 一项是无法填充的,只有进程运行后,printf 函数的地址才能确定。

在运行时如何进行重定位呢?一个简单的方法就是直接把 <printf函数的地址> 修改为这个函数真正的地址即可。

这个方案有以下两个缺点:

  • 现代操作系统不允许修改代码段,只能修改数据段
  • 如果 print_banner 函数是在一个动态库(.so 对象)内,修改了代码段,那么它就无法做到系统内所有进程共享同一个动态库。

因此,直接更改代码段是不可行的,我们应当通过更改数据段来实现我们的目标

运行时重定位无法修改代码段,只能将 printf 重定位到数据段。那在编译阶段就已生成好的 call 指令,怎么感知这个已重定位好的数据段内容呢?

答案是:链接器生成一段额外的小代码片段,通过这段代码支获取 printf 函数地址,并完成对它的调用

链接器生成额外的伪代码如下:

.text
...

// 调用printf的call指令
call printf_stub
...

printf_stub:
    mov rax, [printf函数的储存地址] // 获取printf重定位之后的地址
    jmp rax // 跳过去执行printf函数

.data
...
printf函数的储存地址:
  这里储存printf函数重定位后的地址

我们通过将 <printf函数的地址> 替代为 <printf_stub函数的地址>,然后插入一段 printf_stub 的代码。其中插入的代码表项我们称其为程序链接表(PLT,Procedure Link Table),位于代码段中,而存放函数地址的数据表项我们称其为全局偏移表(GOT, Global Offset Table),位于数据段中。

当确定了 printf 函数真正的地址之后,如何跳转过去呢?因为在运行过程中 printf 函数是一个外部函数,并没有位于我们进程当中的代码段中。答案是每一个进程都维护了一个共享库的内存映射区域,大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样的共享库的代码和数据的区域,这部分区域主要与链接过程相关。

为什么动态连接编译还要带上 so 文件?

假如说 Program1.cProgram2.c 两个文件都引用了动态链接库 Lib.so 中的文件,那么为什么在动态链接的时候还要指定上 Lib.so 呢?

因为虽然是动态链接,但是编译的时候总要告诉 Program,foobar 这个函数是动态链接吧。而 footbar 的符号信息就在 Lib.so 中,所以这里编译时还是要带上 Lib.so。这样,当编译器找不到 Program1.c, Program2.cfoo() 函数的实现时,再对比 Lib.so 中的符号表,那么就可以轻而易举地得出这个符号是需要动态链接的了。

gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so

Reference