Inline Hook

本节为轩辕的编程宇宙公众号《从零开始学逆向》的“Inline Hook”小节的学习笔记。详细课程内容请自行 跳转 加入查看。

在之前的 Maldev 开发课程中,已经学习过 Hook 原理及代码实现了。当时是通过 Detours 库、MinHook 库实现,或者手动写代码实现 Hook。但当学习《从零开始学逆向》的“Inline Hook”小节时,发现与之前的实现略有差异,新的代码涉及到了对偏移地址的计算,所以这里写一文来对比下与之前的差异。

学完后发现,两者的本质差异在于 JMP 指令的寻址方式。Maldev 开发课程使用的是 绝对近跳转,即通过寄存器(如 eax)直接存储目标函数的完整内存地址来完成跳转;《从零开始学逆向》则采用了 相对近跳转,即通过计算目标地址与当前指令地址之间的 偏移量(Offset)来完成跳转。

本文代码在 VS 2026 - x86 Debug 模式下编译测试完成。

1、JMP 指令细解

《从零开始学逆向》的“Inline Hook”小节中,涉及到对 JMP 指令跳转偏移量的计算。如果不理解偏移量是怎么计算的,那么大概率也很难吃透后续的代码。所以,这里花了很大的篇幅来介绍 JMP 指令。理解透了这部分,后面写 Hook 代码的时候就很容易理解了。

我们可以将 JMP 指令分为三大类:短跳转近跳转远跳转

1-1、JMP Short(短跳转)

它是最轻量级的跳转,只能向后(Forward)或者向前(Back)跳转。指令只占用 2 个字节。向后(Forward)指的是继续执行程序后续的代码指令,向前(Back)指的是程序之前已执行过的代码。

(1)指令格式

1
jmp short 标号(1字节偏移量)

(2)指令示例

1
jmp short 0x05   ; 向后(Forward)跳转 5 个字节

(3)机器码示例

1
EB XX   ; XX 是 1 字节的偏移量

(4)跳转范围

-128 到 +127 (即 -0x80 ~ 0x7F)

因为偏移量是一个有符号整数,所以这个“偏移量”可以正也可以负。

一字节的有符号整数。符号(+/-)占用 1 Bit,那么剩下的偏移量值就只有 7 bit 来表示了。那么最大正数可表示为 $2^7 - 1 = 127$,即最大向后(Forward)跳转 0x7F(127) 个字节;那么最大负数可表示为 $-(2^7)=-128$,即最大向前(Back)跳转 0x80(128) 个字节。

一个字节可以用 256 个编码来表示正数、负数,还有

  • 正数:1 到 127(占用了 127 个编码)
  • :0(占用了 1 个编码)
  • 负数:-1 到 -128(占用了 128 个编码)

因为“”在二进制中被归类为了非负数(即它的符号位是 0)。零占掉了一个本属于正数的“坑位”,那么就导致正数能表示的最大值比负数少了一个。也就是上面说的最大正数可表示为 $2^7 - 1 = 127$。

(5)跳转分析

在 x86 架构中,CPU 执行指令时,EIP/RIP(指令指针)永远指向“下一条即将执行的指令”。相对跳转就是在那个“下一条”的基础上进行加减。

假设我们的代码段从地址 0x1000 开始。

内存地址 机器码 (Hex) 汇编指令 解释
0x1000 EB 05 jmp short 0x05 当前指令EB是操作码,05是偏移量。
0x1002 90 nop 下一条指令地址。CPU读完上面那条,指针就指到这了。
0x1003 90 nop 距离下一条指令 +1 字节
0x1004 90 nop 距离下一条指令 +2 字节
0x1005 90 nop 距离下一条指令 +3 字节
0x1006 90 nop 距离下一条指令 +4 字节
0x1007 CC int 3 [+]目的地(Target) 距离下一条指令 +5 字节。

在 x86 环境下,相对跳转的计算公式如下:

目标地址 = 当前指令地址 + JMP指令长度 + 偏移量

目标地址 = 下一条指令地址 + 偏移量

带入公式:

目标地址 = 0x1000 + 2 + 0x05 (当前指令地址 + JMP指令长度 + 偏移量)

目标地址 = 0x1002 + 0x05 (下一条指令地址 + 偏移量)

目标地址 = 0x1007

(6)使用场景

常用于 if 语句、小型循环。

1-2、JMP Near(近跳转)

近跳转是指在当前代码段(CS)内部进行的跳转。根据寻址方式不同,分为相对近跳转(计算距离)和绝对近跳转(直接指定地址)。

”的本质定义:不改变 CS(代码段寄存器)。只要 CS 不变,无论是在 4GB 还是 16EB 的范围内跳,在 CPU 眼里都叫“段内跳转”,也就是“近跳转”。

1-2-1、相对近跳转(Rel)

相对近跳转 (Relative Near Jump)。机器码以 E9 开头,指令占用 5 个字节

(1)指令格式

1
jmp 标号(4字节偏移量)

(2)指令示例

1
2
jmp 0x12345678  ; 向后(Forward)跳转 0x12345678 个字节
# 注意!:0x12345678 是偏移量的值,只是看着像内存地址,实际”不是,不是,不是“程序的内存地址。

(3)机器码示例

1
E9 XX XX XX XX  ; XX 是 4 字节的有符号偏移量(小端序存储)

(4)跳转范围

-2,147,483,648 到 +2,147,483,647 (即 ±2GB)

偏移量为 4 字节(32 Bit)有符号整数。由于“零”占用了正数一个编码位,其范围遵循 $-2^{31}$到$2^{31}-1$。

(5)跳转分析

假设我们的代码段从地址 0x401000 开始。

内存地址 机器码 (Hex) 汇编指令 解释
0x401000 E9 FB 00 00 00 jmp 0xFB 当前指令地址E9 是操作码。
0x401005 ... ... 下一条指令地址 (0x401000 + 5)
0x401100 ... ... [+]目的地 (Target) 距离下一条指令 +0xFB(251) 字节。

在 x86 环境下,相对跳转的计算公式如下:

目标地址 = 当前指令地址 + JMP指令长度 + 偏移量

目标地址 = 下一条指令地址 + 偏移量

带入公式:

目标地址 = 0x401000 + 5 + 0xFB (当前指令地址 + JMP指令长度 + 偏移量)

目标地址 = 0x401005 + 0xFB (下一条指令地址 + 偏移量)

目标地址 = 0x401100

(6)使用场景

常用于函数间跳转、动态链接库调用等。

1-2-2、绝对近跳转(Abs)

绝对近跳转 (Absolute Near Jump)。机器码以 FF 开头,它不计算距离,直接跳转到寄存器或内存中存放的绝对地址

(1)指令格式

1
jmp 寄存器 ( rax/eax)

(2)指令示例

1
2
mov rax, 0x00007FF712345678
jmp rax   ; 直接跳转到 rax 存储的绝对地址

(3)机器码示例

1
FF E0     ; jmp rax 的机器码

(4)跳转范围

全内存空间 (x86 为 4GB / x64 为 16EB)

因为它直接将目标地址加载进指令指针寄存器(EIP/RIP),不受偏移量位数的限制,可以到达当前模式下 CPU 能寻址的任何地方。

(5)跳转分析

绝对跳转不需要计算偏移量,也没有“下一条指令”的基准加法。假设我们要跳往 0x7FF712345678

内存地址 机器码 (Hex) 汇编指令 解释
0x1000 48 B8 78 56 34 12 F7 7F 00 00 mov rax, 0x7FF712345678 先将 8 字节绝对地址存入寄存器
0x100A FF E0 jmp rax 当前指令地址。直接传送。
0x7FF712345678 ... ... [+]目的地 (Target)

1-3、JMP Far(远跳转)

JMP Far(远跳转)是 JMP 中最“重型”的跳转。它不仅改变指令指针(EIP/RIP),还会改变代码段寄存器(CS)

平坦内存模型:现代 Windows 系统中,所有应用程序的 CSDSSS 段基址其实都指向同一个地方(0地址)。换句话说,大家都在同一个“大段”里,很少需要到跨段需求。

(1)指令格式

1
2
3
jmp far 属性:偏移地址
; 汇编示例
jmp 0x0008:0x12345678

(2)指令示例

1
jmp ptr [mem]  ; 这里的 mem 包含 6 字节(32位模式)或 10 字节(64位模式)数据

(3)机器码示例

1
EA XX XX XX XX YY YY  ; EA 是操作码,后面跟 4 字节地址 + 2 字节段选择子

注意:在 64 位模式下,EA 指令通常被禁用,绝对远跳转多通过 FF /5 间接实现

(4)跳转范围

任意位置,超越当前段限制

它没有“距离”概念。因为它强制修改了 CS 寄存器,所以它可以跨越不同的权限级(Ring 3 切换到 Ring 0)或者不同的运行模式(32 位兼容模式切换到 64 位长模式)。

(5)跳转分析

JMP Far 与前两者最本质的区别:它会同时刷新 CS 和 EIP

假设我们要从当前的 32 位代码段跳往另一个段:

内存地址 机器码 汇编指令 解释
0x1000 EA 78 56 34 12 08 00 jmp 0x0008:0x12345678 当前指令地址EA 是操作码。
目的地 CPU 行为:
新 CS 0x0008 CPU 把 0x0008 加载进 CS 寄存器
新 EIP 0x12345678 CPU 把 0x12345678 加载进 EIP 寄存器

目标地址由指令直接给出。

1-4、JMP 指令小结

总结项 JMP Short JMP Near (Rel) JMP Near (Abs) JMP Far
字节大小 2 字节 5 字节 变长 (通常 2+) 7-11 字节
核心逻辑 相对距离 相对距离 绝对地址 绝对地址 + 段切换
修改寄存器 仅 EIP 仅 EIP 仅 EIP CS + EIP

2、基础 Inline Hook

Inline Hook 基本原理是直接修改原始函数的汇编指令为 jmp xxx。(一般在原函数开头修改,实际在原函数内任意位置修改均可)。

image-20260124210941222

本节会介绍最基础的两个 Inline Hook 示例(Hook 用户自定义函数、Hook 系统函数)。在不考虑原函数代码被再次调用的情况,直接只跳转到目标函数去执行。

2-1、Hook 用户自定义函数

Hook 用户自定义函数示例:

 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
#include <Windows.h>
#include <stdio.h>


void print_hello() {
	printf("Hello, World\n");
}

void hooked_function() {
	printf("Goodbye, World\n");
}

void install_hook1() {
	
	// 一般只定义5个字节,满足存储最小 jmp 指令大小即可
	// jmp_code = {0x00, 0x00, 0x00, 0x00, 0x00};
	unsigned char jmp_code[5] = { 0 };

	// jmp 指令,E9 操作码部分
	jmp_code[0] = 0xE9;

	// jmp 指令,标号(4字节偏移量)部分。
	// 公式参考:目标地址 = 当前指令地址 + JMP指令长度 + 偏移量
	// 公式转换:偏移量 = 目标地址 - (当前指令地址 + JMP指令长度)
	// 因为我们实现 Hook 的效果是当程序执行 print_hello() 函数的时候,触发执行目标函数(hooked_function)。那么我们就在刚好进入 print_hello() 函数,还未执行第一条语句的时候,实现跳转指令即可。此时的当前指令地址直接用 print_hello() 函数首地址即可。(在 C 语言中,函数名称就代表函数的地址)
	int offset = (int)hooked_function - (int(print_hello) + 5);
	// 偏移量赋值到 jmp_code 的标号(4字节偏移量)部分。
	*(int *)&jmp_code[1] = offset;

	// 修改内存页面的权限
	DWORD dwOldProctect = NULL;
	VirtualProtect(print_hello, 4096, PAGE_EXECUTE_READWRITE, &dwOldProctect);

	// 拷贝构造的 jmp 指令到函数 print_hello 首地址处,完成指令覆盖。
	memcpy(print_hello, jmp_code, 5);
}


int main() {

	install_hook1();
	print_hello();

	return 0;

}

2-2、Hook 系统函数

Hook 系统函数示例:

 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
#include <Windows.h>
#include <stdio.h>

// 直接从 VS 的MessageBoxA() 函数定义拿即可
int WINAPI HookMessageBoxA(_In_opt_ HWND hWnd, _In_opt_ LPCSTR lpText, _In_opt_ LPCSTR lpCaption, _In_ UINT uType) {

	printf("[+] %s:%s\n", lpCaption, lpText);
	return 0;

}


void install_hook2() {

	// 一般只定义5个字节,满足存储最小 jmp 指令大小即可
	// jmp_code = {0x00, 0x00, 0x00, 0x00, 0x00};
	unsigned char jmp_code[5] = { 0 };

	// jmp 指令,E9 操作码部分
	jmp_code[0] = 0xE9;

	// jmp 指令,标号(4字节偏移量)部分。
	// 公式参考:目标地址 = 当前指令地址 + JMP指令长度 + 偏移量
	// 公式转换:偏移量 = 目标地址 - (当前指令地址 + JMP指令长度)
	int offset = (int)HookMessageBoxA - (int(MessageBoxA) + 5);
	// 偏移量赋值到 jmp_code 的标号(4字节偏移量)部分。
	*(int *)&jmp_code[1] = offset;

	// 修改内存页面的权限
	DWORD dwOldProctect = NULL;
	VirtualProtect(MessageBoxA, 4096, PAGE_EXECUTE_READWRITE, &dwOldProctect);

	// 拷贝构造的 jmp 指令到函数 MessageBoxA 首地址处,完成指令覆盖。
	memcpy(MessageBoxA, jmp_code, 5);
}


int main() {

	install_hook2();
	MessageBoxA(NULL, "Test Tnformation", "Info", MB_OK);

	return 0;

}

2-3、Hook 代码优化

可以将前面的 Hook 代码优化为一个通用的 Hook 函数。后续使用时直接调用即可。

 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
#include <Windows.h>
#include <stdio.h>


int WINAPI HookMessageBoxA(_In_opt_ HWND hWnd, _In_opt_ LPCSTR lpText, _In_opt_ LPCSTR lpCaption, _In_ UINT uType) {

	printf("[+] %s:%s\n", lpCaption, lpText);
	return 0;

}

void install_hook(void *hooked_function, void *target) {

	// 一般只定义5个字节,满足存储最小 jmp 指令大小即可
	// jmp_code = {0x00, 0x00, 0x00, 0x00, 0x00};
	unsigned char jmp_code[5] = { 0 };

	// jmp 指令,E9 操作码部分
	jmp_code[0] = 0xE9;

	// jmp 指令,标号(4字节偏移量)部分。
	// 公式参考:目标地址 = 当前指令地址 + JMP指令长度 + 偏移量
	// 公式转换:偏移量 = 目标地址 - (当前指令地址 + JMP指令长度)
	int offset = (int)target - (int(hooked_function) + 5);
	// 偏移量赋值到 jmp_code 的标号(4字节偏移量)部分。
	*(int *)&jmp_code[1] = offset;

	// 修改内存页面的权限
	DWORD dwOldProctect = NULL;
	VirtualProtect(hooked_function, 4096, PAGE_EXECUTE_READWRITE, &dwOldProctect);

	// 拷贝构造的 jmp 指令到函数 MessageBoxA 首地址处,完成指令覆盖。
	memcpy(hooked_function, jmp_code, 5);
}

int main() {

	install_hook(MessageBoxA, HookMessageBoxA);
	MessageBoxA(NULL, "Test Tnformation", "Info", MB_OK);

	return 0;

}

3、完整 Inline Hook

前面的基础代码存在三个问题。

问题一:在 Hook 的处理函数(HookMessageBoxA)中,要调用原始函数(MessageBoxA)怎么办?

因为原始函数被我们 Hook 了。那么,如果在 Hook 的处理函数(HookMessageBoxA)中调用原始函数(MessageBoxA)就会导致死循环。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 陷入死循环
int WINAPI HookMessageBoxA(_In_opt_ HWND hWnd, _In_opt_ LPCSTR lpText, _In_opt_ LPCSTR lpCaption, _In_ UINT uType) {

	printf("[+] %s:%s\n", lpCaption, lpText);
  
  // 调用原始函数 MessageBoxA
  MessageBoxA(NULL, "Test Tnformation", "Info", MB_OK);
  
	return 0;

}

问题二:如果要调用原始函数(MessageBoxA),但原始函数(MessageBoxA)中的前几行代码已经被我们覆盖掉了,那覆盖掉的指令又如何处理?

一般情况下,我们需要将原始函数(MessageBoxA)的前几行指令备份一下。

问题三:假如被 Hook 位置的指令被 jmp 指令(5字节)拦腰截断了。那么即使备份了,也是备份的残缺不全的指令,这又如何处理?

image-20260126020124491

此时,就需要根据目标位置的实际情况,动态决定到底备份几个字节。

3-1、实现逻辑图

image-20260126020753390

第一步:备份原始函数的前几字节指令到 origin_code 处(原函数入口)。

第二步:修改原始函数(被Hook函数)的前几字节为 jmp 指令,使其跳转到 Hook 中转代码(Hook Stub)处。

第三步:jz/jnz指令均通过 nop 代码指令序列,执行到 jmp hook_handler(Hook 处理函数)中去。

第四步:当在 Hook 处理函数中要调用原始函数时,就可以直接使用保留的”原函数入口“地址。”原函数指令入口“保存的原始函数指令执行后,会继续执行原始函数剩下的其他指令(jmp origin_next_code 跳转回到原代码块实现)。

第五步:flag1、flag2 字段,可实现代码辨识度。

相关代码实现及讲解,建议还是自行去看完轩辕大佬讲解的《从零开始学逆向》的“Inline Hook”小节视频。大佬讲的很好,这里就不再重复 Copy-Paste 了。

updatedupdated2026-01-262026-01-26