PLT Hook从入门到实战

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

PLT Hook是Android进阶必须掌握的技术之一,该技术在性能优化上有着广泛的应用,笔者这里以Native 内存泄漏检测,来带大家掌握PLT Hook技术

Native 内存泄漏的主要原因是 so 库的代码调用 malloc 函数申请了内存,但是在业务结束之后却没有调用 free 函数释放内存,随着程序的运行,泄漏的内存越来越多,最终会导致程序因为内存占用过大而发生异常。

想要检测 Native 的内存泄漏,我们通常要拦截住 so 库中的 malloc 函数和 free 函数,并插入我们自己的逻辑来统计 malloc 与 free 的内存大小,如果某个 so 库申请的内存减去释放的内存超过我们设置的一个阈值时,便认为这个 so 库发生了内存泄漏。

笔者在 Demo 示例程序的 Native 层异常申请接近 88M 的内存,并将这个 Native 代码打包成 example.so 库。

我们接着在上层的 Activity 中加载这个 so 库,并调用该方法,便可以模拟一个 Native Malloc 内存异常的场景。笔者接着就通过这个示例程序,来带读者一步步的排查检测出这个异常的 Native 内存泄漏异常。

PLT Hook技术原理

程序的运行过程就是一个不断的调用和执行函数的过程,而调用函数就一定需要知道这个函数在内存中的地址。Native 代码再被打包成 so 库时,每个函数都会分配好一个偏移地址,因此如果是 so 库内部函数之间的互相调用,那么直接通过编译期间给函数分配的偏移地址就能完成函数的调用。

但如果我们调用的是一个外部 so 库的函数时,就只能通过绝对地址来进行调用了,也就是该外部 so 库在内存中的首地址 + 该函数的偏移地址。那么外部函数的调用流程是怎样的呢?这里以 Demo 中的 example.so 为例,它在调用位于 libart.so 这个库中的 malloc 函数时,会先查找内部的 .plt 过程链接表,这是一个包含跳转指令的代码段,通过跳转指令会接着跳转到 malloc 函数对应的 .got 表,这是一个包含外部函数地址的数据段,位于 .dynamic 段中,而 .got 表则会记录 malloc 函数的真实地址。但是在编译期间,.got表是没法确认 malloc 函数的地址的,所以初始地址为 0,在程序运行过程, Linker (动态链接器) 这个系统程序会在malloc 函数调用时,来将 malloc 函数的真实地址写入到 example.so 库对应 .got 全局偏移表中。

什么是 plt 表和 got 表呢?我们知道 so 库实际上就是一个 ELF 格式的文件,里面包含了 .text、.data、.bss .dynamic 等各种数据段,在程序运行某个 so 库时,so 库的这些数据段就都会被加载进内存中。而 .plt 表,也就是过程链接表(Procedure Linkage Table),实际上就是位于 .text 段中的一张表,记录了跳转到外部的函数对应的 got 表的代码段,got 表则是位于 .data 段下面的一张表,记录了外部库函数的地址。在程序运行时,动态链接器会根据函数的符号信息,将函数的真实地址回写到.got 表中,从而实现函数的动态调用。

通过 Android NDK 中自带的 objdump 这个工具,执行 "objdump -D libexample.so" 命令,即可查看 example.so 对应的汇编代码,我们找到调用 mallocLeak 函数对应的汇编代码。地址 80e 对应的汇编指令 "blx 734 malloc@plt" ,其中 blx 是函数调用指令,734是函数对应的地址,也就是 malloc 函数对应的 .plt 表地址,即 malloc@plt 函数。可以看到,通过这段指令,对函数 malloc 的调用便会跳转到对应的 .plt 表中。

我们再接着看 malloc 在 .plt 表中的汇编代码,它是包含了三条指令的代码段,解释分别如下:

  • 第一条指令 "add ip, pc, #0 , 12" 表示将 0 左移12 位后和程序计数器(PC)的值相加,并将结果写入到 ip 寄存器中。因为在ARM中,PC的值为当前指令的地址加上 8 个字节,所以此时 ip 寄存器的值为:734+8。
  • 第二条指令 "add ip, ip, #12288 ; 0x3000" 表示将ip 寄存器的值,加上偏移地址为 #12288 的值 0x3000 ,并将结果存储回寄存器ip中,所以此时 ip 寄存器的值为 734+8 + 3000。
  • 第三条指令"ldr pc, [ip, #2200]! ; 0x898"表示将ip寄存器的值,加上偏移量地址为 #2200的的值 0x898,并将结果存储在程序计数器(PC)中,所以此时 pc 寄存器的值为 8 + 734 + 3000 + 898 = 3fd4。

所以 PC 寄存器中的 3fd4 就是指令接下来要跳转的地址,读者如果此时对这三条指令不太理解也没关系,可以在第五章熟悉了这些汇编指令之后再回头来看,我们只需要知道接下来会跳转到地址为 3fd4 的地方即可。

继续找到 3fd4 对应的代码,可以看到它位于 .got 表中,该地址对应的值 00000708 就的 malloc 函数真正的地址。为什么 .got 表中所有的数据都是 00000708 这个地址呢?实际上 00000708 对应的地址会跳转到一段动态链接代码段中,当程序运行且调用malloc函数后,这段代码段会调用 Linker 并将 malloc 真正的地址写入进来。

了解了上述外部函数调用的原理后,就可以开始了解 PPLTt Hook 这一技术了。如果我们在程序运行的过程中,将 libexample.so 库中偏移地址为 3fd4 种对应的地址数据替换成我们自定义的函数的绝对地址,那么该库中所有对 malloc 的函数调用都会跳转到我们的自定义函数中来,当自定义的函数的逻辑执行完成,并函数末尾跳转到 3fd4 地址原本所记录的地址,便能继续执行原来的 malloc 函数逻辑,通过这一流程便完成了对 malloc 函数的拦截操作。

实现流程

了解了流程和思路,接下来我们就可以通过代码来实现了,主要流程的代码如下:

  1. 通过逐行读取 maps 文件,找到并解析出 libexample.so 的地址,当然我们也可以通过Linux系统提供的 dl_iterate_phdr 函数,来更便捷的找到 libexample.so 的基地址。
c 复制代码
FILE *fp = fopen("/proc/self/maps", "r");
char line[1024];
uintptr_t base_addr = 0;
while (fgets(line, sizeof(line), fp)) {
    __android_log_print(ANDROID_LOG_DEBUG, "hookMallocByPLTHook", "line:%s", line);
    if (NULL != strstr(line, "libexample.so")) {
        std::string targetLine = line;
        std::size_t pos = targetLine.find('-');
        if (pos != std::string::npos) {
            std::string addressStr = targetLine.substr(0, pos);
            // stoull函数用于将字符串转换为无符号长长整型
            base_addr = std::stoull(addressStr, nullptr, 16);
            break;
        }
    }
}
fclose(fp);
  1. 获取的 so 库基地址后,就能根据设备平台转换成 Elf32_Ehdr 或 Elf64_Ehdr 数据结构,该数据结构是 ELF 文件加载进内存后的数据结构,通过 #include <linux/elf.h> 头文件后就能使用该数据结构了,该数据结构的字段详情可以参考下图。笔者的平台环境是 32 位,所以后文中统一以32 做代码演示,实际使用过程中需要判断一下平台版本,然后再选择对应的 ELF 结构体。
ini 复制代码
//将 base_addr 强制转换成Elf_Ehdr格式
Elf32_Ehdr *header = (Elf32_Ehdr *) (base_addr);
  1. 在 Elf_Ehdr 的数据结构中找到程序头表的入口地址,直接取 e_phoff(程序表的偏移地址)+ 库 so 库的基地址即可。找到程序头表的地址后,依然可以强制的转换为 Elf32_Phdr 的数据结构,该数据结构用来描述程序表,并且也是定义在 elf.h 文件中的,参考图3-21。接着我们就可以遍历程序头表的数据结构,找到 p_type 为 PT_DYNAMIC 的段,也就是 .dynamic 段,并拿到该段的地址和大小 ,代码实现如下:
ini 复制代码
size_t phr_count = header->e_phnum;  // 程序头表项个数
Elf32_Phdr *phdr_table = (Elf32_Phdr *) (base_addr + header->e_phoff);  // 程序头部表的地址
unsigned long dynamicAddr = 0;
unsigned int dynamicSize = 0;
for (int i = 0; i < phr_count; i++) {
    if (phdr_table[i].p_type == PT_DYNAMIC) {
        //so基地址加dynamic段的偏移地址,就是dynamic段的实际地址
        dynamicAddr = phdr_table[i].p_vaddr + base_addr;
        dynamicSize = phdr_table[i].p_memsz;
        break;
    }
}
  1. 遍历找到的.dynamic 段,当 d_tag 为 DT_PLTREL,即为指向重定位表(plt 表)的段,通过 d_val 我们就能拿到 plt 表的地址。
ini 复制代码
uintptr_t symbolTableAddr;
Elf32_Dyn *dynamic_table = (Elf32_Dyn *) dynamicAddr;
for (int i = 0; i < dynamicSize; i++) {
    if (dynamic_table[i].d_tag == DT_PLTGOT) {
        symbolTableAddr = dynamic_table[i].d_un.d_ptr + base_addr;
        break;
    }
}
  1. 修改内存属性为可写, 并遍历 .plt.got 表,找到 malloc 函数的地址后,将 malloc 函数地址替换成我们自己的 malloc_hook 函数地址。
ini 复制代码
//读写权限改为可写
mprotect((void *)symbolTableAddr, PAGE_SIZE,PROT_READ|PROT_WRITE);
//目标函数偏移地址
originFunc = 0x3fd4 + base_addr;
//替换的hook函数的偏移地址
uintptr_t newFunc = (uintptr_t) &malloc_hook_by_plt;
int *symbolTable = (int *) symbolTableAddr;
for (int i = 0;; i++) {
    if ((uintptr_t) &symbolTable[i] == originFunc) {
        originFunc = symbolTable[i];
        symbolTable[i] = newFunc;
        break;
    }
}
  1. 在自定义的拦截函数中函数实现想要的逻辑,如对内存申请过大的逻辑打印堆栈,记录so库申请的总内存等,并在函数最后执行原来被替换的函数地址即可,代码如下所示。
scss 复制代码
void *malloc_hook_by_plt(size_t len) { 
    __android_log_print(ANDROID_LOG_DEBUG, "hookMallocByPLTHook", "origin malloc size:%d", len);
    if(len > 20*1024*1024){
        __android_log_print(ANDROID_LOG_DEBUG, "hookMallocByPLTHook", "do somethings");
        //堆栈打印
        printNativeStack();
    }
    // 调用原函数
    return reinterpret_cast<void *(*)(size_t)>((void*)originFunc)(len);
}

运行Demo程序,通过日志,可以看到我们成功的hook了malloc函数

可以看到,这个流程实现起来并没有太复杂,但是其中还有一个问题我们没有解决,在上述流程中 malloc 函数的入口地址为 3fd4,但这个地址并不是固定的,可能每次启动程序都会变化,所以我们要如何知道 malloc 的入口地址都为 3fd4,我们只需要去.rel.plt 表里面去查找就知道了。在前面我们通过三条指令计算出 plt 下一步的跳转地址是 3fd4,而这三条指令之所知道要往这个地址跳转,是因为在 .rel.plt 表中记录了。.rel.plt 表包含了对PLT中的入口进行重定位所需的信息,以及重定位所需的符号信息。通过在汇编代码中查看 .rel.plt表,如图可以看到其中 3fd4 这一项。

.rel.plt 表也在 .dynamic 段中,我们依然可以用 d_tag 为DT_JMPREL来判断,.rel.plt表包含了 malloc 函数在 .got 表中的地址以及 malloc 函数对应的符号。我们可以在遍历.dynamic段时,顺便获取符号表 DT_SYMTAB,实现代码如下:

ini 复制代码
Elf32_Rel *rela;
Elf32_Sym *sym;
size_t pltrel_size = 0;
// 遍历动态信息表,查找DT_JMPREL和DT_PLTRELSZ标签
for (int i = 0; i < dynamicSize; i++) {
    if (dynamic_table[i].d_tag == DT_JMPREL) {
        rela = (Elf32_Rel *)(dynamic_table[i].d_un.d_ptr + base_addr);
        __android_log_print(ANDROID_LOG_DEBUG, "hookMallocByPLTHook", "DT_PLTRELSZ2 size:%d", dynamic_table[i].d_un.d_val);
    } else if(dynamic_table[i].d_tag == DT_PLTRELSZ){
        pltrel_size = dynamic_table[i].d_un.d_val;
    } else if(dynamic_table[i].d_tag == DT_SYMTAB){
        sym = (Elf32_Sym *)(dynamic_table[i].d_un.d_val + base_addr);
    }
}

当我们拿到 .rel.plt表后,遍历该表,并且拿对应的符号去符号表中获取符号的名称信息,如果字符串包含了 malloc 即说明使我们需要寻找的项。代码实现如下:

ini 复制代码
size_t entries = pltrel_size / sizeof(Elf32_Rel);
for (size_t i = 0; i < entries; ++i) {
    Elf32_Rel *reloc = &rela[i];
    size_t symbol_index = ELF32_R_SYM(reloc->r_info);
    // 根据symbol_index获取符号表中的符号信息
    std::string name = getSymbolNameByValue(base_addr,&sym[symbol_index]);
    if(name.find("malloc")!= std::string::npos){
        originFunc = reloc->r_offset + base_addr;
        break;
    }
}

其中 getSymbolNameByValue 中会获取so库的符号表,它位于 symtab 段中,因为我们要将 so 进行段遍历,并找到symtab段( SHT_STRTAB),代码实现如下:

ini 复制代码
std::string getSymbolNameByValue(uintptr_t base_addr , Elf32_Sym *sym) {
    Elf32_Ehdr *header = (Elf32_Ehdr *) (base_addr);
    // 获取段头部表的地址
    Elf32_Shdr *seg_table = (Elf32_Shdr *) (base_addr + header->e_shoff);
    // 段的数量
    size_t seg_count = header->e_shnum;
    Elf32_Shdr* stringTableHeader = nullptr;
    for (int i = 0; i < seg_count ; i++) {
        if (seg_table[i].sh_type == SHT_STRTAB) {
            stringTableHeader = &seg_table[i];
            break;
        }
    }
    char* stringTable = (char*)(base_addr + stringTableHeader->sh_offset);

    std::string symbolName = std::string(stringTable + sym->st_name);
    return symbolName;
}

到这里我们才算真正的完整的实现了 plt hook 的流程,读者可以自己实践一下,加深对流程的理解。

使用开源框架

前面我们通过代码一步步的完成了 PLT Hook 的实现,它原理实际并不是很复杂,但是整个流程有大量的针对 ELF 文件中目标地址的查找,和修改操作,所以想要熟悉整个流程,需要对so 文件格式有较深的掌握,实现起来还是比较繁琐的,稍不注意就会出错,在面对线上环境时,更要做好全面的兼容性和异常的处理。

Naitve hook 是一项很成熟的技术,GitHub 上有很多相关的开源库,所以当我们了解原理和流程后,也并不需要自己去重复造轮子再去实现一套完善的 PLT Hook的库。笔者在这里推荐几个主流的开源 plt Hook 库:

这里笔者以 bhook 这个开源的 plt hook 库为例,来 hook demo 中 testmalloc.so 库的 malloc 函数。我们新建一个 hookmalloc 的 so 库,按照 bhook 提供的 bytehook_hook_single 接口,来实现对 libexample.so 库中的 malloc 函数的 hook,代码实现如下。

arduino 复制代码
Java_com_example_performance_1optimize_memory_NativeLeakActivity_hookMallocByBHook(
        JNIEnv *env,
        jobject thiz) {
    bytehook_stub_t stub = bytehook_hook_single(
            "libexample.so",
            nullptr,
            "malloc",
            reinterpret_cast<void *>(malloc_hook),
            nullptr,
            nullptr);
}

在上层的 activity 进行调用且运行后,通过 log 日志可以看到,我们成功检测到了这一笔 size 为100000000 的内存申请。通过使用三方的开源库,我们可以实现一行代码完成 PLT Hook,并能有较好的稳定性与兼容性。

相关推荐
雾里看山2 分钟前
【MySQL】内置函数
android·数据库·mysql
风浅月明13 分钟前
[Android]页面间传递model列表
android
法迪14 分钟前
Android自带的省电模式主要做什么呢?
android·功耗
风浅月明16 分钟前
[Android]AppCompatEditText限制最多只能输入两位小数
android
没有晚不了安28 分钟前
1.11作业
android
zhangphil31 分钟前
Android Coil3缩略图、默认占位图placeholder、error加载错误显示,Kotlin(1)
android·kotlin
貂蝉空大1 小时前
uni-app开发安卓和ios app 真机调试
android·ios·uni-app
少年芒2 小时前
Leetcode 490 迷宫
android·算法·leetcode
IT猿手3 小时前
2025最新智能优化算法:鲸鱼迁徙算法(Whale Migration Algorithm,WMA)求解23个经典函数测试集,MATLAB
android·数据库·人工智能·算法·机器学习·matlab·无人机
兰琛4 小时前
12.1 Android中协程的基本使用
android