elf文件格式
本文最后更新于:2021-12-06 下午
ELF文件格式
elf文件,Executable and Linking Format,“可执行可连接格式”,具有这种格式的文件称为elf文件。
在elf规范中,把elf文件宽泛的称为“目标文件(Object File)”,这与我们通常理解的“.o”文件不同,elf文件仅指连接好的可执行文件。对于“.o”文件,可以直接称为可重定位文件。
elf文件主要分为三种类型:
- 可重定位文件(relocatable file):由源文件编译而成且尚未链接的目标文件,通常以“.o”作为扩展名,用于与其他目标文件进行连接以构建可执行文件或动态链接库,通常是一段位置独立的代码。
- 共享目标文件(shared object file):动态链接库文件。用于在链接过程中与其他动态链接库或可重定位文件一起构建新的目标文件,或者在可执行文件加载时,链接到进程中作为运行代码的一部分。
- 可执行文件(executable file):经过链接的、可执行的目标文件,通常也被称为程序。
目标文件的作用有两个,一是用于构建程序,构建动态链接库或可执行程序;二是用于运行程序。所以有两种视角来看待一个程序,链接视角,通过节来进行划分,另一种是运行视角,通过段来进行划分。
链接视角 | 运行视角 |
---|---|
ELF文件头 | ELF文件头 |
程序头表(可选) | 程序头表 |
第一节 | |
第二节 | 第一段 |
… | … |
第N节 | 第N段 |
节头表 | 段头表(可选) |
这里使用的示例程序
1 |
|
执行下面四个命令,可以分别生成前面说过的几种文件
gcc elfDemo.c -o elfDemo.exec
gcc -static elfDemo.c -o elfDemo_static.exec
gcc -c elfDemo.c -o elfDemo.rel
gcc -c -fPIC elfDemo.c -o elfDemo_pic.rel && gcc -shared elfDemo_pic.rel -o elfDemo.dyn
使用file命令查看,可以看到它们的类型
elf文件头
elf文件头位于目标文件最开始的位置,包含描述整个文件的一些基本信息,比如elf文件类型、版本、目标机器、程序入口等。
如图可以看到elfDemo.rel文件头的一些信息。
Elf64_Ehdr结构体如下所示:
1 |
|
节
一个目标文件包含许多节,这些节的信息保存在节头表中,表的每一项都是Elf64_Shdr结构体,记录了节的名字、长度、偏移等信息。节头表的位置记录在文件头的e_shoff域中。节头表对于程序运行不是必须的,
elfDemo.rel的节头表如下所示:
Elf64_Shdr结构体如下:
1 |
|
下面来分别看看示例程序elfDemo.rel的.text节.data节和.bss节。
首先是.text节,Contents of section中从左到右依次是偏移,数据,以及十六进制形式。
Disassembly of section则是反汇编的结果
接下来看数据节和只读数据节。可以看到在.data中保存着0a000000和14000000而它们分别是int global_init_var和static int local_static_init_var(0xa=10,0x14=20)。
.rodata保存只读数据,包括只读变量和字符串常量。源码中调用printf用了“%d\n”,这是一种只读数据,保存在.rodata中。
BSS节用于保存未初始化的全局变量和局部静态变量。可以看到它并没有CONTENTS属性,表示该节在文件中实际上不存在,只是为了变量预留位置。
其他一些常见的节
节名 | 说明 |
---|---|
.comment | 版本控制信息,如编译器版本 |
.debug_XXX | DWARF格式的调试信息 |
.strtab | 字符串表 |
.shstrtab | 节名的字符串表 |
.symtab | 符号表 |
.dynamic | ld.so使用的动态链接信息 |
.dynstr | 动态链接的字符串表 |
.dynsym | 动态链接的符号表 |
.got | 全局偏移量表,用于保存全局变量引用的地址 |
.got.plt | 全局偏移量表,用于保存函数引用的地址 |
.plt | 过程链接表,用于延迟绑定 |
.hash | 符号哈希表 |
.rela.dyn | 变量的动态重定位表 |
.rela.plt | 函数的动态重定位表 |
.rel.text/rela.text | 静态重定位表 |
.rel.XXX/rela.XXX | 其他节的静态重定位表 |
.note.XXX | 额外的编译信息 |
.eh_frame | 用于操作异常的frame unwind信息 |
.init/.fini | 程序初始化和终止的代码 |
字符串表
字符串表包含了以NULL结尾的字符序列,用来表示符号名和节名,引用字符串时只需要给出字符序列在表中的偏移即可。字符串表的第一个字符和最后一个字符都是NULL字符,确保所有字符串的开始和终止。
符号表
符号表记录了目标文件中所用到的所有符号信息,通常分为.dynsym和.symtab。.dynsym保存了引用自外部文件的符号,只能在运行时被解析,而.symtab还保存了本地符号,用于调试和链接。目标文件通过一个符号在表中的索引值来使用该符号。索引值从0开始计数,但值为0的表项不具有实际的意义,它表示未定义的符号。每个符号都有一个符号值,对于变量和函数,该值就是符号的地址。
1 |
|
重定位表
重定位是把符号引用与符号定义连接在一起的过程。我们在编写程序的过程中,我们只需要写入要调用的函数名(即符号引用),在重定位的过程中,函数名会与实际的函数所在地址联系起来,让程序知道应该跳转到哪里去。
Elf64_Rel和Elf64_Rela的结构体如下所示。
1 |
|
1 |
|
可执行文件的装载
刚才了解了目标文件的链接视角,下面将从运行视角来看一下。当运行一个程序的时候,首先要将该文件和动态链接库装载到进程空间中,形成一个进程镜像。每一个进程都拥有独立的虚拟地址空间,这个空间的布局是由记录在段头表中的程序头决定的。
可以看到每个段都包含了一个或者多个节,相当于是对这些节进行了分组,段的出现也正是出于此目的。随着节的数量增多,在进行内存映射的时候就产生了空间和资源浪费的问题。实际上,系统并不关心每个节的实际内容,而是关心这些节的权限,通过将不同权限的节分组,即可同时装载多个节,从而节省资源。
下面简要的讲解几个段。
通常一个可执行文件至少要有一个PT_LOAD类型的段,用于描述可装载的节,而动态链接的可执行文件则包含两个,将.data和.text分开存放。动态段PT_DYNAMIC包含了一些动态链接所必须的信息,如共享库列表、GOT表等。PT_NOTE类型的段保存了系统相关的附加信息。PT_INTERP段将位置和大小信息存放在一个字符串中,是对程序解释器位置的描述。PT_PHDR段保存了程序头表本身的位置和大小。
Elf64_Phdr结构如下
1 |
|
静态链接
地址空间的分配
链接由链接器完成,根据发生的时间不同,可以分为编译时链接、加载时链接和运行时链接。
重新写两个示例文件,main.c和func.c.
main.c
1 |
|
func.c
1 |
|
使用如下命令进行编译
1 |
|
在将main.o和func.o两个目标文件链接成一个可执行文件时,最简单的方法是按照顺序叠加。
这里就直接用了书上的图
这种方法的弊端就是,如果参与链接文件很多的话,那么输出的可执行文件就会非常的零散。从对齐的角度来讲,越多的代码节和数据节也会造成内存空间的浪费。
另一种方案就是将相似的节进行合并,将不同目标文件相同属性的节合并为一个节,比如将两个文件中的.text节合并为新的.text节。这种方案被当前的链接器所采用,首先对各个节的长度、属性和偏移进行分析,然后将输入目标文件中符号表的符号定义与符号引用统一生成全局符号表,最后读取输入文件的各类信息对符号进行解析、重定位等操作。相似节的合并就发生在重定位时。完成之后,程序中的每个指令和全局变量就有唯一的运行时内存地址了。
此图也直接用了书上的图。
静态链接的详细过程
为了构造可执行文件,链接器必须完成两个重要的工作:符号解析和重定位。符号解析是将每个符号(函数、全局变量、静态变量)的引用与其定义进行关联。重定位则是将每个符号的定义与一个内存地址进行关联,然后修改这些符号的引用,使其指向这个内存地址。
可以比较一下静态链接可执行文件func.ELF和中间产物main.o的区别。重点关注下.text、.data和.bss节。
VMA是虚拟地址,LMA是加载地址,一般情况下,两者是相同的。可以看到main.o的VMA和LMA都是0而链接完成后的func.ELF中,相似节被合并,完成了虚拟地址的分配。
查看main.o的反汇编代码,看到在1D处,进行了call指令,这就是对于func的调用,此时符号还没有重定位,看到相对的偏移是0。
再看链接完成后的func.ELF,可以看到400b8a
处的call func,其中机器码是e8 07,而下一条指令偏移7处就是400b96,func的地址。
可重定位文件中最重要的就是要包含重定位表,用于告诉链接器如何修改节的内容。每个重定位表对应一个需要被重定位的节,例如名为.rel.text的节用于保存.text的重定位表。
动态链接
关于动态链接
随着系统中可执行文件的增加,静态链接带来的磁盘和内存空间浪费问题愈发严重。比如,大部分可执行文件都需要glibc,那么在静态链接时就要把libc.a和编写的代码链接进去,单个libc.a文件大小为5M左右,如果有1000个的话就是5G。这就会造成内存空间的浪费。还有一个明显的缺点就是,如果对标准函数做一些改动,都需要重新编译整个源文件,使得开发和维护很困难。
如果把系统可和编写的代码分成两个独立的模块,等到程序运行的时候,再把两个模块进行链接,就可以节省硬盘空间,并且内存中的一个系统库还可以被多个程序共同使用,还节省了物理内存空间。这种在加载或运行时,在内存中完成链接的过程就是动态链接,这些用于动态链接的系统库称为共享库,整个过程由动态链接器完成。
GCC默认使用的是动态链接编译,通过下面的命令可以将func.c编译为共享库,然后使用这个库编译main.c。参数-shared
表示生成共享库,-fpic
表示生成与位置无关的代码。这样可执行文件func.ELF2就会在加载时与func.so进行动态链接。
1 |
|
位置无关代码
可以加载而无需重定位的代码称为位置无关代码(PIC),它是共享库必须具有的属性,通过PIC,一个共享库的代码可以被无限多个进程所共享,从而节约内存资源。
由于一个程序的数据段和代码段的相对距离总是保持不变的,因此,指令和变量之间的距离是一个运行时常量,与绝对内存地址无关。于是就有了全局偏移量表(GOT),它位于数据段的开头,用于保存全局变量和库函数的引用,每个条目占8个字节,在加载时会进行重定位并填入符号的绝对地址。
实际上,为了引入RELRO保护机制,GOT被拆分为.got节和.got.plt节两部分,不需要延迟绑定的前者用于保存全局变量引用,加载到内存后被标记为只读;需要延迟绑定的后者则用于保存函数引用,具有读写权限。
总结
主要是看了些书上的内容记录了一下,有的地方还是不是很理解,最后关于有的动态链接的地方也没有写,就先不记录这些内容,就先记录这些,感觉要慢慢在实践中去理解了,向当初学习PE一样,最早看也觉得不是很明白,但是后来慢慢实际的东西中去学习,理解就越来越深了。
参考
《CTF竞赛权威指南(PWN篇)》
https://paper.seebug.org/papers/Archive/refs/elf/Understanding_ELF.pdf
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!