Inline Hook 确实简单粗暴:找到地址,改写前 5 个字节为 jmp,完事。

但在现代反作弊(BE, EAC)越来越严苛的环境下,无脑改写 .text(代码段)无异于在安检门前大喊自己带了违禁品。内存完整性校验(Memory Integrity Check)分分钟教你做人。

这时候,我们需要一种更优雅、更底层的手段——VTable Hijacking(虚函数表劫持)。它不改一行机器码,只在堆内存里做数据指针的偷天换日。


理论篇:什么是 VTable Hijacking?

要理解虚表劫持,必须先彻底搞懂 C++ 编译器是如何实现多态的。

虚函数表(VTable)的本质

当一个 C++ 类中存在 virtual 关键字修饰的函数时,编译器为了在运行时决定到底该调用哪个具体实现(父类还是子类的),会在这个类的内存布局的最开头,强行塞入一个隐藏的指针(vptr)

这个 vptr 指向哪里?指向一个属于该类的函数指针数组,这个数组就是大名鼎鼎的 VTable(虚函数表)。

假设有一个简单的类:

1
2
3
4
5
6
7
8
class CBuilding {
public:
virtual ~CBuilding() {} // Index 0
virtual void Update() {} // Index 1
virtual void ApplyDamage(int dmg) {} // Index 2

int16_t durability; // 偏移 +0x08 (64位系统下)
};

在 64 位游戏的内存里,一个 CBuilding 实例的样子是这样的:

  • [0x00 - 0x07]vptr (指向 .rdata 段里的虚表)
  • [0x08 - 0x09]durability (耐久度)

当我们调用 pBuilding->ApplyDamage(50); 时,CPU 底层执行的汇编其实是:

1
2
mov rax, [rcx]          ; rcx 是 this 指针,拿到 vptr (虚表地址)
call qword ptr [rax+10h]; 调用虚表里偏移 0x10 (也就是第 3 个,Index 2) 的函数

劫持的艺术:偷天换日

Inline Hook 是去 ApplyDamage 的代码实体里改指令。
而 VTable Hijacking 的思路是:我不动原函数,我给你换一本“花名册”。

  1. 游戏原版的 VTable 存在 .rdata(只读数据段),直接改会触发保护或崩溃。
  2. 我们在堆内存(Heap)里,自己 mallocnew 一个同样大小的指针数组。
  3. 把原版 VTable 里的所有函数指针,原封不动地 memcpy 拷贝到我们的新数组里。
  4. 【核心】:把新数组里 ApplyDamage 对应的那个位置,替换成我们自己写的外挂函数地址。
  5. 最后,把内存里那个建筑对象的 vptr,强行指向我们的新数组。

就这么简单。业务代码依然执行 call qword ptr [rax+10h],但此时 rax 已经是我们的假表了,程序神不知鬼不觉地飞进了我们的外挂逻辑里。完全规避了 .text 段的扫描。


实战篇

纸上谈兵没用,直接上强度。前两天在拆 San14 的内存时,通过底层的流序列化函数(Serialization Archive),我们精准还原了地图建筑/陷阱对象(CBuilding)的底层结构。

第一步:情报收集与结构体重建

通过 IDA 静态分析和 CE 动态比对,确定 CBuilding 大小为 32 字节(0x20),核心偏移如下:

1
2
3
4
5
6
7
8
9
10
#pragma pack(push, 1)
struct CBuilding {
void** vptr; // +0x00 虚表指针
char pad_08[0x0C]; // +0x08
int16_t durability; // +0x14 建筑当前耐久度
int16_t maxDurability; // +0x16
uint8_t status; // +0x18
char pad_19[0x07]; // +0x19
};
#pragma pack(pop)

第二步:定位目标虚函数 (VTable Index)

我们要劫持扣除耐久的函数。如何在几百个虚函数里找到它?

  1. 打开 Cheat Engine,找到你自己建的一个投石台,锁定它的耐久度地址(比如 0x1418C9014,注意这正好是对象首地址 +0x14)。
  2. 右键该地址 -> **找出是什么改写了这个地址 (Find out what writes to this address)**。
  3. 在游戏里让敌人打一下这个投石台。
  4. CE 会捕获到一条类似 sub eax, edx 然后 mov [rcx+14h], ax 的指令。
  5. 顺着 CE 给出的堆栈往上一层找,看是谁 call 了这个逻辑。你会看到极其经典的虚表调用特征:
    call qword ptr [rax+28h]

0x28 除以 8(64位指针大小)等于 5。
破案了!扣除耐久的函数,在 CBuilding 虚表里的 Index 是 5。 函数原型推测为 void ApplyDamage(CBuilding* this, int damage)

第三步:C++ 劫持代码实现

我们在自己的 DLL 里写下这套 VTable Hijacking 的标准模板:

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
#include <iostream>
#include <cstring>

// 1. 定义原函数指针类型
typedef void(__fastcall* tApplyDamage)(CBuilding* pThis, int damage);
tApplyDamage oApplyDamage = nullptr;

// 我们的假虚表(全局保留,防止被回收)
void** g_FakeVTable = nullptr;
bool g_bIsVTableCreated = false;

// 2. 编写我们的外挂逻辑 (Detour)
void __fastcall DetourApplyDamage(CBuilding* pThis, int damage)
{
// 这里需要一个判断敌我的逻辑,防止把敌人的建筑也变无敌了。
// 具体可以通过比对对象内的派系 ID,或者从外部数组校验。
// 为演示原理,假设我们有一个 IsPlayerFaction 宏/函数
if (IsPlayerFaction(pThis))
{
// 建筑锁血核心:将受到的伤害强行归零!
damage = 0;
}

// 带着被篡改的参数,去执行原版正常的扣血逻辑
// 如果是敌人,damage 原样传入,正常掉血
// 如果是我方,传入 0,耐久纹丝不动
oApplyDamage(pThis, damage);
}

// 3. 执行虚表替换 (需要传入一个真实存在的建筑对象指针)
void SetupBuildingGodMode(CBuilding* pTargetBuilding)
{
if (pTargetBuilding == nullptr) return;

// 拿到光荣原版的虚表地址
void** pOriginalVTable = pTargetBuilding->vptr;

// 如果是第一次运行,我们需要初始化假表
if (!g_bIsVTableCreated)
{
// 假设通过 IDA 查看 .rdata,发现这个虚表大概有 20 个函数
const int VTABLE_SIZE = 20;

// 在堆上申请我们自己的数组
g_FakeVTable = new void*[VTABLE_SIZE];

// 将原版虚表完整拷贝过来
memcpy(g_FakeVTable, pOriginalVTable, sizeof(void*) * VTABLE_SIZE);

// 保存原版 Index 5 的函数地址,以备后续调用
oApplyDamage = (tApplyDamage)pOriginalVTable[5];

// 偷天换日:把假表里的 Index 5 替换成我们的 Detour 函数
g_FakeVTable[5] = &DetourApplyDamage;

g_bIsVTableCreated = true;
std::cout << "[+] Fake VTable Created." << std::endl;
}

// 将当前建筑对象的虚表指针,无情地指向我们的假表
pTargetBuilding->vptr = g_FakeVTable;
std::cout << "[+] Building " << std::hex << pTargetBuilding << " is now invincible." << std::endl;
}

第四步:注入与批量应用

上述代码只修改了 pTargetBuilding一个建筑实例的虚表指针。
在实际写代码时,我们通常会挂一个底层的遍历 Hook(比如拦截引擎生成对象的工厂函数 CreateBuilding),在每个属于玩家的建筑被 new 出来的时候,顺手调用一下 SetupBuildingGodMode(pNewBuilding)

只要对象的 vptr 被换成了我们的指针,从今往后,无论是 AI 投石、火攻还是战法,只要试图削减它的耐久,CPU 都会乖乖跳进我们的 DetourApplyDamage 里,把伤害化为乌有。

结语

VTable Hijacking 是每一个 C++ 逆向工程师必修的内功。它利用了 C++ 面向对象底层的实现机制,实现了真正意义上的“逻辑层劫持”。

当然,这种技术也不是完美无缺的。如果引擎在核心循环里对对象的 vptr 做了硬编码校验(检测 vptr 是否落在 .rdata 段内),这种方法就会暴露。但在大多数单机游戏和中低强度的网游中,这一手“偷梁换柱”,足以让你所向披靡。