把以前学习ELF的笔记整理一下写成博客,该文章会随着时间的更新不断的完善
思考:
- 对于链接来说,当定义一个具有static属性的变量或者函数时,到底意味着什么 ?
- 一个so首次被加载时,加载的过程是怎么样的?
- 对于一个so,它定义了一些全局变量,一个链接了此so的进程代码修改了这些全局变量的值,对链接了此so的其他进程没有影响,是如何实现的?
- A程序在编译时链接了libfoo.so,并调用了它的add函数,那么在链接过程A程序中会有对应的got表指向add函数地址,如果A程序并不是在编译时链接libfoo.so,而是通过loadLibrary,dlopen,dlsym这几个函数来运行时动态链接libfoo.so,那么A程序还有got表么,如果没有,位置无关代码这块的逻辑是怎么处理的?
编译基础:
一个hello.c文件编译成可执行文件经历的过程:
-
预处理阶段: hello.c首先经过预处理器cpp程序转换成通常为.i的文件,转换的过程中cpp根据以字符#开头的命令,修改原始c程序。可以手动调用cpp程序来转换:
cpp hello.c -o hello.i 或者 gcc -E hello.c -o hello.i
-
编译阶段: hello.i经过编译器将hello.i转换成hello.s文本文件,这是一个汇编语言程序,假设gcc的版本号为4.8.5:
/usr/lib/gcc/x86_64-linux-gnu/4.8.5/cc1 hello.i 或者 gcc -S hello.i -o hello.s
hello.c文件中如果使用了extern的函数add和printf,在生成的汇编程序中会生成相应的"call add"和"call printf"指令,但是需要进行链接以后才能确定add和printf函数的指令。 -
汇编阶段: 汇编器as将hello.s翻译成机器语言指令:
as hello.s -o hello.o 或者 gcc -c hello.s -o hello.o
调用as如果不指定-o参数则输出文件名为a.out,hello.o的格式为relocatable object program,在我的机器上打印出来的格式为:hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
- 链接阶段:
由于hello.c调用了printf函数,而这个函数存在于一个名为printf.o的目标文件中,因此这个文件必须以某种方式合并到hello.o程序中,并生成可执行文件,这个阶段由调用ld来完成。
链接主要包括了地址和空间分配,符号决议和重定位等步骤。它可以执行于编译时(静态链接),也可以执行于加载时(动态链接),甚至执行于运行时由应用程序来执行。
命令大致如下:
ld [system object files and args] hello.o
注意,编译器和汇编器生成从地址0开始的代码和数据节,链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。
使用命令gcc -v hello.c的时候,可以看出最终调用的链接程序为/usr/lib/gcc/x86_64-linux-gnu/4.8/collect2,这个collect2可以看作是ld链接器的一个包装,它会调用ld链接器来完成对目标文件的链接,然后再对链接结果进行一些处理,主要是收集所有与程序初始化相关的信息并且构造初始化结构。 可以看出链接过程至少有下面一些库和目标文件被链接进来: crt1.o,crti.o,crtbegin.o,crtn.o,crtend.o
目标文件的三种形式:
可重定位目标文件: 比如各种.o文件。ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
可执行目标文件: 可以直接被拷贝到存储器中执行。ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=d123665a1af8a2d4c17ecda841072aac01e434f9, not stripped
共享目标文件so : 一种特殊的可重定位目标文件,可以在加载或者运行时被动态地加载到存储器被链接。ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, BuildID[sha1]=799a037243ce66d67e34983bef8928c90457dbf4, stripped
目标文件不仅包含机器指令代码和数据,还包括了链接时需要的一些信息,如符号表,调试信息,字符串等,对于裸板程序来说,生成的可执行文件需要只保留指令代码和数据。
目标文件的格式:
由于目标文件参与了链接过程和执行过程,因此它提供了同一个文件内容的不同视图以体现这点,分为链接视图和执行视图,如下:
上图给出的各个部分的顺序并不是绝对的,只有ELF header固定在文件的开头,其他部分出现的位置不同的文件可能会不同。
ELF header位于文件的头部,用来描述整个文件的组织结构。
Section如text节,data节,symtab节等。
Section header table包含了信息用于描述文件里面的section,每一个section在这个表里面都有一个entry(又叫section header),每个entry都给出了诸如section名,section大小等信息。用于链接的目标文件必须要有Section header table,其他的目标文件可能有也可能没有。
Program header table告诉系统如何创建进程的image,用于执行的目标文件必须要有Program header table。
多个Section节在加载阶段会被合并成一个segment,如:
.interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame都属于可执行文件中的可执行文件text段。
init_array .fini_array .jcr .dynamic .got .got.plt .data .bss都属于可执行文件中的data段。
Elf相关数据结构位于/usr/include/linux/elf.h文件,数据类型定义为:
typedef __u32 Elf32_Addr;
typedef __u16 Elf32_Half;
typedef __u32 Elf32_Off;
typedef __s32 Elf32_Sword;
typedef __u32 Elf32_Word;
ELF头结构:
32位ELF头结构为:
#define EI_NIDENT 16
typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT]; //前16个字节用来标识ELF文件,又称为magic
Elf32_Half e_type; //指示目标文件的类型,比如EXEC可执行文件
Elf32_Half e_machine; //ELF文件的cpu平台属性
Elf32_Word e_version; //ELF版本号,一般为0x1
Elf32_Addr e_entry; /* Entry point */ //入口地址,操作系统在加载完该程序后从这个地址开始执行进程的指令,可重定位文件一般没有入口地址,则此值为0
Elf32_Off e_phoff; //指示program header table在文件中的偏移,如果没有该项,则此值为0
Elf32_Off e_shoff; //指示section header table在文件中的偏移,如果没有该项,则此值为0
Elf32_Word e_flags; //和文件相关的处理器指定的flag
Elf32_Half e_ehsize; //本ELF头大小
Elf32_Half e_phentsize; //program header table表中一个program header(又叫entry)的字节大小,所有entry具有相同的大小
Elf32_Half e_phnum; //program header table表中program header的数量
Elf32_Half e_shentsize; //section header table表中一个section header(又叫entry)的字节大小,所有entry具有相同的大小
Elf32_Half e_shnum; //section header table表中section header的数量
Elf32_Half e_shstrndx; //.shstrtab这个section的entry在section header table表中的下标
} Elf32_Ehdr;
可以使用readelf -h来查看ELF头的具体信息。 从上面可以看出,通过ELF头可以查找到section header table,从而可以遍历里面的每个entry,从而可以索引到每个具体section的信息。
ELF section header结构:
section header table就是Elf32_Shdr结构的数组。
查询ELF文件section header table的命令为readelf -S,注意:objdump -h给出的信息不全
section header为section的描述信息,它的结构如下:
typedef struct elf32_shdr {
Elf32_Word sh_name; //表示section name,值为.shstrtab这个section中的下标
Elf32_Word sh_type; //section类型,比如SHT_DYNAMIC,SHT_STRTAB,SHT_SYMTAB,SHT_REL等
Elf32_Word sh_flags; //section标志位,SHF_WRITE可写,SHF_ALLOC需要分配空间,SHF_EXECINSTR表示可被执行
Elf32_Addr sh_addr; //如果此section被加载至进程的虚拟地址空间,它表示该section在进程中的虚拟地址
Elf32_Off sh_offset; //此section距离文件开头的偏移字节数
Elf32_Word sh_size; //此section占据的字节数
Elf32_Word sh_link; //section header table的下标link
Elf32_Word sh_info; //section的额外信息
Elf32_Word sh_addralign; //section地址对齐值
Elf32_Word sh_entsize; //有些section自身是某个固定大小数据类型(entry)的数组,比如符号表,此字段的含义是其中每项占据的字节数
} Elf32_Shdr;
特殊的section及它们的说明:
始化变量是为了空间效率,在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。
.comment : 保存版本控制信息
.data: 已初始化的全局c变量,相对的,局部变量在运行时保存在栈中,它既不出现在.data也不出现在.bss
.debug : 调试符号表,其中的内容为程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件,只有以-g选项调用编译器时才会得到这张表
.dynamic : 持有动态链接信息
.dynstr : 它的类型是STRTAB,表明它是个字符串表,它其实是动态符号字符串表,用来辅助.dynsym 的(由于在动态链接下,我们需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号哈希表)
.dynsym : 它的类型是DYNSYM,它是个动态符号表,只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存,很多时候动态链接的模块同时拥有.dynsym和.symtab两个表,.symtab中往往保存了所有的符号,包括.dynsym中的符号
.fini : 保存着进程终止代码,当一个进程正常退出的时候,系统会执行此section中的代码
.got : global offset table
.hash : 保存着符号hash表
.init : 保存着进程初始化代码,当一个程序开始运行的时候系统会执行此section中的代码,在main函数之前运行
.interp : 保存着the path name of a program interpreter
.line : 原始c源程序的行号和.text节中机器指令之间的映射,只有以-g选项调用编译器时才会得到这张表
.rel.xxx和.rela.xxx : xxx section的重定位信息,比如.rel.text就代表.text节中位置的列表,当链接器把这个目标文件和其他文件结合时,需要修改这些位置,一般而言任何调用外部函数或者引用全局变量的指令都需要修改,另一方面,调用本地函数的指令则不需要修改,可执行目标文件中并不需要重定位信息。
.rodata : 只读数据
.shstrtab : 保存着section名称字符串
.strtab : 一个字符串表,为以null结尾的字符串序列
.symtab : 符号表,它存放在程序中定义和引用的函数和全局变量的信息,它不包含局部变量的条目
.text : 已编译程序的机器代码
ELF符号表结构:
对于最上面的问题1:当定义一个具有static属性的变量或者函数时,到底实际意味着什么 ?
回答此问题涉及到符号表知识,符号表section名为.symtab,它的内容为Elf32_Sym结构的数组,结构定义如下:
typedef struct elf32_sym{
Elf32_Word st_name; //表示符号名,值为字符串表中的下标,通过下标可以取得符号名字符串
Elf32_Addr st_value; //符号相对应的值,这个值跟符号有关,可能是一个绝对值,也可能是一个地址值等,不同的符号,它所对应的值含义不同,如果符号是一个函数或者变量的定义,那么符号的值就是这个函数或者变量的地址
Elf32_Word st_size; //符号大小,对于包含数据的符号这个值为该数据类型的大小。
//低4位为符号类型:STB_LOCAL(局部符号,对目标文件外部不可见)
//STB_GLOBAL(全局符号外部可见),STB_WEAK(弱符号)
//高28位为符号绑定信息,STT_NOTYPE(未知符号类型),STT_OBJECT(数据对象如变量数组等),STT_FUNC(函数或者其他可执行代码)
//STT_SECTION(表示符号为一个section,它类型必须为STB_LOCAL),STT_FILE(文件名,一般代表目标文件名称,也必须为STB_LOCAL)
unsigned char st_info;
unsigned char st_other;//目前没有用到
Elf32_Half st_shndx; //符号所在的section
} Elf32_Sym;
使用readelf -s可以查看符号表,如果在一个源文件中定义了一个全局函数foo,那么使用readelf -s可以看到foo符号表中的信息为:
Num: Value Size Type Bind Vis Ndx Name
9: 0000000000000000 16 FUNC GLOBAL DEFAULT 1 foo
而将foo修改为static以后,foo符号表中的信息就变为
6: 0000000000000000 16 FUNC LOCAL DEFAULT 1 foo
可以看到foo符号的类型由STB_GLOBAL变成了STB_LOCAL,因此在链接过程中foo符号对于其他模块就是隐藏的,将函数修改为static并不影响函数的指令,即并不影响text节的内容。
静态链接:
将几个输入目标文件合并成一个可执行文件。
假设一个hello.c文件使用到了在其他模块中定义的add函数,单独编译hello.c文件得到的hello.o生成的调用add函数的指令为callq 0,而经过静态链接以后的可执行文件此处的指令被修正为callq 400541,意味着add函数的地址被分配在0x400541处,所有使用到add函数的代码指令都被修正至此位置。
静态链接分为两部:空间与地址分配,以及符号解析与重定位。
在ELF文件中,重定位表结构专门用来保存与重定位相关的信息,可以使用objdump -r来查看目标文件的重定位信息,假设hello.c文件如下:
#include <stdio.h>
#include <elf.h>
extern int add(int l, int r);
int main(){
printf("Hello World!\n");
add(3,4);
return 0;
}
objdump -r hello.o的结果如下:
hello.o: 文件格式 elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000005 R_X86_64_32 .rodata
000000000000000a R_X86_64_PC32 puts-0x0000000000000004
0000000000000019 R_X86_64_PC32 add-0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
OFFSET为需要重定位处距离所属section的偏移,上面结果显示.text节中0x19偏移处调用的是外部的add函数,需要被重定位。来验证一下个这值是否正确,首先利用readelf -S hello.o得知.text的偏移为0x40,那么callq add指令的操作数就位于0x40 + 0x19 = 0x59处,使用xxd hello.o查看0x59 - 1处的字节,正是"e8 00 00 00 00",也就是"callq 0",链接的时候需要重定位为最终的add函数地址。
重定位表的结构为:
typedef struct elf32_rel {
Elf32_Addr r_offset; //对于可重定位文件来说,这个值就是需要重定位的位置距离所属section的偏移
//对于可执行文件或者共享对象文件来说,这个值为需要重定位处的第一个字节的虚拟地址
Elf32_Word r_info; //需要重定位处的类型和符号,低8位表示类型,高24位表示符号在符号表中的下标
//类型为R_X86_64_32或者R_X86_64_PC32,前者表示绝对寻址修正,后者表示相对寻址修正
} Elf32_Rel;
.rel.text节里面的内容就是Elf32_Rel数组。
上面还提到了地址修正方式,分为绝对寻址修正和相对寻址修正,对于绝对寻址修正,修正完以后的地址为符号的实际地址,对于相对寻址修正,修正完的地址值加上下一条指令的地址才为符号的实际地址。如objdump -d a.out查看修正以后的callq add指令地址:
400535: e8 07 00 00 00 callq 400541 <add>
40053a: b8 00 00 00 00 mov $0x0,%eax
可以看出callq add的指令代码为e8 07 00 00 00, 0x07 + 下一条指令的地址 = 0x07 + 0x40053a = 0x400541 = add函数的实际地址。
静态链接的步骤分为如下几步:
符号解析 : 将代码中每个符号引用和确定的一个符号定义(即它的一个输入目标模块中的一个符号表条目)联系起来。
重定位节和符号定义 : 经过第一步的符号解析,链接器就知道了它的输入目标模块中的代码节和数据节的确切大小,就可以进行重定位了,链接器将所有相同的节合并成同一类型的聚合节,例如来自输入模块的.data节将被合并成一个节,这个节成为输出的可执行目标文件的.data节,然后链接器将运行时存储地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每个指令和全局变量都有唯一的运行时存储器地址了。
重定位节中的符号引用 : 在这一步中,链接器修改代码节和数据节中对每个符号的引用,使用它们指向正确的运行时地址。
可执行文件的装载::
调用装载器是通过调用execve函数,从而调用到系统调用sys_execve(),相应的加载内核代码就会被执行。
加载器将可执行文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一条指令或入口点来运行该程序。
进程运行时的存储器映射图可以看<深入理解计算机系统第二版>的7.9节。
可执行文件需要从执行视图来看,如最上面的图,它拥有program header table,为program header的数组,program header的作用是用来定位文件中的segment,以及包含其他必要的信息用来创建进程的内存映射。 在链接的过程中,链接器会尽量把相同权限属性的section分配在同一空间,比如可读可执行的section都放在一起,这样的多个section称为segment。
program header为描述segment的结构,它的代码为:
typedef struct elf32_phdr{
Elf32_Word p_type; //segment的类型,主要的类型有PT_LOAD(可加载的segment),PT_DYNAMIC(包含动态加载信息),PT_INTERP(包含动态链接器路径)
Elf32_Off p_offset; //segment在文件中的偏移
Elf32_Addr p_vaddr; //segment在虚拟空间中的地址
Elf32_Addr p_paddr; //segment的物理地址,它一般和p_vaddr相同
Elf32_Word p_filesz;//segment在文件中占据的字节大小,值有可能为0
Elf32_Word p_memsz; //segment在进程虚拟空间中占据的字节大小,值有可能为0
Elf32_Word p_flags; //segment权限属性,如可读R,可写W,可执行X,私有P
Elf32_Word p_align; //segment对齐属性
} Elf32_Phdr;
查看program header table的命令是readelf -l,program header table只存在于可执行文件和共享文件中。
可以看到加载器根本就不关心segment的名称,它只关心segment的类型。
cat /proc/pid/maps可以用来查询进程的虚拟地址空间分布。
比如一个测试程序的readelf -l a.out输出为:
程序头:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
INTERP 0x000154 0x08048154 0x08048154 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x006f0 0x006f0 R E 0x1000
LOAD 0x000f04 0x08049f04 0x08049f04 0x00120 0x00124 RW 0x1000
DYNAMIC 0x000f14 0x08049f14 0x08049f14 0x000e8 0x000e8 RW 0x4
NOTE 0x000168 0x08048168 0x08048168 0x00044 0x00044 R 0x4
GNU_EH_FRAME 0x00058c 0x0804858c 0x0804858c 0x00044 0x00044 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
GNU_RELRO 0x000f04 0x08049f04 0x08049f04 0x000fc 0x000fc R 0x1
主要关注LOAD段
它的cat /proc/pid/maps输出为:
08048000-08049000 r-xp 00000000 08:02 76164164 ~/Sotest/a.out
08049000-0804a000 r--p 00000000 08:02 76164164 ~/Sotest/a.out
0804a000-0804b000 rw-p 00001000 08:02 76164164 ~/Sotest/a.out
09d70000-09d91000 rw-p 00000000 00:00 0 [heap]
f7dcd000-f7dce000 rw-p 00000000 00:00 0
f7dce000-f7f7e000 r-xp 00000000 103:02 29693 /lib/i386-linux-gnu/libc-2.23.so
f7f7e000-f7f80000 r--p 001af000 103:02 29693 /lib/i386-linux-gnu/libc-2.23.so
f7f80000-f7f81000 rw-p 001b1000 103:02 29693 /lib/i386-linux-gnu/libc-2.23.so
f7f81000-f7f84000 rw-p 00000000 00:00 0
f7fac000-f7fad000 rw-p 00000000 00:00 0
f7fad000-f7fb0000 r--p 00000000 00:00 0 [vvar]
f7fb0000-f7fb2000 r-xp 00000000 00:00 0 [vdso]
f7fb2000-f7fd5000 r-xp 00000000 103:02 29689 /lib/i386-linux-gnu/ld-2.23.so
f7fd5000-f7fd6000 r--p 00022000 103:02 29689 /lib/i386-linux-gnu/ld-2.23.so
f7fd6000-f7fd7000 rw-p 00023000 103:02 29689 /lib/i386-linux-gnu/ld-2.23.so
ffed0000-ffef2000 rw-p 00000000 00:00 0 [stack]
上面的[heap],[vdso],[stack]没有映射到文件中,这些区域被称为VMA(Anonymous Vitrual Memory Area),其中vdso地址位于内核空间,事实上它是一个内核的模块,进程可以通过访问这个VMA来跟内核进行一些通信。
段的加载需要考虑对齐,假设默认页的大小为4096字节,段的加载起始地址必须是4096整数倍,段本身大小也需要为4096整数倍。
位置无关的代码:
生成so文件命令:
gcc -shared -fPIC -o libxxx.so xxx.c yyy.c
共享库中的代码由于被多个进程共享,所加载的虚拟地址都是不同的,因此共享库中的代码不能假设自己被加载到什么位置,使用-fPIC生成共享库的位置无关的代码来说就很必要了。如果创建共享库的时候不使用位置无关的代码,不同的进程就需要拷贝共享库代码的独立副本,并在装载时确定符号的地址,修改所有引用这些符号的位置处的指令,但这样做就失去了共享库节省内存的优势。
在ubuntu16.04上面测试,如果一个文件里面foo函数调用了自己的add函数,如果不使用-fPIC选项试图编译共享库会报错: 符号 `add' can not be used when making a shared object; recompile with -fPIC
对于数据来说,它不像指令,各个进程可以修改共享库暴露出来的全局数据,因此它们必须在各自进程中都有独立的副本。
在IA32系统中,对同一个目标模块中局部函数的调用是不需要特殊处理的,因为引用函数是通过相对PC的方式引用的,引用的地方(准确的说应该是引用的地方的下一条指令处?)到函数定义的地方之间的偏移量是已知的,这个偏移量就为call指令后面的操作码,因此它本身就是PIC了。然后对外部定义的函数调用(即使该函数就定义在当前源文件中,只要没加static,编译器就无法确定是不是在本模块定义,就认定它也属于此范围)和全局变量,它们需要特殊处理以使对它们的引用为PIC。
实现起来的基本想法是将指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本(本身数据部分在每个进程中就需要拥有单独的副本)。
将so中对各种类型的地址引用方式,分为四种情况:
-
模块内部的函数调用,跳转等
-
模块内部的数据访问,比如模块中定义的全局变量,静态变量
-
模块外部的函数调用,跳转等
-
模块外部的数据访问,比如访问其他模块中定义的全局变量
-
对于模块内部的函数调用,可以是相对地址调用,或者是基于寄存器的相对调用,目的地址为当前指令的下一条指令加上某个偏移值,这种指令不需要重定位
-
对于模块内部的数据访问来说,由于任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,因此只要将当前指令加上固定的偏移量就可以访问模块内部数据了。和函数调用的call指令直接支持相对地址引用不同,程序必须手动获取PC寄存器的值,并且加上固定的偏移量,而IA32中无法使用$eip得到PC寄存器的值(只能采用间接的手段如写个__x86.get_pc_thunk.bx函数),但是x86-64可以使用$rip得到PC寄存器的值
-
对于模块外部的数据访问,其他模块全局变量的地址要等到装载时才能确定,要想使对这些模块外部的数据访问是地址无关的,做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。当指令中需要访问变量b时,程序会先找到GOT,然后根据GOT中变量b所对应的项找到变量的目标地址,每个变量都对应一个4字节的地址,链接器在装载模块时会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确,由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。GOT表相对于当前指令的偏移是已知的,可以在编译时确定。
生成对这种全局变量的引用的PIC依赖于这样一个有趣的事实:无论我们在存储器中的何处加载一个目标模块(包括共享目标模块),数据段总是被分配成紧随在代码段的后面,因此代码段中任何指令和数据中任何变量之间的距离都是一个运行时的常量,与代码段和数据段的绝对存储器位置是无关的。
这种生成PIC的方式可描述如下:
(1).在数据段的开始创建一个叫GOT的表(GOT表是.data section的一部分) ,每一个被此模块引用的全局数据在GOT表中都有一个条目,调用这些全局数据被改成了跳转到GOT表对应的下标处,由于GOT的位置在最终加载时是确定的,因此这一步对调用指令的修改是位置无关的,即不论模块被加载到何处,运行的时候依照偏移总能找到GOT表并跳转到GOT表处。
(2).GOT表对应下标处的内容为跳转至符号真正加载的地址处,当然在编译的时候还没有确定引用符号的地址,加载的时候才确认,从而会修改GOT表,修改为真正加载的符号地址,由于GOT表算是数据段的内容,因此每个进程有自己独立的副本,修改GOT表只会对本进程有影响。 -
对于模块间的函数调用,类似于访问模块外部数据的处理方式,不过采用一个新的节.got.plt。
通过GOT(GOT结构类似于指针数组)间接地引用每个全局变量:
call L1
L1: popl %ebx //ebx寄存器保存着PC值
addl $VAROFF, %ebx //ebx寄存器指向GOT表中var变量的地址
movl (%ebx), %eax //通过GOT表间接的访问var变量
movl (%eax),%eax
通过GOT间接地调用外部函数:
call L1
L1: popl %ebx //ebx寄存器保存着PC值
addl $PROCOFF, %ebx //ebx寄存器指向GOT表中proc函数的地址
call *(%ebx) //通过GOT表间接的调用proc函数
从上面可以看到,访问变量的指令现在变成了五条指令,而调用函数的指令变成了四条指令。
ELF编译系统使用了称为延迟绑定的技术来提升效率,使用延迟绑定的动机是对于一个像libc.so 这样的共享库输出的成百上千上函数中,一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用。
思想就是:只有在真正调用函数的时候才进行,那么就创建一个新的节叫PLT,PLT为过程链接表(Procedure Linkage Table),如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT,GOT是.data节的一部分,PLT是.text节的一部分,它是16字节的数组结构。
plt过程描述:
如A程序想调用libfoo.so中的addvec函数,则应该调用addvec@plt,执行的顺序是:
- call addvec@plt //假设addvec@plt函数的地址位于0x8048420处,0x8048420地址处于.plt节,那么这条指令就是跳转至0x8048420处执行,假设0x8048420处的指令是jmp *0x12345
- jmp *0x12345 //执行此条指令,这条指令的含义是取0x12345地址处的值做为跳转目标执行,0x12345地址属于.got节,该地址处的值正好为addvec@plt指令的下一条指令地址,假设为(0x8048420+0x6),因此这条指令绕了一个大弯最终执行了call addvec@plt的下一条指令
- push $0x0 //这就是call addvec@plt的下一条指令,将函数标识符0x0 push到栈中
- jmp 0x8048410 //再下一条指令: 跳转到0x8048410处执行,该地址处的指令为pushl 0x804a004
- pushl 0x804a004 //0x804a004不是一个普通值,它的地址处拥有链接器的标识信息
- jmp *0x804a008 //0x804a008 地址处的值为动态链接器的入口点,因此这句话的结果就是跳转至动态链接器执行
上面的执行可总结如下:
- push addvec函数的标识到栈中
- push动态链接器的标识到栈中 3.跳转至动态链接器执行 动态链接器会确定addvec的真实地址,填充至0x12345地址处,这一步执行以后,调用addvec函数其实只有两条指令了:
call addvec@plt
jmp *0x12345 //0x12345处于.got节,该地址处已经有函数addvec链接以后的真实地址了
got表的hook就是使用ptrace或者其他手段注入到进程中去,然后把自己的函数地址修改至内存中GOT处,试图拦截函数,改变函数的执行流程。
在实际当中存在两个表,".got"和".got.plt", ".got"用来保存全局变量引用的地址,".got.plt"用来保存函数引用的地址。".got.plt"还有个特殊的地方是它的前三项有特殊意义,意义如下:
- 第一项保存的是".dynamic"节的地址,这个section描述了本模块动态链接相关的信息。
- 第二项保存的是本模块的ID
- 第三项保存的是_dl_runtime_resolve()的地址
位置无关可执行文件 PIE:
编译出位置无关可执行文件:
$CC app.c -o app -fPIE -pie
位置无关可执行文件的功能和ASLR有关,ASLR为Address space layout randomization,即地址空间布局随机化,用来防止返回libc这种缓冲区溢出攻击,可执行文件加载到虚拟地址空间的地址是随机的,加载以后每条指令实际的地址 = (模块基地址+ida pro这种反汇编工具看到的静态地址)。
在android 5.0以上编译出来的可执行文件必须是PIE。
因为有了ASLR,在使用frida进行inline hook的时候要添加上基地址的偏移:
var base_hello_jni = Module.findBaseAddress("libhello-jni.so");
Interceptor.attach(base_hello_jni.add(0x1234), {
onEnter : function(args) {
this.arg0 = args[0];
this.arg1 = args[1];
}, onLeave : function(retval) {
}
});
判断是否开启了ASLR:
cat /proc/sys/kernel/randomize_va_space
返回值代表:
- 0代表没有随机化,加载的运行时地址即为可执行文件中的静态地址
- 1代表保守型随机化,共享库、栈、mmap、VDSO和堆是随机地址
- 2代表全随机化,包括brk管理的内存也是随机化的
动态链接:
对于一个so,它定义了一些全局变量,一个链接了此so的进程代码修改了这些全局变量的值,对链接了此so的其他进程没有影响,是如何实现的?
答:没有影响,对于全局变量来说,每个进程访问的都是自己单独的副本,采用的是写时拷贝机制。
将链接推迟到运行时执行,称为动态链接,而与之相对的静态链接在编译过程的链接阶段执行。
动态链接下的装载和静态链接下的装载有很多相似之处,首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的"Program Header table"中读取每个segment的虚拟地址,文件地址和属性,并将它们映射到进程虚拟地址的相应位置,在静态链接的情况下,操作系统就将控制权转交给可执行文件的入口地址,程序开始执行,但是对于动态链接的情况下,由于可执行文件还依赖于很多共享对象,需要进行动态链接,因此此时操作系统就会启动一个动态链接器(对于android系统为/system/bin/linker程序)。在Linux下动态链接器ld.so实际上也是一个共享对象,动态链接器具有特殊性,它本身不可以依赖于其他任何共享对象。操作系统同样通过映射的方式将它加载到进程的地址空间中,操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址,执行动态链接,当动态链接的工作完成以后动态链接器会将控制权转交给可执行文件的入口地址,程序开始正式执行。
动态链接器的路径由可执行文件中的.interp节所指定,objdump -s a.out可以查看a.out中所有节的内容,可以看到.interp节的内容为:/lib64/ld-linux-x86-64.so.2,这个就是动态链接器的路径。
hello.c:
#include <stdio.h>
extern int add(int l, int r);
int main(){
printf("Hello World! %d\n",add(3,6));
add(3,4);
return 0;
}
add.c:
extern int add(int l,int r);
int add(int l,int r){
return l + r;
}
其中add.c编译成libadd.so文件,并且和hello.c链接在一起
gcc -shared -fPIC -o libadd.so add.c
gcc hello.c libadd.so
动态链接ELF是最重要的结构应该是.dynamic节,可以将它视为动态链接下ELF文件的"文件头",它保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象,动态链接符号表的位置,动态链接重定位表的位置,共享对象初始化代码的地址等,可以使用readelf -d a.out查询.dynamic节的信息,比如显示出上面示例编译出来的a.out依赖于libadd.so和libc.so.6。调用命令ldd可以查看a.out所有依赖的共享库链及共享库路径。
.dynamic节中为Elf32_Dyn结构的数组,Elf32_Dyn结构为:
typedef struct dynamic{
Elf32_Sword d_tag;
union{
Elf32_Sword d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;
d_tag表示该结构的类型,不同的类型,d_un代表的值不同,以下为类型的解释:
DT_SYMTAB 动态链接符号表的地址,d_ptr代表.dynsym的地址
DT_STRTAG 动态链接字符串表的地址,d_ptr代表.dynstr的地址
DT_STRSZ 动态链接字符串表大小,d_val表示大小
DT_SONAME
DT_RPATH 动态链接共享对象搜索路径
DT_INIT 初始化代码地址
DT_FINIT 结束代码地址
DT_NEED 依赖的共享对象文件, d_ptr表示所依赖的共享对象文件名
DT_REL 动态链接重定位表的地址
DT_RELENT 动态重读位表入口数量
动态符号表(.dynsym)用来表示模块之间动态链接的符号导入导出关系,它与.symtab不同,只保存了与动态链接相关的符号,使用readelf --dyn-syms可以查看动态符号表中的信息。对于上面的示例,a.out和libadd.so都有自己的动态符号表,a.out中引用到外部函数add出现在动态符号表中:
Num: Value Size Type Bind Vis Ndx Name
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND add
Ndx一项显示为UND,表示add函数的定义在其他模块中。
还有个动态符号字符串表(.dynstr)用来保存动态链接有关的字符串。
和动态链接有关的重定位表为.rel.dyn和.rel.plt,它们分别相当于静态链接中的.rel.text和.rel.data。 .rel.dyn是对数据引用的修正,修正的位置位于.got以及数据段,而.rel.plt是对函数引用的修正,它所修正的位置位于.got.plt。
对于上面的示例来说使用readelf -r命令查看a.out的动态链接重定位表得到的结果为:
重定位节 '.rela.dyn' 位于偏移量 0x500 含有 1 个条目:
偏移量 信息 类型 符号值 符号名称 + 加数
000000600ff8 000500000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
重定位节 '.rela.plt' 位于偏移量 0x518 含有 3 个条目:
偏移量 信息 类型 符号值 符号名称 + 加数
000000601018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 add + 0
000000601020 000300000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000601028 000400000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
可以看到重定位类型和静态链接的重定位类型不同,为R_X86_64_GLOB_DAT和R_X86_64_JUMP_SLO。
动态链接的步骤:
- 启动动态链接器,动态链接器的路径在.interp节中指定
- 装载所有需要的共享对象:
- 重定位和初始化
完成以上三个步骤以后动态链接器就将进程的控制权转交给程序的入口并且开始执行。
一个so首次被加载时,加载到哪一个进程中去?加载的过程是怎么样的?
当第一个需要共享库中的模块的程序启动时,库的单个副本就会在运行时加载进内存(物理内存),当后面使用同一共享库的其他程序启动时,它们会使用已经被加载进内存的库的副本(不同的虚拟内存映射到同一份物理内存)。
装载.dynamic节中DT_NEEDED的共享库集合:将共享库相应的代码段和数据段映射到进程空间中(内存空间到磁盘文件的映射,不同进程的虚拟地址映射到共享库所在的相同的物理页面),如果这个共享对象还依赖于其他共享库,则依赖的共享库也要加入到集合中,直到所有依赖的共享对象都被装载为止,当一个新的共享对象装载进来以后,它的符号表会被合并到全局符号表中,所以当所有的共享对象都被装载进来的时候,全局符号表里面将包含进程中所有的动态链接所需要的符号。如果有两个共享对象定义了相同的符号,就会有共享对象全局符号介入的问题(Global symbol interpose),linux的动态链接器是这样处理的:
当一个符号需要被加入到全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
共享对象的路径查找规则:
编译的时候指定gcc hello.c libadd.so
时,readelf -d a.out
查看.dynamic节中DT_NEEDED会发现有一项为libadd.so,这个名称不是以/开头,因此动态链接器在以下的路径中按顺序查找:
- 在DT_RPATH中查找,编译的时候使用
gcc -Wl,-rpath=./ hello.c libadd.so
,然后再使用readelf -d a.out
查看.dynamic节中RPATH会发现它的内容为Library rpath: [./],这样直接执行./a.out就可以执行成功(ps: -Wl可以将指定的参数传递给链接器) - 查找环境变量LD_LIBRARY_PATH指定的目录
- )/lib , /usr/lib
重定向和初始化:
动态链接器遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中每个需要重定位的位置进行修正,如果某个共享对象拥有.init节,则动态链接器执行.init节中的代码用以实现共享对象特有的初始化过程,相对应的可能还有.finit节,当进程退出时会执行里面的代码。
共享库及其关联的符号链接会被安装在标准目录中,标准目录包括:
/usr/lib,它是大多数标准库安装的目录
/lib,应该将系统启动时用到的库安装在这个目录中(因为在系统启动时可能还没有挂载/usr/lib)
/usr/local/lib,应该将非标准或实验性的库安装在这个目录中
在/etc/ld.so.conf中列出的目录
显示运行时链接::
应用程序可以在它运行时要求动态链接器加载和链接任意共享库,而无需在编译时链接这些库到应用中去。
调用dlopen()打开动态库,dlsym()查找符号,dlerror()错误处理和dlclose()关闭动态库。
void *dlopen(const char *filename , int flag)
第一个参数filename如果是相对路径,则dlopen会尝试以以下的顺序查找该动态库:
- 查找环境变量LD_LIBRARY_PATH指定的目录
- 查找由/etc/ld.so.cache里面反指定的共享库路径
- /lib , /usr/lib
这种在运行时进行链接并加载的程序称为链接加载器,它和单纯的加载器没有太大的区别,主要和最明显的区别在于链接加载器将输出放到内存中而不是文件中