ELF文件格式
ELF文件可以被标记为下面几种类型:
- ET_NONE:未知类型。
- ET_REL:重定位文件。类型标记为relocatable,表示这个文件被标记了一段可重定位的代码,在编译完代码后可以看到一个.o文件。
- ET_EXEC:可执行文件。类型标记为executable.
- EY_DYN:共享目标文件(共享库)。类型标记为dynamic,可动态链接的目标文件,这类共享库会在程序运行时被装载并链接到程序的进程镜像中。
- ET_CORE:核心文件。在程序崩溃时生成的文件,记录了进程的镜像信息,可以用gdb调试来找到崩溃的原因。
用readelf -e
命令可以看到ELF头、节头、程序头、段节这些信息,接下来我们会对其进行简单地介绍。
ELF头
用$ readelf -h
命令可以查看ELF文件头:
在usr/include/elf.h文件中可以看到对elf头结构体的定义:
我们注意到前面readelf的输出里的“Magic”的16个字节刚好是对应”Elf32_Ehdr”的e_ident这个成员。这16个字节被ELF标准规定用来标识ELF文件的平台属性,比如ELF字长(32位/64位),字节序,ELF文件版本等等。
在输出中我们还能看到类别、数据、入口点地址等等重要信息,在分析一个ELF二进制文件之前检查ELF头是很重要的。
节头
首先要注意的是节不是段。段是程序执行的必要组成部分,在每个段中,会有代码或数据被划分为不同的节。
而节头表是对这些节的位置和大小的描述,主要用于链接和调试。一个二进制文件中如果缺少节头并不说明节不存在,只是无法通过节头来引用节,所以,ELF文件一定会有节,但是不一定会有节头。
text段的布局如下:
1 | [.text] 程序代码 |
2 | [.rodata] 只读数据 |
3 | [.hash] 符号散列表 |
4 | [.dynsym] 共享目标文件符号数据 |
5 | [.dynstr] 共享目标文件符号名称 |
6 | [.plt] 过程链接表 |
7 | [.rel.got] G.O.T重定位数据 |
data段的布局如下:
1 | [ ] 全局初始化变量 |
2 | [ ] 动态链接结构的对象 |
3 | [ ] 全局偏移表 |
4 | [ ] 全局未初始化变量 |
接下来将介绍一些比较重要的节:
- .text节
保存了程序代码数据的代码节。如果存在Phdr,那么.text节就会存在于text段中。 - .rodata节
保存了只读的数据,比如printf ("Hello World!\n");
这句代码就是保存在.rodata节中,并且只能在text段中找到.rodata节。 - .plt节
包含动态链接器调用从共享库导入的函数所必须的相关代码。 - .data节
.data节存在于data段中,保存了初始化的全局变量等数据。 - .bss节
保存了未进行初始化的全局数据,在data段中。 - .got.plt节
.got节保存了全局偏移表,.got和.plt节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。 - .dynsym节
保存了从共享库导入的动态符号信息,在text段中。 - .dynstr节
保存了动态符号字符串表,存放了代表符号名称的字符串。 - .rel.*节
重定位节保存了重定位相关的信息,这些信息描述了如何在链接或者运行时对ELF目标文件的某部分或进程镜像进行补充或修改, - .ctors、.dtors节
构造器(.ctors)和解析器(.dtors)保存了指向构造函数和析构函数的函数指针,构造函数是指在main函数执行之前执行的代码,析构函数是在main函数之后执行的代码。
ELF程序头
ELF程序头是对二进制文件中段的描述,而段是在内核装载是被解析,描述了磁盘上可执行文件的内存布局以及如何映射到内存中的。
PT_LOAD
可装载段,即这类段将被装载或映射到内存中。PT_DYNAMIC
动态段的Phdr(Program Headers),动态段是动态链接可执行文件所特有的,包含了动态链接器所必须的信息。包括:
1
运行时需要链接的共享库列表
2
全局偏移表(GOT)的地址
3
重定位条目的相关信息
PT_NOTE
保存了操作系统的规范信息,实际上在可执行文件运行时不需要这个段,所以这个段成了很容易感染病毒的地方。PT_INTERP
对程序解释器即动态链接器位置的描述,将位置和大小信息存放在以null为终止符的字符串中。PT_PHDR
保存了程序头表本身的位置和大小,Phdr表保存了所有Phdr对文件中段的描述信息。
用$ readelf -l
命令可以查看文件的Phdr表:
LOAD段是需要操作系统加载到内存的部分,而INTERP段则是存储链接器的位置,我们的ls所需要的链接器为/lib64/ld-linux-x86-64.so.2。
.symtab和.dynsym
ELF文件包含了我们需要记录的东西,以便以后访问:名称、地址、大小和预期目的。如果没有这些信息,ELF文件将无法运行。
考虑一下:当你用任何语言编写程序时,只要是直接机器码,你都会给函数和数据赋予符号名称。编译器将这些东西转换成代码。在机器级别,它们只能理解它们的地址(文件内的偏移量)和大小。这个机器代码里没有名字。那么,一个链接器如何组合多个目标文件,或者一个符号调试器如何知道对给定地址使用什么名称呢?我们如何理解这些文件?
符号是我们管理这些信息的方式。编译器生成代码的同时生成符号信息。链接器操作符号,读入它们,匹配它们,然后把它们写出来。链接器做的几乎所有事情都是由符号驱动的。最后,调试器使用它们来找出它们所查看的内容,并为您提供该信息的可读视图。
因此,没有符号表的ELF文件是非常罕见的。然而,大多数程序员只有一种抽象的知识,即符号表存在,它们与它们的函数和数据以及一些“其他的东西”松散地对应。受到编译器、链接器和调试器抽象的保护,我们通常不需要了解过多有关如何组织符号表的细节。我最近完成了一个项目,该项目要求我学习关于符号表非常详细。今天,我将写下链接器使用的符号表。
.symtab和.dynsym格式文件
可共享对象和动态可执行文件通常有两个不同的符号表,一个名为“.symtab”,另一个名为“.dynsym”。(为了便于阅读,从现在开始,我将引用这些没有引号或前导点号的引用。)
dynsym是symtab的子集,只包含全局符号。因此,在dynsym中找到的信息也在symtab中找的到,而相反的情况却不存在。你几乎肯定想知道为什么我们用两个符号表使世界复杂化。一张表不行吗?可以的,但是会牺牲在运行过程中使用超过必要的内存。
为了理解这个过程,我们需要理解可分配和不可分配ELF节之间的区别。ELF文件包含一些节(例如:代码和数据)在运行时被使用它们的进程所需要。这些部分被标记为可分配。有许多其他部分是链接器、调试器和其他类似工具所需要的,但是运行的程序并不需要。这些资源据说是不可分配的。当一个链接器构建一个ELF文件时,它将所有可分配的节聚集在文件的一个部分中,而所有不可分配的节则被放置到其他地方。当操作系统加载生成的文件时,只有可分配部分映射到内存中。不可分配部分保留在文件中,但在内存中不可见。可以使用strip(1)从文件中删除某些不可分配的节。这通过丢弃信息来减少文件大小。程序仍然可以运行,但是调试器告诉你程序在做什么的能力可能会受到阻碍。
完整的符号表包含链接或调试文件所需的大量数据,但在运行时不需要。事实上,在共享库和动态链接之前,在运行时不需要任何共享库和动态链接。有一个单一的、不可分配的符号表(合理地命名为“symtab”)。当动态链接添加到系统中时,原始设计者面临着一个选择:使symtab可分配,或者提供第二个更小的可分配副本。运行时需要的符号是总数的一小部分,因此第二个符号表在运行过程中保存了虚拟内存。这是一个重要的考虑。因此,第二个符号表被用于动态链接,并因此命名为“动态”。
总结就是ELF有两个符号表。symtab包含所有内容,但它是不可分配的,可以被剥离的,并且没有运行时开销。dynsym是可分配的,并包含支持运行时操作所需的符号。
ElF装载
程序装载是操作系统创建或扩充进程镜像的过程。进程空间如何构造,内存页面如何管理,以及进程如何被处理,不同的操作系统有不同的作法。
当系统创建或者扩充一个进程镜像时,逻辑上,它要把文件中的段复制成为虚拟内存中的一个段。但是系统不一定立刻真正地去读文件,什么时候读,还要依赖于程序的行为、系统负载等等。
为了提高了系统的性能和效率,可执行文件和共享目标文件中镜像在文件中的偏移量或者内存虚拟地址尽量是面向页面大小对齐。
对于 Intel 架构来说,页面的最大尺寸为 4KB,所以段的虚拟地址和文件内偏移量要向 4KB(0x1000)或者 4KB 的整数倍对齐,这样便于整页的换入换出,可以提高效率。以一个elf文件的程序头部表为例,包括代码段和数据段的内容,对齐属性p_align为4KB。
通过上表中的数据,计算出的文件偏移与内存偏移之间的关系如下图所示,其中不足一页的地方用0填充。
依赖库的加载(动态链接)
链接有两种方式,一种是静态链接,另一种是动态链接,这两种链接方式各有好处。
所谓静态链接就是在编译链接时直接将需要的执行代码拷贝到调用处,优点是在程序发布的时候就不需要依赖库,也就是不再需要带着库一块发布,程序可以独立执行,但是体积可能会相对大一些。
所谓动态链接就是在编译的时候不直接拷贝可执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统,操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定的代码时,去共享执行内存中已经加载的动态库可执行代码,最终达到运行时连接的目的。
假如是静态链接产生的elf可执行文件,则不需要这一步,完成装载阶段即可,只需最后将eip指向e_entry。
但是静态链接的程序相对较少,更多地是需要动态链接的程序,在linux 中很多程序都会依赖glibc,那谁来负责完成glibc的加载呢?这就涉及INTERP段了,里面包含了动态链接器的路径。
动态链接器
当创建一个可执行文件时,如果依赖其它的动态链接库,那么链接编辑器会在可执行文件的程序头中加入一个 PT_INTERP 项,告诉系统这里需要使用动态链接器,一般链接器为ld。
可执行文件与动态链接器一起创建了进程的镜像,这个过程包含以下活动。
- 添加可执行文件的段到进程空间;
- 添加共享目标文件的段到进程空间;
- 为可执行文件和共享目标文件进行重定位;
- 如果动态链接器使用了可执行文件的文件描述符,应关闭它;
- 把控制权交给程序。
因此如果程序是动态链接的,那在可执行程序运行之前,首先需要完成对链接器的装载并执行,之后的工作都交给链接器就好了。
程序与链接器交互
上文说到链接器会对程序进行处理,并且最后将控制权交还给程序,那站在链接器的位置思考就会出现问题:
当操作系统把控制权交给链接器时,它将开始进行链接工作,那么它至少需要知道关于可执行文件与进程的一些信息,不然无法对已经映射好的可执行文件镜像进行重定位。
这就涉及一个对动态链接器很重要的信息:辅助信息数组。辅助信息的格式也是一个结构数组,它的结构被定义在“elf.h”:
1 | typedef struct |
2 | { |
3 | uint32_t a_type; /* Entry type */ |
4 | union |
5 | { |
6 | uint32_t a_val; /* Integer value */ |
7 | /* We use to have pointer elements added here. We cannot do that, |
8 | though, since it does not work when using 32-bit definitions |
9 | on 64-bit platforms and vice versa. */ |
10 | } a_un; |
11 | } Elf32_auxv_t; |
摘录几个比较重要的类型值,这几个类型值都是比较常见的,而且是动态链接器在启动时所需要的:
在程序运行的时候,这些辅助信息很容易看到,通过设置环境变量 LD_SHOW_AUXV=1:
ELF装载总结
通过上文的思考,可以知道elf loader需要实现三个方面的内容:
- 映射可执行文件的LOAD段到内存中,并判断是否对齐
- 搜索INTERP段,如果存在,则像装载可执行文件一样装载链接器
- 初始化数据包括设置辅助信息数组,环境变量和参数,最后将eip指向程序基地址
用户态execve
用户态execve的开源项目:https://github.com/bediger4000/userlandexec
1 | 安装 |
2 | make |
3 | 运行 |
4 | ./example ./ulexec.so /usr/bin/cat /proc/self/maps |
用sysdig
监控命令执行的结果
1.正常执行cat /proc/self/maps
,监控到exe是cat命令
2.使用./example ./ulexec.so /bin/cat /proc/self/maps
,执行监控不到cat命令了,exe对应的是一个二进制文件。
这个用户态execve有什么作用呢?可用于绕过内核execve的高危命令监控。
检测
1.用户态execve绕过了高危命令的检测,但是二进制文件./example
和./ulexec.so
的文件还是调用execve
。可通过特征函数判断二进制文件是恶意的。
2.exe调用elf文件,参数中调用了命令,则很可能是elf loader。这是针对上述userlandexec类似的实现机制检测,如果在代码中中写死命令,则args=中为空的则很难判断。
3.其他更通用的检测方式暂时没有想到,如果你有更好的方法,请联系我。
参考:
http://articles.manugarg.com/aboutelfauxiliaryvectors.html
https://grugq.github.io/docs/ul_exec.txt
https://cloud.tencent.com/developer/article/1629853
https://xz.aliyun.com/t/2254#toc-2