对C++服务基于JIT模式的二进制无侵入式动态插桩研究

· · 个人记录

1. 背景介绍

Google2010年发布论文介绍Dapper分布式追踪系统以来,业界对于动态链路追踪已经研究了很长时间。现在也诞生了许多链路追踪相关的框架,如SkyWalkingOpenTelemetryCATZipKin等。但是他们中的大部分都是基于侵入式埋码,需要业务方对业务代码进行修改;而可以进行无侵入式的框架则只能实现对于JavaPython等语言编写的业务进行动态追踪,却对C++服务无能为力。

2. 二进制无侵入式动态插桩

现有的二进制动态插桩工具主要是DynamoRIOIntel 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的使用

5. 通过DynamoRIO向插桩程序插入函数调用

概念说明:在DynamoRIO中,被插桩程序被称为Application,编写的用于控制插桩的程序被称为Client

5.1 向Application中插入对Client的调用

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实现,我们可以知道这个构造函数需要传入三个参数:thischar*std::allocator<char>&this表示构造的位置,这里string的实现需要32个字节大小的空间。前面有一个push rbxRBP减少了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]的位置。

后面的两次调用是对临时对象stringstd::allocator<char>&进行析构,不再赘述。

instance->getName();instance->A::getName();两句的汇编代码如下:

第一个函数调用instance->getName();需要注意的是getName()是一个虚函数,instance是一个基类指针,所以call后跟的不是前文见到的函数名,而是RCX寄存器。call前进行的操作大部分为取得虚表地址及虚表中getName()地址的操作,由于使用DynamoRIO drsym扩展可以直接获取到指定的函数地址,不需要这样访问虚表,所以这里不对取函数地址的操作做解释。

从第二个函数调用instance->A::getName();也可以看到,当明确指定调用哪个函数时,不需要取虚函数地址,而是直接call对应函数即可。

那么当我们需要做一些调用时,就需要将相应语句对应的汇编代码插入到调用位置。需要注意的是,调用可能会破坏寄存器,所以在调用前需要插入push或采用其他DynamoRIO提供的方式保存寄存器。而且当我们需要使用一些临时变量等做保存操作时,也需要注意调整堆栈指针RSP。在调用结束后需要手动恢复上下文,避免堆栈和寄存器被破坏。

这里提供一份ApplicationClient的样例,读者可以自行复现。如果平台、架构、版本等不一致,可能需要手动修改诸如libstdc++.so.6,函数签名等参数。

5.2.3 解决方案:通过在Client中定义调用函数所需参数,仅插入调用相关汇编语句实现调用

通过与开源作者的进一步沟通,笔者得知这个需求已经在2014年提出issues,但是至今没有相关贡献。所以寄托于DynamoRIO有相关的顶层API是不现实的了。

根据issues 497issues 758中作者提供的思路,笔者选择自行实现一套相关的API用以实现该需求。

通过上述资料笔者得知,Application可以访问到Client的内存空间,所以通过在Client中定义参数可以大大减少在Application中添加调用的复杂度。

5.2.4 展望:在Client中实现一套可以自动生成对应汇编代码的API,或实现将Client汇编代码转换后插入Application的API。