软件安全实验-HOOK技术

软件安全实验-HOOK技术

【实验目的】

Hook 是在指令的关键位置插入特定代码,以干预程序原有的执行流程,实现拦截目标进程运行过程的关键信息、改变目标进程原本执行流程等目的。本实验使用 Inline Hook 技术,挂钩 TraceMe.exe 中计算序列号的代码,获取正确的序列号,加深对 Hook 技术的理解。

【实验原理】

Inline Hook(内联挂钩)技术。 Inline Hook 是一种在不修改可执行文件本身的情况下,通过在目标进程的内存代码段中修改指令来改变程序执行流程的技术。其核心思想是在目标函数的关键位置,将原本的若干条指令覆盖为一个跳转指令(JMP),使程序流程强制跳转到攻击者自定义的 Detour 函数处执行,从而实现对目标进程的拦截和监控。

DLL 劫持技术。 利用 Windows 进程加载 DLL 时的搜索路径优先级顺序——当前目录优先于系统目录,编写一个恶意的 lpk.dll 放置在 TraceMe.exe 同级目录下。当程序启动时会优先加载我们的伪造 DLL。同时,伪造的 DLL 通过 #pragma comment(linker, ...) 将原本属于系统 lpk.dll 的正常函数调用转发给改名后的 lpkOrg.dll,从而在不破坏原程序功能的前提下实现代码注入。

JMP 指令实现。 本实验以 JMP 方式实现 Inline Hook。标准的 JMP 指令占用 5 个字节(1 字节操作码 E9 + 4 字节相对偏移量),将这 5 字节写入目标地址后,CPU 会跳转到 Detour 函数执行。Detour 函数执行完毕后,需要还原被覆盖的原始指令,再跳回原程序继续执行。

堆栈与寄存器分析。 在 TraceMe.exe 中,调用 wsprintfA 之前,计算得到的序列号存放在 edx 寄存器中并被压入栈。wsprintfA 调用完成后,序列号仍在栈中。由于 wsprintfA 是 __cdecl 调用约定,函数执行完后栈顶尚未清理,此时 esp+8 对应的正是计算出来的正确序列号字符串指针。

Step 1 创建劫持 lpk 的 DLL 项目

项目创建

在 Visual Studio 中创建 Win32 DLL 项目。在 DllMain 的 DLL_PROCESS_ATTACH 事件中,为了防止直接在入口函数中执行耗时操作导致加载死锁,创建了一个独立的异步工作线程 ThreadWorking,在线程中调用安装 Hook 的主函数 InstallInlineHook()。

函数转发导出

通过 #pragma comment(linker, ...) 指令,将 lpk.dll 的 11 个导出函数全部转发到 lpkOrg.dll,确保原程序功能不受影响。

DllMain 入口函数

在 DLL 加载时创建工作线程,避免在 DllMain 中直接执行耗时操作导致死锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(hModule);
CreateThread(NULL, 0, ThreadWorking, NULL, 0, NULL);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

Step 2 Detour 函数

Hook 点分析

通过 x32dbg 打开 TraceMe.exe 进行动态调试,确定精确的 Hook 位置。在地址 00401380 处,程序调用了格式化字符串的 wsprintfA 函数。调用完成后,选择在紧接着的地址 00401386 处作为 Hook 点。

TraceMe.exe 中调用 wsprintfA 之前的汇编代码

被覆盖指令长度确定

由于标准的 JMP 指令需要 5 个字节,为了保持指令的完整性不出现断字,选择覆盖以下两条完整的原始指令,总计 7 个字节

  • 8B4424 1C \(\rightarrow\) mov eax, dword ptr ss:[esp+1Ch](4 字节)

  • 83C4 0C \(\rightarrow\) add esp, 0Ch(3 字节)

因此,原始指令长度 nOriginalLen = 7,超出 5 字节的剩余 2 个字节用 NOP(0x90)补齐。

序列号堆栈定位

由于 wsprintfA 是 __cdecl 调用约定,函数执行完后栈顶尚未清理。此时 esp 指向第一个参数,esp+4 指向格式化字符串,而 esp+8 对应的正是计算出来的正确序列号字符串指针。

Detour 函数实现

Detour 函数采用 naked 裸函数编写,通过内联汇编实现现场保护、数据提取、原始指令还原和安全跳回。核心逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 全局变量,保存跳回原程序的地址
unsigned long g_backPos = 0;

// 保证使用 __stdcall,自动清理参数
void WINAPI DoSomething(ULONG sid)
{
TCHAR strText[16] = { 0 };
wsprintf(strText, TEXT("%d"), sid);
MessageBox(NULL, strText, TEXT("Real Serial Number"), MB_OK);
}

__declspec(naked) void DetourFunction()
{
__asm
{
push dword ptr[esp + 8]
call DoSomething

mov eax, dword ptr[esp + 1Ch]
add esp, 0Ch

jmp g_backPos
}
}

Step 3 安装 Inline Hook 函数

内存属性修改

由于代码段(.text)默认是只读的,直接写入会导致内存保护异常。因此需要先通过 VirtualProtect 将目标内存区域的属性修改为可读写执行(PAGE_EXECUTE_READWRITE),写入完成后恢复原保护属性。

Hook 安装流程

InstallInlineHook 函数的实现流程如下:

  1. 利用 GetModuleHandle(NULL) 动态获取程序加载基址,加上偏移 0x1386 计算出精确 Hook 地址。

  2. 计算相对跳转偏移量:目标地址 - (当前地址 + 5)

  3. 构造 7 字节的 Hook 代码:1 字节 JMP 操作码 + 4 字节偏移量 + 2 字节 NOP 填充。

  4. 调用 VirtualProtect 修改内存保护属性。

  5. 使用 WriteProcessMemory 将构造好的代码写入目标地址。

  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
25
26
27
28
29
30
int InstallInlineHook()
{
unsigned char nOriginalLen = 7;

HMODULE hMod = GetModuleHandle(NULL);
unsigned long nHookPos = (unsigned long)hMod + 0x1386;

g_backPos = nHookPos + nOriginalLen;

unsigned char nHookCodeLen = 5;

unsigned char chHookCodes[16] = { 0xE9 };

*((ULONG*)(chHookCodes + 1)) = (ULONG)DetourFunction - (nHookPos + 5);

memset(chHookCodes + nHookCodeLen, 0x90, nOriginalLen - nHookCodeLen);

ULONG nWrite = 0;
HANDLE hProcess = GetCurrentProcess();

DWORD dwOldProtect;
VirtualProtect((void*)nHookPos, nOriginalLen, PAGE_EXECUTE_READWRITE, &dwOldProtect);

int nRet = WriteProcessMemory(hProcess, (void*)nHookPos, (void*)chHookCodes, nOriginalLen, &nWrite);

VirtualProtect((void*)nHookPos, nOriginalLen, dwOldProtect, &dwOldProtect);

if (nRet)return 1;
else return 0;
}

Step 4 显示序列号

Detour 函数获取到序列号后,通过 DoSomething 函数使用 MessageBox 弹窗显示。DoSomething 函数的调用约定为 __stdcall,由被调用者清理堆栈。

1
2
3
4
5
6
void WINAPI DoSomething(ULONG sid)
{
TCHAR strText[16] = { 0 };
wsprintf(strText, TEXT("%d"), sid);
MessageBox(NULL, strText, TEXT("Real Serial Number"), MB_OK);
}

Step 5 观察结果

将编译生成的 32 位 lpk.dll、原版系统 lpk.dll(重命名为 lpkOrg.dll)以及目标 TraceMe.exe 放置在同一个全新文件夹中。运行 TraceMe.exe,输入任意用户名和错误的序列号,点击 Check 按钮。

成功添加 Hook 后弹出提示弹窗,显示正确序列号

程序成功被拦截,优先弹出了一个名为 “Real Serial Number” 的提示框,里面清晰地显示出了程序内部计算出来的正确序列号。

关闭弹窗后程序仍能正常运行

点击确定关闭弹窗后,TraceMe.exe 完美恢复运行,没有发生卡死或崩溃现象。这验证了 Detour 函数中现场保护与恢复的正确性。

添加 Hook 之后的汇编代码

在完成了上述的Hook实验后,在虚拟机中运行x32dbg对修改后的程序,可以看到在地址0x00401386处已经成功被修改为 JMP 指令,指向了我们自定义的 Detour 函数,剩余空间使用NOP指令填充。同时,原本的指令被覆盖掉了,但由于我们在 Detour 函数中还原了这些指令,所以程序能够正常执行并跳回原程序继续运行。

【思考与总结】

(1)尝试用其他非 JMP 方式,以及不同的位置实现 Inline Hook

除了本实验所采用的标准 5 字节 JMP 相对跳转方式外,Inline Hook 还有多种替代实现方案。其中一种可行的方案是采用非 JMP 的 CALL 方式实现 Hook,即在原程序 0x1386 位置(即 wsprintfA 调用后),改用 CALL 指令(机器码 0xE8)进行挂钩。与 JMP 方式不同的是,CALL 指令在跳转前会自动将返回地址压入栈顶,因此中继函数执行完毕后不需要复杂的绝对跳转技巧,直接执行一条 ret 指令即可安全返回到原程序继续执行。

采用 CALL 方式后,Detour 裸函数的编写也需要做出相应调整。由于 CALL 指令自身将返回地址压入了栈顶(占 4 字节),再加上保护现场时 push 的 3 个寄存器(占 12 字节),序列号的相对偏移会进一步下移,从原本的 [esp + 14h] 变为 [esp + 18h]。函数执行完后,只需恢复现场、补写被覆盖的原始指令,然后直接 ret 即可——此时栈顶正好是 CALL 指令自动压入的返回地址,无需维护全局变量 g_backPos。具体实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__declspec(naked) void DetourFunction_CallMode()
{
__asm
{
// 获取序列号
// CALL 指令自身将返回地址压入栈顶(占 4 字节)
push dword ptr [esp + 0Ch]
call DoSomething

// 执行被覆盖的原程序指令
mov eax, dword ptr [esp + 1Ch]
add esp, 0Ch

// 返回(栈顶即为 CALL 压入的返回地址)
ret
}
}

安装 Hook 的函数也只需做一处微小的修改,将硬编码的 0xE9(JMP 操作码)替换为 0xE8(CALL 操作码),偏移量的计算公式与 NOP 填充、内存属性修改、WriteProcessMemory 写入等流程与原实验完全一致:

1
unsigned char chHookCodes[16] = { 0xE8 }; // 0xE8 代表 CALL 指令

与 JMP 方式相比,CALL 方式省去了维护全局跳回地址的开销,利用 CPU 自身的调用返回机制实现了更优雅的流程控制。但需要注意的是,CALL 指令会额外占用栈顶 4 字节的返回地址空间,在编写 Detour 函数时必须将这一差异纳入堆栈偏移的计算中,否则会导致数据读取错误。

采用call指令添加 Inline Hook

此外,通过选择不同的Hook位置也可以完成实验。在原实验中,我们选择的位置是 wsprintfA 调用完后的 00401386 处(此时序列号在堆栈中)。根据逆向分析,还可以选择以下两个完全不同的战略位置来实现拦截:在调用 lstrcmpA 之前进行拦截(最推荐的替换位置)。在调用 lstrcmpA 之前,程序必须把两个待比较的字符串指针压入栈中:一个是用户输入的假序列号,另一个是程序计算出来的真序列号。可以直接 Hook 挂钩 0040138F 这一行(或者它上方的 push eax 处)。当程序即将进入比较函数时,我们直接去读栈顶的参数,就能同时把“用户输入的序列号“和“正确的序列号“一起提取出来。

另外,应该也可以尝试直接Hook API 本身,利用 GetProcAddress 动态获取系统 USER32.dll 里的 wsprintfA 函数的头部地址,或者 KERNEL32.dll 里的 lstrcmpA 函数的头部地址 。直接去修改这些系统 API 的前 5 个字节,让它们跳转到我们的中继函数。

(2)总结 C/ASM 混合编程的方法

C/ASM 混合编程是底层开发和安全研究中的重要技能,主要有三种实现方式。第一种是内联汇编(__asm 块),即在 C/C++ 函数中直接嵌入汇编代码,使用 __asm { ... } 语法,适用于需要精确控制寄存器和堆栈的场景,内联汇编可以自由访问 C/C++ 变量,编译器不会为其生成额外的函数序言和尾声代码。第二种是 Naked 函数(裸函数),使用 __declspec(naked) 修饰符声明函数,告诉编译器不生成任何函数序言和尾声代码(如 push ebp / mov ebp,esp / sub esp,xxx / pop ebp / ret 等),程序员完全掌控堆栈布局,适用于 Detour 函数、中断处理等底层场景,但需要程序员自行管理堆栈平衡。第三种是外部汇编文件,即将汇编代码单独写在 .asm 文件中,通过 EXTERN 关键字声明 C/C++ 函数,在汇编中调用,实现模块化的混合编程。

在进行 C/ASM 混合编程时,有几个关键注意事项需要牢记。堆栈平衡是首要问题,在 Naked 函数中,每次 push 必须有对应的 pop,确保函数返回时 esp 和 ebp 的值与调用前一致,堆栈不平衡是混合编程中最常见的 Bug 来源。调用约定方面,不同调用约定(__cdecl__stdcall__thiscall 等)决定了参数传递方式和堆栈清理责任,__cdecl 由调用者清栈,__stdcall 由被调用者清栈,本实验中 DoSomething 使用 __stdcall,Detour 函数在调用前手动平衡了堆栈。寄存器保护同样至关重要,在 Detour 函数中调用其他 API(如 MessageBox)时,这些 API 会污染通用寄存器(EAX、ECX、EDX),必须在调用前保存、调用后恢复,否则原程序会因寄存器环境破坏而崩溃。函数导出方面,DLL 中的导出函数需要使用 __stdcallWINAPI 修饰符,并通过 .def 文件或 #pragma comment(linker, ...) 指定导出名称和序号。

【附录-完整源代码】

lpk.cpp

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
#include <windows.h>
#include <tchar.h>
#include "pch.h"

#pragma comment(linker, "/EXPORT:LpkTabbedTextOut=lpkOrg.LpkTabbedTextOut,@1")
#pragma comment(linker, "/EXPORT:LpkDllInitialize=lpkOrg.LpkDllInitialize,@2")
#pragma comment(linker, "/EXPORT:LpkDrawTextEx=lpkOrg.LpkDrawTextEx,@3")
#pragma comment(linker, "/EXPORT:LpkPSMTextOut=lpkOrg.LpkPSMTextOut,@4")
#pragma comment(linker, "/EXPORT:LpkUseGDIWidthCache= lpkOrg.LpkUseGDIWidthCache,@5")
#pragma comment(linker, "/EXPORT:LpkGetCharacterPlacement= lpkOrg.LpkGetCharacterPlacement,@6")
#pragma comment(linker, "/EXPORT:LpkGetTextExtentExPoint= lpkOrg.LpkGetTextExtentExPoint,@7")
#pragma comment(linker, "/EXPORT:LpkInitialize=lpkOrg.LpkInitialize,@8")
#pragma comment(linker, "/EXPORT:ftsWordBreak=lpkOrg.ftsWordBreak,@9")
#pragma comment(linker, "/EXPORT:LpkEditControl=lpkOrg.LpkEditControl,@10")
#pragma comment(linker, "/EXPORT:LpkExtTextOut=lpkOrg.LpkExtTextOut,@11")

// 全局变量,保存跳回原程序的地址
unsigned long g_backPos = 0;

// 保证使用 __stdcall,自动清理参数
void WINAPI DoSomething(ULONG sid)
{
TCHAR strText[16] = { 0 };
wsprintf(strText, TEXT("%d"), sid);
MessageBox(NULL, strText, TEXT("Real Serial Number"), MB_OK);
}

__declspec(naked) void DetourFunction()
{
__asm
{
push dword ptr[esp + 8]
call DoSomething

mov eax, dword ptr[esp + 1Ch]
add esp, 0Ch

jmp g_backPos
}
}

int InstallInlineHook()
{
unsigned char nOriginalLen = 7;

HMODULE hMod = GetModuleHandle(NULL);
unsigned long nHookPos = (unsigned long)hMod + 0x1386;

g_backPos = nHookPos + nOriginalLen;

unsigned char nHookCodeLen = 5;

unsigned char chHookCodes[16] = { 0xE9 };

*((ULONG*)(chHookCodes + 1)) = (ULONG)DetourFunction - (nHookPos + 5);

memset(chHookCodes + nHookCodeLen, 0x90, nOriginalLen - nHookCodeLen);

ULONG nWrite = 0;
HANDLE hProcess = GetCurrentProcess();

DWORD dwOldProtect;
VirtualProtect((void*)nHookPos, nOriginalLen, PAGE_EXECUTE_READWRITE, &dwOldProtect);

int nRet = WriteProcessMemory(hProcess, (void*)nHookPos, (void*)chHookCodes, nOriginalLen, &nWrite);

VirtualProtect((void*)nHookPos, nOriginalLen, dwOldProtect, &dwOldProtect);

if (nRet)
return 1;
else
return 0;
}

DWORD WINAPI ThreadWorking(LPVOID lpParameters)
{
InstallInlineHook();
return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(hModule);
CreateThread(NULL, 0, ThreadWorking, NULL, 0, NULL);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

思考题一

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#include <windows.h>
#include <tchar.h>
#include "pch.h"

#pragma comment(linker, "/EXPORT:LpkTabbedTextOut=lpkOrg.LpkTabbedTextOut,@1")
#pragma comment(linker, "/EXPORT:LpkDllInitialize=lpkOrg.LpkDllInitialize,@2")
#pragma comment(linker, "/EXPORT:LpkDrawTextEx=lpkOrg.LpkDrawTextEx,@3")
#pragma comment(linker, "/EXPORT:LpkPSMTextOut=lpkOrg.LpkPSMTextOut,@4")
#pragma comment(linker, "/EXPORT:LpkUseGDIWidthCache= lpkOrg.LpkUseGDIWidthCache,@5")
#pragma comment(linker, "/EXPORT:LpkGetCharacterPlacement= lpkOrg.LpkGetCharacterPlacement,@6")
#pragma comment(linker, "/EXPORT:LpkGetTextExtentExPoint= lpkOrg.LpkGetTextExtentExPoint,@7")
#pragma comment(linker, "/EXPORT:LpkInitialize=lpkOrg.LpkInitialize,@8")
#pragma comment(linker, "/EXPORT:ftsWordBreak=lpkOrg.ftsWordBreak,@9")
#pragma comment(linker, "/EXPORT:LpkEditControl=lpkOrg.LpkEditControl,@10")
#pragma comment(linker, "/EXPORT:LpkExtTextOut=lpkOrg.LpkExtTextOut,@11")


void WINAPI DoSomething(ULONG sid)
{
TCHAR strText[16] = { 0 };
wsprintf(strText, TEXT("%d"), sid);
MessageBox(NULL, strText, TEXT("Real Serial Number"), MB_OK);
}

__declspec(naked) void DetourFunction()
{
__asm
{
push eax
push ecx
push edx

push dword ptr[esp + 18h]
call DoSomething
pop edx
pop ecx
pop eax

mov eax, dword ptr[esp + 20h]

mov ecx, [esp]
add esp, 10h

push ecx

mov ecx, [esp - 0Ch]

ret
}
}

int InstallInlineHook()
{
unsigned char nOriginalLen = 7;

HMODULE hMod = GetModuleHandle(NULL);
unsigned long nHookPos = (unsigned long)hMod + 0x1386;

unsigned char nHookCodeLen = 5;
unsigned char chHookCodes[16] = { 0xE8 };

*((ULONG*)(chHookCodes + 1)) = (ULONG)DetourFunction - (nHookPos + 5);

memset(chHookCodes + nHookCodeLen, 0x90, nOriginalLen - nHookCodeLen);

ULONG nWrite = 0;
HANDLE hProcess = GetCurrentProcess();

DWORD dwOldProtect;
VirtualProtect((void*)nHookPos, nOriginalLen, PAGE_EXECUTE_READWRITE, &dwOldProtect);

int nRet = WriteProcessMemory(hProcess, (void*)nHookPos, (void*)chHookCodes, nOriginalLen, &nWrite);

VirtualProtect((void*)nHookPos, nOriginalLen, dwOldProtect, &dwOldProtect);

if (nRet)
return 1;
else
return 0;
}

DWORD WINAPI ThreadWorking(LPVOID lpParameters)
{
InstallInlineHook();
return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(hModule);
CreateThread(NULL, 0, ThreadWorking, NULL, 0, NULL);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

软件安全实验-HOOK技术
https://eleco.top/2026/06/04/软件安全实验-HOOK技术/
作者
Eleco
发布于
2026年6月4日
许可协议