前言
上一篇文章我们介绍了ELF文件的整体结构,可以说操作系统就是ELF Header,SHT,PHT来获得可执行程序的操作说明书的。但是对于ELF文件内部的具体细节仍然值得我们深入研究一下。
ELF的段
代码段:程序的入口
我们从ELF-Header中可以看到 Entry point address的信息,指向的就是程序的代码段的起始地址,CPU一般会跳转到这里开启程序的执行。
通过objdump工具,我们可以将可执行文件的所有段以16进制的方式打印出来。
erlang
$ objdump -s main.o
...
...
Contents of section .text:
1060 f30f1efa 31ed4989 d15e4889 e24883e4 ....1.I..^H..H..
1070 f0505445 31c031c9 488d3de4 000000ff .PTE1.1.H.=.....
1080 15532f00 00f4662e 0f1f8400 00000000 .S/...f.........
1090 488d3d79 2f000048 8d05722f 00004839 H.=y/..H..r/..H9
10a0 f8741548 8b05362f 00004885 c07409ff .t.H..6/..H..t..
10b0 e00f1f80 00000000 c30f1f80 00000000 ................
10c0 488d3d49 2f000048 8d35422f 00004829 H.=I/..H.5B/..H)
10d0 fe4889f0 48c1ee3f 48c1f803 4801c648 .H..H..?H...H..H
10e0 d1fe7414 488b0505 2f000048 85c07408 ..t.H.../..H..t.
10f0 ffe0660f 1f440000 c30f1f80 00000000 ..f..D..........
1100 f30f1efa 803d052f 00000075 2b554883 .....=./...u+UH.
1110 3de22e00 00004889 e5740c48 8b3de62e =.....H..t.H.=..
1120 0000e819 ffffffe8 64ffffff c605dd2e ........d.......
1130 0000015d c30f1f00 c30f1f80 00000000 ...]............
1140 f30f1efa e977ffff fff30f1e fa554889 .....w.......UH.
1150 e5488d05 ac0e0000 4889c7e8 f0feffff .H......H.......
1160 905dc3f3 0f1efa55 4889e5b8 00000000 .].....UH.......
1170 e8d4ffff ffb80000 00005dc3 ..........].
...
...
这种16进制表示得代码很难读,因此我们一般会使用反汇编的方式把它转换为汇编代码,更易读。
less
objdump -d main.o // 获取二进制文件中可执行段的反汇编代码
objdump -D main.o // 获取所有段的反汇编代码
yaml
Disassembly of section .text:
0000000000001060 <_start>:
1060: f3 0f 1e fa endbr64
1064: 31 ed xor %ebp,%ebp
1066: 49 89 d1 mov %rdx,%r9
1069: 5e pop %rsi
106a: 48 89 e2 mov %rsp,%rdx
106d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
1071: 50 push %rax
1072: 54 push %rsp
1073: 45 31 c0 xor %r8d,%r8d
1076: 31 c9 xor %ecx,%ecx
1078: 48 8d 3d e4 00 00 00 lea 0xe4(%rip),%rdi # 1163 <main>
107f: ff 15 53 2f 00 00 call *0x2f53(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
1085: f4 hlt
1086: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1)
108d: 00 00 00
0000000000001090 <deregister_tm_clones>:
1090: 48 8d 3d 79 2f 00 00 lea 0x2f79(%rip),%rdi # 4010 <__TMC_END__>
1097: 48 8d 05 72 2f 00 00 lea 0x2f72(%rip),%rax # 4010 <__TMC_END__>
109e: 48 39 f8 cmp %rdi,%rax
10a1: 74 15 je 10b8 <deregister_tm_clones+0x28>
10a3: 48 8b 05 36 2f 00 00 mov 0x2f36(%rip),%rax # 3fe0 <_ITM_deregisterTMCloneTable@Base>
10aa: 48 85 c0 test %rax,%rax
10ad: 74 09 je 10b8 <deregister_tm_clones+0x28>
10af: ff e0 jmp *%rax
10b1: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
10b8: c3 ret
10b9: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
00000000000010c0 <register_tm_clones>:
10c0: 48 8d 3d 49 2f 00 00 lea 0x2f49(%rip),%rdi # 4010 <__TMC_END__>
10c7: 48 8d 35 42 2f 00 00 lea 0x2f42(%rip),%rsi # 4010 <__TMC_END__>
10ce: 48 29 fe sub %rdi,%rsi
10d1: 48 89 f0 mov %rsi,%rax
10d4: 48 c1 ee 3f shr $0x3f,%rsi
10d8: 48 c1 f8 03 sar $0x3,%rax
10dc: 48 01 c6 add %rax,%rsi
10df: 48 d1 fe sar %rsi
10e2: 74 14 je 10f8 <register_tm_clones+0x38>
10e4: 48 8b 05 05 2f 00 00 mov 0x2f05(%rip),%rax # 3ff0 <_ITM_registerTMCloneTable@Base>
10eb: 48 85 c0 test %rax,%rax
10ee: 74 08 je 10f8 <register_tm_clones+0x38>
10f0: ff e0 jmp *%rax
10f2: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
10f8: c3 ret
10f9: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
0000000000001100 <__do_global_dtors_aux>:
1100: f3 0f 1e fa endbr64
1104: 80 3d 05 2f 00 00 00 cmpb $0x0,0x2f05(%rip) # 4010 <__TMC_END__>
110b: 75 2b jne 1138 <__do_global_dtors_aux+0x38>
110d: 55 push %rbp
110e: 48 83 3d e2 2e 00 00 cmpq $0x0,0x2ee2(%rip) # 3ff8 <__cxa_finalize@GLIBC_2.2.5>
1115: 00
1116: 48 89 e5 mov %rsp,%rbp
1119: 74 0c je 1127 <__do_global_dtors_aux+0x27>
111b: 48 8b 3d e6 2e 00 00 mov 0x2ee6(%rip),%rdi # 4008 <__dso_handle>
1122: e8 19 ff ff ff call 1040 <__cxa_finalize@plt>
1127: e8 64 ff ff ff call 1090 <deregister_tm_clones>
112c: c6 05 dd 2e 00 00 01 movb $0x1,0x2edd(%rip) # 4010 <__TMC_END__>
1133: 5d pop %rbp
1134: c3 ret
1135: 0f 1f 00 nopl (%rax)
1138: c3 ret
1139: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
0000000000001140 <frame_dummy>:
1140: f3 0f 1e fa endbr64
1144: e9 77 ff ff ff jmp 10c0 <register_tm_clones>
0000000000001149 <sayWords>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 48 8d 05 ac 0e 00 00 lea 0xeac(%rip),%rax # 2004 <_IO_stdin_used+0x4>
1158: 48 89 c7 mov %rax,%rdi
115b: e8 f0 fe ff ff call 1050 <puts@plt>
1160: 90 nop
1161: 5d pop %rbp
1162: c3 ret
0000000000001163 <main>:
1163: f3 0f 1e fa endbr64
1167: 55 push %rbp
1168: 48 89 e5 mov %rsp,%rbp
116b: b8 00 00 00 00 mov $0x0,%eax
1170: e8 d4 ff ff ff call 1149 <sayWords>
1175: b8 00 00 00 00 mov $0x0,%eax
117a: 5d pop %rbp
117b: c3 ret
汇编代码其实我也不是特别的熟悉,虽然大学时学过,但是所学知识已经连本带利全部返还学校了。 但还是简单和大家介绍一下上面输出的内容的含义:
- 最左边的一列表示地址偏移,程序再编译后可能假设基地址为0,所以我们可以把偏移地址作为史记地址来分析。
- 中间一列则是程序指令的16进制表示。
- 最右侧的一列则是把中间这列的指令翻译为汇编代码,
针对最右侧的汇编指令,以mian函数为例:
- 第一条指令 endbr64是intel CPU提供的硬件保护指令,一般放在函数开头
- push属于压栈操作,mov属于赋值指令,rsp,rbp,eax都是CPU 特殊功能的寄存器,用来存储数据,这三条指令属于函数调用前的栈堆栈指针和返回值的准备设置。
- 然后调用函数sayWords,然后返回
我们看到0x1060的入口地址所指向的不是main函数。而是_start函数,这不难理解,所谓入口函数只是对开发者而言的,实际上真正进入开发者的逻辑之前,程序需要一些准备工作,设置好运行环境,之后才能正式调用main函数。
关于计算机是如何执行函数的,因为篇幅所限不在此展开。
数据段
数据段用于存储数据,比如代码里的全局变量和局部静态变量等,都存储在该段。
由于之前的demo中没有定义我们自己的变量,因此连夜改代码紧急定义一个变量来看看。
arduino
// x.c
#include<stdio.h>
int global_value = 17;
int global_value2 = 0xffeebbaa;
void sayWords(){
printf("hello owrld from C \n");
printf("number: %d %d",global_value,global_value2);
}
int main(){
sayWords();
return 0;
}
然后进行编译
gcc -o x.o x.c
然后打印可执行文件中的数据段的16进制表示
yaml
$ objdump -s x.o
Contents of section .data:
4000 00000000 00000000 08400000 00000000 .........@......
4010 11000000 aabbeeff ....
我们发现地址(偏移)为0x4010的位置的8个字节为:0x00000011,0xffeebbaa。0x11转换为10进制就是17。而0xffeebbaa就是我们给第二个变量赋的值。
你可能会好奇为什么是0x00000011,0xffeebbaa,而不是0x11000000,0xaabbeeff,这个涉及到计算机硬件中字节存储方式的区别:大端序列和小端序列
- 大端字节序:高位字节在前,低位字节在后
- 小端字节序:低位字节在前,高位字节在后
简单来说,大端字节序列和人类的书写阅读方式相吻合,小端字节序列则相反。 比如正常人写一个数字100,是从左往右写,并且也是从左往右阅读理解的。这就是大端序列。而小端则会写成001,然后从右往左来读,最终也是读成100。
如果我们翻阅前一篇文章的elf-header的信息,会发现这个文件是小端存储的,所以我们按照小端存储的方式来读取数据,也就是0x00000011,0xffeebbaa。
总之数据段主要存储的是数据。
符号表段
看完数据段大家肯定也不禁有些疑惑,难道数据段真的只存数据啊,多一分都不存的,我们知道全局变量除了它所表示得数据之外,还有变量名和引用,这个在数据表里并未体现。那么关于变量名的相关信息存放在哪里呢?在符号表里。
其实符号表不只存储了变量的引用符号,包括函数(无论内部定义还是外部定义),段,文件信息等都存储在符号表里。
我们可以通过如下命令查看符号表
yaml
$ readelf -s x.o
# dynsym 动态符号表 保存动态链接过程中,保存符号引用的表(外部依赖库的函数,变量)
Symbol table '.dynsym' contains 8 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.34
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTable
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
7: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5
#symtab是 符号表 包含了动态符号表的内容
Symbol table '.symtab' contains 40 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS Scrt1.o
2: 000000000000038c 32 OBJECT LOCAL DEFAULT 4 __abi_tag
3: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c
4: 00000000000010b0 0 FUNC LOCAL DEFAULT 16 deregister_tm_clones
5: 00000000000010e0 0 FUNC LOCAL DEFAULT 16 register_tm_clones
6: 0000000000001120 0 FUNC LOCAL DEFAULT 16 __do_global_dtors_aux
7: 0000000000004018 1 OBJECT LOCAL DEFAULT 26 completed.0
8: 0000000000003db8 0 OBJECT LOCAL DEFAULT 22 __do_global_dtors_aux_fini_array_entry
9: 0000000000001160 0 FUNC LOCAL DEFAULT 16 frame_dummy
10: 0000000000003db0 0 OBJECT LOCAL DEFAULT 21 __frame_dummy_init_array_entry
11: 0000000000000000 0 FILE LOCAL DEFAULT ABS x.c
12: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c
13: 0000000000002130 0 OBJECT LOCAL DEFAULT 20 __FRAME_END__
14: 0000000000000000 0 FILE LOCAL DEFAULT ABS
15: 0000000000003dc0 0 OBJECT LOCAL DEFAULT 23 _DYNAMIC
16: 0000000000002028 0 NOTYPE LOCAL DEFAULT 19 __GNU_EH_FRAME_HDR
17: 0000000000003fb0 0 OBJECT LOCAL DEFAULT 24 _GLOBAL_OFFSET_TABLE_
18: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.34
19: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTable
20: 0000000000004000 0 NOTYPE WEAK DEFAULT 25 data_start
21: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5
22: 0000000000004018 0 NOTYPE GLOBAL DEFAULT 25 _edata
23: 00000000000011c0 0 FUNC GLOBAL HIDDEN 17 _fini
24: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5
25: 0000000000004014 4 OBJECT GLOBAL DEFAULT 25 global_value2 #全局变量
...
...
30: 0000000000004010 4 OBJECT GLOBAL DEFAULT 25 global_value #全局变量
31: 0000000000004020 0 NOTYPE GLOBAL DEFAULT 26 _end
32: 0000000000001169 60 FUNC GLOBAL DEFAULT 16 sayWords
33: 0000000000001080 38 FUNC GLOBAL DEFAULT 16 _start
34: 0000000000004018 0 NOTYPE GLOBAL DEFAULT 26 __bss_start
35: 00000000000011a5 25 FUNC GLOBAL DEFAULT 16 main
36: 0000000000004018 0 OBJECT GLOBAL HIDDEN 25 __TMC_END__
37: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
38: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5
39: 0000000000001000 0 FUNC GLOBAL HIDDEN 12 _init
针对上述的符号表,linux也定义了一个数据结构专门用来表示他们
csharp
typedef struct {
Elf64_Word st_name; # 4byte 指向符号字符串表的偏移
unsigned char st_info; # 符号类型与板定的信息、
unsigned char st_other;
Elf64_Half st_shndx; #符号所在的段,如果不再文件的段内,则表示其他信息
Elf64_Addr st_value; # 8byte 符号对应的值,绝对值或者地址
Elf64_Xword st_size; # 符号大小
} Elf64_Sym;
我们看到自己定义的变量在符号表的第25行和第30行,而他们在表中的Ndx=25,说明在SHT段表中的第25段,我们打印一下x.o的SHT发现,第25段就是数据段。我们也可以看到这两个全局变量的value分别就等于他们在data段中对应数值的地址。
除此之外,我们还发现符号表中有ndx=UND的项,und就是undefined的缩写,表示该符号还未定义。一般是在我们引用外部共享库的符号或者函数时会出现,因为这些符号都定义在外部,在本文件中确实没有定义。比如printf@GLIBC_2.2.5,puts@GLIBC_2.2.5,这些都是定义在外部的函数,只是被我们引用了而已,此刻并不知道这个引用的函数的真实地址,所以他们的地址value=0。需要等到被引用的库加载到内存之后程序才能正确获取到它的地址,然后再修正过来。
而与之相反的,我们自己内部定义的函数sayWords则是有正确的地址和所属的段的数组下标。
对于那些从外部共享库中引入的符号,我们往往称作导入符号。而那些定义在本地的,可以被外部所调用的符号,我们称作导出符号
重定位相关
我们说符号表中的那些ndx=UND的项,往往是源代码引入了外部共享库的字段或者函数造成的,因为在编译期间无法获知从外部共享库导入的符号,所以对应的地址往往也是0.那么什么时候确定外部符号的地址的呢?在程序运行时动态链接共享库的时候对共享库进行重定位。
这个链接工作由链接器来完成。我们在上一篇文章的SHT和PHT中都看到一个.interp,这个段就是用来加载ld-linux-x86-64.so这个加载器的
dynamic段
那么链接器是如何完成外部符号的重定位过程的呢?
首先,链接器需要知道当前程序到底依赖了哪个共享库,才能把对应的共享库链接进来。这些信息在.dynamic段中,此外,dynamic段内还包含动态符号表和动态链接重定位表的地址。链接器从这个段中就能确定依赖的哪个库以及导入了哪些符号。以及完成重定位之后该把地址填到哪里。
总之dynamic表可以算作动态链接的入口。
而dynamic段的数据结构也比较简单
ini
typedef struct {
Elf64_Sxword d_tag; /* entry tag value */
union {
Elf64_Xword d_val;
Elf64_Addr d_ptr;
} d_un;
} Elf64_Dyn;
由一个d_tag表示类型,然后还有一个数值d_val或者 地址d_ptr(union结构体表示内部元素共用一个内存空间,可以理解为二选一)
而d_tag的取值情况如下
arduino
/* This is the info that is needed to parse the dynamic section of the file */
#define DT_NULL 0
#define DT_NEEDED 1
#define DT_PLTRELSZ 2
#define DT_PLTGOT 3
#define DT_HASH 4
#define DT_STRTAB 5 #动态链接符号表(.dynsym)的地址,d_ptr指向dynsym的地址
#define DT_SYMTAB 6 #动态链接符号字符串表(.dynstr) d_ptr指向地址
#define DT_RELA 7 #动态链接重定位表
#define DT_RELASZ 8
#define DT_RELAENT 9
#define DT_STRSZ 10
#define DT_SYMENT 11
#define DT_INIT 12 #初始代码的地址
#define DT_FINI 13 #结束代码的地址
#define DT_SONAME 14
#define DT_RPATH 15
#define DT_SYMBOLIC 16
#define DT_REL 17 #动态链接符号表
#define DT_RELSZ 18
#define DT_RELENT 19
#define DT_PLTREL 20
#define DT_DEBUG 21
#define DT_TEXTREL 22
#define DT_JMPREL 23
#define DT_ENCODING 32
#define OLD_DT_LOOS 0x60000000
#define DT_LOOS 0x6000000d
#define DT_HIOS 0x6ffff000
#define DT_VALRNGLO 0x6ffffd00
#define DT_VALRNGHI 0x6ffffdff
#define DT_ADDRRNGLO 0x6ffffe00
#define DT_ADDRRNGHI 0x6ffffeff
#define DT_VERSYM 0x6ffffff0
#define DT_RELACOUNT 0x6ffffff9
#define DT_RELCOUNT 0x6ffffffa
#define DT_FLAGS_1 0x6ffffffb
#define DT_VERDEF 0x6ffffffc
#define DT_VERDEFNUM 0x6ffffffd
#define DT_VERNEED 0x6ffffffe
#define DT_VERNEEDNUM 0x6fffffff
#define OLD_DT_HIOS 0x6fffffff
#define DT_LOPROC 0x70000000
#define DT_HIPROC 0x7fffffff
通过如下命令可以查看dynamic段的信息
python
$ readelf -d x.o
Dynamic section at offset 0x2dc0 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x1000
0x000000000000000d (FINI) 0x11c0
0x0000000000000019 (INIT_ARRAY) 0x3db0
0x000000000000001b (INIT_ARRAYSZ) 8 (bytes)
0x000000000000001a (FINI_ARRAY) 0x3db8
0x000000000000001c (FINI_ARRAYSZ) 8 (bytes)
0x000000006ffffef5 (GNU_HASH) 0x3b0
0x0000000000000005 (STRTAB) 0x498
0x0000000000000006 (SYMTAB) 0x3d8
0x000000000000000a (STRSZ) 148 (bytes)
0x000000000000000b (SYMENT) 24 (bytes)
0x0000000000000015 (DEBUG) 0x0
0x0000000000000003 (PLTGOT) 0x3fb0
0x0000000000000002 (PLTRELSZ) 48 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x630
0x0000000000000007 (RELA) 0x570
0x0000000000000008 (RELASZ) 192 (bytes)
0x0000000000000009 (RELAENT) 24 (bytes)
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) NOW PIE
0x000000006ffffffe (VERNEED) 0x540
0x000000006fffffff (VERNEEDNUM) 1
0x000000006ffffff0 (VERSYM) 0x52c
0x000000006ffffff9 (RELACOUNT) 3
0x0000000000000000 (NULL) 0x0
通过上面的介绍,我们对打印出来的dynamic段的数据含义就不会太陌生了。有了dynamic段提供的动态链接相关的信息,连接器可以比较方便的完成动态链接。
在此之前,我们先来聊聊共享库是如何提供导出符号的。
共享库如何提供导出符号
我们前面提到过导出符号和导入符号,其实一个共享库既有导出符号又有导入符号,这很正常。导出符号可以理解为是共享库自己定义的变量或者函数,然后准备该外部来调用。当它被编译为送文件时,函数往往被放入代码段。并且有一个偏移地址(so内假设基地址为0),其实这个代码段的地址就是程序最终想要定位到的地址。
而这个地址往往被记录到so内的符号表中.symtab,就比如我们自己的代码里的sayWords函数,就有正常的偏移地址和所属段的数组下标。
共享库就是通过符号表来对外提供导出符号的。而程序定位某个共享库的符号时,也是通过该共享库的符号表来获取符号的地址。
地址无关代码与GOT
我们回到可执行程序端,当我们在代码中引用外部共享库的函数时,这个引用也都会编译打包到代码段中,而代码段是只读的,不允许在运行时进行修改,而引用的外部符号只有在运行时才能够获取真实的地址,而改动又不能放在代码段中,因此只能通过设计一种机制:代码段中的调用外部函数时,把这个外部函数调用转换为跳转到数据段中的一个相对地址中去。这个部分就叫全局偏移表(Global Offset Table),在全局偏移表中记录具体函数的地址,然后跳转到对应的地址中就行了。
由于在ELF文件中,代码段和数据段之间的偏移距离是固定的,所以使用相对位置跳转即可,这样就避免了在代码段中使用绝对地址了。而由于数据段是可读可写的,所以链接器在找到相关函数符号的具体地址之后,把地址填入到GOT表中即可。
重定位表
连接器通过dynamic表可以找到重定位表,.rela重定位表中统计了各个段中需要重定位的信息。
重定位表的结构也很简单无需过多介绍:
arduino
typedef struct elf64_rela {
//r_offset是完成重定位之后所要修正的位置的地址
Elf64_Addr r_offset; /* Location at which to apply the action */
Elf64_Xword r_info; /* index and type of relocation */
Elf64_Sxword r_addend; /* Constant addend used to compute value */
//低32位表示重定位入口的类型,高32位表示符号在符号表中的下标
} Elf64_Rela;
我们可以通过如下命令打印重定位表
sql
$ readelf -r x.o
...
...
Relocation section '.rela.plt' at offset 0x630 contains 2 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
0000000000003fc8 0000000300000007 R_X86_64_JUMP_SLOT 0000000000000000 puts@GLIBC_2.2.5 + 0
0000000000003fd0 0000000400000007 R_X86_64_JUMP_SLOT 0000000000000000 printf@GLIBC_2.2.5 + 0
.rela或.rel开头的都叫重定位表,后面跟着的.plt或者.dyn则表示是针对哪个段进行重定位,比如。rela.plt就是针对plt段进行重定位。
.rela和rel都是重定位表。两者的区别在于。重定位表项的数据结构上,rela表多了一项Addend(上表中的最后一项),此字段是用于计算偏移的常数。
.rela.plt是对plt(Procedure Linkage Table)段的重定位,我们称作过程链接表。 我们可以看到puts函数和printf函数的offset分别是0x3fc8 0x3fd0,这表示链接器重定位之后,要把真实地址修正到0x3fc8和0x3fd0处。然后我们查看这个地址位于哪里:
css
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[24] .got PROGBITS 0000000000003fb0 002fb0 000050 08 WA 0 0 8
我们发现就位于got表中。 Info的值分别是0000000300000007和0000000400000007,根据签名对info的定义,低32位表示重定位入口的类型,高32位表示该符号在符号表中的下标(符号表中第几个)。所以puts函数和printf函数分别在符号表的第3个和第四个。我们再回看动态符号表对照一下看对不对:
sql
$ readelf -s x.o
# dynsym 动态符号表 保存动态链接过程中,保存符号引用的表(外部依赖库的函数,变量)
Symbol table '.dynsym' contains 8 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.34
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTable
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
7: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5
果然,第三个和第四个分别是puts函数和printf函数。
重定位入口的类型是7,也就是R_X86_64_JUMP_SLOT,这个类型的意思就是找到真实地址之后,直接填入到偏移地址为对应的地址处即可处即可。
其实一般而言是会同时存在got表和.plt.got表,一个存放数据的引用地址,一个存放函数的引用地址;假如你在编译时支持了延迟绑定(命令如下 gcc -o x.o -z lazy x.c),那么有关于函数符号的引用都会被单独放在.got.plt中,否则都会放在got表中。
延迟绑定
什么是延迟绑定呢?
我们先来思考一个问题,假如程序在链接共享库的时候,就把一次性所有符号表都进行重定位,然后把地址更新进来,那么这显然会给运行时的带来一些性能问题。
于是一种等我们用到了该函数时再重定位的思想就自然而然出现了。
这就叫延迟绑定。
那么延迟绑定的具体实原理大概是怎样的呢?
每当代码中调用到某个共享库函数时,编译器就会生成关于一段关于这个函数调用的代码(plt表中的一项)。每次调用该函数都会直接跳转到这段生成的代码块的地址中。这段代码的逻辑大概是这样的(以printf为例):
- 当(第一次)进入代码块时,先跳转到plt.got表中记录printf函数的位置,由于这个位置暂时没有printf的真实地址,里面默认填入的是代码块的下一个指令地址,
- 后面指令执行的就是查找printf函数的逻辑(查找函数的地址一般存在plt表的第一项中),查找到之后把地址填写到plt.got表中对应的位置
- 当再次进入这个代码块时,仍然跳转到plt.got中的对应项的位置,此时里面记录的地址已经是真实地址了,于是直接跳转真实地址执行函数。
示意图如下
第一次调用
第二次调用
至此,就实现了按需重定位。
不过移动设备普遍使用的ARM处理器架构似乎并不支持动态绑定,因此往往是再动态链接时定位完所有的导入符号地址。
符号字符串表
前文说到符号表,符号表主要用于记录符号的引用相关的信息,比如符号的引用地址。实际上符号表的字符串表示都在符号字符串表(.strtab)中,其中动态符号表(包含引用的外部函数的符号)的字符串引用的是动态符号字符串表: .dynstr,我们可以简单看看动态符号表如何引用到动态符号字符串表的。
我们利用readelf命令把dynstr段的16进制内容打印出来:
yaml
$ readelf -s x.o
Contents of section .dynstr:
0498 005f5f63 78615f66 696e616c 697a6500 .__cxa_finalize.
04a8 5f5f6c69 62635f73 74617274 5f6d6169 __libc_start_mai
04b8 6e007075 74730070 72696e74 66006c69 n.puts.printf.li
04c8 62632e73 6f2e3600 474c4942 435f322e bc.so.6.GLIBC_2.
04d8 322e3500 474c4942 435f322e 3334005f 2.5.GLIBC_2.34._
04e8 49544d5f 64657265 67697374 6572544d ITM_deregisterTM
04f8 436c6f6e 65546162 6c65005f 5f676d6f CloneTable.__gmo
0508 6e5f7374 6172745f 5f005f49 544d5f72 n_start__._ITM_r
0518 65676973 74657254 4d436c6f 6e655461 egisterTMCloneTa
0528 626c6500 ble.
字符串使用过ASCII来表示的,所以一个字节可以表示一个符号。字符串之间使用'\0'来分隔(ascii码为0x00)。比如从地址498开始的字符,0x00是'\0'空字符,0x5f则是'_',以此类推。
那么动态符号表中的符号是如何关联到动态符号字符串表的呢?我们前面分析符号表的结构Elf64_Sym时,介绍过其中的st_name所指向的就是字符串表中的偏移。
我们来举个例子。
这是前面读到的符号表
sql
# 从readelf中读取出来的动态符号表的内容(这是转换后的数据)
Symbol table '.dynsym' contains 8 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.34
2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTable
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5
4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5
5: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
7: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5
但是因为打印出来的数据其实已经是转换后的数据了,readlef命令直接帮我们把name一栏替换成了对应的符号名以及其所属的共享库和版本号。
但是直接看动态符号表的16进制的表示有很蛋疼,不知道从何开始。
erlang
# 动态符号表的16进制表示
Contents of section .dynsym:
03d8 00000000 00000000 00000000 00000000 ................
03e8 00000000 00000000 10000000 12000000 ................
03f8 00000000 00000000 00000000 00000000 ................
0408 4f000000 20000000 00000000 00000000 O... ...........
0418 00000000 00000000 22000000 12000000 ........".......
0428 00000000 00000000 00000000 00000000 ................
0438 27000000 12000000 00000000 00000000 '...............
0448 00000000 00000000 6b000000 20000000 ........k... ...
0458 00000000 00000000 00000000 00000000 ................
0468 7a000000 20000000 00000000 00000000 z... ...........
0478 00000000 00000000 01000000 22000000 ............"...
0488 00000000 00000000 00000000 00000000 ................
在此我们再引入一个二进制文件查看工具:010 Editor
我们以printf函数为例
然后我们找到sym_name的项
然后我们知道sym_name=0x27(小端字节序),十进制就是39,这表示这一项所引用的字符串从动态符号表字符串中的第39个字符的位置开始。
于是我们回到动态符号字符串表:
yaml
$ readelf -s x.o
Contents of section .dynstr:
0498 005f5f63 78615f66 696e616c 697a6500 .__cxa_finalize.
04a8 5f5f6c69 62635f73 74617274 5f6d6169 __libc_start_mai
04b8 6e007075 74730070 72696e74 66006c69 n.puts.printf.li
04c8 62632e73 6f2e3600 474c4942 435f322e bc.so.6.GLIBC_2.
04d8 322e3500 474c4942 435f322e 3334005f 2.5.GLIBC_2.34._
04e8 49544d5f 64657265 67697374 6572544d ITM_deregisterTM
04f8 436c6f6e 65546162 6c65005f 5f676d6f CloneTable.__gmo
0508 6e5f7374 6172745f 5f005f49 544d5f72 n_start__._ITM_r
0518 65676973 74657254 4d436c6f 6e655461 egisterTMCloneTa
0528 626c6500 ble.
我们从头开始数,发现第39刚好就是一个空字符串的分隔符,后面就是字符串printf。
当然,其实这样很傻,我们可以让特定段的二进制展示字符串
ini
$ readelf --string-dump=7 x.o // 数字是SHT表的下标,7是.dynstr 所在的位置
String dump of section '.dynstr':
[ 1] __cxa_finalize
[ 10] __libc_start_main
[ 22] puts
[ 27] printf
[ 2e] libc.so.6
[ 38] GLIBC_2.2.5
[ 44] GLIBC_2.34
[ 4f] _ITM_deregisterTMCloneTable
[ 6b] __gmon_start__
[ 7a] _ITM_registerTMCloneTable
这样看就很直接了。
后记
其实ELF文件并不只有上面介绍的那些段,我们通过打印SHT表会发现一共大概有40个段,但是很多段并不是程序运行时的重点,甚至也不会被加载进内存,因此我们挑了比较重要段来讲,包括常见的代码段,数据段,符号段,同时围绕动态链接的重定位过程分析了相关联的几个段。可以说从整体到细节都有了一定的了解。
基于对ELF文件的了解,接下来我们会讲讲native hook,以及它的实现和相关的背景知识。