网站首页 / 生活 / 正文

elf是什么意思(oneself是什么意思)

时间:2022-04-03 19:48:13 浏览:10次 作者:用户投稿 【我要投诉/侵权/举报 删除信息】
elf是什么意思

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

前言

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

$ r2 a.outSegmentation 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文件主要分为3个部分:

ELF HeaderSection Header TableProgram Header Table

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

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

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

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

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

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

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

elf是什么意思

至于不同类型的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_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_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主要有下面操作:

对ELF文件做一些基本检查,保证 e_phentsize = sizeof(struct elf_phdr) 并且 e_phnum 的个数在一定范围内;循环查看每一项program header,如果有PT_INTERP则使用 open_exec 加载进来,并替换原程序的 bprm->buf ;根据 PT_GNU_STACK 段中的flag设置栈是否可执行;使用 flush_old_exec 来更新当前可执行文件的所有引用;使用 setup_new_exec 设置新的可执行文件在内核中的状态;setup_arg_pages 在栈上设置程序调用参数的内存页;循环每一项 PT_LOAD 类型的段, elf_map 映射到对应内存页中,初始化BSS;如果存在interpreter,将入口(elf_entry)设置为interpreter的函数入口,否则设置为原ELF的入口地址;install_exec_creds(bprm) 设置进程权限等信息;create_elf_tables 添加需要的信息到程序的栈中,比如 ELF auxiliary vector ;设置 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了这么多,有什么实际意义吗?呵呵,本节就来分享几个我认为比较有用的应用场景。

Interpreter Hack

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

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

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

$ cat hello.c#include <stdio.h>int main() { return puts("hello!");}$ gcc hello.c -o hello$ ./hellohello!$ chmod -x hello$ ./hellobash: ./hello: Permission denied$ /lib64/ld-linux-x86-64.so.2 ./hellohello!$ strace /lib64/ld-linux-x86-64.so.2 ./hello 2>&1 | grep execexecve("/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 11c1< 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

版权声明:
本文内容由互联网用户自发贡献,该文观点仅代表作者本人,因此内容不代表本站观点、本站不对文章中的任何观点负责,内容版权归原作者所有、内容只用于提供信息阅读,无任何商业用途。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站(文章、内容、图片、音频、视频)有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至353049283@qq.com举报,一经查实,本站将立刻删除、维护您的正当权益。