重新学了一下c++在内存中的实现情况,但是并没有结合题目
以32位 VC++ 6.0 为例,64位或者gcc应该差不多
类与结构体的访问控制
在编写源程序的时候类与结构体的最显著的区别在于类默认的访问控制为private
,而结构体则可以看作默认public
,类又可以设置成员变量访问控制权限为public,private,protected
但是所有访问控制的检查都是在编译期进行的,也就是说在逆向的时候,结构体和类是没有访问控制的区别的
类的大小
一般情况下类的大小即各成员变量大小之和,而其中也有一些特殊情况
- 空类:类中没有数据成员。空类的实例对象大小为1字节,即该实例this指针所指地址
- 内存对齐:一般成员变量的地址是依次排列在类中的,但是对于类中的不同数据类型编译器会按照一定规则填充字节让内存完成8字节对齐、4字节对齐或2字节对齐等。感觉详细规则并不用深究(毕竟不是要开发编译器),具体对齐情况在逆向的时候应该可以一目了然
- 静态数据成员:类中的静态数据成员存放的位置和全局变量一样位于bss段,只是编译器增加了作用域检查,使其在作用域之外不可见,即只能被同类对象共同享有
- 有虚函数:有虚函数的类需要存放虚表,具体见下文
构造函数与析构函数
- 构造函数不可定义返回值,调用构造函数后会返回对象首地址,也就是this指针
- 对象生成时会自动调用构造函数,找到了定义对象的地方就找到了构造函数的调用时机
- 在o2选项优化编译之后,某些结构简单的类会被转化为几个连续定义的变量,故不是所有类都有默认的构造函数
- 析构函数会将this指针作为参数,但是编译器隐藏了这一过程,对于开发者而言析构函数是无参函数
- 在需要调用复制构造函数(拷贝构造函数)的时候,如果没有定义复制构造函数则会直接对副本对象中的成员变量进行复制,也就是进行浅拷贝;否则直接调用定义好的复制构造函数,在定义的复制构造函数中需要处理好分配的堆地址等资源数据,也就是进行深拷贝
不同生命周期的对象构造函数及析构函数调用时机(不讨论直接调用析构函数的情况):
-
局部变量
局部对象在产生时调用构造函数,较容易识别;在对象的作用域结束(函数返回)时调用析构函数
-
堆对象
在c++中调用new、delete函数来为对象成功分配或释放堆地址时会自动调用该类的无参构造函数或析构函数,而malloc、free则没有这样的功能
在保存对象数组的堆地址中前4个字节用来保存该数组中对象的总个数,所以在delete时需要判断释放的内存保存的是否为对象数组
- 如果是对象数组,则依次调用所有对象的析构函数,最后将数组首元素地址减4的堆地址释放
- 如果不是对象数组,则直接调用析构函数并释放堆地址
但是对于基本数据类型而言,没有构造函数和析构函数的问题,则可以直接释放保存数组的堆地址,所以调用
delete
与delete []
效果一样 -
参数对象
参数对象属于局部变量中的一种特殊情况。对象作为函数参数,调用函数时会调用参数对象的复制构造函数,在函数中使用
参数对象在该函数结束时作用域结束,调用析构函数
-
返回对象
返回对象属于局部变量中的一种特殊情况。
对象作为函数返回值,调用函数时编译器会隐式地将返回对象的地址作为函数参数,函数返回时会将函数中局部对象的数据“复制”到参数指向的返回对象中,这里的“复制”带引号,是因为有两种情况,如下getmystring函数返回了一个Mystring类的对象:
-
Mystring mystring = getmystring();
由于函数返回的时候会对刚定义的对象mystring赋值,则此时作为参数的所谓返回对象的地址就是mystring的this指针,在getmystring函数返回前会调用mystring对象的复制构造函数来完成mystring对象的构造
mystring对象作为一个局部对象遵循局部对象的作用域
-
mystring = getmystring();
由于函数返回时mystring对象已经定义过了,则此时作为参数的所谓返回对象的地址是一个临时对象的this指针,在getmystring函数返回前会调用这个临时变量的复制构造函数,函数执行完毕后如果Mystring类中定义了
=
的运算符重载,则调用之;否则直接将临时变量中的成员数据浅拷贝至mystring对象临时对象的作用域为一条高级语句,也就是
;
时会调用其析构函数mystring对象作为一个局部对象遵循局部对象的作用域
用类的指针来传递函数返回值与用对象作为函数返回值不同的是如果指针作为函数返回值则不需要在调用的时候将返回对象的地址作为参数以及在返回的时候调用复制构造函数
可能产生UAF漏洞:
如果类a的构造函数调用了new分配内存,而且没有复制构造函数,也就是在复制时进行浅拷贝,则如果类a的实例A作为某个函数的参数进行传递,在函数调用结束后会造成UAF漏洞
可以通过三种方法缓解:
- 使用深拷贝
- 添加引用计数
- 使用指针进行传参和返回
-
-
全局对象与静态对象
按照《C++反汇编与逆向分析技术揭秘》中介绍的VC++ 6.0编译情况下,其构造函数在main函数之前,析构函数在main函数之后,我认为用处不大,不做记录
虚函数
虚函数的实现
由于上C++课时划水太多、平时学习摸鱼太多,先学习一下虚函数的知识
class本身只是struct的一种衍生方式,而类中的成员函数会被直接编译到text段,对一般成员函数进行调用的时候,会直接跳转到函数在.text
中的地址,而对虚拟成员函数调用,则略微有些复杂
- 定义虚函数是为了用对象的指针或引用来调用虚函数,主要的用法是使用基类的指针来调用子类的这个函数,如果只是直接使用对象调用自身的虚函数,没有必要查表访问
- 每个包含了虚函数的类都包含一个虚表(对于继承了包含虚函数的基类的派生类来说,派生类可以调用基类的虚函数,故派生类也会有自己的虚表,其虚表中的指针会指向其继承的最近的一个类的虚函数),这个类的所有对象都使用同一个虚表,虚表一般保存在
.rodata
,没有写权限 - 虚表是一个指针数组,其元素是虚函数的指针,对象的首地址前4个字节是指向虚表的虚表指针,也就是用于保存对象的内存中偏移为0的地址是指向虚表的虚表指针
- 虚函数的调用过程:程序调用某类的虚函数->通过该类的虚表地址找到该类对应的虚表->从虚表中找到函数地址进行调用
- 虚表中元素的赋值,在编译的时候完成
- 对于有虚函数的类,编译器会在构造函数(如果没有定义构造函数则编译器提供默认的构造函数)中添加一段代码来初始化虚表指针
- 对于有虚函数的类,在析构函数中也会有一段代码来还原虚表指针,让其指向该类虚表的首地址,防止在析构函数中调用虚函数时取到非自身虚表(如子类在析构函数中先调用父类的析构函数,而父类的析构函数需要调用虚函数的情况),导致函数调用错误
贴一张图来表达
再举个栗子
#include <iostream>
#include <string>
using namespace std;
class A {
public:
virtual void vfunc1(){cout << "A::vfunc1()\n";};
virtual void vfunc2(){cout << "A::vfunc2()\n";};
void func1(){cout << "A::func1()\n";};
void func2(){cout << "A::func2()\n";};
private:
int m_data1, m_data2;
};
class B : public A {
public:
virtual void vfunc1(){cout << "B::vfunc1()\n";};
void func1(){cout << "B::func1()\n";};
private:
int m_data3;
};
class C: public B {
public:
virtual void vfunc2(){cout << "C::vfunc2()\n";};
void func2(){cout << "C::func2()\n";};
private:
int m_data1, m_data4;
};
int main(){
A a, *p;
B b;
C c;
p = &b;
p->func1();
p->func2();
p->vfunc1();
p->vfunc2();
cout << endl;
p = &c;
p->func1();
p->func2();
p->vfunc1();
p->vfunc2();
cout << endl;
return 0;
}
这里C继承B、B继承A,输出结果为
A::func1()
A::func2()
B::vfunc1()
A::vfunc2()
A::func1()
A::func2()
B::vfunc1()
C::vfunc2()
三个类对应的虚表分别为
- 由于都有虚函数,所以三个类都有自己的虚表
- B对虚函数vfunc1进行了重写,并从A继承了vfunc2,所以B虚表里的函数指针为
B::vfunc1()
和A::vfunc2()
- C对虚函数vfunc2进行了重写,并从B继承了vfunc1,所以C虚表里的函数指针为
B::vfunc1()
和C::vfunc2()
- 在使用A类型的指针p来调用成员函数的时候,调用没有被定义为虚函数的
func1
和func2
都直接调用了A::func1
和A::func2
_IO_FILE
的vtable这个虚表存在的原因就是因为对于linux中各种不同的IO对象(块设备上的文件,驱动设备,伪文件系统中的文件)虽然都是调用的统一的
fopen()
、fread()
、fclose()
函数,但是其实对于不同的对象,这些函数的实现方法肯定是不一样的,也就是为什么存在虚表的原因了
所以除了可以用_IO_FILE来泄露libc地址以外,还可以伪造其vtable来进行利用,但是libc2.24之后加入了vtable check机制,无法再构造vtable
虚函数的识别
虚函数识别特征:
- 类中隐式定义了一个数据成员
- 该数据成员在首地址处,是一个指针
- 构造函数会将此数据成员初始化为某个数组的首地址
- 这个数组属于
.rodata
,是相对固定的地址 - 这个数组是一个函数指针数组
- 这个数组中的函数在被调用时第一个参数必然是this指针(注意调用约定)
- 在这些函数内部,很可能会对this指针使用相对间接的访问方式
构造函数与析构函数的特征汇编:
;具有成员函数特征,传递对象首地址作为this指针
lea ecx,[ebp-8] ;获取对象首地址
call xxxxxxxxh ;调用函数
;调用函数的实现代码
mov reg,this ;取出首地址前4个字节数据
;向对象首地址处写入4字节数据,查看并确认此字节数据是否为函数地址表的首地址
mov dword ptr [eax], xxxxxxxxh
菱形继承
例如:D类继承于B类、C类,而B类、C类又继承于A类;其中A类为祖父类,B类、C类为父类,D类为子类
它们在继承时一般使用虚继承的方式,可以避免共同派生出的子类产生多义性错误
菱形继承中的子类实例的内存结构发生改变,虚表会记录所有的虚函数
一个栗子:
class A{
...
};
class B : virtual public A {
...
};
class C : virtual public A {
...
};
class D : public B, public C {
...
};
C类实例的内存结构:
0x12ff58 *vt0; ;指向虚表中继承于父类C的虚函数
0x12ff5c *offset0; ;指向记录父类C的相关结构体
0x12ff60 int; ;继承于父类C的成员变量
0x12ff64 *vt1; ;指向虚表中继承于父类B的虚函数
0x12ff68 *offset1; ;指向记录父类B的相关结构体
0x12ff6c int; ;继承于父类B的成员变量
0x12ff70 int; ;继承于父类B的成员变量
0x12ff74 int; ;继承于父类B的成员变量
0x12ff78 *vt2; ;指向虚表中继承于祖父类A的虚函数
0x12ff7c int; ;继承于祖父类A的成员变量
... ...
虚表:
vt2:
0x42501c *func ;继承于祖父类A的虚函数
0x425020 *func ;继承于祖父类A的虚函数
0x425024 *func ;继承于祖父类A的虚函数
vt1:
0x425028 *func ;继承于父类B的虚函数
0x42502c *func ;继承于父类B的虚函数
0x425030 *func ;继承于父类B的虚函数
vt0:
0x425034 *func ;继承于父类C的虚函数
0x425038 *func ;继承于父类C的虚函数
0x42503c *func ;继承于父类C的虚函数
0x425040 *func ;继承于父类C的虚函数
... ...
其中的两个offset结构体:
offset1:
0x425044 0xfffffffc ;-4,是指向虚表中继承于B类的虚函数的指针vt1与该结构体指针offset1的偏移
0x425048 0x00000010 ;16,是指向虚表中继承于A类(B类的父类)的虚函数的指针vt2与该结构体指针offset1的偏移
offset0:
0x425050 0xfffffffc ;-4,是指向虚表中继承于C类的虚函数的指针vt0与该结构体指针offset0的偏移
0x425054 0x0000001c ;28,是指向虚表中继承于A类(C类的父类)的虚函数的指针vt2与该结构体指针offset0的偏移
;如果父类也继承自多个父类,其父类的信息也会记录在该结构体中
... ...
另外在子类构造的时候需要先构造父类,在构造父类时编译器会隐式传入一个参数,用于判断该父类是否已经被构造了,从而避免重复构造
参考资料
《C++反汇编与逆向分析技术揭秘》