想要拦截并修改so库中的malloc和free函数,就需要使用Native Hook技术,这里介绍一种Native Hook技术——PLT Hook 。
1.PLT Hook技术
程序的运行过程就是一个不断调用和执行函数的过程,而要调用函数就一定要知道这个函数在内存中的地址。Native代码在被打包成so库时,每个函数都会被分配一个偏移地址,因此如果是so库中内部函数之间的相互调用,那么直接通过编译期间给函数分配的偏移地址就能实现。
但如果我们调用的是一个外部so库的函数,那就只能通过该函数在内存中的绝对地址来进行调用了,也就是该外部so库在内存中的首地址+该函数的偏移地址。那么外部函数的调用流程是怎样的呢?这里以示例程序中的example.so库为例。因为malloc函数是位于外部库libc.so中的一个函数,所以example.so库中的代码在调用malloc函数时,会先查找内部的.plt过程链接表(这是一个包含跳转指令的代码段),再通过跳转指令跳转到malloc函数对应的.got表(这是一个包含外部函数地址的数据段,位于.dynamic段中),.got表会记录malloc函数的真实地址,流程如图2-14所示。但是在编译期间,.got表是无法确认malloc函数的地址的,所以初始地址为0。在程序运行过程中,Linker(动态链接器)这个系统程序会在调用malloc函数时,将malloc函数的真实地址写入libexample.so库对应的.got全局偏移表中。
图2-14 外部函数调用跳转流程
什么是.plt表和.got表呢?我们知道so库实际上就是一个ELF格式的文件,里面包含了代码段(.text)、数据段(.data)、BSS段(.bss)、动态段(.dynamic)等多种数据段,在程序运行某个so库时,so库的这些数据段都会被加载进内存中。而.plt表,也就是过程链接表(Procedure Linkage Table),实际上是位于代码段中的一张表,记录了跳转到外部的函数所对应的.got表的代码段。.got表则是位于数据段下面的一张表,记录了外部库函数的地址,而该外部库的函数地址,是在程序运行时,由动态链接器(Linker)这个系统程序回写到.got表中的。
通过Android NDK中自带的objdump工具执行“objdump-D libexample.so”命令,即可查看example.so对应的汇编代码。我们找到示例场景中的mallocLeak函数对应的汇编代码,如图2-15所示。
图2-15 mallocLeak函数对应的汇编代码
通过上面的汇编代码可以看到,地址1ede6对应的汇编指令是“blx 1caec<malloc@plt>”,其中blx是函数调用指令,1caec是函数对应的地址,也就是malloc函数对应的.plt表地址,即<malloc@plt>函数。通过这段指令,对函数malloc的调用便会跳转到对应的.plt表中。
下面看一下malloc函数在.plt表中的汇编代码,如图2-16所示,它包含了3条指令的代码段,分别解释如下。
图2-16 malloc函数的.plt表
❑第一条指令“add ip,pc,#0,12”表示将0左移12位后和pc(Program Counter,程序计数器)寄存器的值相加,并将结果写入ip寄存器中。因为在ARM架构中,pc的值为当前指令的地址加上8字节,所以此时ip寄存器的值为1caec+8=1caf4。
❑第二条指令“add ip,ip,#217088;0x35000”表示将ip寄存器的值加上217 088这个十进制值,该值的十六进制数即为0x35000,并将结果存回ip寄存器中,所以此时ip寄存器的值为1caf4+35000=51af4。
❑第三条指令“ldr pc,[ip,#2432]!;0x980”表示将ip寄存器的值加上2432这个十进制的值,该值的十六进制数即为0x980,并将结果存储在pc寄存器中,所以此时pc寄存器的值为51af4+980=52474。
由上面可知,pc寄存器中的52474就是指令接下来要跳转的地址,此时读者如果对这3条指令不太理解也没关系,待大家学完第3章中要介绍的常用的汇编指令和寄存器后再回头来看就会有更深入的理解了。这里只需要知道接下来会跳转到地址为52474的地方即可。
继续找到52474对应的代码,如图2-17所示,可以看到它位于.got表中,该地址对应的值0001cac0就是malloc函数真正的地址。为什么.got表中所有的数据都是0001cac0这个地址呢?实际上0001cac0对应的地址会跳转到一段动态链接代码段中,当程序运行且调用malloc函数后,这段代码段会调用动态链接器(Linker)将malloc函数真正的地址写进来。
图2-17 malloc函数在.got表中的值
了解了上述外部函数调用的原理后,就可以开始了解PLT Hook技术了。如果我们在程序运行过程中将libexample.so库中的偏移地址(52474)替换成我们自定义的函数的绝对地址,那么该库中所有对malloc函数的调用都会跳转到我们自定义的函数中。当自定义函数的逻辑执行完成,并在该自定义函数的末尾跳转回52474地址原本所记录的地址后,程序便能继续执行原来的malloc函数逻辑,这样便完成了对malloc函数的拦截操作,如图2-18所示。
图2-18 PLT Hook流程
了解了流程和思路,接下来我们就可以通过代码来实现了,主要流程的代码如下。
1)通过逐行读取maps文件,找到并解析出libexample.so的地址。当然,我们也可以通过Linux系统提供的dl_iterate_phdr函数找到libexample.so的基地址。
2)根据设备的平台环境,将获取的so库基地址转换成Elf32_Ehdr或Elf64_Ehd数据结构,该数据结构是ELF文件加载进内存后对应的数据结构,通过#include<linux/elf.h>引入头文件后就能使用该数据结构了,该数据结构的字段详情如图2-19所示。笔者的平台环境是32位,所以后文中统一以32位的数据结构做代码演示,但实际使用过程中需要先判断平台版本,然后再选择对应的ELF结构体。
图2-19 ELF文件头部数据结构
图2-19 ELF文件头部数据结构(续)
3)通过Elf 32_Ehdr的数据结构得到程序头表的入口地址,也就是e_phoff(程序头表的偏移地址)+so库的基地址。得到程序头表的地址后,将其转换为程序头表对应的数据结构Elf32_Phdr,该数据结构也定义在elf.h文件中,如图2-20所示。接着我们就可以遍历程序头表的数据结构,找到p_type为PT_DYNAMIC的段,也就是.dynamic段,并得到该段的地址和大小。
图2-20 程序头表的数据结构
代码如下。
4)遍历找到的.dynamic段,该段的数据结构如图2-21所示,当d_tag为DT_PLTREL,即为指向.plt表的段时,我们就能通过d_val得到.plt表的地址了。
图2-21 .dynamic段的数据结构
代码如下。
5)修改内存属性为可写,并遍历.plt表,根据malloc函数在.got表中的地址(52474+so库的基地址)找到记录在对应的.got表中的值,并将该值替换成我们自定义函数的地址,同时我们需要将该值保存下来,以便在执行完自定义函数后,接着执行该值对应的地址中的命令,也就是malloc函数对应的真正地址处的命令。
6)在自定义的拦截函数中实现想要的逻辑,如打印内存申请过大的逻辑堆栈、记录so库申请的总内存等,并在函数最后执行原来被替换的函数地址,代码如下。
运行Demo程序,通过日志可以看到,我们成功地拦截了malloc函数,如图2-22所示。
图2-22 拦截成功日志
可以看到,这个流程实现起来并不复杂,但是其中还有一个问题我们没有解决。在上述流程中,malloc函数在.got表中的入口地址为52474,但这个地址并不是固定的,可能当so库新增了一些代码并重新打包后,这个地址就发生了变化。所以我们要动态获取malloc函数在.got表中的地址,实际上只需要去.rel.plt表中查找就知道了。前文中,我们通过3条指令计算出.plt下一步的跳转地址是52474,而这3条指令之所以知道要往这个地址跳转,也是因为.rel.plt表中记录了跳转地址。.rel.plt表包含了对.plt中的入口地址进行重定位所需的信息,以及重定位所需的符号信息。在汇编代码中查看.rel.plt表,可以看到其中包含了地址为52474的条目,如图2-23所示。
图2-23 .rel.plt表数据
.rel.plt表也在.dynamic段中,我们可以将d_tag作为DT_JMPREL来判断是不是该表。.rel.plt表包含了malloc函数在.got表中的地址以及malloc函数对应的符号,我们可以在遍历.dynamic段时,顺便获取符号表DT_SYMTAB以及.rel.plt表的大小DT_PLTRELSZ,这两项数据在后面都会用到。代码如下。
当我们得到.rel.plt表后接着遍历该表,并且根据表条目中记录的符号去符号表中获取符号的名称信息。如果名称信息字符串包含了malloc,则说明该条目是我们要找的目标项。代码如下。
在上面的代码中,我们通过条目的索引获取到该条目的符号,但是符号里面都是索引数据,并没有符号名称的数据,所以我们还需要调用getSymbolNameByValue方法并根据该符号去获取对应的符号名称。记录符号名称的表位于.symtab段中,因此我们要对so库进行段遍历,并找到.symtab段(SHT_STRTAB),代码如下。流程中涉及较多符号的知识,如果读者对这部分知识不熟悉,可以先阅读第4章,待对符号有更深入的理解后,再回头看这里的流程。
到这里我们才算真正实现了完整的PLT Hook流程,读者可以按照上面的代码来自己实践,以此加深对流程的理解。
2.使用开源框架
前面我们通过代码逐步实现了PLT Hook的完整流程,它的原理实际上并不复杂,但是整个流程有大量针对ELF文件中目标地址的查找和修改操作,所以想要熟悉整个流程,需要掌握so文件格式,实现过程还是比较烦琐的,稍不注意就会出错。特别是在面对线上环境时,我们更要做好全面的兼容和异常处理。
Naitve Hook是一项很成熟的技术,GitHub上有很多相关的开源库,所以当我们了解了原理和流程后,并不需要自己去重复造轮子,再去开发一套完善的PLT Hook库。bhook、profilo等开源库都是稳定可靠的开源PLT Hook库。
这里以bhook这个开源的PLT Hook库为例,来演示Hook示例程序中对testmalloc.so库中malloc函数的拦截。通过bhook提供的bytehook_hook_single接口,便能轻松实现对libexample.so库中malloc函数的拦截,代码如下。
在上层的Activity调用该方法后,通过日志可以看到,我们成功检测到这个大小为100 000 000B的内存申请,如图2-24所示。通过使用第三方的开源库,我们仅用数行代码便完成了PLT Hook,并有更好的稳定性与兼容性保障。
图2-24 bhook执行成功的日志