计算机原理系列之八 ——– 可执行文件的PLT和GOT 帮助你深刻理解libc库函数调用的细节(动态库)

计算机原理系列之八 ——– 可执行文件的PLT和GOT

 上篇文章我们提到,为了保证代码复用和节省计算机资源,在链接时,动态链接库的代码段和数据段等是不会被复制到最终生成的可执行文件中的,这些部分会在程序加载的时候复制到内存,并做动态链接,使原来可执行文件能够对其中定义的符号正常引用。也就是说在这个时候,可执行文件代码段中对动态链接库包含的符号引用的地址才真正确定下来。但是我们查看各个segment的属性可以知道,.text segment是只读的,也就是说在编译成可以执行文件之后,就不能被修改了,那么如何确保它能够正确的引用在加载时才能确定下来的动态链接库里的符号呢?这就需要我们这篇文章里的GOT和PLT作为跳板来实现了。

一、什么是PLT和GOT

 GOT全称Global Offset Table,即全局偏移量表。它在可执行文件中是一个单独的section,位于.data section的前面。每个被目标模块引用的全局符号(函数或者变量)都对应于GOT中一个8字节的条目。编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含正确的目标地址。

 PLT全称Procedure Linkage Table,即过程链接表。它在可执行文件中也是一个单独的section,位于.textsection的前面。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目实际上都是一小段可执行的代码。

二、PLT和GOT里面有什么

2.1 PLT和GOT结构及关系

 下面我们依然以如何编译目标文件中的hello.c为例来研究GOT和PLT。在这个程序中我们使用到了库函数中的printf函数。

1  #include <stdio.h>
2
3  int main()
4  {
5          printf("hello, world\n");
6          return 0;
7  }

 

 首先,我们从section角度查看相关的结构。使用命令readelf -S hello,得到:

  [12] .plt              PROGBITS         00000000004003f0  000003f0
       0000000000000030  0000000000000010  AX       0     0     16  #有3个条目,起始为4003f0
  [13] .plt.got          PROGBITS         0000000000400420  00000420
       0000000000000008  0000000000000000  AX       0     0     8
  [14] .text             PROGBITS         0000000000400430  00000430
       0000000000000182  0000000000000000  AX       0     0     16

    ...

  [23] .got              PROGBITS         0000000000600ff8  00000ff8
       0000000000000008  0000000000000008  WA       0     0     8
  [24] .got.plt          PROGBITS         0000000000601000  00001000
       0000000000000028  0000000000000008  WA       0     0     8
  [25] .data             PROGBITS         0000000000601028  00001028
       0000000000000010  0000000000000000  WA       0     0     8

 上述内容可知:
1. .plt起始于0x4003f0,每个条目大小为0x10(16个字节),共3个条目
2. .plt.got起始于0x400420,大小为0x8(8个字节);
3. .got起始于0x600ff8,每个条目大小为0x8(8个字节),共1个条目;
4. .got.plt起始于0x601000,每个条目大小为0x8(8个字节),共5个条目;

然后我们使用命令objdump -d hello,得到以下内容:

Disassembly of section .plt:

00000000004003f0 <puts@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 <puts@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

 结合上面的分析,可以知道:
1. .plt里存放了三个条目分别是puts@plt-0x10, puts@plt, __libc_start_main@plt这三个函数的plt;
2. .plt第一个条目将0x601008里的内容压栈,然后跳转到了0x601010,而0x601010 = 0x601000 + 2 * 0x8,也就是说跳转到了.got.plt的第3个条目(GOT是一个数组,因此下标从0开始计数,第三个条目就是GOT[2])
3. .plt第二个条目是printf函数的plt。它的第一条指令表示跳转到到0x601018,也就是.got.plt的第4个条目(GOT[3])。第二条指令将0压栈,第三条指令跳转到.plt第一个条目继续执行;
4. .plt第三个条目是__libc_start_main函数的plt。它的第一条指令表示跳转到到0x601020,也就是.got.plt的第5个条目(GOT[4])。第二条指令将0压栈,第三条指令也跳转到.plt第一个条目继续执行;
5. .plt.got里面的指令时跳转到0x600ff8,也就是.got的第一个条目(GOT[0]);

直观一点如下图所示:

got_plt

注:图中省略了其他各个section。

2.2 使用debugger工具演绎PLT和GOT的使用

 下面我们使用GDB工具来详细分析printf函数调用过程。

2.2.1 编译

 如果想要使用GDB调试程序,需要在编译时添加-g参数。命令如下:

gcc -g -o hello hello.c

2.2.2 printf函数的调用

 在调试前,我们先回顾一下之前关于printf函数的相关代码变化:
1. 在编译过程分析中,我们看到调用printf函数被编译成了汇编指令e8 00 00 00 00 callq e ,其中,e是一个占位符,它所在的位置就应该printf函数的地址。

  1. 类似于链接过程分析中的方法(objdump -d hello),我们发现在最终的可执行文件中,调用printf函数的指令变成了e8 cc fe ff ff callq 400400 <puts@plt>,printf函数的地址表示成了0x400400,查看对应位置的代码(如下代码),发现这里属于.plt section,似乎并非是printf函数真正的实现。

2.2.3 进入printf的PLT

 使用GDB运行可执行程序hello

$ gdb hello
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1
Copyright (C) 2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from hello...done.
(gdb) 

查看main函数的反汇编指令:

(gdb) disassemble main
Dump of assembler code for function main:
   0x0000000000400526 <+0>:     push   %rbp
   0x0000000000400527 <+1>:     mov    %rsp,%rbp
   0x000000000040052a <+4>:     mov    $0x4005c4,%edi
   0x000000000040052f <+9>:     callq  0x400400 <puts@plt>
   0x0000000000400534 <+14>:    mov    $0x0,%eax
   0x0000000000400539 <+19>:    pop    %rbp
   0x000000000040053a <+20>:    retq
End of assembler dump.

当执行到printf函数时,代码会跳转到0x400400处。在0x400400处设置断点,然后运行程序:

(gdb) b *main-294
Breakpoint 1 at 0x400400
(gdb) r
Starting program: /home/xxxxxx/sync/csapp/hello/hello

Breakpoint 1, 0x0000000000400400 in puts@plt ()

跳转到0x400400处, 根据puts@plt的反汇编代码,将会跳转到0x601018(.GOT.PLT[3])续执行。使用GDB查看它里面存放的内容:

(gdb) x/2xw 0x601018
0x601018:       0x00400406      0x00000000

由此可见,下一步将执行的是0x00400406处的指令,其实就是顺序执行puts@plt的代码,然后继续向下执行,根据反汇编,将会跳转到0x004003f0puts@plt-0x10)。

2.2.4 进入公共PLT(.PLT[0])

 结合上面的反汇编,进入puts@plt-0x10之后,先做一次压栈操作,然后跳转到0x601010,使用GDB在0x4003f6设置断点,然后运行程序,查看0x601010的内容:

(gdb) b *main-304
Breakpoint 2 at 0x4003f6
(gdb) c
Continuing.

Breakpoint 2, 0x00000000004003f6 in ?? ()
(gdb) x/2xw 0x601010
0x601010:       0xf7dee870      0x00007fff

可以看到下一步将调用0x00007ffff7dee870处的代码。

2.2.5 调用_dl_runtime_resolve_avx函数

将其反汇编得到下面的结果:

(gdb) disassemble 0x00007ffff7dee870
Dump of assembler code for function _dl_runtime_resolve_avx:
   0x00007ffff7dee870 <+0>:     push   %rbx
   0x00007ffff7dee871 <+1>:     mov    %rsp,%rbx
   0x00007ffff7dee874 <+4>:     and    $0xffffffffffffffe0,%rsp

...

可以看到,进入_dl_runtime_resolve_avx函数中,经过google得知,这个函数是包含于动态链接器的代码。它的主要工作是:找到调用它的函数的真实地址,并将它填入到该函数对应的GOT中对于本例来讲,就是在将Glibc库加载到内存之后,把其中的printf函数的地址填充到.GOT.PLT[3]中,然后返回

 我们来验证一下,在调用完printf处(0x400534)设置断点,然后继续执行并查看.GOT.PLT[3]的值:

(gdb) b *main+14
Breakpoint 3 at 0x400534: file hello.c, line 6.
(gdb) c
Continuing.
hello, world

Breakpoint 3, main () at hello.c:6
6               return 0;
(gdb) x/2xw 0x601018
0x601018:       0xf7a7c690      0x00007fff
(gdb) disassemble 0x00007ffff7a7c690
Dump of assembler code for function _IO_puts:
   0x00007ffff7a7c690 <+0>:     push   %r12
   0x00007ffff7a7c692 <+2>:     push   %rbp

···

可以看到.GOT.PLT[3]指向了0x00007ffff7a7c690,而0x00007ffff7a7c690对应的代码正是printf函数的代码。

三、总结

综上所示,对动态链接库中的函数动态解析过程如下:
1. 从调用该函数的指令跳转到该函数对应的PLT处;
2. 该函数对应的PLT第一条指令执行它对应的.GOT.PLT里的指令。第一次调用时,该函数的.GOT.PLT里保存的是它对应的PLT里第二条指令的地址;
3. 继续执行PLT第二条、第三条指令,其中第三条指令作用是跳转到公共的PLT(.PLT[0]);
4. 公共的PLT(.PLT[0])执行.GOT.PLT[2]指向的代码,也就是执行动态链接器的代码;
5. 动态链接器里的_dl_runtime_resolve_avx函数修改被调函数对应的.GOT.PLT里保存的地址,使之指向链接后的动态链接库里该函数的实际地址;
6. 再次调用该函数对应的PLT第一条指令,跳转到它对应的.GOT.PLT里的指令(此时已经是该函数在动态链接库中的真正地址),从而实现该函数的调用。

上述过程用动图表示如下:

got_plt

注:由于博客框架限制,无法直接上传原图,点击图片查看原图。

四、分析中用到的GDB命令解释

  1. disassemble xxx(函数名)/yyy(内存地址): 这条命令作用是输出xxx/yyy的反汇编代码。
  2. rrun的简写,作用是运行需要调试的程序。
  3. x : examine的简写,用法为:x/nfu addr,作用是查看内存地址为addr里存放的值。其中,n表示列举出从addr开始的n个单位的内容;f表示输出格式(比如x表示将结果显示成十六进制)。u表示单位。
  4. bbreak的简写,作用是设置断点。b后面需要接“函数名”或者“文件+行号”。本文中我们使用了b func+/-offset这种用法。
  5. c: continue的简写,一般用于从断点处继续执行。

五、参考阅读

    1. Acronyms relevant to Executable and Linkable Format (ELF)
    2. PLT与GOT
    3. 聊聊Linux动态链接中的PLT和GOT(1)——何谓PLT与GOT
    4. 通过 GDB 调试理解 GOT/PLT
posted @ 2020-10-08 21:18  bonelee  阅读(602)  评论(0编辑  收藏  举报