前兩篇認識了 DLL 劫持原理和專案結構。這篇進入整個登入器技術的核心:怎麼讀寫遊戲的記憶體,以及怎麼用 Hook 技術改變遊戲的執行行為——這是「不修改 exe 檔案,只在執行期間介入」這套思路能夠成立的根本原因。
記憶體位址與指標:地基概念
程式執行時,可以把記憶體想像成一條超長的信箱走廊:每個信箱存 1 個位元組,每個信箱都有唯一的「門牌號碼」(記憶體位址),32位元程式的地址範圍是 0x00000000 到 0xFFFFFFFF。
指標是存放地址的變數:一般變數存值,指標存「值住在哪裡」:
int hp = 9999; // hp 這個格子裡裝的是數字 9999
int* pHp = &hp; // pHp 這個格子裡裝的是「hp 住在哪裡」(hp的地址)
靜態地址 vs 動態地址
靜態地址是程式碼段裡的函式地址,每次啟動同一個exe通常在固定位置——因為v83沒有啟用ASLR(位址空間配置隨機化),地址可以直接寫死使用。這正是 AddyLocations.h 存放的地址類型。
動態地址是堆積上的物件(例如角色HP),每次進遊戲可能在不同位置,必須從已知的靜態地址出發,按指標鏈一步步走過去:
靜態基址 → 第一層指標 → 第二層指標 → ... → 目標值
因為登入器以DLL注入形式存在於遊戲進程「內部」,可以直接用C++指標讀寫記憶體,不需要跨進程的 ReadProcessMemory/WriteProcessMemory:
DWORD value = *(DWORD*)0x009F0000; // 直接讀
*(DWORD*)0x009F0000 = 12345; // 直接寫
Memory.cpp:封裝好的讀寫工具
直接手寫指標操作可行,但型別轉換要重複寫、offset寫錯會直接崩潰又沒有提示。Memory.cpp 把常用操作封裝成模板函式:
namespace Memory {
template<typename T> T Read(DWORD address) { return *(T*)address; }
template<typename T> void Write(DWORD address, T value) { *(T*)address = value; }
void Nop(DWORD address, int size);
DWORD ReadMultiPointer(DWORD baseAddress, std::vector<DWORD> offsets);
}
template<typename T> 讓同一個函式能處理不同型別:Memory::Read<DWORD>(addr) 讀4 byte,Memory::Read<BYTE>(addr) 讀1 byte,不用為每個型別各寫一個函式。
WriteProtected 是安裝Hook最常用到的函式,因為程式碼段預設是「唯讀可執行」,不能直接寫入:
template<typename T>
void Memory::WriteProtected(DWORD address, T value) {
DWORD oldProtect;
VirtualProtect((LPVOID)address, sizeof(T), PAGE_EXECUTE_READWRITE, &oldProtect); // 臨時改成可寫
*(T*)address = value; // 寫入
VirtualProtect((LPVOID)address, sizeof(T), oldProtect, &oldProtect); // 還原保護
}
還原保護屬性是為了不讓修改過的頁面一直保持「可讀可寫又可執行」的可疑狀態(容易被防作弊或Windows DEP盯上)。
Nop 則是把指定位置填成 0x90(CPU的「空轉」指令),常用來讓某個條件跳轉永遠不會發生,例如關閉遊戲的CRC完整性檢查:
原始:74 0A JE 0x0040100E (如果為空,跳到阻止邏輯)
修改:90 90 NOP / NOP (跳轉消失,直線執行)
AddyLocations.h:地址表
所有地址集中在一個檔案,給每個地址有意義的名稱,而不是散落各處的魔法數字:
namespace addys {
constexpr uintptr_t CWvsApp_SetUp = 0x009F5239; // CWvsApp::SetUp
constexpr uintptr_t CClientSocket_Connect1 = 0x00494CA3; // 連線函式
constexpr uintptr_t GameWidth_Addr = 0x00C4FCA4; // 遊戲視窗寬度
}
這些地址不是憑空想出來的,是逆向工程師用 IDA Pro / Ghidra 反組譯 MapleStory.exe、分析函式行為後手動命名記錄下來的。
地址和exe版本強綁定:即使只是重新編譯、未改動任何功能,同一份原始碼編出的exe地址也可能完全不同。私服如果更新了客戶端,登入器的地址表就要重新逆向工程。這也是為什麼登入器專案通常會註明「只支援哪個確切版本/MD5的exe」。
AutoTypes.h:讓地址裡的資料有意義
只有地址還不夠——知道「HP存在偏移0x00」不代表看得懂資料結構。AutoTypes.h 把遊戲的記憶體結構映射成C++ struct:
#pragma pack(push, 1) // 不要插入欄位對齊的填充位元組
struct CharacterStats {
i32 hp; // +0x00
i32 maxHp; // +0x04
i32 mp; // +0x08
i32 level; // +0x20
i64 exp; // +0x24(8 byte,因為高等級EXP很大)
};
#pragma pack(pop)
#pragma pack(1) 是關鍵:C++編譯器預設會在欄位之間插入對齊用的填充位元組,但遊戲的記憶體排列是遊戲自己的編譯器決定的,如果我們的struct多了填充,所有欄位偏移就會全部錯位。可以用 static_assert(sizeof(CharacterStats) == 0x38, ...) 提早在編譯期抓到大小不符的問題。
型別定義也包含函式指標,呼叫遊戲原生函式時要注意呼叫慣例(Calling Convention):
using FnCWvsApp_SetUp = void(__thiscall*)(void* thisPtr); // this指標透過ECX傳入
__thiscall 是C++成員函式的預設呼叫慣例,Hook時如果用錯呼叫慣例,堆疊會失衡導致程式崩潰。
Function Hooking:不改 exe,攔截執行流程
MapleStory是閉源軟體,只有編譯好的機器碼,沒有原始碼。直接修改exe有版本更新要重新patch、可能過不了完整性驗證等問題。Function Hooking 的做法是不改磁碟上的檔案,而是在執行時把函式入口重新導向我們的程式碼。
x86的 JMP rel32(無條件跳轉)是5個byte:E9 XX XX XX XX。安裝Hook就是把目標函式開頭改寫成這5個byte,CPU執行到這裡就立刻跳到我們的函式。
Hook有三種主要型態:Inline Hook(改寫函式開頭,最常見)、IAT Hook(改寫Windows API匯入表裡的地址,MapleEzorsia-v2對CreateMutexA等系統API用這招)、VTable Hook(改寫C++虛函式表)。
Trampoline:讓原始功能繼續運作
如果直接覆蓋函式開頭5個byte,被覆蓋的原始指令就消失了。Trampoline(跳板) 的做法是:先把被覆蓋的指令複製到別處備份,加上一條跳回原函式(+5之後)的指令,讓Hook函式做完自己的事之後,還能透過Trampoline呼叫原始邏輯繼續執行。
Detours:讓 Hook 安裝變成幾行程式碼
手動實作Inline Hook + Trampoline很繁瑣(計算相對跳轉距離、分析指令邊界避免截斷、動態分配Trampoline記憶體、處理多執行緒競爭)。Microsoft Detours 函式庫把這些都包了:
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach(&(PVOID&)original_CClientSocket_Connect, Hook_CClientSocket_Connect);
DetourTransactionCommit();
用「事務(Transaction)」機制是為了讓多個Hook一起生效,而不是一個一個生效,避免遊戲在Hook裝到一半的中間狀態下運作而崩潰。實際的Hook函式:
void __fastcall Hook_CClientSocket_Connect(void* thisPtr, void* edxDummy,
const char* host, unsigned short port) {
const char* privateIp = MainMain::GetInstance()->GetServerIp();
unsigned short privatePort = MainMain::GetInstance()->GetServerPort();
original_CClientSocket_Connect(thisPtr, privateIp, privatePort); // 呼叫Trampoline,帶著修改後的參數
}
原始函式是 __thiscall,Hook函式改用 __fastcall 並插入一個 edxDummy 參數佔位,是讓函式正確收到原本放在ECX的this指標、同時相容Detours機制的常見技巧。
Code Cave:不依賴函式庫的手動 Hook
Detours處理的是「函式開頭」的Hook。如果要修改的是函式中間的某段程式碼(例如遊戲主迴圈裡某個判斷分支),Detours無法直接處理,就需要手動的 Code Cave:用 VirtualAlloc 分配一塊可執行記憶體,寫入自訂機器碼,再把目標位置改成跳到這塊記憶體。
uintptr_t CodeCave::Allocate(size_t size) {
return (uintptr_t)VirtualAlloc(NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
}
void CodeCave::WriteJmp(uintptr_t from, uintptr_t to) {
DWORD oldProtect;
VirtualProtect((LPVOID)from, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
*(BYTE*)from = 0xE9;
*(int32_t*)(from + 1) = (int32_t)(to - (from + 5)); // JMP rel32的相對偏移公式
VirtualProtect((LPVOID)from, 5, oldProtect, &oldProtect);
}
MapleEzorsia-v2 用 Code Cave 處理的場景包括:CRC32繞過、解析度注入、字串池Hook、EXP表替換——這些修改點都不在函式開頭,需要精確控制插入位置。
下一篇進入 Client.cpp 的深度解析:實際看這些記憶體操作和Hook技術如何組合起來,完成解析度修改、UI元素位置修正、登入與選角畫面修正。