对C++服务基于JIT模式的二进制无侵入式动态插桩研究
1. 背景介绍
从Google在2010年发布论文介绍Dapper分布式追踪系统以来,业界对于动态链路追踪已经研究了很长时间。现在也诞生了许多链路追踪相关的框架,如SkyWalking,OpenTelemetry,CAT,ZipKin等。但是他们中的大部分都是基于侵入式埋码,需要业务方对业务代码进行修改;而可以进行无侵入式的框架则只能实现对于Java、Python等语言编写的业务进行动态追踪,却对C++服务无能为力。
2. 二进制无侵入式动态插桩
现有的二进制动态插桩工具主要是DynamoRIO和Intel Pin。他们的主要实现方式均为JIT(Just In Time),这种技术将待运行或待接管的目标进程视为数据,然后对其中的指令进行实时翻译,并写入code cache中,在实际运行过程中原进程不参与实际的运行,而是运行code cache中的代码。在执行到非code cache缓存内容时,将会将控制权转移给DBI,然后重复上述过程。
通过这种技术,可以在没有虚拟机抽象层的语言上实现类似JVMTI运行时动态修改字节码的功能,而这正是对进程进行无侵入式动态插桩的关键所在。
3. DynamoRIO与Intel Pin对比
DynamoRIO |
Intel Pin |
|---|---|
开源,MIT License |
闭源,ISSL License |
both Intel and ARM |
only Intel |
采用code cache机制完全控制代码流 |
采用修改源码的机制,code cache仅用于实现对插桩程序的透明 |
| 用户编写的工具对插桩有完全的控制权 | 由Pin对插桩代码进行内联和优化 |
| 对插桩程序性能损失相对较低 | 对插桩程序性能损失相对较高 |
| 抽象层次少 | 抽象层次相对清晰 |
| 学习与编写工具难度高 | 学习与编写工具难度低 |
4. Windows下初步探索DynamoRIO与Intel Pin的使用
-
DynamoRIO -
Intel Pin -
由于二者原理接近,
DynamoRIO为开源软件且MIT协议更加友好,后续使用DynamoRIO继续研究。
5. 通过DynamoRIO向插桩程序插入函数调用
概念说明:在
DynamoRIO中,被插桩程序被称为Application,编写的用于控制插桩的程序被称为Client。
5.1 向Application中插入对Client的调用
- 在
Application函数调用前后插入回调可以使用drwrap拓展。其中drwrap_wrap和drwrap_wrap_ex主要用于在函数前后插入回调,drwrap_replace和drwrap_replace_native(实验性)主要用于直接替换函数。 - 需要向
Application任意位置插入回调可以使用DynamoRIO原生API。使用较多的为dr_insert_clean_call和dr_insert_clean_call_ex,他们提供了在任意元指令前保存Application上下文状态,并调用回调callee的接口。
5.2 向Application中插入对Application的调用
5.2.1 探索:使用drsym扩展获取Application函数指针后直接在Client中调用
读者可以自行尝试复现或在“[DynamoRIO]Windows下初探drsyms”基础上进行修改,需要注意部分API是不具有平台可移植性的。
与开源作者进行沟通后,笔者得知在Client中与Application共享代码是不安全且违反DynamoRIO透明性的。Application代码或函数只应该在Application上下文中运行。当然,如果读者需要的功能允许Client单独加载一套所需使用的Application库,那可以参考上文链接中开源作者所说的使用DynamoRIO提供的私有加载器。笔者由于能力有限,没有在这个思路上继续探索,如果有读者在这个方向上有所收获,欢迎相互交流。
5.2.2 探索:通过Compiler Explorer等获取调用函数所需的汇编代码,插入汇编语句实现调用
在Compiler Explorer中输入以下代码,我们可以得到其编译产生的汇编代码。这里笔者选择的是x86-64 gcc 11.4编译器,c++11标准。
#include <cstdio>
#include <string>
#include <iostream>
using namespace std;
class A {
public:
virtual string getName() const {
return "A::getName called";
}
};
class B : public A {
public:
B(const string& name)
: name(name) {}
string getName() const override {
return "B::getName called: " + name;
}
static A* getInstance(string name) {
static A* instance = new B(name);
return instance;
}
private:
string name;
};
int testSumLength(const string& a, const string& b) {
return a.size() + b.size();
}
int main() {
A* instance = B::getInstance("Single");
instance->getName();
instance->A::getName();
cout << "total length: " << testSumLength(instance->getName(), instance->A::getName()) << endl;
return 0;
}
函数的具体实现我们可以直接跳过,看main函数里的调用部分。
A* instance = B::getInstance("Single");对应的汇编代码如下:
在调用B::getInstance("Single")前,首先会调用string("Single")构造临时对象。根据STL实现,我们可以知道这个构造函数需要传入三个参数:this,char*和std::allocator<char>&。this表示构造的位置,这里string的实现需要32个字节大小的空间。前面有一个push rbx将RBP减少了8,所以给RDI寄存器传入的内容是栈顶[RBP-208]。第二个参数是构造用的char*,所以传给ESI的是"Single"字面量的地址。第三个参数是std::allocator<char>&,根据实现,这是一个空对象,所以可以直接选择了栈里的一个位置[RBP-161]传入。在[RBP-161]处调用构造函数不是必要的。
调用B::getInstance需要传入的是刚才构造的临时对象,所以给RDI寄存器传入[RBP-208],即前文构造的临时对象的this地址。
函数返回值保存在RAX寄存器中,将B::getInstance返回值保存到栈区变量instance内,即[RBP-24]的位置。
后面的两次调用是对临时对象string和std::allocator<char>&进行析构,不再赘述。
instance->getName();instance->A::getName();两句的汇编代码如下:
第一个函数调用instance->getName();需要注意的是getName()是一个虚函数,instance是一个基类指针,所以call后跟的不是前文见到的函数名,而是RCX寄存器。call前进行的操作大部分为取得虚表地址及虚表中getName()地址的操作,由于使用DynamoRIO drsym扩展可以直接获取到指定的函数地址,不需要这样访问虚表,所以这里不对取函数地址的操作做解释。
从第二个函数调用instance->A::getName();也可以看到,当明确指定调用哪个函数时,不需要取虚函数地址,而是直接call对应函数即可。
那么当我们需要做一些调用时,就需要将相应语句对应的汇编代码插入到调用位置。需要注意的是,调用可能会破坏寄存器,所以在调用前需要插入push或采用其他DynamoRIO提供的方式保存寄存器。而且当我们需要使用一些临时变量等做保存操作时,也需要注意调整堆栈指针RSP。在调用结束后需要手动恢复上下文,避免堆栈和寄存器被破坏。
这里提供一份Application与Client的样例,读者可以自行复现。如果平台、架构、版本等不一致,可能需要手动修改诸如libstdc++.so.6,函数签名等参数。
- 样例
5.2.3 解决方案:通过在Client中定义调用函数所需参数,仅插入调用相关汇编语句实现调用
通过与开源作者的进一步沟通,笔者得知这个需求已经在2014年提出issues,但是至今没有相关贡献。所以寄托于DynamoRIO有相关的顶层API是不现实的了。
根据issues 497和issues 758中作者提供的思路,笔者选择自行实现一套相关的API用以实现该需求。
通过上述资料笔者得知,Application可以访问到Client的内存空间,所以通过在Client中定义参数可以大大减少在Application中添加调用的复杂度。
- 样例