如何才能把C++逆向标题写长点

Published: 2021年09月21日

In Reverse.

去年帮别人破解的一个程序,C++写的,第一次认真的分析C++的程序发现果然是那么痛苦,后来就着书把它啃出来了,记录了部分过程,现在整理笔记发现它,打算发出来水一期,然后...忘完了...所以想到啥写啥,以后遇到再补充吧...

???

Question:C++面向对象编程的三个特点是什么?Answer:封装,继承,多态。

Question:它们是怎么实现的?Answer:...​

一般提到C++与C的区别,都会想到一个面向对象一个面向过程,但是多看看C的代码,会发现它的结构体用法和C++的类特别像了,而且一些SDK同时提供C和C++版本,会发现在使用时,C仅仅是多了个一个结构体指针的参数,所以...它们很像嘛!​

之前的《网络设备漏洞挖掘与代码审计》提到逆向的一个重点是结构体识别,并提了一些识别方法,那里主要针对C语言的二进制程序,对于C++对应起来就是类的识别了。

方法

事实上C++里的结构体可以认为是一种特殊类,一种没有方法且所有域都是public属性的类。再想类和对象实例,类的成员类似结构体,于是对象实例也就是结构体的所描述的值,而类的方法就比较特殊了,它不属于对象实例,是对象实例共用的一段代码。识别对象的重点就是这些方法的特征:
1.普通方法:它运行在对象实例上下文,它有个隐含的this指针,该指针作为第一个参数被编译器自动插入,代表着这个对象,在x86下一般用thiscall方式进行函数调用,它使用ECX寄存器传递this值,这个ECX就是方法识别的关键,而在x64下它退化成了通用的调用约定,如Windows下使用RCX传递,Linux下为RDI。​

2.静态方法:它和普通方法类似但是不和对象实例绑定,即它不再有隐含的this参数,在二进制层面就难以区分它是否属于某个类了。​

3.构造方法析构方法:它们代表着对象的诞生与消亡,更精确的说是初始化与资源释放,它们被内存分配与回收包裹着。构造方法在存在时是对象执行的第一个方法(但是该方法可能被编译器优化为内联,于是不可见),在未定义构造方法时若存在虚表或基类有构造方法则编译器会添加默认构造方法,构造方法的第一个参数是新分配的供对象使用的空间的首地址(即this指针),它的返回也是该地址,在里面它会依次赋值虚函数表指针并调用基类构造函数,另外还有大量的赋值操作。而析构方法它只有一个参数,即对象指针,它会做和构造方法相反的基类析构与虚表赋值操作,它们在作用域成对出现,对象创建有如下情况:

  • 全局对象:空间也在数据区,并在main函数执行前被初始化,如VC里被_cinit初始化,在此时它会在atexit里注册析构方法。
  • 非静态局部对象:它的空间在栈上,在到函数内部被初始化,函数结束时调用析构方法。
  • 堆对象:空间在堆上,使用new创建,delete时会调用析构方法。
  • 实参:若是值传递,那么它会在栈上分配空间并使用拷贝构造函数创建对象的副本,并将地址传入被调函数。
  • 返回值:作为函数的返回值时,不像其他语言动态申请空间,它在调用前就在栈上申请了空间用于存放返回值,一般来说函数的返回值会被在此赋值给一个变量,因此经常会出现在返回后,再次将这片区域复制到另一个区域。

构造函数与析构函数是类识别的重点,如通过上面的特征识别出一个函数是构造方法,那么调用它的所有位置都是该类的创建位置或派生类的构造函数内,因此可定位到虚表等信息,而创建位置又有对象的分配操作,如果是动态分配那么就可以知道对象的大小!​

3.虚方法:它是多态的实现方式,C++本身是静态的语言,所有代码在编译时就决定了,比如一个调用一个对象的方法,这段机器指令在编译时就确定了,那如何在运行时根据对象的实际类型调用它自己的方法呢?就是把这类方法的地方放一张表里,并在对象里存放该表的指针,于是不同对象里指向的函数表不同,就可以实现多态特性,这类方法就是虚方法,在分析时也是比较恼人的方法,这类方法不是直接调用,而是通过多层解指针获取函数地址再调用。不过也有好处,它们的地址在一个表里,因此很好识别,如使用dumpvtable。​

空间布局与RTTI

先看控件布局,它是指类的对象实例在内存中的布局,这不在C++标准里,因此不同编译器可以自己实现,但这部分差别不大,如下是一种典型的布局例子:image.png

上图,Ex5继承了Ex2和Ex4,则先嵌入了Ex2,再嵌入了Ex4,之后才是自己定义的实例成员,而它定义的虚函数将覆盖基类的虚函数表或新加到第一个虚表之后,即上图偏移0处指向的是Ex2的虚表拷贝,偏移8是Ex4的虚表拷贝,若Ex5重写了基类的虚函数,则修改对应虚表即可,而新加的虚函数会被追加在偏移0处指向的虚表之后。如果一个类没有非静态成员变量也没有虚函数,也没有继承这些,那么它也要占一字节空间,使this指针有所附着,不同的继承方式其布局方式不同,下面将分平台进行详细介绍。

再看RTTI,即运行时类型信息(Run Time Type Information),它是逆向的大宝贝,用它可以获取很多有用的信息。C++是静态编译的语言,语言的符号只在这个阶段有用,之后在运行时不再使用这些符号,但是考虑这样一种情况,在一个函数的参数是基类的指针,于是所有派生类都可以作为参数传入,而在函数内部,它又需要根据实参的实际类型执行不同的操作,这里的操作中如果是调用基类的虚方法那就是多态,而如果是派生类特有的方法或者访问派生类特有的属性,则需要进行一种转换,将基类指针转换为派生类指针,这里粗暴的强制转换会让编译器很为难,C++是提供了一个函数,这个函数做的就是检查它的实际类型并返回新的指针类型,这就是typeiddynamic_cast所做的事,如:

#include <iostream>

using namespace std;

class Base{
public:
    virtual void vf1(){cout<< "call Base vf1()"<<endl;}
    void f1(){cout<<"call Base f1()"<<endl;}
};
class Derived: public Base{
public:
    virtual void vf1(){cout <<"call Derived vf1()"<<endl;}
    virtual void vf2(){cout <<"call Derived vf2()"<<endl;}
    void f1(){cout<<"call Derived f1()"<<endl;}         
};
void func(Base* obj){
    cout<<"the class is "<<typeid(*obj).name()<<endl;  // 真实的类型:the class is 7Derived
    obj->f1();  // 非多态:call Base f1()
    obj->vf1(); // 多态:call Derived vf1()
    if(typeid(*obj)== typeid(Derived)){
        dynamic_cast<Derived*>(obj)->vf2();  // 派生类转换:call Derived vf2()
    }
}
int main() {
    Derived a;
    a.f1(); // 正常:call Derived f1()
    func(&a);
    return 0;
}

要在运行时实现这些功能,就必须把类的一些信息保存起来,在运行时根据这些信息实现功能,保存起来的就是RTTI了,可以想到其实RTTI也不是一定要有的,也不是每个类都要有,这先不看,继续说RTTI的结构,它也和语言实现有关,但大致相同,下面将分别介绍windows和Linux的。

Windows下的RTTI

Windows通常使用msvc编译,其生成的结构体与RTTI信息如下:image.png这堆结构体中只需要意识到两件事:它包含类名(struct TypeDescriptor::pTypeInfoString),它包含类继承关系(struct BassClassDescriptor)。C++使用Name Mangling来实现命名空间与方法重载,不同编译器会有特定的命名方法,因此根据这些命名特征就可以识别出类名,而由类名可以定位出以上整个结构,如虚表,类继承关系等。IDA本身就是使用这些信息识别虚函数表,并且还有些插件也利用了这些功能,如ClassInformer

image.png

在学习时,可用/d1 reportSingleClassLayout(用户类)和/d1 reportAllClassLayout(所有类)选项输出RTTI和类布局信息,例如这里

Linux下的RTTI

Linux下通常使用GCC或Clang,它们用的是Itanium C++ ABI,可通过查找__cxxabiv1字符确认,它的RTTI信息和Windows存在一些区别,以Morgan Deters的例子[3]做解释:

1.无虚函数的非虚继承根据从左到右深度优先递归的排列每一个对象,此时不存在虚表

2.有虚函数的继承有了虚表,此时派生类(class B)会复制第一个直接基类(class A)的虚函数表vftable,派生类重写或新增的任何虚函数vfunc都会作用在第一张vftable,此处由于类B没有新增也没重写vfunc因此它的虚函数表就是A的vftable,在vftable之上还有两个域,下面解释

image-20221125144127615

3.有多个虚表的非虚多继承可见对象c已经有了两个vftable指针,可以看top_offset这个域了,它表示指向当前虚表的对象距离整个对象首地址,即“内嵌”的对象b到对象c的首地址偏移为-8 (32位下):

image-20221125143017483

它有什么作用呢,前面提到派生类中的任何vfun修改都要作用在第一个vftable中,因此若重写了w()方法则它也是在第一个vftable中,但此时第二个vftable里的B::w()也需要修改,修改成一段叫做thunk的代码,这段代码是自动生成的,它的作用是根据top_offset的值将this指针调整到整个对象的首地址再调用第一个vftable里的函数,另外在使用dynamic_cast<>时也需要它获取派生类对象的地址。

在ida中会遇到non-virtual thunk和virtual thunk修饰的函数,它们都是这个作用,区别是它们继承时是否用的是虚继承,非虚继承布局(偏移)是确定的可直接写在代码中,虚继承偏移由最终的类层级图决定,因此virtual thunk中修正所用的偏移其实来自vcall offset(位于top_offset之上)。

4.虚拟多继承(真菱形继承)时会把虚继承的基类放在最末尾,此时虚表里又多加了一项叫做vbase_offset来表示指向当前虚表的位置距离虚基类的偏移,这在对应类要调用基类的方法或访问基类的域时被需要:

image-20221125150503774

不像普通继承直来直去,虚继承的布局特别复杂,为了虚继承的基类只有一个实例,它不能放置在某个子类前面于是各种偏移都得因实际层级决定,由于只有一个基类实例因此构造函数只能被调用一次,于是原有的每个子类创建父类病调用其构造函数的方式需要变化,于是又引入了一些概念,首先看看abi#c|dtor里定义的构造与析构函数:

  <ctor-dtor-name> ::= C1           # complete object constructor
           ::= C2           # base object constructor
           ::= C3           # complete object allocating constructor
           ::= CI1 <base class type>    # complete object inheriting constructor
           ::= CI2 <base class type>    # base object inheriting constructor
           ::= D0           # deleting destructor
           ::= D1           # complete object destructor
           ::= D2           # base object destructor

先看C3和D0,它们表示由自己分配与释放空间,例如有些对象位于栈上,此时就不应该使用D0去释放内存。剩下的两类是对应的,此处只讲构造器:

1.C2为基对象构造奇特,它会处理所有非虚部分,发生于基类构造时。

2.C1为完整对象构造器,它会处理所有部分,在创建对象时,会使用这个类的C1,而C1里可能会调用其基类的C2。

现在来看上图中D类实例的构造过程,它会按A->B->C->D的顺序完成构造,代码如下(64位):

main:
  v3 = (D *)operator new(0x30uLL);          // 先分配内存
  D::D(v3);                             // 再调用 _ZN1DC1Ev ,即C1
_ZN1DC1Ev:
  /* 在C1里,先调用 _ZN1AC2Ev (C2),传入的地址为A子对象的地址 */
    A::A((A *const)this->gap20);          
          this->_vptr_A = (int (**)(...))&off_3CF8;
  /* 接着调 _ZN1BC2Ev (C2),传入的是B的地址(B和D共用虚表) */
  B::B((B *const)this, (const void **const)&_vtt_parm);         
              this->_vptr_B = (int (**)(...))*__vtt_parm;
            *(int (***)(...))((char *)&this->_vptr_B + *((_QWORD *)this->_vptr_B - 3)) = (int (**)(...))__vtt_parm[1];  // 这里通过构造虚表获取A的地址(this+vtable[vbase])再给它当前阶段该使用的vtable
  /* 接着调用 _ZN1CC2Ev (C2),同理 */
  C::C((C *const)&this->C:96, (const void **const)&off_3BF8);
        this->_vptr_C = (int (**)(...))*__vtt_parm;
        *(int (***)(...))((char *)&this->_vptr_C + *((_QWORD *)this->_vptr_C - 3)) = (int (**)(...))__vtt_parm[1];
  this->_vptr_B = (int (**)(...))off_3B88;
  *(_QWORD *)this->gap20 = &off_3BD8;
  this->_vptr_C = (int (**)(...))&off_3BB8;

构造A没任何问题,但是构造B和C就不同了,由于它们虚继承A,那么A距它们的位置不再确定,如单独实例话B和C那么它们A子对象的偏移应该是一样的,但在D中(如上图)B比C离A更远,即在虚继承时虚基类的位置不再是固定的,需要由最终的类层级决定,因此对于虚基类它们的基构造器多了个参数,如上例的B::B和C::C,用来接收由派生类传入的一张特定的虚表(叫做构造虚表 construct vtable),特定的基类在不同的层级中构造虚表不一样,主要体现在虚表里的vbase和vcall等记录偏移的数据不一样,因此利用该虚表就能获取到正确的偏移从而正确初始化基类子对象,有上面的代码可见,在虚基层时会有多个虚表,此时会有一个叫做虚表表(VTT)的表来存放这些虚表,因此定位到VTT就可以通过它里面的各种vtable获取相关的偏移[4]。

到此为止,上面还漏了typeinfo指针的部分,它是我们查找虚表的关键。由abi#rtti-layout可知,每个有虚表的类都会有个域(&vptr-1)指向派生自std::type_info的结构,这里需要关注三个派生类,abi用它们表示用户定义类型:

// 最原始的类型,只有一个域表示其名字
class type_info {  ... private:  const char *__type_name;};

// 表示没有基类的类型,修饰后名称为 _ZTVN10__cxxabiv117__class_type_infoE
class __class_type_info : public std::type_info {}

// 表示单 公共 非虚基址在0偏移处继承 类型,修饰后的名称为 _ZTVN10__cxxabiv120__si_class_type_infoE
class __si_class_type_info : public __class_type_info {
  public:  const __class_type_info *__base_type;  // 指向直接基类
};

// 表示其他类型的继承,修饰后名称为_ZTVN10__cxxabiv121__vmi_class_type_infoE
class __vmi_class_type_info : public __class_type_info {
  public:
  unsigned int __flags;                                                     // 表示最近类结构形状,如重复继承/菱形
  unsigned int __base_count;                                            // 直接基类的个数
  __base_class_type_info __base_info[1];                    // 每一个基类的信息,结构如下
};

struct abi::__base_class_type_info {
  public:
  const __class_type_info *__base_type;
  long __offset_flags;                                          // 低8位表示继承基类的方式 如虚继承,public继承,剩下的为偏移
};

有了这就可以去查找所有虚表了,方法是先获取这三个类符号的地址,再遍历获取所有对该符号的引用(即获取了用户定义类型的typeinfo对象),再获取对该对象的引用几获取了虚表。可使用IDA_GCC_RTTI自动完成该过程,它还能很好的展示继承关系,如下面为某分析某产品时生成的图,可以清晰的看出类间继承关系:

dot -Tpdf -o axigen.pdf aeigen.386

image.png

涉及到虚继承时布局就开始复杂了起来,此时可自己生成并用ida打开分析,此外,和msvc类似gcc在8.0之前可使用-fdump-class-hierarchy输出类布局,在8.0之后的使用-fdump-lang-class,不过我试了下其他RTTI和虚表都没问题,类布局没看懂O_O,也可以配合pahole看布局,但也有些问题X_X,还好clang也是用的这个abi于是还可以用它生成布局:

clang++ -Xclang -fdump-record-layouts rtti.cpp  # 输出类对象内存布局
clang++ -Xclang -fdump-vtable-layouts rtti.cpp  # 输出虚表等信息

该abi也规定了很多名称修饰字符,详细的还是看文档吧,在ida里可以用demangle_name('_ZN6ThreadD0Ev',0)函数去解析,结束!

桥豆麻袋!!再介绍两个工具,HexRaysCodeXplorer也可以用于部分重建类(结构体),它的思想和之前讲的结构体重建类似,它可以在类的构造函数处选择实例变量再按<R>键入新的结构体名称,如下:

它还支持在虚函数表处按<V>新建虚表结构,两者配合就能获得较好的逆向体验。还有个HexRaysPyTools也挺好用的,可惜两年没更了。

STL

标准模板库特别复杂,还好不需要逆它,但是需要简单了解它里面的基本结构组成,以便能识别它们,例如遇到一堆红黑树操作时,可以判断出是否是STL的字典操作,而有的时候需要识别出string与vector,如string它在libstdc++里的实现如下:

struct std_string
{
size_t length;
size_t capacity;
size_t refcount;
char s[];  // s指向该位置
};

在调试时直接打印s即可获取字符串的内容,更多可见SGI-STL与《STL源代码剖析》。

COM组件

和C++很相似的是COM组件,它主要为了解决二进制库的兼容调用,想想普通C的可以直接提供函数,再说明调用约定就能对外提供服务了,而C++就复杂多了,它有类,有new/delete,还有版本区别,由于只能导出函数或变量,因此只能通过工厂函数之类的功能对外提供类的创建,管理功能,COM组件就是为了实现这些功能的一种方案,它定义了接口描述语言IDL说明接口信息,用IDL可以生成其他语言所需的stub,接口可以忽略内部实现,并且它使用UUID(GUID)为每个实现命名(IID/CLSID),从而实现同名(不同版本)实现的IID不同,通过CLSID与IID就可以定位到特定版本的函数,它的全过程如下:

1.编写COM组件,它需要继承并实现IUnkown接口

2.将COM组件注册到注册表,使用regsvr32.exe可将它的CLSID与路径添加到注册表的特定位置

3.其他程序使用,使用CoCreateInstance查询加载组件并获取接口的实例对象,或使用QueryInterface定位特定接口实例

4.调用实例对象的方法

5.销毁实例,释放COM组件

COM组件似乎是微软独家的,一般很少遇到,我只在某一年HVV中遇到过一次,某邮箱系统后端全是COM组件,那是个老程序员写的了...所以这里只简单记录下,它一般是CPP写的DLL,可先使用COMRaider扫一下获取函数等信息:
image.png
也可以用ClassInformer读虚表等,对于调试,可以直接写代码调用特定接口,例如:

#include "stdafx.h"
#include "..."
#include<iostream>
using namespace std;

int _tmain(int argc, _TCHAR* argv[])
{
    Interface * Ivar = NULL;
    HRESULT hr = CoInitialize(NULL);

    if(SUCCEEDED(hr))
    {
        hr = CoCreateInstance(CLSID,
            NULL,
            CLSCTX_INPROC_SERVER,
            IID,
            (void **)&Ivar);
        if(SUCCEEDED(hr))
        {
            Ivar->Func(...);
            Ivar->Release();
        }
        else
        {
            cout << "CoCreateInstance Failed." << endl;
        }
    }
    CoUninitialize();
    return 0;
}

之后可以去库里下断,也可以直接在OLEAUT32.dll的DispCallFunc上下断,它会最终调用到实际的函数。更多内容可见COM组件的逆向

参考

  1. 《C++反汇编与逆向分析技术揭秘》-钱林松,赵海旭著
  2. Reversing C++》-Paul Vincent Sabanal,Mark Vincent Yason
  3. VTable Notes on Multiple Inheritance in GCC C++ Compiler v4.0.1》 - Morgan Deters
  4. What does C++ Object Layout Look Like?》/ 《C++ Virtual Table Tables(VTT)》 - nimrod