Memory.cpp 解析:讀寫記憶體工具函式

本篇定位

這是 Phase 3 的第 2 篇。假設你已讀過 01_什麼是記憶體位址與指標,理解指標和地址的基本概念。 本篇解析 Memory.cpp / Memory.h — 它是登入器裡的「瑞士刀」,提供一組通用的記憶體讀寫函式,讓其他模組不必每次都手寫指標操作。


一、為什麼需要 Memory.cpp?

直接使用 C++ 指標操作記憶體完全可行,但有幾個問題:

// 直接寫法 — 可以,但危險且難讀
*(DWORD*)(0x009F0000 + offset) = newValue;
 
// 問題1:型別轉換每次都要重寫
// 問題2:offset 錯了會直接 crash,沒有任何提示
// 問題3:遍布全專案的指標魔法數字,難以維護

Memory.cpp 解決了這些問題:封裝成通用函式,寫一次、到處呼叫,並加入基本的安全保護。


二、Memory.h:宣告介面

#pragma once
#include <Windows.h>
 
namespace Memory {
 
    // 從指定地址讀取 T 型別的值
    template<typename T>
    T Read(DWORD address);
 
    // 向指定地址寫入 T 型別的值
    template<typename T>
    void Write(DWORD address, T value);
 
    // 修改記憶體保護屬性後寫入(用於寫入程式碼段)
    template<typename T>
    void WriteProtected(DWORD address, T value);
 
    // 在目標地址寫入 NOP 指令(填 0x90)
    void Nop(DWORD address, int size);
 
    // 讀取指標鏈的最終值
    DWORD ReadMultiPointer(DWORD baseAddress, std::vector<DWORD> offsets);
}

template<typename T> 是什麼?

C++ 的「模板(Template)」讓同一個函式可以處理不同型別。 呼叫 Memory::Read<DWORD>(addr) 讀 4 byte,Memory::Read<BYTE>(addr) 讀 1 byte,Memory::Read<float>(addr) 讀浮點數——同一個函式,型別不同。 不需要為每個型別寫不同的函式。


三、Read:讀取記憶體值

template<typename T>
T Memory::Read(DWORD address) {
    return *(T*)address;
    // (T*)address  → 把 address 這個數字當成「指向 T 型別」的指標
    // *(T*)address → 去那個地址把 T 大小的資料讀出來
}

呼叫範例

// 讀取 DWORD(4 byte)
DWORD someValue = Memory::Read<DWORD>(0x009F0000);
 
// 讀取 BYTE(1 byte)
BYTE flagByte = Memory::Read<BYTE>(0x009F0004);
 
// 讀取 float(4 byte,浮點數)
float gameSpeed = Memory::Read<float>(0x00A12345);
 
// 讀取指標的指標(兩層間接)
DWORD* objPtr = Memory::Read<DWORD*>(0x009F0010);
DWORD  hpVal  = Memory::Read<DWORD>((DWORD)objPtr + 0x0C);

注意:不做邊界檢查

如果 address 是無效地址(如 nullptr、未映射的頁面),讀取會觸發 Access Violation,導致遊戲崩潰。在呼叫前請確認地址有效。


四、Write:寫入記憶體值

template<typename T>
void Memory::Write(DWORD address, T value) {
    *(T*)address = value;
}

呼叫範例

// 寫入一個 DWORD
Memory::Write<DWORD>(0x009F0000, 9999);
 
// 寫入一個 BYTE
Memory::Write<BYTE>(0x009F0004, 0x01);   // 設定某個 flag 為 true
 
// 修改遊戲的解析度寬度(Client.cpp 裡的用法)
Memory::Write<int>(Client::ResolutionWidth_Addr, 1920);

五、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);
}

為什麼需要 VirtualProtect?

Windows 把記憶體分成不同「保護屬性」的頁面(每頁 4096 byte):

頁面類型保護屬性說明
程式碼段(.text)PAGE_EXECUTE_READ只能執行和讀取,不能寫入
資料段(.data)PAGE_READWRITE可以讀取和寫入
堆積(Heap)PAGE_READWRITE可以讀取和寫入

函式的機器碼位於程式碼段,預設不能寫入。如果我們直接 *(BYTE*)funcAddr = 0xE9(JMP 指令),Windows 會給我們一個 Access Violation。

VirtualProtect 臨時把保護改成可寫 → 寫入 → 再改回去,完成整個操作。

為什麼要還原保護屬性?

留著 PAGE_EXECUTE_READWRITE 不還原的話,某些防作弊系統(甚至 Windows 本身的 DEP,Data Execution Prevention)會把「可讀可寫又可執行」的頁面視為可疑。還原保護讓我們的修改看起來更「正常」。


六、Nop:用 NOP 填滿指令(無效化程式碼)

NOP(No Operation)是 x86 的指令 0x90,讓 CPU「空轉一個 cycle,什麼都不做」。

void Memory::Nop(DWORD address, int size) {
    DWORD oldProtect;
    VirtualProtect((LPVOID)address, size, PAGE_EXECUTE_READWRITE, &oldProtect);
 
    // 把 size 個 byte 全部填成 0x90(NOP)
    memset((void*)address, 0x90, size);
 
    VirtualProtect((LPVOID)address, size, oldProtect, &oldProtect);
}

NOP 的實際用途

假設遊戲有一段「檢查背包已滿就阻止撿取」的程式碼:

原始機器碼:
0x00401000: 85 C0        TEST eax, eax   ; 檢查是否為空
0x00401002: 74 0A        JE   0x0040100E  ; 如果是,跳到阻止邏輯

用 NOP 填滿 0x00401002 開始的 2 個 byte:

修改後:
0x00401000: 85 C0        TEST eax, eax
0x00401002: 90 90        NOP / NOP       ; JE 消失了,程式直線執行不再跳躍

這樣就讓那個條件跳轉永遠不會發生。

在 MapleEzorsia-v2 中,NOP 常用來關閉遊戲的 CRC 完整性檢查,讓被修改過的客戶端能正常運行。


七、ReadMultiPointer:跟隨指標鏈

動態物件的地址每次都不同,必須從已知靜態地址出發,逐層追蹤:

DWORD Memory::ReadMultiPointer(DWORD baseAddress, std::vector<DWORD> offsets) {
    DWORD addr = Memory::Read<DWORD>(baseAddress);  // 讀第一層指標的值
 
    for (size_t i = 0; i < offsets.size() - 1; i++) {
        addr = Memory::Read<DWORD>(addr + offsets[i]);  // 加上偏移,讀下一層
    }
 
    return addr + offsets.back();  // 最後一個 offset 加上去,得到目標地址
}

實際呼叫範例

假設角色 HP 的位置需要三層指標:

靜態基址:0x00C4EB48
→ 讀出:0x02A5F000(第一層物件指標)
→ + 0x44 → 0x02A5F044
→ 讀出:0x03BC0200(第二層物件指標)
→ + 0x0C → 得到 HP 值的地址
DWORD hpAddress = Memory::ReadMultiPointer(
    0x00C4EB48,              // 靜態基址
    { 0x44, 0x0C }           // 偏移清單(最後一個是終點偏移)
);
DWORD hp = Memory::Read<DWORD>(hpAddress);  // 讀 HP 值

八、整體架構圖

graph TD
    A["其他模組<br/>(Client.cpp / dllmain.cpp)"] -->|呼叫| B[Memory::Read&lt;T&gt;]
    A -->|呼叫| C[Memory::Write&lt;T&gt;]
    A -->|呼叫| D[Memory::WriteProtected&lt;T&gt;]
    A -->|呼叫| E[Memory::Nop]
    A -->|呼叫| F[Memory::ReadMultiPointer]

    D -->|臨時解除保護| G[VirtualProtect]
    D -->|寫入| H[目標記憶體地址]
    G -->|還原保護| H

    E -->|填 0x90| H

    B -->|直接讀取| H
    C -->|直接寫入| H

九、常見問題

Q:為什麼用 DWORD 而不是 int 表示地址?

DWORD 是 Windows 定義的「32 位元無符號整數(unsigned 32-bit integer)」,地址不可能是負數,用無符號更安全。int 是有符號整數,理論上可以表示負數,用來放地址容易在邊界情況出問題。

Q:模板函式定義要放在 .h 裡嗎?

是的。C++ 模板函式必須在標頭檔中定義(或 include 對應的 .inl/.tpp 檔),因為編譯器需要在看到呼叫點時就知道完整實作才能展開。如果只在 .cpp 裡定義,其他 .cpp include 時只看到宣告,連結階段會報「未定義符號」錯誤。

Q:WriteProtected 是執行緒安全的嗎?

不是。如果兩個執行緒同時呼叫 WriteProtected 對同一段記憶體操作,可能造成保護屬性被覆蓋或資料競爭。在登入器啟動時(單一執行緒初始化階段)使用最安全,避免在多執行緒環境中並發呼叫。


延伸閱讀