虚函数(Virtual Function)是C++中实现多态的一种机制,它允许在运行时通过基类的指针或引用调用派生类中的函数,而不是基类中的版本。虚函数通常与继承和多态结合使用。通过在基类中使用 virtual
关键字声明函数,允许派生类重写该函数。当通过基类指针或引用调用时,根据对象的实际类型决定调用哪个版本的函数;它依赖虚函数表(vtable)实现,用于动态绑定,常用于多态场景下的接口设计和扩展。
虚函数表的布局
每个包含虚函数的类都有一张虚函数表:
如果类没有派生类,则虚函数表存储类自身的虚函数地址。 如果类有派生类,并且派生类中重写了基类的虚函数,则派生类的虚函数表中会存储派生类的函数地址。
现有如下程序:
#include <iostream>
using namespace std;
class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }virtual ~Base() { cout << "Base::destructor func2" << endl; } // 虚析构函数
};
class Derived : public Base {
public:void func1() override { cout << "Derived::func1" << endl; }void func2() override { cout << "Derived::func2" << endl; }virtual ~Derived() { cout << "Derived::destructor func2" << endl; } // 虚析构函数
};
int main() {Base* obj = new Derived();obj->func1(); // 调用 Derived::func1obj->func2(); // 调用 Derived::func2delete obj;return 0;
}
以下是该程序中的基类、派生类的虚函数表在内存中的大致结构:
基类的虚函数表:
索引 | 虚函数地址 |
---|---|
0 | Base::func1() 地址 |
1 | Base::func2() 地址 |
派生类的虚函数表:
索引 | 虚函数地址 |
---|---|
0 | Derived::func1() 地址 |
1 | Derived::func2() 地址 |
每个包含虚函数的对象会有一个隐藏的虚指针(vptr),指向该对象所属类的虚表。而vptr
通常存储在对象内存布局的开头部分(具体位置依赖于编译器实现)。
现使用VS
将上述代码进行编译,生成exe文件后,放入x96dbg
中进行调试分析。
定位到main
函数后,对反汇编函数进行查看,跳过初始化指令:
后续代码则为程序主体代码;接着就针对第一部分指令进行分析。
push 4
call virtual-func.7F012D
add esp,4
在x32dbg
中双击call
指令调用的virtual-func.7F012D
函数,可以看到该函数已经被识别为new
运算符的实现;在 C++ 中,new
运算符用于动态分配内存,其底层实现通常会调用 operator new
,负责分配指定大小的内存块。
此时该代码的反汇编解释为:push 4
:表示分配内存的大小(Derived
类大小为 4 字节);
Derived 继承了 Base,且包含虚函数,此时在对象中则保存了指向虚表(vtable)的指针大小为4字节,且因为 Base 类没有数据成员;Derived 类也没有额外的数据成员。因此,Derived 对象的大小只包括一个虚指针(vptr) 的大小,所以分配内存大小为4.
call virtual-func.7F012D
:调用分配内存的函数(即 operator new
或 malloc
);add esp, 4
:调用结束后清理参数栈。接着持续跟进代码,可以发现:new
运算符实际上就是调用了底层分配函数 malloc
。
在 x86 和 x64 架构下,new
运算符调用内存分配函数(如 operator new
或 malloc
)后,分配的内存地址(即对象指针)通常存放在返回值寄存器中。具体存放的位置取决于目标架构:
x86 架构:返回值寄存器:EAX
在 x86 的调用约定中,函数的返回值通常存放在 EAX寄存器中。因此,当调用 new 或类似的内存分配函数时,分配的内存地址(即指针)会存放在 EAX 中。
x64 架构:返回值寄存器:RAX
在 x64 的调用约定中,函数的返回值通常存放在 RAX 寄存器中;当调用 new 运算符时,分配的内存地址会存放在 RAX 中。
后续代码解释:
mov dword ptr ss:[ebp-D4],eax
cmp dword ptr ss:[ebp-D4],0
je virtual-func.803E9E
xor eax,eax
mov ecx,dword ptr ss:[ebp-D4]
mov dword ptr ds:[ecx],eax
mov ecx,dword ptr ss:[ebp-D4]
call virtual-func.7ED79D
mov dword ptr ss:[ebp-F4],eax
jmp virtual-func.803EA8
mov dword ptr ss:[ebp-F4],0
mov edx,dword ptr ss:[ebp-F4]
mov dword ptr ss:[ebp-8],edx
mov eax,dword ptr ss:[ebp-8]
mov edx,dword ptr ds:[eax]
mov esi,esp
mov ecx,dword ptr ss:[ebp-8]
mov eax,dword ptr ds:[edx]
call eax
这段汇编代码展示了一个典型的 C++ 对象创建、初始化和调用操作的流程。它可能涉及到动态内存分配 (new
)、构造函数调用,以及对象的虚函数表 (vtable
) 操作。以下是逐行的分析和解释:
mov dword ptr ss:[ebp-D4],eax
cmp dword ptr ss:[ebp-D4],0
je virtual-func.803E9E
mov dword ptr ss:[ebp-D4],eax
:将 eax
的值(返回的对象指针)存储到 [ebp-D4]
,这通常是一个局部变量用于保存对象的内存地址。
cmp dword ptr ss:[ebp-D4],0
:检查对象指针是否为 nullptr
。
je virtual-func.803E9E
:如果对象指针为 nullptr
(分配失败),跳转到错误处理或清理代码。如果对象指针不为 nullptr
则继续往下执行。
xor eax,eax
mov ecx,dword ptr ss:[ebp-D4]
mov dword ptr ds:[ecx],eax
xor eax,eax
:将寄存器 eax
置为 0
mov ecx,dword ptr ss:[ebp-D4]
:将对象的指针加载到 ecx
,准备操作。
mov dword ptr ds:[ecx],eax
:将 eax
(值为 0)写入对象的第一个成员变量(通常是虚表指针 vptr
或类的其他重要数据)。
接下去进行构造函数的调用:
mov ecx,dword ptr ss:[ebp-D4]
call virtual-func.7ED79D
mov ecx,dword ptr ss:[ebp-D4]
:将对象指针加载到 ecx
,作为调用构造函数的上下文(在 C++ 中,构造函数隐式地以对象指针为 this
参数)。
call virtual-func.7ED79D
:调用构造函数的实际实现(地址 7ED79D
),完成对象的初始化。
存储构造完成的对象指针
mov dword ptr ss:[ebp-F4],eax
jmp virtual-func.803EA8
mov dword ptr ss:[ebp-F4],0 ;运行时被跳过
mov dword ptr ss:[ebp-F4],eax
:将构造函数返回值(eax
,可能是同一个对象指针)存储到 [ebp-F4]
。接着可以通过eax
中的地址在内存中定位(右击寄存器窗口中的eax
进行跳转即可),查看对象;可以看到在构造函数完成后,对象中的第一个元素则成为虚表地址。
准备虚函数调用
mov edx,dword ptr ss:[ebp-F4]
mov dword ptr ss:[ebp-8],edx
mov eax,dword ptr ss:[ebp-8]
mov edx,dword ptr ds:[eax]
mov edx,dword ptr ss:[ebp-F4]
:将对象指针从 [ebp-F4]
加载到 edx
。
mov dword ptr ss:[ebp-8],edx
:保存对象指针到 [ebp-8]
,可能是另一个局部变量。
mov eax,dword ptr ss:[ebp-8]
:将对象指针加载到 eax
。
mov edx,dword ptr ds:[eax]
:加载对象的虚表指针 vptr
到 edx
。
调用虚函数
mov esi,esp
mov ecx,dword ptr ss:[ebp-8]
mov eax,dword ptr ds:[edx]
call eax
mov esi,esp
:保存当前栈指针 esp
到 esi
,通常用于记录调用现场或检查栈空间。
mov ecx,dword ptr ss:[ebp-8]
:将对象指针加载到 ecx
,作为虚函数调用的 this
参数。
mov eax,dword ptr ds:[edx]
:从虚表中加载虚函数的地址到 eax
。
call eax
:调用虚函数,eax
中保存了函数的实际地址。
此时,派生类的虚函数成功运行输出。在虚函数调用完毕后会进行栈状态检查:
接着进行第二个虚函数的调用,具体步骤与第一个虚函数步骤相似,相关代码如下:
mov eax,dword ptr ss:[ebp-8]
mov edx,dword ptr ds:[eax]
mov eax,dword ptr ss:[ebp-8]
:从 [ebp-8]
加载一个指针到 eax
,[ebp-8]
通常是存储的对象指针(即 this
指针)。
mov edx,dword ptr ds:[eax]
:从对象指针 eax
的第一个成员变量中加载虚表指针(vptr
)
mov esi,esp
mov ecx,dword ptr ss:[ebp-8]
mov eax,dword ptr ds:[edx+4]
mov esi,esp
:将当前的栈指针 esp
保存到 esi
;这一步是为了后续检查栈的状态。
mov ecx,dword ptr ss:[ebp-8]
:将对象指针从 [ebp-8]
加载到 ecx
,在 C++ 的调用约定中,ecx
通常用来传递 this
指针给成员函数。
mov eax,dword ptr ds:[edx+4]
:从虚表指针 edx
中偏移 4
字节加载函数地址到 eax
,在虚表中,edx
指向的是一张函数地址表,偏移 4
字节可能是虚表中第二个虚函数的地址。
call eax
:调用 eax
中存储的地址,这实际上是虚表中的函数地址。此时第二个虚函数调用成功:
同样的,调用成功以后也是一个栈检查:
后续代码:
mov eax,dword ptr ss:[ebp-8]
mov dword ptr ss:[ebp-EC],eax
mov ecx,dword ptr ss:[ebp-EC]
mov dword ptr ss:[ebp-E0],ecx
cmp dword ptr ss:[ebp-E0],0
je virtual-func.803F20
这段代码的作用是从栈中加载、保存对象指针,并进行空指针检查。通过这些操作,程序可以安全地访问对象,避免在没有有效对象时进行操作。
接着就是以同样的手法,调用了虚析构函数。
跟进函数中分析(通过edx
中的函数指针地址,跳转至对应的反汇编代码),最后除了派生类的虚析构函数之外还执行了包含基类的析构函数。
在本文中,我们深入探讨了虚函数在编译后产生的内存布局、调用过程以及在逆向分析中的解读方法。通过对关键实现细节的拆解和案例分析,相信读者能够更清晰地理解虚函数的工作机制以及如何在逆向工程中定位相关特征。希望本文能为逆向分析虚函数的学习者提供启发和指导。如果您有任何疑问或见解,欢迎交流讨论,共同探索更深层次的技术细节!