在 C++ 编程世界里,虚拟函数是一项强大且重要的特性,它为多态性的实现提供了关键支持。然而,当涉及到逆向工程时,虚拟函数却给开发者带来了不少挑战。今天,我们就深入探讨如何对 C++ 虚拟函数进行逆向工程,尤其是在大型企业级代码库的复杂环境下。
在互联网上,已经有不少关于 C++ 逆向工程的讨论,其中或多或少都会涉及到虚拟函数的处理。但对于包含成千上万的类和庞大类型层次结构的大型代码库,处理虚拟函数需要一些特定的技巧,这正是我们今天要探讨的重点。在开始前,先明确一些前提条件:我们的代码是在没有启用运行时类型信息(RTTI)且禁用异常的情况下编译的,以 32 位 x86 平台为例,并且二进制文件已经被剥离了符号信息。需要注意的是,不同编译器对虚拟函数的实现细节并不统一,这里我们主要关注 GCC 编译器的行为。一般来说,我们处理的二进制文件是通过g++ -m32 -fno-rtti -fnoexceptions -O1 file.cpp编译,再用strip命令剥离符号信息得到的。
逆向工程的目标
在大多数情况下,我们很难在逆向工程中 “去虚拟化” 一个虚拟函数调用。因为实现这一操作所需的信息直到运行时才会出现。所以,我们在逆向工程虚拟函数时的主要目标是确定在特定点可能被调用的函数。后续,我们还会进一步探讨如何缩小这些可能性范围。
虚拟函数的实现原理
假设你对 C++ 编程有一定了解,但可能对其底层实现机制不太熟悉。让我们先来看看编译器是如何实现虚拟函数的。假设有这样一组类定义:
class Mammal {
public:
virtual void run() = 0;
virtual void walk() = 0;
virtual void move() = 0;
virtual ~Mammal() = 0;
};
class Cat : public Mammal {
public:
void run() override { std::cout << "Cat::run\n"; }
void walk() override { std::cout << "Cat::walk\n"; }
void move() override { std::cout << "Cat::move\n"; }
~Cat() override { std::cout << "Cat::~Cat\n"; }
};
class Dog : public Mammal {
public:
void run() override { std::cout << "Dog::run\n"; }
void walk() override { std::cout << "Dog::walk\n"; }
void move() override { std::cout << "Dog::move\n"; }
~Dog() override { std::cout << "Dog::~Dog\n"; }
};
以及使用这些类的代码:
int main() {
Mammal* m;
if (rand() & 1) {
m = new Cat();
} else {
m = new Dog();
}
m->walk();
delete m;
return 0;
}
在这段代码中,m到底是Cat还是Dog取决于rand()函数的返回值,编译器在编译时无法确定这一点,那么它是如何调用正确的函数呢?答案是,对于每个包含虚拟函数的类型,编译器会在生成的二进制文件中插入一个函数指针表,称为虚函数表(vtable)。每个该类型的对象都会有一个额外的成员,即虚指针(vptr),它指向该对象对应的正确 vtable。在构造函数中,会添加代码将这个指针初始化为正确的值。
当编译器需要调用一个虚拟函数时,它只需访问对象 vtable 中的正确条目并调用相应的函数。这就意味着,相关类型的 vtable 中条目的顺序必须一致,比如每个类的run函数可能在索引 1 处,每个walk函数在索引 2 处,以此类推。
所以,我们可以预期在二进制文件中找到Mammal、Cat和Dog的三个 vtable。通过在.rodata段中查找相邻的函数偏移量,能快速定位它们。在查看反编译后的main函数时,可以看到:
1 int _cdecl main()
2 {
3 _DWORD *v0; //ebx@2
4 if (rand() &1)
5 {
6 v0=(_DWORD *) operator new (4u);
7 sub_80488C4 (v0);
8 }
9 else
10 {
11 v0 =(_DWORD *) operator new (4u);
12 sub_80488F0 (v0);
13 }
14 if (v0) (* (void (_cdecl **) (_DWORD *))(*v0 +12)) (v0) ;
15 (*(void(_cdecl **) (_DWORD *)) (*v0 +4)) (v0) ;
16 return 0;
17 }
可以看到,在两个分支中都分配了 4 个字节的内存,这是因为对象结构中唯一的数据就是编译器添加的 vptr。在第 14 行和第 16 行,能看到虚拟函数的调用。在第 14 行,编译器通过解引用(获取 vptr)并加上 12 来访问 vtable 中的第 4 个条目;第 16 行则获取 vtable 中的第 2 个条目,然后调用从表中获取的函数指针。
再查看sub_函数的内容,我们发现walk函数对于Dog和Cat的定义分别是sub_80487AA和sub_804877E。通过排除法,___cxa_pure_virtual函数必然属于Mammal的 vtable,因为Mammal没有walk函数的定义,当函数是纯虚函数时,GCC 会插入这些 “pure_virtual” 条目。由此可知,第一个 vtable 属于Mammal对象,第二个属于Cat,第三个属于Dog。
这里有个有趣的现象,每个 vtable 中有 5 个条目,但实际上只有 4 个虚拟函数(run、walk、move和析构函数)。多出来的这个条目是一个额外的析构函数。这是因为 GCC 会根据不同情况插入多个析构函数,第一个析构函数仅销毁对象的成员,第二个析构函数还会释放为对象分配的内存(在示例代码的第 16 行调用的就是这个版本),在某些虚拟继承的情况下,可能还会有第三个版本。
进一步查看sub_函数的内容,我们得到 vtable 的布局如下:
| Offset | Pointer to |
|--------+-------------|
| 0 | Destructor1 |
| 4 | Destructor2 |
| 8 | run |
| 12 | walk |
| 16 | move |
不过要注意,Mammal表的前两个条目是零。这是较新版本 GCC 的一个特性,当类中有纯虚函数(即抽象类)时,编译器会将析构函数条目替换为 NULL 指针。
定义结构辅助分析
了解了这些之后,定义一些结构来辅助我们的分析会很有帮助。Mammal、Cat和Dog结构中唯一的成员就是它们的 vptr,所以可以这样定义:
struct Mammal {
void* vptr;
};
struct Cat {
void* vptr;
};
struct Dog {
void* vptr;
};
接下来,为每个 vtable 创建一个结构。这样做的目的是让反编译器输出在m为特定类型时实际会调用的函数。我们可以遍历这些可能性并检查所有选项。
创建的 vtable 结构成员将与相应的函数名对应,如下所示:
struct MammalVtable {
void (*null1)();
void (*null2)();
void (*pure_virtual)();
void (*pure_virtual2)();
void (*Mammal_move)();
};
struct CatVtable {
void (*Cat_destructor1)();
void (*Cat_destructor2)();
void (*Cat_run)();
void (*Cat_walk)();
void (*Mammal_move)();
};
struct DogVtable {
void (*Dog_destructor1)();
void (*Dog_destructor2)();
void (*Dog_run)();
void (*Dog_walk)();
void (*Mammal_move)();
};
需要将每个结构中 vptr 的类型设置为相应的Vtable类型,例如Cat的 vptr 类型应该是CatVtable*。同时,将每个 vtable 条目的类型设置为函数指针,比如Dog__run元素的类型应该是void (*) (Dog*),这样能帮助 IDA 正确显示信息。
回到反编译后的main函数,如果将局部变量重命名为m,并将其类型设置为Cat*或Dog*,就可以清晰地看到在调用点可能调用的函数。如果m是Cat类型,第 15 行将调用Cat__walk;如果是Dog类型,则会调用Dog__walk。
如果将m的类型设置为Mammal*,会发现问题。在代码中,如果m的实际类型是Mammal,第 15 行将调用一个纯虚函数,这在实际中是不应该发生的。第 17 行还会调用一个空指针,显然会导致程序出错。所以可以得出结论,m的实际类型不可能是Mammal。虽然m在代码中被声明为Mammal*,但这是编译时类型(静态类型),我们关注的是m的动态类型(运行时类型),因为动态类型决定了在虚拟函数调用中实际调用的函数。实际上,对象的动态类型永远不可能是抽象类型。所以,如果一个给定的 vtable 包含___cxa_pure_virtual函数,那么它就不是一个可能的类型,可以忽略它。虽然我们本可以不创建Mammal的 vtable 结构,因为它永远不会被使用,但了解其中的原因有助于我们更好地理解整个机制。
以上就是 C++ 虚拟函数逆向工程的基础知识。在实际的大型代码库中,情况会更加复杂,涉及更多的类、更复杂的继承关系和更多的虚拟函数。但掌握了这些基础原理,我们就有了进一步探索的基石。在后续的文章中,我们将深入探讨如何在更复杂的场景中处理虚拟函数的逆向工程,包括处理多重继承、虚拟继承以及在大型代码库中快速定位和分析虚拟函数等内容。希望通过今天的分享,能让大家对 C++ 虚拟函数的逆向工程有更深入的理解,在面对相关问题时能够更加得心应手。如果你在学习或实践过程中有任何疑问或心得,欢迎在评论区留言分享。
科技脉搏,每日跳动。
与敖行客 Allthinker一起,创造属于开发者的多彩世界。
- 智慧链接 思想协作 -