查户口之虚表的户籍

2024-03-21 13:24:41
标签: virtual cffexplorer msvc

    虚函数表是一个C++领域比较基础内容,同时也是面试中经常涉及到的一个内容,今天这个Session就通过一个实际例子,带你一步一步揭开虚表的面纱(注:本文讨论的仅针对 MSVC微软实现的方式,其他平台可能会有所不同,有机会再给大家分享其他平台的实现)。

    !废话不多,直接上代码。我们的测试工程包括一个简单的CMakeLists.txt文件,定义了两个工程,一个Base,只有一个基类,另一个Main,里面有子类和main方法。

cmake_minimum_required(VERSION 3.26)

project(main)

add_library(BASE SHARED base.cpp)

target_compile_definitions(BASE PRIVATE MYLIBRARY_EXPORTS)

add_executable(main main.cpp derived.cpp)

target_link_libraries(main PRIVATE BASE)

BASE工程中,只有base.cpp一个源文件,它引用base.h

Base.cpp

#include "base.h"

#include 

 Base::Base() {

    vfunfoo();

}

Base::~Base()

{

}

void Base::vfunfoo()

{

    printf("vfunfoo base is called!");

}

void Base::vfunbar()

{

    printf("vfunbar base is called!");

}

Base.h

#ifdef MYLIBRARY_EXPORTS

#define MYLIBRARY_API __declspec(dllexport)

#else

#define MYLIBRARY_API __declspec(dllimport)

#endif

 

class MYLIBRARY_API Base {

public:

    Base();

    ~Base();

    virtual void vfunfoo();

    virtual void vfunbar();

};

基类实现了两个虚方法,并且在基类构造中调用了其中一个虚方法,这时调用的将永远是基类方法。原因在后续揭密部分会有详细说明,各位看官请耐心。

接下来看一下Main工程,它有main.cpp

Main.cpp

#include "derived.h"

int main()

{

    Derived* dv = new Derived();

    dv->vfunbar();

    delete dv;

    return 0;

}

Derived.h代码如下:

#include 

#include "base.h"

class Derived public Base

{

public:

    Derived();

    ~Derived();

    virtual void vfunbar();

};

Derived.cpp

#include 

#include "derived.h"

Derived::Derived() : Base()

{

    vfunfoo();

    vfunbar();

}

Derived::~Derived()

{

 

}

void Derived::vfunbar()

{

    printf("vfunbar derived is called!");

}

    然后用CMake直接生成VisualStudio的工程。     

    我们跳过编译的环节,直接通过调试代码的方法,来确定一下main函数中,Derived类中调用的虚方法,以及基类中调用的虚方法各自是如何工作的。

    直接把断点打在基类的构造函数上,运行代码,触发断点。

基类的构造函数

    可以看到在调用vfunfoo()之前,会把基类的虚表指针放到this开头的8个字节,也就是说,在当前情况下类的第一个数据成员是虚表的指针,所以后续调用的是基类的vfunfoo。切换到CallStack窗口。

基类构造函数的调用堆栈

    双击子类构造的frame,切换到子类的构造函数,如下图

子类构造函数的调用堆栈

    发现此时基类还没完成(绿色小箭头所指的是调用完之后的返回地址),当基类完成以后,才会把虚表指针指向子类的虚表,再之后的调用才会调用子类的虚方法。

     回到今天的主题,我们来查一下虚表的户口,它是如何产生的,在二进制文件的什么地方。

     我们先看基类虚表。

基类虚表地址

    基类虚表指针的值是0x00007FF85B0E9940

    我们通过内存窗口来看一眼 0x00007FF85B0E9940这里的内容,它存了两个虚函数的地址。

基类虚函数表内容

  我们再去0x00007ff85b0e11d1看看

基类虚表跳转指令

    跳转去0x07FF85B0E1520,再确定一下这个地址的内容

基类第一个虚函数代码

     可以看到它是基类第一个虚函数的实现。

     基于上面这些数据,我们换个角度,来看一下,虚表在文件层面到底在哪里。先通过模块窗口,看一下Base.dll的加载地址。

 

Base.dll的加载基地址信息

    可以看到Base.dll被加载到了0x00007FF85B0E0000的基址上。

    基类的虚函数表指针相对于模块基址的相对偏移地址RVA0x9940

    0x00007FF85B0E9940-0x00007FF85B0E0000 = 0x9940

    打开CFF Explorer工具,查看0x9940所在的Section

Base.dll的节表信息

    可以看到RVA0x9940的位置处在.rdata节。这个节通常包括只读数据 ,以及导入导出表数据。

    再看一下0x9940具体存的数据。

0x9940的数据

    这里只存了0x1800011D1,那这里的数据是怎么知道自己在运行的时候要变成  0x00007FF85B0E11D1的呢? 0x00007FF85B0E11D1和模块基址0x00007FF85B0E0000只差了0x11D1,那高位的0x180000000又是干吗用的呢?

    查看Base.dll的可选文件头。

 

Base.dll的可选文件头

    可以发现这个dll_IMAGE_OPTIONAL_HEADER64 中的ImageBase 成员值为0x180000000, 它记录了二进制文件希望被加载的优选地址,如果能加载到这个地址,那是不需要进行重定位的(地址修复)。于是二进制文件中存的其他值,如0x1800011D1都是基于如果加载到这个优选地址时不需要做重定位时的值,现在显然不是,所以要基址0x00007FF85B0E0000+0x1800011D1-0x180000000= 0x00007FF85B0E11D1,就是加载后真实的地址。

    那这里又有一个问题,系统怎么知道0x9940这个地址在加载后需要被重定位,参见重定位表可见0x9940这个地址是在重定位表中的。

Base.dll的重定位表

    总结一下,基类的虚表指针是在Base.dll文件的.rdata节,对应的地址同样在重定位节中有指明。

    所以当加载完成后,系统会进行重定位操作,以保证虚表指针的值永远指向正确的函数。

     同样的,我们来看一下子类对象的虚表有什么特点。

     让代码执行到子类的构造函数。

子类构造函数代码

    我们通过内存窗口来看一眼子类虚表的位置0x00007FF7FA77AC50

子类虚函数表内容

    通过模块窗口确定一下main.exe被加载的基地址。

主程序的加载基地址信息

    Main被加载的基地址是0x00007FF7FA770000,所以虚表指针的相对偏移地址RVA0xAC50

    再通过CFF Explorer查看一下0xAC50所在的节,同样也是在.rdata节。

主程序的节表信息

    我们再去看一眼,0xAC50所存的具体内容。

0xAC50的数据

    具体值为0x10400018EF,同样地查看重定位表以及可选文件头中的ImageBase字段。

主程序的重定位表
主程序的可选文件头

   可知被加载后的真实地址应该是

    基址0x00007FF7FA770000+ 0x10400018EF – 0x1040000000 = 0x00007FF7FA7718EF

   这于上面内存窗口中看到的值一致。

   通过反汇编窗口,我们再看一下0x00007FF7FA7718EF地址的代码,发现它和基类的情况已经不同了。

子类虚函数的间接跳转

     对比一下基类的情况。

基类的直接跳转

    可以清楚地看到,基类是直接跳转到了0x7FF85B0E1520Base::vfunfoo的地址。而现在这次可以看到代码是

00007FF7FA7718EF FF 25 13 E7 00 00   jmp         qword ptr [__imp_Base::vfunfoo(07FF7FA780008h)]

    它跳到了0x07FF7FA780008所在的内存地址

 

间接跳转表中的真实地址

    清楚地看到它就是Base.dll中的地址,并且与基类的虚表指针的第一项具有一样的指针值,因为在子类中没有overwrite基类的行为。所以这个函数最终才调用到了Base.dll中的代码。

    我们再来看一下这一切是如何发生的。

    我们先来看一下0x18EF处的内容。

主程序中0x18EF的数据

    这里的内容和上面的二进制指令值是一模一样的。

也就是00007FF7FA7718EF FF 25 13E7 00 00    jmp         qword ptr [__imp_Base::vfunfoo(07FF7FA780008h)]

    它去取0x00007FF7FA780008中的内容,这个地址的RVA0x10008

    这个RVA对应在文件中.idata节,也就是导入表中。

主程序导入表在节表中的位置

    查看0x10008这个RVA在文件中的内容

 

主程序中0x10008的数据

     再切换到导入表视图,可以看到10008对应第二项。

主程序导入表结构

    同时,对比Base.dll的导出表,可以看到序号和名称是对应的。

Base.dll的导出表结构

    综上所述,当模块加载的时候,系统会根据main的导入表和Base.dll的导出表对应关系,把0x10008这个RVA中所记录的0x0000000000010A00,修正为Base.dll导出表最后一项所对应的RVA。也就是地址0x000011D1

    注意这里的导出表只有纯的RVA,并且是一个四字节数据 。导入导出表修正,它是一个跨模块的操作,它会将被导入模块Base.dll中的地址(基地址+四字节偏移得到8字节绝对地址)完全填进导入模块Main的对应导入表结构。

    重定位则只会考虑本模块是否加载到了最佳位置,如果已经加载到最佳位置,重定位将不发生。

    好了,今天的内容就到此为止,我们下次再见。


阅读(0) 收藏(0) 转载(0) 举报/Report
相关阅读

新浪BLOG意见反馈留言板 欢迎批评指正

新浪简介 | About Sina | 广告服务 | 联系我们 | 招聘信息 | 网站律师 | SINA English | 产品答疑

新浪公司 版权所有