我们日常开发中编写的C/C++代码经过NDK
进行编译和链接之后,生成的动态链接库或可执行文件都是ELF
格式的,它也是Linux
的主要可执行文件格式。我们今天就要借助一个示例来理解一下android平台下native层hook的操作和原理,不过在这之前,我们还是要先了解一下ELF相关的内容。
这里给了一段示例代码:写入一段文本到文件中去。
为了简单起见,后面的都是以armeabi-v7a
为例
void writeText(const char *path, const char *text) {
FILE *fp = NULL;
if ((fp = fopen(path, "w")) == NULL) {
LOG_E("file cannot open");
}
//写入数据
fwrite(text, strlen(text), 1, fp);
if (fclose(fp) != 0) {
LOG_E("file cannot be closed");
}
}
输出目标共享库:libnative-write.so
,这个共享库的作用是写入一段文本,我们今天的目标就是对这个目标共享库
的fwrite函数
进行hook操作。
ELF文件有两种视图形式:链接视图
和执行视图
链接视图:可以理解为目标文件的内容视图
执行视图:可以理解为目标文件的内存视图
文件头(elf_header)
文件头部定义了魔数
,以及指向节头表SHT(section_header_table)
和程序头表PHT(program_header_table)
的偏移
。
节头表SHT(section_header_table)
ELF文件在链接视图
中是 以节(section)
为单位来组织和管理各种信息。
.dynsym
:为了完成动态链接,最关键的还是所依赖的符号和相关文件的信息。为了表示动态链接这些模块之间的符号导入导出关系,ELF有一个叫做动态符号表(Dynamic Symbol Table)的段用来保存这些信息。
.rel.dyn
:实际上是对数据引用的修正,它所修正的位置位于.got
以及数据段。
.rel.plt
:是对函数引用的修正,它所修正的位置位于.got
。
.plt
:程序链接表(Procedure Link Table),外部调用的跳板。
.text
:为代码段,也是反汇编处理的部分,以机器码的形式存储。
.dynamic
:描述了模块动态链接相关的信息。
.got
:全局偏移表(Global Offset Table),用于记录外部调用的入口地址。
.data
: 数据段,保存的那些已经初始化了的全局静态变量和局部静态变量。
程序头表PHT(program_header_table)
ELF文件在执行视图
中是 以段(Segment)
为单位来组织和管理各种信息。
所有类型为 PT_LOAD
的段(segment)
都会被动态链接器(linker)
映射(mmap)
到内存中。
1、装载
这个很好理解,我们在使用一个动态库内的函数时,都要先对其进行加载,在android
中,我们通常是使用System.loadLibrary
的方式加载我们的目标共享库,它的内部实现其实也是调用系统内部linker
中的dlopen、dlsym、dlclose函数
完成对目标共享库的装载。
2、动态链接
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个个单独的可执行文件。
当共享库被装载的时候,动态链接器linker
会将共享库装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。
3、重定位
共享库需要重定位的主要原因是导入符号的存在。动态链接下,无论是可执行文件或共享对象,一旦它依赖于其他共享对象,也就是说有导入的符号时(比如fwrite函数
),那么它的代码或数据中就会有对于导入符号的引用。在编译时这些导入符号的地址未知,在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。
动态链接的文件中,有专门的重定位表
分别叫做.rel.dyn
和.rel.plt
:
arm-linux-androideabi-readelf -r libnative-write.so
R_ARM_GLOB_DAT
和R_ARM_JUMP_SLOT
是ARM
下的重定位方式,这两个类型的重定位入口表示,被修正的位置只需要直接填入符号的地址即可。比如我们看fwrite函数
这个重定位入口,它的类型为R_ARM_JUMP_SLOT
,它的偏移为0x0002FE0
,它实际上位于.got
中。
前面的过程装载->动态链接->重定位
完成之后,目标共享库的基址
已经确定了,当我们调用某个函数时(比如fwrite
函数),调用函数并不是直接调用原始fwrite函数的函数地址
,它会先经过PLT程序链接表(Procedure Link Table)
,跳转至GOT全局偏移表(Global Offset Table)
获取目标函数fwrite函数
的全局偏移,这时候就可以通过基址+偏移
的方式定位真实的fwrite
函数地址了,目前android平台大部分CPU架构
是没有提供延迟绑定(Lazy Binding)
机制的(只有MIPS架构支持延迟绑定
),所有外部过程引用
都在映像执行之前解析。
PLT
:程序链接表(Procedure Link Table),外部调用的跳板,在ELF文件中以独立的段存放,段名通常叫做".plt"
GOT
:全局偏移表(Global Offset Table),用于记录外部调用的入口地址,段名通常叫做".got"
前面的内容都是一些概念性的内容,比较枯燥,接下来会以writeText函数
为入口,一步一步查看我们最终的目标函数fwrite
的地址。
从.dynsym开始
.dynsym
:上面也说到了,这个节
里只保存了与动态链接相关的符号导入导出关系。
arm-linux-androideabi-readelf -s libnative-write.so
我们可以看到目标的writeText函数
在0x705
的地方,我们再看下对应的反汇编代码:
arm-linux-androideabi-objdump -D libnative-write.so
这里会看到我们自己的writeText函数
通过BLX(相对寻址)指令
走到fwrite@plt
里面,简化上面的图:
从上面的简图中,我们可以看到,当执行我们的代码段.text
中的writeText函数
的时候,内部会通过BLX相对寻址
的方式进入.plt节
,计算程序计数器 PC 的当前值
跳转进入.got节
。
00000668 <fwrite@plt>:
668: e28fc600 add ip, pc, #0, 12 //由于ARM三级流水,PC = 0x668 + 0x8;
66c: e28cca02 add ip, ip, #8192 ; 0x2000 // ip = ip + 0x2000
670: e5bcf970 ldr pc, [ip, #2416]! ; 0x970 // pc = ip + 0x970
以上三条指令执行完,从0x668 + 0x8 + 0x2000 + 0x970 = 0x2FE0
位置取值给PC
,通过LDR
完成间接寻址的跳转。因此在.got(全局符号表)
中偏移为0x2FE0
的位置就是目标函数fwrite
的偏移了。
可以看到,当我们通过libnative-write.so
共享库中的writeText函数
调用libc
中的导入函数fwrite
的时候,还是经历了一些曲折的过程
,这里的过程,指的就是经过PLT
和GOT
的跳转,到达我们最终的真实的导入函数的地址
。
更快速的找到目标函数的偏移
前面也提到过动态链接重定位表
中的.rel.plt
是对函数引用
的修正,它所修正的位置位于.got
。我们最终都是要通过.got
确定目标函数的偏移,因此这里我们可以用readelf
直接看到fwrite函数
的偏移
通过如下可以查看ELF中需要重定位的函数,我们看下fwrite()函数
。
arm-linux-androideabi-readelf -r libnative-write.so
可以看到我们从libc库
中的导入函数fwrite
,这个偏移和我们刚才计算的偏移是一致的都是:0x2FE0
我们首先来看基址的获取,这里要用到linux系统的一些特性
# 进程的虚拟地址空间
cat /proc/<pid>/maps
上图已经列举出了我们的应用加载的一些so库,左边标记红色的地址就是各个so库的基址
#在进程ID为32396的进程中加载的几个库中
libhook-simple.so库的基址为:0xD40D8000
libnative-hook.so库的基址为:0xD411B000
libnative-write.so库的基址为:0xD414F000
因此我们实际需要hook的函数fwrite
的地址为:
addr = base_addr + 0x2FE0
通过前面的分析,我们已经拿到目标函数fwrite()
的地址指针了,理论上只要朝这个地址写入我们目标函数的地址就可以了?
并不是!!!
注意:
1、目标函数的地址很可能没有写权限,因此需要提前调整目标函数地址的权限
2、由于ARM有缓存指令集,hook之后可能会不成功,读取的是缓存中的指令,因此这里需要清除一下指令缓存
这时候我们就需要用到linux中的函数:
//调整目标内存区域的权限
int mprotect(void* __addr, size_t __size, int __prot);
//清除缓存指令
__builtin___clear_cache(void * __page_start,void * __page_end)
操作如下:
//调整写权限
mprotect((void *) PAGE_START(addr), PAGE_SIZE, PROT_READ | PROT_WRITE);
//朝目标函数的地址写新的地址
*(void **) addr = hook_fwrite;
//清除指令缓存
__builtin___clear_cache((void *) PAGE_START(addr), (void *) PAGE_END(addr));
完整的hook操作:
#include <stdio.h>
#include <malloc.h>
#include <string.h>
#include <inttypes.h>
#include <sys/mman.h>
#include "hook_simple.h"
#include "logger.h"
#define PAGE_START(addr) ((addr) & PAGE_MASK)
#define PAGE_END(addr) (PAGE_START(addr) + PAGE_SIZE)
size_t hook_fwrite(const void *buf, size_t size, size_t count, FILE *fp) {
LOG_D("hook fwrite success");
//这里插入一段文本
const char *text = "hello ";
fwrite(text, strlen(text), 1, fp);
return fwrite(buf, size, count, fp);
}
/**
* 直接硬编码的方式进行
* hook演示操作
* @param env
* @param obj
* @param jSoName
*/
void Java_com_feature_hook_NativeHook_hookSimple(JNIEnv *env, jobject obj, jstring jSoName) {
const char *soName = (*env)->GetStringUTFChars(env, jSoName, 0);
LOG_D("soName=%s", soName);
char line[1024] = "\n";
FILE *fp = NULL;
uintptr_t base_addr = 0;
uintptr_t addr = 0;
// 1. 查找自身对应的基址
if (NULL == (fp = fopen("/proc/self/maps", "r"))) return;
while (fgets(line, sizeof(line), fp)) {
if (NULL != strstr(line, soName) &&
sscanf(line, "%"PRIxPTR"-%*lx %*4s 00000000", &base_addr) == 1)
break;
}
fclose(fp);
LOG_D("base_addr=0x%08X", base_addr);
if (0 == base_addr) return;
//2. 基址+偏移=真实的地址
addr = base_addr + 0x2FE0;
LOG_D("addr=0x%08X", addr);
//注意:调整写权限
mprotect((void *) PAGE_START(addr), PAGE_SIZE, PROT_READ | PROT_WRITE);
//替换目标地址
*(void **) addr = hook_fwrite;
//注意:清除指令缓存
__builtin___clear_cache((void *) PAGE_START(addr), (void *) PAGE_END(addr));
}
可以看到这里已经成功完成了hook操作
看了上面的例子,大家觉得native-hook复杂吗?看上去不复杂?那如果让你来设计一个类似于xHook
的库,你能直接在框架里硬编码0x2FE0
吗?,当然不行,因此需要一个通用的逻辑来定位具体的偏移
和基址
才行,接下来我们重点来看下偏移
和基址
如何通过通用的代码来动态确定
我们接下来要做的重要的工作是在运行期间,动态定位目标共享库中的基址
和偏移
。
这里主要如下几个步骤:
1、获取目标so库的基址
基址很好确定:
void *get_module_base(pid_t pid, const char *module_name) {
FILE *fp;
long addr = 0;
char filename[32] = "\n";
char line[1024] = "\n";
LOG_D("pid=%d ", pid);
if (pid < 0) {
snprintf(filename, sizeof(filename), "/proc/self/maps");
} else {
snprintf(filename, sizeof(filename), "/proc/%d/maps", pid);
}
// 获取指定pid进程加载的内存模块信息
fp = fopen(filename, "r");
while (fgets(line, sizeof(line), fp)) {
if (NULL != strstr(line, module_name) &&
sscanf(line, "%"PRIxPTR"-%*lx %*4s 00000000", &addr) == 1)
break;
}
fclose(fp);
return (void *) addr;
}
我们只需要读取自身进程的/proc/self/maps
就可以获取当前进程装载的模块信息,这个不算复杂。
2、保存原始的调用地址
当我们自己的共享库完成对目标共享库的hook操作
之后,要保证功能正常运行,需要先保存原始的函数调用地址。
3、解析ELF文件头部
这里先根据魔数
来确定是否为ELF文件格式,而且文件头部里实际已经指明了SHT
和PHT
的偏移信息了
4、根据(基址 + e_phoff)确定程序头表PHT(Program Header Table)的地址
上图中的这个e_phoff
的值是指向程序头表PHT
的偏移,0x34 = 52
5、遍历程序头表PHT(Program Header Table)
看上面的图示,程序头表PHT内的元素是个数组,但是我们目前只关心类型为PT_DYNAMIC(指定动态链接信息)
的项,获取对应的p_vaddr
6、根据(基址+p_vaddr
)确定.dynamic段
的地址,遍历dynamic link table
接着遍历出d_tag=DT_JMPREL
类型的项的d_val
值,这个值是指向重定位表
的偏移,不要疑惑下图中的偏移是0x2E7C
,为什么下面Start
却是0x1E7C
,刚才也说了ELF文件有两种视图,一个链接视图
,一个执行视图,下面的图是链接视图
,但我们最终要以执行视图里的结果为准。
7、根据(基址+d_val)确定重定位表的地址,接下来我们遍历函数名称对比即可找到目标函数的偏移
参考下面这张图吧
也就是说上面的那么多步骤,实际目的就是确定运行期间的目标共享库中的重定位表
的地址。
笔者只是借助一个示例来理解基于PLT/GOT
进行hook操作
的原理,实际项目中,我们完全可以借助这种方案对目标共享库中的malloc
,free
进行hook操作
,在没有源码的情况下,以此来分析第三方共享库中可能存在的内存泄露
问题。
具体可以看看:LoliProfiler
的实现。
Q:比如我要hook
我当前应用中的malloc函数
,是否只对某个共享库
进行hook即可?
A:并不是
,每一个共享库
都有它自己的PLT/GOT表
,因此需要对每个共享库
都要进行hook操作
才行。
Q:我在共享库中通过dlopen、dlsym
的方式调用系统导入函数
,这中方式可以被hook住吗?
A:不可以
,上面的整个内容其实都是基于PLT/GOT表
定位目标函数进行hook操作
,而dlopen、dlsym
是目标共享库在运行期间,动态定位导入函数
,这种方式并不生效。
其实hook操作
本身的技术原理并不复杂,但是要针对android平台下
的共享库
进行hook操作
,仅仅只了解hook操作
是不够的,可以看到上面大部分的内容其实是在跟ELF文件周旋
,要结合它的加载、动态链接、重定位过程
,才能更好的理解基于PLT/GOT
的hook原理
,由于笔者能力有限,在部分细节的描述可能不全面或者会有偏差,欢迎指正!
native-hook
《程序员的自我修养:链接、装载与库》
https://github.com/iqiyi/xHook/
https://www.cnblogs.com/goodhacker/p/9306997.html