深入浅出ELF

本文介绍了ELF的基本结构和内存加载的原理,并用具体案例来分析如何通过ELF特性实现HIDS bypass、加固/脱壳以及辅助进行binary fuzzing。

前言

作为一个安全研究人员,ELF可以说是一个必须了解的格式,因为这关系到程序的编译、链接、封装、加载、动态执行等方方面面。有人就说了,这不就是一种文件格式而已嘛,最多按照SPEC实现一遍也就会了,难道还能复杂过FLV/MP4?曾经我也是这么认为的,直到我在日常工作时遇到了下面的错误:

$ r2 a.out
Segmentation fault

作为一个开源爱好者,我的radare2经常是用master分支编译的,经过在github中搜索,发现radare对于ELF的处理还有不少同类的问题,比如issue#17300以及issue#17379,这还只是近一个月内的两个open issue,历史问题更是数不胜数。

总不能说radare的开发者不了解ELF吧?事实上他们都是软件开发和逆向工程界的专家。不止radare,其实IDA和其他反编译工具也曾出现过各类ELF相关的bug

说了那么多,只是为了引出一个观点: ELF既简单也复杂,值得我们去深入了解。网上已经有了很多介绍ELF的文章,因此本文不会花太多篇幅在SPEC的复制粘贴上,而是结合实际案例和应用场景去进行说明。

ELF 101

ELF的全称是Executable and Linking Format,这个名字相当关键,包含了ELF所需要支持的两个功能——执行和链接。不管是ELF,还是Windows的PE,抑或是MacOS的Mach-O,其根本目的都是为了能让处理器正确执行我们所编写的代码。

在上古时期,给CPU运行代码也不用那么复杂,什么代码段数据段,直接把编译好的机器码一把梭烧到中断内存空间,PC直接跳过来就执行了。但随着时代变化,大家总不能一直写汇编了,即便编译器很给力,也会涉及到多人协作、资源复用等问题。这时候就需要一种可拓展(Portable)的文件标准,一方面让开发者(编译器/链接器)能够高效协作,另一方面也需要系统能够正确、安全地将文件加载到对应内存中去执行,这就是ELF的使命。

view.png
view

从大局上看,ELF文件主要分为3个部分:

  • ELF Header
  • Section Header Table
  • Program Header Table

其中,ELF Header是文件头,包含了固定长度的文件信息;Section Header Table则包含了链接时所需要用到的信息;Program Header Table中包含了运行时加载程序所需要的信息,后面会进行分别介绍。

ELF头部的定义在elf/elf.h中(以glibc-2.27为例),使用POD结构体表示,内存可使用结构体的字段一一映射,头部表示如下:

#define EI_NIDENT (16)

typedef struct
{
  unsigned char	e_ident[EI_NIDENT];	/* Magic number and other info */
  Elf32_Half	e_type;			/* Object file type */
  Elf32_Half	e_machine;		/* Architecture */
  Elf32_Word	e_version;		/* Object file version */
  Elf32_Addr	e_entry;		/* Entry point virtual address */
  Elf32_Off	e_phoff;		/* Program header table file offset */
  Elf32_Off	e_shoff;		/* Section header table file offset */
  Elf32_Word	e_flags;		/* Processor-specific flags */
  Elf32_Half	e_ehsize;		/* ELF header size in bytes */
  Elf32_Half	e_phentsize;		/* Program header table entry size */
  Elf32_Half	e_phnum;		/* Program header table entry count */
  Elf32_Half	e_shentsize;		/* Section header table entry size */
  Elf32_Half	e_shnum;		/* Section header table entry count */
  Elf32_Half	e_shstrndx;		/* Section header string table index */
} Elf32_Ehdr;

注释都很清楚了,挑一些比较重要的来说。其中e_type表示ELF文件的类型,有以下几种:

  • ET_NONE: 未知类型
  • ET_REL: 可重定向类型(relocatable),通常是我们编译的*.o文件
  • ET_EXEC: 可执行类型(executable),静态编译的可执行文件
  • ET_DYN: 共享对象(shared object),动态编译的可执行文件或者动态库*.so
  • ET_CORE: coredump文件

e_entry是程序的入口虚拟地址,注意不是main函数的地址,而是.text段的首地址_start。当然这也要求程序本身非PIE(-no-pie)编译的且ASLR关闭的情况下,对于非ET_EXEC类型通常并不是实际的虚拟地址值。

其他的字段大多数是指定Section Header(e_sh)和Program Header(e_ph)的信息。Section/Program Header Table本身可以看做是数组结构,ELF头中的信息指定对应Table数组的位置、长度、元素大小信息。最后一个e_shstrndx表示的是section table中的第e_shstrndx项元素,保存了所有section table名称的字符串信息。

上节说了section header table是一个数组结构,这个数组的位置在e_shoff处,共有e_shnum个元素(即section),每个元素的大小为e_shentsize字节。每个元素的结构如下:

typedef struct
{
  Elf32_Word	sh_name;		/* Section name (string tbl index) */
  Elf32_Word	sh_type;		/* Section type */
  Elf32_Word	sh_flags;		/* Section flags */
  Elf32_Addr	sh_addr;		/* Section virtual addr at execution */
  Elf32_Off	sh_offset;		/* Section file offset */
  Elf32_Word	sh_size;		/* Section size in bytes */
  Elf32_Word	sh_link;		/* Link to another section */
  Elf32_Word	sh_info;		/* Additional section information */
  Elf32_Word	sh_addralign;		/* Section alignment */
  Elf32_Word	sh_entsize;		/* Entry size if section holds table */
} Elf32_Shdr;

其中sh_name是该section的名称,用一个word表示其在字符表中的偏移,字符串表(.shstrtab)就是上面说到的第e_shstrndx个元素。ELF文件中经常使用这种偏移表示方式,可以方便组织不同区段之间的引用。

sh_type表示本section的类型,SPEC中定义了几十个类型,列举其中一些如下:

  • SHT_NULL: 表示该section无效,通常第0个section为该类型
  • SHT_PROGBITS: 表示该section包含由程序决定的内容,如.text.data.plt.got
  • SHT_SYMTAB/SHT_DYNSYM: 表示该section中包含符号表,如.symtab.dynsym
  • SHT_DYNAMIC: 表示该section中包含动态链接阶段所需要的信息
  • SHT_STRTAB: 表示该section中包含字符串信息,如.strtab.shstrtab
  • SHT_REL/SHT_RELA: 包含重定向项信息

虽然每个section header的大小一样(e_shentsize字节),但不同类型的section有不同的内容,内容部分由这几个字段表示:

  • sh_offset: 内容起始地址相对于文件开头的偏移
  • sh_size: 内容的大小
  • sh_entsize: 有的内容是也是一个数组,这个字段就表示数组的元素大小

与运行时信息相关的字段为:

  • sh_addr: 如果该section需要在运行时加载到虚拟内存中,该字段就是对应section内容(第一个字节)的虚拟地址
  • sh_addralign: 内容地址的对齐,如果有的话需要满足sh_addr % sh_addralign = 0
  • sh_flags: 表示所映射内容的权限,可根据SHF_WRITE/ALLOC/EXECINSTR进行组合

另外两个字段sh_linksh_info的含义根据section类型的不同而不同,如下表所示:

type.png
type

至于不同类型的section,有的是保存符号表,有的是保存字符串,这也是ELF表现出拓展性和复杂性的地方,因此需要在遇到具体问题的时候查看文档去进行具体分析。

program header table用来保存程序加载到内存中所需要的信息,使用段(segment)来表示。与section header table类似,同样是数组结构。数组的位置在偏移e_phoff处,每个元素(segment header)的大小为e_phentsize,共有e_phnum个元素。单个segment header的结构如下:

typedef struct
{
  Elf32_Word	p_type;			/* Segment type */
  Elf32_Off	p_offset;		  /* Segment file offset */
  Elf32_Addr	p_vaddr;		/* Segment virtual address */
  Elf32_Addr	p_paddr;		/* Segment physical address */
  Elf32_Word	p_filesz;		/* Segment size in file */
  Elf32_Word	p_memsz;		/* Segment size in memory */
  Elf32_Word	p_flags;		/* Segment flags */
  Elf32_Word	p_align;		/* Segment alignment */
} Elf32_Phdr;

既然program header的作用是提供用于初始化程序进程的段信息,那么下面这些字段就是很直观的:

  • p_offset: 该segment的数据在文件中的偏移地址(相对文件头)
  • p_vaddr: segment数据应该加载到进程的虚拟地址
  • p_paddr: segment数据应该加载到进程的物理地址(如果对应系统使用的是物理地址)
  • p_filesz: 该segment数据在文件中的大小
  • p_memsz: 该segment数据在进程内存中的大小。注意需要满足p_memsz>=p_filesz,多出的部分初始化为0,通常作为.bss段内容
  • p_flags: 进程中该segment的权限(R/W/X)
  • p_align: 该segment数据的对齐,2的整数次幂。即要求p_offset % p_align = p_vaddr

剩下的p_type字段,表示该program segment的类型,主要有以下几种:

  • PT_NULL: 表示该段未使用
  • PT_LOAD: Loadable Segment,将文件中的segment内容映射到进程内存中对应的地址上。值得一提的是SPEC中说在program header中的多个PT_LOAD地址是按照虚拟地址递增排序的。
  • PT_DYNAMIC: 动态链接中用到的段,通常是RW映射,因为需要由interpreter(ld.so)修复对应的的入口
  • PT_INTERP: 包含interpreter的路径,见下文
  • PT_HDR: 表示program header table本身。如果有这个segment的话,必须要在所有可加载的segment之前,并且在文件中不能出现超过一次

在不同的操作系统中还可能有一些拓展的类型,比如PT_GNU_STACKPT_GNU_RELRO等,不一而足。

至此,ELF文件中相关的字段已经介绍完毕,主要组成也就是Section Header Table和Program Header Table两部分,整体框架相当简洁。而ELF中体现拓展性的地方则是在Section和Segment的类型上(s_type和p_type),这两个字段的类型都是ElfN_Word,在32位系统下大小为4字节,也就是说最多可以支持高达2^32 - 1种不同的类型!除了上面介绍的常见类型,不同操作系统或者厂商还能定义自己的类型去实现更多复杂的功能。

程序加载

在新版的ELF标准文档中,将ELF的介绍分成了三部分,第一部分介绍ELF文件本身的结构,第二部分是处理器相关的内容,第三部分是操作系统相关的内容。ELF的加载实际上是与操作系统相关的,不过大部分情况下我们都是在GNU/Linux环境中运行,因此就以此为例介绍程序的加载流程。

Linux中分为用户态和内核态,执行ELF文件在用户态的表现就是执行execve系统调用,随后陷入内核进行处理。

内核空间对execve的处理其实可以单独用一篇文章去介绍,其中涉及到进程的创建、文件资源的处理以及进程权限的设置等等。我们这里主要关注其中ELF处理相关的部分即可,实际上内核可以识别多种类型的可执行文件,ELF的处理代码主要在fs/binfmt_elf.c中的load_elf_binary函数中。

对于ELF而言,Linux内核所关心的只有Program Header部分,甚至大部分情况下只关心三种类型的Header,即PT_LOADPT_INTERPPT_GNU_STACK。以3.18内核为例,load_elf_binary主要有下面操作:

  1. 对ELF文件做一些基本检查,保证e_phentsize = sizeof(struct elf_phdr)并且e_phnum的个数在一定范围内;
  2. 循环查看每一项program header,如果有PT_INTERP则使用open_exec加载进来,并替换原程序的bprm->buf;
  3. 根据PT_GNU_STACK段中的flag设置栈是否可执行;
  4. 使用flush_old_exec来更新当前可执行文件的所有引用;
  5. 使用setup_new_exec设置新的可执行文件在内核中的状态;
  6. setup_arg_pages在栈上设置程序调用参数的内存页;
  7. 循环每一项PT_LOAD类型的段,elf_map映射到对应内存页中,初始化BSS;
  8. 如果存在interpreter,将入口(elf_entry)设置为interpreter的函数入口,否则设置为原ELF的入口地址;
  9. install_exec_creds(bprm)设置进程权限等信息;
  10. create_elf_tables添加需要的信息到程序的栈中,比如ELF auxiliary vector
  11. 设置current->mm对应的字段;

从内核的处理流程上来看,如果是静态链接的程序,实际上内核返回用户空间执行的就是该程序的入口地址代码;如果是动态链接的程序,内核返回用户空间执行的则是interpreter的代码,并由其加载实际的ELF程序去执行。

为什么要这么做呢?如果把动态链接相关的代码也放到内核中,就会导致内核执行功能过多,内核的理念一直是能不在内核中执行的就不在内核中处理,以避免出现问题时难以更新而且影响系统整体的稳定性。事实上内核中对ELF文件结构的支持是相当有限的,只能读取并理解部分的字段。

内核返回用户空间后,对于静态链接的程序是直接执行,没什么好说的。而对于动态链接的程序,实际是执行interpreter的代码。ELF的interpreter作为一个段,自然是编译链接的时候加进去的,因此和编译使用的工具链有关。对于Linux系统而言,使用的一般是GCC工具链,而interpreter的实现,代码就在glibc的elf/rtld.c中。

interpreter又称为dynamic linker,以glibc2.27为例,它的大致功能如下:

  • 将实际要执行的ELF程序中的内存段加载到当前进程空间中;
  • 将动态库的内存段加载到当前进程空间中;
  • 对ELF程序和动态库进行重定向操作(relocation);
  • 调用动态库的初始化函数(如*.preinit_array, .init, .init_array*);
  • 将控制流传递给目标ELF程序,让其看起来自己是直接启动的;

其中参与动态加载和重定向所需要的重要部分就是Program Header Table中PT_DYNAMIC类型的Segment。前面我们提到在Section Header中也有一部分参与动态链接的section,即.dynamic。我在自己解析动态链接文件的时候发现,实际上 .dynamic section中的数据,和PT_DYNAMIC中的数据指向的是文件中的同一个地方,即这两个entry的s_offset和p_offset是相同。每个元素的类型如下:

typedef struct
{
  Elf32_Sword	d_tag;			/* Dynamic entry type */
  union
    {
      Elf32_Word d_val;			/* Integer value */
      Elf32_Addr d_ptr;			/* Address value */
    } d_un;
} Elf32_Dyn;

d_tag表示实际类型,并且d_un和d_tag相关,可以说是很有拓展性了:) 同样的,标准中定义了几十个d_tag类型,比较常用的几个如下:

  • DT_NULL: 表示_DYNAMIC的结尾
  • DT_NEEDED: d_val保存了一个到字符串表头的偏移,指定的字符串表示该ELF所依赖的动态库名称
  • DT_STRTAB: d_ptr指定了地址保存了符号、动态库名称以及其他用到的字符串
  • DT_STRSZ: 字符串表的大小
  • DT_SYMTAB: 指定地址保存了符号表
  • DT_INIT/DT_FINI: 指定初始化函数和结束函数的地址
  • DT_RPATH: 指定动态库搜索目录
  • DT_SONAME: Shared Object Name,指定当前动态库的名字(logical name)

其中有部分的类型可以和Section中的SHT_xxx类型进行类比,完整的列表可以参考ELF标准中的Book III: Operating System Specific一节。

在interpreter根据DT_NEEDED加载完所有需要的动态库后,就实现了完整进程虚拟内存映像的布局。在寻找某个动态符号时,interpreter会使用广度优先的方式去进行搜索,即先在当前ELF符号表中找,然后再从当前ELF的DT_NEEDED动态库中找,再然后从动态库中的DT_NEEDED里查找。

因为动态库本身是位置无关的(PIE),支持被加载到内存中的随机位置,因此为了程序中用到的符号可以被正确引用,需要对其进行重定向操作,指向对应符号的真实地址。这部分我在之前写的关于GOT,PLT和动态链接的文章中已经详细介绍过了,因此不再赘述,感兴趣的朋友可以参考该文章。

实际案例

有人也许会问,我看你bibi了这么多,有什么实际意义吗?呵呵,本节就来分享几个我认为比较有用的应用场景。

在渗透测试中,红队小伙伴们经常能拿到目标的后台shell权限,但是遇到一些部署了HIDS的大企业,很可能在执行恶意程序的时候被拦截,或者甚至触发监测异常直接被蓝队拔网线。这里不考虑具体的HIDS产品,假设现在面对两种场景:

  1. 目标环境的可写磁盘直接mount为noexec,无法执行代码
  2. 目标环境内核监控任何非系统路径的程序的执行都会直接告警

不管什么样的环境,我相信老红队都有办法去绕过,这里我们运用上面学到的ELF知识,其实有一种更为简单的解法,即利用interpreter。示例如下:

$ cat hello.c
#include <stdio.h>
int main() {
	return puts("hello!");
}
$ gcc hello.c -o hello
$ ./hello
hello!

$ chmod -x hello
$ ./hello
bash: ./hello: Permission denied
$ /lib64/ld-linux-x86-64.so.2 ./hello
hello!
$ strace /lib64/ld-linux-x86-64.so.2 ./hello 2>&1 | grep exec
execve("/lib64/ld-linux-x86-64.so.2", ["/lib64/ld-linux-x86-64.so.2", "./hello"], 0x7fff1206f208 /* 9 vars */) = 0

/lib64/ld-linux-x86-64.so.2本身应该是内核调用执行的,但我们这里可以直接进行调用。这样一方面可以在没有执行权限的情况下执行任意代码,另一方面也可以在一定程度上避免内核对execve的异常监控。

利用(滥用)interpreter我们还可以做其他有趣的事情,比如通过修改指定ELF文件的interpreter为我们自己的可执行文件,可让内核在处理目标ELF时将控制器交给我们的interpreter,这可以通过直接修改字符串表或者使用一些工具如patchelf来轻松实现。

对于恶意软件分析的场景,很多安全研究人员看到ELF就喜欢用ldd去看看有什么依赖库,一般ldd脚本实际上是调用系统默认的ld.so并通过环境变量来打印信息,不过对于某些glibc实现(如glibc2.27之前的ld.so),会调用ELF指定的interpreter运行,从而存在非预期命令执行的风险。

当然还有更多其他的思路可以进行拓展,这就需要大家发挥脑洞了。

与逆向分析比较相关的就是符号表,一个有符号的程序在逆向时基本上和读源码差不多。因此对于想保护应用程序的开发者而言,最简单的防护方法就是去除符号表,一个简单的strip命令就可实现。strip删除的主要是Section中的信息,因为这不影响程序的执行。去除前后进行diff对比可看到删除的section主要有下面这些:

$ diff 0 1
1c1
< There are 35 section headers, starting at offset 0x1fdc:
---
> There are 28 section headers, starting at offset 0x1144:
32,39c32
<   [27] .debug_aranges    PROGBITS        00000000 00104d 000020 00      0   0  1
<   [28] .debug_info       PROGBITS        00000000 00106d 000350 00      0   0  1
<   [29] .debug_abbrev     PROGBITS        00000000 0013bd 000100 00      0   0  1
<   [30] .debug_line       PROGBITS        00000000 0014bd 0000cd 00      0   0  1
<   [31] .debug_str        PROGBITS        00000000 00158a 000293 01  MS  0   0  1
<   [32] .symtab           SYMTAB          00000000 001820 000480 10     33  49  4
<   [33] .strtab           STRTAB          00000000 001ca0 0001f4 00      0   0  1
<   [34] .shstrtab         STRTAB          00000000 001e94 000145 00      0   0  1
---
>   [27] .shstrtab         STRTAB          00000000 00104d 0000f5 00      0   0  1

其中**.symtab是符号表,.strtab**是符号表中用到的字符串。

仅仅去掉符号感觉还不够,熟悉汇编的人放到反编译工具中还是可以慢慢还原程序逻辑。通过前面的分析我们知道,ELF执行需要的只是Program Header中的几个段,Section Header实际上是不需要的,只不过在运行时动态链接过程会引用到部分关联的区域。大部分反编译工具,如IDA、Ghidra等,处理ELF是需要某些section信息来构建程序视图的,所以我们可以通过构造一个损坏Section Table或者ELF Header令这些反编译工具出错,从而干扰逆向人员。

当然,这个方法并不总是奏效,逆向人员可以通过动态调试把程序dump出来并对运行视图进行还原。一个典型的例子是Android中的JNI动态库,有的安全人员对这些so文件进行了加密处理,并且在.init/.initarray这些动态库初始化函数中进行动态解密。破解这种加固方法的策略就是将其从内存中复制出来并进行重建,重建的过程可根据segment对section进行还原,因为segment和section之间共享了许多内存空间,例如:

$ readelf -l main1
...
 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
   03     .init_array .fini_array .dynamic .got .got.plt .data .bss
   04     .dynamic
   05     .note.ABI-tag .note.gnu.build-id
   06     .eh_frame_hdr
   07
   08     .init_array .fini_array .dynamic .got

Section to Segment mapping中可以看到这些段的内容是跟对应section的内容重叠的,一个segment可以包含多个section,但是依然可以根据内存的读写属性、内存特征以及对应段的一般顺序进行区分。

如果程序中有比较详细的日志函数,我们还可以通过反编译工具的脚本拓展去修改.symtab/.strtab段来批量还原ELF文件的符号,从而高效地辅助动态调试。

考虑这么一种场景,我们在分析某个IoT设备时发现了一个定制的ELF网络程序,类似于httpd,其中有个静态函数负责处理输入数据。现在想要单独对这个函数进行fuzz应该怎么做?直接从网络请求中进行变异是一种方法,但是网络请求的效率太低,而且触达该函数的程序逻辑也可能太长。

既然我们已经了解了ELF,那就可以有更好的办法将该函数抽取出来进行独立调用。在介绍ELF类型的时候其实有提到,可执行文件可以有两种类型,即可执行类型(ET_EXEC)和共享对象(ET_DYN),一个动态链接的可执行程序默认是共享对象类型的:

$ gcc hello.c -o hello
$ readelf -h hello | grep Type
  Type:  DYN (Shared object file)

而动态库(.so)本身也是共享对象类型,他们之间的本质区别在于前者链接了libc并且定义了main函数。对于动态库,我们可以通过dlopen/dlsym获取对应的符号进行调用,因此对于上面的场景,一个解决方式就是修改目标ELF文件,并且将对应的静态函数导出添加到dynamic section中,并修复对应的ELF头。

这个思想其实很早就已经有人实现了,比如lief的bin2lib。通过该方法,我们就能将目标程序任意的函数抽取出来执行,比如hugsy就用这个方式复现了Exim中的溢出漏洞(CVE-2018-6789),详见Fuzzing arbitrary functions in ELF binaries(中文翻译)。

总结

本文主要介绍了32位环境下ELF文件的格式和布局,然后从内核空间和用户空间两个方向分析了ELF程序的加载过程,最后列举了几个依赖于ELF文件特性的案例进行具体分析,包括dynamic linker的滥用、程序加固和反加固以及在二进制fuzzing中的应用。

ELF文件本身并不复杂,只有三个关键部分,只不过在section和segment的类型上保留了极大的拓展性。操作系统可以根据自己的需求在不同字段上实现和拓展自己的功能,比如Linux中通过dymamic类型实现动态加载。但这不是必须的,例如在Android中就通过ELF格式封装了特有的.odex .oat文件来保存优化后的dex。另外对于64位环境,大部分字段含义都是类似的,只是字段大小稍有变化(Elf32->Elf64),并不影响文中的结论。

参考链接