本文发自 http://www.binss.me/blog/plt-and-got/,转载请注明出处。

定义

最近在做lab时,gdb中反汇编后经常出现xxx@plt的指令,这是什么意思呢?花了一个下午进行学习,以文记之。

我们知道,函数调用就是在压入参数后对函数地址call一下,而由于编译时的局部性,对于定义在其他文件中的函数,编译时编译器会留下一个坑,等到链接时链接器会对坑进行填坑。但是对于定义在动态链接库中的函数,比如glibc中的函数printf来说,显然此时是不知道函数地址的,需要等到运行时才能进行填坑。

但由于现代操作系统不允许对代码段进行修改(也不应该修改,考虑动态链接库函数调用动态链接库函数的情况),不能在运行时直接填坑,因此只能采取比较间接的方法。比如函数利用data段存储的坑进行跳转,而data段的坑在运行时进行填坑。但是我们又不想一开始就把所有的坑给填了(后文会提到),于是采取了比较间接的方式:

先让坑指向一段同样位于代码段的代码,这段代码判断坑是否被填,如果没填,填之;如果填了,直接调用函数。

这段代码段被定义为过程连接表(Procedure Linkage Table,PLT),而data段中的坑被定义为全局偏移表(Global Offset Table, GOT)。

实验

对于以下代码:

#include <stdio.h>

int main()
{
    printf("hello");
    printf("hello again");
    return 0;
}

编译后,用objdump查看:

0000000000400526 <main>:
  400526:   55                      push   %rbp
  400527:   48 89 e5                mov    %rsp,%rbp
  40052a:   bf d4 05 40 00          mov    $0x4005d4,%edi
  40052f:   b8 00 00 00 00          mov    $0x0,%eax
  400534:   e8 c7 fe ff ff          callq  400400 <printf@plt>
  400539:   bf da 05 40 00          mov    $0x4005da,%edi
  40053e:   b8 00 00 00 00          mov    $0x0,%eax
  400543:   e8 b8 fe ff ff          callq  400400 <printf@plt>
  400548:   b8 00 00 00 00          mov    $0x0,%eax
  40054d:   5d                      pop    %rbp
  40054e:   c3                      retq
  40054f:   90                      nop

链接器发现printf定义在动态库中,于是将printf函数的位置设置为地址为0x400400的printf@plt

对于PLT表段,内容如下:

Disassembly of section .plt:

00000000004003f0 <printf@plt-0x10>:
  4003f0:   ff 35 12 0c 20 00       pushq  0x200c12(%rip)        # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
  4003f6:   ff 25 14 0c 20 00       jmpq   *0x200c14(%rip)        # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
  4003fc:   0f 1f 40 00             nopl   0x0(%rax)

0000000000400400 <printf@plt>:
  400400:   ff 25 12 0c 20 00       jmpq   *0x200c12(%rip)        # 601018 <_GLOBAL_OFFSET_TABLE_+0x18>
  400406:   68 00 00 00 00          pushq  $0x0
  40040b:   e9 e0 ff ff ff          jmpq   4003f0 <_init+0x28>

0000000000400410 <__libc_start_main@plt>:
  400410:   ff 25 0a 0c 20 00       jmpq   *0x200c0a(%rip)        # 601020 <_GLOBAL_OFFSET_TABLE_+0x20>
  400416:   68 01 00 00 00          pushq  $0x1
  40041b:   e9 d0 ff ff ff          jmpq   4003f0 <_init+0x28>

Disassembly of section .plt.got:

0000000000400420 <.plt.got>:
  400420:   ff 25 d2 0b 20 00       jmpq   *0x200bd2(%rip)        # 600ff8 <_DYNAMIC+0x1d0>
  400426:   66 90                   xchg   %ax,%ax

printf@plt有两个跳转,一个使用0x601018中存的数据,一个跳转到printf@plt-0x10,是什么意思呢?

根据maps信息:

binss@giantvm:~/work/test$ cat /proc/5119/maps
00400000-00401000 r-xp 00000000 fc:00 25175545                           /home/binss/work/test/plt
00600000-00601000 r--p 00000000 fc:00 25175545                           /home/binss/work/test/plt
00601000-00602000 rw-p 00001000 fc:00 25175545                           /home/binss/work/test/plt
00602000-00623000 rw-p 00000000 00:00 0                                  [heap]
7ffff7a0e000-7ffff7bcd000 r-xp 00000000 fc:00 18747735                   /lib/x86_64-linux-gnu/libc-2.23.so
7ffff7bcd000-7ffff7dcd000 ---p 001bf000 fc:00 18747735                   /lib/x86_64-linux-gnu/libc-2.23.so
7ffff7dcd000-7ffff7dd1000 r--p 001bf000 fc:00 18747735                   /lib/x86_64-linux-gnu/libc-2.23.so
7ffff7dd1000-7ffff7dd3000 rw-p 001c3000 fc:00 18747735                   /lib/x86_64-linux-gnu/libc-2.23.so
7ffff7dd3000-7ffff7dd7000 rw-p 00000000 00:00 0
7ffff7dd7000-7ffff7dfd000 r-xp 00000000 fc:00 18747736                   /lib/x86_64-linux-gnu/ld-2.23.so
7ffff7fe4000-7ffff7fe7000 rw-p 00000000 00:00 0
7ffff7ff6000-7ffff7ff8000 rw-p 00000000 00:00 0
7ffff7ff8000-7ffff7ffa000 r--p 00000000 00:00 0                          [vvar]
7ffff7ffa000-7ffff7ffc000 r-xp 00000000 00:00 0                          [vdso]
7ffff7ffc000-7ffff7ffd000 r--p 00025000 fc:00 18747736                   /lib/x86_64-linux-gnu/ld-2.23.so
7ffff7ffd000-7ffff7ffe000 rw-p 00026000 fc:00 18747736                   /lib/x86_64-linux-gnu/ld-2.23.so
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]

可见0x601018处于data段中,根据objdump的注释信息,我们得知它是GOT表的表项。

我们采用gdb大法,在第一个printf处断点,看看0x601018里面是啥:

(gdb) x 0x601018
0x601018:   0x00400406

可见指向了0x00400406,即0x400400跳转到了它的下一条语句,于是会执行第二个跳转,跳转到printf@plt-0x10。

这是一个典型的函数调用过程,先压入参数,再跳转到函数地址执行,这个地址同样在GOT中,我们看看0x601010是啥:

(gdb) x 0x601010
0x601010:   0xf7dee6a0
(gdb) disas 0xf7dee6a0
No function contains specified address.

然而我们直接这个地址进行反汇编时发现找不到内容?根据上面的maps信息,猜想此时已经进入glibc中,也就是7ffff7a0e000开始的代码段中了。于是我们尝试魔改地址为0x7ffff7dee6a0,再次disas,得到了位于_dl_runtime_resolve_avx函数内一大串汇编代码,对于这种代码,我选择google。这个函数做的就是查找printf函数在动态链接库中的地址,然后将该地址回填到0x601018中,然后调用printf。

这时问题来了,_dl_runtime_resolve_avx是如何得知要向哪里填充什么函数的呢?答案是根据elf文件的relocation信息:

binss@giantvm:~/work/test$ readelf -r plt
...
Relocation section '.rela.plt' at offset 0x398 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000601018  000100000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000601020  000200000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0

这些信息告诉_dl_runtime_resolve_avx,对于参数为0x0的调用,它要填坑的函数rela.plt段中第一个记录的函数,即printf,填到0x601018中。对于0x1的调用,要填坑的函数就是是__libc_start_main。

在执行完该行代码后,再次查看0x601018:

(gdb) n
6       printf("hello again");
(gdb) x 0x601018
0x601018:   0xf7a63800
(gdb) disas 0x7ffff7a63800
Dump of assembler code for function __printf:
   0x00007ffff7a63800 <+0>: sub    $0xd8,%rsp
...

它已经指向了填坑后的glibc中printf的地址。

进一步试验

在前面的实验中,我们基于这样一个猜想:参数的值和在rela.plt中的偏移量是一一对应的,即参数为多少就对应第几条记录。为了验证这个问题,我们再搞两个调用进行实验:

#include <stdio.h>
#include <stdlib.h>
#include <math.h>

int main()
{
    int random = rand();
    int random_sqrt = sqrt(random);
    printf("%d", random);
    printf("%d", random_sqrt);
    return 0;
}

PLT段:

Disassembly of section .plt:

0000000000400520 <printf@plt-0x10>:
  400520:   ff 35 e2 0a 20 00       pushq  0x200ae2(%rip)        # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
  400526:   ff 25 e4 0a 20 00       jmpq   *0x200ae4(%rip)        # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
  40052c:   0f 1f 40 00             nopl   0x0(%rax)

0000000000400530 <printf@plt>:
  400530:   ff 25 e2 0a 20 00       jmpq   *0x200ae2(%rip)        # 601018 <_GLOBAL_OFFSET_TABLE_+0x18>
  400536:   68 00 00 00 00          pushq  $0x0
  40053b:   e9 e0 ff ff ff          jmpq   400520 <_init+0x28>

0000000000400540 <__libc_start_main@plt>:
  400540:   ff 25 da 0a 20 00       jmpq   *0x200ada(%rip)        # 601020 <_GLOBAL_OFFSET_TABLE_+0x20>
  400546:   68 01 00 00 00          pushq  $0x1
  40054b:   e9 d0 ff ff ff          jmpq   400520 <_init+0x28>

0000000000400550 <sqrt@plt>:
  400550:   ff 25 d2 0a 20 00       jmpq   *0x200ad2(%rip)        # 601028 <_GLOBAL_OFFSET_TABLE_+0x28>
  400556:   68 02 00 00 00          pushq  $0x2
  40055b:   e9 c0 ff ff ff          jmpq   400520 <_init+0x28>

0000000000400560 <rand@plt>:
  400560:   ff 25 ca 0a 20 00       jmpq   *0x200aca(%rip)        # 601030 <_GLOBAL_OFFSET_TABLE_+0x30>
  400566:   68 03 00 00 00          pushq  $0x3
  40056b:   e9 b0 ff ff ff          jmpq   400520 <_init+0x28>

Disassembly of section .plt.got:

0000000000400570 <.plt.got>:
  400570:   ff 25 82 0a 20 00       jmpq   *0x200a82(%rip)        # 600ff8 <_DYNAMIC+0x1e0>
  400576:   66 90                   xchg   %ax,%ax

relocation段:

binss@giantvm:~/work/test$ readelf -r plt2
...
Relocation section '.rela.plt' at offset 0x498 contains 4 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000601018  000200000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000601020  000300000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
000000601028  000700000007 R_X86_64_JUMP_SLO 0000000000000000 sqrt@GLIBC_2.2.5 + 0
000000601030  000800000007 R_X86_64_JUMP_SLO 0000000000000000 rand@GLIBC_2.2.5 + 0

发现0x2对应sqrt,0x3对应rand。这验证了我们的猜想。

另外对该程序进行gdb,断点main函数进行查看,发现0x601018、0x601028、0x601030都存放了0x00400566,即它们在第一次执行的时候,都跳转到了printf@plt-0x10进行执行。

这里问题又来了,为啥不直接通过pushq 0x200ae2(%rip)然后jmpq *0x200ae4(%rip)直接跳转到_dl_runtime_resolve_avx中运行呢?对此我的猜想是,程序通常大量调用动态链接库中的函数,如果每个调用都重复这两行,会导致plt表膨胀(虽然也没膨胀多少),而且不优雅,于是抽出来作为一个公共函数。这个公共函数只有两条指令,而其他函数都有三条指令,不舒服,因此再补个nopl占位。这样一来,这些函数就对齐了,我们可以认为每三个指令就是PLT的一个表项。

另外我们还观察到一个现象:0x601010中的内容,也就是_dl_runtime_resolve_avx的地址,在main函数的第一条指令执行之前就已经填充了。可以认为,在程序开始执行之前,首先要有一种外部机制(动态链接器)根据rela.plt将这个坑给填了,这样后续的函数才能调用该函数进行填坑。至于为什么动态链接器为啥不顺便把后面的坑都填了,考虑这种情况:

#include <stdio.h>
#include <stdlib.h>
#include <math.h>

int main()
{
    int ok;
    scanf("%d", &ok);
    if (ok)
    {
        int random = rand();
        int random_sqrt = sqrt(random);
        printf("%d", random);
        printf("%d", random_sqrt);
    }
    return 0;
}

如果用户输入0,那么程序直接返回,坑也就不用填了,这种就是所谓的lazy策略吧。

总结

PLT是位于text段的一张表,每个表项由三行代码组成,总长度为0x10,有多少个动态链接的函数就有多少个表项。

GOT是位于data段的一张表,每个表项用于存放动态链接函数的地址。在填坑前存放的是PLT表项第二条指令的地址。

当动态链接的函数第一次被调用时,跳转到对应的PLT表项,在第一个指令中根据对应GOT表项进行跳转,由于此时未填坑,跳转到PLT表项的第二条指令,压入参数,在第三个指令中跳转到printf@plt-0x10,由它负责调用_dl_runtime_resolve_avx进行填坑并执行一次调用。

于是在后续调用时,跳转到对应的PLT表项,在第一个指令中根据对应GOT表项进行跳转,由于此时已填坑,跳转到动态链接库的对应函数中进行执行。