学过c++一段时间都会知道,c++是依靠虚函数实现多态的,如下代码:
- #include <iostream>
- using namespace std;
- class Base
- {
- public:
- virtual void Print()
- {
- cout<<"^-^"<<endl;
- }
- };
- class Derive:public Base
- {
- public:
- virtual void Print()
- {
- cout<<"T-T"<<endl;
- }
- };
- int main()
- {
- Base *p=new Derive();
- p->Print();
- }
呵呵,输出T-T~~~~~~
对于理解虚函数的实现原理,历来是一个c++新手到中手的必经之路之一,关于其实现原理,个人推荐《深入探索c++对象模型》这本书,
原理讲的很透彻的。现在分析一下一些主流的编译器的具体实现方式,并从汇编的角度来分析编译器的虚函数的实现原理(最近找c/c++工作,估计虚函数被问到可能性很高~~)。
首先c++标准仅仅规定了虚函数的行为,并没有规定这种行为的具体实现,但目前主流的编译器(vc,g++)在实现上达成了一定默契,都是通过在对象前4个字节安插一个虚表指针,
这个虚表指针指向对应类的虚表,在调用虚函数时,通过虚表指针查找虚表最终获得要调用的函数的,这也就是动态绑定的底层实现方式。
以下是vc10默认编译选项debug下上面程序的反汇编:
- 257: int main()
- 258: {
- 01031500 push ebp
- 01031501 mov ebp,esp
- 01031503 sub esp,0DCh
- 01031509 push ebx
- 0103150A push esi
- 0103150B push edi
- 0103150C lea edi,[ebp-0DCh]
- 01031512 mov ecx,37h
- 01031517 mov eax,0CCCCCCCCh
- 0103151C rep stos dword ptr es:[edi];开启堆栈帧(/RTCs)后,就会有类似的汇编,将未
- ;初始化的局部变量值初始化为cc,一个int 3指令
- ;如果输出未初始化的一个int 变量,值就是-858993460
- ;因为数据以补码保存,-858993460补码就是0xcccccccc
- ;输出未初始化的字符则输出 烫,这就是我们debug时,
- ;内存里老多 烫烫烫烫烫烫的原因。
- 259: Base *p=new Derive();
- 0103151E push 4 ;operator new函数参数入栈,即要为Derive对象分配四个字节的空间。
- 01031520 call operator new (1031208h) ;调用operator new函数
- 01031525 add esp,4 ;__cdecl调用约定,函数调用者,调整栈帧,
- 01031528 mov dword ptr [ebp-0D4h],eax;将operator new 函数返回结果存入dword ptr[ebp-0D4h]
- ;这段空间,operator new结果返回一个指针,指向分配的内
- ;存的地址,vc中整形或者能隐式转化成整形的返回值放入eax
- 0103152E cmp dword ptr [ebp-0D4h],0 ;测试返回值是否为0
- 01031535 je main+4Ah (103154Ah) ;为0则调转
- 01031537 mov ecx,dword ptr [ebp-0D4h];将operator new 分配的内存地址放入ecx,vc中成员函数
- ;调用时this指针存入ecx的。
- 0103153D call Derive::Derive (1031127h) ;调用构造函数,在构造函数里完成虚表指针的初始化,
- ;由于没有显时定义默认构造函数,所以编译器负责生成一个
- 01031542 mov dword ptr [ebp-0DCh],eax ;编译器生成的默认构造函数中,将构造好虚表指针的对象的地
- ;址放入了eax,所以这句相当于取对象的地址。编译器生成的
- ;默认构造函数代码稍后介绍
- 01031548 jmp main+54h (1031554h) ;跳过下一条指令的执行。
- 0103154A mov dword ptr [ebp-0DCh],0;如果走这条指令说明是je main+4Ah (103154Ah)
- ;跳转过来的,说明内存分配失败,这条指令的作用就是将p值设为0,
- ;也就是this值设为0,以期望this+偏移访问数据时触发一个异常。
- 01031554 mov eax,dword ptr [ebp-0DCh] ;如果内存分配没有问题的话,那么dword ptr [ebp-0DCh]
- ;保存的是对象的地址值。
- 0103155A mov dword ptr [p],eax ;把对象的地址值赋给dword ptr [p]这段空间,下面的代码就是
- ;就是通过虚表指针查找虚表的关键代码了,要说关键点了
- 260: p->Print();
- 0103155D mov eax,dword ptr [p] ;将对象的地址值存入eax,现在eax=p(p指向对象的起始地址)
- 01031560 mov edx,dword ptr [eax] ;通过eax寻址,对应的操作是从eax对应的地址值开始往高地址涵盖
- ;双字,即4个字节,将这4个字节里的数据按照整形方式读出赋给edx
- ;相当于edx=*(int*)p,前面说了对象的前4个字节是为虚表指针所分
- ;配的空间,这句指令相当于获取虚表指针的值。
- 01031562 mov esi,esp
- 01031564 mov ecx,dword ptr [p] ;this指针存入ecx
- 01031567 mov eax,dword ptr [edx];查找虚表的操作,跟上面的分析一样,从edx对应地址值开始,往高
- ;地址涵盖4个字节的内存,读出这段内存里的数据,可以知道edx的值
- ;即是虚表指针的值,虚表指针指向一个虚表,虚表的地址假设为
- ;0x00100000,那么0x00100000~0x00100003是存储第一个虚函数的
- ;地址,0x00100004~0x00100007是存储第二个虚函数的地址.....
- ;这条指令即是获取第一个虚函数的地址,eax=*(int*)*(int*)p
- ;现在eax值是一个合法函数指针的值了
- 01031569 call eax ;进行函数调用
- 0103156B cmp esi,esp
- 0103156D call @ILT+435(__RTC_CheckEsp) (10311B8h)
- 261: }
- 01031572 xor eax,eax
- 01031574 pop edi
- 01031575 pop esi
- 01031576 pop ebx
- 01031577 add esp,0DCh
- 0103157D cmp ebp,esp
- 0103157F call @ILT+435(__RTC_CheckEsp) (10311B8h)
- 01031584 mov esp,ebp
- 01031586 pop ebp
- 01031587 ret
通过上述分析,类似*(int*)*(int*)p这样的表达式来获取虚表中函数方法大家应该明白了吧,这个地方确实是考察指针应用的基本功的。
void(*f)()=(void(*)())*(int*)*(int*)p;
f();
最终调用的是Derive::Print();很显然*(int*)(*(int*)p+4)是虚表中第二个函数地址地址值,如果有的话~~~~~
下面来看下,编译器生成的构造函数里到底做了些什么,
Derive::Derive:
01031127 jmp Derive::Derive (10315B0h)
找到内存10315B0h处的汇编指令:
- Derive::Derive:
- 010315B0 push ebp
- 010315B1 mov ebp,esp
- 010315B3 sub esp,0CCh
- 010315B9 push ebx
- 010315BA push esi
- 010315BB push edi
- 010315BC push ecx
- 010315BD lea edi,[ebp-0CCh]
- 010315C3 mov ecx,33h
- 010315C8 mov eax,0CCCCCCCCh
- 010315CD rep stos dword ptr es:[edi]
- 010315CF pop ecx
- 010315D0 mov dword ptr [ebp-8],ecx;将ecx中保存的this指针值存入dword ptr [ebp-8]
- 010315D3 mov ecx,dword ptr [this] ;this指针存入ecx,调用成员函数用,单继承下
- ;dword ptr [this]和dword ptr [ebp-8]值是一样
- 010315D6 call Base::Base (1031131h);调用基类构造函数
- 010315DB mov eax,dword ptr [this] ;dword ptr [this]指向对象已通过Base::Base
- ;进行了初始化,此时虚表指针指向了父类的虚表
- 010315DE mov dword ptr [eax],offset Derive::`vftable' (1037834h)
- ;将dword ptr [this]指向对象的虚表指针修改成
- ; Derive::`vftable' ,dword ptr [this]相当于
- ;一个对象指针,假设为p,这句指令相当于*(int*)p=
- ;Derive::`vftable' .
- 010315E4 mov eax,dword ptr [this] ;将初始化好的对象地址存入eax,相当于设置返回值
- 010315E7 pop edi
- 010315E8 pop esi
- 010315E9 pop ebx
- 010315EA add esp,0CCh
- 010315F0 cmp ebp,esp
- 010315F2 call @ILT+435(__RTC_CheckEsp) (10311B8h)
- 010315F7 mov esp,ebp
- 010315F9 pop ebp
- 010315FA ret
看一下Base::Base (1031131h);汇编代码
- Base::Base:
- 01031690 push ebp
- 01031691 mov ebp,esp
- 01031693 sub esp,0CCh
- 01031699 push ebx
- 0103169A push esi
- 0103169B push edi
- 0103169C push ecx
- 0103169D lea edi,[ebp-0CCh]
- 010316A3 mov ecx,33h
- 010316A8 mov eax,0CCCCCCCCh
- 010316AD rep stos dword ptr es:[edi]
- 010316AF pop ecx
- 010316B0 mov dword ptr [ebp-8],ecx
- 010316B3 mov eax,dword ptr [this]
- 010316B6 mov dword ptr [eax],offset Base::`vftable' (1037844h)
- 010316BC mov eax,dword ptr [this] ;和Derive类似,也有一个设置虚表指针的操作
- 010316BF pop edi
- 010316C0 pop esi
- 010316C1 pop ebx
- 010316C2 mov esp,ebp
- 010316C4 pop ebp
- 010316C5 ret
分析到这里,相信大家对虚函数调用有个基本的认识了,编译器在实现虚函数时,主要有以下步骤:
1 编译时,根据类的声明,生成一个虚函数表
2 创建对象时,编译器会在类的构造函数内安插一部分代码,用来初始化对象的虚表指针,一般(vc g++)在进入构造函数
开始部分便安插代码。
3 当以指针或引用来调用虚函数时便激活动态绑定,实质是一个通过虚表指针查找函数的过程
所以类似这样代码Derive(){memset(this,0,sizeof(Derive));}将是灾难性的~~~
由于虚函数的实现要借助构造函数,所以构造函数不能是虚拟函数~~~
最后介绍两个关于c++虚函数的hack的简单程序,以加深编对译器实现虚函数机制的了解~~~~
- #include <iostream>
- #include <vector>
- using namespace std;
- class Base
- {
- public:
- virtual void PrintA()
- {
- cout<<"^-^"<<endl;
- }
- virtual void PrintB()
- {
- cout<<"T-T"<<endl;
- }
- };
- class Derive:public Base
- {
- public:
- virtual void PrintA()
- {
- cout<<":)"<<endl;
- }
- virtual void PrintB()
- {
- cout<<":("<<endl;
- }
- };
- void Hack1()
- {
- cout<<"Hack1"<<endl;
- }
- void Hack2()
- {
- cout<<"Hack2"<<endl;
- }
- int main()
- {
- Base *p=new Derive();
- int *pVtable[2]={(int*)Hack1,(int*)Hack2};//构造一个虚表
- *(int*)p=(int)pVtable;//设置虚表指针
- p->PrintA();
- p->PrintB();
- system("pause");
- }
很显然通过修改虚表指针来劫持程序,下面来通过修改虚表来劫持程序~~~~~~~~~~~~~~
- #include <iostream>
- #include <Windows.h>
- using namespace std;
- class Base
- {
- public:
- virtual void PrintA()
- {
- cout<<"^-^"<<endl;
- }
- virtual void PrintB()
- {
- cout<<"T-T"<<endl;
- }
- };
- class Derive:public Base
- {
- public:
- virtual void PrintA()
- {
- cout<<":)"<<endl;
- }
- virtual void PrintB()
- {
- cout<<":("<<endl;
- }
- };
- void Hack1()
- {
- cout<<"Hack1"<<endl;
- }
- int main()
- {
- Base *p=new Derive();
- int PrintAAdress=*(int*)*(int*)p;//获取PrintA在虚表中的地址值
- int PrintBAdress=*(int*)(*(int*)p+4);//获取PrintB在虚表中的地址值
- //vc debug下函数指针值和函数名对应的地址开始,存放是一个jmp指令
- //对应机器吗是0xe9
- if (*(unsigned char*)PrintAAdress==0xe9)
- {
- DWORD d;
- int PrintBOffset=*(int*)(PrintBAdress+1);//获取jmp指令后立即数的值
- int Hack1Offset=*(int*)((int)Hack1+1);
- //jmp 后立即数是相对于本条jmp指令的偏移,这里想把虚表的PrintA地址修
- //改成PrintB,所以重新计算偏移
- int diff=PrintBOffset-(PrintAAdress-PrintBAdress);
- WriteProcessMemory(GetCurrentProcess(),(int*)(PrintAAdress+1), &diff, 4, &d);
- diff=Hack1Offset-(PrintBAdress-(int)Hack1);
- WriteProcessMemory(GetCurrentProcess(),(int*)(PrintBAdress+1), &diff, 4, &d);
- //release下函数指针和函数名的值就是函数对应汇编指令的起始地址
- }else{
- DWORD dwIdOld;
- HANDLE hProcess=OpenProcess(PROCESS_ALL_ACCESS,1,GetCurrentProcessId());
- //把对应的内存页修改成可读写的,debug下权限比较大,所以可以直接读写
- VirtualProtectEx(hProcess,(int*)*(int*)p,4,PAGE_READWRITE,&dwIdOld);
- WriteProcessMemory(hProcess,(int*)*(int*)p, &PrintBAdress, 4, 0);
- VirtualProtectEx(hProcess,(int*)*(int*)p,4,dwIdOld,&dwIdOld);
- int Hack1Adress= (int)Hack1;
- VirtualProtectEx(hProcess,(int*)(*(int*)p+4),4,PAGE_READWRITE,&dwIdOld);
- WriteProcessMemory(hProcess,(int*)(*(int*)p+4), &Hack1Adress, 4, 0);
- VirtualProtectEx(hProcess,(int*)(*(int*)p+4),4,dwIdOld,&dwIdOld);
- }
- //现在成功改写了虚表,所有Derive对象动态绑定,都会转到PrintB和Hack1上
- p->PrintA();
- p->PrintB();
- }
上面程序就是成功修改了编译器创建的虚表,可以真正算得上一个hack了~~~,上面程序vc9/10+win7 debug/release默认编译选项通过~~~~
如果您看懂上述两个程序,相信您对虚表的编译器实现的认识更加深刻了~~~~
原理就是这个样子了,在多继承情况下,可能麻烦一些,因为对象可能产生多个虚表指针,另外虚析构函数在虚表中布局,各个编译器差异也比较大,
也就是为什么com在实现时要有一个类似release的接口~~~~
声明:转自 demon__hunter