logo头像

凡是过往,皆为序章

GOT表

本文讨论 GOT 表相关概念。主要是整理学习笔记。

GOT 表(Global Offset Table)

动态链接是指在运行时完成链接。链接到库有利于复用代码,而动态链接还节约程序所占磁盘和内存空间。在Linux系统中,动态链接库名字以 .so 结束。(静态链接库名字结尾为 .a

链接需要确定相关被调用函数的具体地址。动态链接如何在运行时实现这个功能呢?GOT 和 PLT 表(Procedure Linkage Table,过程链接表)被用来解决这一问题。

在 ELF 文件中相关段的名称与解释如下。

段 Section 解释
.got 外部变量的 got 表,例如 stdin / stdout /stderr,非延迟绑定
.got.plt 外部库函数的 got 表,一个表项对应一个函数,例如 printf,延迟绑定
.plt 每个外部库函数对应一段 plt 中的代码

本文的 GOT 表是指 .got.plt 段。

GOT 表项为一系列函数(或仅仅是小段汇编代码)首地址,PLT 表内容其实是小段汇编。正如下图所示,GOT 表通常是可读可写的。

1

所有外部库函数调用都会查询 GOT 表来确定跳转的目标地址。GOT 表初始值为各个函数对应的 PLT 表项。第一次调用时,进入 PLT 查询 GOT 表确定跳转目的地址,将跳转到 PLT +6,然后调用 _dl_runtime_resolve(),它将找到被调用函数的地址,修改 GOT 表项为被调用函数的地址,并调用被调用函数。第二次以及以后的调用,进入 PLT 查询 GOT 表确定跳转目的地址,将跳转到被调用函数真实地址。

2

(puts@plt+6 为什么是 +6?你可以参看分析 hello world 中的汇编,地址 0x80482e0 处的 jmp 指令长度为6)

下文调试 Hello World 部分将进一步展示相关代码与结构。


以上阐述的其实是加上了延迟绑定的动态链接。引入延迟绑定技术是因为很多函数可能在程序执行完时都不会被用到,若无延迟绑定,启动程序时做了无用功。举个例子,一个程序动态链接了一千个外部库函数,但是每次运行有条件分支,实际只调用十个外部库函数,这造成了大量性能损失,尤其是在程序反复运行的情况下。由于外部变量通常数量少,不会造成性能瓶颈,外部变量不使用延迟绑定。

延迟绑定可以在编译时关闭。(参考后文防御措施)

说句废话,由于少了查表跳转等等,静态链接比动态链接快,约 1% 到 5% 。

分析 Hello World

准备

C 语言编写 Hello World 如下。

1
2
3
4
5
6
7
#include<stdio.h>
int main(void)
{
puts("Hello world!");
puts("Hello world again!");
return 0;
}

gcc 编译

1
2
3
gcc hello.c -o hello -m32 -no-pie
# -m32 表示生成32位程序
# -no-pie 表示强制关闭地址随机化,似乎以前默认关闭,现在默认开启,为了便于分析我们关闭

gdb 调试

我们使用 gdb 调试观察延迟绑定。

使用 gdb 查看 main 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
disassemble main
Dump of assembler code for function main:
0x08048426 <+0>: lea ecx,[esp+0x4]
0x0804842a <+4>: and esp,0xfffffff0
0x0804842d <+7>: push DWORD PTR [ecx-0x4]
0x08048430 <+10>: push ebp
0x08048431 <+11>: mov ebp,esp
0x08048433 <+13>: push ebx
0x08048434 <+14>: push ecx
0x08048435 <+15>: call 0x8048360 <__x86.get_pc_thunk.bx>
0x0804843a <+20>: add ebx,0x1bc6
0x08048440 <+26>: sub esp,0xc
0x08048443 <+29>: lea eax,[ebx-0x1b00]
0x08048449 <+35>: push eax
0x0804844a <+36>: call 0x80482e0 <puts@plt>
0x0804844f <+41>: add esp,0x10
0x08048452 <+44>: sub esp,0xc
0x08048455 <+47>: lea eax,[ebx-0x1af3]
0x0804845b <+53>: push eax
0x0804845c <+54>: call 0x80482e0 <puts@plt>
0x08048461 <+59>: add esp,0x10
0x08048464 <+62>: mov eax,0x0
0x08048469 <+67>: lea esp,[ebp-0x8]
0x0804846c <+70>: pop ecx
0x0804846d <+71>: pop ebx
0x0804846e <+72>: pop ebp
0x0804846f <+73>: lea esp,[ecx-0x4]
0x08048472 <+76>: ret
End of assembler dump.

在 PLT 表的小段汇编处设置断点。

1
b *0x80482e0

在 gdb 中运行程序,将在断点处暂停,可以看到 PLT 表中代码如下。(这是 gdb-peda 插件)

1
2
3
4
5
6
7
8
9
10
   0x80482d6:	jmp    DWORD PTR ds:0x804a008
0x80482dc: add BYTE PTR [eax],al
0x80482de: add BYTE PTR [eax],al
=> 0x80482e0 <puts@plt>: jmp DWORD PTR ds:0x804a00c
| 0x80482e6 <puts@plt+6>: push 0x0
| 0x80482eb <puts@plt+11>: jmp 0x80482d0
| 0x80482f0 <__libc_start_main@plt>: jmp DWORD PTR ds:0x804a010
| 0x80482f6 <__libc_start_main@plt+6>: push 0x8
|-> 0x80482e6 <puts@plt+6>: push 0x0
0x80482eb <puts@plt+11>: jmp 0x80482d0

地址 0x80482e0 处的汇编是查 GOT 表,由于此处是第一次调用,于是 GOT 表(地址 0x804a00c)中内容为 0x80482e6 ,指向 PLT 表中准备调用 _dl_runtime_resolve() 的代码。

(奇怪的知识)函数尾调用(原本是 call)可以被编译优化为 jmp 指令,所以可以从这个视角来认识一下这些 PLT 表中的小段汇编代码,push 之后就 jmp 跳走,其实就是函数参数入栈、调用函数。

继续运行到第二次触发断点。可以发现 jmp 目的地址已经变为 0xf7e46360,即 libc 中的 puts 地址。

1
2
3
4
5
6
7
8
9
10
11
12
   0x80482d6:	jmp    DWORD PTR ds:0x804a008
0x80482dc: add BYTE PTR [eax],al
0x80482de: add BYTE PTR [eax],al
=> 0x80482e0 <puts@plt>: jmp DWORD PTR ds:0x804a00c
| 0x80482e6 <puts@plt+6>: push 0x0
| 0x80482eb <puts@plt+11>: jmp 0x80482d0
| 0x80482f0 <__libc_start_main@plt>: jmp DWORD PTR ds:0x804a010
| 0x80482f6 <__libc_start_main@plt+6>: push 0x8
|-> 0xf7e46360 <puts>: push ebp
0xf7e46361 <puts+1>: mov ebp,esp
0xf7e46363 <puts+3>: push edi
0xf7e46364 <puts+4>: push esi

获取 GOT 表项地址

可使用 objdump 获取 GOT 表项地址信息

1
2
3
4
5
6
7
8
9
objdump -R hello

hello: 文件格式 elf32-i386

DYNAMIC RELOCATION RECORDS
OFFSET TYPE VALUE
08049ffc R_386_GLOB_DAT __gmon_start__
0804a00c R_386_JUMP_SLOT puts@GLIBC_2.0
0804a010 R_386_JUMP_SLOT __libc_start_main@GLIBC_2.0

如何防御 GOT 表劫持

GOT 表劫持需要:

  • GOT 表内存属性可写
  • 存在内存写漏洞

最终可以劫持程序流程。

重定位只读(Relocation Read Only)缓解措施

  • 编译选项:gcc -z,relro
  • 在进入 main() 之前,所有的外部函数都会被解析
  • 所有 GOT 表设置为只读
  • 绕过方法
    • 劫持为开启该保护的动态库中的 GOT 表(例如 libc 中的 GOT 表)
    • 改写函数返回地址或函数指针

上面的编译选项有问题,应该是这个:CTF All in One: RELRO 编译参数。你可以用上文的代码再次实验,了解这个编译选项的效果。

1
gcc -z now

3

我的闲扯

动态链接器自举

算是本文的延伸内容吧。

动态链接器负责动态库的装入与链接。

对于使用了动态链接库的程序,在系统开始运行程序之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给程序,然后开始执行。动态链接器本身也是一个共享对象,其他共享对象可以依赖动态链接器完成链接和装载。我们不能允许“鸡生蛋、蛋生鸡”无限循环,那么动态链接器如何自举?

一篇博客 如下。

动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始运行。自举代码首先会找到它自己的 GOT。而 GOT 的第一个入口保存的是 “.dynamic” 段的偏移地址,由此找到了动态连机器本身的 “.dynamic” 段。通过 “.dynamic” 的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始,动态链接器代码中才可以使用自己的全局变量和静态变量。

实际上在动态链接器的自举代码中,除了不可以使用全局变量和静态变量之外,甚至不能调用函数,即动态链接器本身的函数也不能调用。这是为什么呢?其实我们在前面分析地址无关代码时已经提到过,实际上使用 PIC 模式编译的共享对象,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,即使用 GOT/PLT 的方式,所以在 GOT/PLT 没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。

更多内容请参考《程序员的自我修养——链接,装载与库》以及相关读书笔记博客吧。

参考资料

  • 选题与多数内容选自长亭杨坤 2020-4-13 公开课。听课一时爽,没有保存到一些原始的材料,我写的可能有出入。
  • 《程序员的自我修养——链接,装载与库》

TODO

  • 找 pwn 题补充