ELF文件装载链接过程及hook原理
ELF文件格式解析
可执行和可链接格式(Executable and Linkable Format,缩写为ELF),常被称为ELF格式,在计算机科学中,是一种用于执行档、目的档、共享库和核心转储的标准文件格式。
ELF文件主要有四种类型:
- 可重定位文件(Relocatable File) 包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
- 可执行文件(Executable File) 包含适合于执行的一个程序,此文件规定了 exec() 如何创建一个程序的进程映像。
- 共享目标文件(Shared Object File) 包含可在两种上下文中链接的代码和数据。首先链接编辑器可以将它和其它可重定位文件和共享目标文件一起处理,生成另外一个目标文件。其次,动态链接器(Dynamic Linker)可能将它与某个可执行文件以及其它共享目标一起组合,创建进程映像。
以一个简单的目标文件为例:
|
|
|
|
ELF文件结构
链接视图和执行视图
ELF文件在磁盘中和被加载到内存中并不是完全一样的,ELF文件提供了两种视图来反映这两种情况:链接视图和执行视图。顾名思义,链接视图就是在链接时用到的视图,而执行视图则是在执行时用到的视图。
程序头部表(Program Header Table),如果存在的话,告诉系统如何创建进程映像。
节区头部表(Section Header Table)包含了描述文件节区的信息,比如大小,偏移等。
ELF文件头(ELF Header)
定义了ELF魔数、硬件平台等、
入口地址、程序头入口和长度、
段表的位置和长度及段的数量、
段表字符串表(.shstrtab)所在的段在段表中的下标。
可以在”/usr/include/elf.h”中找到它的定义(Elf32_Ehdr)。
ELF各个字段的说明:
段表(Section Header Table)
描述了各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其它属性。
段表的结构是一个以Elf32_Shdr结构体(段描述符)为元素的数组。
每个Elf32_Shdr结构体对应一个段。
使用readelf工具查看ELF文件的段:
段描述符(Elf32_Shdr)的各个成员及含义:
段的类型(sh_type)
对于编译器和链接器,主要决定段的属性的是段的类型(sh_type)和段的标志位(shflags)。段的类型相关常量以SHT开头,列举如下表。
段的标志位(shflag)表示该节在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。相关常量以SHF开头,如下表:
段的链接信息(sh_link、sh_info) 如果节的类型是和链接相关的,比如重定位表、符号表等,那么sh_link和sh_info两个成员包含的意义如下。对于其他段,这两个成员没有意义。
代码段(.text)
使用objdump工具查看代码段的内容,”-d”参数将所有包含指令的段反汇编。
数据段(.data)和只读数据段(.rodata)
.data段保存的是那些已经初始化了的全局静态变量和局部静态变量。前面SimpleSection.c代码里面一共有两个这样的变量,都是int类型的,一共刚好8字节。
在SimpleSection.c里在调用”printf”的时候,用到了一个字符串常量”%d\n”,它是一种只读数据,所以被放到了”.rodata”段。
BSS段(.bss)
.bss段存放的未初始化的全局变量和局部静态变量。.bss段不占磁盘空间。
字符串表(.strtab)
在ELF文件中,会用到很多字符串,比如节名,变量名等。所以ELF将所有的字符串集中放到一个表里,每一个字符串以’\0’分隔,然后使用字符串在表中的偏移来引用字符串。
比如下面这样:
那么偏移与他们对用的字符串如下表:
这样在ELF中引用字符串只需要给出一个数组下标即可。字符串表在ELF也以段的形式保存,常见的段名为”.strtab”或”.shstrtab”。这两个字符串表分别为字符串表(String Table)和段表字符串表(Header String Table),字符串表保存的是普通的字符串,而段表字符串表用来保存段表中用到的字符串,比如段名。
符号表(.symtab)
在链接的过程中需要把多个不同的目标文件合并在一起,不同的目标文件相互之间会引用变量和函数。在链接过程中,我们将函数和变量统称为符号,函数名和变量名就是符号名。
每一个目标文件中都有一个相应的符号表(System Table),这个表里纪录了目标文件所用到的所有符号。每个定义的符号都有一个相应的值,叫做符号值(Symbol Value),对于变量和函数,符号值就是它们的地址。
符号表是一个Elf32_Sym(32位)的数组,每个Elf32_Sym对应一个符号。这个数组的第一个元素,也就是下标为0的元素为无效的”未定义”符号。
他们的定义如下:
符号类型和绑定信息(st_info)
该成员的低4位标识符号的类型(Symbol Type),高28位标识符号绑定信息(Symbol Binding),如下表所示。
符号所在段(st_shndx)
如果符号定义在本目标文件中,那么这个成员表示符号所在段在段表中的下表,但是如果符号不是定义在本目标文件中,或者对于有些特殊符号,sh_shndx的值有些特殊。如下:
符号值(st_value)
每个符号都有一个对应的值。主要分下面几种情况:
- 如果符号不是”COMMON”类型的(即st_shndx不为SHN_COMMON),则st_value表示该符号在段中的偏移,即符号所对应的函数或变量位于由st_shndx指定的段,偏移st_value的位置。比如SimpleSection中的”func1”,”main”和”global_init_var”。
- 在目标文件中,如果符号是”COMMON”类型(即st_shndx为SHN_COMMON),则st_value表示该符号的对齐属性。比如SimleSection中的”global_uninit_var”。
- 在可执行文件中,st_value表示符号的虚拟地址。
下图为使用readelf工具来查看ELF文件的符号:
比如,Num13行指的是符号表中的第13个元素,符号名为main,它是函数类型,定义在第一个段(即.text段)的第001b偏移处,大小为64字节。
重定位表(.rel.text)
SimpleSection.o中有一个叫”.rel.text”的段,它的类型(sh_type)为”SHT_REL”,也就是说它是一个重定位表。链接器在处理目标文件时,需要对目标文件中的某些部位进行重定位,即代码段和数据中中那些绝对地址引用的位置。对于每个需要重定位的代码段或数据段,都会有一个相应的重定位表。比如”.rel.text”就是针对”.text”的重定位表,”.rel.data”就是针对”.data”的重定位表。
静态链接
这节以下面两个文件为例
|
|
当我们有两个目标文件时,如何将他们链接起来形成一个可执行文件?
对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?输出文件中的空间如何更配给输入文件?
下图为现在链接器采用的空间分配策略。
整个链接过程分两步:
- 第一步 空间与地址分配 扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表中。
- 第二步 符号解析与重定位 使用第一步中收集到的信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等
使用ld链接器将”a.o”和”b.o”链接起来:
查看链接前后各个段的属性
VMA表示虚拟地址,LMA表示加载地址,正常情况下这两个值应该一样。
整个链接过程前后,目标文件各段的分配、程序虚拟地址:
在Linux下,ELF可执行未见默认从地址0x08048000开始分配。
符号解析与重定位
编译器在将”a.c”编译成指令时,它如何访问”shared”变量?如何调用”swap”函数?
重定位表(Relocation Tabel)专门用来保存与重定位相关的信息,链接器根据它知道哪些指令时要被调整的,以及如何调整。
对于32位的Intel x86系列处理器来说,重定位表的结构是一个Elf_32Rel结构的数组,每个数组元素对应一个重定位入口。定义如下:
可以使用objdump来查看目标文件的重定位表:
将”a.o”的代码段反汇编可以看到,此时编译器并不知道“shared”的地址,暂时把地址0看做”shared”的地址。
0xE8是一条近址相对位移调用指令,后面4个字节就是被调用函数的相对于调用指令的下一条指令的偏移量。
此处”swap”函数的地址是0x2b-4=0x27,可以看出0xfffffffc也是一个临时地址。
指令修正方式
指令修复的结果
可执行文件的装载与进程
程序执行时所需要的指令和数据必需在内存中才能够正常运行。
页映射将内存和所有磁盘中的数据和指令按照“页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位就是页。
进程的建立需要做下面三件事情:
- 创建一个独立的虚拟地址空间
- 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。
对于第2步,当操作系统捕获到缺页错误时,它应该知道程序当前所需的页在可执行文件中的哪一个位置。
这种映射关系是保存在操作系统内部的一个数据结构VMA。
例如下图中,操作系统创建进程后,会在进程相应的数据结构中设置有一个.text段的VMA:它在虚拟空间中的地址为0x08048000~0x08049000,它对应ELF文件中偏移为0的.text,它的属性为只读,还有一些其他的属性。
页错误
在上面的例子中,程序的入口地址为0x08048000,当CPU开始打算执行这个地址的指令时,发现页面0x08048000~0x08049000(虚拟地址)是个空页面,于是它就认为这是一个页错误。CPU将控制权交给操作系统,操作系统将查询虚拟空间与可执行文件的映射关系表,找到空页面所在的VMA,计算相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还给进程,进程从刚才页错误的位置重新开始执行。
链接视图和执行视图
以下面的程序为例。
下面的elf文件被重新划分成了三个部分,有一些段被归入可读可执行的,他们被统一映射到一个CODE VMA;另一部分段是可读可写的,它们被映射到了DATA VMA;还有一些段在程序执行时没有用,所以不需要映射。
ELF与Linux进程虚拟空间映射关系(一个常见进程的虚拟空间)如下图。
程序头表(Program Header Table)
用来保存“Segment”的信息,描述了ELF文件该如何被操作系统映射到虚拟空间。因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有。
使用readelf查看程序头表。
跟段表结构一样,程序头表也是一个结构体数组,其结构体用Elf32_Phdr表示。
下表是Elf32_Phdr结构的各个成员的基本含义。
堆和栈
VMA除了被用来映射可执行文件中的各个”segment”以外,操作系统通过使用VMA来对进程的地址空间进行管理,包括堆和栈。
在Linux下,可以通过查看”/proc”来查看进程的虚拟空间分布:
我们可以看到进程中有5个VMA,只有前两个是映射到可执行文件中的两个Segment。另外三个段的文件所在设备主设备号及文件节点号都是0,则表示他们没有映射到文件中,这种VMA叫做匿名虚拟内存区域。另外有一个很特殊的VMA叫“vdso”,它的地址已经位于内核空间了(即大于0xC0000000的地址),事实上它是一个内核的模块,进程可以通过访问这个VMA来跟内核进行一些通信。
操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA。
动态链接
以下面的代码为例
将Lib.c编译成一个共享对象文件:
|
|
分别编译链接Program1.c和Program2.c:
|
|
查看进程的虚拟地址空间分布:
上图中的ld-2.6.so实际上是Linux下的动态链接器,它与普通共享对象一样被映射到了进程的地址空间,在系统开始运行program1之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给program1,然后开始执行。
通过readelf查看Lib.so的装载属性:
与普通程序不同的是,动态链接模块的装载地址是从地址0x00000000开始的,这个地址是无效的,共享对象的最终装载地址在编译时时不确定的,而是在装载时,装载器根据当前地址空间的空前情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。
地址无关代码(PIC)
装载时重定位是解决动态模块中有绝对地址引用的方法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。我们还需要有一种更好的方法解决共享对象指令中对绝对地址的重定位问题。其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本思想就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。
模块中各种类型的地址引用方式如下图:
全局偏移表(GOT)
用于模块间数据访问,在数据段里建立一个指向外部模块变量的指针数组。当代码需要引用该全局变量时,可以通过GOT中相对用的项间接引用,它的基本机制如下图。
当指令中需要访问变量b时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。每个变量都对应一个4字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
延迟绑定(PLT)
动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后再进行间接跳转。程序开始执行时,动态链接器都要进行一次链接工作,会寻找并装载所需的共享对象,然后进行符号查找地址重定位等工作,如此一来,程序的运行速度必定会减慢。
延迟绑定的实现
函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。
GOT 位于 .got.plt section 中,而 PLT 位于 .plt section中。
GOT 保存了程序中所要调用的函数的地址,运行一开时其表项为空,会在运行时实时的更新表项。一个符号调用在第一次时会解析出绝对地址更新到 GOT 中,第二次调用时就直接找到 GOT 表项所存储的函数地址直接调用了。
printf()函数的调用过程如下图
GDB调试分析延迟绑定机制
为了加深理解可以用GDB动态调试,Examine下断点前后GOT表的内存的变化。
动态加载器解析结束,可以看到got表项正确指向了libc动态库中printf的地址
动态链接的相关结构
.interp段
在动态链接的ELF可执行文件中,有一个专门的段叫做”.interp”段。里面保存的是一个字符串,记录所需动态链接器的路径。
从下图可以看出,Android用的动态链接器是linker
.dynamic段
这个段里保存了动态链接器所需要的基本信息,比如依赖哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。
.dynamic段里保存的信息有点像ELF文件头。
.dynamic段的结构是由Elf32_Dyn组成的数组。
Elf32_Dyn结构由一个类型值加上一个附加的数值或指针,对于不同的类型,后面附加的数值或者指针有着不同的含义。
动态符号表(.dynsym)
为了表示动态链接模块之间的符号导入导出关系,ELF专门有一个叫做动态符号表的段用来保存这些信息。
与”.symtab”不同的是,”.dynsym”只保存了与动态链接相关的符号,对于那些模块内部的符号,比如模块私有变量则不保存。很多时候动态链接模块同时拥有”.dynsym”和”.symtab”两个表,”.symtab”中往往保存了所有符号,包括”.dynsym”中的符号。
动态符号字符串表(.dynstr)
在动态链接时用于保存符号名的字符串表。
符号哈希表(.hash)
由于动态链接下,需要在程序运行时查找符号,为了加快符号的查找过程,往往还有辅助的符号好戏表。
用readelf查看elf文件的动态符号表及它的哈希表。
动态链接重定位表
在动态链接中,导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。
“.rel.dyn”段对数据引用的修正,它所修正的位置位于”.got”以及数据段;
“.rel.plt”段对函数引用修正,它所修正的位置位于”.got.plt”。
用readelf来查看一个动态链接的文件的重定位表:
R_386_JUMP_SLOT和R_386_GLOB_DAT这两个类型的重定位入口表示,被修正的位置只需要直接填入符号地址即可。
比如,printf这个重定位入口,它的类型为R_386_JUMP_SLOT,它的偏移为0x000015d8,它位于”.got.plt”中,下图为其结构。
当链接器需要进行重定位时,它先查找”printf”的地址,“printf”位于libc.so中。假设链接器在全局符号表里面找到”printf”的地址为0x08801234,那么链接器就会将这个地址填入到”.got.plt”中偏移为0x000015d8的位置中去,从而实现了地址的重定位。
R_386_GLOB_DAT是对”.got”的重定位,它跟R_386_JUMP_SLOT的做法一样。
android arm架构的一种hook实现方案
具体实现来自Andrey Petrov的blog.
|
|
用法:
1.调用dlopen拿到so的句柄,得到soinfo,它包含了符号表、重定位表、plt表等信息。
2.查找需要hook的函数的符号,得到它在符号表中的索引。具体实现是soinfo_elf_lookup函数。
bucket数组包含nbucket个项目,chain数组包含nchain个项目,下标都是从0开始。bucket和chain中都保存了符号表的索引。chain表项和符号表存在对应。符号表项的数目应该和nchain相等,所以符号表的索引也可以用来选取chain表项。哈希函数能够接受符号名并返回一个可以用来计算bucket的索引。如果哈希函数针对某个名字返回了数值x,则bucket[x%nbucket]给出了一个索引y,该索引可用于符号表,也可用于chain表。如果该符号表项不是所需要的,那么chain[y]则给出了具有相同哈希值的下一个符号表项。我们可以沿着chain链一直搜索,直到所选中的符号表项包含了所需要的符号,或者chain项中包含值STN_UNDEF。
3.遍历plt表,直到匹配第2步中找到的符号索引。
如果是JUMP_SLOT类型(函数调用),替换为新的符号地址(函数指针)。
程序中调用mprotect的作用是:
修改一段指定内存区域的保护属性。
函数原型为:int mprotect(const void *start, size_t len, int prot);
mprotect()函数把自start开始的、长度为len的内存区的保护属性修改为prot指定的值。
需要指出的是,指定的内存区间必须包含整个内存页(4K)。区间开始的地址start必须是一个内存页的起始地址,并且区间长度len必须是页大小的整数倍。
参考
- 《程序员的自我修养》
- 《深入理解计算机系统》
- Andrey Petrov’s blog
- Redirecting functions in shared ELF libraries