2.2 ELF文件格式
ELF(Executable and Linkable Format),即“可执行可链接格式”,最初由UNIX系统实验室作为应用程序二进制接口(Application Binary Interface – ABI)的一部分而制定和发布,是COFF(Common file format)格式的变种。Linux系统上所运行的就是ELF格式的文件,相关定义在“/usr/include/elf.h”文件里。
2.2.1 ELF文件的类型
上一节中我们展示了一个程序从源代码到可执行文件的全过程,现在我们来看一个更复杂的例子,代码如下所示。
使用下面4条命令分别进行编译(gcc版本5.4.0),可以得到5个不同的目标文件(object file),分别是elfDemo.dyn、elfDemo.exec、elfDemo_pic.rel、elfDemo.rel和elfDemo_static.exec。
从上面file命令的输出以及文件后缀可以看到,ELF文件分为三种类型,可执行文件(.exec)、可重定位文件(.rel)和共享目标文件(.dyn):
? 可执行文件(executable file):经过链接的、可执行的目标文件,通常也被称为程序。
? 可重定位文件(relocatable file):由源文件编译而成且尚未链接的目标文件,通常以“.o”作为扩展名。用于与其他目标文件进行链接以构成可执行文件或动态链接库,通常是一段位置独立的代码(Position Independent Code, PIC)。
? 共享目标文件(shared object file):动态链接库文件。用于在链接过程中与其他动态链接库或可重定位文件一起构建新的目标文件,或者在可执行文件加载时,链接到进程中作为运行代码的一部分。
除了上面三种主要类型,核心转储文件(Core Dump file)作为进程意外终止时进程地址空间的转储,也是ELF文件的一种。使用gdb读取这类文件可以辅助调试和查找程序崩溃的原因。
2.2.2 ELF文件的结构
在ELF文件格式规范中,ELF文件被统称为Object file,这与我们通常理解的“.o”文件不同。本书决定与规范保持一致,因此当提到目标文件时,即指各种类型的ELF文件。对于“.o”文件,我们则直接称为可重定位文件,由于这类文件包含了代码和数据,可以被用于链接成可执行文件或者共享目标文件,本节将通过分析这类文件的结构来学习ELF文件的格式。
如图2-3所示,在审视一个目标文件时,有两种视角可供选择,一种是链接视角,通过节(Section)来进行划分;另一种是运行视角,通过段(Segment)来进行划分。本小节我们先讲解链接视角,通常目标文件都会包含代码(.text)、数据(.data)和BSS(.bss)三个节。其中代码节用于保存可执行的机器指令,数据节用于保存已初始化的全局变量和局部静态变量,BSS节则用于保存未初始化的全局变量和局部静态变量。
图2-3 审视目标文件的两种视角
示例代码的链接视角如图2-4所示。除了上述的三个节,简化的目标文件还应包含一个文件头(ELF header)。
图2-4 示例代码的链接视角
将程序指令和程序数据分开存放有许多好处,从安全的角度讲,当程序被加载后,数据和指令分别被映射到两个虚拟区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读,防止程序的指令被改写和利用。
ELF文件头
ELF文件头(ELF header)位于目标文件最开始的位置,包含描述整个文件的一些基本信息,例如ELF文件类型、版本/ABI版本、目标机器、程序入口、段表和节表的位置和长度等。值得注意的是文件头部存在魔术字符(7f 45 4c 46),即字符串“\177ELF”,当文件被映射到内存时,可以通过搜索该字符确定映射地址,这在dump内存时非常有用。
Elf64_Ehdr结构体如下所示。
节头表
一个目标文件中包含许多节,这些节的信息保存在节头表(Section header table)中,表的每一项都是一个Elf64_Shdr结构体(也称为节描述符),记录了节的名字、长度、偏移、读写权限等信息。节头表的位置记录在文件头的e_shoff域中。节头表对于程序运行并不是必须的,因为它与程序内存布局无关,是程序头表的任务,所以常有程序去除节头表,以增加反编译器的分析难度。示例程序elfDemo.rel的节头表如下所示。
Elf64_Shdr结构体如下所示。
下面我们来分别看看示例程序的.text、.data和.bss节。首先是代码节。可以看到,Contents of section .text部分是.text数据的十六进制形式,总共0x4e个字节,最左边一列是偏移量,中间四列是内容,最右边一列是ASCII码形式。Disassembly of section .text部分则是反汇编的结果。
接下来是数据节和只读数据节。可以看到.data节保存已经初始化的全局变量和局部静态变量。源代码中共有两个这样的变量:global_init_var(0a000000)和local_static_init_var(14000000),每个变量4个字节,一共8个字节。
.rodata节保存只读数据,包括只读变量和字符串常量。源代码中调用printf()函数时,用到了一个字符串“%d\n”,它是一种只读数据,因此保存在.rodata节中,可以看到字符串常量的ASCII形式,以“\0”结尾。
最后是BSS节,用于保存未初始化的全局变量和局部静态变量。如果仔细观察,会发现该节没有CONTENTS属性,这表示该节在文件中实际上并不存在,只是为变量预留了位置而已,因此该节的sh_offset域也就没有意义了。
表2-1中列举了其他一些常见的节,然后我们会选择其中几个详细讲解。
表2-1 其他常见的节
字符串表中包含了以null结尾的字符序列,用来表示符号名和节名,引用字符串时只需给出字符序列在表中的偏移即可。字符串表的第一个字符和最后一个字符都是null字符,以确保所有字符串的开始和终止。
符号表记录了目标文件中所用到的所有符号信息,通常分为.dynsym和.symtab,前者是后者的子集。.dynsym保存了引用自外部文件的符号,只能在运行时被解析,而.symtab还保存了本地符号,用于调试和链接。目标文件通过一个符号在表中的索引值来使用该符号。索引值从0开始计数,但值为0的表项不具有实际的意义,它表示未定义的符号。每个符号都有一个符号值(symbol value),对于变量和函数,该值就是符号的地址。
Elf64_Sym结构体如下所示。
重定位是连接符号定义与符号引用的过程。可重定位文件在构建可执行文件或共享目标文件时,需要把节中的符号引用换成这些符号在进程空间中的虚拟地址。包含这些转换信息的数据就是重定位项(relocation entries)。关于符号绑定和重定位的详细过程我们会在后续章节中阐述,涉及ret2dl-resolve攻击方法。
Elf64_Rel和Elf64_Rela结构体如下所示。其中,r_offset是在重定位时需要被修改的符号的偏移。r_info分为两个部分:type指示如何修改引用,symbol指示应该修改引用为哪个符号。r_addend用于对被修改的引用做偏移调整。
有时为了方便程序调试,我们在编译时会使用“-g”选项,此时GCC就会在目标文件中添加许多调试信息,采用DWARF格式的形式保存在下面这些段中,如果不再需要调试信息,使用strip命令即可将其去除。
2.2.3 可执行文件的装载
现在已经了解了目标文件的链接视角,下面我们将从运行视角来进行审视。当运行一个可执行文件时,首先需要将该文件和动态链接库装载到进程空间中,形成一个进程镜像。每个进程都拥有独立的虚拟地址空间,这个空间如何布局是由记录在段头表中的程序头(Program header)决定的。ELF文件头的e_phoff域给出了段头表的位置。
可以看到每个段都包含了一个或多个节,相当于是对这些节进行分组,段的出现也正是出于这个目的。随着节的数量增多,在进行内存映射时就产生空间和资源浪费的问题。实际上,系统并不关心每个节的实际内容,而是关心这些节的权限(读、写、执行),那么通过将不同权限的节分组,即可同时装载多个节,从而节省资源。例如.data和.bss都具有读和写的权限,而.text和.plt.got则具有读和执行的权限。
下面简要地讲解几个常见的段。通常一个可执行文件至少有一个PT_LOAD类型的段,用于描述可装载的节,而动态链接的可执行文件则包含两个,将.data和.text分开存放。动态段PT_DYNAMIC包含了一些动态链接器所必须的信息,如共享库列表、GOT表和重定位表等。PT_NOTE类型的段保存了系统相关的附加信息,虽然程序运行并不需要这些。PT_INTERP段将位置和大小信息存放在一个字符串中,是对程序解释器位置的描述。PT_PHDR段保存了程序头表本身的位置和大小。
Elf64_Phdr结构体如下所示。
当然,在进程镜像中仅仅包含各个段是不够的,还需要用到栈(Stack)、堆(Heap)、vDSO等空间,这些空间同样通过权限来进行访问控制,从而保证程序运行时的安全。动态链接的可执行文件装载完成后,还需要进行动态链接才能顺利执行,该过程我们会在后文中讲解。