前言

虚函数本身就很玄学,像基类只做声明,派生类在定义,然后不同派生类之间还能依次找到不同的。


正文

注:x86环境,非x64

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class AIM{
public:
int HP;
virtual void Eat(){
std::cout << "AIM\n";
}
virtual void Die(){
std::cout << "AIM-DIE\n";
}
};

class WOLF :public AIM{
public:
virtual void Eat(){
std::cout << "WOLF\n";
}
virtual void Die(){
std::cout << "WOLF-DIE\n";
}
void Sound(){
std::cout << "aowu~aowu~\n";
}
};

当用sizeof查看aim类的大小时,发现是占用8个字节,但是按照逻辑,实际上应该是只有成员变量占用了实例化的内存。
而这多出来的4字节,就看要是在成员变量前面还是后面了。

std::cout << aim << " " << &aim->HP << std::endl;

发现这个四字节应该是存在于hp之前,说明大概率和后面的虚函数有关

通过反汇编之后

我们看到类调用一个虚函数会这么麻烦。

他先把aim对象地址上的值传给eax,然后又把eax传给edx。
至于aim又传给ecx先不管,看后面的,edx+4 传给eax之后,就直接call eax了,我们知道call的都是函数的地址。
说明通过edx+4之后偏移得到了die虚函数的地址。
而根本的一切还是从aim开头。

说明这个四字节很有可能就像地址表一样。

然后在调用之前的eat函数

看到基本步骤都差不多,差异就在后面不是edx+4,而是直接传edx。

也就是说这个带有虚函数的类前面多出来的四字节,就是一个指针,指向虚函数表

因为是指针,我们就可以取出来查看。

可以看到利用指针拆分掉这个类,变成两个数组成员,0的位置就是我们类的指针区域。

因为x86的指针大小就是4字节,所以找到vtable[0]这个基地址,再将其拆出两个虚函数的地址。

虽然这两个虚函数的地址可能看着很奇怪,毕竟不是放在同一个地方的。
可以用反汇编去印证一下。

当我们逐语句执行到call eax的时候,就能观察到eax现在经过偏移得到我们调用的die虚函数的地址。

所以对于多态类而言

1
2
3
4
5
6
7
8
9
10
class AIM{
public:
int HP;
virtual void Eat(){
std::cout << "AIM\n";
}
virtual void Die(){
std::cout << "AIM-DIE\n";
}
};
地址 变量名 含义
+0x0 vatble aim类虚函数地址表
+0x4 hp 成员变量

他的内存分布上会有明显差异。

所以虚表的性质

  1. 同一个类的多个实例都指向同一个虚函数表
  2. 通过修改虚函数表的数据可以实现劫持
  3. 只有通过指针访问函数才会调用显示函数表

也就是基类的实例化对象再多,这个虚表都是一个地方,派生类肯定会和基类的虚表地址不同。
修改了这个虚表,就能通过这个数据偏移到其他地方

1
2
3
void hack(){
std::cout << "被劫持辣!\n";
}

随便创个函数,然后修改func[0]为这个函数地址,就有意思了。
不过vs的ide好像有保护,直接写入会报错

额个人是不会处理的,看大哥操作,调用windows先搞掉原有保护

1
2
3
4
5
6
7
8
#include<Windows.h>				//引入头文件

int main(){
//.....
DWORD old;
VirtualProtect(func, 8, PAGE_EXECUTE_READWRITE, &old);
//.....
}

增加这些之后,就可以修改虚地址表了。

可以看到两个指向虚函数的地址都被修改成了hack函数的地址。

至于第一个说性质在这里就能看出来,因为之前的是通过wolf构建初始化的,当我们修改wolf的虚表,是不会对基类造成影响的

至于第三个,像我们这种正常初始化的情况下,他没有指针,也没有通过所谓的虚表偏移去找到虚函数,而是当成一个简单的方法调用

就没用收到之前修改虚表指向hack的问题。


结语

指针。。地址,表还是很玄乎的东西。