0%

【CSAPP#0x02】程序:从源码到终止

概述

本文将从源代码开始,追溯一个简单程序从编译到运行结束的全过程。
系统环境是 WSL2 Ubuntu 20.04.5 LTS,编译使用 gcc 和 glibc 版本为 gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0 以及 GLIBC 2.31-0ubuntu9.9
使用的程序代码如下:

1
2
3
4
5
6
7
8
#include <stdio.h>
#include <stdlib.h>
int main() {
char *s = (char*)malloc(16);
scanf("%15s" , s);
printf("Hello %s\n" , s);
return 0;
}

1 编译和链接

我们平时使用的”编译器”gcc,其全称是 GNU Compiler Collection,是一套组合程序,即教材中的 compiler driver。
gcc将程序编译为完整程序的过程可以分为如下几步:

  1. 预编译:C 预编译器 cpp 会处理源代码中的宏以及引用,并简化代码(删除所有注释,调整缩进)
  2. 编译:C 编译器 cc1 会将 C 代码翻译成汇编代码文本
  3. 汇编:汇编器 as 将根据汇编代码文本生成一个二进制的可重定位目标文件
  4. 链接:链接器 ld (注意加载器是 ld.so)把多个可重定位目标文件以及需要的系统目标文件进行链接,生成二进制可执行文件

在实际操作中,我们可以一步一步完成上述的整个过程。
预编译: cpp ./prog.c prog.i
编译: /usr/lib/gcc/x86_64-linux-gnu/9/cc1 ./prog.i -o prog.s
汇编: as ./prog.s -o prog.o
链接: gcc ./prog.o -o prog
最后一步还是不得不使用了 gcc,这是因为直接使用 ld 或者其封装 collect2 需要我们自己指定链接用的库,如果直接使用会报如下错误(找不到某些符号在哪):

1
2
3
4
5
6
$ ld ./prog.o -o prog
ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000
ld: ./prog.o: in function `main':
prog.i:(.text+0xe): undefined reference to `malloc'
ld: prog.i:(.text+0x2a): undefined reference to `__isoc99_scanf'
ld: prog.i:(.text+0x42): undefined reference to `printf'

在使用 gcc 进行编译时,可以通过参数 -v, --verbose 来显示编译过程的信息。得到的信息过于复杂,但是我们也可以从中发现一部分 ld 报错的原因,在 gcc 调用 collect2 的时候,参数多得吓死人,但从中我们可以看到几个教材中出现过的熟悉的身影,这里按顺序列举一下—— Scrt1.ocrti.ocrtbeginS.o、一大堆 -L 用来指定库、crtendS.ocrtn.o
报错中说找不到符号_start,是因为没有链接 Scrt1.o 。报错中说找不到某些库函数,是因为没有用 -L 告诉链接器有哪些库。由于库的目录比较多,涉及到繁琐的细节,因此这里就不深究了。

我们可以检查一下每一步得到的中间文件(附件里都有),来探究一下到底每一步干了什么。

1.1 预编译

预编译之后得到一个极大无比的文本文件,与源文件相比,多出来的部分主要是两个 #include 被展开,其中包含了一大堆的函数声明。即使程序没用到这些函数,但这些函数在头文件里存在,就会被拿过来放到 .i 文件中。

1.2 编译

令人感到神奇的是,编译之后得到的汇编代码文本文件,居然只有短短的 41 行。其中有汇编代码,也有诸如 .section .string 的指令。
我们可以看到程序用到的两个常量字符串 "%15s""Hello %s\n" 位于 .rodata section,而 main 这个全局标号位于 .text 节(代码节)。

1.3 汇编

这一步能够生成目标文件,由于是二进制文件所以体积一下子变大了。
作为一个 ELF 文件,目标文件具有严格的规范,因此汇编器除了翻译 prog.s 中的指令以外,还添加了许多内容来满足 ELF 的格式。我们可以借助 objdumpreadelf 来看看里面有哪些东西。

首先看文件头(elf header),使用指令 readelf -h ./prog.o ,其中包含了文件的魔数、架构、大端还是小端、section headers 的位置、还有各种 flag 信息。

在 section header table 中,存储了目标文件各个 section 的名字、大小、相对于文件起始处的偏移(即位置)等信息,这里结合教材观察几个重要的section。

  • .text 节紧跟在文件头之后,有 0x48 个字节,可以用 objdump -d 反汇编程序所有可执行代码;
  • .rel.text 节记录了需要重定位的代码地址;
  • .data 节和 .bss 节分别存储已初始化和未初始化的全局变量,这里大小都为 0;
  • .rodata 节存储只读的常量,大小为 0xf,恰好是两个常量字符串大小相加,十分合理;
  • .symtab 节记录了函数和全局变量的信息 (readelf -s),比如 main 和用到的库函数(UND);
  • .strtab 节记录了符号表中符号的名称 (readelf -p .strtab),比如 “main” 和 “malloc”。

1.4 链接

链接过后的 目标文件成为了可执行文件,体积一下子从 1.7K 变成了 17K。

首先是多了一个端头部表,或者称为 PHT(Program Header Table),用来指示加载器如何加载各个 segment 到不同的页中(包括各个 segment 的物理和虚拟地址、物理和虚拟大小等信息)。
其次是多了许多的代码,重要的如 _start,处理动态链接的 .plt segment,_init_fini
此外还添加了许多数据结构,重要的如记录库函数真实地址的 GOT 表,记录动态链接所需信息的 .dynamic 节,本报告后续会提到这些数据结构。

2 命令行执行

终于到了激动人心的执行时刻。这一部分将会探索从命令行执行指令 ./prog,按下回车键,一直到程序开始执行 _start 中的第一条指令前,计算机都完成了哪些工作。

首先,shell 程序会对指令进行解析,把字符串拆分成一个字符数组,这里就是单纯的一个 ["./prog", NULL]
在 shell 确认这不是一个内置的指令后,它会 fork(系统调用)出一个子进程,内核为新的子进程创建其数据结构、分配一个新的 PID 、并复制一个 mm_struct 然后把里面的页都标记成 private copy-on-write,从抽象上讲已经为新进程复制了所有的空间。

由于我们没有用 '&' 指定后台运行,因此 shell 主进程会调用 waitpid 系统调用来等待子进程运行结束。
而对于子进程,通过 strace 工具可以明确看到,接下来它会调用 execve("./prog", ["./prog"], 0x7ffea1185a30) = 0,来让自己”变成”我们运行的程序,或者说 .prog 替换了 shell 的子进程的程序。其中,0x7ffea1185a30 是环境变量数组的地址, shell 会直接让子进程继承自己的环境变量。


结合 execve 的 man page 和教材,execve(即内核)会负责完成程序的加载:删除原有用户空间的地址映射,然后重新映射新程序的代码段、数据段、栈的区域。如果程序是动态链接 ELF 的话,内核还会调用 PT_INTERP segment 中记录的动态加载器。使用 glibc 编译的话就是 ld.so
由于是内核处理,因此 strace 不会记录这些过程。
可以用 ldd 工具查看+查找一个 ELF 需要的动态链接库和动态加载器。我们看到程序要求的加载器为 /lib64/ld-linux-x86-64.so.2

1
2
3
4
$ ldd ./prog
linux-vdso.so.1 (0x00007ffc4b5b4000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1b9400b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f1b9420e000)

另外,由于按需加载原则,实际上这里并没有将程序真的从硬盘中取到内存中,而只是在页表中添加了其映射关系。

我们借助 strace 工具以及进程的 /proc/[PID]/maps 来分析这个过程。
首先子进程会调用 execve,在执行完 execve 后出现了一大堆系统调用,是加载器 ld.so 加载共享链接库的过程。(通过共享库的加载地址和 strace 打印的 mmap 返回地址对照即可发现)另外,加载器 ld.so 本身的加载由 execve (也就是内核)完成,因为我们在 execve 之后并不能看到加载加载器的系统调用。
在动态加载器操作完之后,共享库映射关系都已经确定(或者说已经 allocated 了),此时才会真正开始从程序的入口处执行程序。为了证实这一点,我们可以通过 gdb 下断点断在 _start 处,然后查看此时程序的虚拟地址空间映射(这里使用了 gdb 插件 pwndbg 提供的 vmmap 指令),可以看到这时共享库确实已经被加载完毕了。

之后,终于进入程序运行流,开始运行程序。

3 启动 main 函数

本部分我们来简单探索一下从 _startmain 的过程。由于这部分教材中并没有详细讲解,因此本报告中也不深挖这部分的细节。

简而言之,_start 调用 __libc_start_main,顾名思义是位于共享库 libc 中的一个用来启动 main 函数的函数,其实同时也负责在 main 函数返回后处理程序后事。
然后 __libc_start_main 会调用 main 函数,进入程序员编写的代码部分。

我们可以通过 gdb 来观察这个过程,只需要从 _start 一步一步执行即可。逃课的方法就是把断点下在 main,然后使用 backtrace 查看这时的函数调用关系:

1
2
3
4
pwndbg> backtrace
#0 0x000055555555515d in main ()
#1 0x00007ffff7df0083 in __libc_start_main (main=0x555555555159 <main>, argc=1, argv=0x7fffffffe008, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffdff8) at ../csu/libc-start.c:308
#2 0x000055555555509e in _start ()

更具体一些的话,__libc_start_main() 会调用程序静态链接的 __libc_csu_init() 函数,这个函数又会调用位于 .init 段中的 _init_proc() 和位于 .init_array 中的函数。(这是一个函数指针数组)
此外,__libc_start_main() 还会调用 _cxa_atexit(),这个函数可以让库函数 exit() 在退出程序前执行指定的函数,这里是让 exit() 执行 __libc_csu_fini() 函数。
在进行完上述步骤后,它才会调用 main 函数,真是十分复杂的初始化过程。报告写得如此详细,是因为我接触过一道通过修改与 .init_array 类似的 .fini_array 中函数指针的地址来完成攻击的 CTF 题目(pwnable. tw-3x17)(这两个全局变量竟然是 RW 的)。
本部分参考了 linux编程之main()函数启动过程

4 运行 main 函数

进入 main 函数的执行!main 函数作为一个用到了局部变量的用户态函数,会在用户栈中有属于自己的栈帧,因此在函数的开头和末尾都有用于开辟、退出栈帧的代码。

在我们的程序中,main 调用了三个库函数—— malloc(), scanf() 以及 printf()。在调用它们之前,main 函数会将参数放到 rdi 和 rsi 等寄存器中(在汇编指令中可能会放到 edi 等寄存器中,由于高 32 位会自动清零,这么做可以缩短代码长度),这是 64 位 Linux 的规约。在 32 位 Linux 下就不会这么传参,而是将参数按顺序放在栈上(第一个参数在地址最低处,以此类推),然后再调用函数(因此返回地址上面就是其参数)。

之后本章将会分为三个小节——动态链接、动态内存分配和 I/O。

4.1 动态链接

动态链接库 libc.so.6 在被加载到内存时,由于 Linux 系统默认开启的 ASLR 保护,它会被加载到一个随机的位置,不过仍然满足基础的 4KB 的页对齐(也就是其基址最低 12 比特一定是 0)。程序需要调用的库函数,其实际位置(指位于进程虚拟内存空间的地址)在加载器 ld 用 mmap 把共享库映射到进程的虚拟内存空间之前是未知的,因此在程序开始运行后我们需要处理动态链接的”重定位”。
之所以这里重定位打了个引号,是因为动态链接的符号,其重定位机制和静态链接大有不同。静态链接的重定位就是直接修改代码中的地址,但动态链接不是这么处理的。
理由之一是进程的代码段权限是 RX,也就是不可写的,要是可写的话会产生严重的安全隐患。但对于这个理由我可以提出疑惑:如果让 ld 在程序的 _start 开始执行之前,就由 ld 做好全部代码的重定位,然后再用 mprotect 系统调用修改代码段权限为不可写,不是一样安全吗?
但是问题来了,这样对大量引用库函数的程序非常不友好:在程序还未开始时,ld 会花较大的时间开销来进行重定位工作,这时的重定位可不像编译软件时一样慢点也就慢点了,而是会实实在在地增加程序运行的启动时间。
因此,类似于按需调页的机制,动态链接也使用了延迟绑定(Lazy Binding)的机制,只在用到库函数的时候才去处理它的重定位。显然,上述修改代码段的地址引用的重定位方法无法做到安全的延迟绑定,因此有了 PLT 和 GOT 表机制。

简而言之,GOT(Global Offset Table)存符号地址,PLT 存负责调用 GOT 的代码。
回到我们的 prog,当它调用库函数时,它实际调用的是 PLT 中的代码,可以用 objdump 看到:
call 1040 <malloc@plt>
call 1050 <__isoc99_scanf@plt>
call 1030 <printf@plt>

malloc 为例说明延迟绑定机制,注意到我的环境下编译得到的 PLT 和 GOT 机制与教材中有差异,但本质不变。
prog 第一次调用 malloc 时,GOT 中还没有其实际位置,而是保存着 PLT 中某处的代码地址。所以第一次调用 malloc 的大致流程如下,我们使用 gdb 追踪一下这个流程 :

  1. 调用 malloc 对应 PLT 条目代码,并跳转到 GOT 当前记载的地址 0x0000555555555040

    1
    2
    3
    4
    5
       0x555555555080 <malloc@plt>                  endbr64 
    ► 0x555555555084 <malloc@plt+4> bnd jmp qword ptr [rip + 0x2f95]

    pwndbg> x/gx $rip+0x2f95+0x7 # 加的0x7是该指令本身长度
    0x555555558020 <malloc@got.plt>: 0x0000555555555040
  2. 虽然 0x0000555555555040 不像书中一样是 malloc@plt 的第二条指令,但其工作和书中相同:将 malloc 对应编号压栈并调用 PLT[0]:

    1
    2
    3
    ► 0x555555555040                                   endbr64 
    0x555555555044 push 1
    0x555555555049 bnd jmp 0x555555555020
  3. PLT[0]将 GOT[1]压栈并调用 GOT[2],也就是负责处理动态链接的 ld.so 中的库函数 _dl_runtime_resolve_xsavec ()

    1
    2
      0x555555555020  push   qword ptr [rip + 0x2fe2]  <_GLOBAL_OFFSET_TABLE_+8>
    ► 0x555555555026 bnd jmp qword ptr [rip + 0x2fe3] <_dl_runtime_resolve_xsavec>
  4. 动态链接器将 GOT[“malloc”]覆写成其实际地址,并直接调用之。此详细过程严重超纲因此不在本报告研究范围内。在从 malloc 返回之后,我们用 pwndbg 的命令 got 查看当前 GOT 表,可以看到只有 malloc 地址被填好了,还没用到的 printfscanf 都指向 PLT 某处:

    1
    2
    3
    4
    5
    pwndbg> got
    GOT protection: Partial RELRO | GOT functions: 3
    [0x555555558018] printf@GLIBC_2.2.5 -> 0x555555555030 ◂— endbr64
    [0x555555558020] malloc@GLIBC_2.2.5 -> 0x7ffff7e660e0 (malloc) ◂— endbr64
    [0x555555558028] __isoc99_scanf@GLIBC_2.7 -> 0x555555555050 ◂— endbr64

另外,值得一提的是 Linux 的 RELRO 保护机制。
开启了 Full RELRO 保护的 binary 会在 main 开始运行前就将所有的 GOT 表项填充完毕,程序执行时 GOT 表权限不可写,从而防止攻击者覆写 GOT 来劫持程序控制流。
不知为何,我的环境下使用 gcc ./prog.c -o prog 编译出的可执行文件默认开启了 Full RELRO 而非采用延迟绑定的 Partial RELRO,因此为了开启延迟绑定,需要添加编译选项 -z lazy。上面的分析就是我开启了延迟绑定之后重新编译后完成的。

4.2 动态内存分配

动态内存分配的过程其实可以拆分成多层,我们关注三层:

  • 用户程序 prog,调用 malloc 函数
  • 库函数 malloc ,负责调用系统调用 brkmmap
  • 系统调用 brkmmap (内核代码)负责处理虚拟页分配的工作
    本节重点关注中间那层—— malloc 可以看作一个对 brkmmap 的封装,在内核给的大块空间的基础上,根据用户需求切割成一个个小的 chunk 给用户使用,为了增加 locality 而编写了一套十分复杂的已释放区块复用&回收的机制。

由于我们的 prog 没有多线程,因此他的堆是通过 brk 来分配的。我们通过 strace 输出和 gdb 来尝试观察。在 gdb 中使用指令 catch syscall brk 可以捕捉 brk 系统调用,我们第一次捕捉到是在运行 ld.so 中的代码时,对应 strace 开头捕捉到的一次。第二次就是运行 malloc
时了,所以 malloc 实际调用了两次 brk

1
2
brk(NULL)                               = 0x563917de6000
brk(0x563917e07000) = 0x563917e07000

第一次调用是为了获取当前堆顶指针的位置(虽然这时候堆还不存在),第二次获取是为了设置堆顶指针的值,也就是给堆申请了空间,简单计算得出申请大小为 0x21000,也就是 33 个页(132KB)。

在得到这么大一片空间后,malloc 会从其中分出一小部分来给用户。我们使用 pwndbg 来查看从 malloc 返回后堆的区块情况:

1
2
3
4
5
6
7
8
9
10
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x555555559000
Size: 0x291
Allocated chunk | PREV_INUSE
Addr: 0x555555559290
Size: 0x21
Top chunk | PREV_INUSE
Addr: 0x5555555592b0
Size: 0x20d51
  • 地址最低处的 0x290 大小的区块和一种释放区块缓存机制 Tcache 有关,这里不细究。
  • 中间的那个区块就是用户程序申请得到的空间,0x21 中那个 1 是一个 flag,表示前一个区块正在使用中(PREV_INUSE bit),0x20 而不是 0x10 是因为这个 chunk 的前 0x10 字节用来存储一些 metadata(具体来说是 prev_size 和 size 字段),后面的 0x10 是真正给用户使用的空间。因此,malloc 的返回地址也不是这里显示的 chunk 地址,而是加了 0x10 后的地址。
  • 最后一个是特殊的 Top chunk,malloc 用这个超大的 chunk 来指代没被分配给用户的空间

我们在调用 scanf 并输入 "aaaabbbbccccdddd" 后再来看看这个 chunk 的内容:

1
2
3
pwndbg> x/4gx 0x555555559290
0x555555559290: 0x0000000000000000 0x0000000000000021
0x5555555592a0: 0x6262626261616161 0x0064646463636363

这就证实了上面介绍的 chunk 的结构。我们调用 scanf 时限制读取 15 大小,因此这里用户可用的 0x10 个字节最后正好用来存放 NULL Byte,没有出现溢出。由于小端法,这个地址最高位的 '\x00' 被理解为一个八字节整形的最高位。

4.3 I/O

IO 相关库函数和 malloc 一样,是封装了系统调用 readwrite 并提供更复杂接口功能的函数。scanfprintf 会使用从 shell 那里继承下来的文件描述符 stdinstdout 来读取和输出。
IO 相关库函数会有自己的 buffer,而非直接进行输入输出。在调用了 scanf 后,我们再在 pwndbg 里使用 heap 指令,就可以发现 scanf 调用 malloc 分配了一块大小为 0x411(申请大小为 0x400)的空间,这就是输入的 buffer;在 printf 结束后同样可以看到一块输出的 buffer。(我们甚至还可以检查一下 Buffer 里的内容,但报告已经满 8 页就不看了)

1
2
3
4
5
6
7
Allocated chunk | PREV_INUSE
Addr: 0x5555555592b0
Size: 0x411
Allocated chunk | PREV_INUSE
Addr: 0x5555555596c0
Size: 0x411
Top chunk | PREV_INUSE

5 程序退出

当 main 函数返回后,程序回到 __libc_start_main,然后调用了库函数 exit。库函数 exit 会调用系统调用 exit。内核具体干了什么超出了 ICS 的教学范围,这里我们就快进到进程已终止。
如果不被父进程回收的话,那么这个程序会一直保持僵尸状态;不过我们运气很好,shell 主进程还一直 waitpid 着呢。于是 shell 把它的子进程回收了,在命令行上打印出一个 prompt,然后继续等待用户输入下一个指令。至此,程序运行完成!