C++中的Thunk技术和它的使用场景

        Thunk技术就是申请一段可执行的内存,并通过手动构造CPU指令的形式来生成代码块,实现针对一些特定问题的解决方案,如:

  • 通知我们要在窗口的回调函数里获得包含此窗口的类的指针得把窗口类指针放到窗口绑定的数据里,可是有一种技术可以帮助你省去这种绑定的麻烦。(如常见的问题:类的成员函数不能作为回调函数,也不能通过参数传递this的情况)。如下图:

因为是 WindowProc 是 __stdcall 调用约定,就算我们多压入了一个this参数,也不管调用者的事, 因为堆栈是由被调用者(windowProc)来清理的。虽然只有4个显式参数, 但作为成员函数的WindowProc在结束的时候是用ret 14h返回的,this被自动清除,你知道为什么吗?

我们只需构造如下的3条简单的指令即可:

  • 我们知道一个窗口对映一个对象,很自然设计一个类。如MyWin *p1和MyWin *p2,在类里有窗口的句柄,这样就进入到window的程序世界里了。通过类可以找到窗口句柄,但通过窗口句柄怎么找到类,这就是一个比较难办的问题
  • 虚函数多继承情况。如class B : public A1, public A2, public A3{}。所谓的thunk就是一段汇编代码,这段汇编代码可以以适当的偏移值来调整this指针以跳到对应的虚函数中去,并调用这个函数,也就是说当使用A1的指针指向B的对象,不需要发生偏移,而使用A2的指针指向B则需要进行偏移sizeof(A1)个字节。并跳转到A1中的函数来执行。这就是通过thunk的jmp指令跳转到这个函数。

        所以具体的虚函数表中的情况如下:

        ①如果两个基类中的虚函数名字不同,派生类只重写了第二个基类的虚函数,则不会产生thunk用以跳转。

        ②如果基类中虚函数名字相同,派生类如果重写,将会一次性重写两个基类的虚函数,这时候第二个基类的虚函数表中存放的就是thunk对象,当指针指向此处的时候,会自动跳转到A类的对应虚函数(已经被B重写)执行。如下图:重写虚函数f()

        ③第一个基类的虚函数被重写与否都不会产生thunk对象,因为这个类是被别的基类指针跳转的目标,而这个类的指针施行多态的时候是不会发生跳转的。

        ④派生类的重新定义的虚函数将会排在第一个虚函数表内部A1虚函数的后面,但是当A2调用这个函数的时候,会通过thunk技术跳转回第一个类的虚函数表以执行相对应的虚函数。如图:

        ⑤除了第一个基类的虚析构函数,其他基类的析构函数都是thunk对象。 如下图:

        综上所述,thunk对象用于所有基类都被派生类重写后,调用虚函数将跳到最开始的基类部分。或者派生类中定义的虚函数也会跳转到第一个基类的虚函数表中。而仅出现在后面的基类的虚函数表中的虚函数,无论被重写与否都不会产生thunk对象。因为这里不会在第一个基类中由对应的虚函数指针。

Q&&A

        1. C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)

        2. 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。如:B* pb = new B; 派生类的指针指向派生类的对象,由于派生类的对象模型没有自己的虚表指针,直接借用的第一个父类的虚表指针索引到正确的虚函数。如图:

       

以上,就是三种常见的Thunk技术的应用。当然还有很多,这里不一一列举。

        

        我们在开发中经常遇到调用约定类型,如__cdeclstdcallPASCALfastcall。这些调用约定类型就用来指定函数参数的传递方式的。上面几种约定类型,除了fastcall是使用寄存器方式传递参数外,其他的都是使用堆栈传递参数的。

        堆栈是一种“后进先出”的数据结构,ESP寄存器始终指向栈顶。栈中数据地址从底部到顶部依次减小,也就是说,栈底对应高地址,栈顶对应低地址。

        调用函数时,调用者依次把参数压栈,然后调用函数,函数被调用之后,在堆栈中取得参数数据。函数调用结束以后,堆栈需要恢复到函数调用之前的样子,而到底是由调用者来恢复还是由函数自身来恢复,根据不同的调用约定类型采用不同的方式。

约定类型__cdeclstdcallPASCALfastcall
参数传递顺序从右到左从右到左从左到右使用寄存器
堆栈平衡者调用者函数自身函数自身函数自身

        __cdcel是C/C++/MFC程序默认的调用约定。
        stdcall是绝大多数Win32 API函数的约定方式,也有少部分使用__cdcel约定方式(如wsprintf等)。
        在Windows C/C++开发中常用的就是__cdecl和stdcall这2种调用约定。

按照不同的调用约定来调用函数int add(int a, int b)。从调用者的视角来看,其汇编代码分别表示如下:

        __cdecl

        push b     ;参数按从右到左传递
        push a
        call add
        add esp, 8 ;调用者在函数外部平衡堆栈

        stdcall

        push b     ;参数按从右到左传递
        push a
        call add   ;函数自己内部平衡堆栈,调用者不需要平衡堆栈

        以下主要介绍1中涉及的技术方案,通过代码体现(__cdecl、__stdcall指令有区别,不含调用端代码)

Thunk技术的大致步骤(以下代码有体现):
<1>. 在指定的内存区域(可以使用VirtualAlloc函数来分配)写入模拟函数调用的汇编指令:
    // 该行用于存储函数的返回地址,因为在进入调用函数后,栈顶存储的就是函数的返回地址,所以可以直 
    // 接使用[esp]。push dword ptr [esp] 
    // 将pThis指针放入到函数的第一个参数位置(注意参数入栈是从右往左入栈的,而上面一行代码又压入            
    // 了函数返回地址,所以是[esp+4])mov  dword ptr [esp+4], pThis 
    // 直接使用jmp跳转到指定的相对地址(留意相对地址的计算方式)
    jmp XXXXXXX
<2>. 通过Windows提供的FlushInstructionCache函数来更新指令缓存
<3>. 最后再将指定的内存区域的起始地址作为函数指针来使用。
__stdcall:
void* Thunk::fStdcall(void* pThis, void* mfun)
{
    /****************************************************************************************
    machine code                    assembly code                       comment
    ------------------------------------------------------------------------------------------
    FF 34 24                        push    dword ptr[esp]              ;再次压入返回地址
    C7 44 24 04 ?? ?? ?? ??         mov     dword ptr[esp+4],this       ;传入this指针
    E9 ?? ?? ?? ??                  jmp     (relative target)           ;转到成员函数
    ****************************************************************************************/

    // 组织汇编语句
    // push
    m_pthis->m_stdcall.push[0] = 0xFF;
    m_pthis->m_stdcall.push[1] = 0x34;
    m_pthis->m_stdcall.push[2] = 0x24;
 
    // +4 mov 
    m_pthis->m_stdcall.mov = 0x042444C7;
    m_pthis->m_stdcall.pthis = (byte4)pThis;

    // jmp
    m_pthis->m_stdcall.jmp = 0xE9;
    m_pthis->m_stdcall.addr = (byte4)mfun - ((byte4)&m_pthis->m_stdcall.jmp + 5);

    // 要跳转的回调入口
    cout << (byte4)mfun << " " << ((byte4)&m_pthis->m_stdcall.jmp + 5) << " " << m_pthis->m_stdcall.addr << endl;

    // 刷新指定进程的指令高速缓存,让CPU加载新的指令
    FlushInstructionCache(GetCurrentProcess(), &m_pthis->m_stdcall, sizeof(m_pthis->m_stdcall));

    return &m_pthis->m_stdcall;
}

__cdecl:
void* Thunk::fCdeclcall(void* pThis, void* mfun)
{
    /****************************************************************************************
    machine code                    assembly code                       comment
    ------------------------------------------------------------------------------------------
    3E 8F 05 ?? ?? ?? ??            pop     dword ptr ds:[?? ?? ?? ??]  ;弹出并保存返回地址
    ?? ?? ?? ??                     push    this                        ;压入this指针
    ?? ?? ?? ??                     push    my_ret                      ;压入我的返回地址
    9E ?? ?? ?? ??                  jmp     (relative target)           ;跳转到成员函数
    C4 04                           add     esp,4                       ;清除this栈
    3E FF 25 ?? ?? ?? ??            jmp     dword ptr ds:[?? ?? ?? ??]  ;转到原返回地址
    ****************************************************************************************/

    // 组织汇编语句
    // pop
    m_pthis->m_cdecl.pop_ret[0] = 0x3E;
    m_pthis->m_cdecl.pop_ret[1] = 0x8F;
    m_pthis->m_cdecl.pop_ret[2] = 0x05;
    *(byte4*)&m_pthis->m_cdecl.pop_ret[3] = (byte4)&m_pthis->m_cdecl.ret_addr;

    // push
    m_pthis->m_cdecl.push_this[0] = 0x68;
    *(byte4*)&m_pthis->m_cdecl.push_this[1] = (byte4)pThis;

    // push
    m_pthis->m_cdecl.push_my_ret[0] = 0x68;
    *(byte4*)&m_pthis->m_cdecl.push_my_ret[1] = (byte4)&m_pthis->m_cdecl.add_esp[0];

    // jmp 
    m_pthis->m_cdecl.jmp_mfun[0] = 0xE9;
    *(byte4*)&m_pthis->m_cdecl.jmp_mfun[1] = (byte4)mfun - ((byte4)&m_pthis->m_cdecl.jmp_mfun + 5);
    cout << (byte4)mfun << " " << ((byte4)&m_pthis->m_stdcall.jmp + 5) << " " << * (byte4*)&m_pthis->m_cdecl.jmp_mfun[1] << endl;

    // add
    m_pthis->m_cdecl.add_esp[0] = 0x83;
    m_pthis->m_cdecl.add_esp[1] = 0xC4;
    m_pthis->m_cdecl.add_esp[2] = 0x04;

    // jmp
    m_pthis->m_cdecl.jmp_ret[0] = 0x3E;
    m_pthis->m_cdecl.jmp_ret[1] = 0xFF;
    m_pthis->m_cdecl.jmp_ret[2] = 0x25;
    *(byte4*)&m_pthis->m_cdecl.jmp_ret[3] = (byte4)&m_pthis->m_cdecl.ret_addr;
    cout << *(byte4*)&m_pthis->m_cdecl.jmp_ret[3] << " " << (byte4)&m_pthis->m_cdecl.ret_addr << endl;

    // 刷新指定进程的指令高速缓存,让CPU加载新的指令
    FlushInstructionCache(GetCurrentProcess(), &m_pthis->m_cdecl, sizeof(m_pthis->m_cdecl));

    return &m_pthis->m_cdecl;
}
  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
以下是一个使用Thunk技术和VirtualAlloc函数的示例代码,演示了如何在C++创建可执行代码的内存块,并使用Thunk函数进行调用: ```cpp #include <iostream> #include <Windows.h> class ThunkClass { public: ThunkClass(void (*func)()) : m_func(func) {} void Call() { m_func(); } private: void (*m_func)(); }; // 示例函数 void MyFunc() { std::cout << "MyFunc called!" << std::endl; } int main() { // 分配可执行的内存块 LPVOID executableMemory = VirtualAlloc(NULL, sizeof(ThunkClass), MEM_COMMIT, PAGE_EXECUTE_READWRITE); if (executableMemory == NULL) { std::cerr << "Failed to allocate executable memory!" << std::endl; return -1; } // 创建一个类Thunk对象,并将目标函数传递给构造函数 ThunkClass* thunk = new (executableMemory) ThunkClass(MyFunc); // 调用类Thunk对象的Call方法 thunk->Call(); // 释放内存 thunk->~ThunkClass(); VirtualFree(executableMemory, 0, MEM_RELEASE); return 0; } ``` 在这个示例,我们定义了一个类 `ThunkClass`,它接受一个函数指针作为构造函数参数,并提供了一个 `Call` 方法来调用该函数。我们使用 `VirtualAlloc` 函数在内存分配了一块可执行的内存块。 然后,我们使用定位 new 运算符将 `ThunkClass` 对象构造在可执行内存。传递给构造函数的函数指针是我们想要调用的目标函数。 接下来,我们通过调用 `Call` 方法来执行目标函数。这里,类 `ThunkClass` 内部的 `Call` 方法会调用存储在对象的函数指针。 在使用完内存块后,我们首先显式调用类 `ThunkClass` 对象的析构函数 `~ThunkClass()`,然后使用 `VirtualFree` 函数释放内存。 这个示例代码演示了如何使用Thunk技术和VirtualAlloc函数来创建可执行代码的内存块,并通过类Thunk对象进行调用。请注意,这个示例仅供了解和学习类Thunk技术使用,实际应用需要谨慎考虑安全性和可移植性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值