Debug Hacks (1): 理解用GOT/PLT调用函数的原理
1. GOT/PLT
GOT是Global Offset Table,是保存库函数地址的区域。程序运行时,库函数的地址会设置到GOT中。由于动态库的函数是在使用时才被加载,因此刚开始GOT表是空的。
地址的设置就涉及到了PLT,Procedure Linkage Table,它包含了一些代码以调用库函数,它可以被理解成一系列的小函数,这些小函数的数量其实就是库函数的被使用到的函数的数量。简单来说,PLT就是跳转到GOT中所设置的地址而已。如果这个地址是空,那么PLT的跳转会巧妙的调用 _dl_runtime_resolve 去获取最终地址并设置到GOT中去。由于库函数的地址在运行时不会变,因此GOT一旦设置以后PLT就可以直接跳转到库函数的真实地址了
2. PIC VS Relocatable
在 Linux 下制作动态链接库,“标准” 的做法是编译成位置无关代码(Position Independent Code,PIC),然后链接成一个动态链接库。那么什么是PIC呢?如果是非PIC的,那么会有什么问题?
(1) 可重定位代码(relocatable code):Windows DLL 以及不使用 -fPIC 的 Linux so。
生成动态库时假定它被加载在地址 0 处。加载时它会被加载到一个地址(base),这时要进行一次重定位(relocation),把代码、数据段中所有的地址加上这个 base 的值。这样代码运行时就能使用正确的地址了。当要再加载时根据加载到的位置再次重定位的。(因为它里面的代码并不是位置无关代码)。因为so被每个程序加载的位置都不同,显然这些重定位后的代码也不同,当然不能共享。如果被多个应用程序共同使用,那么它们必须每个程序维护一份so的代码副本了。当然,主流现代操作系统都启用了分页内存机制,这使得重定位时可以使用 COW(copy on write)来节省内存(32 位 Windows 就是这样做的);然而,页面的粒度还是比较大的(例如 IA32 上是 4KiB),至少对于代码段来说能节省的相当有限。不能共享就失去了共享库的好处,实际上和静态库的区别并不大,在运行时占用的内存是类似的,仅仅是二进制代码占的硬盘空间小一些。
(2) 位置无关代码(position independent code):使用 -fPIC 的 Linux so。
这样的代码本身就能被放到线性地址空间的任意位置,无需修改就能正确执行。通常的方法是获取指令指针(如 x86 的 EIP 寄存器)的值,加上一个偏移得到全局变量/函数的地址。AMD64 下,必须使用位置无关代码。x86下,在创建so时会有一个警告。但是这样的so可以完全正常工作。PIC 的缺点主要就是代码有可能长一些。例如 x86,由于不能直接使用 [EIP+constant] 这样的寻址方式,甚至不能直接将 EIP 的值交给其他寄存器,要用到 GOT(global offset table)来定位全局变量和函数。这样导致代码的效率略低。PIC 的加载速度稍快,因为不需要做重定位。多个进程引用同一个 PIC 动态库时,可以共用内存。这一个库在不同进程中的虚拟地址不同,但操作系统显然会把它们映射到同一块物理内存上。
因此,当so需要共享时,要加上-fPIC。
3. GOT和PLT
我们都知道动态库是在运行时绑定的。那么编译器是如何找到动态链接库里面的函数的地址呢?事实上,直到我们第一次调用这个函数,我们并不知道这个函数的地址,这个功能要做延迟绑定 lazy bind。 因为程序的分支很多,并不是所有的分支都能跑到,想想我们的异常处理,异常处理分支的动态链接库里面的函数也许永远跑不到,所以,启动时解析所有出现过的动态库里面的函数是个浪费的办法,降低性能并且没有必要。
Global Offset Table (GOT)
在位置无关代码中,一般不能包含绝对虚拟地址(如共享库)。当在程序中引用某个共享库中的符号时,编译链接阶段并不知道这个符号的具体位置,只有等到动态链接器将所需要的共享库加载时进内存后,也就是在运行阶段,符号的地址才会最终确定。因此,需要有一个数据结构来保存符号的绝对地址,这就是GOT表的作用,GOT表中每项保存程序中引用其它符号的绝对地址。这样,程序就可以通过引用GOT表来获得某个符号的地址。
在x86结构中,GOT表的前三项保留,用于保存特殊的数据结构地址,其它的各项保存符号的绝对地址。对于符号的动态解析过程,我们只需要了解的就是第二项和第三项,即GOT[1]和GOT[2]:GOT[1]保存的是一个地址,指向已经加载的共享库的链表地址;GOT[2]保存的是一个函数的地址,定义如下:GOT[2] = &_dl_runtime_resolve,这个函数的主要作用就是找到某个符号的地址,并把它写到与此符号相关的GOT项中,然后将控制转移到目标函数
Procedure Linkage Table (PLT)
过程链接表(PLT)的作用就是将位置无关的函数调用转移到绝对地址。在编译链接时,链接器并不能控制执行从一个可执行文件或者共享文件中转移到另一个中(如前所说,这时候函数的地址还不能确定),因此,链接器将控制转移到PLT中的某一项。而PLT通过引用GOT表中的函数的绝对地址,来把控制转移到实际的函数。
在实际的可执行程序或者共享目标文件中,GOT表在名称为.got.plt的section中,PLT表在名称为.plt的section中。
➜ hacker cat test.cc
#include <cstring>
#include <cstdio>
#include <cstdlib>
void pirnt()
{
printf("hello world\n");
}
int main()
{
char buf[100];
gets(buf);
rand();
return 0;
}
动态库里面需要重定位的函数在.got.plt这个段里面,通过readelf我们可以看到,它一共有六个地址空间,前三个我们已经解释了。说明该程序预留了三个所需要重新定位的函数。因此用不到的函数是永远不会被加载的。
➜ hacker readelf -S app
There are 35 section headers, starting at offset 0x3038:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[21] .dynamic DYNAMIC 0000000000600e28 00000e28
00000000000001d0 0000000000000010 WA 6 0 8
[22] .got PROGBITS 0000000000600ff8 00000ff8
0000000000000008 0000000000000008 WA 0 0 8
[23] .got.plt PROGBITS 0000000000601000 00001000
0000000000000040 0000000000000008 WA 0 0 8
➜ hacker gdb app
GNU gdb (GDB) 8.2
Copyright (C) 2018 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-pc-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 app...done.
#反汇编main函数
(gdb) disas main
Dump of assembler code for function main():
0x00000000004005cd <+0>: push %rbp
0x00000000004005ce <+1>: mov %rsp,%rbp
0x00000000004005d1 <+4>: sub $0x70,%rsp
0x00000000004005d5 <+8>: lea -0x70(%rbp),%rax
0x00000000004005d9 <+12>: mov %rax,%rdi
0x00000000004005dc <+15>: callq 0x4004b0 <gets@plt>
0x00000000004005e1 <+20>: callq 0x4004c0 <rand@plt>
0x00000000004005e6 <+25>: mov $0x0,%eax
0x00000000004005eb <+30>: leaveq
0x00000000004005ec <+31>: retq
End of assembler dump.
# rand函数反汇编
(gdb) disas rand
Dump of assembler code for function rand@plt:
0x00000000004004c0 <+0>: jmpq *0x200b72(%rip) # 0x601038 <rand@got.plt>
0x00000000004004c6 <+6>: pushq $0x4
0x00000000004004cb <+11>: jmpq 0x400470
End of assembler dump.
# 真正有意思的在# 0x004004c6 <rand@got.plt> 也就是rand@plt首先会跳到这里。我们看一下这里是什么:
(gdb) x 0x601038
0x601038 <rand@got.plt>: 0x004004c6
# 接着看0x004004c6是什么: 0x00000000004004c6 <+6>: pushq $0x4
# 可能你注意到了,这里的处理是和刚才的rand@plt的jmpq一样。都是将0x04入栈,然后jmpq 0x400470。因此这样就 # 避免了GOT表是否为是真实值的检查:如果是空,那么去寻址;否则直接调用。
(gdb) disas 0x004004c6
Dump of assembler code for function rand@plt:
0x00000000004004c0 <+0>: jmpq *0x200b72(%rip) # 0x601038 <rand@got.plt>
0x00000000004004c6 <+6>: pushq $0x4
0x00000000004004cb <+11>: jmpq 0x400470
End of assembler dump.
#其实接下来处理的就是调用_dl_runtime_resolve_()函数,该函数最终会寻址到rand的真正地址并且会调用_dl_fixup来将rand的实际地址填入GOT表中。
我们将整个程序执行完,然后看一下0x601008 <_GLOBAL_OFFSET_TABLE_+32>是否已经修改成rand的实际地址:
(gdb) b main
Breakpoint 1 at 0x4005d5: file test.cc, line 13.
(gdb) r
Starting program: /root/workspace/hacker/app
Breakpoint 1, main () at test.cc:13
13 gets(buf);
(gdb) x 0x601038
0x601038 <rand@got.plt>: 0x004004c6
(gdb) r
Starting program: /root/workspace/hacker/app
Breakpoint 1, main () at test.cc:13
13 gets(buf);
(gdb) x 0x601038
0x601038 <rand@got.plt>: 0x004004c6
(gdb) n
14 rand();
(gdb) n
15 return 0;
# 查看rand的实际地址
(gdb) x 0x601038
0x601038 <rand@got.plt>: 0xf7a54890