1. 背景
在我们的日常工作中经常会遇到一些BUG,而且这些BUG发生在native层,也就是在我们的so共享库中,对于这些BUG有时我们可以修改代码,重新编译生成SO文件,但很多情况下这些SO文件都是外部提供,在没有源码的情况下我们没法修改并编译,这时候Hook技术就有了用武之地。
举个例子,在之前的一个项目中,作者使用Android插件技术调起某款应用,在使用的过程中,发现某功能无法使用,后来从对方研发那边打听到,他们的某个so苦文件中在调用标准C函数dlopen时,传入了一个写死的路径,这个路径类似于/data/data/com.yingyong,这是Android中应用的数据存储的私有路径,如果应用未安装,该路径也就不存在,所以也就导致了我们在使用插件技术调起对方应用时发生了错误。在综合考虑解决该问题的各种方法之后,最终我们决定hook住对方应用内的so文件,替换掉内存中dlopen函数的入口地址,当对方应用so文件中调用该函数时,最终会调用到我们的代理函数,我们在代理函数中预先做些处理,然后再去调用真实的dlopen函数。基于上面所说的方法,我们解决问题的思路大概如下:
1. 首先so库肯定会加载到进程空间中来,我们在我们的进程空间中,找到这个so库的起始地址
2. 基于对so文件格式的理解,想办法找到dlopen这个函数调用的入口地址,我们把这个入口地址替换掉,替换成我们自定义的hook函数的地址,同时保存原来的dlopen函数的地址。
3. 当我们把dlopen函数的入口地址替换掉之后,so文件中在调用dlopen函数时就会跑到我们的hook函数中,执行我们的代码,我们在我们的hook函数里面对传进来的路径进行判断,如果是/data/data/com.yingyong那么函数就直接return,如果是其他的路径则跳转到原来的dlopen函数的入口地址里面去执行。
要想达到上面所说的目的,我们有两个障碍:
(1) 我们得熟悉so文件格式,了解so是如何被进程加载的,so在进程空间中是怎么存储的,我们该如何从进程空间中找到dlopen函数的入口
(2)在找到dlopen函数的入口后,我们如何去修改进程空间把原来的dlopen函数的入口地址替换成我们自己写的函数的地址
这两个问题中,第一个问题比较复杂,我们放在后面讲,先讲第一个问题如何解决。
2.内存映射镜像
要想修改内存中dlopen函数的入口地址,首先我们得先看看so在进程空间中是如何存在。
先让我们来看一个典型的应用进程的内存镜像:
上面的信息可以通过命令:cat /proc/pid/maps 来查看。注意,如果查看本进程,pid传入的是字符串“self”,如果是其他进程,pid就是那个进程的进程号。通过上面这幅图,我们可以查看到当前进程空间的内存映射情况,模块加载情况以及虚拟地址和内存读写执行(rwxp)属性等。我们以其中一行为例来解释这里面数据的含义:
第一列“aec20000-aec2b000”表示该段数据的起始和结束地址; 第二列“r-xp”表示的是这段内存的权限, r表示只读,w表示可写,x表示可执行,p表示私有;第三列“00000000”表示在进程地址里面的偏移量,第四列“fe:00”是主设备号和次设备号,第五列“999598”是文件节点号inode
上面的这行有只读和执行权限,其实熟悉虚拟机的同学就可以判断出这就是程序内的代码段,我们在c程序里面定义的一些函数的信息也就保存在这里,比如我们上面所说的dlopen函数的入口地址。但是现在问题来了,这段内存是只读的,那我们怎么去把dlopen的入口地址替换成我们自定义的函数地址呢?其实linux给我们提供了一个函数mprotect,该函数可以修改内存块的读取权限,其定义如下:
#include
intmprotect(void*addr,size_tlen,intprot);
参数:
addr:要修改的内存基址(必须页面对齐,page size的倍数,一般为4K对齐)
len:大小(bytes)
prot:修改后的rwx属性,可以有以下值:
PROT_EXEC Pages may be executed.//可执行
PROT_READ Pages may be read.//可读
PROT_WRITE Pages may be written.//可写
PROT_NONE Pages may not be accessed.//不可访问
当我们找到存放dlopen入口地址的内存块时,首先调用mprotect函数修改该内存块的读取权限,然后通过memcpy函数,将该内存块存储的dlopen入口地址替换成我们定义的函数地址,memcpy函数的定义如下:
#include
intmemcpy(void*pDest,void*pSour,size_tpLen);
该函数的作用就是从pSour地址开始拷贝pLen长的数据到pDest开始的内存中去。
到这里,上面的第二个问题就得到解决了 ,我们知道通过mprotect函数去修改内存块读写权限,然后我们可以通过memcpy将自己写的hook函数的地址拷贝到保存dlopen函数地址的内存块中去,但现在的问题就是要找到dlopen函数的入口地址,这就需要我们去了解ELF文件格式了,只有了解了so文件的文件格式,我们才能写出方法,找到存储dlopen函数地址的地方。下面我们就来深入了解so文件格式,并着手解决第一个问题
3. ELF文件格式的理解
ELF是类Unix类系统当然也包括Android系统上的对象文件的格式(包括.so和.o类文件)。可以理解为Android系统上的exe或者dll文件格式。理解ELF文件规范,是理解Android系统上进程加载、执行的前提。
首先,你需要知道的是所谓对象文件(Object files)有三个种类:
1) 可重定位的对象文件(Relocatable file)。这是由汇编器汇编生成的 .o 文件。
2) 可执行的对象文件(Executable file)。比如我们用的QQ、微信、浏览器等等
3) 可被共享的对象文件(Shared object file)这些就是所谓的动态库文件,也即 .so 文件。
一个动态库也就是so文件要想发挥作用,必须经过两个步骤:
a) 链接编辑器(link editor)拿它和其他Relocatable object file以及其他shared object file作为输入,经链接处理后,生存另外的 shared object file 或者 executable file。
b) 在运行时,动态链接器(dynamic linker)拿它和一个Executable file以及另外一些 Shared object file 来一起处理,在Linux系统里面创建一个进程映像。
下面用一张图来对ELF的文件格式有个总览:
左边是静态视图,而右边则是链接加载时的视图,都是同一个文件的两种状态。
我觉得下面这两张图能够更准确的反映ELF文件的组成:
我们来解释一下文件各部分的意义:
ELF header在文件开始处描述了整个文件的组织,
程序头部表,后面我们叫Program header table,指出怎样创建进程映像,含有每个program header的入口,
节区,后面我们都叫Section,提供了目标文件的各项信息(如指令、数据、符号表、重定位信息等)
节区头部表,后面我们叫section header table,包含每一个section的入口,给出名字、大小等信息。
在我们的Android NDK环境中有一个工具arm-linux-androideabi-readelf,工具位于android-ndk-r10d/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin/下面,这个工具可以读取并显示so文件中的一些信息。我们以之前免安装工程里面写的libhook.so共享库为例,来看看so文件各部分信息的组成:
ELF Header信息
ELF Header:顾名思义,这是所有ELF文件都有的”头“。里面包含了ELF文件的”纲领性“信息,如CPU架构,Section header在文件中的偏移字节数,Section的个数等等,用C语言数据结构来描述就是:
/* ELF Header */
typedefstructelfhdr {
unsignedchare_ident[EI_NIDENT];/* ELF Identification */
Elf32_Half e_type;/* object file type */
Elf32_Half e_machine;/* machine */
Elf32_Word e_version;/* object file version */
Elf32_Addr e_entry;/* virtual entry point */
Elf32_Off e_phoff;/* program header table offset */
Elf32_Off e_shoff;/* section header table offset */
Elf32_Word e_flags;/* processor-specific flags */
Elf32_Half e_ehsize;/* ELF header size */
Elf32_Half e_phentsize;/* program header entry size */
Elf32_Half e_phnum;/* number of program header entries */
Elf32_Half e_shentsize;/* section header entry size */
Elf32_Half e_shnum;/* number of section header entries */
Elf32_Half e_shstrndx;/* section header table's "sectionheader string table" entry offset */
} Elf32_Ehdr;
其中e_ident的16个字节标明是个ELF文件。e_type表示文件类型,e_machine说明机器类别,e_entry给出进程开始的虚地址,即系统将控制转移的位置, 可以理解成main函数的位置。e_phoff指出program header table的文件偏移,e_phentsize表示一个program header每一项的长度(字节数表示),e_phnum给出program header里面数据项的数目。类似的,e_shoff,e_shentsize,e_shnum分别表示section header table的文件偏移,表中每一个section项的的字节数和section的个数。e_flags给出与处理器相关的标志,e_ehsize给出ELF文件头的长度(字节数表示)。e_shstrndx表示section header string table在文件中的偏移位置。
我们要想定位到一个具体的Section,主要关注以下几个值:
e_shoff: Section header table的起始地址
e_shnum: Section header的个数
e_shstrndx: 有一个特殊的Section Header,它里面保存的相当于一个String数组,每一项都是一个Section的名字。
Programe Header信息
一个可执行文件及其依赖的共享目标文件被完全成功地装载到进程的内存地址空间中之后,这个可执行文件或共享目标文件中的程序头部表(Program Header Table)就是必须存在的、不可缺少的必需品,程序头部表是一个数组,数组中的每一个元素就称为一个程序头(Program Header),每一个程序头描述一个内存段(Segment)或者一块用于准备执行程序的信息;内存中的一个目标文件中的段包含一个或多个节;也就是ELF文件在磁盘中的一个或多个节可能会被映射到内存中的同一个段中;程序头只对可执行文件或共享目标文件有意义,对于其它类型的目标文件,该信息可以忽略;p_type=PT_LOAD时,段的内容会被从文件中拷贝到内存中,所有PT_LOAD类型的程序头都按照p_vaddr的值做升序排列;
Section 信息
Section Header Table则列出了所有包含在文件中的Section区信息列表。它是一个Elf32_Shdr结构的数组,Section头表的索引是这个数组的下标。通过Section Header Table可以定位到所有的Section。在一个so文件中会有很多的Section,我们写的libhook.so文件里面就有22个Section,如.interp,.text, .plt, .rel.plt等等
可以说,Section是在ELF文件里头用以装载内容数据的最小容器。在ELF文件里面,每一个Section内都装载了性质属性都不一样的内容,比方:
1) .text section 里装载了可执行代码;
2) .data section 里面装载了被初始化的数据;
3) .bss section 里面装载了未被初始化的数据;
4) 以 .rel 打头的 sections 里面装载了重定位条目,如.rel.plt里面存储的就是外部函数的重定位信息
5) .symtab 或者 .dynsym section 里面装载了符号信息;
6) .strtab 或者 .dynstr section 里面装载了字符串信息;
7) 其他还有为满足不同目的所设置的section,比方满足调试的目的、满足动态链接与加载的目的等等
下面我们再看一下Section Header的定义:
/* Section Header */
typedefstruct{
Elf32_Word sh_name;/* name - index into section header
string table section */
Elf32_Word sh_type;/* type */
Elf32_Word sh_flags;/* flags */
Elf32_Addr sh_addr;/* address */
Elf32_Off sh_offset;/* file offset */
Elf32_Word sh_size;/* section size */
Elf32_Word sh_link;/* section header table index link */
Elf32_Word sh_info;/* extra information */
Elf32_Word sh_addralign;/* address alignment */
Elf32_Word sh_entsize;/* section entry size */
} Elf32_Shdr;
这个我们要详细解释一下,后面能用到:
字段:
sh_name:顾名思义,Section的名字,类型是Elf32_Word,实际上它是指向section header string table的索引值
sh_flags:类型。.dynsym的类型为DYNSYM表示该节区包含了要动态链接的符号等等
sh_addr:地址。该节区在内存中,相对于基址的偏移
sh_offset:偏移。表示该节区到文件头部的字节偏移。
sh_size:节区大小
sh_link:表示与当前section有link关系的section索引,不同类型的section,其解释不同。如上面的libc.so,其.dynsym的link为2,而2正好是.dynstr的索引,实际上就是动态符号串表的索引
sh_info:一些附加信息
sh_addralign:节区的地址对齐
sh_entsize:节区项的大小(bytes)
上面这么多乱七八糟的看起来很多,实际上为了解决上面的第一个问题我们只需要关注三个Section:.dynsym, .dynstr,.rel.plt,因为,dlopen函数是标准的c库函数,在一个so文件中要想调用他,那么就得给这个函数生成一个重定位信息,这个信息就存储在.rel.plt这个section里面,.rel.plt相当于一个数组,这个数组里面的每一项都存储了一项可重定位信息。所以我们要想找到dlopen的入口地址,就得到.rel.plt中去,一个一个的去遍历数组,找到那个dlopen相关的那一项,但是.rel.plt不会存储一个“dlopen”的字符串,我们首先得到.dynsym这个section里面去找代表“dlopen”的那个符号信息,这个符号信息的数据结构里面也不会存储具体的函数名称(如“dlopen”),这些名称会存储在.dynstr这个section里面,所以又得去.dynstr里面去找。所以要想找到dlopen函数代表的重定位信息,首先就得遍历.rel.plt, 对于里面的每一项通过.dynsym去.dynstr里面去找这个重定位函数的名称是不是叫“dlopen”,如果是,那么这条重定位信息,就是代表dlopen的重定位信息,下面我们来具体想一看.rel.plt 、 .dynsym 、 .dynstr这三个节区里面代表每一项数据的数据结构:
.rel.plt: 该Section用来保存所有的可重定位信息, 对于每一条可重定位信息,可以用下面的数据结构Elf32_Rel来表示:
typedefstruct{
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;
在数据结构Elf32_Rel中r_offset表示的就是在运行期间,该重定位条目相对于so基地址的偏移量,我们要找的也就是这个偏移量,然后加上so在内存中的基地址,得到的就是我们要找的存储函数入口地址的地方;r_info是由两个值组成的:1. 该重定位在.dynsym中的索引, 2. 该重定位的类型。 我们可以通过ELF32_R_SYM这个宏获取到该重定位在.dynsym中的索引,通过ELF32_R_TYPE获取该重定位的类型。
typedefstructelf32_sym{
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsignedcharst_info;
unsignedcharst_other;
Elf32_Half st_shndx;
} Elf32_Sym;
其中st_name包含指向符号表字符串表(.dynstr, 也就是dynamic symbol string table)中的索引,从而可以获得符号名。举个例子,对于我们要找的dlopen这个函数,就存在一个上面的Elf32_Sym对象,该对象的st_name值可能是15,这时候我们就能从.dynstr(Dynamic symble string table)里面找到第15项,这个第15项里面就存了一个字符串“dlopen”。
st_value指出符号的值,可能是一个绝对值、地址等。st_size指出符号相关的内存大小,比如一个数据结构包含的字节数等。st_info规定了符号的类型和绑定属性,指出这个符号是一个数据名、函数名、section名还是源文件名;并且指出该符号的绑定属性是local、global还是weak。
.dynstr:全称叫dynamic symbol string table, 我们可以把他理解成一个字符串数组,即char* [ ];
到了这里我们的知识储备就能够找到运行时dlopen函数在进程空间中的地址了:
通过读取ELF文件内容,找到.dynsym, .dynstr,.rel.plt这三个section,读取这三个section的内容。然后从.rel.plt这个section中读取到dlopen函数对应的重定位项,怎么找到这个重定位项呢,那就是遍历.rel.plt中的所有的重定位项信息,读取这些重定位项对应的Elf32_Rel结构对象,从Elf32_Rel的r_info字段中通过ELF32_R_SYM读取一个index值,这个index值就是dlopen函数在.dynsym中对应的符号信息的index,通过这个index读取dlopen这个符号对应的Elf32_Sym结构对象。而通过这个结构对象的st_name去.dynstr中去找相应的字符串,当这个字符串等于“dlopen”时,我们就知道了,刚才在.rel.plt中找到的Elf32_Rel对应的就是dlopen函数对应的重定位项,通过读取这个重定位项的r_offset就可以知道存储dlopen函数入口地址的地方在整个so镜像中的偏移量,用这个偏移量加上so映射到进程中的起始地址,就得到了dlopen这个函数在我们应用进程中的绝对地址,这个地址保存的是dlopen函数的入口地址,我们把这个入口地址替换成我们函数的地址,这样在调用dlopen函数时,就进入到我们定义的函数中了。
至此我们就完成了我们应用包含的so中的dlopen函数的hook。