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 文件可以抽象成下面的这个结构
其中,最重要的也就是
- 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
例如:
所以,链接器主要看 section,加载器主要看 segment
常见的 Section 详解
.text
存放机器码,也就是函数指令本体。它有以下特点:
- 通常只读
- 通常可执行
这个是在逆向分析中最重要的 section 之一
例如:
这里面的 add() 和 main(),编译后,他们的机器码通常就会放进 .text section,怎么放呢?当然是变成汇编咯,然后放进去
放进去就是这玩意儿
.data
存放已初始化的全局变量和静态变量
例如:
| |
这类变量一般进入 .data
特点:
- 可读可写
- 文件中有真实内容
.bss
存放未初始化或初始化为 0 的全局/静态变量
例如:
特点:
- 在文件中通常不真正存储这些数据
- 只记录"需要多大空间"
- 程序装载是由系统分配并清零
因此, .bss 很大不代表文件体积也很大
.rodata
存放只读常量,比如:
- 字符串常量
- const 常量
- switch 跳转表
例如:
| |
字符串 hello 通常就存放在 .rodata 中,像我们经常逆向的时候会发现,某些字节数据逆向到最后发现在 .rodata 中,它就代表是固定的常量
.symtab 与.strtab
.symtab 是符号表,用于记录函数名,变量名,符号地址,大小,类型,绑定属性.strtab 是字符串表,给符号表提供名字字符串
通常符号表不直接存字符串,而是存字符串偏移,但是这样说可能没啥概念,举个例子吧
比如说代码是这样的
他有哪些符号呢? global_var, add, main,这些符号信息就会放在 .symtab 中,怎么存的呢?
| 符号名偏移 | 值(地址) | 大小 | 类型 | 绑定 |
|---|---|---|---|---|
| 1 | 0x401000 | 4 | OBJECT | GLOBAL |
| 12 | 0x401126 | 20 | FUNC | GLOBAL |
| 16 | 0x40113a | 30 | FUNC | GLOBAL |
他不是存的"global_var",“add”,“main”,这些字符串,而是存的一个偏移地址,那它真正存放的字符串在哪儿呢?
在strtab中,它是这样存放的
| 偏移 | 内容 |
|---|---|
| 0 | "" |
| 1 | “global_var” |
| 12 | “add” |
| 16 | “main” |
这样来对应一下:
symtab[0].name=1=>去.strtab+1读到global_varsymtab[1].name=12=>去strtab+12读到addsymtab[2].name=16=>去strtab+16读到main
.dynsym 与 .dynstr
这是动态链接相关的符号表与字符串表,它和.symtab相比
.symtab更完整,偏向链接和调试.dynsym更精简,只保留动态链接所需符号
如果一个strip过的elf可能没有.symtab,但是它通常会保留.dynsym
.rel.* / .rela.*
这个是重定位表,如果某段代码引用了一个编译时尚未确定地址的符号,就需要重定位信息来告诉链接器或加载器:将来如何修补这个位置
常见场景包括:
- 调用外部函数
- 访问全局变量
- 动态库符号引用
REL和RELA的区别在于:
REL:不显式存放addendRELA:显式存放addend
在x86_64上常见的是RELA,但是这个还是不太懂,找个例子看一下吧
在编译的时候,很多地址还不知道,ELF就会先留一个坑,顺便会记录下来:以后要补哪儿;按照哪个符号补;用什么方式补
例如:
编译器在编译main时,不知道运行时printf的真实地址,所以它不能够直接写死
| |
因为printf在libc.so里,地址要等加载后才知道,于是,这个编译器/链接器就会干两件事情
- 机器码里放一个占位写法
- 在
.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段:
这里就对应着上面的各个类型,首先看type,代表 LOAD,表示一个需要被加载到内存中的段offset,这里是0x0000,代表这个段的数据,从ELF文件里的0x0000偏移开始读,假如把一块ELF文件想象成一整块二进制数据,p_offset就是在说从文件哪个位置开始读这一段内容VirtAddr,虚拟地址,这里是0x400000,代表这段内容加载到进程内存后,要放到虚拟地址0x400000,也就是说文件里的一段字节,会被映射到内存里的这个地址范围,比如说这个段的大小是0x1200,那么内存里大致就是0x400000~0x4011ffFileSiz文件大小,这里是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的过程
一个典型的动态链接程序执行的流程如下:
ELF Header中的e_entry一般指向程序入口,但是这个入口通常不是main,而是_start_start负责:
- 初始化栈环境
- 准备参数
- 调用
__libc_start_main - 最终才进入
main
9.动态链接机制.plt和.got
这个是逆向中比较会常用的一个点了
比如说程序中调用了:
| |
编译器在编译主程序的时候,其实并不知道printf运行时会被映射到哪个地址,所以需要一套机制来解决"外部符号地址未知"的问题
.plt和.got分别是什么?
.plt过程链接表,本质上是一组跳板代码,程序调用外部函数时,不一定直接跳转到真实地址,而是先跳转到.plt里的一个中转站
.got全局偏移表,本质上就是一张地址表,可以理解成.got里保存的时某些外部符号在运行时解析出来的真实地址
.plt负责怎么跳,.got负责跳到哪
为什么需要.plt和.got呢
因为动态连接程序在编译时,并不知道外部函数运行时的真实地址
比如:
这里的printf不在当前的这个程序本体中,而在libc.so中,编译主程序时,编译器并不知道:
- 运行libc被加载到哪儿去了
- printf最终地址是多少
所以程序没法直接写死,比如说call 0x7f1234567类似于这样的地址,因为这个地址在运行前根本不知道
所以,需要一种机制:
- 先允许程序"先调着"
- 真地址运行时再补
- 补完以后下次调用得更快
这个就是.plt和.got需要存在的原因
建立一个简单的调用图
一个正常的简化流程是这样的
然后第二次调用的时候:
这个就是延迟绑定
10.其他问题
重定位是什么?
重定位的本质就是:把那些在编译时还不知道具体地址的引用,修补成最终可用地址
比如:
编译这段地址时,编译器并不知道:
- x在哪
- printf在哪
所以,它会先生成一份"半成品机器码",同时附带重定位记录
重定位分两类:
链接时重定位:发生在多个.o文件被链接成最终二进制时,由链接器完成
运行时重定位:发生在程序运行、动态库装载时,由链接器完成。
符号表是什么?
符号表可以理解成"名字到地址/属性"的映射表
符号项一般包括:
- 名字
- 值(地址或偏移)
- 大小
- 类型
- 绑定方式
常见的类型有:
STT_FUNC:函数STT_OBJECT:对象STT_SECTION:节
常见的绑定有:
STB_LOCAL:局部符号STB_GLOBAL:全局符号STB_WEAK:弱符号
但是,弱符号有什么用呢?若符号常用于:
- 提供默认实现
- 允许被其他强符号覆盖
- 库中提供可选功能入口