在介绍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, %esp
 80483d2:    sub  $0xc, %esp
 80483d5:    push $0x80484a8  
 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标准库和数学库这样的共享库的代码和数据的区域,这部分区域主要与链接过程相关。

Reference