C 程序加载过程

2019-10-20 language c/cpp

利用动态库,可以节省磁盘、内存空间,而且可以提高程序运行效率;不过同时也导致调试比较困难,而且可能存在潜在的安全威胁。

这里主要讨论符号的动态链接过程,即程序在执行过程中,对其中包含的一些未确定地址的符号进行重定位的过程。

简介

假设有如下的示例。

#include <stdio.h>

int main(void)
{
	puts("Hello World.");
	return 0;
}

可以通过 gcc -o main main.c 编译生成二进制文件,然后通过 readelf -S main 查看当前的段信息,其中比较关键的有。

$ readelf -S main | grep -E '(plt|got)'
  [10] .rela.plt         RELA             0000000000400448  00000448
  [12] .plt              PROGBITS         0000000000400480  00000480
  [21] .got              PROGBITS         0000000000600fe0  00000fe0
  [22] .got.plt          PROGBITS         0000000000601000  00001000

各个段的用途如下。

  • .got Global Offset Table 会通过链接器将实际函数的地址填充。
  • .plt Procedure Linkage Table 编辑器生成的代码,如果已经填充了 .got.plt 那么会直接跳转,否则会调用链接器查找对应的函数。
  • .got.plt 如果链接器已经完成了查找,那么就包含了实际函数地址,或值跳转到 .plt 地址执行查找。

符号表

函数和变量作为符号被存在可执行文件中,同时会将不同类型的符号又放到一块,称为符号表,包括了两类:A) 常规 .symtab .strtab;B) 动态 .dynsym .dynstr

$ readelf -S main | grep -E '\.(dyns|symtab|strtab)'
  [ 5] .dynsym           DYNSYM           00000000004002b8  000002b8
  [ 6] .dynstr           STRTAB           0000000000400348  00000348
  [27] .symtab           SYMTAB           0000000000000000  000016d0
  [28] .strtab           STRTAB           0000000000000000  00001eb0

常规的符号表通常只在调试时使用,正常发布的二进制文件会 strip 调,而动态符号表则是程序执行时候真正会查找的目标。

$ readelf -S /bin/bash | grep -E '\.(dyns|symtab|strtab)'
  [ 6] .dynsym           DYNSYM           0000000000004ca8  00004ca8
  [ 7] .dynstr           STRTAB           0000000000012af0  00012af0

另外,生成的目标文件是没有 .dyn* 类型的符号表的。

除此之外,还有一个 .shstrtab 保存的是段表字符串表 (Section Header String Table),也就是通过 readelf -S 读出的段名称。

每个段的地址可以通过 ld --verbose 查看。

符号地址

在目标文件中符号是没有地址的,只有在生成可执行文件后会指定。

$ gcc -c main.c
$ nm main.o
0000000000000000 T main
                 U puts

$ gcc -o main main.c
$ nm main | grep -E "(\<main|puts)"
0000000000400586 T main
                 U puts@@GLIBC_2.2.5

在链接后,函数 main() 的地址已经确定,但是,动态库中的 puts() 函数是没有指定的,而该函数在标准 C 库中实现,可以通过如下方法查看。

$ nm -D /usr/lib64/libc-2.28.so  | grep -E '\<puts\>'
0000000000073010 W puts

也可以通过 readelf -s 查看,上述表明,这是一个若引用,其它的库可以将其覆盖。

重定位

在编译时,只知道外部符号的类型,而不知道具体函数或变量的地址,地址是通过重定位的方式获取,有两种重定位方式:A) 链接时重定位;B) 运行时重定位。

在链接阶段会将中间文件通过链接器链接成一个可执行文件,主要做的事情有:A) 对各个中间文件的同名段进行合并;B) 对代码段、数据段等进行地址分配;C) 链接时重定位。

在重定位时,如果在其它中间文件中已经定义,链接阶段可以直接重定位到函数地址;如果是在动态库中定义了的函数,链接阶段无法直接重定位到函数地址,就需要使用如下介绍的方式。

基本概念

在动态库中的函数以及变量会在运行时动态确定,编译链接后的二进制文件中包含了几个与之相关的段,运行时加载到内存中,然后以此确定真实的地址。

  • .got Global Offset Table 全局偏移表,这是链接器为外部符号填充的实际偏移表。
  • .plt Procedure Linkage Table 程序链接表,链接器生成的代码片段,实现懒加载功能。

注意,GOT PLT 只是 Linux 中实现的一种动态链接的方式。

位置无关代码

在执行程序时,需要先将磁盘上的文件读取到内存中, 然后再执行,而每个进程都有自己的虚拟内存空间,32 位程序的寻址空间为 2^32 大小,而 64 位当前为 2^48 ,虚拟内存最终会通过页表映射到物理内存中。

按照 System V ABI 的规定,32 位程序会加载到 0x08048000 这个地址中,而 64 位则会加载到 0x00400000 中,那么所写的程序,实际就是以这个地址为基础,对变量进行绝对地址寻址。

例如,如下程序。

$ readelf -S main | grep -A 1 -E "\.data"
  [Nr] Name              Type             Address           Offset
	Size              EntSize          Flags  Link  Info  Align
  [23] .data             PROGBITS         0000000000601020  00001020
       0000000000000004  0000000000000000  WA       0     0     1

动态解析

控制权先是提交到解释器,由解释器加载动态库,然后控制权才会到用户程序。动态库加载的大致过程就是将每一个依赖的动态库都加载到内存,并形成一个链表,后面的符号解析过程主要就是在这个链表中搜索符号的定义。

$ cat test.c
#include <stdio.h>

int main(void)
{
	puts("Hello World");
	return 0;
}

----- 编译连接
$ gcc test.c -o test -g
----- 打印程序的反汇编
$ objdump -S test

----- 使用gdb调式
$ gdb test -q
(gdb) break main
(gdb) run
(gdb) disassemble
Dump of assembler code for function main:
   0x0000000000400586 <+0>:     push   %rbp
   0x0000000000400587 <+1>:     mov    %rsp,%rbp
=> 0x000000000040058a <+4>:     mov    $0x400638,%edi
   0x000000000040058f <+9>:     callq  0x400490 <puts@plt> 此处调用的地址是固定的
   0x0000000000400594 <+14>:    mov    $0x0,%eax
   0x0000000000400599 <+19>:    pop    %rbp
   0x000000000040059a <+20>:    retq
End of assembler dump.

从上面反汇编代码可以看出,调用 puts() 函数时,实际上调用的是 puts@plt 这个符号,也就是位于 0x400490 地址处,实际上这是一个 PLT 条目,可以通过反汇编查看相应的代码,不过它代表什么意思呢?

PLT

上述会跳转到 puts@plt 中,可以直接通过 objdump -S test 命令查看反汇编,其中的 .plt 内容如下。

Disassembly of section .plt:

0000000000400480 <.plt>:
  400480:       ff 35 82 0b 20 00       pushq  0x200b82(%rip)        # 601008 <_GLOBAL_OFFSET_TABLE_+0x8>
  400486:       ff 25 84 0b 20 00       jmpq   *0x200b84(%rip)        # 601010 <_GLOBAL_OFFSET_TABLE_+0x10>
  40048c:       0f 1f 40 00             nopl   0x0(%rax)

0000000000400490 <puts@plt>:
  400490:       ff 25 82 0b 20 00       jmpq   *0x200b82(%rip)        # 601018 <puts@GLIBC_2.2.5>
  400496:       68 00 00 00 00          pushq  $0x0
  40049b:       e9 e0 ff ff ff          jmpq   400480 <.plt>

当然,也可以通过 gdb 命令进行反汇编。

(gdb) disassemble 0x400490
Dump of assembler code for function puts@plt:
=> 0x0000000000400490 <+0>:     jmpq   *0x200b82(%rip)        # 0x601018 <puts@got.plt>
   0x0000000000400496 <+6>:     pushq  $0x0
   0x000000000040049b <+11>:    jmpq   0x400480
End of assembler dump.

可以看到 puts@plt 中包含三条指令,而且可以看出,除 PLT0(__gmon_start__@plt-0x10) 所标记的内容,其它的所有 PLT 项的形式都是一样的,而且最后的 jmp 指令都是 0x400480,即 PLT0 为目标的;所不同的只是第一条 jmp 指令的目标和 push 指令中的数据。

PLT0 则与之不同,但是包括 PLT0 在内的每个表项都占 16 个字节,所以整个 PLT 就像个数组。

另外,需要注意,每个 PLT 表项中的第一条 jmp 指令是间接寻址的,比如的 puts() 函数是以地址 0x601018 处的内容为目标地址进行中跳转的。

GOT

也就是说,上述执行的是 jmpq *0x601018,而 *0x601018 就是 0x00400496,就是会调转到 0x400496 所在的地址执行。

----- 实际是顺序执行,最终会调转到0x400400
(gdb) x/w 0x601018
0x601018 <puts@got.plt>:        0x00400496

也就是在 puts@plt 的代码中,没有直接执行下一条指令,而是通过一次跳转后再继续执行下一条指令,那么,为什么要多此一举?这个问题后面解释,这里接着向下看。

最终会跳转到 0x400480 地址处,也就是 .plt 的第一个。

Resolve

看看第一个 .plt 中的内容是什么。

(gdb) b *0x400406               设置断点
(gdb) c
Breakpoint 2, 0x0000000000400406 in ?? ()
(gdb) ni
_dl_runtime_resolve () at ../sysdeps/x86_64/dl-trampoline.S:58
58              subq $REGISTER_SAVE_AREA,%rsp
(gdb) i r rip
rip            0x7ffff7df0290   0x7ffff7df0290 <_dl_runtime_resolve>

如上,在 jmpq 中设置一个断点,观察到,实际调转到了 _dl_runtime_resolve() 这个函数,这里,实际上就是真正链接器查找函数所在的地址,也就是 _dl_runtime_resolve 函数。

验证

实际上,上面所谓的多此一举是实现动态加载的关键操作,第一次的时候,是跳转到下一条,如果引用的地址已经被替换成了需要的地址,那么就可以直接跳转了。

在执行完 callq 0x400490 <puts@plt> 指令之后,那么 0x601018 地址中保存的应该是最新的 puts 函数地址了。

(gdb) break *0x400594
Breakpoint 3 at 0x400594: file main.c, line 6.
(gdb) continue
Continuing.
Hello World.

Breakpoint 3, main () at main.c:6
6               return 0;
(gdb) x/w 0x601018
0x601018 <puts@got.plt>:        0xf7a84010

也就是 0xf7a84010 地址就是真实的 puts 函数地址。

地址解析

在 gdb 中,可以通过 disassemble _dl_runtime_resolve 查看该函数的反汇编,感兴趣的话可以看看其调用流程,这里简单介绍其功能。

从调用 puts@plt_dl_runtime_resolve ,总共有两次压栈操作,一次是 pushq $0x0,另外一次是 pushq 0x200c02(%rip) # 601008,分别表示了 puts 函数在 GOT 中的偏移以及 GOT 的起始地址。

_dl_runtime_resolve() 函数中,会解析到 puts() 函数的绝对地址,并保存到 GOT 相应的地址处,这样后续调用时则会直接调用 puts() 函数,而不用再次解析。

安全风险

如上,在 .got.plt 中保存的是实际已经查找到函数的地址,那么只需要修改这个段,就可以完成程序执行的跳转,也就是常见的攻击手段。

注意,一般主持 NX 的系统中,不会同时设置 Write 和 eXecute 两个权限,也就是说,我们无法覆盖执行的节。

防范措施

也就是所谓的 Relocations Read-only ,一般简称为 RELRO ,包括了两种。

  • Partial RELRO 编译时使用 -Wl,-z,relro 参数,

参考

关于动态库的加载过程,可以参考 动态符号链接的细节