C++逆向/反汇编角度的C++对象内存模型

2,287 阅读8分钟

通过ida工具逆向可执行文件可以更深入的理解内存模型与C++实现。
下面的分析基于arm32指令。

一.C++类向后兼容C结构体

class MyClass
{
public:
    int x;
    int y;
    int z;
};
int main(int argc, char const *argv[])
{

    MyClass myclass;
    myclass.x = 0x11;
    myclass.y = 0x22;
    myclass.z = 0x33;
    return 0;
}

上面简单的代码中,类只有三个成员变量x,y,z
ida反汇编的输出:

2021-06-26_15-49.png

可以看到:
1.[sp, #0x18 + var_18] = [sp]处即为对象的地址,也为x成员变量的地址,赋值为0x11
2.[sp, #0x18 + var_14] = [sp, #4]处即y成员变量的地址,赋值为0x22
3.[sp, #0x18 + var_10] = [sp, #8]处即z成员变量的地址,赋值为0x33

因此这种情况下的对象的内存布局信息如下:

框架.png

对象地址也就是x成员变量的地址可以通过打印&myclass和&myclass.x是否相等得到佐证。
从汇编结果还可以得到一个结论: 此时编译器并没有为MyClass类生成一个"nontrivial default constructor",即合成的构造函数。

二.成员函数

添加一个成员函数setZ:

class MyClass
{
public:
    int x;
    int y;
    int z;
    void setZ(int value)
    {
        z = value;
    }
};
int main(int argc, char const *argv[])
{

    MyClass myclass;
    myclass.x = 0x11;
    myclass.y = 0x22;
    myclass.z = 0x33;
    myclass.setZ(0x88);
    return 0;
}

可以看到调用setZ被编译成跳转:BL _ZN7MyClass4setZEi:

2021-06-26_20-45.png

函数_ZN7MyClass4setZEi代码为:

2021-06-26_20-44.png

可以看到成员函数和普通的函数的区别在于第一个参数R0为this指针。

三.静态成员变量和静态成员函数:

添加一个静态成员变量和静态成员函数:

class MyClass
{
public:
    int x;
    int y;
    int z;
    static int s_value;
    void setZ(int value)
    {
        z = value;
    }

    static void set_s_value(int value)
    {
        s_value = value;
    }
};
int MyClass::s_value;

int main(int argc, char const *argv[])
{

    MyClass myclass;
    myclass.x = 0x11;
    myclass.y = 0x22;
    myclass.z = 0x33;
    myclass.setZ(0x88);
    MyClass::set_s_value(0x99);
    return 0;
}

可以看到调用set_s_value被编译为如下代码:

2021-06-26_21-21.png 可以看到函数set_s_value的参数value(值为0x99)被放在R0中,并且调用了函数_ZN7MyClass11set_s_valueEi:

2021-06-26_21-22.png 静态函数就没有this指针了,R0即为第一个参数,R1为第二个参数,R2为第三个参数,依此类推,这里将第一个参数存储到R1寄存器所指向内存地址处,该地址处于.bss section,也就是静态成员变量s_value所处的空间,因为代码中并没有将s_value初始化,所以它占据的空间处于.bss:

2021-06-26_21-29.png 由此可见静态成员变量和具体某个对象所属的内存没有关联,它和c语言中的全局变量差不多。
为什么类的静态函数无法访问类的非静态函数和类的非静态成员变量呢?因为这些访问需要R0做为this指针,通过this指针来访问对象内存,而静态函数没有这样的指针。

四.程序员自己实现的构造与析构:

在没有虚函数和继承的情况下写一个构造函数:

class MyClass
{
public:
    int x;
    int y;
    int z;
    MyClass(int x_value, int y_value, int z_value)
    {
        x = x_value;
        y = y_value;
        z = z_value;
    }
};

int main(int argc, char const *argv[])
{

    MyClass myclass(0x11, 0x22, 0x33);
    myclass.x = 0x111;
    return 0;
}

可以看到构造函数调用的时候传递的参数是四个: R0,R1,R2,R3,分别代表着this指针,第一个参数,第二个参数和第三个参数:

2021-06-26_22-10.png

从这里可以看出其实在构造函数被调用之前对象的内存已经分配好了。
而构造函数的实现则将传递进来的参数依次存储于R0指针所对应的偏移处,且隐含了返回R0 this指针的含义:

2021-06-26_22-08.png

再来看析构函数:

class MyClass
{
public:
    MyClass() {}
    ~MyClass() {}
};
MyClass myclass2;
int main(int argc, char const *argv[])
{

    MyClass myclass;
    return 0;
}

为了看清楚析构函数的调用时机,这里创建了MyClass的两个对象,一个是全局变量,一个在栈中分配。
在栈中分配的对象myclass,它的作用域处于main函数块,它的析构函数在main函数返回之前被调用,从反汇编也可以得出此结论:

2021-06-27_09-46.png

C++和C语言的一个区别是C++中可以创建全局变量并调用某个函数来对其初始化,像这样:

int foo(){
    return 1;
}
int bar = foo();

但是这段代码放在C语言中用gcc编译会报initializer element is not constant
来看一下上面代码中的MyClass myclass2是怎么创建出来的:

2021-06-27_10-05.png 可以看到编译器生成了一个函数__cxx_global_var_init,这个函数的地址在.init_array中注册,这样就可以保证在可执行文件被装载的时候__cxx_global_var_init得到执行,从这个函数的名字也可以看出它是用来初始化c++中的全局变量的,在这个函数中首先调用了MyClass的构造函数创建出myclass:

2021-06-27_10-10.png

再调用__cxa_atexit注册析构函数的地址,这样析构函数就可以在可执行文件执行退出的时候得到调用。 __cxa_atexit函数的原型为:
int __cxa_atexit(void (*destructor) (void *), void *arg, void *__dso_handle);
从ida可以看出第一个参数为析构函数地址,第二个参数为对象地址,第三个参数为Dynamic Shared Object的handle

五.虚函数,虚表与合成的构造函数:

在有虚函数的情况下,内存布局会稍有不同,代码:

class MyClass
{
public:
    int x;
    int y;
    int z;
    virtual void setX(int x_value)
    {
        x = x_value;
    }
};

int main(int argc, char const *argv[])
{
    MyClass myClass;
    myClass.x = 0x11;
    myClass.y = 0x22;
    myClass.z = 0x33;
    myClass.setX(0x44);

    MyClass *ptr_class = &myClass;
    ptr_class->setX(0x55);
    return 0;
}

MyClass其中多了一个虚函数,看一下对应的反汇编:

2021-06-27_16-49.png

得出结论:

  1. 当类定义了虚函数时,如果程序员没有定义构造函数,编译器会生成一个默认构造函数
  2. 当类定义了虚函数时,对象的内存布局不再像c语言的结构体一样,而是发生了一些变化
  3. 通过对象调用虚函数,调用的方式和非虚函数的方式一样:BL 虚函数地址。而通过指针调用虚函数,则需要计算出虚函数的地址然后再BL,即上面的BLX R2

再来看一下编译器生成出来的构造函数做了哪些事情:

2021-06-27_17-03.png

_ZTV7MyClass地址值+8处正是虚函数setX的地址,因此可以得到对象的内存布局,也引入了虚表V-Table的概念:

框架.png

六.继承与重写:

当一个没有虚函数的类继承自另外一个也没有虚函数的类,子类也没有重写的情况,内存布局比较简单,就是子类拥有父类所定义的变量,这里就不再详述了。

看下面的示例:

class BaseClass
{
public:
    int base_x;
    int base_y;
    int base_z;
    void set_base_x(int value)
    {
        base_x = value;
    }

    void set_base_y(int value)
    {
        base_y = value;
    }
};

class MyClass : public BaseClass
{
public:
    int base_y;
    int base_z;
    int sub_a;
    int sub_b;
    void set_base_x(int value)
    {
        base_x = value + 1;
    }
};

![框架.png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4cdf91cf29a848079b99870ed32f0ce5~tplv-k3u1fbpfcp-watermark.image)
int main(int argc, char const *argv[])
{
    MyClass myClass;
    myClass.BaseClass::base_x = 0x11;
    myClass.BaseClass::base_y = 0x22;
    myClass.BaseClass::base_z = 0x33;
    myClass.base_y = 0x44;
    myClass.base_z = 0x55;
    myClass.sub_a = 0x66;
    myClass.sub_b = 0x77;
    myClass.set_base_x(0x88);
    return 0;
}

MyClass继承自BaseClass的三个成员base_x,base_y,base_z,并且重写了两个成员base_y,base_z,且有自己定义的两个成员sub_a和sub_b。

汇编结果:

2021-06-27_18-35.png

从结果得知对象的成员变量内存布局为:

框架.png

七. 无重写情况下的虚函数:

来看一下子类不重写父类的虚函数的代码:

class BaseClass
{
public:
    virtual void f() { cout << "f()" << endl; }

    virtual void g() { cout << "g()" << endl; }

    virtual void h() { cout << "h()" << endl; }
};

class MyClass : public BaseClass
{
public:
    virtual void f1() { cout << "f1()" << endl; }

    virtual void g1() { cout << "g1()" << endl; }

    virtual void h1() { cout << "h1()" << endl; }
};

int main(int argc, char const *argv[])
{
    MyClass myClass;
    return 0;
}

从汇编结果可以看出编译器又生成了一个构造函数,因为子类和父类都有虚函数所以需要编译器合成一个构造函数。
在这个构造函数中做的事情为:
1.调用父类构造函数设置父类的虚表
2.设置子类的虚表

2021-06-27_20-16.png

再来看一下子类虚表的结构:

2021-06-27_20-17.png

上面的代码中先调用了父类的构造函数初始化虚表,紧接着又调用子类的构造函数覆盖掉了在父类中设置了的虚表,这实际是希望得到的结果。

内存结构为:

框架.png

八. 有重写情况下的虚函数及多态:

代码如下:

class BaseClass
{
public:
    virtual void f() { cout << "f()" << endl; }

    virtual void g() { cout << "g()" << endl; }

    virtual void h() { cout << "h()" << endl; }
};

class MyClass : public BaseClass
{
public:
    virtual void f() { cout << "sub f()" << endl; }

    virtual void g1() { cout << "g1()" << endl; }

    virtual void h1() { cout << "h1()" << endl; }
};

int main(int argc, char const *argv[])
{
    BaseClass *cls = new MyClass();
    cls->f();
    return 0;
}

上面的代码中MyClass只重写了f()函数。
汇编代码调用虚函数的时候采用通过查找虚表的方式调用,这样就可以实现多态机制:

2021-06-27_20-37.png

2021-06-27_20-48.png

对象的内存布局为:

框架.png

九.运行时类型识别RTTI:

RTTI是一个编译器实现细节,而不是一个语言问题,编译器没有标准的方法来实现RTTI功能。
这里讨论的实现是GNU g++。
先来写一段使用RTTI的代码:

class BaseClass
{
    virtual int vfunc() = 0;
    virtual int base_func() { return 0; }
};

class MyClass : public BaseClass
{
    int vfunc()
    {
        return 1;
    }
};
void print_type(BaseClass *p)
{
    cout << typeid(*p).name() << endl;
}

int main(int argc, char const *argv[])
{
    BaseClass *base = new MyClass();
    print_type(base);
    return 0;
}

来看一下print_type函数的汇编代码:

2021-06-28_22-08.png

R0为BaseClass*参数,接下来经过一系列的计算并且调用函数_ZNKSt9type_info4nameEv
_ZNKSt9type_info4nameEv函数汇编:

2021-06-28_22-08_1.png

可以看到RTTI相关类型的指针存储在虚表指针的前边,-4地址处。

事实上每个多态对象都包含一个指向虚表的指针,编译器将类的类型信息与类虚表存储在一起。具体来说,编译器在类虚表之前放置一个指针,这个指针指向一个结构体,其中包含用于确定拥有虚表的类的名称所需的信息。这个结构体因编译器的不同而有所不同,在g++中这个结构体的定义为type_info。type_info中必须要有name()函数用于返回一个类型名称的可打印形式。

type_info与类虚表的关系对于上面的示例中如下图示:

框架 (1).png

在逆向C++的过程中通过在.data.rel.ro节中查找RTTI,可以有助于对程序逻辑的分析。

以上的过程中熟悉了c++对象在反汇编情况下的表现和特征,有助于我们在逆向过程中理解源程序的构造与逻辑。