翻译自:https://lwn.net/Articles/631631/
前一篇主要是描述了用户空间程序调用execve()
到内核是如何处理的,更加generic一些。上篇中讲到如何每个程序的执行都会通过search_binary_handler()
来决定如何处理,不管是script还是misc,最终都会以调用ELF格式程序来结束,这一篇主要集中在ELF主题上。
ELF
ELF((Executable and Linkable Format)是linux系统上应用最为广泛的二进制文件格式,内核中对其的支持代码集中在fs/binfmt_elf.c
中,它是一种比较复杂的文件格式,主要的处理逻辑load_elf_binary()
长度超过400行,ELF部分的代码是老式的a.out
格式代码的4倍。ELF提供了一种通用的文件管理方式,不仅描述文件的构成,还关联了它运行时的信息,它可以管理静态链接文件、动态可执行文件、动态库、可重定位文件,还用于core dump文件、kdump文件、gdb的调试等。
一个可执行程序的ELF文件一定会包含程序头表,它一般紧跟在ELF头后面,表里每一项都提供了它的加载地址和文件偏移等信息,这些信息决定了如何加载和运行。
内核只关注三中类型的程序头表项:
- PT_LOAD段,描述了新程序运行内存的区域信息,通常包括文件中代码、数据段,还有BSS段,BSS在文件中只有内存大小的section管理数据,但是不占据文件空间,加载程序时会为BSS段分配空间并且填充0。
- PT_INTERP段,描述了运行时链接器,它会在运行时在内存中组装可执行程序和它依赖的库,进行重定位和符号解析工作。不过现在我们先看静态链接的LEF文件,后面会有单独的一节来看动态链接文件。
- PT_GNU_STACK段,它不占据文件信息,但是段管理信息中有栈权限标志,通常是
RW
类型。如果有这种类型的段用户栈就会被设置成不可执行的,没有的话就可以执行。
这篇文章只聚焦在ELF加载过程,没有解释ELF格式的所有信息,感兴趣的读者可以从wikipedia
上找到参考链接和文档或者通过objdump
工具来查看一个具体的ELF文件。
ELF内核处理
内核通过load_elf_binary()
来加载ELF可执行程序,会首先检查ELF头是否合法,内核是否支持这种格式。上一篇中可执行文件处理时要求所有的信息需要位于文件头部的的128字节,但是ELF格式校验的时候需要整个的ELF程序头表,它非常可能超出128字节,此时ELF会读取所有的信息到临时空间。
之后会遍历程序头表项,检查PT_INTERP段的解释器和PT_GNU_STACK段来决定程序的栈是否可执行。这些准备工作结束后,内核将会开始初始化新程序的属性;新的SUSv3(Single UNIX Specification version 3)规范描述了绝大多数需要遵守的规范。
开始执行新进程时需要先清理旧进程的信息,通常会调用flush_old_exec()
,它会清理老进程的相关信息和状态。如果老进程是一个多线程的程序,它的其他线程都会被杀死,之后新的进程将会以一个单线程形式开始执行。新旧进程间的信号处理是不共享的,它可以在之后被安全的替换掉。旧进程的pending定时器将会被清除,/proc/pid/exec
描述的可执行文件信息将会被更新。旧进程的虚拟内存映射将会被释放,同时会杀掉所有pending的异步IO操作,释放所有注册的uprobes
。进程的一些个性化信息将会被更新,移除掉所有可能影响安全的信息,即linux_binprm.per_clear
中记录的位信息都会在新进程中清除,它会通过SET_PERSONALITY()
宏来为新的64位程序设置thread flags。
与之相对应的新进程会调用setup_new_exec()
来初始化它的状态,它会检查新程序是否可以生成core dump文件或者是否能通过ptrace
attach到新进程,对于标记了setuid
和setgid
的程序默认是不会生成core dump文件,如果当前用户凭证没有权限读取可执行文件时也不会生成core dump文件。接下来__set_task_comm()
会根据原始可执行文件名称设置当前进程的comm
,除了此时设置comm
字段还可以之后在用户空间通过系统调用pctrl(PR_SET_NAME)
来更新它,通过pctrl(PR_GET_NAME)
来获取它的名称。下面会通过flush_signal_handlers()
来为新进程设置它的信号处理句柄,信号处理行为不是SIG_IGN
的将会设置成默认的SIG_DFL
,所以在老进程中忽略信号处理可以被新的进程继承过去。
最后,调用do_close_on_exec()
来关闭旧进程的所有设置了O_CLOEXEC
属性的文件描述符,但是其他的文件描述符会被新的进程所继承。所以如果设计成自重启模式A->execve(A)
的进程尤其需要注意文件描述符属性是否有O_CLOEXEC
,它可以在open
的时候设置,也可以通过fcntl
设置。
新进程的虚拟内存需要重新建立。内核为了安全考虑(主要是防御栈溢出攻击),栈的最高地址通常会从arch允许的最高地址向下有一个随机偏移。初始调用setup_arg_pages()
之后会创建管理栈信息的vm_area_struct
跟踪对象,并且调整栈的位置。之后遍历所有的PT_LOAD
类型的段并且把他们映射到进程的地址空间,建立新进程的内存布局。同时会根据程序的BSS段描述来对这部分内存数据进行清零操作。与此同时,一些额外的特属页,例如vDSO(virtual dynamic shared object),需要映射到用户空间,这部分通常是由arch_setup_additional_pages()
来完成的。为了保持程序地址空间的后向兼容性(旧的SVr4程序假设从一个空指针读取数据返回零而不是触发一个SIGSEGV
信号)可能需要映射一个空页到零地址。
接下来,为新进程安装用户凭证,这部分由install_exec_creds()
来完成,它主要是让当前注册的LSM模块都知道用户凭证的变化(bprm_committing_creds & bprm_committed_creds
hook),它的子函数commit_creds()
会实际完成用户凭证的更新工作。
最后的准备工作是创建新进程的栈,它会有一个随机偏移地址,实际是由create_elf_tables()
来完成,下面会有单独的章节来讲这件事。
现在所有的准备工作都已经完成,新的程序可以被加载了。这篇文章解释了系统调用发生时,system_call
入口点将用户空间的所有寄存器全都压栈到内核栈上,之后才进行系统调用处理。当系统调用完成后返回用户空间之前会从栈上恢复到寄存器中。栈上保存这些寄存器的空间被转换成一个pt_regs
结构对象,这些保存的寄存器值可以在开始新的进程开始之前被覆写。start_thread()
函数将会设置保存的指令寄存器(RIP)的值为新进程的入口点(或者是动态解释器的入口点),根据linux_binprm.p
的值设置栈寄存器(RSP)的值指向当前的栈顶。上述的操作返回0表示成功,之后execve()
系统调用就会返回到用户空间,不过它是一个全新的不同的用户空间,进程的内存都已经被重新映射,寄存器的值也被新进程所覆写。
栈填充
通用部分的代码在新进程将参数和环境变量信息放到栈上,除此之外create_elf_tables()
函数在新进程的栈上也增加了很多的信息,主要有两部分,一个是通过arch_align_stakc()
将现在的栈位置按照16字节对齐,另一个是将栈顶进行了随机偏移处理。
额外的信息一部分是ELF的辅助变量,他是以(id, value)
形式成对存放的数组,它主要描述了程序运行时信息和它运行时的环境变量地址信息,完成内核到用户空间信息的传递作用。为了构建辅助变量数组,首先需要入栈一些非64位的值,对于x86_64是一个平台相关描述(字符串“x86_64”
)和16字节的随机数据(用户空间随机数生成的种子)。
下面,组装(id, value)
形式的数组存放到mm_struct.saved_auxv
内,我们可以通过LD_SHOW_AUXV=1 whoami
这种方式查看辅助变量的内容。Michael Kerrisk
有篇文章讲述了这些数组的内容,下面看一些有趣的项:
数组第一项是arch相关的信息,在x86_64是AT_SYSINFO_EHDR
,它描述了vDSO
页的地址。
AT_PLATFORM
是平台相关描述的位置,在x86_64上是“x86_64”
,在create_elf_tables()
中设置的
AT_RANDOM
描述了随机数据的地址
AT_EXECFN
描述了原始的程序文件名称的地址,它位于参数和环境变量地址之上
AT_ENTRY
描述了程序的入口地址
辅助变量数组组装完成后将会开始组装新进程栈的剩余部分,计算需要使用的空间,把辅助变量从低地址到高地址插入到栈顶
argc
在栈的顶部,接下来是参数指针数组,它以NULL
指针表明自身结束,main()
的argv参数就指向这个地址。接下来是环境变量指针数组入栈,同样以NULL
指针表明自身结束,这是environ
指向的地址。辅助变量被放在之后的地址,在它上面有一些它自身需要引用的数据,下面的图描述了整个栈的布局:
------------------------------------------------------------- 0x7fff6c845000
0x7fff6c844ff8: 0x0000000000000000
_ 4fec: './stackdump\0' <------+
env / 4fe2: 'ENVVAR2=2\0' | <----+
\_ 4fd8: 'ENVVAR1=1\0' | <---+ |
/ 4fd4: 'two\0' | | | <----+
args | 4fd0: 'one\0' | | | <---+ |
\_ 4fcb: 'zero\0' | | | <--+ | |
3020: random gap padded to 16B boundary | | | | | |
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -| | | | | |
3019: 'x86_64\0' <-+ | | | | | |
auxv 3009: random data: ed99b6...2adcc7 | <-+ | | | | | |
data 3000: zero padding to align stack | | | | | | | |
. . . . . . . . . . . . . . . . . . . . . . . . . . .|. .|. .| | | | | |
2ff0: AT_NULL(0)=0 | | | | | | | |
2fe0: AT_PLATFORM(15)=0x7fff6c843019 --+ | | | | | | |
2fd0: AT_EXECFN(31)=0x7fff6c844fec ------|---+ | | | | |
2fc0: AT_RANDOM(25)=0x7fff6c843009 ------+ | | | | |
ELF 2fb0: AT_SECURE(23)=0 | | | | |
auxiliary 2fa0: AT_EGID(14)=1000 | | | | |
vector: 2f90: AT_GID(13)=1000 | | | | |
(id,val) 2f80: AT_EUID(12)=1000 | | | | |
pairs 2f70: AT_UID(11)=1000 | | | | |
2f60: AT_ENTRY(9)=0x4010c0 | | | | |
2f50: AT_FLAGS(8)=0 | | | | |
2f40: AT_BASE(7)=0x7ff6c1122000 | | | | |
2f30: AT_PHNUM(5)=9 | | | | |
2f20: AT_PHENT(4)=56 | | | | |
2f10: AT_PHDR(3)=0x400040 | | | | |
2f00: AT_CLKTCK(17)=100 | | | | |
2ef0: AT_PAGESZ(6)=4096 | | | | |
2ee0: AT_HWCAP(16)=0xbfebfbff | | | | |
2ed0: AT_SYSINFO_EHDR(33)=0x7fff6c86b000 | | | | |
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . | | | | |
2ec8: environ[2]=(nil) | | | | |
2ec0: environ[1]=0x7fff6c844fe2 ------------------|-+ | | |
2eb8: environ[0]=0x7fff6c844fd8 ------------------+ | | |
2eb0: argv[3]=(nil) | | |
2ea8: argv[2]=0x7fff6c844fd4 ---------------------------|-|-+
2ea0: argv[1]=0x7fff6c844fd0 ---------------------------|-+
2e98: argv[0]=0x7fff6c844fcb ---------------------------+
0x7fff6c842e90: argc=3
注意,栈布局中有两次随机化:用户栈最高地址的随机化,参数和辅助变量引用的数据有一个随机的间隔。SP
寄存器存放了程序栈顶的位置(最低地址),命令行参数指针在它的上面,以NULL
结尾。环境变量的值紧随其后,同样以NULL
结尾。下面是辅助环境变量,以AT_NULL
类型的id结尾。这些值给出了命令行参数字符串,环境变量字符串,辅助数据的地址,所以不需要知道参数和辅助变量引用的数据的间隔是多大。
动态链接程序
截止目前位置,我们都是假设程序是一个静态链接类型的,跳过去了PT_INTERP
段的处理。但是大部分的程序是动态链接的,那就意味着需要在运行时进行重定位和链接。这部分工作是有运行时链接器来完成的,x86_64上一般是/lib64/ld-linux-x86-64.so.2
,PT_INTERP
段中存储了运行时链接器的路径字符串
为了使用解释器,ELF处理时首先读取解释器文件名称到临时空间,然后通过open_exec
打开它。之后读取文件的前128字节到linux_binprm.buf
中,覆盖掉原始程序文件读取到的内容,之后开始处理链接器文件,它也必须是一个ELF格式的。
按照上面把原始程序代码加载到内存之后,ELF内核代码将会通过load_elf_interp()
将解释器程序也加载到内存中,两个的过程是非常相似的:检查ELF头的信息,读取程序头表,映射文件中所有的PT_LOAD
段到内存并且为解释器的BSS段预留充足的空间。
程序执行的起始地址被设置成解释器程序的入口地址而不是原始程序的入口地址。当execve()
返回后开始执行解释器的代码,它会处理程序的链接依赖关系:找到并且加载所有依赖的动态库,对未定义符号进行重定位以指向正确的地址。一旦链接处理完成(这个需要对ELF格式有比较深的理解,内核代码并没有牵涉到太多的动态链接),解释器就会根据AT_ENTRY
的地址将控制权交给原始程序。
arch的兼容性
就像前面所讲的,现代的x86_64位系统也支持两种类型的32位二进制格式文件:x86_32格式和x32 ABI程序(他可以使用额外的64位寄存器),内核是如何支持这些类型的二进制格式呢?
As described previously, a modern 64-bit (x86_64) Linux system can also support running 32-bit binaries of two types: normal 32-bit binaries (x86_32), and x32 ABI programs (which can make use of additional x86_64 registers). So how does the kernel support these binaries?
支持这些格式的关键代码位于compat_binfmt_elf.c
中,当内核使能了CONFIG_COMPAT_BINFMT_ELF
后就可以兼容这些格式了。这个文件并没有在linux_binfmt
链表中注册,它并没有自己的代码,相反它通过#include
的形式包含了binfmt_elf.c
中的代码,使用其中的32位兼容性版本的代码。除了这些它和正常的ELF处理是完全一样的。
32位版本的ELF文件布局中所有的地址和值都换成了32bit版本的,例如地址在64位上是ELF64_Addr,在32位上是ELF32_Addr。
同样的,它在处理32位程序的时候将一些替换成兼容性版本:
SET_PERSONALITY() ->set_personality_ia32()
start_thread->compat_start_thread()
结语
所有程序的执行都需要通过execve
接口,所以这个系统调用的实现是非常值得仔细探究的。尽管内核支持脚本和其他机器码格式的程序,但是最终他们都需要执行elf格式的程序。ELF是一个非常复杂的格式,不过内核的实现可以不必关注它的所有规范,只需要理解ELF加载过程,是如何从可执行文件到内存的映射,又是如何调用用户空间的链接器来完成运行时的链接工作。
有趣的链接和方向
ELF的动态链接:glibc中rtld
ELF病毒和防御:Linux二进制分析