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:選哪個?

特性DetoursCode 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 = 0x00400000to = 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 計算途中插入修正,讓結果永遠符合預期
解析度注入在遊戲初始化視窗時,攔截並注入自訂寬高
字串池 HookStringPool::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++ 函式之間共享資料,應使用全域變數或靜態變數,而不是區域變數。


延伸閱讀