ELF文件格式

1.什么是 ELF

ELF,全称Executable and Linkable Format,即可执行与可链接格式,他是 Linux/unix 系统中最主流的二进制文件格式,常见于:

  • 可执行文件
  • 目标文件(.o)
  • 动态链接(.so)
  • Core Dump 文件
    总的来说,ELF 就是一种标准化的二进制封装格式,它把程序运行和链接所需的各种信息组织在了一起,包括:
  • 机器码
  • 数据
  • 符号表
  • 重定位信息
  • 动态链接信息
  • 入口地址
  • 段与节的布局信息

所以,可以理解成,ELF 是编译器,链接器,加载器,调试器,逆向工具共同遵守的一套二进制文件结构规范

2.ELF 文件解决了什么问题

一个程序从源码到运行,大致会经历以下过程:

  • 源码经过编译,生成目标文件 .o
  • 多个 .o 被链接成最终可执行文件或共享库
  • 操作系统把可执行文件加载到内存
  • 程序运行时,动态加载器解析动态库和符号引用

在这个过程中,不同阶段都需要不同的信息

  • 链接器需要直到有哪些符号,哪些地方需要重定向
  • 加载器需要直到哪些内容应该映射到内存,权限是什么
  • 调试器需要直到符号,源码映射,调试信息
  • 逆向工具需要直到代码段,数据段,导入导出符号

ELF 就是承载这些信息的一个统一的容器

3.ELF文件有哪些类型

ELF Header中有一个属性e_type,它就是专门用来说明文件类型的,可以看一下,它有哪些取值:

  • ET_REL:可重定位文件,比如说.o
  • ET_EXEC:可执行文件
  • ET_DYN:共享对象文件,比如说.so 文件就是这个类型
  • ET_CORE:Core Dump 文件(就是程序崩溃了,系统生成的一个 core 文件,可以看些日志啥的)

可以简单的理解为, .o 文件是给链接器用的(我记得好像之前在学习 arm汇编的时候用 ndk 去编译过 so 文件的生成),可执行文件和

4.ELF 文件的整体结构

一个典型的 ELF 文件可以抽象成下面的这个结构
image.png
其中,最重要的也就是

  • ELF Header:整个文件的总控头
  • Program Header Table:描述程序如何被装载到内存的
  • Section Header Table:描述文件中有哪些 section,以及他们的位置和属性

5.ELF Header:文件总说明书

ELF 文件开头就是 ELF Header,它相当于整个文件的说明书
常见关键字段包括:

  • e_ident:标识信息
  • e_type:文件类型
  • e_machine:目标架构
  • e_entry:程序入口地址
  • e_phoff:Program Header Table 偏移
  • e_shoff:Section Header Table 偏移
  • e_phnum:Program Header 数量
  • e_shnum:Section Header 数量

其中, e_ident 挺重要的,它包含:

  • ELF 魔数: 0x7f,'E','L','F'
  • 32 位还是 64 位
  • 大端还是小端
  • ABI 的相关信息

具体什么值代表什么特征,就用到了再去查吧,因为我发现就算写文章写了,后面还是不会记得
所以,当看到文件的开头是 7f 45 4c 46,那就能够判断出这个 ELF 文件

6.Section 和 Segment 到底有什么区别

这个是 ELF 最容易混淆的点,先好好理一下

Section:逻辑组织单位

Section(节)主要是给链接器,分析器,反汇编器使用的,它反映的是"文件中有哪些逻辑内容"。
例如:

  • .text:代码
  • .data:已初始化数据
  • .bss:未初始化数据
  • .rodata:只读常量
  • .symtab:符号表
  • .strtab:字符串表
  • .rela.text:重定向信息

Segment:运行时装载单位

Segment(段)主要是给操作系统加载器使用的,它反映的是"程序该如何映射到内存"。
加载器不关心 section 的名字是什么,它更关心:

  • 从文件哪个偏移读取
  • 映射到内存哪个地址
  • 映射多大
  • 权限是读,写和执行

最关键的区别

Section 是逻辑分块,Segment 是运行分块,多个 section 可以被打包进同一个 segment
例如:

1
2
.text+.rodata =>一个R-X的LOAD段(可读,可执行)
.data+.bss =>一个RW-的LOAD段(可读,可写)

所以,链接器主要看 section,加载器主要看 segment

常见的 Section 详解

.text

存放机器码,也就是函数指令本体。它有以下特点:

  • 通常只读
  • 通常可执行
    这个是在逆向分析中最重要的 section 之一
    例如:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

int main() {
    int x = add(1, 2);
    printf("%d\n", x);
    return 0;
}

这里面的 add()main(),编译后,他们的机器码通常就会放进 .text section,怎么放呢?当然是变成汇编咯,然后放进去

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
add:
    push rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov DWORD PTR [rbp-8], esi
    mov edx, DWORD PTR [rbp-4]
    mov eax, DWORD PTR [rbp-8]
    add eax, edx
    pop rbp
    ret

放进去就是这玩意儿

.data

存放已初始化的全局变量和静态变量
例如:

1
int g = 10;

这类变量一般进入 .data
特点:

  • 可读可写
  • 文件中有真实内容

.bss

存放未初始化或初始化为 0 的全局/静态变量
例如:

1
2
int x;
char buf[1024];

特点:

  • 在文件中通常不真正存储这些数据
  • 只记录"需要多大空间"
  • 程序装载是由系统分配并清零

因此, .bss 很大不代表文件体积也很大

.rodata

存放只读常量,比如:

  • 字符串常量
  • const 常量
  • switch 跳转表

例如:

1
char *s = "hello";

字符串 hello 通常就存放在 .rodata 中,像我们经常逆向的时候会发现,某些字节数据逆向到最后发现在 .rodata 中,它就代表是固定的常量

.symtab 与.strtab

.symtab 是符号表,用于记录函数名,变量名,符号地址,大小,类型,绑定属性
.strtab 是字符串表,给符号表提供名字字符串
通常符号表不直接存字符串,而是存字符串偏移,但是这样说可能没啥概念,举个例子吧
比如说代码是这样的

1
2
3
4
5
6
7
8
9
int global_var = 123;

int add(int a, int b) {
    return a + b;
}

int main() {
    return add(global_var, 1);
}

他有哪些符号呢? global_var, add, main,这些符号信息就会放在 .symtab 中,怎么存的呢?

符号名偏移值(地址)大小类型绑定
10x4010004OBJECTGLOBAL
120x40112620FUNCGLOBAL
160x40113a30FUNCGLOBAL

他不是存的"global_var",“add”,“main”,这些字符串,而是存的一个偏移地址,那它真正存放的字符串在哪儿呢?

strtab中,它是这样存放的

偏移内容
0""
1“global_var”
12“add”
16“main”

这样来对应一下:

  • symtab[0].name=1=>去.strtab+1读到global_var
  • symtab[1].name=12=>去strtab+12读到add
  • symtab[2].name=16=>去strtab+16读到main

.dynsym 与 .dynstr

这是动态链接相关的符号表与字符串表,它和.symtab相比

  • .symtab更完整,偏向链接和调试
  • .dynsym更精简,只保留动态链接所需符号

如果一个strip过的elf可能没有.symtab,但是它通常会保留.dynsym

.rel.* / .rela.*

这个是重定位表,如果某段代码引用了一个编译时尚未确定地址的符号,就需要重定位信息来告诉链接器或加载器:将来如何修补这个位置

常见场景包括:

  • 调用外部函数
  • 访问全局变量
  • 动态库符号引用

RELRELA的区别在于:

  • REL:不显式存放addend
  • RELA:显式存放addend

在x86_64上常见的是RELA,但是这个还是不太懂,找个例子看一下吧

在编译的时候,很多地址还不知道,ELF就会先留一个坑,顺便会记录下来:以后要补哪儿;按照哪个符号补;用什么方式补

例如:

1
2
3
4
5
6
#include <stdio.h>

int main() {
    printf("hello\n");
    return 0;
}

编译器在编译main时,不知道运行时printf的真实地址,所以它不能够直接写死

1
call 0x7fxxxxxx

因为printf在libc.so里,地址要等加载后才知道,于是,这个编译器/链接器就会干两件事情

  1. 机器码里放一个占位写法
  2. .rel.*或者.rela.*里留一条记录,这里将来要与printf这个符号关联起来

而这条记录就是重定位项。

.dynamic

这里是动态链接的重要信息区,里面保存着:

  • 依赖哪些共享库
  • 动态符号表位置
  • 动态字符串表位置
  • 重定位信息位置
  • 初始化和析构相关地址

动态加载器就会重点解析这个部分的内容

.plt和.got

这个在动态链接这个部分还挺重要的,后面需要重点分析一下

7.Program Header

Program Header Table 描述的是这个ELF文件应该如何被装载到内存,每一项叫一个program header或者segment descriptor,关键字段有:

  • p_type:段类型
  • p_offset:文件偏移
  • p_vaddr:虚拟地址
  • p_filesz:文件中大小
  • p_memsz:内存中大小
  • p_flags:段权限
  • p_align:对齐方式

常见的p_type有:

  • PT_LOAD:可加载段
  • PT_DYNAMIC:动态链接信息
  • PT_INTERP:动态加载器路径
  • PT_NOTE:附加说明信息
  • PT_PHDR:Program Header自身
  • PT_TLS:线程局部存储

但是,这么说有点干,举个例子吧,假设ELF中有有这样一个PT_LOAD段:

1
2
Type    Offset   VirtAddr   FileSiz  MemSiz   Flags  Align
LOAD    0x0000   0x400000   0x1200   0x1200   R E    0x1000

这里就对应着上面的各个类型,首先看type,代表 LOAD,表示一个需要被加载到内存中的段
offset,这里是0x0000,代表这个段的数据,从ELF文件里的0x0000偏移开始读,假如把一块ELF文件想象成一整块二进制数据,p_offset就是在说从文件哪个位置开始读这一段内容
VirtAddr,虚拟地址,这里是0x400000,代表这段内容加载到进程内存后,要放到虚拟地址0x400000,也就是说文件里的一段字节,会被映射到内存里的这个地址范围,比如说这个段的大小是0x1200,那么内存里大致就是0x400000~0x4011ff
FileSiz文件大小,这里是0x1200,意思是这个段在ELF文件里实际占0x1200字节,也就是从文件偏移0x0000开始,连续读取0x1200字节
MemSiz,内存中的大小,这里也是0x1200,意思是这段装入内存后,也占0x1200字节,在这个例子中,文件里有多大,内存里就有多大,没有额外补0的区域
Flags段权限,这里是R E,意思是R代表刻度,E代表可执行,没有W,代表不可写,所以这是一个典型的代码段权限

  • 通常.text所在段常见是R E
  • .data所在段常见是RW

Align对齐方式,这里是0x1000,意思是这个段在文件和内存中的映射需要按0x1000对齐,0x1000=4096,通常就是一页内存大小,这表示文件偏移和虚拟地址通常会按页对齐,方便操作系统按页映射

8.程序从执行到进入main的过程

一个典型的动态链接程序执行的流程如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
用户执行程序
内核读取 ELF Header
根据 Program Header 映射 PT_LOAD 段
发现 PT_INTERP
加载动态链接器(如 ld-linux.so)
动态链接器加载 libc 等共享库
进行符号解析和重定位
执行初始化函数
跳转到程序入口 _start
libc 完成运行时初始化
调用 main

ELF Header中的e_entry一般指向程序入口,但是这个入口通常不是main,而是_start
_start负责:

  • 初始化栈环境
  • 准备参数
  • 调用__libc_start_main
  • 最终才进入main

9.动态链接机制.plt.got

这个是逆向中比较会常用的一个点了
比如说程序中调用了:

1
printf("hello\n");

编译器在编译主程序的时候,其实并不知道printf运行时会被映射到哪个地址,所以需要一套机制来解决"外部符号地址未知"的问题

.plt和.got分别是什么?

.plt过程链接表,本质上是一组跳板代码,程序调用外部函数时,不一定直接跳转到真实地址,而是先跳转到.plt里的一个中转站
.got全局偏移表,本质上就是一张地址表,可以理解成.got里保存的时某些外部符号在运行时解析出来的真实地址
.plt负责怎么跳,.got负责跳到哪

为什么需要.plt和.got呢

因为动态连接程序在编译时,并不知道外部函数运行时的真实地址
比如:

1
2
3
4
5
6
#include <stdio.h>

int main() {
    printf("hello\n");
    return 0;
}

这里的printf不在当前的这个程序本体中,而在libc.so中,编译主程序时,编译器并不知道:

  • 运行libc被加载到哪儿去了
  • printf最终地址是多少

所以程序没法直接写死,比如说call 0x7f1234567类似于这样的地址,因为这个地址在运行前根本不知道
所以,需要一种机制:

  • 先允许程序"先调着"
  • 真地址运行时再补
  • 补完以后下次调用得更快

这个就是.plt和.got需要存在的原因

建立一个简单的调用图

一个正常的简化流程是这样的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
main 里调用 printf
     printf@plt
   查 got.plt[printf]
  如果没解析过 -> 交给动态链接器解析
  动态链接器找到 libc.so 中真正的 printf
  把真实地址写回 got.plt[printf]
  再跳转到真实 printf

然后第二次调用的时候:

1
2
3
4
5
6
7
main
printf@plt
got.plt[printf] 已经有真实地址
直接跳到真实 printf

这个就是延迟绑定

10.其他问题

重定位是什么?

重定位的本质就是:把那些在编译时还不知道具体地址的引用,修补成最终可用地址
比如:

1
2
extern int x;
printf("%d\n", x);

编译这段地址时,编译器并不知道:

  • x在哪
  • printf在哪

所以,它会先生成一份"半成品机器码",同时附带重定位记录
重定位分两类:
链接时重定位:发生在多个.o文件被链接成最终二进制时,由链接器完成
运行时重定位:发生在程序运行、动态库装载时,由链接器完成。

符号表是什么?

符号表可以理解成"名字到地址/属性"的映射表
符号项一般包括:

  • 名字
  • 值(地址或偏移)
  • 大小
  • 类型
  • 绑定方式

常见的类型有:

  • STT_FUNC:函数
  • STT_OBJECT:对象
  • STT_SECTION:节

常见的绑定有:

  • STB_LOCAL:局部符号
  • STB_GLOBAL:全局符号
  • STB_WEAK:弱符号

但是,弱符号有什么用呢?若符号常用于:

  • 提供默认实现
  • 允许被其他强符号覆盖
  • 库中提供可选功能入口