前言
根据前面两篇文章所介绍的ELF文件的结构。如何实现native hook已经初具雏形,但是如果你仔细思考会发现一个盲点,那就是我们分析ELF文件时,是存盘存储的格式,但是native hook显然应该是运行时hook,那么运行时的状态的ELF文件是怎样的呢?对我们实现hook有什么影响呢?
操作系统的一些背景知识
虚拟内存
我们都知道程序是运行在内存中的,早期程序确实都直接运行在同物理内存中,但这会导致很多问题,最大的问题是在同一个物理内存空间中,程序A可能直接会恶意修改程序B某些地址的数据,导致很大的安全隐患。因此今天操作系统的的程序基本都运行在一个虚拟内存空间中。
什么是虚拟内存
那么什么是虚拟内存呢?简单点讲就是在程序和物理内存之间,加一个中间层(计算机程序问题的经典解决方案)。每个程序不再直接接触物理内存,而是在一个叫做虚拟内存的中间层上运行,而且每个程序有自己独立的内存空间。当然这么说其实不准确。因为虚拟内存不仅仅与物理内存交互,实际上也和部分磁盘空间进行交互。因此虚拟内存实际上是把程序的运行内存进行了抽象。让程序运行在这个抽象层,而实现层到底是物理内存还是磁盘空间对程序而言无法感知。
要始终记住,虚拟内存是完全虚拟的,人为假设,实际不存在的。就像圣诞老人一样,只有小孩(用户程序)才相信圣诞老人的存在,真正给你袜子里塞礼物的是你的父母(物理内存空间)。
什么是地址空间
操作系统分配的内存空间的地址的集合就叫地址空间,比如操作系统分配了4GB的内存大小,那么意味着这个内存大小所包含的地址一共是2^32(次方)个,我们也称这个地址空间为32位地址空间。当然我们这是反过来说了,属于因果倒置。真正决定给程序最大分配多少内存实际上是计算机硬件决定的。
scss
{0,1,2,3,4,5...(2^32-1)} // 地址集合
当我们说计算机是32位还是64位,一般指的是CPU的寻址能力,即一次性能读取32位还是64位,与地址空间也有关联,如果CPU只能读取32位,那么CPU的最大寻址能力就是4GB,如果一个程序用了5GB的空间,那么超过4GB的部分的地址cpu就读不了了。所以32位的计算机一般只给程序分最高4GB的内存空间。
虚拟地址到物理空间的映射
关于虚拟地址空间与物理空间的映射,我们最直接设想的当然是虚拟地址与物理地址一对一(一个字节地址对一个字节地址)进行映射,把它们的映射关系存在一表里。但是这样非常消耗存储空间,4GB的内存有2^32个地址,就有2^32个映射关系,如果这么存,那物理内存空间全拿来存映射表都不够。
既然映射表不能存太多映射关系,那么自然想到分块映射了,这个在操作系统里叫分页映射,也就是把内存空间分成固定大小的页,比如4GB,如果4M作为一页,那么只需要存储1024对页之间映射关系,这时所消耗的内存空间就小很多了,而页内部的对应关系就通过页偏移的一致来实现。这就是页映射的概念。
要实现页映射就需要把地址的表示方式设计为"页号:页内偏移"的形式,先通过虚拟地址的页序号找到找到映射的物理地址的页序号,然后再找该物理页内的对应的偏移,即为虚拟地址映射的物理地址了。
这个页号的位数以及页内偏移的位数和操作系统的CPU寻址能力以及页大小相关,而且为了进一步缩小页的数量,操作系统实现的是多级页表,具体的计算在此不做赘述,理解概念为主,可以看《深入理解计算机系统》虚拟内存相关的章节。
程序如何在虚拟内存中运行
介绍了上面的基本概念之后,我们需要了解一下程序是如何在虚拟内存中运行的?
1,创建一个独立的虚拟地址空间。 此时并没有开辟任何真正的物理空间,相当于操作系统给程序开了一张4GB的空头支票:你尽管跑,我给你准备了4GB的空间哦(并没有)。
2,(内核)读取ELF文件头,并且建立虚拟空间与可执行文件的映射关系。 建立虚拟空间与elf文件的映射关系,其实就是建立虚拟空间与存储elf文件的磁盘地址之间的映射关系。但不是把整个文件都映射起来,而是通过读取ELF header,把ELF文件中可导入的段(Loadable Segment)与虚拟内存空间的地址建议映射关系。可以参考前面文章中的PHT表的介绍。
3,将CPU的指令寄存器设置成可执行文件的入口地址,启动运行 我们都知道ELF header中有程序入口地址,也就是代码段的首位地址。
4,操作系统交还控制权给程序,此时程序读取虚拟地址处的代码段,CPU通过这个虚拟地址找不到对应的物理地址(因为映射的是磁盘文件,内存中肯定找不到),此时CPU会抛出一个缺页错误,然后控制权交还给操作系统,进入中断处理程序,内核找到磁盘中对应的位置的数据,把它读入物理内存,然后建立虚拟地址空间与这个物理内存之间的映射关系,然后让程序从刚才的位置重新开始执行。
多个进程共享对象
我们都知道C代码可以生成.o后缀的可执行文件,也可以生成.so的动态链接库,以及.a的静态库。这三者的文件结构没有什么本质区别,因此前文中并没有单独介绍.so和.a文件。我们在这里对这两个依赖库做个简单介绍,它们都可以被可执行程序依赖,区别在于.a文件会被打包进可执行文件内,属于程序私有的。而so文件不是,它可以被许多不同的可执行文件共享,也没有被打包进程序文件内。从内存利用率的角度看,so库更好。
实际上C语言的程序确实的依赖了动态链接库,它们会在运行时都会载入它们所依赖到呃so文件。比如之前文章中隐约出现的libc.so库,这个是大部分程序运行时都会依赖的基础库,所以大家都会在在运行时载入它。由于libc.so在不同程序时共享的,内存中只有一份,会极大节省内存空间。
如何共享
那么我们现在要问了,so库是如何实现多个进程之间共享的呢?
答案就是我们刚才介绍的虚拟内存空间。
我们就以libc.so为例,假设此前内存中并没有导入过这个库,而此时打开的程序A依赖了这个库,那么操作系统就会先寻找内存中是否已经载入了这个库,如果没有,就在磁盘中找到libc.so库读到物理内存中的某个位置(当然也是分页读取的,假设为0x1200),然后为该程序的虚拟地址(oxff00)和这个物理地址(0x1200)建立映射关系,后续如果执行0xff00地址的代码时,CPU会把他翻译成0x1200的真实地址去执行,程序不关心背后的翻译过程。
这时又打开了一个程序B,发现B也依赖了这个库,于是操作系统先查找已加载的库中是否有符合要求的,发现了libc就在0x1200处,于是就让B程序的虚拟地址0xaa00与0x1200建立映射关系。
最终的状态大概是这样
如何隔离
我们也知道ELF文件中能载入内存的不仅仅有那种只读的段,还有那种可读写的段,这也就意味着这部分段的数据可以被修改,而一旦数据被修改之后,其他程序(进程)映射到这个部分是否就产生同步产生了变化呢?如果是这样的话那么会带来很多风险,毕竟进程程序之间相互隔离,对于自己映射进来的共享库部分数据突然发生改变不符合预期。
因此so共享库的共享是有限度的,针对那些可读写的,比如数据段,其实是进程会拥有独立的副本,这部分属于进程的私有区域。
大概是怎么样实现数据部分的隔离的呢?依赖的是一种写时赋值(copy-on-write)的技术,当程序A和程序B只是简单依赖了libc.so时,他们共享libc.so的数据部分,但是当程序A尝试对数据段进行写入时,会触发保护故障,保护故障处理程序会把数据段部分复制到一个新的物理内存区域,然后让程序A的虚拟地址重新映射到该新区域。
那么此时的内存状态大概是这样:
后续程序B尝试写入时也是同样的处理方式。
程序如何重定位共享对象的导出符号
我们前面说过,一个ELF文件里既有导入符号又有导出符号。 导入符号是ELF文件中引入的外部符号(变量或者函数等) 导出符号是ELF内部定义的符号,可以被外部使用。
我们站在程序A的视角,它会可能使用libc.so的printf函数,这就属于导入了libc.so的导出符号。printf函数定义在libc.so中,所以libc中是存在printf函数在库中的偏移地址的(我们假设printf函数在libc中的偏移地址是0x34)。
但是程序在编译链接阶段无法获知printf的真实调用地址。因为libc.so并没有被打包进程序,所以无法提前获知这个printf的偏移地址,就无法给printf设置一个相对偏移地址。
程序A会在运行时动态链接它所以来的动态链接库,而此时操作系统会协助把动态链接库libc.so读入内存中(假设就读到物理地址0x1200处),同时映射到程序A所在进程的虚拟内存空间(就像上一节讲的那样,假设就读到了地址0xFF00处)。此时libc中的printf函数所在的地址就确定了,就是so库映射的基地址+so文件中写好的偏移地址之和,也就是oxff00+ox34 = 0xFF34处。
此时,对程序A来说就完成了printf函数的重定位。然后根据我们前面文章对elf文件的分析,就会把这个重定位的地址更新到.rela.plt表所指向的.got或者.got.plt表中的对应地址处,后续再调用printf函数,就可以通过got或者got.plt里记录的地址直接调用到地址0xFF34处。而当指令传递到CPU,CPU需要翻译地址,就会根据映射表找到真实的物理地址也就是0x1200+0x34=0x1234处。完成整个函数的调用。
由于可执行程序和动态链接库的文件结构基本一致,所以上面说的重定位过程在.o可执行程序和.so动态链接库都是适用的。
而且由于动态链接库被大量运用,逻辑更多的放入动态链接库中,程序本身反而成为空壳,重定位可能更多的发生在so库中。
这就像是插件一样。
程序的hook到底做了什么
我们常说的native hook,一般是指是hook 程序的函数。仍然以libc.so的printf为例,假设程序A(A.o)被启动,然后调用了主要逻辑库logic.so执行业务逻辑,在logic.so中,使用了libc.so的printf函数。
我们想要hook一下 logic.so中对printf的调用,那么我们要怎么做?
当然是重新写一个函数myprintf,然后定义一个hookPrintf函数,用于替换掉logic.so中rela.plt中记录的printf函数的重定位地址,把它指向我们自己定义的函数myPrintf,然后再我们在即定义的函数中调用printf就可以完成整个hook过程了,把我们自己的函数打包成new.so库(或者写在A.o程序里也行),然后让程序先调用一下hookPrintf函数。
此时如果你思考了这个过程,或许会有些疑惑:如果我们改动了重定位表中printf函数所指向的重定位地址的函数调用,那么在我们自己的myprintf函数内调用printf怎么确保它会正确调用到libc.so的正确位置呢?
主要的原因是可执行程序programA.o,logic.so,libc.so他们都是独立的ELF文件,在进程中会有自己独立的内存区域,他们的段不会进行合并,修改logc.so中的got表记录的地址不贵改变programA.o中记录的printf地址,因为这两个ELF文件的got表是独立的。
我想如果我们能大致的描绘下在内存中的可执行程序调用链接库函数,以及链接库函数之间的调用方式或许可以更清楚的解释这样的疑惑
正常情况下,我们启动程序A,然后发现程序A依赖了logic.so库,然后系统准备加载这个库,但系统发现logic.so还依赖了libc.so。所以先加载libc.so,加载完logic的所有依赖之后,再加载logic.so,然后程序就可以正常的使用了。那么这时programA.o,logic.so,libc.so这三个ELF文件在内存中是怎样的呢?
那么按照我们前面所述的hook过程之后,内存中的情况是怎样的呢?
可以看到,当我们在programA.o中hook了logic中记录的printf的地址之后,我们仍然可以在programA中正常的调用到printf函数,因为programA中记录的printf没有被修改过。
查看运行时程序的地址
即使有前面的示意图帮助大家辅助理解程序运行时的内存分布情况,但是如果没有真正看见可能仍然觉得心里不踏实,一般来讲我们可以使用dl_iterate_phdr函数来查询运行时的共享库分布情况,我们可以在原来的源文件中添加如下代码:
ini
#define _GNU_SOURCE
#include<stdio.h>
#include <link.h>
#include <stdint.h>
#include <stdlib.h>
static int callback(struct dl_phdr_info *info, size_t size, void *data)
{
char *type;
int p_type;
printf("Name: \"%s\" (%d segments)\n", info->dlpi_name,
info->dlpi_phnum);
printf("base_addr: %14p \n",info->dlpi_addr);
for (size_t j = 0; j < info->dlpi_phnum; j++) {
p_type = info->dlpi_phdr[j].p_type;
type = (p_type == PT_LOAD) ? "PT_LOAD" :
(p_type == PT_DYNAMIC) ? "PT_DYNAMIC" :
(p_type == PT_INTERP) ? "PT_INTERP" :
(p_type == PT_NOTE) ? "PT_NOTE" :
(p_type == PT_INTERP) ? "PT_INTERP" :
(p_type == PT_PHDR) ? "PT_PHDR" :
(p_type == PT_TLS) ? "PT_TLS" :
(p_type == PT_GNU_EH_FRAME) ? "PT_GNU_EH_FRAME" :
(p_type == PT_GNU_STACK) ? "PT_GNU_STACK" :
(p_type == PT_GNU_RELRO) ? "PT_GNU_RELRO" : NULL;
printf(" %2zu: [%14p; memsz:%7jx] flags: %#jx; ", j,
(void *) (info->dlpi_addr + info->dlpi_phdr[j].p_vaddr),
(uintmax_t) info->dlpi_phdr[j].p_memsz,
(uintmax_t) info->dlpi_phdr[j].p_flags);
if (type != NULL)
printf("%s\n", type);
else
printf("[other (%#x)]\n", p_type);
}
return 0;
}
/******** 以上添加 **********/
int global_value = 17;
int global_value2 = 0xffeebbaa;
void sayWords(){
printf("hello owrld from C \n");
printf("number: %d %d \n",global_value,global_value2);
}
int main(){
sayWords();
dl_iterate_phdr(callback, NULL); // 调用dl_iterate_phdr函数
return 0;
}
经过编译链接之后,生成新的x.o文件,然后运行该文件
yaml
$ ./x.o
hello owrld from C
number: 17 -1131606 // 之前的打印信息
Name: "" (13 segments) // 主程序的13个segment
base_addr: 0x55e449eae000
0: [0x55e449eae040; memsz: 2d8] flags: 0x4; PT_PHDR
1: [0x55e449eae318; memsz: 1c] flags: 0x4; PT_INTERP
2: [0x55e449eae000; memsz: 6a0] flags: 0x4; PT_LOAD
3: [0x55e449eaf000; memsz: 421] flags: 0x5; PT_LOAD
4: [0x55e449eb0000; memsz: 22c] flags: 0x4; PT_LOAD
5: [0x55e449eb1da8; memsz: 278] flags: 0x6; PT_LOAD
6: [0x55e449eb1db8; memsz: 1f0] flags: 0x6; PT_DYNAMIC
7: [0x55e449eae338; memsz: 30] flags: 0x4; PT_NOTE
8: [0x55e449eae368; memsz: 44] flags: 0x4; PT_NOTE
9: [0x55e449eae338; memsz: 30] flags: 0x4; [other (0x6474e553)]
10: [0x55e449eb00f8; memsz: 44] flags: 0x4; PT_GNU_EH_FRAME
11: [0x55e449eae000; memsz: 0] flags: 0x6; PT_GNU_STACK
12: [0x55e449eb1da8; memsz: 258] flags: 0x4; PT_GNU_RELRO
...
...
Name: "/lib/x86_64-linux-gnu/libc.so.6" (14 segments) // 依赖的libc.so中的14个segment
base_addr: 0x7f5f7b200000
0: [0x7f5f7b200040; memsz: 310] flags: 0x4; PT_PHDR
1: [0x7f5f7b3e3e30; memsz: 1c] flags: 0x4; PT_INTERP
2: [0x7f5f7b200000; memsz: 27fe0] flags: 0x4; PT_LOAD
3: [0x7f5f7b228000; memsz: 1944c1] flags: 0x5; PT_LOAD
4: [0x7f5f7b3bd000; memsz: 578cc] flags: 0x4; PT_LOAD
5: [0x7f5f7b4158f0; memsz: 12560] flags: 0x6; PT_LOAD
6: [0x7f5f7b418bc0; memsz: 1d0] flags: 0x6; PT_DYNAMIC
7: [0x7f5f7b200350; memsz: 30] flags: 0x4; PT_NOTE
8: [0x7f5f7b200380; memsz: 44] flags: 0x4; PT_NOTE
9: [0x7f5f7b4158f0; memsz: 90] flags: 0x4; PT_TLS
10: [0x7f5f7b200350; memsz: 30] flags: 0x4; [other (0x6474e553)]
11: [0x7f5f7b3e3e4c; memsz: 70cc] flags: 0x4; PT_GNU_EH_FRAME
12: [0x7f5f7b200000; memsz: 0] flags: 0x6; PT_GNU_STACK
13: [0x7f5f7b4158f0; memsz: 3710] flags: 0x4; PT_GNU_RELRO
...
...
从上面打印的信息中我们观察主程序的所有segment所分布的地址是0x55e449eae000------0x55e449eb1db8+file_size;而libc.so所在的地址为0x7f5f7b200000------0x7f5f7b418bc0+file_size,显然他们都有自己独立的内存区块,这也印证了我们说法。
当然除了印证一下我们的说法之外,还有一个重要的原因,那就是这能帮助我们找到特定共享库的基地址。比如本例中libc.so就加载在base_addr=0x7f5f7b200000处,有了这个基地址,再加上我们之前从elf文件可以读到的偏移地址,就能确定某个位置在运行时的真实地址,这样才能完成hook的工作。
我们发现操作系统会把程序的所有的段都映射到虚拟内存空间中。
总结
本来想梳理一下native hook的一些前置的基础知识,但是写到这里发现篇幅已经有点长了,因此把代码实现放到下一篇来讲,这一篇就当作原理梳理吧:我们先解释了操作系统的虚拟内存的基本概念,然后在此基础上介绍了在虚拟内存空间中ELF可执行程序时如何运行的,以及它在运行时的状态,再基于以上导出native hook的基本原理(其实目前准备介绍的只算是native hook的一种方式),在概念上相信native hook的实现已经比较容易理解了。