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),跳到你写的自定义函数里。你的函数执行完后,再跳回原来的函数继续执行。
我画了个图,帮你理解这个过程:
这里有个关键点:你替换掉的那几条原始指令,必须完整保存下来。不然你跳回去的时候,程序就崩了。为什么会这样?因为 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。
步骤如下:
- 打开 CE,附加游戏进程
- 找到目标函数地址(比如 0x401000)
- 点击「Memory View」→「Tools」→「Auto Assembler」
- 选择「Template」→「Code injection」
- 输入函数地址,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 的脚本里可以用 SuspendThread 和 ResumeThread 来实现。
| 恢复方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| CE 脚本自动恢复 | 简单,一键操作 | 不够灵活 | 快速测试 |
| 手动写回原始字节 | 完全可控 | 需要处理内存保护 | 生产环境 |
| 使用 Hook 框架 | 功能完善 | 依赖外部库 | 大型项目 |
最后说一句:Hook 技术是把双刃剑。用在正道上,你可以做游戏修改器、调试工具、性能分析器。用在歪路上,就是外挂和作弊器。我个人建议你多研究 Hook 在安全领域的应用——比如反 Hook 检测、Hook 检测绕过,这些才是真正值钱的技术。
嗯,Hook 的内容就讲到这里。记住我强调的那几点:保存原始指令、注意字节对齐、多线程安全。把这些搞明白了,Inline Hook 对你来说就是小菜一碟。