0%
毅种循环

模块 61 - API Hooking - 自定义代码

恶意软件开发课程 - API Hooking - 自定义代码


模块 61 - API Hooking - 自定义代码#

API Hooking - 自定义代码#

简介#

到目前为止,我们一直使用开源库来实现 API Hooking。然而,这种方法的一个主要问题是,这些库的源代码是公开的,这使得安全研究人员和安全产品供应商非常容易针对其构建 IoC (入侵指标)。出于这个原因,本模块将通过手动方式实现 API Hooking。虽然这种方式不像前面演示的库那样复杂,但足以在不产生已知 IoC 的情况下达到预期的效果。

如果你只想 Hook 单个函数,编写自定义 Hook 代码通常是更好的选择。这可以避免链接其他库的麻烦,并能减小最终二进制文件的体积(这些库往往会增加文件重量)。

创建跳板 Shellcode (Trampoline Shellcode)#

Hook 函数的一种常见方式是使用新指令覆盖其前几个字节。这些新指令被称为 跳板 (Trampoline),负责将执行流程从原始函数重定向到我们的替代函数。

跳板通常是一段简短的跳转 Shellcode,执行一条跳转到目标地址的 jmp 指令。为了执行 jmp,我们需要先将目标地址保存到寄存器中:

  • 64 位系统:通常使用 r10 寄存器。
  • 32 位系统:通常使用 eax 寄存器。

我们将使用 mov 指令将目标地址存入寄存器,然后再执行跳转。

64 位跳转 Shellcode#

64 位的跳转指令逻辑如下:

mov r10, pAddress  ; 将替代函数的地址存入 r10
jmp r10            ; 跳转到 r10 指向的地址
asm

将其转换为机器码(Opcode):

0x49, 0xBA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov r10, pAddress (8 字节空位)
0x41, 0xFF, 0xE2                                            // jmp r10
c

32 位跳转 Shellcode#

32 位的版本:

mov eax, pAddress  ; 将地址存入 eax
jmp eax            ; 跳转到 eax
asm

对应的机器码:

0xB8, 0x00, 0x00, 0x00, 0x00,     // mov eax, pAddress (4 字节空位)
0xFF, 0xE0                        // jmp eax
c

💡 初学者提示:占位符

上面的 0x00 只是占位符。因为我们在编写代码时还不知道替代函数的具体内存地址,程序会在运行时动态地将真正的地址填入这些位置。

运行时填入地址#

由于 Hook 是在程序运行时安装的,我们需要获取替代函数的地址并将其补全到 Shellcode 中。我们可以通过直接使用函数名获取地址,或者使用 GetProcAddress。获取地址后,使用 memcpy 将地址修改到跳板代码的正确偏移位置。

64 位补丁示例#

uint8_t uTrampoline[] = {
    0x49, 0xBA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov r10, (地址占位)
    0x41, 0xFF, 0xE2                                            // jmp r10
};

uint64_t uPatch = (uint64_t)pAddress;
// 从索引 2 开始复制 8 个字节的地址
memcpy(&uTrampoline[2], &uPatch, sizeof(uPatch)); 
c

32 位补丁示例#

uint8_t uTrampoline[] = {
    0xB8, 0x00, 0x00, 0x00, 0x00,     // mov eax, (地址占位)
    0xFF, 0xE0                        // jmp eax
};
  
uint32_t uPatch = (uint32_t)pAddress;
// 从索引 1 开始复制 4 字节地址
memcpy(&uTrampoline[1], &uPatch, sizeof(uPatch)); 
c

写入跳板代码#

在覆盖目标函数头部之前,必须先将该内存区域标记为 可写。通常,代码段内存是只读的。我们需要使用 VirtualProtect 将权限修改为 PAGE_EXECUTE_READWRITE

[!IMPORTANT] 内存必须同时具备“执行”和“写入”权限。因为当程序调用该函数时,它需要执行我们的跳板代码。

// 将目标函数头部的内存保护属性修改为可读写执行 (RWX)
if (!VirtualProtect(pFunctionToHook, sizeof(uTrampoline), PAGE_EXECUTE_READWRITE, &dwOldProtection)) {
	return FALSE;
}

// 将准备好的跳转代码(跳板)拷贝到目标函数开头
memcpy(pFunctionToHook, uTrampoline, sizeof(uTrampoline));
c

卸载 Hook (Unhooking)#

为了让程序在完成拦截任务后恢复正常,我们需要在安装 Hook 之前 备份目标函数头部原本的字节。卸载时,只需将备份的原始字节用 memcpy 写回去即可。

// 恢复原始字节
memcpy(pFunctionToHook, pOriginalBytes, sizeof(pOriginalBytes));

// 建议恢复原来的内存保护属性
VirtualProtect(pFunctionToHook, sizeof(uTrampoline), dwOldProtection, &dwOldProtection);
c

定义 HookSt 结构体#

为了简化开发,我们创建一个 HookSt 结构体来保存 Hook 过程中所需的所有关键信息。

// 根据架构定义跳板大小
#ifdef _M_X64
#define TRAMPOLINE_SIZE		13 // 64 位需要 13 字节
#else
#define TRAMPOLINE_SIZE		7  // 32 位需要 7 字节
#endif

typedef struct _HookSt {
	PVOID	pFunctionToHook;                  // 要被挂钩的原始函数地址
	PVOID	pFunctionToRun;                   // 我们的替代函数地址
	BYTE	pOriginalBytes[TRAMPOLINE_SIZE];  // 用于备份原始字节,以便后续还原
	DWORD	dwOldProtection;                  // 原始内存保护属性
} HookSt, *PHookSt;
cpp

核心函数实现#

1. 初始化结构体#

BOOL InitializeHookStruct(IN PVOID pFunctionToHook, IN PVOID pFunctionToRun, OUT PHookSt Hook) {
	Hook->pFunctionToHook = pFunctionToHook;
	Hook->pFunctionToRun  = pFunctionToRun;

	// 1. 在覆盖前,先备份 TRAMPOLINE_SIZE 大小的原始数据
	memcpy(Hook->pOriginalBytes, pFunctionToHook, TRAMPOLINE_SIZE);

	// 2. 修改权限为可读写执行,并记录旧权限以便后续恢复
	if (!VirtualProtect(pFunctionToHook, TRAMPOLINE_SIZE, PAGE_EXECUTE_READWRITE, &Hook->dwOldProtection)) {
		printf("[!] VirtualProtect 失败,错误代码: %d \n", GetLastError());
		return FALSE;
	}

	return TRUE;
}
cpp

2. 安装 Hook#

BOOL InstallHook (IN PHookSt Hook) {
    // 准备跳板代码...(根据 X64/X86 填充地址,逻辑同上文)
    // ...
	
	// 将最终生成的跳板代码写入目标函数开头
	memcpy(Hook->pFunctionToHook, uTrampoline, sizeof(uTrampoline));

	return TRUE;
}
cpp

3. 移除 Hook#

BOOL RemoveHook (IN PHookSt Hook) {
	DWORD dwOldProtection = NULL;

	// 1. 写回原始字节,取消跳转
	memcpy(Hook->pFunctionToHook, Hook->pOriginalBytes, TRAMPOLINE_SIZE);
	
	// 2. 还原内存权限
	if (!VirtualProtect(Hook->pFunctionToHook, TRAMPOLINE_SIZE, Hook->dwOldProtection, &dwOldProtection)) {
		return FALSE;
	}

	return TRUE;
}
cpp

实战演示#

由于这种简单的跳板 Hook 会直接覆盖函数头部,导致在替代函数内很难直接调用原函数(除非反过来先 Unhook 再调用,但这很麻烦)。在本例中,我们演示拦截 MessageBoxA,但在替代函数内部转而调用 MessageBoxW(宽字符版本)。

💡 知识扩展:EDR 视角

这种简单的 Hook 方法虽然避开了开源库的特征,但在内存中依然会留下“函数头部被修改为 jmp 指令”的痕迹。先进的 EDR 会定期扫描内存中的库函数(如 ntdll.dll),对比磁盘上的原始字节。如果发现头部不匹配,它们就会发出“内存补丁”警报。在后续模块中,我们将学习如何应对这些扫描技巧。


🎯 总结#

在本模块中,我们学习了:

  1. 脱离类库:掌握了 Hook 的底层原理,明白了 jmp 指令是如何重定向执行流的。
  2. Shellcode 补丁:学会了如何在运行时动态计算并填充机器码中的地址。
  3. 内存保护:理解了 VirtualProtect 在修补运行中代码时的关键作用。

📚 下一步学习

下一个模块将探讨 API Hooking - Using Windows APIs。我们将看看系统本身提供了一些什么样的“官方”挂钩机制!