Api Demo | 接口原型

android中基于plt/got的hook实现原理

2021-06-25T21:52:30
--------------------------------------------------------------------------------------------------------

目录


概述

简单示例

ELF文件格式初探

装载、动态链接与重定位

PLT与GOT

如何定位基址?

如何修改呢?

解析基址和偏移

思考和小结

概述


我们日常开发中编写的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");

    }

}

1

2

3

4

5

6

7

8

9

10

11

输出目标共享库:libnative-write.so,这个共享库的作用是写入一段文本,我们今天的目标就是对这个目标共享库的fwrite函数进行hook操作。


ELF文件格式初探


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

1


R_ARM_GLOB_DAT和R_ARM_JUMP_SLOT是ARM下的重定位方式,这两个类型的重定位入口表示,被修正的位置只需要直接填入符号的地址即可。比如我们看fwrite函数这个重定位入口,它的类型为R_ARM_JUMP_SLOT,它的偏移为0x0002FE0,它实际上位于.got中。


PLT与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

1


我们可以看到目标的writeText函数在0x705的地方,我们再看下对应的反汇编代码:


arm-linux-androideabi-objdump -D libnative-write.so

1


这里会看到我们自己的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

1

2

3

4

以上三条指令执行完,从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

1


可以看到我们从libc库中的导入函数fwrite,这个偏移和我们刚才计算的偏移是一致的都是:0x2FE0


如何定位基址?


我们首先来看基址的获取,这里要用到linux系统的一些特性


# 进程的虚拟地址空间

cat /proc/<pid>/maps

1

2


上图已经列举出了我们的应用加载的一些so库,左边标记红色的地址就是各个so库的基址


#在进程ID为32396的进程中加载的几个库中

libhook-simple.so库的基址为:0xD40D8000

libnative-hook.so库的基址为:0xD411B000

libnative-write.so库的基址为:0xD414F000

1

2

3

4

因此我们实际需要hook的函数fwrite的地址为:


addr = base_addr + 0x2FE0

1

如何修改呢?


通过前面的分析,我们已经拿到目标函数fwrite()的地址指针了,理论上只要朝这个地址写入我们目标函数的地址就可以了?

并不是!!!


注意:


1、目标函数的地址很可能没有写权限,因此需要提前调整目标函数地址的权限

2、由于ARM有缓存指令集,hook之后可能会不成功,读取的是缓存中的指令,因此这里需要清除一下指令缓存


这时候我们就需要用到linux中的函数:


//调整目标内存区域的权限

int mprotect(void* __addr, size_t __size, int __prot);

//清除缓存指令

__builtin___clear_cache(void * __page_start,void * __page_end)

1

2

3

4

操作如下:


//调整写权限

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));

1

2

3

4

5

6

完整的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 (*old_fwrite)(const void *buf, size_t size, size_t count, FILE *fp);


size_t hook_fwrite(const void *buf, size_t size, size_t count, FILE *fp) {

    LOG_D("hook fwrite success");

    //这里插入一段文本

    const char *text = "hello ";

    old_fwrite(text, strlen(text), 1, fp);

    return old_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);


//保存旧的函数地址

old_fwrite = *(void **) addr;


    //替换新的目标地址

    *(void **) addr = hook_fwrite;

    //注意:清除指令缓存

    __builtin___clear_cache((void *) PAGE_START(addr), (void *) PAGE_END(addr));

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

可以看到这里已经成功完成了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;

}

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

我们只需要读取自身进程的/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://blog.csdn.net/byhook/article/details/103500524