codecaves.h 解析:Code Cave 技術實作
本篇定位
這是 Phase 4 的第 3 篇,也是整個 Hook 技術的進階篇。 Code Cave 是一種「不使用 Detours 函式庫,完全用手動組合語言實作的 Hook 技術」,適合需要精細控制的特殊場景。讀完這篇你會理解 Detours 背後實際在做什麼,以及為什麼有些 Hook 選擇手動實作。
一、什麼是 Code Cave?
「Code Cave(程式碼洞穴)」指的是記憶體裡一塊空閒的空間,可以用來放我們的自訂程式碼。
這個空間從哪裡來?
graph LR A["EXE 載入到記憶體"] --> B["程式碼段 .text<br/>(充滿遊戲機器碼)"] A --> C["資料段 .data<br/>(全域變數)"] A --> D["未初始化資料段 .bss<br/>(全零,大量空間)"] D -->|"可用來放自訂 Shellcode"| E["我們的 Code Cave"]
或者更常用的方式:用 VirtualAlloc 在任意位置動態分配一塊可執行記憶體,放入我們撰寫的組合語言指令。
二、Code Cave vs Detours:選哪個?
| 特性 | Detours | Code Cave(手動) |
|---|---|---|
| 實作難度 | 低(幾行 API) | 高(需要寫組合語言) |
| 靈活度 | 中(有些場景不支援) | 最高(完全手控) |
| 相容性 | 需要引入外部函式庫 | 零依賴,純 C++/ASM |
| 適用場景 | 大多數 Hook | 需要插入多條 inline 指令、或修改中間片段而非開頭 |
| 指令邊界分析 | Detours 自動處理 | 必須手動確保完整指令 |
MapleEzorsia-v2 同時使用兩者:
- 大多數函式 Hook 使用 Detours(
Hook_sub_XXXXXX) - 部分精細修改(如修改遊戲迴圈中的特定指令)使用手動 Code Cave
三、Code Cave 的基本結構
一個 Code Cave Hook 的完整流程,以修改遊戲某個判斷分支為例:
原始程式碼(在 0x009F5300):
0x009F5300: 83 3D XX XX XX XX 00 CMP dword ptr [globalFlag], 0
0x009F5307: 74 2A JE 0x009F5333 ← 我們想改這個跳轉條件
0x009F5309: ...(後續程式碼)
目標:讓這個 JE 永遠不跳轉(等同於把條件移除)
方法一:直接 NOP
最簡單的 Code Cave,把不想要的指令填成 NOP:
// JE 指令是 2 byte(74 2A),填成 NOP NOP
Memory::Nop(0x009F5307, 2);這已經足夠,不一定需要「Cave」。
Memory::Nop本身就是最簡單的 Code Cave 應用。
方法二:JMP + Code Cave(插入複雜邏輯)
如果我們想做的事不只是 NOP,而是要插入一段自訂邏輯:
步驟一:在原始位置寫入 JMP 到我們的 Cave
步驟二:Cave 裡執行自訂邏輯
步驟三:Cave 裡執行被覆蓋的原始指令
步驟四:JMP 回原始位置的下一條指令
四、codecaves.h 的程式碼結構
codecaves.h 定義了一系列輔助函式,讓 Code Cave 的建立更有條理:
#pragma once
#include <Windows.h>
#include <cstdint>
namespace CodeCave {
// 分配一塊可執行記憶體,用來放 Cave 程式碼
// size: 需要多少 byte
// 回傳: 分配到的地址
uintptr_t Allocate(size_t size);
// 在 from 地址寫入 JMP 跳轉到 to 地址(5 byte)
void WriteJmp(uintptr_t from, uintptr_t to);
// 在 from 地址寫入 CALL 跳轉到 to 地址(5 byte)
void WriteCall(uintptr_t from, uintptr_t to);
// 計算相對跳轉距離
// JMP/CALL 的 operand = to - (from + 5)
int32_t RelativeOffset(uintptr_t from, uintptr_t to);
} // namespace CodeCave核心實作
uintptr_t CodeCave::Allocate(size_t size) {
// MEM_COMMIT | MEM_RESERVE:直接提交可用的記憶體
// PAGE_EXECUTE_READWRITE:可讀、可寫、可執行(放機器碼用)
LPVOID mem = VirtualAlloc(
NULL, // 讓系統選擇地址
size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
return (uintptr_t)mem;
}
void CodeCave::WriteJmp(uintptr_t from, uintptr_t to) {
DWORD oldProtect;
VirtualProtect((LPVOID)from, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
*(BYTE*)from = 0xE9; // JMP 指令 opcode
*(int32_t*)(from + 1) = RelativeOffset(from, to); // 相對偏移
VirtualProtect((LPVOID)from, 5, oldProtect, &oldProtect);
}
int32_t CodeCave::RelativeOffset(uintptr_t from, uintptr_t to) {
// JMP rel32 的計算公式:operand = 目標地址 - (指令地址 + 5)
// 因為 CPU 在執行 JMP 時,EIP 已經指向「JMP 之後」的地址(即 from + 5)
return (int32_t)(to - (from + 5));
}相對跳轉距離的計算
假設
from = 0x00400000,to = 0x00500000:operand = 0x00500000 - (0x00400000 + 5) = 0x00500000 - 0x00400005 = 0x000FFFFB機器碼:
E9 FB FF 0F 00(小端序)
五、實戰範例:攔截遊戲主迴圈中的特定呼叫
假設遊戲主迴圈(CWvsApp::Run)在某個位置呼叫渲染函式,我們想在每一幀都插入自訂的繪圖程式碼:
原始程式碼(在 0x009F5C80):
0x009F5C80: E8 XX XX XX XX CALL CRenderer::Render
0x009F5C85: ...(繼續主迴圈)
我們要把這個 CALL 改成呼叫我們的 Cave:
void InstallRenderHook() {
// 1. 分配一塊足夠大的 Cave
uintptr_t cave = CodeCave::Allocate(32);
// 2. 在 Cave 裡填入指令(以 byte 陣列形式手動組裝機器碼)
BYTE caveCode[] = {
// 呼叫我們的自訂繪圖函式
0xE8, 0x00, 0x00, 0x00, 0x00, // CALL OurDrawFunction(偏移待填)
// 執行被我們覆蓋的原始 CALL(呼叫 CRenderer::Render)
0xE8, 0x00, 0x00, 0x00, 0x00, // CALL CRenderer::Render(偏移待填)
// JMP 回原始位置 +5(繼續主迴圈)
0xE9, 0x00, 0x00, 0x00, 0x00, // JMP back(偏移待填)
};
// 3. 填入各個相對偏移
*(int32_t*)(caveCode + 1) = CodeCave::RelativeOffset(cave + 0, (uintptr_t)OurDrawFunction);
*(int32_t*)(caveCode + 6) = CodeCave::RelativeOffset(cave + 5, originalRenderAddr);
*(int32_t*)(caveCode + 11) = CodeCave::RelativeOffset(cave + 10, 0x009F5C85); // 回到 +5
// 4. 把機器碼寫入 Cave
memcpy((void*)cave, caveCode, sizeof(caveCode));
// 5. 把原始 CALL 改成 JMP 到 Cave
CodeCave::WriteJmp(0x009F5C80, cave);
}六、視覺化:記憶體狀態前後對比
【安裝前】
0x009F5C80: E8 XX XX XX XX CALL CRenderer::Render
0x009F5C85: ...(後續指令)
【安裝後】
0x009F5C80: E9 YY YY YY YY JMP → Cave(0x0A000000)
0x009F5C85: ...(後續指令,不變)
Cave(0x0A000000):
0x0A000000: E8 ... CALL OurDrawFunction
0x0A000005: E8 ... CALL CRenderer::Render(原始目標)
0x0A000010: E9 ... JMP 0x009F5C85(回到原始流程)
七、MapleEzorsia-v2 中的實際應用場景
Code Cave 在本專案中主要用於:
| 場景 | 說明 |
|---|---|
| CRC32 繞過 | 在 CRC 計算途中插入修正,讓結果永遠符合預期 |
| 解析度注入 | 在遊戲初始化視窗時,攔截並注入自訂寬高 |
| 字串池 Hook | 在 StringPool::GetString 內部特定位置插入攔截,修改特定字串回傳值 |
| EXP 表替換 | 在遊戲讀取 EXP 表的函式中,把讀取地址重定向到我們的自訂表格 |
這些場景的共同特點是:修改點不在函式開頭,而是在函式中間,Detours 無法直接處理,需要用 Code Cave 精確控制。
八、常見問題
Q:
PAGE_EXECUTE_READWRITE分配的記憶體,防毒軟體會不會報警?可能。動態分配可執行記憶體是惡意軟體的常見行為,某些防毒引擎會標記。在私服登入器的情境下,這是技術上的必要手段。對於生產環境,可以考慮改用
PAGE_EXECUTE_READ:先以PAGE_READWRITE寫入,再改成PAGE_EXECUTE_READ,降低可疑程度。
Q:Cave 的大小要分配多少?
原則上分配你要放進去的機器碼大小再加上 16 byte 的緩衝。
VirtualAlloc最小分配一個頁面(4096 byte),所以分配 32 byte 和分配 4096 byte 在實際佔用上是一樣的——不需要吝嗇。
Q:如果需要在 Cave 裡存取 C++ 區域變數,可以嗎?
不行。Cave 是原始機器碼,沒有 C++ 的堆疊框架。如果需要在 Cave 和 C++ 函式之間共享資料,應使用全域變數或靜態變數,而不是區域變數。
延伸閱讀
- 01_什麼是函式掛鉤(Hooking) — Inline Hook 和 Trampoline 的基礎概念
- 02_Detours函式庫使用方式 — 和 Code Cave 互補的 Hook 工具
- 02_Memory.cpp_讀寫記憶體工具 — WriteProtected 是 Code Cave 安裝的底層工具
- 02_解析度修改原理與實作 — Phase 5:實際使用 Code Cave 修改遊戲解析度