elf中文意思是什么,elf有哪几种意思

首页 > 数码 > 作者:YD1662024-06-06 10:23:15

elf中文意思是什么,elf有哪几种意思(1)

本文介绍了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的使命。

elf中文意思是什么,elf有哪几种意思(2)

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

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

ELF Header

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文件的类型,有以下几种:

*.o *.so

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

上节说了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中定义了几十个类型,列举其中一些如下:

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

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

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

elf中文意思是什么,elf有哪几种意思(3)

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

Program Header

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_type 字段,表示该program segment的类型,主要有以下几种:

在不同的操作系统中还可能有一些拓展的类型,比如 PT_GNU_STACK 、 PT_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_LOAD 、 PT_INTERP 和 PT_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为例,它的大致功能如下:

其中参与动态加载和重定向所需要的重要部分就是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类型,比较常用的几个如下:

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

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

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

实际案例

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

Interpreter Hack

在渗透测试中,红队小伙伴们经常能拿到目标的后台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文件的符号,从而高效地辅助动态调试。

Binary Fuzzing

考虑这么一种场景,我们在分析某个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),并不影响文中的结论。

作者:PansLabyrinth

栏目热文

文档排行

本站推荐

Copyright © 2018 - 2021 www.yd166.com., All Rights Reserved.