C++ 虚函数解析
一;普通函数与虚函数的比较
我们都知道,可以在类当中定义函数,当然也可以利用关键字“virtual”定义虚函数。接下来,我们使用如下代码看看,在汇编层当中两者之间有什么区别呢?
#include <stdio.h>
class a
{
public:
int b;
void c() {
int c = 1;
printf("%d\n", c);
}
virtual void d() {
int d = 2;
printf("%d\n", d);
}
};
int main() {
a test;
a* p = &test;
p->c();
p->d();
return 0;
}
代码当中定义了一个类,其中包含了函数c()以及虚函数d()。接下来,我们在“p->c();”位置下断点,然后转到汇编层来看看。我们看到如下代码:
21: p->c();
00FD1A30 mov ecx,dword ptr [p]
00FD1A33 call a::c (0FD10FFh)
22: p->d();
00FD1A38 mov eax,dword ptr [p]
00FD1A3B mov edx,dword ptr [eax]
00FD1A3D mov esi,esp
00FD1A3F mov ecx,dword ptr [p]
00FD1A42 mov eax,dword ptr [edx]
00FD1A44 call eax
00FD1A46 cmp esi,esp
00FD1A48 call __RTC_CheckEsp (0FD1258h)
1.1;普通函数的汇编代码
首先,我们来看在调用c()函数的对应的汇编代码
00FD1A30 mov ecx,dword ptr [p]
00FD1A33 call a::c (0FD10FFh)
我们来看一下,它call 指令后面跟了一个地址“0FD10FFh”。那么我们来跟踪一下这个地址。麻烦这个地址对应的指令是一个jmp指令。
a::c:
00FD10FF jmp a::c (0FD1810h)
接着,我们继续跟踪,发现,该跳转的地址就是我们类当中c()函数的地址。
7: void c() {
00FD1810 push ebp
00FD1811 mov ebp,esp
00FD1813 sub esp,0D8h
00FD1819 push ebx
00FD181A push esi
00FD181B push edi
00FD181C push ecx
总结来说:普通函数,是直接Call一个固定的地址。该固定的地址最终是指向了类调用函数的地址。
1.2;虚函数
接下来我们来看看虚函数所对应的汇编代码,初次看,我们就发现,它对应的汇编代码行数上就比普通函数要多。那么,我们就慢慢来看。
00FD1A38 mov eax,dword ptr [p]
00FD1A3B mov edx,dword ptr [eax]
00FD1A3D mov esi,esp
00FD1A3F mov ecx,dword ptr [p]
00FD1A42 mov eax,dword ptr [edx]
00FD1A44 call eax
我们主要关注“mov eax,dword ptr [p]”,"mov edx,dword ptr [eax]","mov eax,dword ptr [edx]","call eax"。首先它指针p当中的值放入到寄存器edx当中。最后在从寄存器当中将值取出放入寄存器eax 当中,并且执行call eax 指令。
总结来说:虚函数执行的call 指令后面并不是一个绝对的地址,而是寄存器eax , 其中eax 的值是什么地址,那么将执行什么函数。
二;同类多虚函数情况
我们在同一个类当中定义多个虚函数,那么在执行调用,在底层会是一个什么样的情况呢?我们使用下面案例来测试一下:
#include <stdio.h>
class a
{
public:
int b;
void c() {
int c = 1;
printf("%d\n", c);
}
virtual void d() {
int d = 2;
printf("%d\n", d);
}
virtual void e() {
int e = 3;
printf("%d\n", e);
}
virtual void f() {
int f = 4;
printf("%d\n", f);
}
};
int main() {
a test;
a* p = &test;
p->c();
p->d();
p->e();
p->f();
return 0;
}
我们在调用虚函数位置使用断点,执行并且进入到“反汇编”窗口,详细代码如下:
29: p->c();
00E34C30 mov ecx,dword ptr [p]
00E34C33 call a::c (0E310FFh)
30: p->d();
00E34C38 mov eax,dword ptr [p]
00E34C3B mov edx,dword ptr [eax]
00E34C3D mov esi,esp
00E34C3F mov ecx,dword ptr [p]
00E34C42 mov eax,dword ptr [edx]
00E34C44 call eax
00E34C46 cmp esi,esp
00E34C48 call __RTC_CheckEsp (0E31258h)
31: p->e();
00E34C4D mov eax,dword ptr [p]
00E34C50 mov edx,dword ptr [eax]
00E34C52 mov esi,esp
00E34C54 mov ecx,dword ptr [p]
00E34C57 mov eax,dword ptr [edx+4]
00E34C5A call eax
00E34C5C cmp esi,esp
00E34C5E call __RTC_CheckEsp (0E31258h)
32: p->f();
00E34C63 mov eax,dword ptr [p]
00E34C66 mov edx,dword ptr [eax]
00E34C68 mov esi,esp
00E34C6A mov ecx,dword ptr [p]
00E34C6D mov eax,dword ptr [edx+8]
00E34C70 call eax
00E34C72 cmp esi,esp
00E34C74 call __RTC_CheckEsp (0E31258h)
我们来对比调用虚函数d(),e(),f() ,大体上的汇编代码是一样的,有区别的是在每次调用的第5行。熟悉汇编代码的人可能很快就知道这是利用定长偏移来寻址的一种方式(即:[ebp]表示某个地址A,[ebp+4]即表示A地址下一位地址。)。为了结论的严谨,这里我们耐心的追踪一下。
2.1 p->d()
我们首先追踪一下第一个虚函数d(),首先会将指针p里面的内容放入寄存器EAX,然后将寄存器EAX当中的值作为地址来搜索内容并且放入寄存器EDX。
此时,EAX=1EFA20,EDX=E37BEC
直到执行到地址“00E34C44”在寄存器EDX的值为地址去两个字大小的内容放入寄存器EAX。
我们来看一下,此时EAX是指的哪个指令,它指向了一个jmp 指令(地址:00E3135C)。
继续执行跟踪跳转后的地址,最终我们来到了虚函数d()的代码位置。
2.2p->e()
刚刚我们已经分析了。在分析“p->d()”虚函数的时候,寄存器EDX当中存储的值作为地址寻址操作,最终定位的位置正是虚函数d()代码存储的位置。那么接下来我们看看虚函数e(),同样我们将代码执行到地址“00E34C5A”位置。此时我们发现EAX的值为00E313DE。
同样的,我们追踪一下,发现代码定位到jmp 指令位置。详细如下图:
继续更新,我们发现同样定位到虚函数e()
2.3p->f()
同样的追踪过程,可以定位到虚函数f()
2.4 总结
通过刚刚的定位,我们都可以找到对应的虚函数,那么,这些虚函数有什么关系呢?我们首先观察指令“mov eax,dword ptr [p]”,“ mov edx,dword ptr [eax] ”。定位到该地址,然后在内存当中找到对应位置:
此时,EDX=00E37BEC。那么EDX+4 = 00E37BF0,EDX+8 = 00E37BF4,所以值为[EDX+4]= 00e313de,[EDX+8]= 00e313e3 。 我们将这些值当做地址来寻址,发现正好对应的就是虚函数的JMP跳转地址。
所以,我们知道了。在调用虚函数的时候,首先会将多个虚函数用一些JMP指令指向,存储这些jmp指令的地址连续的存储在一个地址空间当中。(这个地址空间,我们通常认为这是虚函数地址表)。在调用对应虚函数之前,首先将虚函数地址表的地址放入寄存器EDX当中。通过偏移定长来决定选择哪个虚函数。详细如下图:
[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界
最后于 2021-4-13 15:37
被天象独行编辑
,原因: