ReplacementFuncs.h 解析:函式替換技術

本篇定位

這是 Phase 6「進階功能實作」的第一篇。 ReplacementFuncs.h 定義了一批「替換函式(Replacement Functions)」—— 不只是攔截遊戲函式,而是完全取代其行為,重新實作整個邏輯。


一、Hook 攔截 vs 函式替換

到目前為止,我們學到的 Hook 模式是「攔截 + 選擇是否呼叫原始函式」:

// 攔截模式:修改參數,再呼叫原始函式
void Hooked_Connect(void* thisPtr, const char* host, uint16_t port) {
    original_Connect(thisPtr, "127.0.0.1", 8484);  // 改 IP,但還是呼叫原始邏輯
}

函式替換模式不同:完全不呼叫原始函式,我們自己重新實作整個功能:

// 替換模式:原始函式的邏輯完全被我們的版本取代
void* Replacement_GetProcAddress(HMODULE hModule, LPCSTR lpProcName) {
    // 整個函式邏輯都是我們寫的
    // 遊戲的原始 GetProcAddress 永遠不會被呼叫
    return OurCustomGetProcAddress(hModule, lpProcName);
}

什麼時候用替換而不用攔截?

  • 原始函式的行為完全不符合需求(不只是改參數,而是整個邏輯要換)
  • 原始函式有反除錯 / 反注入邏輯,呼叫它會讓遊戲偵測到我們
  • 我們對這個功能有更好的實作方式(例如自訂記憶體分配器)

二、MyGetProcAddress:遊戲的自訂查詢函式

MapleStory v83 有一個內部函式 sub_44E88E,作用類似 Windows 的 GetProcAddress,但加入了 Themida 的混淆邏輯。遊戲用它來動態查詢自己 DLL 匯出的函式地址,同時可以偵測「這個函式有沒有被 Hook 過」。

我們把它完全替換:

// 原始函式簽名(從 IDA Pro 逆向得到)
using tMyGetProcAddress = void*(__cdecl*)(HMODULE hModule, LPCSTR lpProcName);
 
// Trampoline(替換後不呼叫,但仍需宣告供 Detours 使用)
tMyGetProcAddress original_MyGetProcAddress =
    (tMyGetProcAddress)addys::MyGetProcAddress;
 
// 替換函式
void* __cdecl Replacement_MyGetProcAddress(HMODULE hModule, LPCSTR lpProcName) {
    // 直接呼叫真正的 Windows GetProcAddress,跳過 Themida 混淆層
    void* result = (void*)::GetProcAddress(hModule, lpProcName);
 
    // 如果 Windows 找不到(result == nullptr),有幾種情況:
    // 1. 函式不存在(正常情況,回傳 nullptr 即可)
    // 2. 函式名稱被 Themida 加密(需要解密後再查詢)
    // MapleEzorsia-v2 選擇直接回傳 Windows 的結果,跳過加密層
 
    return result;
}

為什麼繞過 Themida 的 GetProcAddress?

Themida 的自訂 GetProcAddress 可能包含「檢查回傳的函式指標是否被 Hook 過」的邏輯(比對函式開頭的 byte)。如果我們的 Hook 改了某個函式開頭,原版 GetProcAddress 可能就回傳 nullptr 或觸發崩潰。用我們的版本繞過這個檢查。


三、Replacement_CWvsApp_CWvsApp:替換遊戲建構子

CWvsApp 是 MapleStory 的主應用程式類別,它的建構子(sub_9F4FDA)做了大量初始化工作,其中有幾個我們不想要的行為:

using tCWvsApp_Ctor = void(__thiscall*)(void* thisPtr);
tCWvsApp_Ctor original_CWvsApp_Ctor = (tCWvsApp_Ctor)addys::CWvsApp_Ctor;
 
void __fastcall Replacement_CWvsApp_Ctor(void* thisPtr, void* edx) {
    // 先呼叫原始建構子(這次是攔截 + 替換的混合模式)
    original_CWvsApp_Ctor(thisPtr);
 
    // 原始建構子跑完後,修正它做了但我們不想要的事:
 
    // 1. 把「全螢幕模式」設定改回視窗模式
    //    (建構子內部有一段從 registry 讀全螢幕設定的邏輯)
    *(BOOL*)((BYTE*)thisPtr + 0x20) = TRUE;  // Windowed = TRUE
 
    // 2. 停用多螢幕偵測
    //    (建構子會查詢 Monitor 數量,多螢幕時改變解析度策略)
    *(int*)((BYTE*)thisPtr + 0x3C) = 1;      // MonitorCount = 1(假裝只有一個螢幕)
}

四、Replacement_StringPool_GetString:字串重導向

StringPool::GetString 是遊戲取得所有 UI 字串的入口。替換它讓我們可以在執行期修改任何遊戲顯示的文字:

using tStringPool_GetString = const wchar_t*(__thiscall*)(void* thisPtr, int stringId);
tStringPool_GetString original_StringPool_GetString =
    (tStringPool_GetString)addys::StringPool_GetString;
 
// 我們維護的自訂字串表
std::unordered_map<int, std::wstring> g_customStrings;
 
const wchar_t* __fastcall Replacement_StringPool_GetString(
    void* thisPtr, void* edx, int stringId)
{
    // 先查我們的自訂表
    auto it = g_customStrings.find(stringId);
    if (it != g_customStrings.end()) {
        return it->second.c_str();   // 回傳自訂字串
    }
 
    // 找不到就用原始的
    return original_StringPool_GetString(thisPtr, stringId);
}

使用範例:

// 在 MainFunc 裡修改登入畫面的伺服器選單文字
g_customStrings[12001] = L"Scania (私服)";
g_customStrings[12002] = L"Bera (私服)";

五、ReplacementFuncs.h 的整體結構

#pragma once
 
// ── 函式型別定義 ──────────────────────────────────────────────────────────────
using tMyGetProcAddress      = void*(__cdecl*)(HMODULE, LPCSTR);
using tCWvsApp_Ctor          = void(__thiscall*)(void*);
using tStringPool_GetString  = const wchar_t*(__thiscall*)(void*, int);
// ...
 
// ── Trampoline 指標(替換函式也需要宣告,供 Detours DetourAttach 使用)──────
extern tMyGetProcAddress     original_MyGetProcAddress;
extern tCWvsApp_Ctor         original_CWvsApp_Ctor;
extern tStringPool_GetString original_StringPool_GetString;
// ...
 
// ── 替換函式宣告 ──────────────────────────────────────────────────────────────
void* __cdecl Replacement_MyGetProcAddress(HMODULE, LPCSTR);
void __fastcall Replacement_CWvsApp_Ctor(void*, void*);
const wchar_t* __fastcall Replacement_StringPool_GetString(void*, void*, int);
// ...
 
// ── 安裝 / 卸載函式 ───────────────────────────────────────────────────────────
void Hook_MyGetProcAddress(bool install);
void Hook_CWvsApp_Ctor(bool install);
void Hook_StringPool_GetString(bool install);

六、常見問題

Q:替換函式如果根本不呼叫原始函式,Trampoline 指標還需要嗎?

需要。DetourAttach 的第一個參數就是 Trampoline 指標,Detours 用它來找到目標函式的位置(它從這個指標讀取地址,然後在那裡安裝 JMP)。即使你的替換函式不打算呼叫 original_xxx,也必須正確宣告並傳入指標。

Q:完全替換建構子安全嗎?

有風險。建構子的邏輯通常很複雜,如果完全替換,很容易遺漏某些初始化步驟導致後續崩潰。MapleEzorsia-v2 採用「先呼叫原始建構子,再修正」的混合模式,比完全替換安全得多。


延伸閱讀