简单的32位Inline Hook及蹦床实现

前言

之前介绍了很多类型的Hook,但一直都没有写最简单、最常用的Hook,也就是Inline Hook

其实Inline Hook是最容易被检测的Hook,且在没有做特殊处理时也是非线程安全的Hook,有极小概率在Hook时破坏运行栈,从而导致程序崩溃

但Inline Hook依然是最常用的Hook,因为它几乎适用任何需要Hook的场景,不存在VMT、IAT Hook需要特定场景的窘境

这次本文就32位下的Inline Hook和蹦床做简单的原理介绍和实现

预备知识

在学习Inline Hook之前,我们得先了解几个点

  1. 程序被编译后会变成机器码。而在程序运行时,这些机器码是被载入内存中的(在.text段)

所以,我们可以修改运行时的机器码,达到修改程序执行顺序的目的,从而实现Hook

  1. 无条件跳转指令(JMP)

那我们只需要将被Hook的函数入口的指令改为无条件跳转到我们函数中即可

  1. 短跳转和长跳转

其实JMP指令分为短跳转和长跳转

  • 短跳转:机器码为2个字节EB XX,E8是短JMP的机器码,XX是跳转范围-128~127

  • 长跳转:机器码为5个字节E9 XX XX XX XX,E9是长JMP的机器码,剩下4个字节表示转移偏移量

由于我们自己编写的函数在内存中的位置很明显不可能距离源函数只有255

所以Inline Hook肯定是要采用长跳转来实现,因为长跳转的寻址范围覆盖了整个32位进程的地址空间

长JMP计算实例

1
2
3
4
5
......
00401000 call jmp 402398
......
00402398 mov eax,eax
......

转移偏移量的公式是:目的地址 - 起始地址 - 跳转指令自身长度

由于长JMP指令自身长度为5,所以转移偏移量就是:00402398h - 00401000h - 5h = 00001393h

而根据小端存储的原则(高存高,低存低),所以上述代码中JMP指令的机器码就是E9 93 13 00 00

实现

知道了预备知识,那么实现Inline Hook就很轻松了,只需要以下几步

  1. 修改内存保护
  2. Nop掉原函数开头前5或6个指令(不是必要的,但是一个很好的保障措施,而且也方便调试)
  3. 先计算出JMP指令的转移偏移量
    1
    uintptr_t relative_offset = function_B - function_A - 5;
  4. 将跳转指令的机器码覆盖到源函数的开头
    1
    2
    3
    4
    5
    6
    7
    // 初始化JMP指令数组,第一个字节为0xE9(长JMP的机器码)
    uint8_t jmp_codes[5] = { 0xE9 };
    // 拷贝转移偏移量到JMP指令数组的后4个字节
    std::memcpy(jmp_codes + 1, &relative_offset, sizeof(relative_offset));
    // 写入跳转指令的机器码到原函数开头
    std::memcpy(src, jmp_codes, sizeof(jmp_codes));

  5. 恢复内存保护

完整实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
constexpr uint8_t kNopCode = 0x90;
constexpr uint8_t kLongJmpCode = 0xE9;
constexpr size_t kStolenSize = 6;
constexpr size_t kJmpCodeSize = 5;

template <typename F>
void simple_x86_inline_hook(F* src, const F* dst)
{
static_assert(std::is_function<F>::value, "inline hook require a function pointer");
// 修改内存保护
DWORD cur_prot;
if (!VirtualProtect(src, kStolenSize, PAGE_EXECUTE_READWRITE, &cur_prot))
throw std::runtime_error("VirtualProtect Error");
// Nop掉原函数开头前6个指令
std::memset(src, kNopCode, kStolenSize);
// 计算出JMP指令的转移偏移量
uintptr_t relative_offset =
reinterpret_cast<uintptr_t>(dst) - reinterpret_cast<uintptr_t>(src) - kJmpCodeSize;
// 初始化JMP指令数组,第一个字节为0xE9(长JMP的机器码)
uint8_t jmp_codes[kJmpCodeSize] = { kLongJmpCode };
// 拷贝转移偏移量到JMP指令数组的后4个字节
std::memcpy(jmp_codes + 1, &relative_offset, sizeof(relative_offset));
// 写入跳转指令的机器码到原函数开头
std::memcpy(src, jmp_codes, sizeof(jmp_codes));
// 恢复内存保护
DWORD _;
if (!VirtualProtect(src, kJmpCodeSize, cur_prot, &_))
throw std::runtime_error("VirtualProtect Error");
}

Hook函数A到函数B,反汇编后如下所示

在Hook之前可以看到函数开头的一些原始指令(保存栈基址、开辟栈空间等操作)。但Hook之后,这些指令就被我们的JMP指令覆盖了

这些被覆盖的指令就叫被盗字节

为啥JMP指令只有5个字节,但我Nop了6个字节呢?

  1. 方便调试,有Nop的地方意味着做了修改
  2. 原始的前3个指令占用了6个字节,多出来的1个字节如果不Nop掉,反汇编出的代码会很奇怪

蹦床

刚才我们已经实现了最简单的Inline Hook,但我们Hook一个函数后,往往只是修改参数或做一些记录,最终还是需要调用源函数的

但上面的实现如果尝试调用源函数会引起无限循环调用,最终导致栈溢出...

所以,我们还需要一个蹦床,帮我们实现可以在钩子函数中调用源函数的功能

实现

其实蹦床的原理很简单,核心思想就是保存被盗字节

具体实现步骤如下:

  1. 开辟一块空间(大小是6字节 + 5字节)作为蹦床
  2. 计算与原函数的JMP指令的转移偏移量
  3. 将被盗字节写入前6个字节
  4. 将JMP机器码写入后5个字节中
  5. 执行Inline Hook
  6. 将蹦床返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template <typename F>
F* x86_inline_hook_tramp(F* src, const F* dst)
{
static_assert(std::is_function<F>::value, "inline hook require a function pointer");
// 开辟一块空间(大小是6字节 + 5字节)作为蹦床
uint8_t* gateway = static_cast<uint8_t*>(VirtualAlloc(nullptr, kStolenSize + kJmpCodeSize,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
if (!gateway)
throw std::runtime_error("VirtualAlloc Error");
// 计算与原函数的JMP指令的转移偏移量
uintptr_t relative_offset =
reinterpret_cast<uintptr_t>(src) - reinterpret_cast<uintptr_t>(gateway) - kJmpCodeSize;
uint8_t jmp_codes[kJmpCodeSize] = { kLongJmpCode };
std::memcpy(jmp_codes + 1, &relative_offset, sizeof(relative_offset));

// 将被盗字节写入前6个字节
std::memcpy(gateway, src, kStolenSize);
// 将JMP机器码写入后5个字节中
std::memcpy(gateway + kStolenSize, jmp_codes, sizeof(jmp_codes));
// 执行Inline Hook
simple_x86_inline_hook(src, dst);
// 将蹦床返回
return reinterpret_cast<F*>(gateway);
}

Hook函数A到函数B,并在函数B中调用原函数,反汇编后如下所示

为啥用VirtualAlloc而不用malloc? 因为VirtualAlloc可以指定内存的保护标志,而我们需要这块内存有可执行权限。malloc的话还需要调用一次VirtualProtect

简单的测试实例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 用于保存原函数的蹦床
decltype(Sleep)* src_sleep;

// 钩子函数
void WINAPI my_sleep(DWORD ms)
{
std::cout << "[?] Hooked Sleep Function Called!" << std::endl;
std::cout << "Sleeping for: " << ms << std::endl;
// 调用源函数
src_sleep(10000);
}

int main()
{
src_sleep = x86_inline_hook_tramp(Sleep, my_sleep);

Sleep(1000);

VirtualFree(src_sleep, 0, MEM_RELEASE);
return 0;
}

总结

  • 执行速度:10
  • 编写难度:2
  • 检测率:7

像Inline Hook这种直接在.text段中修改指令的方式,非常简单和常用,也适用于绝大多数场景

但很容易被各种反作弊引擎检测到,不过Inline Hook的变种很多,也有很多办法可以进行隐蔽