前言

动态链接是一种高效且节省空间的程序间共享代码方式。若程序使用静态链接方式,则程序所有代码都将集成到同一个二进制文件中,其优点在于无依赖关系,可以在不同运行环境的OS下运行。但是缺点也十分明显,由于二进制文件中包含全部代码,所以所占空间较大;如果多次运行同一个程序,则OS可能会对某个库函数进行多次重复 的加载,占用了不必要的内存;若某个公用的库函数产生了更新,则需要重新编译所有使用了该库的程序,工作量较大。

静态链接的一个典型的例子就是Golang,其默认所有程序都是使用静态链接的方式,包含有所有使用到的Golang库函数,因此使用Golang编写的程序因为具有优秀的可移植性和开箱即用受到较多好评。但较为直观的也能看见上面所说的缺点:Linux x86_64下,一个Golang编写的HelloWorld二进制文件占用空间为1.7MB。

而为了解决静态链接存在的重复加载、重复编译等问题,引入了动态链接的方式。使用动态链接的程序不包含库函数的代码,库函数通过动态链接库(.so)的形式独立存在。当程序开始运行并产生外部函数调用时,动态链接器将承担加载动态链接库和重定位函数地址、变量地址的工作,在运行时确定外部函数地址和变量的值,也叫惰性加载。动态链接能够减少程序的启动时间(程序占用空间变小),且动态链接器也不会产生较多额外的性能开销,因此动态链接还是如今比较广泛应用的一种链接方式。

为了支撑动态链接这一工作过程,在ELF文件中有4个Section与之相关:

  • .got:全局偏移表(Global Offset Table),用于存储外部符号的绝对地址,由链接器进行填充。
  • .plt:过程链接表(Procedure Linkage Table),存有从.got.plt中查找外部函数地址的代码,若是第一次调用该函数,则会触发链接器解析函数地址并填充在.got.plt相应的位置;若函数地址已经存储在.got.plt中则直接跳转到对应地址继续执行。
  • .got.plt:GOT中专用于PLT存储外部函数地址的部分,是属于GOT的一部分。
  • .plt.got:不知道干啥用的,可能只是为了名字的对称……

下面将对基于GOT和PLT来进行外部符号地址重定向的工作方式进行分析。为了便于演示过程,编写了两个C文件,一个编译为共享的动态链接库,另一个是可执行程序。代码和编译命令如下:

 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
// main.c
// gcc -g -m32 -no-pie -L. main.c lib.so -o main
#include <stdio.h>

static int a;
extern int b;
extern void external();

void internal() {
    printf("[*] INT\n");
}

int main(void) {
    printf("a = %d, b = %d\n", a, b);
    internal();
    external();
    return 0;
}

// lib.c
// gcc -g -m32 -shared -fPIC lib.c -o lib.so
#include <stdio.h>
int b = 0xdeadbeef;
void external() {
    printf("[*] EXT\n");
}

这份代码展示了符号引用的四个场景:

  • 模块内部函数调用,即internal()函数;
  • 模块内部的变量访问,即全局变量a
  • 模块外部的函数调用,即external()函数;
  • 模块外部的变量访问,即外部变量b

模块内部的调用和取值只需直接从ELF对应地址中读取数据或调用函数即可,此处重点关注后面的两种情况。

需要注意的是,编译后的动态链接库文件所在目录必须包含在环境变量LD_LIBRARY_PATH中,否则主程序运行时会提示无法找到动态链接库文件:

1
2
$ LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/your/lib
$ export LD_LIBRARY_PATH

位置无关代码

从网上搜索使用GCC编译动态链接库文件的命令时,大部分命令中都包含有-fPIC这个参数,其中PIC指的就是位置无关代码(Position-independent Code)。位置无关代码使得为共享库文件支持了地址空间随机化(ASLR)特性。其中所有对地址的引用都是通过相对地址偏移量来实现的,使得无论这个ELF文件被OS加载到何处,都能够通过相对地址正确找到对应的地址数据。相反的,若代码不是位置无关的,则其中每一条指令的地址都是固定的,运行时也将被载入虚拟地址空间的对应地址段内。

但是对于动态链接库而言,顾名思义其代码是动态被加载至内存中的,而如果是按确定的地址进行加载,在一个进程中线性的地址空间中,无法预知这个地址段会不会和其他的共享库的地址产生冲突。因此为了避免这个问题,动态链接库的代码都是位置无关的,这样使得OS可以控制各个共享库的加载位置,使得地址不会产生冲突。

类似的概念同样存在于可执行文件上,被称为位置无关可执行文件(Position-independent Executable,PIE)。上述编译指令中-no-pie参数的意义即为关闭PIE。当PIE关闭时,链接器会默认将其加载到OS虚拟地址空间的代码段(Text Segment)中,此时可执行文件将会有一个固定的地址前缀(如x86_32下为0x08048000,x86_64下为0x0000000000400000),这也正是代码段的起始地址(可以参考x86的ABI中对虚拟内存空间的布局)。使用GDB分别对开启PIE和未开启PIE的程序进行反编译,可以看见开启PIE的代码地址以及函数调用地址均为相对的偏移量,而未开启PIE的程序则全部为绝对地址:

重定向表

对动态链接的可执行文件而言,其调用的外部函数可能在其运行时并未载入内存,因为地址的可变性,所以无法在可执行文件中存入一个确切的地址。对于这个问题的解决方案,是在ELF的某些段中为函数地址和变量值预留一个位置,当程序运行时,动态链接器会负责将正确的函数地址和变量数据填充到对应的位置。而为了让链接器知道有哪些外部变量/函数,以及需要填充数据到什么地方,就需要使用到重定位表。

在静态链接时,.rel.text表示代码段重定位段,.rel.data表示数据段重定位段;而在动态链接时则分别为.rel.plt.rel.dyn.rel.plt中的项是需要进行重定位的函数引用,引用的地址存储于.got.plt中;.rel.dyn中的项则是数据段中需要重定位的外部变量,对应的数据/地址存储于.got和数据段中。

使用readelf可以读取ELF文件的重定向表,可以看见.rel.dyn中包含引用自lib.so的外部变量b.rel.plt中有Glibc中的库函数以及引用自lib.so的外部函数external()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ readelf -r main

Relocation section '.rel.dyn' at offset 0x320 contains 2 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
0804bfec  00000206 R_386_GLOB_DAT    00000000   b
0804bff0  00000406 R_386_GLOB_DAT    00000000   __gmon_start__

Relocation section '.rel.plt' at offset 0x330 contains 4 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
0804c000  00000107 R_386_JUMP_SLOT   00000000   printf@GLIBC_2.0
0804c004  00000307 R_386_JUMP_SLOT   00000000   puts@GLIBC_2.0
0804c008  00000507 R_386_JUMP_SLOT   00000000   __libc_start_main@GLIBC_2.0
0804c00c  00000607 R_386_JUMP_SLOT   00000000   external

对应的,查看这些地址偏移值所在的段,可以验证上面的叙述。下面是部分程序段的输出,可以确定外部变量的引用是存在.got中,外部函数引用存在.got.plt中。(更准确地说,类型为R_386_GLOB_DAT的外部变量将会被指向.got表中的位置,类型为R_390_JMP_SLOT的函数引用将会被指向.got.plt中。参考链接

这两个段实际上也是属于数据段的一部分,在程序代码中为了保证位置无关性,相关的引用都必须是相对位置引用,对于这类绝对地址的调用则分离出来放在数据段,此时代码再通过计算当前PC与.got.plt.got的偏移值来引用这些绝对地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ readelf -S main
There are 35 section headers, starting at offset 0x39e4:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  ......
  [12] .plt              PROGBITS        08049020 001020 000050 04  AX  0   0 16
  [13] .text             PROGBITS        08049070 001070 000205 00  AX  0   0 16
  ......
  [21] .got              PROGBITS        0804bfec 002fec 000008 04  WA  0   0  4
  [22] .got.plt          PROGBITS        0804bff4 002ff4 00001c 04  WA  0   0  4
  [23] .data             PROGBITS        0804c010 003010 000008 00  WA  0   0  4
  ......

程序分析

下面使用GDB对上面的可执行程序进行反编译和调试,来观察GOT和PLT的工作方式。

静态分析

再贴一下main函数代码:

1
2
3
4
5
6
int main(void) {
    printf("a = %d, b = %d\n", a, b);
    internal();
    external();
    return 0;
}

反编译过后的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
30
Dump of assembler code for function main:
   0x080491b1 <+0>:     lea    ecx,[esp+0x4]
   0x080491b5 <+4>:     and    esp,0xfffffff0
   0x080491b8 <+7>:     push   DWORD PTR [ecx-0x4]
   0x080491bb <+10>:    push   ebp
   0x080491bc <+11>:    mov    ebp,esp
   0x080491be <+13>:    push   ebx
   0x080491bf <+14>:    push   ecx
   0x080491c0 <+15>:    call   0x80490c0 <__x86.get_pc_thunk.bx>
   0x080491c5 <+20>:    add    ebx,0x2e2f
   0x080491cb <+26>:    mov    eax,DWORD PTR [ebx-0x8]
   0x080491d1 <+32>:    mov    edx,DWORD PTR [eax]
   0x080491d3 <+34>:    mov    eax,DWORD PTR [ebx+0x28]
   0x080491d9 <+40>:    sub    esp,0x4
   0x080491dc <+43>:    push   edx
   0x080491dd <+44>:    push   eax
   0x080491de <+45>:    lea    eax,[ebx-0x1fe4]
   0x080491e4 <+51>:    push   eax
   0x080491e5 <+52>:    call   0x8049030 <printf@plt>
   0x080491ea <+57>:    add    esp,0x10
   0x080491ed <+60>:    call   0x8049186 <internal>
   0x080491f2 <+65>:    call   0x8049060 <external@plt>
   0x080491f7 <+70>:    mov    eax,0x0
   0x080491fc <+75>:    lea    esp,[ebp-0x8]
   0x080491ff <+78>:    pop    ecx
   0x08049200 <+79>:    pop    ebx
   0x08049201 <+80>:    pop    ebp
   0x08049202 <+81>:    lea    esp,[ecx-0x4]
   0x08049205 <+84>:    ret
End of assembler dump.

出现了4个call指令,其中__x86.get_pc_thunk.bx函数是用于获取当前PC的值并存储在对应寄存器里(这里是EBX,同理还有__x86.get_pc_thunk.ax等其他的函数,效果都是类似的)。由于在32位下不支持直接访问PC寄存器,所以采用这样的实现方式,64位则是直接取PC的值。

剩余三个函数则分别是C代码中调用的三个函数。其中internal()函数直接指向对应的代码(对应地址在.text段),其余两个外部函数直接标注出了这是指向PLT的地址。

接下来可以dump出.plt段的具体数据:

经过GDB的标注,可以发现PLT表可以通过每0x10个字节来进行划分。首先是第一部分,这段代码是PLT的公共代码,用于调用动态链接器来装填外部函数的地址。但是可以发现在程序尚未运行的时候,这两个地址(0x804bff8和0x804bffc,位于.got.plt)对应的值为0,是因为这两个值同样是由动态链接器进行填充。

剩余的每0x10个字节分别对应各个外部函数的处理代码。可以看见main函数中对外部函数的调用地址也位于对应函数PLT表项的起始位置。下面以printf()函数为例,读取对应的数据,可以发现对应的地址恰好是函数的处理代码中的第二条push指令:

1
2
pwndbg> x/wx 0x804c000
0x804c000 <printf@got.plt>:     0x08049036

也就是说,在默认状态下(也就是函数第一次调用时),第一条jmp指令的作用等效于继续向下执行。很容易发现不同函数中,push的值也不尽相同,这个值是由什么来确定的呢?可以在重定向表.rel.plt中找到答案。如下所示,可以看见每个重定位的函数表项所占用的空间都是8个字节,所以push的值也就相当于对应函数在重定位表中的偏移值,链接器也就可以通过这个偏移值来定位到要解析的函数信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Hex dump of section '.rel.plt':
  0x08048330 00c00408 07010000 04c00408 07030000 ................
  0x08048340 08c00408 07050000 0cc00408 07060000 ................

Relocation section '.rel.plt' at offset 0x330 contains 4 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
0804c000  00000107 R_386_JUMP_SLOT   00000000   printf@GLIBC_2.0
0804c004  00000307 R_386_JUMP_SLOT   00000000   puts@GLIBC_2.0
0804c008  00000507 R_386_JUMP_SLOT   00000000   __libc_start_main@GLIBC_2.0
0804c00c  00000607 R_386_JUMP_SLOT   00000000   external

在push指令之后,所有的函数处理代码最终都会跳转到一个一样的地址,对应的也就是PLT的第一项公共代码,也就是交由链接器进行地址的解析和装填。

接下来查看GOT中的数据排布。查询ELF的节信息可以知道,.got段只包括前面的8个字节,后面的数据均属于.got.plt段:

1
2
3
0x804bfec:      0x00000000      0x00000000      0x0804befc      0x00000000
0x804bffc:      0x00000000      0x08049036      0x08049046      0x08049056
0x804c00c:      0x08049066

对应重定位表.rel.dyn中的两条数据,可以发现.got段的确是用于存储外部变量重定向的地址,在动态分析的过程中可以看的更为详细。.got.plt段中,前三项为公共项,后面的项则用于存储外部函数重定向的地址。这三个公共项分别为:

  • GOT[0]:本ELF文件中.dynamic段的地址
  • GOT[1]:本ELF文件中的link_map数据结构描述符地址
  • GOT[2]:_dl_runtime_resolve函数地址,顾名思义是用于函数地址解析的

如前文所述,GOT[1]和GOT[2]在程序未运行时的值为0,在程序运行前,由链接器来负责填充。

基于上面的分析,可以画出这个可执行程序中的GOT和PLT布局图。

动态分析

为了搞清楚GOT[1]和GOT[2]是在何时被填充的,可以使用GDB为对应地址添加Watchpoint观测其变化,再使用continue令程序继续运行,程序会停在Watchpoint对应地址产生变化的代码处。可以发现程序停在的代码都位于动态链接器的相关代码中:

 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
pwndbg> watch -l *0x804bff8
Hardware watchpoint 1: -location *0x804bff8

pwndbg> watch -l *0x804bffc
Hardware watchpoint 2: -location *0x804bffc

pwndbg> continue
Continuing.

Hardware watchpoint 1: -location *0x804bff8

Old value = 0
New value = -134227504
0xf7fd849f in elf_machine_runtime_setup (profile=0, lazy=1, l=0xf7ffd9d0) at ../sysdeps/i386/dl-machine.h:73
......

pwndbg> continue
Continuing.

Hardware watchpoint 2: -location *0x804bffc

Old value = 0
New value = -134341312
_dl_relocate_object (l=<optimized out>, scope=0xf7ffdb98, reloc_mode=<optimized out>, consider_profiling=<optimized out>) at dl-reloc.c:274
......

此时再去查看.got段,可以发现GOT[1]和GOT[2]已经被填充了对应的地址。同理,可以发现在进入main函数之前,动态链接器已经将外部变量b的值放在了.got段中,对应的值也是在lib.so中所定义的0xdeadbeef

1
2
3
4
5
6
7
8
pwndbg> continue
Continuing.

Hardware watchpoint 5: -location *0x804bfec

Old value = 0
New value = -134471672
elf_dynamic_do_Rel (skip_ifunc=<optimized out>, lazy=<optimized out>, nrelative=<optimized out>, relsize=<optimized out>, reladdr=<optimized out>, map=<optimized out>) at do-rel.h:124

接下来分析外部函数地址的解析(同样以printf()函数的解析为例)。在执行了call指令之后,程序跳转到PLT对应的条目中:

1
2
3
►  0x8049030  <printf@plt>         jmp    dword ptr [printf@got[plt]]   <0x804c000>
   0x8049036  <printf@plt+6>       push   0
   0x804903b  <printf@plt+11>      jmp    0x8049020                     <0x8049020>

接下来的情况和静态分析中的一致,push了函数的偏移值,然后跳转到PLT的公共代码:

1
2
►  0x8049020         push   dword ptr [_GLOBAL_OFFSET_TABLE_+4] <0x804bff8>
   0x8049026         jmp    dword ptr [0x804bffc]         <_dl_runtime_resolve>

整个过程相当于进行了一个下面这样的函数调用,来进行函数地址的重定位:

1
_dl_runtime_resolve(link_map, reloc_offset)

继续添加Watchpoint,发现.got.plt中的函数地址在_dl_runtime_resolve函数中调用的_dl_fixup函数中被改变,最终获得printf()函数的地址为0xf7e13f10

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
pwndbg> continue
Continuing.

Hardware watchpoint 2: -location *0x804c000

Old value = 134516790
New value = -136233200
0xf7fdba6c in _dl_fixup (l=<optimized out>, reloc_arg=<optimized out>) at dl-runtime.c:146

pwndbg> x/wx 0x804c000
0x804c000 <printf@got.plt>:     0xf7e13f10

继续运行,在_dl_runtime_resolve的结尾处,把ESP所指的地址修改为了printf()函数地址,再通过ret指令直接跳转至printf()函数运行。汇编代码如下:

1
2
3
0xf7fe1d54 <_dl_runtime_resolve+20>    mov    dword ptr [esp], eax
0xf7fe1d57 <_dl_runtime_resolve+23>    mov    eax, dword ptr [esp + 4]
0xf7fe1d5b <_dl_runtime_resolve+27>    ret    0xc

此时的堆栈如下,所以在ret指令中需要清理在重定位中使用的堆栈数据,从而恢复到从main函数直接调用printf()的状态:

1
2
3
4
5
6
7
8
00:0000│ esp 0xffffd49c —▸ 0xf7e13f10 (printf) ◂— call   0xf7f05189
01:0004│     0xffffd4a0 —▸ 0x804a010 ◂— 'a = %d, b = %d\n'
02:0008│     0xffffd4a4 —▸ 0xf7ffd9d0 ◂— 0x0
03:000c│     0xffffd4a8 ◂— 0x0
04:0010│     0xffffd4ac —▸ 0x80491ea (main+57) ◂— add    esp, 0x10
05:0014│     0xffffd4b0 —▸ 0x804a010 ◂— 'a = %d, b = %d\n'
06:0018│     0xffffd4b4 ◂— 0x0
07:001c│     0xffffd4b8 ◂— 0xdeadbeef

以上是第一次调用时的运作方式,将会调用动态链接器解析函数地址并直接跳转运行。此时.got.plt对应函数的位置已经填充了正确的函数地址,所以下一次再调用该函数时,jmp dword ptr [printf@got[plt]]这一行汇编代码将会直接跳转至对应函数执行,无需再次解析。

通过GOT和PLT这样的工作方式,可以很好的实现延迟绑定(惰性加载),这样提高了程序的启动速度,也不会在重定位时牺牲过多的开销。

相关攻击方式

// TODO

参考链接