本节为轩辕的编程宇宙公众号《从零开始学逆向》的“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)指令格式
|
|
(2)指令示例
|
|
(3)机器码示例
|
|
(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)指令格式
|
|
(2)指令示例
|
|
(3)机器码示例
|
|
(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)指令格式
|
|
(2)指令示例
|
|
(3)机器码示例
|
|
(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 系统中,所有应用程序的
CS、DS、SS段基址其实都指向同一个地方(0地址)。换句话说,大家都在同一个“大段”里,很少需要到跨段需求。
(1)指令格式
|
|
(2)指令示例
|
|
(3)机器码示例
|
|
注意:在 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。(一般在原函数开头修改,实际在原函数内任意位置修改均可)。

本节会介绍最基础的两个 Inline Hook 示例(Hook 用户自定义函数、Hook 系统函数)。在不考虑原函数代码被再次调用的情况,直接只跳转到目标函数去执行。
2-1、Hook 用户自定义函数
Hook 用户自定义函数示例:
|
|
2-2、Hook 系统函数
Hook 系统函数示例:
|
|
2-3、Hook 代码优化
可以将前面的 Hook 代码优化为一个通用的 Hook 函数。后续使用时直接调用即可。
|
|
3、完整 Inline Hook
前面的基础代码存在三个问题。
问题一:在 Hook 的处理函数(HookMessageBoxA)中,要调用原始函数(MessageBoxA)怎么办?
因为原始函数被我们 Hook 了。那么,如果在 Hook 的处理函数(HookMessageBoxA)中调用原始函数(MessageBoxA)就会导致死循环。
|
|
问题二:如果要调用原始函数(MessageBoxA),但原始函数(MessageBoxA)中的前几行代码已经被我们覆盖掉了,那覆盖掉的指令又如何处理?
一般情况下,我们需要将原始函数(MessageBoxA)的前几行指令备份一下。
问题三:假如被 Hook 位置的指令被 jmp 指令(5字节)拦腰截断了。那么即使备份了,也是备份的残缺不全的指令,这又如何处理?

此时,就需要根据目标位置的实际情况,动态决定到底备份几个字节。
3-1、实现逻辑图

第一步:备份原始函数的前几字节指令到 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 了。