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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
int global_init_var = 10;
int global_uninit_var;
void func(int sum)
{
printf("%d\n",sum);
}
void main(void)
{
static int local_static_init_var = 20;
static int local_static_uninit_var;

int local_init_var = 30;
int local_uninit_var ;

func(global_init_var + local_init_var + local_static_init_var);
}

执行下面四个命令,可以分别生成前面说过的几种文件

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct 
{
unsigned char e_ident[EI_NIDENT]; //elf标志
Elf64_Half e_type; //文件类型
Elf64_Half e_machine; //该文件适用的处理器体系结构
Elf64_Word e_version; //目标文件版本
Elf64_Addr e_entry; //程序入口的虚拟地址
Elf64_Off e_phoff; //程序头表开始处在文件中的偏移
Elf64_Off e_shoff; //节头表开始处在文件中的偏移
Elf64_Word e_flags; //处理器特定的标志位
Elf64_Half e_ehsize; //elf文件头的大小
Elf64_Half e_phentsize; //程序头表中表项大小
Elf64_Half e_phnum; //程序头表中表项数
Elf64_Half e_shentsize; //节头表中表项大小
Elf64_Half e_shnum; //节头表表项数
Elf64_Half e_shstrndx //节头表与节名字表对应的表项索引
}Elf64_Ehdr;

一个目标文件包含许多节,这些节的信息保存在节头表中,表的每一项都是Elf64_Shdr结构体,记录了节的名字、长度、偏移等信息。节头表的位置记录在文件头的e_shoff域中。节头表对于程序运行不是必须的,

elfDemo.rel的节头表如下所示:

Elf64_Shdr结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct
{
Elf64_Word sh_name; //节的名字,只是一个索引号
Elf64_Word sh_type; //节类型
Elf64_Xword sh_flags; //节属性
Elf64_Addr sh_addr; //映射的起始地址
Elf64_Off sh_offset; //相对于文件开头的偏移量
Elf64_Xword sh_size; //节的大小,单位是字节
Elf64_Word sh_link; //索引值,指向节头表中本节所对应的位置
Elf64_Word sh_info; //节的附加信息
Elf64_Xword sh_addralign; //节的对齐信息
Elf64_Xword sh_entsize; //指定每一个表项的大小
}Elf64_Shdr;

下面来分别看看示例程序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
2
3
4
5
6
7
8
9
typedef struct
{
Elf64_Word st_name; //符号名字
unsigned char st_info; //符号类型和属性
unsigned char st_other; //暂未使用
Elf64_Section st_shndx; //节索引
Elf64_Addr st_value; //符号值
Elf64_Xword st_size; //符号大小
}Elf64_Sym;

重定位表

重定位是把符号引用与符号定义连接在一起的过程。我们在编写程序的过程中,我们只需要写入要调用的函数名(即符号引用),在重定位的过程中,函数名会与实际的函数所在地址联系起来,让程序知道应该跳转到哪里去。

Elf64_Rel和Elf64_Rela的结构体如下所示。

1
2
3
4
5
typedef struct
{
Elf64_Addr r_offset; //重定位时需要被修改的符号的偏移
Elf64_Xword r_info; //重定位类型和符号索引
}Elf64_Rel;
1
2
3
4
5
6
typedef struct
{
Elf64_Addr r_offset; //重定位时需要被修改的符号的偏移
Elf64_Xword r_info; //重定位类型和符号索引
Elf64_Sword r_addend; //做偏移调整
}Elf64_Rela;

可执行文件的装载

刚才了解了目标文件的链接视角,下面将从运行视角来看一下。当运行一个程序的时候,首先要将该文件和动态链接库装载到进程空间中,形成一个进程镜像。每一个进程都拥有独立的虚拟地址空间,这个空间的布局是由记录在段头表中的程序头决定的。

可以看到每个段都包含了一个或者多个节,相当于是对这些节进行了分组,段的出现也正是出于此目的。随着节的数量增多,在进行内存映射的时候就产生了空间和资源浪费的问题。实际上,系统并不关心每个节的实际内容,而是关心这些节的权限,通过将不同权限的节分组,即可同时装载多个节,从而节省资源。

下面简要的讲解几个段。

通常一个可执行文件至少要有一个PT_LOAD类型的段,用于描述可装载的节,而动态链接的可执行文件则包含两个,将.data和.text分开存放。动态段PT_DYNAMIC包含了一些动态链接所必须的信息,如共享库列表、GOT表等。PT_NOTE类型的段保存了系统相关的附加信息。PT_INTERP段将位置和大小信息存放在一个字符串中,是对程序解释器位置的描述。PT_PHDR段保存了程序头表本身的位置和大小。

Elf64_Phdr结构如下

1
2
3
4
5
6
7
8
9
10
11
typedef struct
{
Elf64_Word p_type; //段类型
Elf64_Word p_flags; //段属性
Elf64_Off p_offset; //段开头偏移量
Elf64_Addr p_vaddr; //段开始位置的虚拟地址
Elf64_Addr p_paddr; //段开始的物理空间
Elf64_Xword p_filesz; //段在文件中的大小
Elf64_Xword p_memsz; //段在内容镜像中的大小
Elf64_Xword p_align; //段对齐
}Elf64_Phdr;

静态链接

地址空间的分配

链接由链接器完成,根据发生的时间不同,可以分为编译时链接、加载时链接和运行时链接。

重新写两个示例文件,main.c和func.c.

main.c

1
2
3
4
5
6
7
8
9
extern int shared;
extern void func(int *a,int *b);
int main()
{
int a = 100;
func(&a,&shared);
return 0;
}

func.c

1
2
3
4
5
6
7
8
int shared = 1;
int tmp=0;
void func(int *a,int *b)
{
tmp = *a;
*a = *b;
*b = tmp;
}

使用如下命令进行编译

1
gcc -static -fno-stack-protector main.c func.c -save-temps --verbose -o func.ELF

在将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
2
gcc -shared -fpic -o func.so func.c
gcc -fno-stack-protector -o func.ELF2 main.c ./func.so

位置无关代码

可以加载而无需重定位的代码称为位置无关代码(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 协议 ,转载请注明出处!