ELF文件详解

1,199 阅读13分钟

ELF文件

ELF文件是UNIX系统中可执行文件格式。linux下可执行文件格式就是ELF,Executable and Linkable Format。ELF格式文件可以是待重定位文件,也可以是共享目标文件,也可以是可执行文件。他们区别如下:

image.png

下面简单介绍一下如何获取elf文件,主要介绍待重定位文件和可执行文件,以C语言为例子,注意gcc默认生成与你系统位数相匹配的elf文件。

通过gcc可以生产未链接的目标文件,也就是待重定位文件,需要通过 -c 参数
gcc -c main.c -o main.o

gcc直接进行编译生成的文件即为可执行文件
gcc main.c -o main.o

这里其实也可以通过ld生产可执行文件,不过main.c里的入口函数要改为_start才能单独ld main.c文件,不然需要自己通过gcc info去找链接了哪些文件

两种文件通过file命令查看区别

Y0E(02}S6G2(N244R$O9%6M.png

1680008854856.png

简单来说,下者是上者经过链接后得到的。顺便介绍一下如何通过汇编获取可执行elf文件。

假设此时已经有一个写好的main.asm汇编文件,通过nasm可以生成32位未链接目标文件
nasm -f elf32 -o main.o main.asm

通过ld命令可以获得32位可执行文件
ld -m elf_i386 test.o -o test.bin

elf文件结构

以下内容针对可执行文件格式介绍。对于待重定位文件格式来说需要引入节的内容,之后再讲。

elf文件结构其实长得很像http包文,因此在这先用http报文做一个引入。HTTP报文可以分为HTTP头和http主体内容,而对于elf文件来说也一样,可以将其分为elf头与elf段,称之为程序头表(program header table) 和段(segment)。而在HTTP头中,囊括了整个主体内容的元信息,比如数据相对于报文头的偏移,数据长度,字段等信息,而主体中则是真正传递的内容。elf中的程序表头也做了一样的事情,告诉了操作系统有程序有多少个段,每个段的偏移是多少,大小是多少等信息,而段则是程序的执行指令和数据,需要被加载到内存中运行。具体结构如下:

{CUOXF%5UUYRL5V_@AUL6.png

这边只关注运行视图,可以看到有elf头,程序表头和段,最后则是可执行文件体。首先,elf的结构体定义文件可以在/usr/include/elf.h中找到,可以直接通过vim /usr/include/elf.h命令打开 ,而接下来的分析我会直接使用源码进行。

首先是一些定义的数据,方便后续阅读

image.png

elf_header

elf header结构,下面一个一个成员进行解析

#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_ident数组,用来表示 elf 字符等信息,前四位固定值为"7f 45 4c 46"。具体说明如表所示

1680010385198.png

  • e_type占用2字节,是用来指定elf目标文件的类型,取值为1代表可重定位文件,取值为2代表可执行文件,取值为3代表动态共享文件。
  • e_machine占用2字节,用来描述elf目标文件的体系结构类型,也就是说该文件要在哪种硬件平台(哪种机器)上才能运行。
  • e_version占用4字节,用来表示版本信息。
  • e_entry占用4字节,用来指明操作系统运行该程序时,将控制权转交到的虚拟地址。
  • e_phoff占用4字节,用来指明程序头表(program header table)在文件内的字节偏移量。如果没有程序头表,该值为0。
  • e_shoff占用4字节,用来指明节头表(section header table)在文件内的字节偏移量。若没有节头表,该值为0。
  • e_flags占用4字节,用来指明与处理器相关的标志
  • e_ehsize占用2字节,用来指明elf_header的宇节大小。
  • e_phentsize占用2字节,用来指明程序头表(program header table )中每个条目(entry)的字节大小,即每个用来描述段信息的数据结构的字节大小,该结构是后面要介绍的struct Elf32_Phdr。
  • e_phnum占用2字节,用来指明程序头表中条目的数量。实际上就是段的个数。
  • e_shentsize占用2宇节,用来指明节头表(section header table)中每个条目(entry)的字节大小,即每个用来描述节信息的数据结构的字节大小。
  • e shnum占用2字节,用来指明节头表中条目的数量。实际上就是节的个数。
  • e_shstrndx占用2宇节,用来指明string name table在节头表中的索引index。

那么接下来开始实战环节,带领大家开始一步一步解析elf文件,通过第一章讲解已经生成了一个可执行elf文件main.o,由于我所使用的虚拟机位64位虚拟机,因此我这边采取使用汇编,并用nasm生成32位可执行文件,使用的命令上面已经介绍过。如果你确实想用gcc生成32位c可执行文件,这边也给出方法。汇编文件test.asm内容如下:

[BITS 32]
[SECTION .text]

global _start
_start:
    jmp $

1680013229853.png检查一下,确认是32位。

附:64位系统下用gcc生成32位可执行文件方法。首先是main.c文件

// 注意,方法名必须写成_start,这可能是ld入口函数吧
int _start() {
    while(1);
}

然后执行如下命令,一样可以得到elf32位可执行文件

gcc -m32 -c main.c -o main.o
ld -m elf_i386 main.o -o main.bin

用二进制打开文件test.bin,如果没有CLION的可以用命令hexdump -x test.bin –n 64,注意这里的elf文件是LSB的。这里截取文件前64个字节得到结构如下,并且对一些重要的参数进行讲解

1680013507887.png图中框出的前16个字节为e_ident,可以看到前四个字节"7f 45 4c 46",代表是elf文件。

1680013552576.png接下来的两个字节为e_type,2代表了文件为可执行文件(小端排序)

1680014207515.png此四个字节为e_entry,代表了操作系统运行该程序时,将控制权转交到的虚拟地址,地址为0x08048060。

1680014135186.png此四个字节为e_phoff,用来指明程序头表在文件内的字节偏移量,为52字节。

1680014490700.png此两个字节为e_phentsize,指明程序头表每个entry大小为32字节。

1680014674466.png此两个字节为e_phnum,指明程序头只有1个段。这里说明一下为啥只有一个段,因为我们在汇编中只定义了一个代码段,因此也就只有一个段啦。汇编编译器不会像C编译器那样给你做多余的操作的。

以上信息也可以通过命令readelf -h test.bin读取。

1680014776702.png

Elf32_Phdr

接下来查看program header table中的条目,其结构体为Elf32_Phdr,用来描述磁盘上程序中的一个段,为段元数据。结构如下

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;

p_type为四个字节,用来指明程序中该段的类型,说明如表所示: 1680015453634.png

  • p_offset占用4字节,用来指明本段在文件内的起始偏移字节。
  • p_vaddr占用4字节,用来指明本段在内存中的起始虚拟地址
  • p_paddr占用4字节,仅用于与物理地址相关的系统中,因为System V忽略用户程序中所有的物理地址,所以此项暂且保留,未设定。
  • p_filesz占用4字节,用来指明本段在文件中的大小。
  • p_memsz占用4字节,用来指明本段在内存中的大小。
  • p_alin占用4字节,用来指明本段在文件和内存中的对齐方式。如果值为0或1,则表示不对齐。否则p_align应该是2的幂次数。
  • p_flags占用4字节,用来指明与本段相关的标志,此标志取值范围见表:

1680015730372.png

废话少说,那么接下来我们直接实战解析,由上面e_phoff可知应该从0x34位置开始,读取32字节,即为program header table的全部内容:

1680016411454.png,只解析重要部分内容

p_type为1,表明该段为可加载程序段;p_offset为0,代表用来指明本段在文件内的起始偏移字节为0;p_vaddr为0x08048000,指明本段在内存中的起始虚拟地址为0x08048000;p_filesz和p_memsz均为0x62字节,即此段有92个字节。

当然,上面内容仅需要命令readelf -l test.bin就可以查看了

那么问题来了,得到了以上信息,我们该怎么找到真正会被加载到内存上的代码段呢?首先,我们已知了整个elf入口地址为0x8048060,同时也知道了程序第一个段的起始位置为0x8048000,同时也知道了第一段大小为0x62。

那么计算一下,第一个段的末尾是0x8048062,同时程序起点入口为0x8048060,那么说明该段实际可执行代码长度只有2个字节。那么首先验证一下,jmp $是否是两个字节。这里直接给出结论,其对应的机器码为EB FE(这里如果不确定机器码是否是正确的,可以自己用虚拟机跑一下,比如我就是用的bochs跑的jmp $指令)。我们有理由推测,因上面计算的实际可自行代码长度2字节应该就是我们的jmp命令。

接下来我们需要找到实际上代码在文件中的偏移0x60,看看是不是我们的机器码指令EB FE。如下图所示

1680018250338.png可以看到,前两个字节就是EB FE,也证明了程序入口处确实就是我们写的指令。那么问题来了,第一段起始位置为什么是0x08048000,前面的0x60之前哪些内容是啥呢?答案很简单,就是elf_head和program header table啊。

待重定向文件分析

这章主要是分析待重定向elf文件内容,所用到的部分就是上一节中没有介绍的节头。首先依旧是获取elf文件,由于需要分析节头,这里选择使用c语言进行编译,main.c内容如下。

int a;
int b = 0;
char* s1 = "hello1";

int _start() {
    int c = 0;
    char s2[] = "hello2";

    while(1);
}

然后通过命令gcc -m32 -c main.c -o main.o 获得重定向文件

1680021898570.png

elf_head为前面的内容不再赘述,这里主要看三个参数e_shoff, e_shentsize,e shnum。直接通过readelf分析。

1680022223812.png

节头结构体如下:

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;

image.png

  • sh_name:节名字,4字节
  • sh_type:节类型,四字节。这个字段根据节的内容(content)和语义(semantics)对节进行分类
  • sh_flags:4字节,字段标记是否可读可写可执行等,以及是否在内存中分配内存
  • sh_addr:4字节,表示这个节被加载到哪个虚拟地址上
  • sh_offset:4字节,表示这个节在文件中的偏移
  • sh_size:4字节,表示节的大小
  • sh_link:4字节,一般用于关联节所在节头表的数组下标,后续介绍
  • sh_info:一般用于关联节所在节头表的数组下标,后续介绍
  • sh_addralign:对其数值。如果为0或者1表示不对齐。
  • sh_entsize:部分节内部存储是固定数据结构条目数组,针对这类别节sh_entsize指代的是每个条目的字节大小。后续介绍

大致结构如下图:

image.png

通过readelf -S main.o来直接分析有哪些节内容:

1680022327824.png

下面对每个出现的节进行分析。

.text/.data/.rodata/.bss节区

这四个节区是ELF的基本节区,其中:

  • .text是默认的代码段
  • .data是默认的rw数据段,一般定义的全局变量都在这个区,比如代码中的b。
  • .rodata是默认的只读数据段,char*全局变量会放在这个区。
  • .bss是未初始化或初值为0的全局变量所在节区,未赋初值的全局变量会放在这个区。 .bss 中的数据因为默认值为0,故不需要在文件中为其分配空间,程序运行时在内存申请空间即可

事实上,在连接阶段时,连接器ld会将多个.o的.text段进行合并,对应的数据段也会合并。合并方法记录在默认的链接脚本中,可通过 ld -verbose来查看。

在我们的c文件中几种变量的定义实际上已经将这几个区都体现出来了,具体的内容可以通过汇编代码中查看,通过指令gcc -S main.c -o main.s 查看:

	.file	"main.c"
	.comm	a,4,4
	.globl	b
	.bss
	.align 4
	.type	b, @object
	.size	b, 4
b:
	.zero	4
	.globl	s1
	.section	.rodata
.LC0:
	.string	"hello1"
	.data
	.align 4
	.type	s1, @object
	.size	s1, 4
s1:
	.long	.LC0
	.text
	.globl	_start
	.type	_start, @function
_start:
.LFB0:
	.cfi_startproc
	pushl	%ebp
	.cfi_def_cfa_offset 8
	.cfi_offset 5, -8
	movl	%esp, %ebp
	.cfi_def_cfa_register 5
	subl	$24, %esp
	movl	%gs:20, %eax
	movl	%eax, -12(%ebp)
	xorl	%eax, %eax
	movl	$1, -24(%ebp)
	movl	$1819043176, -19(%ebp)
	movw	$12911, -15(%ebp)
	movb	$0, -13(%ebp)
.L2:
	jmp	.L2
	.cfi_endproc
.LFE0:
	.size	_start, .-_start
	.ident	"GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
	.section	.note.GNU-stack,"",@progbits

说实话,我也看不懂gcc汇编出来的内容是啥玩意,但是至少可以看出有.text/.data/.rodata/.bss的节内容,因此在ELF中的节应该也是这些内容对应的转化。还有下面代码段_start:开始的内容中,有一个jmp .L2的指令,这个也是我们需要关注的,他应该就是我们代码中写下的while(1)指令。接下来我们会在.text节找一下是否存在这个机器码,以确定.text确实是代码节区,就要用到这个指令。

符号表(.symtab)节

符号表中符号的实际上指的是汇编代码或汇编器(as)中的对符号的定义。上面代码中的b,.LC0,s1,_start等均为符号。除此之外,如.file和每个.section都会在最终目标文件中输出一个符号, 而以.L开头的符号则默认不会输出到目标文件(除非指定-L)选项。用 readelf -s main.o查看:

Symbol table '.symtab' contains 13 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 
     3: 00000000     0 SECTION LOCAL  DEFAULT    2 
     4: 00000000     0 SECTION LOCAL  DEFAULT    4 
     5: 00000000     0 SECTION LOCAL  DEFAULT    5 
     6: 00000000     0 SECTION LOCAL  DEFAULT    7 
     7: 00000000     0 SECTION LOCAL  DEFAULT    8 
     8: 00000000     0 SECTION LOCAL  DEFAULT    6 
     9: 00000004     4 OBJECT  GLOBAL DEFAULT  COM a
    10: 00000000     4 OBJECT  GLOBAL DEFAULT    4 b
    11: 00000000     4 OBJECT  GLOBAL DEFAULT    2 s1
    12: 00000000    43 FUNC    GLOBAL DEFAULT    1 _start

可以看到有三个全局变量在符号表中,为a,b,s1,符合我们代码所写内容。下面介绍符号表详细结构。

typedef struct { 
    Elf32_Word st_name; /* Symbol name (string tbl index) */ 
    Elf32_Addr st_value; /* Symbol value */ 
    Elf32_Word st_size; /* Symbol size */ 
    unsigned char st_info; /* Symbol type and binding */ 
    unsigned char st_other; /* Symbol visibility */ 
    Elf32_Section st_shndx; /* Section index */ 
} Elf32_Sym;
  • st_name 符号名称,它其实不是一个字符串,而是一个数值,代表的是目标文件中字符串表(.strtab节)中的一个索引值, 那里才真正存储着该符号的名称对应的字符串。如果st_name成员数值不为0,则代表该符号有符号名称。否则,说明该符号没有名称。
  • st_value 符号数值,它给出了相应的符号值。这个符号值具体是什么意思,是要依据上下文的(主要依据不同的符号属性和不同的目标文件),也许是个绝对值,也许是个地址值,等等。
  • st_size 符号大小,很多类型的符号都是有大小属性的。例如,一个数据对象的大小指的是它实际在目标文件中占的字节数。st_size成员如果是0的话,说明这个符号在目标文件中不占用任何字节数(例如common symbols)或者当前是未知大小的(例如undefined symbols)。
  • st_info 符号的类型和绑定属性,下面讲解
  • st_other 符号的可见性,下面讲解
  • st_shndx 符号(关联的节)的节头表索引值,下面讲解

对于st_info,其低四位代表符号的类型,高四位代表绑定的属性。一个符号的类型为该符号关联的实体进行分类。

  • 当符号类型是NOTYPE时,表明该符号未指定类型或者当前还不知道该符号的类型;
  • 当符号类型是OBJECT时,表明该符号关联的实体是个数据对象,例如一个变量,数组等;
  • 当符号类型是FUNC时,表明该符号关联的实体是个函数或者其他的可执行代码;
  • 当符号类型是SECTION时,表明该符号关联的实体是个节。一般符号表中的一个符号是这个类型时,主要是用于重定位的目的,并且其绑定属性一般情况下是LOCAL;
  • 通常情况下,当一个符号的类型是FILE时,这个符号的名称就是该目标文件相关联的源文件的名称。这种类型的符号的绑定属性是LOCAL的,与它相关的节的节头表索引值为ABS,并且如果在符号表中存在此种符号的话,那么其位置排在本地符号(LOCAL)的前头;
  • 当符号类型是COMMON时,表明该符号是个公用块数据对象,并且这个公用块在目标文件中实际是未被分配空间的; 当符号的类型是TLS时,表明该符号对应变量存储在线程局部存储内。

一个符号的绑定属性决定了该符号在链接阶段的可见性以及链接时的处理方式。例如全局符号和本地符号的链接可见性是不同的,而当出现同名的全局符号和弱符号时,链接器会做出相应的处理,这里只介绍两个:LOCAL代表本地变量,表明该符号的链接属性是internal的,对其他目标文件来说是不可见的,即不可访问;GLOBAL代表全局变量,表示该符号的链接属性是external的,对其他目标文件来说是可见的,即可以访问。

st_shndx表示了符号表中每个变量对应了节表中哪一个节,比如变量_start的Ndx为1,代表在.text节内。特殊值如下:

image.png

.plt/.rela.plt节区

elf文件拆解

仔细想想,elf文件中实际上可执行的文件内容包括在每一个段中,我们是否可以只将段抽取出来,单独封装成执行流呢?事实上,在没有操作系统的裸机上,cpu是没法识别出elf文件的,需要我们自己构建执行流。因此接下来的内容我们会将一个elf文件拆解,使他成为一个纯粹的二进制文件,交由CPU执行。对于可执行程序elf,我们只对其中的段(segment)感兴趣,它们才是程序运行的实质指令和数据的所在地,所以我们要找出程序中所有的段。

参考文献

《操作系统真相还原》

ELF文件中的各个节区_.rela.plt_ashimida@的博客-CSDN博客

一个简单程序从编译、链接、装载(执行)的过程-汇编和目标文件 - 知乎 (zhihu.com)

ELF符号表分析(转载)_fastaway的博客-CSDN博客

---------待更新