C++ 逆向工程:分析类的多态与继承
前言
你是否在使用IDA分析没有PDB的C++程序时,对下面反编译出的产物摸不着头脑,对虚函数调用感到匪夷所思,这篇文章将讲清楚如何深入探究C++的类内调用、类多态与继承的源代码级别还原,读者需要有一定C++基础。

工具与设备
编译器:MSVC v143 toolchain.
IDE:Visual Studio 2022.
操作系统:Windows11 25H2 专业版 (26200.8039).
IDA版本:9.3.251224 Windows x64.
编译选项:Debug, x64.
源代码
Derived继承了Base,override了Base类的虚函数VirtualFuncA。
#include <iostream>
class Base {
public:
int base_data = 0x11111111;
virtual void __stdcall VirtualFuncA() {
std::cout << "Base::VirtualFuncA" << std::endl;
}
virtual void __stdcall VirtualFuncB(int x) {
std::cout << "Base::VirtualFuncB: " << x << std::endl;
}
void NormalMemberFunc(int a, int b) {
this->base_data = a + b;
}
static void StaticFunc(int s) {
std::cout << "Static: " << s << std::endl;
}
};
class Derived : public Base {
public:
int derived_data = 0x22222222;
virtual void __stdcall VirtualFuncA() override {
std::cout << "Derived::VirtualFuncA" << std::endl;
}
};
int main() {
Derived test_;
Derived* p = &test_;
p->VirtualFuncA();
Derived* d = new Derived();
Base* b = d;
d->NormalMemberFunc(0xAAAA, 0xBBBB);
b->VirtualFuncA();
b->VirtualFuncB(0xCCCC);
Derived::StaticFunc(0xDDDD);
std::cout << "Object Address: " << d << std::endl;
std::cout << "Vtbl Address (First 4 bytes): " << *(intptr_t*)d << std::endl;
delete d;
return 0;
}什么是虚表
这里推荐一篇介绍虚表的文章:深入理解C++ 虚函数表
在本文中,你只需要知道:
父类的vtbl指针和子类共用一个,都是结构体的第一个成员(void*),但是这个指针指向的vtbl不同,父类的指向父类的,子类的指向子类的。
父类和子类的vtbl中,子类应该有父类的所有虚函数,且子类有可能会更多,并且如果子类进行了同名称的override,对应位置的函数应该是override后的。
非虚函数不会出现在vtbl中,即它也不会被override,只会被hidden。即:如果父类和子类都有一个非虚函数A且不论函数签名是否相同,调用时父类的非虚函数A会被屏蔽掉,只能在子类中看到子类的非虚函数A,如果要调用,需要强制转换为父类指针进而调用。
开始分析
找到main函数
找到main函数是逆向开启的第一步,否则我们要在编译器生成的辅助函数中盯上很久。由于程序是MSVC Debug下编译的,Debug模式下,IDA无法直接找到main函数,原因有以下几个,不过我们可以从start函数找起,然后找到下面的函数,我们都知道main函数的返回地址一般情况下是程序的退出码,我们就可以看到exit的传参变量就是main函数的返回值,然后我们进入返回Code的函数,追溯Code的来源,就可以找到main函数,然后把该函数改名为main,IDA即可自动识别出。
当然,还有其他方案可以找到main函数,比如对我们写的代码中的字符串进行关键字查找并跳转,或者是在Visual Studio 2022中开启调试,在main函数入口打断点,然后切换到汇编,看它的地址,进而在IDA中跳转到该地址。

增量链接
编译器会给程序预留“增量链接“(/INCREMENTAL),也就是调用一个函数,会先跳到一个中转站,再从中转站跳到要访问的真实函数。
这个机制允许我们对代码进行动态修补(HotFix),但同时也干扰了IDA的分析。
仅我的代码
在Debug模式下,默认开启CheckForDebuggerJustMyCode,这是方便在调试时,如果用户深入调试库函数,调试器允许用户可以回到“我写的代码”,这时,程序就跑起来了,然后等到调试器检测到CheckForDebuggerJustMyCode函数调用时,暂停,以达到跳过库函数,快速返回用户的代码的目的。
这个机制会导致每个用户会干涉的函数头都有一个CALL CheckForDebuggerJustMyCode,干扰了IDA分析。
运行时错误检查
Debug下默认开启,也即/RTC,这个机制会把栈拓展,并在合法栈外写0xCCCCCCCC,在函数结尾时,会检查这些值是否被改变,如果被改变,说明缓冲区溢出,程序报错。经过测试,当栈内分配对象时,这个机制才会启用。
.text:00000001400177A0 push rbp
.text:00000001400177A2 push rdi
.text:00000001400177A3 sub rsp, 1C8h
.text:00000001400177AA lea rbp, [rsp+20h]
.text:00000001400177AF lea rdi, [rsp+1D0h+var_1B0]
.text:00000001400177B4 mov ecx, 3Ah ; ':'
.text:00000001400177B9 mov eax, 0CCCCCCCChmain函数分析
回到了开头,我们看到了IDA生成的ugly的伪代码,我们一步一步分析。
RTC
首先,映入眼帘的即是运行时错误检查的反编译代码,因为我们在栈上分配了Derived对象,所以它产生了,我们忽略即可。

JMC
然后我们分析sub_1400114D8,这是一个因为增量链接而产生的间接跳转,它最终指向了这个函数

这个就是CheckForDebuggerJustMyCode的具体实现了,调试器通过Hook GetCurrentThreadId来实现相应功能。
你可以手动编译一个程序,加载pdb,就可以知道这个函数的精确实现
经过简单修复,这个函数应该是这样的:

栈上对象初始化
下来就是
sub_1400113D4->sub_140012280
sub_140011145->sub_140011145



可以看到Derived类初始化,并且初始化指向的内存在栈上,这也符合了源代码中我们的_test。
初始化时,首先调用子类的constructor,然后在子类初始化前,子类的constructor会调用父类的constructor,父类会进行初始化,赋值vtbl,并且初始化内部的变量、对象,完成后子类接着正式初始化,先把父类的vtbl换成自己的,然后初始化子类的变量、对象,注意子类含有父类的所有成员变量,所以是(a1+16)开始的。
理解 __cppobj
由于C++对象是默认以一个类内最大的基本结构的大小为对其的,我们的类中,最大的基本结构是一个vtbl指针,在64位下是8字节,所以Base类0~7字节是vtbl指针,8~11字节是(a1+8)的base_data,然后由于要保证整个结构体8字节对齐,所以其大小还要再扩展到16字节。
所以Derived结构体可以想象为第一个参数是Base,第二个参数才开始是int derive_data,而且Derived也是8字节对齐(不是16字节,因为Base不是基本数据结构),所以它会再pad上4个字节,整体长度达到24字节。
创建数据结构
由于我们理解了C++的对象在内存中的分布原理,我们可以创建IDA的结构体以便我们进行代码阅读。
我们先创建基类的数据结构,对a1右键->"Create new struct type...",即可弹出下面的窗口。

我们不要急着确定,IDA默认align(4),我们改成align8或者把整个替换成__cppobj关键字。
然后我们先把vtbl设置为void*,之后我们再修改,最后加上base_data这样的int即可。

可以看出,IDA非常智慧的显示了这个Base的大小,正好16字节,符合我们__cppobj的定义。

下一步,创建Derived的数据结构。
我们先把这个改成void*,因为IDA会自动判定数据类型,然后我们右键Base继续创建数据结构。

有两种创建方案,笔者喜欢用第二种,更加符合C++语法,不过低版本IDA仅支持第一种,其实它们是等价的,IDA会把第二种自动接到Base的后面。


这样我们的函数就非常好看了。

回到1main函数,可以看出是直接解引用vtbl,然后进行了函数调用,传入的参数是this指针,使用rcx传递,即__fastcall,this指针默认为非静态函数的第一个参数。如果是32位就是ecx传递,不过32位下是thiscall,其他参数是通过stdcall的栈传递,并由被调用者清理堆栈。

vtbl的识别
我们回到constructor函数,先看Base的虚函数表,双击进去。

非常明显的两个函数指针,不过我们要使用数据结构来把他们放入Base结构体里面,以便于我们直接能够生成可以看清楚的伪代码。
我们先把这两个函数分析一下,拿到它们的函数签名。


void __fastcall Base_VirtualFuncA(Base *base);
void __fastcall Base_VirtualFuncB(Base *base, unsigned int a2);
小细节:C++的内置函数,当运算符重载时,定义都非常长,不过我们可以直接给他们改名,IDA就会自动识别出来。
"std::operator<<<std::char_traits<char>>" 命名为"??$?6U?$char_traits@D@std@@@std@@YAAEAV?$basic_ostream@DU?$char_traits@D@std@@@0@AEAV10@PEBD@Z"
"std::endl<char,std::char_traits<char>>" 命名为 "??$endl@DU?$char_traits@D@std@@@std@@YAAEAV?$basic_ostream@DU?$char_traits@D@std@@@0@AEAV10@@Z"
"operator new" 命名为 "??2@YAPEAX_K@Z"
"operator delete" 命名为 "??3@YAXPEAX@Z"
这里我使用了hrtng插件快速生成VTBL,也可以手写。

同理,对Derived也创建vtbl,注意使用插件时,需要把函数签名按Y然后确定正确后Enter,等其变成黑色后,IDA就认为这是对的了,然后再创建vtbl,插件就不会识别错误,一次创建好。

vtbl的继承
由于Base和Derived共用一个vtbl,如果简单的把Base_vtbl写入结构体,替代void*,在继承后,子类调用了自己override的函数,但是IDA依旧会按照Base_vtbl进行反编译,这是让人头疼的事情,解决方案也很优雅,就是使用Union。
使用一个union,包含两个vtbl。

然后在Base类中,将void指针改为uVtbl1指针即可。

这时,回到main函数,可以看到buf.__vftbale->base_vtbl.j_Base_VirtualFuncA,但是这是不对的,因为buf是一个Derived对象,Derived通过override,覆写了VirtualFuncA,不过因为我们这是Union,我们对其按Alt+Y,可以手动解释Union的值。

将Union值手动改为第二种即可。

这样,就非常完美了,双击可以直接跳转到所对应的函数。

堆上对象初始化
C++在堆上对对象进行初始化一般使用new分配内存空间,使用delete回收内存空间,但是其实还有对对象的构造过程。
下面就是对其的具体实现。

sub_140011055(j_operator new)->sub_140012DF0()

按照之前的经验,直接对其命名"??2@YAPEAX_K@Z"
接下来流程就非常清晰了,IDA识别出buf_2就是Derived*,然后调用Constructor。

得益于我们刚刚的分析,IDA基本一步识别到了这些虚表的调用。我们仅需要分析中间那个函数即可。(注,下图的VirtualFuncA需要Choose Union Value)。

下面是中间的那个函数的内部,可以看到是对两个参数进行加后,放入了结构体+8的位置,我们知道,结构体+8位置位于Base中,且该函数没有对+16及以后的内存进行访问,我们可以大胆认为它是基类的非虚函数。

验证是否是基类的函数
从C++代码中显然他是基类的函数,但是反汇编中我们就不太能看出来了,这里我们选择对其进行X-Ref.
可以看到除了j_sub140012690函数外,还有一个位于.pdata段的引用,这个是x64下为了异常处理的Unwind流程方便进行的一个函数相关信息存储。

这里的相对位置其实会暴露对象所属

我们可以看到,其顺序大致是
父类初始化函数
子类初始化函数
无关函数(io相关函数,C++STL类函数,等等)。
我们要分析的函数,即父类的非虚非静态函数。
sub_1400126F0(父类Base的静态函数,我们后面分析)
父类的虚函数A
子类继承父类的虚函数A
父类的虚函数B(子类没继承)
无关函数
当然这只是经验之谈,最好的方法还是看该函数是否操作了子类的类内对象即可。
Base::NormalMemberFunc
很简单,不详细阐述了。

静态函数
最后,我们还剩一个函数没有分析,即Base::StaticFunc,这个函数我们通过代码对应关系,显然,它是 sub_1400114FB(0xDDDD);

可以看到它其实就是一个普通函数,和类Base在编译后没有什么关系,由于它没有this指针,所以它无法访问实例对象的成员,只能访问其他static函数。

函数尾的RTC
在最后还有一个神秘的函数,这个函数其实就是对应着开头的0xCCCCCCCC检查,如果代码把这中间的东西改掉了,这个函数就报错,退出程序。我们只需要把它改名为"_RTC_CheckStackVars"即可让IDA自动将其隐藏掉。
还有一个删除后检查0x8123(33059)的magic number,这是在检测用户是否将其正确释放掉,如果没有释放,那么它将会在Debug阶段设置为一个INVALID指针,有参考说,Windows会把前64KB的虚拟地址设置为陷阱空间,当Visual Studio调试的时候,如果对这些地址进行读写操作,就会报错,进而防止潜在的内存泄露问题,当然Release下没有这个检查,所以时常发生内存泄露的情况。具体可以参考 StackOverFlow的这篇文章

结尾
结果
至此,我们完整的分析了本程序,这对我们还原完整的C++程序非常有帮助。
int __fastcall main(int argc, const char **argv, const char **envp)
{
char *p_rsp_0x20_pointer; // rdi
__int64 i; // rcx
__int64 v5; // rax
__int64 v6; // rax
__int64 v7; // rax
__int64 v8; // rax
char rsp_0x20_pointer; // [rsp+20h] [rbp+0h] BYREF
Derived buf; // [rsp+28h] [rbp+8h] BYREF
Derived *derived; // [rsp+58h] [rbp+38h]
__int64 n33059; // [rsp+78h] [rbp+58h]
Derived *buf2_1; // [rsp+98h] [rbp+78h]
Derived *buf_2; // [rsp+178h] [rbp+158h]
__int64 n33059_3; // [rsp+198h] [rbp+178h]
Derived *n33059_1; // [rsp+1A8h] [rbp+188h]
p_rsp_0x20_pointer = &rsp_0x20_pointer;
for ( i = 58; i; --i )
{
*p_rsp_0x20_pointer = 0xCCCCCCCC;
p_rsp_0x20_pointer += 4;
}
j_CheckForDebuggerJustMyCode(jmc_flag);
j_Derived_Constructor(&buf);
derived = &buf;
buf.__vftbale->derived_vtbl.j_Derived_VirtualFuncA(&buf);
buf_2 = operator new(0x18u);
if ( buf_2 )
{
memset(buf_2, 0, sizeof(Derived));
n33059_1 = j_Derived_Constructor(buf_2);
}
else
{
n33059_1 = 0;
}
n33059 = n33059_1;
buf2_1 = n33059_1;
j_Base_AddTwoNumber(n33059_1, 0xAAAA, 0xBBBB);
buf2_1->__vftbale->derived_vtbl.j_Derived_VirtualFuncA(buf2_1);
buf2_1->__vftbale->derived_vtbl.j_Base_VirtualFuncB(buf2_1, 0xCCCCu);
j_Base_StaticFunc(0xDDDDu);
v5 = std::operator<<<std::char_traits<char>>(std::cout, "Object Address: ");
v6 = std::ostream::operator<<(v5, n33059);
std::ostream::operator<<(v6, std::endl<char,std::char_traits<char>>);
v7 = std::operator<<<std::char_traits<char>>(std::cout, "Vtbl Address (First 4 bytes): ");
v8 = std::ostream::operator<<(v7, *n33059);
std::ostream::operator<<(v8, std::endl<char,std::char_traits<char>>);
n33059_3 = n33059;
operator delete(n33059, 24);
if ( n33059_3 )
{
n33059 = 0x8123;
n33059_1 = 0x8123;
}
else
{
n33059_1 = 0;
}
return 0;
}更多研究
学有余力的读者可以尝试多继承,C++会为其维持多个vtbl,不过在分析上大差不差。
样本下载
推荐自行编译源文件,若下载,可能会遇到缺失动态库的情况。
该下载链接提供了主程序(.exe)与调试符号(.pdb)
提示:蓝奏云盘可能会将下载链接恶意改为广告链接以下载其他软件,请详细甄别下载的文件是否为"research_vtbl.zip".
下载:https://sts.lanzoue.com/i1f3g3lic68j 密码:hy9s