深入探索c/c++函数(3)---虚成员函数调用的基本过程

来源:互联网 发布:注册com域名多少钱 编辑:程序博客网 时间:2024/05/18 02:32

http://blog.csdn.net/demon__hunter/article/details/5447111

学过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的接口~~~~

 

 

 

好了,先写到这儿吧,有时间再补充~~~~~~~~~~~~~~~~





原创粉丝点击