15、Hook 技术:什么是 Hook、Inline Hook 的原理、使用 CE 实现简单的 Inline Hook、Hook 的恢复

好,咱们今天聊点硬核的——Hook 技术。说实话,这玩意儿是游戏逆向里最让我着迷的部分之一。你想想看,一个游戏程序本来按部就班地跑着,你突然在中间插一杠子,让它执行你的代码,然后再回去继续跑……这种感觉,就像你在别人家的客厅里悄悄开了扇暗门。

我最早接触 Hook 是在做一款老网游的加速器时。当时游戏把时间计算写死在代码里,改内存数值根本没用。后来我意识到,只有把那个时间函数“截胡”掉,才能从根本上控制游戏速度。嗯,这就是 Hook 的威力。

核心概念:Hook 就是在程序执行流中插入一个“监听点”或“拦截点”,让原本的指令跳转到我们自定义的代码中执行,执行完再决定是否返回原流程。

15.1 什么是 Hook

Hook 这个词,直译过来就是“钩子”。你可以想象成钓鱼——程序是一条河,你的钩子扔下去,鱼(数据或执行流)经过时就被你钩住了。

在游戏逆向中,Hook 的用途非常广泛:

  • 拦截函数调用——比如游戏调用绘制函数时,你截住它,修改绘制参数
  • 修改函数行为——让原本扣血的操作变成加血
  • 监控数据变化——每次某个变量被修改时,你都能知道
  • 绕过检测——让反作弊系统永远收不到异常报告

我个人习惯把 Hook 分成两大类:软件 Hook 和硬件 Hook。咱们做 CE 逆向,主要玩的是软件 Hook,而软件 Hook 里最经典、最暴力的就是 Inline Hook。

我的经验:很多新手一上来就想搞复杂的 Hook 框架,其实没必要。从 Inline Hook 入手,理解透了,其他 Hook 方式(比如 VTable Hook、IAT Hook)都是换汤不换药。

15.2 Inline Hook 的原理

Inline Hook 的原理,说白了就四个字:指令篡改

你找到目标函数开头的那几条指令,把它们替换成一条跳转指令(JMP),跳到你写的自定义函数里。你的函数执行完后,再跳回原来的函数继续执行。

我画了个图,帮你理解这个过程:

Inline Hook 执行流程 原始函数入口 执行原指令 继续后续执行 原始函数入口 JMP 跳转指令 (替换了原指令) 你的 Hook 函数 跳回原始函数 正常执行 Hook 后执行

这里有个关键点:你替换掉的那几条原始指令,必须完整保存下来。不然你跳回去的时候,程序就崩了。为什么会这样?因为 CPU 是按顺序执行指令的,你把开头几条指令抹掉了,后面的指令上下文就全乱了。

注意:JMP 指令在 x86 下占 5 个字节(E9 + 4字节偏移量)。所以你至少要替换 5 个字节的原始指令。如果目标函数开头指令不足 5 字节,或者指令跨字节边界,你就得用更长的跳转方式(比如 JMP [地址] 占 6 字节)。

15.3 使用 CE 实现简单的 Inline Hook

好,理论讲完了,咱们动手。CE 自带的 Auto Assembler 就支持 Inline Hook,你不需要写复杂的汇编代码。

我拿一个简单的例子演示:假设游戏里有个函数 sub_401000,每次调用时会把 eax 加 10。我想把它改成加 100。

步骤如下:

  1. 打开 CE,附加游戏进程
  2. 找到目标函数地址(比如 0x401000)
  3. 点击「Memory View」→「Tools」→「Auto Assembler」
  4. 选择「Template」→「Code injection」
  5. 输入函数地址,CE 会自动生成模板

生成的模板大概长这样:

// 自动生成的模板
alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)

newmem:   // 这是你的 Hook 函数入口
  // 在这里写你的代码

originalcode:  // 保存原始指令
  // CE 会自动填充被替换的指令

exit:
  jmp returnhere

// 目标地址
0x401000:
  jmp newmem
  nop  // 如果替换的指令不足5字节,用nop填充
  returnhere:

现在,我们把加 10 改成加 100:

alloc(newmem,2048)
label(returnhere)
label(originalcode)
label(exit)

newmem:
  add eax,100    // 改成加100
  jmp originalcode

originalcode:
  add eax,10     // 原始指令(加10)
  jmp exit

exit:
  jmp returnhere

0x401000:
  jmp newmem
  nop
  returnhere:

避坑指南:我曾经在写 Hook 时忘记保存原始指令,结果每次 Hook 触发后程序就崩溃。排查了半天才发现,原来我跳回的位置不对。记住:你的 Hook 函数执行完后,必须让原始指令也执行一遍,除非你故意要屏蔽它。

执行这段脚本后,CE 会把 0x401000 处的指令替换成 jmp newmem,然后你的 Hook 就生效了。每次游戏调用这个函数,都会先跑到你的代码里加 100,再回去执行原来的加 10——等等,这样不就加了 110 吗?

嗯,你说得对。如果你想完全替换掉原始行为,就不要跳转到 originalcode,直接 jmp exit 就行:

newmem:
  add eax,100    // 只加100,不加10
  jmp exit       // 跳过原始指令

originalcode:
  add eax,10     // 这条指令永远不会被执行了

exit:
  jmp returnhere

15.4 Hook 的恢复

Hook 打上去容易,恢复起来却有不少坑。你想想看,你把人家函数开头的指令改成了 JMP,如果不恢复回来,游戏退出时可能会留下一个损坏的进程,或者下次启动时 Hook 还在(如果你写入了文件)。

恢复 Hook 的核心思路就一句话:把被替换的原始指令写回去

在 CE 的 Auto Assembler 里,你可以用 unhook 或者手动写恢复代码。我个人习惯手动写,因为更可控:

// 保存原始字节
label(originalBytes)
originalBytes:
  db 83 C0 0A   // 这是 add eax,10 的机器码

// 恢复 Hook
restoreHook:
  push ebp
  mov ebp,esp
  // 写保护解除(VirtualProtect)
  // 将 originalBytes 写回 0x401000
  // 恢复保护属性
  pop ebp
  ret

不过说实话,在 CE 里做 Hook 恢复,最常用的方式就是直接禁用脚本。CE 的 Auto Assembler 脚本在启用时写入 Hook,禁用时自动恢复原始字节。你只需要在脚本里加一个 dealloc 和恢复逻辑:

// 脚本末尾添加
dealloc(newmem)
// CE 会自动恢复被修改的字节

重要提醒:如果你在 Hook 函数里分配了内存(比如用 alloc),一定要在恢复时释放掉。否则会造成内存泄漏。我曾经在一个长期运行的服务器程序上做 Hook 测试,忘了释放内存,结果跑了三天后程序占用了 2GB 内存……嗯,那是个难忘的教训。

还有一个常见问题:多线程环境下的 Hook 恢复。如果你的 Hook 正在被另一个线程执行,你突然把原始指令写回去,那个线程就会执行到一半的 JMP 指令变成乱码,直接崩溃。解决方案是:先暂停所有线程,再恢复 Hook,最后恢复线程。CE 的脚本里可以用 SuspendThreadResumeThread 来实现。

恢复方式 优点 缺点 适用场景
CE 脚本自动恢复 简单,一键操作 不够灵活 快速测试
手动写回原始字节 完全可控 需要处理内存保护 生产环境
使用 Hook 框架 功能完善 依赖外部库 大型项目

最后说一句:Hook 技术是把双刃剑。用在正道上,你可以做游戏修改器、调试工具、性能分析器。用在歪路上,就是外挂和作弊器。我个人建议你多研究 Hook 在安全领域的应用——比如反 Hook 检测、Hook 检测绕过,这些才是真正值钱的技术。

嗯,Hook 的内容就讲到这里。记住我强调的那几点:保存原始指令、注意字节对齐、多线程安全。把这些搞明白了,Inline Hook 对你来说就是小菜一碟。