函数打桩

转载stub

1 定义

函数打桩,就是将一个函数用另一个函数实现(桩代码)进行替换,以达到在原有函数入口的位置执行新的实现。函数打桩类似于Windows系统中的钩子Hook,不过Hook针对的是事件,这里针对的是函数,或者说是函数的入口。在做C的Cunit单元测试时,遇到了这样的概念,在此梳理一下。

2 目的

函数打桩的目的一般是隔离、占位和控制,这里是参考其他博主的文章进行的大概分类,其实也不是很严格。

隔离就是将复杂的函数从程序中隔离,比如一个复杂的嵌套函数,如果只想要知道前几步的运行结果,则可以在调用下一层函数时替换一个简单的实现,甚至直接return。在进行流程的测试时比较常见。

占位就是对一些未实现的函数进行占位,多见于协同开发中,对于其他人完成的函数可以先使用空函数进行占位。

控制就是将原本的函数功能进行替换,控制流程。如在测试中,替换函数部分内容得到需要的结果,以进行单元测试。或者对一些系统函数进行替换,实现自己的功能。如常见的将内存分配的函数替换为自己重写的内存池分配等,这里就是一个函数Hook。

3 方法

3.1 编译时打桩

用宏定义#define,在预处理时进行字符串替换,将原函数定义成桩函数的形式。同时可以使用条件编译来控制编译选项。

3.2 链接时打桩

将桩函数定义到新的库文件中,并在原代码基础上增加条件编译选项,屏蔽原有的库,采用桩函数库。

3.3 运行时打桩

运行时打桩是对内存的应用,我们知道程序的函数是在代码段中存储,一个函数的操作对应一个栈帧的存储地址,如果在调用函数时,在一旦访问这个栈帧,我们就使它跳转到我们需要的桩函数去,那么也就实现了函数的打桩。这种方法要复杂一点,但是不需要对原有的代码进行修改,而是额外增加了打桩和还原的操作,在进行单元测试时也常用。

简单来说,就是读取到原函数指令的地址,并读取桩函数的地址,并使用jmp命令从原函数跳转到桩函数去,以实现打桩。

一个完整的打桩流程应该分为装载和卸载,因此需要做到的是记录原函数的函数指针,记录桩函数的函数指针,计算地址差值,调用jmp指令,完成桩函数的装载。使用完成后,再删除jmp指令,恢复原函数。此间由于操作系统对于进程的保护机制,可能存在对于进程内存的解锁和权限设置,将指定内存区域设定为可读可写可操作的权限。

3.3.1 数据结构

设定一个链表用于存储原函数、桩函数之间的对应关系,使用链表是为了动态分配同时记录多个函数。数据结构如下:

1
2
3
4
5
6
7
struct stub {
struct stub *node;
void *orig_f; //原函数的函数指针
void *stub_f; //桩函数的函数指针
unsigned int old_flg; //存储原有内存的权限
unsigned char assm[5]; //用于暂存原函数起始的5字节指令,用于被jmp指令(0xE9 + 4bytes地址)覆盖后恢复
};

3.3.2 设置内存的保护属性

在Linux下,mprotect()函数可以用于更改指定内存区域的保护属性。原型为:

1
2
3
4
int mprotect(void *addr, size_t len, int prot);
//addr为内存起始地址,必须是内存页的起始地址
//len为内存区域长度,以字节为单位,必须是页大小的整数倍
//prot为权限值,类似于Linux的权限值

同样,在Windows下,采用读取系统的页大小:

1
2
#include <unistd.h>
long pagesize = sysconf(_SC_PAGESIZE);

计算内存页的起始地址:

1
2
3
4
static inline void *pageof(const void* p)
{
return (void *)((unsigned long)p & ~(pagesize - 1));
}

3.3.3 安装桩函数

计算原函数与桩函数地址的偏移量,将原函数的入口5字节指令替换为jmp [offset],同时缓存原本的5字节,用于后续的恢复。

jmp指令对应0xE9,后续为4字节的偏移量。

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
int install_stub(void *orig_f, void *stub_f)
{
//初始化stub数据结构
struct stub *pstub = calloc(1, sizeof(struct stub));
pstub->orig_f = orig_f;
pstub->stub_f = stub_f;

//设置内存保护属性
if (-1 == mprotect(pageof(orig_f), pagesize * 2, PROT_READ | PROT_WRITE | PROT_EXEC))
{
perror("mprotect to w+r+x faild");
exit(errno);
}

//缓存原函数orig_f头部的5字节指令
memcpy(pstub->assm, pstub->orig_f, sizeof(pstub->assm));
//将头部重写为jmp [offset]
*((char*)pstub->orig_f) = 0xE9;
offset = (unsigned int)((long)pstub->stub_f - ((long)pstub->orig_f + 5));
*((unsigned int*)((char*)pstub->orig_f + 1)) = offset;

//如果有多线程同时操作,加锁
//lock();

//如果对多个函数打桩,维护struct stub 链表
//list_add(&ptsub->node, &head);

//unlock();
}

3.3.4 卸载桩函数

将原函数开始的5字节恢复,并释放stub结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int uninstall_stub(void *stub_f)
{
//从桩函数链表中找到要释放的桩函数对应的数据结构
struct stub *pstub = NULL:
pstub = find_pstub(stub_f);

//设置内存保护属性
mprotect(pageof(pstub->orig_f), pagesize * 2, PROT_READ | PROT_WRITE | PROT_EXEC);

//恢复原函数的初始5字节指令
memcpy(pstub->orig_f, pstub->assm, sizeof(pstub->assm));

//删除节点
del_node(pstub);
}

上述代码只包含了关键代码,部分声明省略,并未对异常情况进行处理。

4 进程内存安全

这里的函数打桩利用jmp指令将原有的函数入口跳转到了我们指定的桩函数入口,自然会想到在非法情况下,也利用jmp将原本的函数跳转到指定的函数去。其实这也是一些病毒的做法,跳转到非法代码;或者一些软件破解的方法,将原本的验证机制跳转屏蔽。只不过我们打桩时是在同一进程内部进行了跳转,且是在获取源码到情况下进行的。而在破解和攻击时,一般只有可执行文件的二进制代码,此时可以通过反汇编得到程序的汇编指令,并找到需要修改的函数入口。在攻击时,也存在另外一个问题,就是跨进程的权限,在进程外对进程的内存进行修改。

操作系统对内存的保护,采取了虚拟地址空间(进程独立的虚拟内存),内存起点的随机偏移量(不能找到代码段段入口)等方式。而且mprotect()仅可以对同一进程的内存块进行修改。如果想要修改其他进程的内存块的保护属性,就要从两个方面下手,一是变成同一个进程,另一个则是对mprotect()动手脚。

当成同一个进程,可以使用代码注入,让我们的攻击代码从进程自身发出,则我们的代码就有了通过mprotect改变进程保护属性的权限。

而对mprotect()做手脚,即实现我们自己的mprotect()函数,让其模仿内核函数的行为,但是却拥有跨进程的能力。这就要编写一个类似于mprotect()函数功能的内核模块。

具体的操作暂时还没有深入了解,原理如上述所示。

5 Cunit的使用

CUnit是一个C语言的单元测试框架,以静态链接库的形式,连接到用户代码中。提供了语义丰富的断言和多种测试结果输出接口,可以方便地生成测试报告。可以结合 gcov/lcov等生成测试覆盖度报表。

5.1 结构

Cunit的结构基本如下图所示,分为三层,第一层是总的测试入口,第二层是测试包,第三层是测试用例。然后通过一系列的断言展示测试结果。

图片名称

5.2 基本流程

  1. 编写待测函数对应的测试函数(如果必要,需要写suite的init/cleanup函数)
  2. 初始化Test Registry - CU_initialize_registry()
  3. 把测试包(Test Suites)加入到Test Registry - CU_add_suite()
  4. 把测试用例(Test Case)加入到测试包当中 - CU_add_test()
  5. 使用适当的接口来运行测试测试程序,例如 CU_console_run_tests()
  6. 清除Test Registry - CU_cleanup_registry()

6 参考

wangwencong-认识单元测试中的打桩

守望-库打桩机制-偷梁换柱

一种C语言”打桩”的源码实现

Covfefe-深入Linux | 如何在任意进程中修改内存保护(含PoC)

陈令祥-CUnit测试工具