这是一篇分析Linux应用程序启动过程的文章,从ELF的基本格式,段和节如何组成一个ELF可执行文件,到应用程序的加载和启动运行的流程做了一个完整的介绍,最后也稍稍涉及到安全性相关的设计(PIE/PIC, GOT/PLT)。
前言
KVM已经显得过时了,容器也已经热过了,它们都是对计算机资源的一种隔离和抽象。比它们还要古早,并且在今天也仍然具备强大的生命力的就是操作系统下的进程,进程是一个经典的对计算机资源进行隔离和抽象的设计,让每一个进程以为自己是独占整个计算机的处理器,内存,及各种输入输出设备,简化了程序设计的模型,通过给进程提供一系列的标准库的接口,也提高了它的可移植性。<br> 在做应用程序的设计和开发的过程中,标准库及之下的部分往往不显山露水,但偶尔也有可能遇到问题,如何解决这一类偏底层的问题,有时候也挺重要的。我在工作中也不时遇到一些应用程序执行失败于main函数之前的情况,不了解启动过程往往就束手无策了。<br> 今天就带着大家把Linux进程的启动过程掰扯一遍。Windows进程的基本原理应该类似,具体实现肯定有差异,这篇也可以作为一个参考。<br>
准备工作
我们从hello world开始,用几行代码来构建一个最简短的Linux下的应用程序。<br> 首先用下面的代码生成一个hello-world.c。一句“Hello, world!”分俩行写算是个彩蛋,帮助分析一个底层的机制,后面会详细解释。 <br>
#include <stdio.h>
int main(int argc, char **argv)
{
printf ("Hello, ");
printf ("world!\n");
return 0;
}
从源码编译生成二进制可执行程序。<br>
$ gcc -o hello-world hello-world.c
执行这个可执行程序,就产生了一个运行的进程,并输出那句经典的“Hello, world!”。<br>
$ ./hello-world
Hello, world!
应用程序是怎么装配起来的
我们来看看这个新编译出来的hello-world应用程序,Linux下有一个非常强大的工具“file”,可以用它来看一下。<br>
$ file hello-world
hello-world: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=900fd32a29411bb434c45f7601dbffcc6b520cf6, for GNU/Linux 3.2.0, not stripped
工具file告诉我们它是一个ELF格式的64位的地址无关(PIE)的可执行程序。请参考资源[3]。ELF是Linux下目前的一个主流的可执行程序的格式,有很多的ELF的工具可以帮我们做进一步分析。<br>
ELF文件格式简析
我们先用工具readelf来粗略的看一下hello-world。请参考资源[2/4/5]
$ readelf -h hello-world
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1080
Start of program headers: 64 (bytes into file)
Start of section headers: 14024 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 30
其中有几个我们接下来会关心的信息:<br>
- 程序入口地址(Entry point address)是0x1080。<br>
- 程序段表(program headers)的大小(56字节)和段的数量(13)。<br>
- 程序节表(section headers)的大小(64字节)和节的数量(31)。<br>
- 程序段(program segment)是一个加载时要使用的重要设施,一些典型的段包括代码段(text),数据段(data),未初始化数据段(bss, block started by symbol),等等。<br>
- 程序节(section)是链接时要使用的重要设施,一个程序尤其是大型的程序是由很多的编译中间态的object文件(.o)装配而成的,每一个object文件里有很多不同的节,节里面也包括text, data, 及一些其他的特定目的的节,比如plt(Procedure Linkable Table), got(Global Object Table)等等。<br>
段和节
那么段和节到底啥关系?节可以认为是程序装配期间的最小的组织单位,不同的.o文件里头的同名的节在进入最终的可执行程序的时候一般都会被汇编进一个节。段是程序加载期间的一个基本单位,不同的节,如果从加载角度有相同的属性就会被合并进相同的段,所以段的数量比节要少很多。<br> 下面我们用工具readelf再读一下hello-world,可以看到程序有13个段,每个段没有名字,只有类型及映射表里的序号。从底下的节到段的映射表(Section to Segment mapping)里可以看到每一个段到底包括了哪些节。<br> 从中我们也可以看到一些重要的段,比如INTERP段,它特地描述了加载器(loader)它老人家的位置(/lib64/ld-linux-x86-64.so.2);以及4个LOAD段,它们都是要加载到内存里头的实实在在占用空间的段,从它们的flags中如果看到E说明是运行执行的应该是代码段了,有W的允许写应该是代码段,只有R的是只读的,应该是一些只读的运行期需要的数据。从底下的映射表里头也可以发现一些线索,包括.text节的应该是代码段,包括.data节的应该是数据段。<br>
$ readelf -l hello-world
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1080
There are 13 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000660 0x0000000000000660 R 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x00000000000001b5 0x00000000000001b5 R E 0x1000
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000
0x00000000000000f4 0x00000000000000f4 R 0x1000
LOAD 0x0000000000002db0 0x0000000000003db0 0x0000000000003db0
0x0000000000000260 0x0000000000000268 RW 0x1000
DYNAMIC 0x0000000000002dc0 0x0000000000003dc0 0x0000000000003dc0
0x00000000000001f0 0x00000000000001f0 RW 0x8
NOTE 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R 0x8
NOTE 0x0000000000000368 0x0000000000000368 0x0000000000000368
0x0000000000000044 0x0000000000000044 R 0x4
GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R 0x8
GNU_EH_FRAME 0x0000000000002014 0x0000000000002014 0x0000000000002014
0x0000000000000034 0x0000000000000034 R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x0000000000002db0 0x0000000000003db0 0x0000000000003db0
0x0000000000000250 0x0000000000000250 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .data .bss
06 .dynamic
07 .note.gnu.property
08 .note.gnu.build-id .note.ABI-tag
09 .note.gnu.property
10 .eh_frame_hdr
11
12 .init_array .fini_array .dynamic .got
用工具readelf的-S参数可以进一步看到节的详细信息,因为篇幅关系就不进一步展开了。<br>
换一个工具看看
今天已经有很多的成熟的工具可以用来分析Linux下的应用程序的结构了,比如Hopper Disassembler,对ELF的头及段和节的解读非常直观,但有一个缺点就是属于收费产品(单个license 99美元),但它有一个免费替代产品ghidra,由美国国安局(National Security Agency)友情呈献,大家可以酌情考虑,涉及机密信息的就不要用它了。<br> 下面是ghidra对hello-world的ELF头的解读,其实没有"readelf -h",但对想要逐个字段解读的程序员还是挺有帮助的。算是对readelf的一个补充吧!<br>
00100000 7f 45 4c 46 Elf64_Ehdr
02 01 01 00
00 00 00 00
00100000 7f db 7Fh e_ident_magic_num
00100001 45 4c 46 ds "ELF" e_ident_magic_str
00100004 02 db 2h e_ident_class
00100005 01 db 1h e_ident_data
00100006 01 db 1h e_ident_version
00100007 00 db 0h e_ident_osabi
00100008 00 db 0h e_ident_abiversion
00100009 00 00 00 00 db[7] e_ident_pad
00 00 00
00100010 03 00 dw 3h e_type
00100012 3e 00 dw 3Eh e_machine
00100014 01 00 00 00 ddw 1h e_version
00100018 80 10 00 00 dq _start e_entry
00 00 00 00
00100020 40 00 00 00 dq Elf64_Phdr_ARRAY_00100040 e_phoff
00 00 00 00
00100028 58 3d 00 00 dq Elf64_Shdr_ARRAY__elfSectionHeaders__00000000 e_shoff
00 00 00 00
00100030 00 00 00 00 ddw 0h e_flags
00100034 40 00 dw 40h e_ehsize
00100036 38 00 dw 38h e_phentsize
00100038 0d 00 dw Dh e_phnum
0010003a 40 00 dw 40h e_shentsize
0010003c 25 00 dw 25h e_shnum
0010003e 24 00 dw 24h e_shstrndx
执行流初探
分析应用程序执行流的话,我们从它的入口地址开始,还记得前面拿到的0x1080这个地址吗?它是名义上的第一条指令。可以用工具nm先看一下hello-world里头的符号。<br> 0x1080其实并没有对应到我们的main函数,而是一个_start函数。说明main其实还不是严格意义上的最早的入口,搞清楚main之前发生了什么,是本篇的一个重要任务,这样如果我们的应用程序在main得到执行之前出了错,我们也可以做分析了。<br>
$ nm hello-world
...
0000000000001169 T main
U printf@GLIBC_2.2.5
U puts@GLIBC_2.2.5
00000000000010e0 t register_tm_clones
0000000000001080 T _start
...
接下来我们用反汇编工具来分析hello-world来看看_start做了什么,Linux里最经典的反汇编工具是objdump。<br> 要想理解_start函数的反汇编,我们需要一点汇编语言和函数调用惯例的知识。<br> 汇编语言有俩种书写惯例,一个是AT&T惯例,一个是Intel惯例,它们在一些指令的操作数顺序上有差别。objdump的输出默认是AT&T惯例,它有命令行参数(-M intel)支持使用Intel惯例。<br>
- AT&T: mov source_reg, destination_reg <br>
- Intel: mov destination_reg, source_reg <br> 另外一个是函数调用时,使用寄存器传参的惯例。请参考资源[8]。Linux下函数参数的前6个依次使用RDI, RSI, RDX, RCX, R8, R9。 <br> 所以这个_start函数其实挺简单的,它准备好各个参数(main函数的地址是第一个参数)后,就调用了__libc_start_main函数。这个函数后面准备了一条停机命令hlt(这是个特权指令,普通Linux,正常情况下应该是不会返回了,否则会出exception了;可能一些不带操作系统的嵌入式系统它会起作用)。应该不难猜出__libc_start_main会调用我们的main,并处理main的返回值。<br>
$ objdump -d -M intel hello-world
...
0000000000001080 <_start>:
1080: f3 0f 1e fa endbr64
1084: 31 ed xor %ebp,%ebp
1086: 49 89 d1 mov %rdx,%r9
1089: 5e pop %rsi
108a: 48 89 e2 mov %rsp,%rdx
108d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
1091: 50 push %rax
1092: 54 push %rsp
1093: 45 31 c0 xor %r8d,%r8d
1096: 31 c9 xor %ecx,%ecx
1098: 48 8d 3d ca 00 00 00 lea 0xca(%rip),%rdi # 1169 <main>
109f: ff 15 33 2f 00 00 call *0x2f33(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
10a5: f4 hlt
10a6: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
10ad: 00 00 00
...
我们进一步看一下__libc_start_main函数,在glibc里的csu目录下的libc-start.c文件里。它的原型如下,AUXVEC应该是定义了的,可以通过命令"$ LD_SHOW_AUXV=1 sleep 0"来查看操作系统为我们的应用程序准备的这些aux vectors。<br> 进一步看__libc_start_main的代码,可以看到它做了一些应用程序运行前的准备工作,比如一些初始化,多线程的还包括pthread(posix thread)的初始化,及安全相关的准备,等等,再调用用户的main函数,并收取main函数的返回值作为exit函数的参数来告诉操作系统,可以把当前线程停止运行了。<br>
STATIC int
LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
int argc, char **argv,
#ifdef LIBC_START_MAIN_AUXVEC_ARG
ElfW(auxv_t) *auxvec,
#endif
__typeof (main) init,
void (*fini) (void),
void (*rtld_fini) (void), void *stack_end);
加载器(loader)
前面提到了一个加载器,也称为loader,有一本经典的书专门讲链接器(应用程序的装配)和加载器(应用程序的加载)非常值得一读。请参考资源[9]。<br> 这里也简要介绍一下加载器。一个应用程序,它实现的逻辑除了自己写的之外,还使用了很多的从标准库里头来的预制件。使用预制件有很多的好处,提高开发速度,减少内存的占用(不同进程使用的相同的库可以共享内存),库的更新升级容易并且不会需要各个应用的更改。当然它也有一些弊端,比如不同的应用程序如果依赖库的不同的版本就容易造成混乱。现在不同的操作系统都对共享库有很好的支持了。<br> 操作系统支持共享库的工作很大程度上是以来加载器来实现的,这样在应用程序加载的时候操作系统只需要加载这个应用程序及它的加载器(loader,也即上文的interpreter)就可以了。加载好了以后,把控制交给加载器就算完成工作了,等这个应用程序被调度到了以后,加载器首先获得运行,它会把这个应用依赖的各项共享库给加载好,并做好相应的初始化,再跳转到这个应用的_start入口函数。所以从绝对意义上来说,一个带共享库的进程的用户态第一个被执行到的还不是自己的_start,其实是loader的_start函数。<br> 我们如果想要看看加载器到底加载了哪些库,及做了什么可以做一个小实验来观察一下,我们以常用的echo命令来试试。<br> 先用工具ldd来察看一下它依赖哪些库。<br>
$ ldd /usr/bin/echo
linux-vdso.so.1 (0x00007fff857fd000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f1d35136000)
/lib64/ld-linux-x86-64.so.2 (0x00007f1d3537b000)
请注意这个linux-vdso这个库是个不存在于文件系统里头的库,也不由加载器所加载,它是操作系统为了应用程序在调用一些常用的操作系统服务时更快而特别搞的一个快速通道(比如应用程序经常要使用的gettimeofday等函数)。<br> 再用LD_DEBUG这个加载器内置的宏开关来跑一下echo看看。<br>
$ LD_DEBUG=libs /usr/bin/echo "Hello, world!"
4825: find library=libc.so.6 [0]; searching
4825: search cache=/etc/ld.so.cache
4825: trying file=/lib/x86_64-linux-gnu/libc.so.6
4825:
4825:
4825: calling init: /lib64/ld-linux-x86-64.so.2
4825:
4825:
4825: calling init: /lib/x86_64-linux-gnu/libc.so.6
4825:
4825:
4825: initialize program: /usr/bin/echo
4825:
4825:
4825: transferring control: /usr/bin/echo
4825:
Hello, world!
它搜索并加载了libc,并且调用了自己的和libc的初始化(init)函数。在最后才把控制交给应用程序echo,然后echo才完成了它的"Hello, world!"的输出。<br>
GOT和PLT
Linux下应用程序发展到今天,黑客发展了很多的技术来攻击和渗透目标系统,应用程序甚至整个系统的安全性在攻防的互动中不停的发展。其中一个经典的攻击方法就是缓冲区溢出攻击来把执行流引导到黑客所能控制的路径上来夺取控制权。请参考资源[3]。目前主流的Linux操作系统的发布版,都把PIE/PIC(Position Independent Execution/Code)作为默认的标配了,其作用就是提高通过缓冲区溢出来获取控制的难度。在包括共享库和应用程序都使用PIE/PIC之后,攻击者没有办法预估进程里的代码的分布,控制流的跳转和获取就变成了一个概率很低的事情了。但它也让程序的加载过程变得更加复杂。<br> 因为进程运行时的函数地址都是加载时动态确定的,函数的交叉引用也需要运行时动态绑定。GOT(Global Object Table)技术就应运而生了,每一个共享库都会有一个GOT表来填充这个库的每一个公开的API的入口地址,它在运行时才会确定下来。应用程序使用PLT(Procedure Linkable Table)来索引到GOT表来实现最后的绑定。为了效率起见,一个应用程序所链接的库的API不一定在每一次运行都会使用到,加载器还发展了一个延迟绑定技术,来在应用程序第一次调用这个GOT的某个函数入口地址的时候才通过_dl_runtime_resolve函数获取真正的目标函数地址,这样将来的调用就不需要再做这个解析(resolve)步骤了。<br> 我们前头的hello-world.c里头把本来一句printf即可完成的事情分成俩句,就是为了演示俩次printf在API的解析和绑定上的细微差别的。有兴趣的朋友可以自己用gdb来调试并跟踪一下详细的过程。(注:编译时需要添加-no-pie的参数)。<br>
总结
本篇从一个Linux下的简单应用程序的编译和装配开始,分析了ELF的文件格式,介绍了段和节的关系。再对执行流做了一下拆解,追溯了应用程序的真正的入口_start及它的基本职责和怎样调用用户的main函数及确保通过exit通知操作系统来做收尾的工作。并进一步分析了加载器的角色和所起的作用。最后介绍了GOT/PLT及PIE/PIC和一点安全性知识。<br> 希望有兴趣的读者朋友多讨论,多提意见,一起提高。<br>
资源
[1] How programs get run: ELF binaries - https://lwn.net/Articles/631631/ [2] Understanding the ELF File Format - https://linuxhint.com/understanding_elf_file_format/ [3] Position Independent Executables - https://www.redhat.com/en/blog/position-independent-executables-pie [4] Executable and Linkable Format – Wikipedia - https://en.wikipedia.org/wiki/Executable_and_Linkable_Format [5] Executable and Linkable Format - https://refspecs.linuxfoundation.org/elf/elf.pdf [6] Hopper Disassembler - https://www.hopperapp.com [7] Ghidra Software Reverse Engineering Framework - https://github.com/NationalSecurityAgency/ghidra [8] x86 calling conventions - https://en.wikipedia.org/wiki/X86_calling_conventions [9] 《链接器和加载器》,linker and loader - John R. Levine