0%

C++反汇编与逆向分析 - 虚函数

对象的多态性需要通过虚表和虚表指针来完成,虚表指针被定义在对象首地址的前四字节,因此虚函数必须作为成员函数使用。由于非成员函数没有this指针,也就无法获得虚表指针,进而无法获得虚表;

运行环境:

  • 操作系统: Windows 7家庭版
  • 编译器:VC6 VS2013

虚函数

虚函数机制

在C++中,使用关键字virtual声明函数为虚函数。当类中定义有虚函数时,编译器会将该类中所有虚函数的首地址保存在一张地址表中,这正表称为虚函数地址表,简称虚表;同时,编译器还会再类中添加一个隐藏数据成员,称为虚表指针。该指针中保存着虚表的首地址;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class CVirtual
{
public:
CVirtual();
~CVirtual();
virtual void Show1();
virtual void Show2();
private:
int m_Numebr;
};

CVirtual::CVirtual()
{
;
}

CVirtual::~CVirtual()
{
;
}

void CVirtual::Show1()
{
printf("This is virtual Func1\n");
}

void CVirtual::Show2()
{
printf("This is virtual Func2\n");
}

int _tmain(int argc, _TCHAR* argv[])
{
CVirtual cv;
cv.Show1();
cv.Show2();
getchar();
return 0;
}




ECX寄存器传递对象的首地址,直接使用ECX寄存器;而01041466处有一个赋值,在构造函数内部,这个地址是指向一个数组:

这些数组中的内容就是虚函数的指针

如果没有虚指针的存在,那么CVirtual大小就是4字节,有了虚指针的存在就是8字节;

我们可以发现,我们并没有实现构造函数,但是编译器自作主张帮我们插入了代码,实现了对虚表的初始化。当我们不提供任何构造篇函数的话,编译器就会提供一个默认的构造函数对虚表进行初始化

当函数被调用时,会间接访问虚表,得到对应的虚函数的地址,并调用执行。这种通过虚表间接寻址的情况只有在使用对象的指针或引用这个虚函数的时候才出现。当直接使用对象调用自身的虚函数时,没有必要查表访问。因为已经明确调用自身的成员函数了,没有构成多态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class CVirtual
{
public:
CVirtual();
~CVirtual();
CVirtual(int n);
virtual void Show1();
virtual void Show2();
private:
int m_Numebr;
};

CVirtual::CVirtual()
{
;
}

CVirtual::~CVirtual()
{
;
}

CVirtual::CVirtual(int n)
{
m_Numebr = n;
}

void CVirtual::Show1()
{
printf("This is virtual Func1\n");
}

void CVirtual::Show2()
{
printf("This is virtual Func2\n");
}

class CVirtualS : public CVirtual
{
public:
CVirtualS();
~CVirtualS();
CVirtualS(int n) : CVirtual(n)
{
;
}

private:

};

CVirtualS::CVirtualS()
{
;
}

CVirtualS::~CVirtualS()
{
;
}



int _tmain(int argc, _TCHAR* argv[])
{
CVirtual cv;
cv.Show1();
CVirtual *pcv = new CVirtualS();
pcv->Show1();
getchar();
return 0;
}


直接通过对象调用虚表的时候,就是直接用对象的地址作为隐式参数传递给这个虚函数:

1
2
3
4
5
6
7
	CVirtual cv;
00DF417D lea ecx,[cv]
00DF4180 call CVirtual::CVirtual (0DF11EAh)
00DF4185 mov dword ptr [ebp-4],0
cv.Show1();
00DF418C lea ecx,[cv]
00DF418F call CVirtual::Show1 (0DF11C7h)

这个时候虚函数和普通成员函数没有区别,之所以要隐含传递都西昂的地址,是为了能够准确的使用对象中所包含的数据成员;

但是如果构成了多态,那么调用方式不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
	CVirtual *pcv = new CVirtualS();
00DF4194 push 8 ;对象大小
00DF4196 call operator new (0DF122Bh) ;new
00DF419B add esp,4
00DF419E mov dword ptr [ebp-0FCh],eax ;new申请地址返回到eax中,eax保存对象首地址
00DF41A4 mov byte ptr [ebp-4],1 ;1是确定它是一个对象
00DF41A8 cmp dword ptr [ebp-0FCh],0 ;确定是否申请成功地址
00DF41AF je wmain+84h (0DF41C4h) ;申请失败则跳转
00DF41B1 mov ecx,dword ptr [ebp-0FCh] ;对象首地址存入ecx中
00DF41B7 call CVirtualS::CVirtualS (0DF1221h) ;构造函数,返回值存入eax,这个返回值无法被我们使用
00DF41BC mov dword ptr [ebp-110h],eax ;CVirtualS首地址
00DF41C2 jmp wmain+8Eh (0DF41CEh) ;
00DF41C4 mov dword ptr [ebp-110h],0
00DF41CE mov eax,dword ptr [ebp-110h] ;
00DF41D4 mov dword ptr [ebp-108h],eax
00DF41DA mov byte ptr [ebp-4],0
00DF41DE mov ecx,dword ptr [ebp-108h]
00DF41E4 mov dword ptr [pcv],ecx ;CVirtualS首地址传递给指针pcv
pcv->Show1();
00DF41E7 mov eax,dword ptr [pcv] ;间接调用需用
00DF41EA mov edx,dword ptr [eax]
00DF41EC mov esi,esp
00DF41EE mov ecx,dword ptr [pcv]
00DF41F1 mov eax,dword ptr [edx]
00DF41F3 call eax

间接调用的两种方式:

  • call reg
  • call [xxx]

实际上我们并不知道pcv指针指向的具体类型说什么,所以要在需表中找到真正对象的虚函数;

虚表指针的初始化,是判断一个函数是构造函数的充分条件

为什么构造函数需要使用函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// Test.cpp : 定义控制台应用程序的入口点。
//

#include "stdafx.h"

class CVirtual
{
public:
CVirtual();
~CVirtual();
CVirtual(int n);
virtual void Show1();
virtual void Show2();
private:
int m_Numebr;
};

CVirtual::CVirtual()
{
;
}

CVirtual::~CVirtual()
{
Show1();
Show2();
printf("Destructor\n");
}

CVirtual::CVirtual(int n)
{
m_Numebr = n;
}

void CVirtual::Show1()
{
printf("This is virtual Func1\n");
}

void CVirtual::Show2()
{
printf("This is virtual Func2\n");
}

class CVirtualS : public CVirtual
{
public:
CVirtualS();
~CVirtualS();
CVirtualS(int n) : CVirtual(n)
{
;
}

private:

};

CVirtualS::CVirtualS()
{
;
}

CVirtualS::~CVirtualS()
{
Show1();
Show2();
printf("Destructor\n");
}



int _tmain(int argc, _TCHAR* argv[])
{
/*
CVirtual cv;
cv.Show1();
*/
CVirtual *pcv = new CVirtualS();
delete pcv;
getchar();
return 0;
}


如果我们执行delete 指针以后,会出现以下流程:

如果当我们把析构函数做虚函数时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// Test.cpp : 定义控制台应用程序的入口点。
//

#include "stdafx.h"

class CVirtual
{
public:
CVirtual();
virtual ~CVirtual();
CVirtual(int n);
virtual void Show1();
virtual void Show2();
private:
int m_Numebr;
};

CVirtual::CVirtual()
{
;
}

CVirtual::~CVirtual()
{
Show1();
Show2();
printf("Destructor\n");
}

CVirtual::CVirtual(int n)
{
m_Numebr = n;
}

void CVirtual::Show1()
{
printf("This is virtual Func1\n");
}

void CVirtual::Show2()
{
printf("This is virtual Func2\n");
}

class CVirtualS : public CVirtual
{
public:
CVirtualS();
virtual ~CVirtualS();
CVirtualS(int n) : CVirtual(n)
{
;
}

private:

};

CVirtualS::CVirtualS()
{
;
}

CVirtualS::~CVirtualS()
{
Show1();
Show2();
printf("Destructor\n");
}



int _tmain(int argc, _TCHAR* argv[])
{
/*
CVirtual cv;
cv.Show1();
*/
CVirtual *pcv = new CVirtualS();
delete pcv;
getchar();
return 0;
}


可以发现,如果不把析构函数作为虚函数时,只调用了父类的虚函数;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
	delete pcv;
01134276 mov eax,dword ptr [pcv]
01134279 mov dword ptr [ebp-0E0h],eax
0113427F mov ecx,dword ptr [ebp-0E0h]
01134285 mov dword ptr [ebp-0ECh],ecx
0113428B cmp dword ptr [ebp-0ECh],0
01134292 je wmain+0D9h (011342B9h)
01134294 mov esi,esp
01134296 push 1
01134298 mov edx,dword ptr [ebp-0ECh]
0113429E mov eax,dword ptr [edx]
011342A0 mov ecx,dword ptr [ebp-0ECh]
011342A6 mov edx,dword ptr [eax]
011342A8 call edx

delete删除指针的时候调用的子类的虚函数,则子类的虚函数外部又调用了弗雷的虚函数。而调用父类的虚函数之前,ecx指针中仍保留的是子类对象的首地址;

子类的析构函数调用自身的虚成员函数:

子类对象调用父类对象的析构函数:

在地址00BE1566处并没有间接赋值,而是直接调用Show1和Show2;

无论是子类对象还说父类对象的析构函数中都会有把当前的虚表的首地址赋值到虚表指针当中去,这是为了防止调用虚函数时取到非自身的虚表

我们先查看调用A的构造函数:

我们查看this指针的地址,因为它会填充到这个地址中;

继续调用虚函数:

调用完A的构造函数,继续往下执行构造函数中其他的部分,为了能够继续调用B的func2,必须还原虚表;

虚函数的识别

1)特征:

1、类中隐式定义了一个数据成员;

2、该数据成员在首地址处;

3、构造函数会将此数据成员初始化为某个数组的首地址;

4、这个地址属于数据区,是相对固定的地址;

5、数组内每个元素都是函数的指针;

6、数组中的这些函数被调用时,第一个参数必然是this指针;

7、这些函数内部有可能对this指针进行相对间接的访问。

2)验证父类和子类的虚表指针:

初始化父类后,父类的两个虚函数地址为:

我们发现,A的虚表中和B的虚表中的第一个函数地址是相同的,不同的是第二个函数的地址。在构造B的时候先构造A,而在构造A的时候要赋值一个虚表指针,是为了防止在A的构造函数中使用使用了虚函数,而无意间调用了B的虚函数。而实际上,构造完B之后,B中就不存在刚刚A的那个虚表指针了。

构造函数不可以是虚函数

  1. 虚函数对应一个虚指针,虚指针其实是存储在对象的内存空间的。如果构造函数是虚的,就需要通过 虚指针执行那个虚函数表(编译期间生成属于类)来调用,可是对象还没有实例化,也就是内存空间还没有,就没有虚指针,所以构造函数不能是虚函数。

  2. 虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,

因此也就规定构造函数不能是虚函数。

  1. 讲道理

(1) 父类A 派生出子类B

(2) A *pA = &B

(3) A 类指针析构,调用的是谁的析构

(4) 如果析构不是虚的,调用A的析构

(5) 实际上要析构B

(6) 有残留

  1. 如果构造变成虚的

(1) A *pA = new B

(2) 构造先构造A,再构造B (3)如果是虚的,B先构造了

没有间接访问,不会虚调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
C++源码
void Show(CMyString str)
{
printf(str.GetString());
}

virtual char* GetString();

0. 申请了虚函数
1. 如果没有使用指针,引用方式,去访问对应的成员函数
2. 使用名称粉碎,call到固定地址
call @ILT+55(CMyString::GetString) (0040103c)

3. C++里面管理成员函数通过名称粉碎来做的 OBJ
|成员函数名|所属作用域名|参数列表|调用约定Y 返回值
?SetString@CMyString@@UAEPADPAD@Z??2@YAPAXI@Z

4. 另一种观察名称粉碎的办法
Setting ‐‐> C\C++ ‐‐> listing File ‐‐> ListFile type



5. 什么时候使用名称粉碎的机制?
(1) 点调用的时候,str.GetString()
(2) 编译器获得了对象类型,函数类型,参数列表等
6. Show函数名称粉碎,参数为对象Show@@YAXVCMyString@@@Z

6.1 Show函数名称粉碎,参数为对象指针
?Show@@YAXPAVCMyString@@@Z

6.2 Show函数名称粉碎,参数为对象引用,底层是指针
?Show@@YAXAAVCMyString@@@Z
分析:
YAX 表示调用约定
V 对 象
PAV 对象指针
AAV 对象引用
7. 对于编译器来说是个模糊查找


使用指针或引用访问成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
. 成员函数是虚函数时,会产生虚调用

printf(pStr.GetString());

// 首先拿到对象首地址
mov eax, DWORD PTR _pStr$[ebp]

// 取首4字节给EDX
mov edx, DWORD PTR [eax] mov esi, esp
mov ecx, DWORD PTR _pStr$[ebp]

// 间接调用虚表内的函数指针
call DWORD PTR [edx+8]

//‐‐‐‐‐‐VC6 DEBUG‐‐‐‐‐‐‐‐‐‐‐‐‐
// 传递this指针
mov ecx,dword ptr [ebp+8]

// 调用函数
call dword ptr [edx+8]
call [xxx] call reg

2. 对象的前4字节,就是虚表首地址,函数指针数组首地址
3. 总结步骤
(1) 先访问对象的前4字节
(2) 前4字节,间隔偏移,做个间接访问,call

4. 是在,全局,只读数据区 0x0042xxxx
5. 虚表(vtptr\_vfptr)里面都是代码段的地址
6. 虚函数肯定是成员函数,没有静态虚函数
7. 虚函数需要this指针 查虚表
8. 虚调用的触发条件
(1) 别调用函数被声明为virtual
(2) 调用时使用对象的指针或引用
(3) 其他情况将会结合名称粉碎机制
(4) 产生直接调用的代码
(5) 直接调用,不产生多态行为和效果

发生函数指针错误

如果调用函数前,后栈不平衡

1
2
3
4
5
mov esi,esp
...
cmp esi,esp
;会出现错误窗口
call __chkesp

虚函数识别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
1.	通过虚表
2. 通过调用 VC6 Release 高级版 没区别
mov ecx,[esp + arg_0] mov eax,[ecx]
call dword ptr [eax+8]

总结:
1. 首先识别出虚调用来
2. 阅读代码找到this指针位置
3. 找到虚表位置
4. 一网打尽所有虚函数



1. 写了构造
2. 有虚函数,一定有构造
VC6 debug 有虚函数
CTest t;
lea ecx,[ebp‐14h]
call @ILT+80(CTest::CTest)
...
1. 能鉴定 后面offset 是虚表
2. 有填写虚表指针的动作(冲分条件)
3. 是构造函数,一定是有虚函数
mov eax,dword ptr [ebp‐4]
mov dword ptr [eax],offset CTest::`vftable' (00425070)
...

4. 析构里面也会填写虚表指针
mov eax,dword ptr [ebp‐4]
mov dword ptr [eax],offset CTest::`vftable' (00425050)
5. 没有默认析构

图解虚函数需要构造原理

虚函数基本机制