第 4 章:深入理解 ELF 文件格式
本文最后更新于:1 年前
Reference
.oat 文件和 ELF Executable and Linkable Format
文件:
- ELF 是 Unix
包括类 Unix
平台上最通用的二进制文件格式。- .c/.c++ 文件编译后得到的 .o
或 .obj
文件就是 ELF 文件。 - 动态库 .so 文件是 ELF 文件。
- .o 文件和 .so 文件链接后得到的二进制可执行文件也是 ELF 文件。
- .c/.c++ 文件编译后得到的 .o
- .oat 文件是 ART 虚拟机的可执行文件,本质是一种经 Android 定制的 ELF 文件。
ELF 文件格式有两种视图
:
- Section Header Table 用于描述该文件中所有 section 的头的信息,是 Linking View 的必须信息。
- Program Header Table 用于描述该文件中所有 segment 的信息,是 Execution View 的必须信息。
- section 和 segment 之间的映射关系
ELF 文件物理上并不存在 segment,segment 其实是一个或多个 section 按照映射关系组合而成的
:区间落在 [p_offset, p_offset + p_filesz] 范围内的 section 属于同一个 segment。
解析 ELF 文件:
- 【readelf】基于 Linking View 的方式:根据 Section Header Table 解析 section,按基于文件的偏移量来读取不同 section 的内容,再根据 section 对应的数据结构来解析。
- 【oatdump】基于 Execution View 的方式:先打开文件,逐个将 Program Header Table 中的 segment 映射到对应的虚拟内存地址,再遍历 segment 的内容
因为 segment 可由多个 section 组成,所以还会用到 Section Header Table
。
ELF Header 32 位和 64 位平台有区别
:
Elf64_Ehdr
64 位平台
:类型 名称 描述 unsigned char[16] e_ident 【0-3】魔数:0x7F、 E
、L
、F
【4】EI_CLASS:1 表示 32 位 ELF 文件;2 表示 64 位 ELF 文件
【5】EI_DATA:1 表示字节序是小端;2 表示字节序是大端
【6】EI_VERSION:ELF 文件版本
【7-15】保留Elf64_Half e_type ELF 文件的类型。0 NONE
表示没有类型;1REL
表示 relocatable如 .obj、.o
;2EXEC
表示 executable;3DYN
表示 shared比如 .so
Elf64_Half e_machine ELF 文件对应哪种 CPU 结构。40 表示 ARM 32 位平台;62 表示 AMD x86 64 位平台;183 表示 ARM 64 位平台 Elf64_Word e_version 取值和 e_ident[6] 相同 Elf64_Addr e_entry 虚拟内存地址,可执行程序的入口 Elf64_Off e_phoff Program Header Table 在该 ELF 文件的起始位置 从文件开头开始算起的偏移量
Elf64_Off e_shoff Section Header Table 在该 ELF 文件的起始位置 从文件开头开始算起的偏移量
Elf64_Word e_flags 和处理器相关的标识 Elf64_Half e_ehsize 表示 ELF Header 的长度 64 位 ELF Header 长度是 64
Elf64_Half e_phentsize Program Header Table 中每个元素的长度 Elf64_Half e_phnum Program Header Table 中包含多少个元素 Elf64_Half e_shentsize Section Header Table 中每个元素的长度 Elf64_Half e_shnum Section Header Table 中包含多少个元素 Elf64_Half e_shstrndx Section Header Table 中每个 section 都有名字,这个索引大概相当于这个 table 的 toString() 的地址 Elf32_Ehdr
32 位平台,只有某些属性的长度不同
:类型 名称 unsigned char[16] e_ident Elf32_Half e_type Elf32_Half e_machine Elf32_Word e_version Elf32_Addr e_entry Elf32_Off e_phoff Elf32_Off e_shoff Elf32_Word e_flags Elf32_Half e_ehsize Elf32_Half e_phentsize Elf32_Half e_phnum Elf32_Half e_shentsize Elf32_Half e_shnum Elf32_Half e_shstrndx 数据类型:
- Elf64_Half、Elf32_Half:作用为 unsigned medium integer,表示无符号的中等大小的整数,2 字节
等同于 short
。 - Elf64_Word、Elf32_Word:作用为 unsigned integer,表示无符号的整数,4 字节
等同于 int
。 - Elf64_Addr:作用为 unsigned program address,表示无符号的程序内的地址,8 字节
等同于 64 位平台的指针类型
。 - Elf32_Addr:作用为 unsigned program address,表示无符号的程序内的地址,4 字节
等同于 32 位平台的指针类型
。 - Elf64_Off:作用为 unsigned file offset,表示无符号的文件偏移量,8 字节
等同于 64 位平台的 long
。 - Elf32_Off:作用为 unsigned file offset,表示无符号的文件偏移量,4 字节
等同于 32 位平台的 int
。
- Elf64_Half、Elf32_Half:作用为 unsigned medium integer,表示无符号的中等大小的整数,2 字节
ELF 文件的作用:
- 参与了源代码的编译和链接
比如一个 C 源文件编译成 relocatable 的 .o 文件,然后编译器处理这些 .o 文件(函数、变量、符号表等都可以调整到合适的位置),最终组成 .so 动态库文件或可执行文件
。 - 可执行程序的运行依赖 ELF 文件里的信息
比如 ELF Header 的 e_entry 指明了可执行程序的入口地址
。
ABI:
- ABI
Application Binary Interface
是用于二进制模块之间以及模块和操作系统之间交互的标准。 - ABI 是 ELF 相关规范在不同硬件平台上的进一步拓展和补充
比如,ABI 会规定应用程序调用动态库的函数时,栈帧该如何创建,参数该如何传递
。
ELF 和 ABI 相关的文档:
- 通用 ELF 和 ABI 文档。
- 特定处理器相关 ELF 和 ABI 文档。
- 特定平台/语言相关的 ABI 文档。
Linking View 视图下的 ELF:
示例程序 1:
main.c:
1
2
3
4
5
6
7#include <unistd.h>
#include <stdio.h>
int main(int argc, char* argv[]) {
printf("this is elf test");
return 0;
}Makefile 文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# 使用方法
all:
$(error "usage: make [obj|so|exe|clean]")
# 编译 relocatable 的 obj 文件
obj:main.c
gcc -c main.c -o main.o
# 编译动态库 so 文件
so:main.c
gcc -fPIC -shared main.c -o main.so
# 编译 exe 可执行程序
exe:main.c
gcc main.c -o main.out
clean:
rm -rf *.o *.so *.out编译生成 main.o
Obj 文件参与链接,可以用于研究 Linking View
:1
make obj
执行 readelf --section main.o
,获取到 Section Header Table 的信息:
分析信息:
- 【0】:占位用的,值全为 0。
- 【10】.shstrtab
Section Header String Table
:专门存储 String 名字的 section。- SHT_STRTAB 类型的 section 用于存储字符串,ELF 文件可能有多个 String Table section
比如 .shstrtab、.strtab、.dynstr
,具体使用哪个取决于 Section Header Table 的 sh_link 的值比如上图【11】的 Link 是【12】
。 - 本质是一块存储区域,通过
[索引, '\0')
的方式截取字符串: - 执行
readelf -p [section 名|索引] main.o
,可将指定名字或索引的 section 的内容转换成字符信息打印出来:
- SHT_STRTAB 类型的 section 用于存储字符串,ELF 文件可能有多个 String Table section
- 【1】.text:用于存储程序的机器指令。
- 【4】.bss
Block Storage Segment
:在 ELF 加载到内存的时候会分配一块由 sh_size 指定大小的内存并初始化数据为 0,在 ELF 文件里不占据任何文件的空间。 - 【3】.data:和 .bss 类似,但是不会初始化数据为 0。
- 【5】.rodata
read-only data
:包含只读数据的信息。 - 【11】.symtab
Symbol Table
:存储的是符号表,主要用于编译链接,也可以参与动态库的加载。
.dynsym[1]Dynamic Linking Symbol Table
:存储的仅是动态链接需要的符号信息。
Section Header Table 中元素的数据结构:
Elf64_Shdr
64 位平台
:类型 名称 描述 Elf64_Word sh_name 指向 .shstrtab Section Header String Table,专门存储 String 名字的 section
的某个位置存储了本 section 的名字
Elf64_Word sh_type section 的类型 Elf64_Xword sh_flags section 的属性 Elf64_Addr sh_addr 如果该 section 被加载到内存,该值指明应该加载到内存的什么位置 进程的虚拟地址空间
Elf64_Off sh_offset section 真正的内容在文件中的位置 从文件开头开始算起的偏移量
Elf64_Xword sh_size section 的大小 Elf64_Word sh_link Section Header Table 的索引链接 其解释取决于 section 的类型
Elf64_Word sh_info 额外信息 其解释取决于 section 的类型
Elf64_Xword sh_addralign 地址对齐 2 的整数幂
Elf64_Xword sh_entsize 一些 section 的表的条目大小是固定的 比如符号表
,该值给定了每个条目的大小Elf32_Shdr
32 位平台
:类型 名称 Elf32_Word sh_name Elf32_Word sh_type Elf32_Word sh_flags Elf32_Addr sh_addr Elf32_Off sh_offset Elf32_Word sh_size Elf32_Word sh_link Elf32_Word sh_info Elf32_Word sh_addralign Elf32_Word sh_entsize
执行 readelf -s main.o
,获取到符号表的信息:
符号表主要用于编译链接,也可以参与动态库的加载。
符号表中元素的数据结构:
Elf64_Sym
64 位平台
:类型 名称 描述 Elf64_Word st_name 符号的名称,指向 .strtab 的某个索引位置 unsigned char st_info 符号的类型和绑定属性。
类型:1OBJECT
表示该符号和某个数据有关;2FUNC
表示该符号和某个函数有关;3SECTION
表示该符号和 section 有关;4FILE
表示存储源文件名。
绑定属性:0LOCAL
表示只在该 ELF 文件里可见的符号;1GLOBAL
表示可在多个 ELF 文件里可见的符号;2WEAK
和 1GLOBAL
一样,但是在链接或加载的时候优先级更低。unsigned char st_other 符号的可见性。DEFAULT 表示该符号的可见性由绑定属性决定 Elf64_Half st_shndx 符号表中的每个元素都和其他 section 有关系,这个值就是相关的 section 的索引。0 UND
表示该符号的定义在别的 ELF 文件中;0xFFF1ABS
表示该符号的值是固定不变的Elf64_Addr st_value 符号的值。对于 relocatable 类型的 ELF 文件,表示该符号位于相关 section 的具体位置;对于 shared 和 executable 类型的 ELF 文件,表示该符号的虚拟内存地址 Elf64_Xword st_size 和这个符号相关联的数据的长度 Elf32_Sym
32 位平台
:类型 名称 Elf32_Word st_name Elf32_Addr st_value Elf32_Word st_size unsigned char st_info unsigned char st_other Elf32_Half st_shndx
示例程序 2:
test.h:
1
void test();
test.c:
1
2
3
4
5#include "test.h"
void test() {
return;
}main.c:
1
2
3
4
5
6
7#include <unistd.h>
#include "test.h"
int main(int argc, char* argv[]) {
test();
return 0;
}Makefile 文件:
1
2
3
4
5
6
7
8
9
10
11# 使用方法
all:
$(error "usage: make [obj|clean]")
# 编译 relocatable 的 obj 文件
obj:test.c main.c
gcc -c test.c -o test.o
gcc -c main.c -o main.o
clean:
rm -rf *.o编译生成 main.o
Obj 文件参与链接,可以用于研究 Linking View
:1
make obj
执行 readelf -r main.o
,获取到重定位表的信息:
- 重定位最主要的作用就是将符号的使用之处和它的定义之处关联起来。
- 重定位的方法:
- 编译链接过程中,最终生成可执行文件或动态库文件时,编译链接器将根据 ELF 文件中的重定位表计算最终的符号的位置。
- 加载动态库时,加载器也会根据重定位信息修改对应的符号使用之处,使得动态库能正常工作。
- 分析信息:
- 【确定要修改的偏移量】.rela.text[0].Offset = 000000000015
先放着,后面用
- 【确定符号在符号表中的索引】.rela.text[0].Info = 000900000002:右移 32 位为 9 -> 符号在符号表中的索引为 9
- 【确定符号表】Section Header 中 .rela.text.Link = 10
符号表在 Section Header 中的索引
-> 符号表是 .symtab - 【确定要修改的 section
根据名字也能猜出来
】Section Header 中 .rela.text.Info = 1要修改的 section 在 Section Header 中的索引
-> 要修改的 section 是 .text - 执行
objdump -r -d main.o
,反编译 .text:- 解析第 0x14 个字节:
- e8:intel 汇编指令,表示 call
函数调用
。 - 00 00 00 00:偏移量
相对于下一条指令
。这里的值是 0x19 + 偏移量为 0,相当于跳过了 test 函数,所以全 0 的地址肯定不对。
【要修改的偏移量】0x15来自于1. 【确定要修改的偏移量】.rela.text[0].Offset = 000000000015
,对应的信息是 15: R_X86_64_PC32 test-0x4还不是最终的目标函数的地址,需要编译器处理
。- 15:偏移量 0x15。
- R_X86_64_PC32:重定位的类型
和目标机器有关,主要作用是告诉编译器如何计算真正的地址值
。 - test-0x4:重定位对应的符号信息和 r_addend 的值。
- e8:intel 汇编指令,表示 call
- 解析第 0x14 个字节:
- 重定位表中元素的数据结构:
Elf64_Rela
64 位平台、section 名字以 .rela 开头
:类型 名称 描述 Elf64_Addr r_offset 偏移量 Elf64_Xword r_info 该重定位项针对符号表的哪一项 + 重定位的类型 Elf64_Sxword r_addend 用于计算最终的重定位信息的位置 Elf32_Rela
32 位平台、section 名字以 .rela 开头
:类型 名称 Elf32_Addr r_offset Elf32_Word r_info Elf32_Sword r_addend Elf64_Rel
64 位平台、section 名字以 .rel 开头
:类型 名称 Elf64_Addr r_offset Elf64_Xword r_info Elf32_Rel
32 位平台、section 名字以 .rel 开头
:类型 名称 Elf32_Addr r_offset Elf32_Word r_info
Execution View 视图下的 ELF:
示例程序 3:
main.c:
1
2
3
4
5
6
7#include <unistd.h>
#include <stdio.h>
int main(int argc, char* argv[]) {
printf("this is elf test");
return 0;
}Makefile 文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# 使用方法
all:
$(error "usage: make [obj|so|exe|clean]")
# 编译 relocatable 的 obj 文件
obj:main.c
gcc -c main.c -o main.o
# 编译动态库 so 文件
so:main.c
gcc -fPIC -shared main.c -o main.so
# 编译 exe 可执行程序
exe:main.c
gcc main.c -o main.out
clean:
rm -rf *.o *.so *.out编译生成 main.out:
1
make exe
执行 readelf -l main.out
,获取到 Program Header Table 的信息:
- Program Header Table 中元素的数据结构:
Elf64_Phdr
64 位平台
:类型 名称 描述 Elf64_Word p_type segment 的类型。1 LOAD
表示可加载到内存的 segment;2DYNAMIC
表示和动态链接有关;3INTERP
表示该 segment 用于说明此程序使用的链接器可执行程序使用动态库的时候,动态库的加载工作是由程序解释器来完成的,程序解释器就是链接器
在系统中的绝对路径;6PHDR
表示 Program Header Table 本身的信息,当文件加载到内存的时候使用Elf64_Word p_flags segment 的标记符,和 segment 在内存中的访问权限有关 Elf64_Off p_offset 该 segment 位于文件的起始位置 Elf64_Addr p_vaddr 该 segment 加载到进程虚拟内存空间时指定的内存地址 Elf64_Addr p_paddr 该 segment 对应的物理地址 对于可执行文件和动态库文件而言,这个值没有意义,因为系统用的是虚拟地址
Elf64_Xword p_filesz 该 segment 在文件中占据的大小 Elf64_Xword p_memsz 该 segment 在内存中占据的大小 Elf64_Xword p_align segment 加载到内存后其首地址需要按 p_align 的要求进行对齐 Elf32_Phdr
32 位平台
:类型 名称 Elf32_Word p_type Elf32_Off p_offset Elf32_Addr p_vaddr Elf32_Addr p_paddr Elf32_Word p_filesz Elf32_Word p_memsz Elf32_Word p_flags Elf32_Word p_align
实例分析:调用动态库中的函数
本质:源码编译成 ELF 文件后,代码就被翻译成了机器指令。而函数调用对应的指令就是指示 CPU 先跳到该函数所在的内存地址,然后执行后面的指令。
【存在的问题】编译时无法确定函数的入口在内存中的地址。原因是:
- 如果一个程序使用多个动态库,编译器很难为所有函数都确定一个绝对地址。
- 出于安全考虑,操作系统加载动态库到内存的时候会基于一个随机数来计算最终的加载位置。
【解决问题】GOT
Global Offset Table
。- GOT 对应的 section 是 .got
存储的是该 ELF 文件用到的符号(函数或变量)地址
。 - GOT[1] 和 GOT[2] 这两项内存存储的是解释器
其实就是链接器,Program Header Table 中类型为 INTERP 的元素,用于加载执行时需要的动态库
的信息和符号解析处理函数的入口地址。其余项中的值将由 Resolver 动态填写。 - 当可执行程序被操作系统加载和准备执行时,如果操作系统发现该程序有 INTERP 类型的 segment,就会先跳转到链接器的 entry point 执行。链接器会设置好 GOT 等对应项,然后将系统的控制权交还给可执行程序。
- 【引入问题】符号地址什么时候计算?
【解决问题】有两种方法:1、链接器将控制权交给可执行程序之前链接器加载了依赖的动态库后就知道了动态库加载到内存的虚拟地址,就可以计算出符号地址
。缺点是加载时间变长;2、用的时候再计算。优点是提升了程序启动速度。- 【引入问题】程序不知道链接器会使用哪种方法计算符号地址。
【解决问题】编译器生成的二进制文件必须同时支持这两种方法。PLTProcedure Linkage Table
。- PLT 中元素存储的是一段代码
辅助触发符号地址的计算以及跳转到正确的符号地址上
。 - 图中 PLT[0] 存储的是跳转到 GOT 表 Resolver 的指令。
- 图中 PLT[1] 中 *name1_in_GOT 表示 GOT[name1] 的值:
- 如果 name1 的地址还没有计算,GOT[name1] 存储的就是后一条指令
push $offset,把 Resolver 计算这个符号地址所需的参数压栈,下一条指令是跳转到 PLT[0]
; - 如果 name1 的地址已经计算过,jmp *name1_in_GOT 就直接跳转到目标地址。
- 如果 name1 的地址还没有计算,GOT[name1] 存储的就是后一条指令
- PLT 中元素存储的是一段代码
- 【引入问题】程序不知道链接器会使用哪种方法计算符号地址。
- 【引入问题】符号地址什么时候计算?
示例程序 4:
test.h:
1
2
3void test();
void test2();test.c:
1
2
3
4
5
6
7
8
9
10
11
12
13#include <unistd.h>
#include <stdio.h>
#include "test.h"
void test2() {
printf("I am in test2");
}
void test() {
printf("I am in test");
test2()
return;
}main.c:
1
2
3
4
5
6
7#include <unistd.h>
#include "test.h"
int main(int argc, char* argv[]) {
test();
return 0;
}Makefile 文件:
1
2
3
4
5
6
7
8
9
10
11
12
13# 使用方法
all:
$(error "usage: make [so|exe|clean]")
# 编译动态库 so 文件
so:test.c
gcc -fPIC -m32 -g -shared test.c -o libtest.so
# 编译 exe 可执行程序
exe:main.c
gcc -g -m32 main.c -o main.out -L`pwd` -ltest
clean:
rm -rf *.exe *.so编译生成
libtest.so
:1
make so
编译生成 main.out:
1
make exe
反编译,获取到的信息:
“符号地址的计算依赖于重定位信息,除非有特殊需要,否则无须死抠细节。”
分析信息:
- main.out 的
call 8048430 <test@plt>
- -> .plt 的 0x8048430 之后的是 PLT[1]
3 行代码
:第一行的 GOT[0x804a014] 还未计算 test 函数的地址地址 0x804a014,在【图 2】.rel.plt 可以查到 0x804a014 的符号名是 test;等计算后会在 .got.plt 的 0x804a014 存储函数符号的地址
,所以后一行地址 0x8048436,最开始时 .got.plt 的 0x804a014 存储的就是这个地址
把参数【图 3】0x10 是 .rel.plt 中 test 的偏移量
压栈,最后一行jmp 8048400
- -> .plt 的 0x8048400 是 PLT[0]
2 行代码
- -> GOT[1] 的地址是 0x804a004
GOT[0] 的地址是 0x804a000,GOT[2] 的地址是 0x804a008
,第二行jmp *0x804a008
- -> Resolver 计算 test 的最终地址,并填写到 GOT[0x804a014],以后就可以直接跳转到 test 函数了。
- -> GOT[1] 的地址是 0x804a004
- -> .plt 的 0x8048400 是 PLT[0]
- -> .plt 的 0x8048430 之后的是 PLT[1]
Absolute PLT 和 PIC Position Independent Code
PLT:
- Absolute PLT:PLT 里的参数都是绝对地址
GOT 地址在编译时可以确定
。- main 函数中调用
libtest.so
的 test() 函数。
- main 函数中调用
- PIC PLT:PLT 里的参数都是基于 GOT 的偏移量
GOT 的值在运行过程中计算出来的
。libtest.so
的 test() 函数中调用 test2() 函数。- x86 平台的 GOT 的地址放在 ebx 寄存器里。
TODO:自己编写一个简单的 ELF 文件解析器
参考
- Special Sections(https://docs.oracle.com/cd/E19120-01/open.solaris/819-0690/6n33n7fcb/index.html) 里只有 .dynsym,没有 .dynsymtab,书中这里应该是写错了。 ↩