转载: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 | struct stub { |
3.3.2 设置内存的保护属性
在Linux下,mprotect()函数可以用于更改指定内存区域的保护属性。原型为:
1 | int mprotect(void *addr, size_t len, int prot); |
同样,在Windows下,采用读取系统的页大小:
1 |
|
计算内存页的起始地址:
1 | static inline void *pageof(const void* p) |
3.3.3 安装桩函数
计算原函数与桩函数地址的偏移量,将原函数的入口5字节指令替换为jmp [offset],同时缓存原本的5字节,用于后续的恢复。
jmp指令对应0xE9,后续为4字节的偏移量。
1 | int install_stub(void *orig_f, void *stub_f) |
3.3.4 卸载桩函数
将原函数开始的5字节恢复,并释放stub结构
1 | int uninstall_stub(void *stub_f) |
上述代码只包含了关键代码,部分声明省略,并未对异常情况进行处理。
4 进程内存安全
这里的函数打桩利用jmp指令将原有的函数入口跳转到了我们指定的桩函数入口,自然会想到在非法情况下,也利用jmp将原本的函数跳转到指定的函数去。其实这也是一些病毒的做法,跳转到非法代码;或者一些软件破解的方法,将原本的验证机制跳转屏蔽。只不过我们打桩时是在同一进程内部进行了跳转,且是在获取源码到情况下进行的。而在破解和攻击时,一般只有可执行文件的二进制代码,此时可以通过反汇编得到程序的汇编指令,并找到需要修改的函数入口。在攻击时,也存在另外一个问题,就是跨进程的权限,在进程外对进程的内存进行修改。
操作系统对内存的保护,采取了虚拟地址空间(进程独立的虚拟内存),内存起点的随机偏移量(不能找到代码段段入口)等方式。而且mprotect()仅可以对同一进程的内存块进行修改。如果想要修改其他进程的内存块的保护属性,就要从两个方面下手,一是变成同一个进程,另一个则是对mprotect()动手脚。
当成同一个进程,可以使用代码注入,让我们的攻击代码从进程自身发出,则我们的代码就有了通过mprotect改变进程保护属性的权限。
而对mprotect()做手脚,即实现我们自己的mprotect()函数,让其模仿内核函数的行为,但是却拥有跨进程的能力。这就要编写一个类似于mprotect()函数功能的内核模块。
具体的操作暂时还没有深入了解,原理如上述所示。
5 Cunit的使用
CUnit是一个C语言的单元测试框架,以静态链接库的形式,连接到用户代码中。提供了语义丰富的断言和多种测试结果输出接口,可以方便地生成测试报告。可以结合 gcov/lcov等生成测试覆盖度报表。
5.1 结构
Cunit的结构基本如下图所示,分为三层,第一层是总的测试入口,第二层是测试包,第三层是测试用例。然后通过一系列的断言展示测试结果。
5.2 基本流程
- 编写待测函数对应的测试函数(如果必要,需要写suite的init/cleanup函数)
- 初始化Test Registry - CU_initialize_registry()
- 把测试包(Test Suites)加入到Test Registry - CU_add_suite()
- 把测试用例(Test Case)加入到测试包当中 - CU_add_test()
- 使用适当的接口来运行测试测试程序,例如 CU_console_run_tests()
- 清除Test Registry - CU_cleanup_registry()