什麼是函式掛鉤(Function Hooking)?

本篇定位

這是 Phase 4「Hook 技術與 Detours 函式庫」的第一篇,也是整個登入器技術核心的入口。 讀完這篇,你會理解:登入器修改遊戲行為的根本手段——不是修改 EXE 檔,而是在執行時期劫持函式的執行流程。


一、問題:我們想改遊戲行為,但沒有原始碼

MapleStory 是閉源軟體。我們拿到的只是編譯好的 MapleStory.exe,裡面全是 CPU 直接執行的機器碼,沒有 C++ 原始碼。

但我們想做的事包括:

  • 讓遊戲連到私服 IP 而不是官方伺服器
  • 修改遊戲解析度
  • 繞過 CRC 完整性檢查
  • 讓遊戲可以多開

直接修改 EXE 是一個選項,但有幾個問題:

  1. 遊戲每次更新就要重新 patch
  2. 修改後的 EXE 檔在某些情況下無法通過自身的完整性驗證
  3. 分發修改過的 EXE 涉及法律問題

Function Hooking 的解法:不修改 EXE,而是在執行時把函式的入口重新導向我們的程式碼。


二、核心概念:攔截 CPU 的執行路徑

正常執行時,遊戲呼叫自己的函式:

遊戲程式碼
    │
    ▼
呼叫 CClientSocket::Connect(...)
    │
    ▼
函式執行:連到官方 IP 106.52.xxx.xxx

安裝 Hook 之後:

遊戲程式碼
    │
    ▼
呼叫 CClientSocket::Connect(...)
    │
    ▼
【Hook 攔截】→ 跳轉到我們的函式
    │
    ├─ 修改參數:把 IP 換成私服 127.0.0.1
    │
    ▼
呼叫原始函式(帶著修改後的參數)
    │
    ▼
函式執行:連到私服

遊戲完全不知道中間發生了什麼,它只是「呼叫 Connect」,但連線目標已經被我們換掉了。


三、Hook 的實作原理:改寫函式開頭的機器碼

在 x86 架構中,JMP rel32(無條件跳轉指令)是 5 個 byte:

E9 XX XX XX XX
│  └──────────── 跳轉距離(相對位移,4 byte)
└─────────────── JMP 指令的 opcode

安裝 Hook 的過程:

【安裝 Hook 前】
函式起始地址 0x00494CA3:
  55           PUSH EBP          ← 原始函式的開頭
  8B EC        MOV  EBP, ESP
  83 EC 08     SUB  ESP, 8
  ...

【安裝 Hook 後】
函式起始地址 0x00494CA3:
  E9 XX XX XX XX   JMP 到我們的 Hook 函式  ← 改寫了
  XX               (原始指令的殘餘,可能已被截斷)
  ...

當遊戲執行到 0x00494CA3,CPU 看到的第一個指令是 JMP,立刻跳到我們的函式。


四、Hook 的三種主要型態

4.1 Inline Hook(改寫函式頭部)

最常見的方式,就是上面描述的:直接把函式開頭改成 JMP

優點:通用,不依賴任何特殊機制
缺點:需要保存被覆蓋的原始指令,並在 Trampoline 中執行後再跳回

4.2 IAT Hook(Import Address Table Hook)

利用 PE(Windows 可執行檔)的匯入表:程式呼叫外部函式(如 Windows API)時,實際上是透過一張「地址表」間接跳轉的。

程式碼: CALL [IAT + offset_CreateMutexA]
                  │
                  ▼
         IAT 裡存的地址 → kernel32.dll 的 CreateMutexA

我們把 IAT 裡那個地址換成我們自己的函式:

程式碼: CALL [IAT + offset_CreateMutexA]
                  │
                  ▼
         IAT 裡存的地址(已被我們改掉)→ 我們的 HookCreateMutexA

MapleEzorsia-v2 對 CreateMutexAWSPStartup 等 Windows API 就是使用 IAT Hook,因為這些函式的呼叫點是透過 IAT 的,不需要改寫函式本體。

4.3 VTable Hook(虛函式表 Hook)

C++ 多型物件有一個 vftable(虛函式表),記錄每個虛函式的地址。把 vftable 裡某個位置換掉,就能攔截對應虛函式的呼叫。


五、Trampoline:保留原始行為的關鍵

如果我們把函式開頭 5 個 byte 全部覆蓋成 JMP,那被覆蓋的原始指令就消失了。

如果我們的 Hook 函式想在「做完自己的事之後,讓遊戲繼續正常執行」,就需要一個 Trampoline(跳板)

【Trampoline 的結構】

Trampoline Buffer(動態分配的一小塊記憶體):
  55           PUSH EBP          ← 被覆蓋的原始指令(複製過來)
  8B EC        MOV  EBP, ESP     ← 同上
  E9 XX XX XX XX  JMP 回原始函式 +5  ← 跳回原始函式的第 6 個 byte 繼續執行

整個執行流程:

遊戲呼叫原始函式
       │
       ▼
執行到 JMP(我們裝的)
       │
       ▼
跳入我們的 Hook 函式
       │
       ├─ 執行我們的邏輯(修改參數、記錄日誌等)
       │
       ▼
呼叫 Trampoline(讓原始函式繼續執行)
       │
       ▼
Trampoline 執行被覆蓋的原始指令
       │
       ▼
JMP 回原始函式 +5(跳過已被覆蓋的開頭,繼續執行函式本體)
       │
       ▼
原始函式正常完成

Trampoline 就是一張「備份紙條」

在改寫函式開頭前,先把那幾個 byte 抄下來,放在安全的地方,配上一個「跳回去」的指令。之後我們的 Hook 函式可以呼叫這份備份,讓原始函式該做的事還是能繼續做。


六、Detours:讓這一切不那麼痛苦

手動實作 Inline Hook + Trampoline 非常繁瑣:

  • 需要計算相對跳轉距離
  • 需要分析函式開頭的指令長度(必須完整複製,不能截斷一條指令)
  • 需要動態分配 Trampoline buffer
  • 需要處理多執行緒競爭

Microsoft Detours 是一個函式庫,幫你處理上面所有細節。下一篇會詳細說明用法。


七、MapleEzorsia-v2 的 Hook 架構總覽

graph TD
    A[DllMain — DLL_PROCESS_ATTACH] -->|立刻安裝| B[Windows API Hook]
    B --> B1[Hook_CreateMutexA — IAT]
    B --> B2[Hook_WSPStartup — IAT]
    B --> B3[Hook_CreateWindowExA — IAT]
    B --> B4[Hook_FindFirstFileA — IAT]

    A -->|開新執行緒| C[MainMain::CreateInstance]
    C -->|等 Themida 解壓| D[MainFunc — 安裝遊戲內部 Hook]
    D -->|Inline Hook| E[遊戲函式 Hook]
    E --> E1[Hook_sub_494CA3 — CClientSocket::Connect]
    E --> E2[Hook_sub_9F4E54 — CRC32 檢查]
    E --> E3[Hook_sub_9F5239 — CWvsApp::SetUp]
    E --> E4[Hook_sub_9F5C50 — CWvsApp::Run]
    E --> E5[... 其他 Hook ...]

八、常見問題

Q:Hook 之後遊戲的 CRC 不是會偵測到我們的修改嗎?

很好的問題。這就是為什麼其中一個 Hook 是針對 CRC32 檢查函式本身。我們先把 CRC 檢查的函式 Hook 掉,讓它永遠回傳「沒問題」,這樣後續的所有 Hook 修改就不會被偵測到。Hook 有安裝順序上的考量。

Q:Hook 和修改 EXE 有什麼本質差別?

修改 EXE 是改磁碟上的檔案(靜態修改),每次啟動都帶著修改。Hook 是在執行時期改記憶體(動態修改),遊戲關閉後記憶體釋放,原始 EXE 完好無損。下次啟動遊戲時必須重新安裝 Hook(由 DLL 自動完成)。

Q:如果 Hook 函式本身有 bug 會怎樣?

因為 Hook 函式在遊戲進程裡執行,任何崩潰(未處理的例外、堆疊溢位)都會直接讓遊戲崩潰。Hook 函式必須非常穩定,盡量避免複雜邏輯,並做好錯誤處理。


延伸閱讀