单元测试中的插桩技术

1. 背景
在编写C/C++代码的过程中经常遇到需要给代码做单元测试的情况,以此验证代码的逻辑是否符合设计的要求。 但是在进行单元测试的过程我们需要对一些外部或者内部的API的行为做一些定制。比如我们在测试过程中需要模拟 malloc 函数分配内存失败的场景,这时候我们应该怎么办呢?又比如我们需要在使用 socket 接口的时候使其返回指定的数据包,那又应该如何做呢?
1.1 常用的方式
在写C/C++代码的时候,我们需要改变API或者是接口的既定行为时通常是使用开关宏来实现的,在不同的运行场景中运行不同的代码 例如我们在实现一个对外接口的时候如果使用到了malloc 函数,然后我们单元测试时候需要测试到malloc失败的场景 代码实现如下:
|
|
在上面的例子中,如果我们需要改变 malloc 接口的一些行为,那么我们只能使用宏替换的方式去将malloc接口替换成我们自己实现的接口。这样咋看起来好像好像没什么太大的问题,又不是不能用 。但是当你细细查看的时候会发现,如果整个代码实现中malloc 有多处被引用,但是我们又不想改变除单侧接口外的 malloc 引用的行为,这时候我们发现好像没有比较优雅的实现方式。这时你可能会说,我们为何不在 get_new_buffer 接口中 malloc 引用的地方使用宏开关来指定单元测试时的实现呢?
1.2 使用插桩的单侧方式
还是上面小节的例子
|
|
这样做代码是不是清晰很多,而且不影响其他的接口测试。那么这种方式具体是怎么实现的呢,且看下节
2. 各种插桩方式实现的原理
插桩顾名思义,那就是在原地插入一个桩函数,桩函数就是我们自己实现的自定义函数。实现这种插桩的方式目前有三种 分别是 GOT/PLT Hook, Trap Hook , Inline Hook
2.1 GOT/PLT hook
在ELF文件中,数据被组织为各个段(section)。.text 保存的就是我们实现的代码部分,.plt 段就是函数链接表,保存的是 .text 段中使用到动态链接的函数地址; .got 段保存是全局偏移表
它们之间的关系如下图
普通绑定调用:
lazy binding:
这时候如果我们需要hook目标函数需要做的两件事就是:注入我们的自定义的函数;将 .got 中的目标函数重定向到我们的自定义函数
2.2 Trap hook
Trap 就是用户进程中的异常。 这是由除零或无效的内存访问引起的。 这也是调用内核接口(系统调用)的常用方法,因为它们的运行优先级高于用户代码。
详细参考: ptrace
Signal backtrace and INT3 for linux
2.3 inline hook
在汇编层面,所有的函数的都有一个入口地址,函数调用就是使用跳转指令跳转到这个地址。那在入口地址的地方加入一些汇编指令使其跳转到其他函数入口地址,这就是inline hook的原理。
要完成上述的跳转我们还需要知道一些汇编层面函数调用的细节。所有的函数都有一个入口地址,其他地方调用这个函数的时候就是使用跳转指令跳转到这个地址的。参数传入传出时是通过寄存器完成的(参数多的时候也会通过栈传递)。
函数调用整个过程就是
- 将参数依次按顺序放入寄存器中, 第1个参数放入寄存器R0中,第2个参数放入R1中,依此类推
- 保存跳转指令下一条指令的地址A1(即函数调用完成后的返回地址),然后使用跳转指令跳转到函数入口地址
- 函数最后将返回值放入寄存器R0中, 然后跳回A1地址
- 调用方从寄存器R0取得函数返回值
- 调用结束
这时候为了完成inline hook, 我们需要在跑到函数入口地址处时保存当前寄存器内容以及栈顶位置还有就是返回地址,然后跳转到指定的函数,最后调用完后后恢复寄存器的值以及返回地址还有栈顶位置。
3. 结语
不同的插桩方式各有优缺点,也各有适用的场景, 如下:
条目 | GOT/PLT hook | Trap hook | Inline hook |
---|---|---|---|
实现层面 | 函数级别 | 指令级别 | 指令级别 |
适用范围 | 有局限 | 广 | 广 |
性能 | 高 | 低 | 高 |
实现难度 | 中 | 中 | 高 |