updated:

Linux Program Start Up


本文来自笔者看到__libc_csu_init函数中的通用gadget的一个偶然的念头:这个函数是做什么的,不惜以安全为代价,把这个函数放在这里的目的又是什么?结果在探究这个函数作用的过程中看到了笔者一直忽略的一个问题:程序是怎样加载的?再结合对之前学习的各种知识的回顾,又查了很多有关的资料写成了这篇文章。 重要:由于技术因素,本文所描述的内容并非完全经过笔者亲自验证。大部分无法验证的知识笔者都经过多方求证,一般不会有太大错误。但有一部分来源实在单一,难以验证的知识已经在文章中做了标注。希望您可以指出我描述的不当之处

系统层面的初始化

首先我们来看一下从你在命令行中键入程序的路径到控制权转移到程序本身的这段时间内发生了什么。

(subtopic仅代表执行的先后顺序) 我们看到,在我们在命令行键入可执行文件的路径和参数后,有两件重要的事情发生了。 第一件事是fork函数被调用,它产生了一个子进程,该进程拥有与当前进程完全相同但不共享的虚拟地址空间,然而此时的虚拟地址空间还没有做好运行一个新程序的准备. 此时发生了第二件事:execve函数的调用。execve发起了一个sys_execve系统调用,进一步调用了do_execve函数。该函数其实是__do_execve_file函数的一个封装。其源码位于linux/fs/exec.c中。该函数及其调用的函数所做的事如图所示,这其中的源代码级别的细节我们暂不深究。 在这两件事完成之后,控制权就交到了程序手中,然而此时程序并不直接从main函数开始执行。我们知道,上面的过程仅仅完成了系统层面的初始化,如设置环境变量,压栈命令行参数等。然而在程序层面的初始化工作还没有完成。

程序层面的初始化

上一张图片中显示最终系统调用的返回地址被设置为了我们要执行的程序的入口点,默认情况下这个入口点由链接器决定,它就是我们熟知的_start函数。此时的栈结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
+--------------------+
argc
+--------------------+
argv
+--------------------+
0 padding
+--------------------+
envp
+--------------------+
0 padding
+--------------------+
auxiliary vector
+--------------------+

(这并非完整的的栈结构)目前我们需要用到的大致有命令行参数个数argc,命令行参数数组(是一个字符串指针数组)argv,环境变量数组envp(同样是指针数组)与辅助数组auxiliary vector。 在调用main函数之前,控制权一直在_start手中,下面我们将以一个静态链接的hello world程序为例,一步一步分析控制权是如何从_start交到main函数手中的: 首先是_start函数进行的一些初始化工作,包括图中指出的工作以及对我们即将调用的_libc_start_main函数的参数的设置:

__libc_start_main函数的原型如下:

1
2
3
4
5
6
7
int __libc_start_main(  int (*main) (int, char * *, char * *),  // rdi -> address of main function
int argc, // rsi -> argc
char * * ubp_av, // rdx -> argv
void (*init) (void), // rcx -> __libc_csu_init
void (*fini) (void), // r8 -> __libc_csu_fini
void (*rtld_fini) (void), // r9 -> mov from rdx, rtld_fini 静态链接可忽略
void (* stack_end)); // set at _start+14, the beginning of the stack frame of __libc_start_main

你可能注意到在这里我们并没有把环境变量作为__libc_start_main函数的参数,这是因为__libc_start_main函数的内部调用了一个名为__libc_init_first的函数,在该函数的内部,经过一系列的操作之后,它将会找出位于argv数组之下的envp数组并将其存放在environ全局变量中。 紧接着,我们来到了__libc_start_main函数的内部,该函数位于libc.a中,是对程序的整个生命周期起统筹作用的一个函数。然而由于该函数过于复杂以至于超出了我们今天要讨论的范围,我们只对该函数的几个功能做文字性的描述,而更关注它所调用的几个重要函数。 __libc_start_main函数的主要功能如下:

  • 做一些关于用户和用户组的安全性检查
  • 启动一个新线程
  • 使用我们传入的 __libc_csu_fin与irtld_fini注册一个at_exit函数,用于程序结束时的清理工作
  • 调用我们传入的__libc_csu_init函数
  • 使用argc,argv以及经处理得到的envp作为参数调用main函数并以main函数的返回值作为参数调用exit函数

在这里,我们重点研究__libc_csu_init函数。我们知道,在C++语言中,我们使用一个constructor来构造一个对象,使用destructor来销毁一个对象。其实同样的思想也存在于executable的生命周期中。__libc_csu_init函数便是可执行文件的构造函数。相对应的,__libc_csu_finit函数是可执行文件的析构函数。这两个函数的内容由编译器决定。 先看__libc_csu_init函数的内容:

该函数首先调用了位于.init section的init_proc(或者是init)函数,这个函数名字挺吓人,但实际上只是在编译时开启-pg的情况下为GNU Profile提供支持,与程序性能分析有关。 接下来的内容就很有意思了,因为这个函数的部分内容为ROP攻击提供了一个很好用的gadget,这样的gadget在别处也是罕见的。可以看到在反编译的结果中,它遍历调用了一个函数数组中的所有函数,控制逻辑很简单,其中数组的上下界来自两个环境变量的地址。该数组位于ELF文件的两个特定的段中:.init_array与.fini_array,其中存放着一些初始化所需要的函数:

对于我们例子中的这些函数的解释如下:

  • frame_dummy:该函数与异常的实现有关,用于设置异常处理有关的内容
  • init_cacheinfo:资料缺失
  • __do_global_ctors_aux:这是最有趣的一个函数,在GCC的环境下,它将会调用所有使用__attribute__ ((constructor))添加了constructor属性的函数(包括我们手动添加的) 以及C++中静态对象的构造函数。有了构造函数当然会有析构函数,我们可以使用类似的方法来让我们程序中的某个函数在main函数之前或者之后运行。
  • fini:资料缺失,从代码来看它基本什么都没做就直接ret了=-=

调用完这些函数之后,__libc_csu_init函数返回到__libc_start_main函数,经过一些操作之后,我们来到了最为激动人心的部分:在将必要的参数放入寄存器后,程序控制权终于通过一句call rax转移到了main函数!!

到这里,我们的分析就告一段落了。接下来让我们做一个总结:

嵌套代表函数调用,从上到下是时间流动方向 然而事情好像并非这么简单。 小思考:有谁决定了我们程序层面的启动过程? 首先,__libc_start_main函数是由libc决定的,而_start,__libc_csu_finit等函数则是由编译器决定的。在编译过程中,编译器将这些代码添加到我们的可执行文件中以进行辅助工作。因此在某些情况下,平台之间的差异可能由内核,libc库,编译器共同决定。

等等,是不是忘记了什么?

等等,说到这里,我们忘记了一个非常重要但常常被忽略的过程。对于静态链接的程序来说,上面的说法还是正确的。然而一个系统上的错误让笔者认识到了之前想法的局限性。 我们知道在链接的过程中,我们可以使用-e来指定程序的入口点,从而使我们得以控制程序的初始化过程。为了验证这一点,笔者写了一个小程序:

1
2
3
4
5
6
7
8
// try.c by Sinon
#include
int hello(void) {
printf("Hello World\n");
return 0;
}
// compile with gcc -c try.c
// link with ld -e hello /lib/x86_64-linux-gnu/libc.so.6

编译链接很正常,objdump一下:

1
2
3
4
5
6
7
8
9
10
Disassembly of section .text:
0000000000401020 :
401020: 55 push %rbp
401021: 48 89 e5 mov %rsp,%rbp
401024: 48 8d 3d d5 0f 00 00 lea 0xfd5(%rip),%rdi # 402000 <hello+0xfe0>
40102b: e8 e0 ff ff ff callq 401010 <puts@plt>
401030: b8 00 00 00 00 mov $0x0,%eax
401035: 5d pop %rbp
401036: c3 retq
</puts@plt></hello+0xfe0>

可以看到,冗长的_start没有了,前途一片光明。可是意外发生了:

1
2
3
root@Neumann StartUp                                             [23:20:27]
> # ./a.out
zsh: no such file or directory: ./a.out

这是怎么回事?明明可执行文件就在当前目录下,为什么会报错呢?不要慌,strace一下:

1
2
3
4
5
root@Neumann StartUp                                             [23:22:31]
> # strace ./a.out
execve("./a.out", ["./a.out"], 0x7fff89561760 /* 67 vars */) = -1 ENOENT (No such file or directory)
strace: exec: No such file or directory
+++ exited with 1 +++

我们可以看到,No such file or directory的背后是execve的调用失败,我们知道execve调用失败是会设置errno环境变量的,不同的errno的值代表着不同类型的错误。既然这里出现了No such file or directory错误,但我们的可执行文件又是确实存在的,排除法,只有可能是某个在可执行文件执行之前执行的文件找不到了,但根据前面我们天衣无缝的分析,好像没有这样的一个符合条件的文件。这又是怎么回事呢? 一切证据都在说明我们前面的分析是有局限性的,有某个我们不知道的文件在我们所编的写的程序的入口点hello函数之前执行了。经过排查发现我的操作系统上缺少了一个软链接,这个链接位于/lib/ld64.so.1。那么罪魁祸首几乎已经揪出来了,只查最后的确认了。 添加缺失的软链接后,使用gdb调试a.out,使用starti指令来在程序执行的第一条指令处停止。我们发现程序停在了一个名为_start的函数处。奇怪了,我们不是已经指定了入口点是hello了吗?_start中的代码又是哪里来的?

紧接着我们注意到rip不太对(0x7ffff7fd5090),使用vmmap确认一下:

果然,我们正在执行/usr/lib/x86_64-linux-gnu/ld-2.30.so映射的内存中的代码。因此对于动态链接的程序来说,有一段代码是先于我们所制定的入口点执行的。下面让我们探索一下这段代码,以及它所在的so文件的作用。 值得一提的是,这段代码的调用在我们接下来要执行的程序主体(即hello函数)看来是隐形的。栈的内容在这段代码调用前后完全没有发生变化:

动态链接:ld.so

作为Program Interpreter的ld.so

实现这一部分功能的libc源码可以在这里找到,这一部分代码相当刺激,因为这一部分代码本身就是为了解决动态链接的重定位问题的,因此这段代码中不能使用任何与重定位有关的数据,这意味着我们不能使用函数(inline函数除外),全局变量以及局部变量,否则就会产生“鸡生蛋,蛋生鸡”的问题。在这种情况下,我们称这段代码是boostrap(自举)的,即“ the technique for producing a self-compiling compiler ”。那么显然这段代码的作用是重定位ld.so自身,使得本动态链接库可用,然后再对我们的可执行文件进行重定位。 接下来,ld.so开始解决动态链接库的依赖问题。这时有两样东西参与了进来:其一是一张名为Global Symbol Table的符号表(看清楚不是GOT),其二是我们之前在 动态链接库基本知识 一文中提到过的.dynamic section中拥有DT_NEEDED的d_tag的项。ld.so将会维护这张全局符号表,首先将其自身和可执行文件中的符号添加进去,然后再对上面提到的DT_NEEDED项做一个广度优先(一般而言)的遍历,将所有依赖的符号添加到全局符号表当中去(冲突符号只导入第一个,由于这种性质,共享模块的函数间调用也要通过plt来实现。PC相对寻址在遇到本文件中的符号被覆盖的情况时将会出现不一致的现象)。 当上述过程完成后,这个全局符号表中就包含了所有可能需要的符号。接下来就到了极为关键的一环:重定位。对于采用了延迟绑定技术的程序而言,一个需要破除的观念就是重定位是一时的行为,实际上此时ld.so仅仅进行一些特定类型的重定位(如函数指针的值的查找)。在延迟绑定机制的作用下,重定位的过程实际上可能贯穿函数的生命周期。 此处存疑!!!信息来源单一,无法查到其他有关“全局符号表”的资料!! 接下来的过程便是调用各个依赖的.init与.fini段的函数进行初始化。调用完成后,控制权移交给可执行文件内定义的_start函数,继续进行与静态链接可执行文件类似的初始化过程。 注意在上述过程中,ld.so使用了所谓auxiliary vector(辅助数组)中的数据,该数组位于栈中envp数组的下方,包含了一些工具性质的信息。得到其地址的方式与得到envp地址的方式类似。

此处大量引用《程序员的自我修养》中的内容,作者写的太好了(可惜不知道对不对,或者是否过时=-=)

作为Runtime Linker的ld.so

此处可以参照上一篇 Process of Lazy-Binding。暂时不再做过多解释。(当然作为Runtime Linker的ld.so功能不止如此,由于知识的局限性,只能暂时放下这个问题)

无关的感悟:延迟加载与局部性

不知你是否注意到一个问题:为什么上面的内存映射中没有出现libc? 这里牵扯到Linux的内存分页的知识,这里只进行简单的描述:为了更高效的利用内存空间,Linux对内存进行了分页,一般以0x1000个字节为一页。当程序请求某处的数据,而该处刚好没有映射对应的页时,会触发一个页错误(page fault)此时控制流将会突变,转而去将相应的页映射到虚拟地址空间中,然后重新执行触发了页错误的指令。将页映射到虚拟地址空间的这一过程被称为换入。对应的,当一个页长期不使用时,操作系统就会取消它的映射,使用别的数据来覆盖它,这一过程被称为换出。 看到这里,再联系到之前我们提到的延迟绑定技术。这里面似乎蕴含着同一种思想,即对于那些需要花费一定时间来完成的事情,如果不是必要,就不去做。不仅是操作系统层面存在这样的行为,在web开发是同样有这种思想存在,如图片的延迟加载(只有图片出现在用户的视野中时才进行加载)。 那么此时可能又会有疑问了:如果一个程序在一个很大的地址范围内随机使用数据,我们的内存分页岂不是帮了倒忙,程序一而再再而三地被打断的过程中存在许多的隐形开支,如页错误引起的中断所造成的上下文切换便是一笔不小的开支。在笔者看来,在背后支持着页机制,延迟绑定等机制的就是之前我们在讨论cache时提到的局部性。 即程序,甚至是人自身,倾向于在时间上频繁访问,在空间上集中访问数据。 这不仅仅是程序的普遍倾向,也是开发者在进行开发时所需要主动注意的一点。以笔者的个人理解,局部性所带来的,是一种加于数据之上的隐性的权重。频繁访问的数据自然拥有更高的权重,在操作系统对局部性的支持之下,它驻留内存,甚至是缓存的时间也将会更长,程序也将会运行的更快。使用不频繁的数据会有更大的几率被换出,可能被放到速度慢一点的swap中。 可以看到,这个我们想象出来的“权重”的大小并不是由开发者指定的,而是开发者和操作系统共同构成的精密的系统在局部性的驱动下共同创造出来的,从而同时保证了时间和空间上效率的最大化。或许这也算得上是程序设计的哲学吧。 本文参考: https://blog.csdn.net/conansonic/article/details/54236335 http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html https://stevens.netmeister.org/631/elf.html https://stackoverflow.com/questions/12697081/what-is-the-gmon-start-symbol 《程序员的自我修养》第七章 《深入理解计算机系统》第二部分8.4进程控制 Glibc-2.29.9000部分源码 System V Application Binary Interface AMD64 Architecture Processor Supplement Draft Version 0.99.7 Oracle Linker and Libraries Guide Part No: 819–0690–10 November 2011


← Prev 一道国赛题的多种解法 | Process of Lazy-Binding Next →