Microsoft Visual C++ 第二部分:类、方法和 RTTI

Microsoft Visual C++ 第二部分:类、方法和 RTTI,第1张

抽象的
Microsoft Visual C++ 是最广泛使用的 Win32 编译器,因此 Win32 逆向器熟悉其内部工作非常重要。


能够识别编译器生成的胶水代码有助于快速专注于程序员编写的实际代码。


它还有助于恢复程序的高级结构。




在这篇由两部分组成的文章的第二部分(另见:第一部分:异常处理)中,我将介绍如何在 MSVC 中实现 C++ 机制,包括类布局、虚拟函数、RTTI。


假定熟悉基本的 C++ 和汇编语言。


基本类布局


为了说明以下材料,让我们考虑这个简单的例子:

class A
    {
      int a1;
    public:
      virtual int A_virt1();
      virtual int A_virt2();
      static void A_static1();
      void A_simple1();
    };

    class B
    {
      int b1;
      int b2;
    public:
      virtual int B_virt1();
      virtual int B_virt2();
    };

    class C: public A, public B
    {
      int c1;
    public:
      virtual int A_virt2();
      virtual int B_virt2();
    };


在大多数情况下,MSVC 按以下顺序排列类:

  • 1. 指向虚函数表(_vtable_ or _vftable_)的指针,仅在类有虚方法且基类中没有合适的表可以重用时添加。


  • 2. 基类
  • 3. 班级成员

虚函数表由虚方法的地址按其首次出现的顺序组成。


重载函数的地址替换基类中的函数地址。




因此,我们三个类的布局将如下所示:

class A size(8):
        +---
     0  | {vfptr}
     4  | a1
        +---

    A's vftable:
     0  | &A::A_virt1
     4  | &A::A_virt2

    class B size(12):
        +---
     0  | {vfptr}
     4  | b1
     8  | b2
        +---

    B's vftable:
     0  | &B::B_virt1
     4  | &B::B_virt2

    class C size(24):
        +---
        | +--- (base class A)
     0  | | {vfptr}
     4  | | a1
        | +---
        | +--- (base class B)
     8  | | {vfptr}
    12  | | b1
    16  | | b2
        | +---
    20  | c1
        +---

    C's vftable for A:
     0  | &A::A_virt1
     4  | &C::A_virt2

    C's vftable for B:
     0  | &B::B_virt1
     4  | &C::B_virt2


上图是由 VC8 编译器使用未记录的开关生成的。


要查看编​​译器生成的类布局,请使用:-d1reportSingleClassLayout查看单个类的布局 -d1reportAllClassLayout 查看所有类的布局(包括内部 CRT 类) 布局转储到标准输出。




正如你所看到的,C 有两个 vftable,因为它继承了两个已经有虚函数的类。


C::A_virt2 的地址替换了 A 的 C 的 vftable 中的 A::A_virt2 的地址,并且 C::B_virt2 替换了另一个表中的 B::B_virt2。


调用约定和类方法


默认情况下,MSVC 中的所有类方法都使用 _thiscall_ 约定。


类实例地址(_this_ 指针)作为 ecx 寄存器中的隐藏参数传递。


在方法体中,编译器通常会立即将其隐藏在其他一些寄存器(例如 esi 或 edi)和/或堆栈变量中。


类成员的所有进一步寻址都是通过该寄存器和/或变量完成的。


但是,在实现 COM 类时,会使用 _stdcall_ 约定。


以下是各种类方法类型的概述。




1)静态方法
静态方法不需要类实例,因此它们的工作方式与普通函数相同。


没有 _this_ 指针传递给它们。


因此,不可能可靠地区分静态方法和简单函数。


例子:

 A::A_static1();
 call    A::A_static1


2)简单方法
简单方法需要一个类实例,所以_this_指针作为隐藏的第一个参数传递给它们,通常使用_thiscall_约定,即在_ecx_寄存器中。


当基对象不位于派生类的开头时,在调用函数之前需要调整_this_指针指向基子对象的实际开头。


例子:

;pC->A_simple1(1);
;esi = pC
push    1
mov ecx, esi
call    A::A_simple1

;pC->B_simple1(2,3);
;esi = pC
lea edi, [esi+8] ;adjust this
push    3
push    2
mov ecx, edi
call    B::B_simple1


如您所见,在调用 B 的方法之前,_this_ 指针被调整为指向 B 子对象。




3)虚拟方法
要调用虚拟方法,编译器首先需要从_vftable_ 中获取函数地址,然后以与简单方法相同的方式调用该地址处的函数(即传递_this_ 指针作为隐式参数)。


例子:

    ;pC->A_virt2()
    ;esi = pC
    mov eax, [esi]  ;fetch virtual table pointer
    mov ecx, esi
    call [eax+4]  ;call second virtual method
    
    ;pC->B_virt1()
    ;edi = pC
    lea edi, [esi+8] ;adjust this pointer
    mov eax, [edi]   ;fetch virtual table pointer
    mov ecx, edi
    call [eax]       ;call first virtual method


4)构造函数和析构
函数构造函数和析构函数的工作类似于一个简单的方法:它们获得一个隐式_this_ 指针作为第一个参数(例如,在_thiscall_ 约定的情况下为ecx)。


构造函数在 eax 中返回 _this_ 指针,即使它正式没有返回值。


RTTI 实施


RTTI(运行时类型标识)是编译器生成的特殊信息,用于支持诸如 dynamic_cast<> 和 typeid() 等 C++ 运算符,也用于 C++ 异常。


由于其性质,RTTI 只需要(和生成)多态类,即具有虚函数的类。




MSVC 编译器在 vftable 之前放置一个指向名为“完整对象定位器”的结构的指针。


如此调用该结构是因为它允许编译器从特定的 vftable 指针中找到完整对象的位置(因为一个类可以有多个)。


COL 如下所示:

struct RTTICompleteObjectLocator
{
    DWORD signature; //always zero ?
    DWORD offset;    //offset of this vtable in the complete class
    DWORD cdOffset;  //constructor displacement offset
    struct TypeDescriptor* pTypeDescriptor; //TypeDescriptor of the complete class
    struct RTTIClassHierarchyDescriptor* pClassDescriptor; //describes inheritance hierarchy
};


Class Hierarchy Descriptor 描述了类的继承层次。


它由一个类的所有 COL 共享。


struct RTTIClassHierarchyDescriptor
{
    DWORD signature;      //always zero?
    DWORD attributes;     //bit 0 set = multiple inheritance, bit 1 set = virtual inheritance
    DWORD numBaseClasses; //number of classes in pBaseClassArray
    struct RTTIBaseClassArray* pBaseClassArray;
};


基类数组描述了所有基类以及允许编译器在执行 _dynamic_cast_ 运算符期间将派生类转换为其中任何一个的信息。


每个条目(基类描述符)具有以下结构:

struct RTTIBaseClassDescriptor
{
    struct TypeDescriptor* pTypeDescriptor; //type descriptor of the class
    DWORD numContainedBases; //number of nested classes following in the Base Class Array
    struct PMD where;        //pointer-to-member displacement info
    DWORD attributes;        //flags, usually 0
};

struct PMD
{
    int mdisp;  //member displacement
    int pdisp;  //vbtable displacement
    int vdisp;  //displacement inside vbtable
};


PMD 结构描述了如何将基类放置在完整类中。


在简单继承的情况下,它位于对象开头的固定偏移量处,该值是_mdisp_ 字段。


如果它是虚拟基础,则需要从 vbtable 中获取额外的偏移量。


将 _this_ 指针从派生类调整为基类的伪代码如下所示:

  //char* pThis; struct PMD pmd;
    pThis+=pmd.mdisp;
    if (pmd.pdisp!=-1)
    {
      char *vbtable = pThis+pmd.pdisp;
      pThis += *(int*)(vbtable+pmd.vdisp);
    }


例如,我们三个类的 RTTI 层次结构如下所示:


我们示例类的 RTTI 层次结构

提取信息


1) RTTI
如果存在,RTTI 是一个有价值的反转信息来源。


从 RTTI 可以恢复类名、继承层次结构,在某些情况下还可以恢复部分类布局。


我的 RTTI 扫描仪脚本显示了大部分信息。


(见附录I)

2)静态和全局初始化
器 全局和静态对象需要在主程序启动之前进行初始化。


MSVC 通过生成初始化函数并将它们的地址放在一个表中来实现这一点,该表在 CRT 启动期间由 _cinit 函数处理。


该表通常位于 .data 部分的开头。


典型的初始化程序如下所示:

    _init_gA1:
        mov     ecx, offset _gA1
        call    A::A()
        push    offset _term_gA1
        call    _atexit
        pop     ecx
        retn
    _term_gA1:
        mov     ecx, offset _gA1
        call    A::~A()
        retn


因此,从这个表格中我们可以发现:

  • 全局/静态对象地址
  • 他们的构造函数
  • 他们的析构函数

另见 MSVC _#pragma_ 指令 _init_seg_ [5]。




3) Unwind Funclet
如果在函数中创建了任何自动对象,VC++ 编译器会自动生成异常处理结构,以确保在发生异常时删除这些对象。


有关C++ 异常实现的详细描述,请参见第一部分。


典型的 unwind funclet 会破坏堆栈上的对象:

    unwind_1tobase:  ; state 1 -> -1
        lea     ecx, [ebp+a1]
        jmp     A::~A()


通过在函数体内找到相反的状态变化或者只是第一次访问相同的堆栈变量,我们还可以找到构造函数:

    lea     ecx, [ebp+a1]
    call    A::A()
    mov     [ebp+__$EHRec$.state], 1


对于使用 new() 运算符构造的对象,unwind funclet 确保在构造函数失败时删除分配的内存:

    unwind_0tobase: ; state 0 -> -1
        mov     eax, [ebp+pA1]
        push    eax
        call    operator delete(void *)
        pop     ecx
        retn


在函数体中:

    ;A* pA1 = new A();
        push    
        call    operator new(uint)
        add     esp, 4
        mov     [ebp+pA1], eax
        test    eax, eax
        mov     [ebp+__$EHRec$.state], 0; state 0: memory allocated but object is not yet constructed
        jz      short @@new_failed
        mov     ecx, eax
        call    A::A()
        mov     esi, eax
        jmp     short @@constructed_ok
    @@new_failed:
        xor     esi, esi
    @@constructed_ok:
        mov     [esp+14h+__$EHRec$.state], -1
     ;state -1: 对象构造成功或内存分配失败
     ;在这两种情况下,进一步的内存管理都由程序员完成


在构造函数和析构函数中使用了另一种类型的展开 funclet。


它确保在异常情况下销毁类成员。


在这种情况下,funclet 使用 _this_ 指针,该指针保存在堆栈变量中:

    unwind_2to1:
        mov     ecx, [ebp+_this] ; state 2 -> 1
        add     ecx, 4Ch
        jmp     B1::~B1


这里 funclet 在偏移量 4Ch 处破坏 B1 类型的类成员。


因此,从 unwind funclets 我们可以发现:

  • 堆栈变量表示 C++ 对象或指向使用 _operator new_ 分配的对象的指针。


  • 他们的析构函数
  • 他们的构造函数
  • 如果是新的对象,它们的大小


4)构造函数/析构函数递归
这条规则很简单:构造函数调用其他构造函数(基类和成员变量),析构函数调用其他析构函数。


典型的构造函数执行以下 *** 作:

  • 调用基类的构造函数。


  • 调用复杂类成员的构造函数。


  • 如果类具有虚函数,则初始化 vfptr(s)
  • 执行程序员编写的构造函数体。


典型的析构函数几乎以相反的顺序工作:

  • 如果类具有虚函数,则初始化 vfptr
  • 执行程序员编写的析构函数体。


  • 调用复杂类成员的析构函数
  • 调用基类的析构函数

MSVC 生成的析构函数的另一个显着特点是它们的 _state_ 变量通常用最高值初始化,然后随着每个被破坏的子对象递减,这使得它们的识别更容易。


请注意,简单的构造函数/析构函数通常由 MSVC 内联。


这就是为什么您经常可以看到 vftable 指针在同一个函数中使用不同的指针反复重新加载。




5)数组构造销毁
MSVC 编译器使用辅助函数来构造和销毁对象数组。


考虑以下代码:

    A* pA = new A[n];
    
    delete [] pA;


它被翻译成以下伪代码:

    array = new char(sizeof(A)*n+sizeof(int))
    if (array)
    {
      *(int*)array=n; //store array size in the beginning
      'eh vector constructor iterator'(array+sizeof(int),sizeof(A),count,&A::A,&A::~A);
    }
    pA = array;
    
    'eh vector destructor iterator'(pA,sizeof(A),count,&A::~A);


如果 A 有一个 vftable,则在删除数组时会调用“向量删除析构函数”:

    ;pA->'vector deleting destructor'(3);
    mov ecx, pA
    push 3 ; flags: 0x2=deleting an array, 0x1=free the memory
    call A::'vector deleting destructor'


如果 A 的析构函数是虚拟的,它会被虚拟调用:

    mov ecx, pA
    push 3
    mov eax, [ecx] ;fetch vtable pointer
    call [eax]     ;call deleting destructor


因此,从向量构造函数/析构函数迭代器调用中,我们可以确定:

  • 对象数组的地址
  • 他们的构造函数
  • 他们的析构函数
  • 班级人数


6)删除析
构函数 当类有虚析构函数时,编译器生成一个辅助函数——删除析构函数。


其目的是确保在销毁类时调用正确的_operator delete_。


删除析构函数的伪代码如下所示:

    virtual void * A::'scalar deleting destructor'(uint flags)
    {
      this->~A();
      if (flags&1) A::operator delete(this);
    };


这个函数的地址被放置在 vftable 中,而不是析构函数的地址。


这样,如果另一个类覆盖了虚拟析构函数,则会调用该类的_operator delete_。


虽然在实际代码中_operator delete_ 很少被覆盖,所以通常你会看到对默认delete() 的调用。


有时编译器还可以生成向量删除析构函数。


它的代码如下所示:

    virtual void * A::'vector deleting destructor'(uint flags)
    {
      if (flags&2) //destructing a vector
      {
        array = ((int*)this)-1; //array size is stored just before the this pointer
        count = array[0];
        'eh vector destructor iterator'(this,sizeof(A),count,A::~A);
        if (flags&1) A::operator delete(array);
      }
      else {
        this->~A();
        if (flags&1) A::operator delete(this);
      }
    };


我跳过了关于使用虚拟基类实现类的大部分细节,因为它们使事情变得相当复杂并且在现实世界中相当罕见。


请参阅 Jan Gray[1] 的文章。


它非常详细,如果匈牙利符号有点重的话。


文章 [2] 描述了 MSVC 中虚拟继承实现的示例。


另请参阅一些 MS 专利 [3] 了解更多详细信息。


附录一:ms_rtti4.idc


这是我为解析 RTTI 和 vftables 编写的脚本。


您可以从Microsoft VC++ Reversing Helpers下载与本文和上一篇文章相关的脚本。


脚本特点:

  • 解析 RTTI 结构并重命名 vftable 以使用相应的类名。


  • 对于一些简单的情况,识别和重命名构造函数和析构函数。


  • 输出一个文件,其中包含所有具有引用函数和类层次结构的 vftable 列表。






    用法:初始分析完成后,加载 ms_rtti4.idc。


    它会询问您是否要扫描 exe 中的 vtables。


    请注意,这可能是一个漫长的过程。


    即使跳过扫描,您仍然可以手动解析 vtable。


    如果您确实选择扫描,脚本将尝试使用 RTII 识别所有 vtable,重命名它们,并识别和重命名构造函数和析构函数。


    在某些情况下它会失败,尤其是虚拟继承。


    扫描后,它将打开带有结果的文本文件。




    加载脚本后,您可以使用以下热键手动解析一些 MSVC 结构:

  • Alt-F8 - 解析一个 vtable。


    光标应位于 vtable 的开头。


    如果有 RTTI,脚本将使用其中的类名。


    如果没有,您可以手动输入类名,脚本将重命名 vtable。


    如果有它可以识别的虚拟析构函数,脚本也会重命名它。


  • Alt-F7 - 解析 FuncInfo。


    FuncInfo 是在堆栈上分配对象或使用异常处理的函数中存在的结构。


    它的地址被传递给函数异常处理程序中的_CxxFrameHandler:

        mov eax, offset FuncInfo1
        jmp _CxxFrameHandler

    在大多数情况下,IDA 会自动识别和解析它,但我的脚本提供了更多信息。


    您还可以使用本文第一部分中的 ms_ehseh.idc 来解析文件中的所有 FuncInfo。




    使用热键并将光标放在 FuncInfo 结构的开头。


  • Alt-F9 - 解析抛出信息。


    Throw info 是 _CxxThrowException 用于实现 _throw_ 运算符的辅助结构。


    它的地址是 _CxxThrowException 的第二个参数:

        lea     ecx, [ebp+e]
        call    E::E()
        push    offset ThrowInfo_E
        lea     eax, [ebp+e]
        push    eax
        call    _CxxThrowException

使用热键并将光标放在 throw 信息结构的开头。


该脚本将解析结构并添加带有抛出类名称的可重复注释。


它还将识别和重命名异常的析构函数和复制构造函数。


附录二:类结构的实际恢复


我们的主题将是 MSN Messenger 7.5(msnmsgr.exe 版本 7.5.324.0,大小 7094272)。


它大量使用了 C++,并为我们的目的提供了大量的 RTTI。


让我们考虑两个 vftable,分别为 0.0040EFD8 和 0.0040EFE0。


它们的完整 RTTI 结构层次结构如下所示:


MSN Messenger 7.5 的 RTTI 层次结构



所以,这两个 vftable 都属于一个类 - CContentMenuItem。


通过检查它的基类描述符,我们可以看到:

  • CContentMenuItem 包含数组中跟随它的三个基——即CDownloader、CNativeEventSink 和CNativeEventSource。


  • CDownloader 包含一个基础 - CNativeEventSink。


  • 因此,CContentMenuItem 直接继承自 CDownloader 和 CNativeEventSource,而 CDownloader 又继承自 CNativeEventSink。


  • CDownloader 位于完整对象的开头,而 CNativeEventSource 位于偏移量 0x24 处。




所以我们可以得出结论,第一个 vftable 列出了 CNativeEventSource 的方法,第二个列出了 CDownloader 或 CNativeEventSink 的方法(如果它们都没有虚拟方法,CContentMenuItem 将重用 CNativeEventSource 的 vftable)。


现在让我们检查一下这些表指的是什么。


它们都由两个函数引用,分别为 .052B5E0 和 .052B547。


(这强化了它们都属于一个类的事实。


)此外,如果我们查看函数的开头 .052B547,我们会看到用 6 初始化的 _state_ 变量,这意味着该函数是析构函数。


由于一个类只能有一个析构函数,我们可以得出结论,.052B5E0 是它的构造函数。


让我们仔细看看:

CContentMenuItem::CContentMenuItem   proc near
this = esi
    push    this
    push    edi
    mov     this, ecx
    call    sub_4CA77A
    lea     edi, [this+24h]
    mov     ecx, edi
    call    sub_4CBFDB
    or      dword ptr [this+48h], 0FFFFFFFFh
    lea     ecx, [this+4Ch]
    mov     dword ptr [this], offset const CContentMenuItem::'vftable'{for 'CContentMenuItem'}
    mov     dword ptr [edi], offset const CContentMenuItem::'vftable'{for 'CNativeEventSource'}
    call    sub_4D8000
    lea     ecx, [this+50h]
    call    sub_4D8000
    lea     ecx, [this+54h]
    call    sub_4D8000
    lea     ecx, [this+58h]
    call    sub_4D8000
    lea     ecx, [this+5Ch]
    call    sub_4D8000
    xor     eax, eax
    mov     [this+64h], eax
    mov     [this+68h], eax
    mov     [this+6Ch], eax
    pop     edi
    mov     dword ptr [this+60h], offset const CEventSinkList::'vftable'
    mov     eax, this
    pop     this
    retn
sub_52B5E0      endp


编译器在 prolog 之后做的第一件事就是将 _this_ 指针从 ecx 复制到 esi,因此所有进一步的寻址都是基于 esi 完成的。


在初始化 vfptrs 之前,它会调用另外两个函数;那些必须是基类的构造函数——在我们的例子中是 CDownloader 和 CNativeEventSource。


我们可以通过进入每个函数来确认 - 第一个使用 CDownloader::'vftable' 初始化其 vfptr 字段,第二个使用 CNativeEventSource::'vftable' 初始化其 vfptr 字段。


我们还可以进一步研究 CDownloader 的构造函数——它调用其基类 CNativeEventSink 的构造函数。




此外,传递给第二个函数的_this_ 指针取自edi,它指向this+24h。


根据我们的类结构图,它是 CNativeEventSource 子对象的位置。


这是另一个确认被调用的第二个函数是 CNativeEventSource 的构造函数。




在调用基础构造函数之后,基础对象的 vfptr 被 CContentMenuItem 的实现覆盖——这意味着 CContentMenuItem 覆盖了基础类的一些虚拟方法(或添加了自己的)。


(如果需要,我们可以比较表并检查哪些指针已更改或添加 - 这些将是 CContentMenuItem 的新实现。




接下来我们看到对 .04D8000 的几个函数调用,其中 _ecx_ 设置为 this+4Ch 到 this+5Ch - 显然一些成员变量已初始化。


我们如何知道该函数是编译器生成的构造函数调用还是程序员编写的初始化函数?有几个提示表明它是一个构造函数。


  • 该函数使用 _thiscall_ 约定,这是第一次访问这些字段。


  • 这些字段按照地址递增的顺序进行初始化。


为了确保我们还可以检查析构函数中的展开 funclet - 我们可以看到编译器生成的对这些成员变量的析构函数调用。




这个新类没有虚拟方法,因此没有 RTTI,所以我们不知道它的真实名称。


我们将其命名为 RefCountedPtr。


正如我们已经确定的那样,4D8000 是它的构造函数。


我们可以从 CContentMenuItem 析构函数的 unwind funclets 中找到析构函数——它位于 63CCB4。




回到 CContentMenuItem 构造函数,我们看到三个用 0 初始化的字段和一个用 vftable 指针初始化的字段。


这看起来像是成员变量的内联构造函数(不是基类,因为基类将存在于继承树中)。


从使用的 vftable 的 RTTI 我们可以看出它是一个 CEventSinkList 模板的实例。




现在我们可以为我们的类编写一个可能的声明。


class CContentMenuItem: public CDownloader, public CNativeEventSource
{
/* 00 CDownloader */
/* 24 CNativeEventSource */
/* 48 */ DWORD m_unknown48;
/* 4C */ RefCountedPtr m_ptr4C;
/* 50 */ RefCountedPtr m_ptr50;
/* 54 */ RefCountedPtr m_ptr54;
/* 58 */ RefCountedPtr m_ptr58;
/* 5C */ RefCountedPtr m_ptr5C;
/* 60 */ CEventSinkList m_EventSinkList;
/* size = 70? */
};


我们无法确定偏移 48 处的字段是否不是 CNativeEventSource 的一部分;但由于它没有在 CNativeEventSource 构造函数中访问,它很可能是 CContentMenuItem 的一部分。


应用了重命名方法和类结构的构造函数列表:

public: __thiscall CContentMenuItem::CContentMenuItem(void) proc near
    push    this
    push    edi
    mov     this, ecx
    call    CDownloader::CDownloader(void)
    lea     edi, [this+CContentMenuItem._CNativeEventSource]
    mov     ecx, edi
    call    CNativeEventSource::CNativeEventSource(void)
    or      [this+CContentMenuItem.m_unknown48], -1
    lea     ecx, [this+CContentMenuItem.m_ptr4C]
    mov     [this+CContentMenuItem._CDownloader._vfptr], offset const CContentMenuItem::'vftable'{for 'CContentMenuItem'}
    mov     [edi+CNativeEventSource._vfptr], offset const CContentMenuItem::'vftable'{for 'CNativeEventSource'}
    call    RefCountedPtr::RefCountedPtr(void)
    lea     ecx, [this+CContentMenuItem.m_ptr50]
    call    RefCountedPtr::RefCountedPtr(void)
    lea     ecx, [this+CContentMenuItem.m_ptr54]
    call    RefCountedPtr::RefCountedPtr(void)
    lea     ecx, [this+CContentMenuItem.m_ptr58]
    call    RefCountedPtr::RefCountedPtr(void)
    lea     ecx, [this+CContentMenuItem.m_ptr5C]
    call    RefCountedPtr::RefCountedPtr(void)
    xor     eax, eax
    mov     [this+CContentMenuItem.m_EventSinkList.field_4], eax
    mov     [this+CContentMenuItem.m_EventSinkList.field_8], eax
    mov     [this+CContentMenuItem.m_EventSinkList.field_C], eax
    pop     edi
    mov     [this+CContentMenuItem.m_EventSinkList._vfptr], offset const CEventSinkList::'vftable'
    mov     eax, this
    pop     this
    retn
public: __thiscall CContentMenuItem::CContentMenuItem(void) endp

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/562258.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-01
下一篇 2022-04-01

发表评论

登录后才能评论

评论列表(0条)

保存