65.9K
CodeProject 正在变化。 阅读更多。
Home

PolyHook - C++11 x86/x64 挂钩库

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (32投票s)

2016年5月17日

MIT

9分钟阅读

viewsIcon

78250

一个现代、通用、C++ 挂钩库。

https://github.com/stevemk14ebr/PolyHook

背景

在无法访问源代码的情况下,通常需要修改应用程序在运行时行为。为此,人们传统上依赖于 Microsoft Detours、Minhook 等库。然而,这些库中的每一个都有明显的缺点。Detours 仅支持 x86,除非使用“Professional”许可证,但该许可证价格高达 10000 美元,即使如此,专业版也会将 DLL 映射到新的代码段,使应用程序非常臃肿。Minhook 相当不错,但它依赖于预先制作的蹦床例程,有时会挂钩失败,并且源代码同样臃肿。对我来说,只有一个真正的解决方案,那就是按照我自己的条件编写我自己的库,目标是成为现有最小、最干净、最简单的挂钩库!

特点

PolyHook 提供了 6 种独立的方式来挂钩函数(所有这些都兼容 x86/x64)。每种暴露的方法都具有相同的接口:Setup()、Hook() 和 Unhook() 方法。我将在接下来的章节中描述每种挂钩方法的作用、工作原理、何时使用它以及提供代码示例。它*应该*是线程安全的,尽管我可能遗漏了什么。

1) 标准 Detour

这是挂钩函数的伪标准方法,这也是 Microsoft Detours 和 Minhook 实现的方式。它通过在函数的序言处写入一个 JMP 汇编指令来实现,该指令将代码流重定向到自定义处理程序。在 x86 模式下使用的指令是

0x00000000 0xE9DEADBEEF JMP 0xDEADBEF4 (EIP+DEADBEEF)

这是一个 32 位相对指令,意味着它跳转到的位置取决于指令本身在内存中的位置。在我的示例中,指令位于 0x00000000 的位置,偏移量为 0xDEADBEEF,然后您包含指令的大小,即 5 字节(E9 +DE+AD+BE+EF),以计算其最终位置,即 0xDEADBEF4。

在 x64 中,它会更复杂一些,因为没有单个指令可以跳转整个 x64 地址范围。因此,我使用了两个不同的汇编片段,并根据序言的大小选择使用哪个:

0xFF25DEADBEEF JMP [DEADBEF4] ([RIP+DEADBEEF]) //6 bytes total

或者当序言大于 6 字节时

push rax
mov rax, 0xDEADBEEFDEADBEEF
xchg qword ptr ss:[rsp], rax
ret

第一个片段很特别,因为它实际上跳转到 (RIP+DEADBEEF) 指向的位置,而不是跳转到 (RIP+DEADBEEF)。在我的实现中,我将此跳转写入分配在序言 +-2GB 范围内的蹦床的末尾,然后在此位置写入处理程序的内存位置,这是实际跳转到的位置。您可能想知道为什么蹦床分配在 +-2GB 范围内,因为指令只能编码高达 32 位的偏移量,指令的大小为 6 字节,-2 字节用于 0xFF、0x25,剩下 4 字节用于写入偏移值。

第二个跳转类型是首选的,因为蹦床可以分配在整个 x64 地址范围内的任何位置。它通过将 rax 寄存器的值保存在堆栈上,将完整的 x64 地址移入 rax,然后切换堆栈以保存 rax 的值,并将 rax 恢复到堆栈上保存的原始值,然后 ret,这只是跳转到堆栈上的第一个值,从而有效地实现了跳转!

在正确实现 Detours 方面,还有许多其他非常重要的细节,我将在稍后标题为“Detours 棘手之处”的部分进行解释,以保持简洁。其余逻辑非常简单,我们写入的跳转将我们带到我们的处理程序,我们在处理程序中执行我们想要的操作,然后我们通过首先执行所谓的蹦床来返回到原始函数,该蹦床只是执行我们之前用写入的跳转覆盖的字节,然后蹦床跳转回我们在序言中放置的跳转之后的内存位置。完整的代码示例

 

typedef int(__stdcall* tMessageBoxA)(HWND hWnd,LPCSTR lpText,LPCSTR lpCaption,UINT uType);
tMessageBoxA oMessageBoxA;

int __stdcall hkMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType)
{
   //You can modify any parameters you want here
   return oMessageBoxA(hWnd,"Hooked", lpCaption, uType);
}

void main()
{
    std::shared_ptr<PLH::Detour> Detour_Ex(new PLH::Detour);
    Detour_Ex->SetupHook((BYTE*)&MessageBoxA,(BYTE*) &hkMessageBoxA);
    Detour_Ex->Hook();
    oMessageBoxA = Detour_Ex->GetOriginal<tMessageBoxA>(); 
} 
2) 虚函数 Detour

在 c++ 中,虚方法被放置在一个由该类在内存中 0x00 位置的指针指向的表中。

//In source code you would have this class
class AClass
{
public:
   virtual void Method1();
   virtual void Method2();
   int m_ExampleMemberOne;
   int m_ExampleMemberTwo
}

//In memory it gets laid out like this
class AClass
{
public:
   VTABLE* m_VirtualFunctions
   int m_ExampleMemberOne;
   int m_ExampleMemberTwo;
}

class VTABLE
{
public:
   void* m_FunctionPointer //First virtual method
   void* m_FunctionPointer //Second virtual method
   //etc
}

此挂钩方法解引用 Vtable 中的函数指针,然后使用上述 detour 挂钩它指向的函数。

class VirtualTest
{
public:
    virtual int NoParamVirt()
    {
        return 4;
    }
    virtual int NoParamVirt2()
    {
        return 1;
    }
};

typedef int(__thiscall* tVirtNoParams)(DWORD_PTR pThis);
tVirtNoParams oVirtNoParams;

int __fastcall hkVirtNoParams(DWORD_PTR pThis)
{
    return oVirtNoParams(pThis);
}

void main()
{
    std::shared_ptr<VirtualTest> ClassToHook(new VirtualTest);
    std::shared_ptr<PLH::VFuncDetour> VFuncDetour_Ex(new PLH::VFuncDetour);

    VFuncDetour_Ex->SetupHook(*(BYTE***)ClassToHook.get(), 0, (BYTE*)&hkVirtNoParams);
    VFuncDetour_Ex->Hook();
    oVirtNoParams = VFuncDetour_Ex->GetOriginal<tVirtNoParams>();
}

注意 DWORD_PTR pThis 的用法。所有虚方法都有一个隐藏的“this”参数,它是指向类的指针,typedef 必须考虑这一点。

3) 虚表指针交换

此方法将实际的 VTABLE* 交换为我们创建的原始 VTABLE 的深层副本。然后,我们将副本中的一个函数指针交换为指向我们的挂钩处理程序。这是一种非常隐蔽的挂钩方法。

//Original
class AClass
{
public:  
    VTABLE* m_VirtualFunctions
    int m_ExampleMemberOne;  
    int m_ExampleMemberTwo;
}

class VTABLE
{
public:
    void* m_FunctionPointer
    void* m_FunctionPointer
}

//Our hook changes it to
class AClass
{
public:
  VTABLECOPY* m_VirtualFunctions //This pointer value is changed
  int m_ExampleMemberOne;
  int m_ExampleMemberTwo;
}

class VTABLECOPY
{
public:
    void* m_FunctionPointer //Change the pointer value to our handler
    void* m_FunctionPointer //Unchanged pointer is copied from original VTABLE
}

完整代码

void main()
{
    std::shared_ptr<VirtualTest> ClassToHook(new VirtualTest);
    std::shared_ptr<PLH::VTableSwap> VTableSwap_Ex(new PLH::VTableSwap); 
    VTableSwap_Ex->SetupHook((BYTE*)ClassToHook.get(), 0, (BYTE*)&hkVirtNoParams); 
    VTableSwap_Ex->Hook(); oVirtNoParams = VTableSwap_Ex->GetOriginal<tVirtNoParams>(); 

    //Once Hook() is called, you can optionally hook aditional virtual functions in the swapped vtable
    oVirtNoParams2 = VTableSwap_Ex->HookAdditional<tVirtNoParams>(1, (BYTE*)&hkVirtNoParams2);
}
4) 虚函数指针交换

这是上述方法的简化。我们不复制 vtable,而是直接更改原始 vtable 中虚函数的指针,使其指向我们的处理程序。这种方法比 VTABLE 交换更容易被检测到,但它也更简单。

//Original 
class AClass 
{ 
public: 
    VTABLE* m_VirtualFunctions 
    int m_ExampleMemberOne;  
    int m_ExampleMemberTwo; 
} 

class VTABLE 
{ 
public: 
    void* m_FunctionPointer //Hook changes this value
    void* m_FunctionPointer 
} 

完整代码

std::shared_ptr<PLH::VFuncSwap> VFuncSwap_Ex(new PLH::VFuncSwap);
VFuncSwap_Ex->SetupHook(*(BYTE***)ClassToHook.get(), 0, (BYTE*)&hkVirtNoParams);
VFuncSwap_Ex->Hook();
oVirtNoParams = VFuncSwap_Ex->GetOriginal<tVirtNoParams>();

 

5) 导入地址表挂钩

在 Windows 上,当 C 或 C++ 程序调用任何 API 时,该 API 的位置会被放入一个名为 IMPORT_ADDRESS_TABLE 的表中。在编译时,这只是一个 API 名称表,在运行时,Windows 加载器会找到 API 的内存位置并将其写入名称表旁边的相同表中。所有对任何 API 的后续调用都会首先在此表中查找,然后调用表中的函数指针。这种挂钩方法将此表中的指针值交换为指向我们自己的处理程序,以便当目标调用 API 时,会调用我们的处理程序。IAT 是一个高级主题,存在比我更好的关于它的文章:http://sandsprite.com/CodeStuff/Understanding_imports.html

完整的代码示例

typedef DWORD(__stdcall* tGetCurrentThreadId)();
tGetCurrentThreadId oGetCurrentThreadID;

DWORD __stdcall hkGetCurrentThreadId()
{
    return oGetCurrentThreadID();
}

void main()
{
    std::shared_ptr<PLH::IATHook> IATHook_Ex(new PLH::IATHook);
    IATHook_Ex->SetupHook("kernel32.dll", "GetCurrentThreadId", (BYTE*)&hkGetCurrentThreadId);
    IATHook_Ex->Hook();
    oGetCurrentThreadID = IATHook_Ex->GetOriginal<tGetCurrentThreadId>();
}

如果您想挂钩位于链接到目标应用程序的 DLL 中的 API,您可以改为编写此代码

IATHook_Ex->SetupHook("kernel32.dll", "GetCurrentThreadId", (BYTE*)&hkGetCurrentThreadId,"Dependancy.dll");

这将挂钩名为“Dependancy”的 DLL 的 GetCurrentThread。

 

6) 向量化异常处理程序挂钩

最后的挂钩方法是一个巧妙的方法,也是最隐蔽的方法之一。通过生成异常,我们可以陷 入异常处理程序。在该异常处理程序中,我们可以更改 RIP/EIP(指令指针)的值为挂钩处理程序的位置。一旦我们移除异常生成方法并从异常处理程序返回 EXCEPTION_CONTINUE_EXECUTION,我们的挂钩处理程序就会开始执行,我们有效地实现了跳转到我们的处理程序。异常生成方法可以是硬件断点、软件断点或保护页面。

但是有一个有趣的怪癖。为了执行我们的原始函数,我们必须移除异常生成机制,以避免再次调用我们的异常处理程序。这让我们陷入了如何在我们执行完原始函数后恢复异常生成机制的困境,因为我们希望能够多次拦截该函数!

结果证明解决方案很简单,C++ 析构函数!由于对象的析构函数保证在我们离开对象作用域之后执行,因此我们可以利用它们让编译器自动放置一个存根,在我们在处理程序中执行完原始函数后恢复保护!这个来自 stackoverflow 的代码示例将在对象销毁时执行一个 std::function。

template<typename Func>
class FinalAction {
public:
    FinalAction(Func f) :FinalActionFunc(std::move(f)) {}
    ~FinalAction()
    {
        FinalActionFunc();
    }
private:
    Func FinalActionFunc;

    /*Uses RAII to call a final function on destruction
    C++ 11 version of java's finally (kindof)*/
};

template <typename F>
FinalAction<F> finally(F f) {
    return FinalAction<F>(f);
}

然后,在内部,我像这样调用这个对象:

auto GetProtectionObject()
{
    return finally([&]() {
        //Some fancy code to restore the exception generating methods
    });
}

然后像这样使用它,我们就得到了一个功能齐全的自动恢复异常挂钩。

typedef int(__stdcall* tVEH)(int intparam);
tVEH oVEHTest;
int __stdcall VEHTest(int param)
{
    return 3;
}

std::shared_ptr<PLH::VEHHook> VEHHook_Ex;
int __stdcall hkVEHTest(int param)
{
    //Protection object auto-magically restores protection once our handler exits!
    auto ProtectionObject = VEHHook_Ex->GetProtectionObject();

    return oVEHTest(param);//Original is unprotected so we can call it
    //Compiler places a stub right here that restores protection!
}

void main()
{
    VEHHook_Ex->SetupHook((BYTE*)&VEHTest, (BYTE*)&hkVEHTest, PLH::VEHHook::VEHMethod::INT3_BP);
    VEHHook_Ex->Hook();
    oVEHTest = VEHHook_Ex->GetOriginal<tVEH>();\
}

 

Detours 棘手之处

指令拆分

在序言中写入我们的跳转指令时,我们必须确保不拆分任何指令。在 x86/x64 中,指令始终是固定大小的,因此我们使用的 jmp 始终是 5 字节。例如,如果我们尝试挂钩以下函数,我们将生成一个异常(只是一个示例序言,这在实际编译器生成的代码中永远找不到)。

//Before our hook
0x50   push eax
0xFFD0 call eax
0x51   push ecx
0xFFD1 call ecx

//After our hook
0xE9DEADBEEF JMP 0xDEADBEF4
0xD1         'JUNK          

如您所见,0xD1 字节将被留下,创建垃圾代码,这很可能在以后导致未定义的行为,从而导致难以发现的崩溃。

解决方案是使用反汇编引擎来确保我们永远不会拆分指令。我为 polyhook 项目使用 capstone,因为它维护良好且功能强大。在内部,我测量我们被迫覆盖的每个汇编指令的大小,然后用 NOP 填充任何额外的指令字节,所以我们会剩下

0xE9DEADBEEF JMP 0xDEADBEF4
0x90         NOP

0xD1 字节被 NOP 覆盖,我们就解决了问题。

代码重定位:

如前所述,某些指令相对于它们在内存中的位置是相对的。当我们向函数序言写入跳转指令时,我们实际上是在覆盖之前存在的代码。所以我们首先将原始代码复制到我们的蹦床中,这样在再次调用原始函数时就会执行它。但是这个蹦床与序言在不同的内存位置,并且某些指令可能是相对类型的,因此移动它们会改变它们的含义!消息框序言就是一个实际的例子。

7FFC0EC2E190: 40 53             push rbx
7FFC0EC2E192: 48 83 EC 30       sub rsp, 0x30
7FFC0EC2E196: 33 DB             xor ebx, ebx
7FFC0EC2E198: 39 1D 7A B7 02 00 cmp dword ptr [rip + 0x2b77a], ebx //This is relative
7FFC0EC2E19E: 74 31             je 0x7ffc0ec2e1d1                  //And so is this

第一个重定位非常容易修复,我们只需重新计算所需的偏移量,使其指向原始位置。它最初指向 0x7FFC0EC2E198+0x2B77A = 0x7FFC0EC59912。修复第一个重定位后的蹦床是

7FFC0EB60000: 40 53                         push rbx
7FFC0EB60002: 48 83 EC 30                   sub rsp, 0x30
7FFC0EB60006: 33 DB                         xor ebx, ebx
7FFC0EB60008: 39 1D 0A 99 0F 00             cmp dword ptr [rip + 0xf990a], ebx //The encoded displacement is changed
7FFC0EB6000E: 74 31                         je //Some wrong location
7FFC0EB60010: 50                            push rax                           //This is just part of the trampoline, ignore it
7FFC0EB60011: 48 B8 A0 E1 C2 0E FC 7F 00 00 movabs rax, 0x7ffc0ec2e1a0
7FFC0EB6001B: 48 87 04 24                   xchg qword ptr [rsp], rax
7FFC0EB6001F: C3                            ret

0x7FFC0EB60008+0xf990a = 0x7FFC0EC59912,正如您所见,我们更改了指令地址,同时保留了它的含义!

然而,第二个重定位很难。je 指令只有一个字节来编码其偏移量,这意味着我们只能移动它高达 0xFF 或 255 字节(0x74 表示 je,下一个字节是偏移量)。然而,我们的函数序言和蹦床之间的差异远远大于 7FFC0EC2E190 - 7FFC0EB60000 = CE190。因此,我们必须改用绝对 x64 jmp(感谢 minhook 的这个)。修复后的蹦床变为

7FFC0EB60000: 40 53                         push rbx 
7FFC0EB60002: 48 83 EC 30                   sub rsp, 0x30 
7FFC0EB60006: 33 DB                         xor ebx, ebx 
7FFC0EB60008: 39 1D 0A 99 0F 00             cmp dword ptr [rip + 0xf990a], ebx //The encoded displacement is changed 
7FFC0EB6000E: 74 10                         je 0x7ffc0eb60020
7FFC0EB60010: 50                            push rax                           //This is just part of the trampoline, ignore it
7FFC0EB60011: 48 B8 A0 E1 C2 0E FC 7F 00 00 movabs rax, 0x7ffc0ec2e1a0
7FFC0EB6001B: 48 87 04 24                   xchg qword ptr [rsp], rax
7FFC0EB6001F: C3                            ret
7FFC0EB60020: 50                            push rax                          //JE POINTS HERE
7FFC0EB60021: 48 B8 D1 E1 C2 0E FC 7F 00 00 movabs rax, 0x7ffc0ec2e1d1
7FFC0EB6002B: 48 87 04 24                   xchg qword ptr [rsp], rax
7FFC0EB6002F: C3                            ret

 

从这里我们看到,当采取 je 路径时,它会重定向到我之前展示的那个花哨的 jmp。

 

后续思考

所有挂钩方法都需要您为函数创建一个 typedef,如果您遇到崩溃,很可能是您的 typedef 不正确,而不是库中的错误,请在评论中发布您的问题,我会查看。如果您有任何建议,请给我发消息,我会看看!

作为奖励,您可以使用 decltype 为 API 等定义 typedef,以 messagebox 为例

decltype(&MessageBoxA) oMessageBoxA;

//Other code

oMessageBoxA = Detour_Ex->GetOriginal<decltype(&MessageBoxA)>();

未来发展

在不远的将来,我将使这个库支持跨平台。

© . All rights reserved.