Home Of Steesha

BLOG

C++ 逆向工程:分析类的多态与继承

2026-03-25
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++ 虚函数表

在本文中,你只需要知道:

  1. 父类的vtbl指针和子类共用一个,都是结构体的第一个成员(void*),但是这个指针指向的vtbl不同,父类的指向父类的,子类的指向子类的。

  2. 父类和子类的vtbl中,子类应该有父类的所有虚函数,且子类有可能会更多,并且如果子类进行了同名称的override,对应位置的函数应该是override后的。

  3. 非虚函数不会出现在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, 0CCCCCCCCh

main函数分析

回到了开头,我们看到了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流程方便进行的一个函数相关信息存储。

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

我们可以看到,其顺序大致是

  1. 父类初始化函数

  2. 子类初始化函数

  3. 无关函数(io相关函数,C++STL类函数,等等)。

  4. 我们要分析的函数,即父类的非虚非静态函数。

  5. sub_1400126F0(父类Base的静态函数,我们后面分析)

  6. 父类的虚函数A

  7. 子类继承父类的虚函数A

  8. 父类的虚函数B(子类没继承)

  9. 无关函数

当然这只是经验之谈,最好的方法还是看该函数是否操作了子类的类内对象即可。

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