好记性不如铅笔头

android, linux, 操作系统

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

本文转自【 android中基于plt/got的hook实现原理_handy周的博客-CSDN博客_android plt hook 】,有删改。

CONTENTS

概述

我们日常开发中编写的C/C++代码经过NDK进行编译和链接之后,生成的动态链接库或可执行文件都是ELF格式的,它也是Linux的主要可执行文件格式。我们今天就要借助一个示例来理解一下android平台下native层hook的操作和原理,不过在这之前,我们还是要先了解一下ELF相关的内容。

简单示例

这里给了一段示例代码:写入一段文本到文件中去。
为了简单起见,后面的都是以armeabi-v7a为例,输出目标共享库:libnative-write.so,这个共享库的作用是写入一段文本,我们今天的目标就是对这个目标共享库的fwrite函数进行hook操作。

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

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)到内存中。

装载、动态链接与重定位

装载

我们在使用一个动态库内的函数时,都要先对其进行加载,在android中,我们通常是使用System.loadLibrary的方式加载我们的目标共享库,它的内部实现其实也是调用系统内部linker中的dlopen、dlsym、dlclose函数完成对目标共享库的装载。

动态链接

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个个单独的可执行文件。
当共享库被装载的时候,动态链接器linker会将共享库装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。

重定位

共享库需要重定位的主要原因是导入符号的存在。动态链接下,无论是可执行文件或共享对象,一旦它依赖于其他共享对象,也就是说有导入的符号时(比如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中。

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

我们可以看到目标的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 (*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));
}

可以看到这里已经成功完成了hook操作:

思考

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原理,由于笔者能力有限,在部分细节的描述可能不全面或者会有偏差,欢迎指正!

参考
《程序员的自我修养:链接、装载与库》
https://github.com/iqiyi/xHook/
https://www.cnblogs.com/goodhacker/p/9306997.html

发表评论

3 × 4 =

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据