visitor@backend-development.net:~$ ls ./posts

# cat ./posts/warmane-anti-cheat.md

[Warmane] Anti Cheat

Disclaimer

This document is provided for educational and research purposes only. The author is not responsible for any misuse of the information contained herein. Any actions taken by individuals using this information are solely their own responsibility. Use of this information for malicious purposes, unauthorized access, or any illegal activities is strictly prohibited and may violate local, state, federal, or international laws.

Executive Summary

Warmane is a World of Warcraft: Wrath of the Lich King private server founded by Kaer in November 2009. Originally launched under the name Molten-WoW, it was later rebranded as Warmane and has since grown into one of the largest and most established private server communities dedicated to the 3.3.5a era of the game.

Notably, Warmane's anti-cheat enforcement is built on top of a Remote Code Execution (RCE) exploit against its own client, allowing the server to run arbitrary code inside the player's game process for detection and inspection purposes.

Anti-Cheat Initialization

When a player logs in-game, Warden activates the injected RCE payload, which in turn loads Warmane's anti-cheat runtime and dispatches its initialization routines. During this handshake, four core functions are executed to establish the anti-cheat's presence within the client process. Dispatcher

Dispatcher The Dispatcher is the central entry point for all incoming anti-cheat checks. Each check arrives as an obfuscated packet, is decrypted in place, executed within the client's address space, and then wiped from memory to eliminate forensic traces. An acknowledgement packet is subsequently re-encrypted and returned to the server, completing the round trip.

int __usercall Dispatcher@<eax>(int a1@<eax>, int a2, int a3, int a4, int a5)
{
    int          i;              // ebx  - inbound decrypt offset
    int          payloadLen;     // esi  - packet length / outbound XOR key
    int          codeLen;        // eax  - shellcode length
    unsigned int j;              // ebx  - pad counter
    int          k;              // ebx  - outbound encrypt offset
    _BYTE        outPkt[4];      // [esp+Ch]  - outbound CDataStore header
    int          outBuf;         // [esp+10h] - outbound buffer pointer
    unsigned int outSize;        // [esp+1Ch] - outbound buffer size
    int          outTail;        // [esp+20h] - outbound tail marker
    int          ctx;            // [esp+28h] - saved self pointer

    ctx = a1;

    // Decrypt inbound payload: rotating-XOR over 4-byte words.
    for (i = 2; *(_DWORD *)(a5 + 16) != i; i += 4)
    {
        *(_DWORD *)(i + *(_DWORD *)(a5 + 4)) ^= a2;
        a2 = __ROR4__(a2, 1);
    }

    // Register handler for opcode 1051 (0x41B).
    payloadLen = ((int (__fastcall *)(int))((char *)&loc_40116C + 4))(a5);
    ClientServices::SetMessageHandler(1051, ctx, payloadLen);

    // Build outbound ACK packet.
    CDataStore::InitPacket2(outPkt);
    CDataStore::PutInt32(outPkt, 1051);
    CDataStore::PutInt32(outPkt, -792828975);       // 0xD0BE6D31 handshake magic

    // Copy shellcode to scratch buffer, execute it, then wipe.
    codeLen = ((int (__fastcall *)(int))((char *)&loc_40116C + 4))(a5);
    CDataStore::GetBytes(a5, ctx + 340, codeLen, codeLen);
    ((void (__cdecl *)(int))(ctx + 340))(ctx + 340);
    memset(ctx + 340, 0);

    // Pad outbound buffer to 4-byte boundary.
    if (outSize % 4)
    {
        for (j = outSize % 4; j != 4; ++j)
            CDataStore::PutInt8(outPkt, 0);
    }

    // Re-encrypt outbound payload with rotating-XOR.
    for (k = 4; outSize != k; k += 4)
    {
        *(_DWORD *)(k + outBuf) ^= payloadLen;
        payloadLen = __ROR4__(payloadLen, 1);
    }

    outTail = 0;
    ClientServices::SendPacket(outPkt);
    return CDataStore::ReleasePacket(outPkt);
}

In practice, the Dispatcher orchestrates the full lifecycle of an anti-cheat check: inbound decryption, handler registration under opcode 1051 (0x41B), execution of the delivered shellcode, memory sanitization, and finally the construction and re-encryption of the response packet before it is handed off to ClientServices::SendPacket.


HWID Collection Once the Dispatcher receives and executes the appropriate anti-cheat check, one of the routines it invokes is Hwid — the hardware fingerprinting stage. This function aggregates a broad set of system identifiers from the client machine (total physical memory, CPU core count, machine and user names, GPU description, Windows install date, CPU brand string, disk capacity, and the local hardware profile GUID) and serializes them into a single outbound packet. Together these values form a stable per-machine signature used to correlate accounts, detect ban evasion, and enforce hardware-level restrictions.

int __usercall Hwid@<eax>(int a1@<edi>)
{
    __int64                  memStatus;      // rax - GlobalMemoryStatusEx result
    int                      cpuCount;       // eax - processor count
    int                      scratch;        // ebx - multi-purpose scratch buffer
    HMODULE                  hAdvapi;        // esi - advapi32.dll handle
    void (__stdcall *pHwProf)(int);          // eax - GetCurrentHwProfileA pointer
    _DWORD                  *cpuidBuf;       // esi - CPUID brand string buffer
    HKEY                     hKey;           //     - registry key handle
    unsigned int             _EAX;           // eax
    int                      _EDX;           // edx
    int                      _ECX;           // ecx
    int                      _EBX;           // ebx
    int                      bufSize;        // [esp+4h] - buffer size for Win32 calls

    // --- System memory ---
    memStatus = GlobalMemoryStatusEx();
    CDataStore::PutInt64(a1, memStatus, HIDWORD(memStatus));

    // --- Processor count ---
    cpuCount = OsGetNumberOfProcessors();
    CDataStore::PutInt32(a1, cpuCount);

    // --- Allocate 424-byte scratch buffer for Win32 string queries ---
    bufSize = 424;
    scratch = (int)malloc(424);

    // --- Computer name ---
    GetComputerNameA((LPSTR)scratch, (LPDWORD)&bufSize);
    CDataStore::PutCString(a1, scratch);

    // --- User name ---
    GetUserNameA((LPSTR)scratch, (LPDWORD)&bufSize);
    CDataStore::PutCString(a1, scratch);

    // --- Primary display device description ---
    EnumDisplayDevicesA((LPCSTR)scratch, 4);
    CDataStore::PutCString(a1, scratch + 36);

    // --- Hardware profile GUID via advapi32!GetCurrentHwProfileA ---
    *(_DWORD *)scratch       = 0;
    *(_DWORD *)(scratch + 4) = 0;
    hAdvapi = LoadLibraryA("advapi32.dll");
    if (hAdvapi)
    {
        strcpy((char *)(scratch + 16), "GetCurrentHwProfileA");
        *(_BYTE *)(scratch + 37) = 0;
        *(_WORD *)(scratch + 38) = 0;
        pHwProf = (void (__stdcall *)(int))GetProcAddress(hAdvapi, (LPCSTR)(scratch + 16));
        if (pHwProf)
            pHwProf(scratch);
        FreeLibrary(hAdvapi);
    }
    CDataStore::PutCString(a1, scratch + 4);

    // --- Windows install date from HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\InstallDate ---
    *(_DWORD *)scratch = 0;
    strcpy((char *)(scratch + 16), "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\");
    *(_WORD *)(scratch + 62) = 0;
    strcpy((char *)(scratch + 64), "InstallDate");
    if (!RegOpenKeyExA(HKEY_LOCAL_MACHINE,
                       (LPCSTR)(scratch + 16),
                       0,
                       KEY_READ,
                       (PHKEY)(scratch + 12)))
    {
        *(_DWORD *)(scratch + 8) = 4;
        RegQueryValueExA(*(HKEY *)(scratch + 12),
                         (LPCSTR)(scratch + 64),
                         NULL,
                         (LPDWORD)(scratch + 8),
                         (LPBYTE)scratch,
                         (LPDWORD)(scratch + 8));
        RegCloseKey(*(HKEY *)(scratch + 12));
    }
    CDataStore::PutInt32(a1, *(_DWORD *)scratch);

    // --- CPU brand string via CPUID leaves 0x80000002-0x80000004 ---
    cpuidBuf = (_DWORD *)scratch;
    *(_DWORD *)scratch        = 0;
    *(_DWORD *)(scratch + 48) = 0;
    _EAX = 0x80000000;
    __asm { cpuid }
    if ((_EAX & 0xFFFF0000) == 0x80000000 && _EAX >= 0x80000004)
    {
        _EAX = 0x80000004;
        __asm { cpuid }
        cpuidBuf[8]  = _EAX;
        cpuidBuf[9]  = _EBX;
        cpuidBuf[10] = _ECX;
        cpuidBuf[11] = _EDX;

        _EAX = 0x80000003;
        __asm { cpuid }
        cpuidBuf[4] = _EAX;
        cpuidBuf[5] = _EBX;
        cpuidBuf[6] = _ECX;
        cpuidBuf[7] = _EDX;

        _EAX = 0x80000002;
        __asm { cpuid }
        cpuidBuf[0] = _EAX;
        cpuidBuf[1] = _EBX;
        cpuidBuf[2] = _ECX;
        cpuidBuf[3] = _EDX;
    }
    CDataStore::PutCString(a1, (int)cpuidBuf);

    // --- Total disk capacity of the default drive ---
    *cpuidBuf   = 0;
    cpuidBuf[1] = 0;
    GetDiskFreeSpaceExA(NULL, NULL, (PULARGE_INTEGER)cpuidBuf, NULL);
    CDataStore::PutInt64(a1, *cpuidBuf, cpuidBuf[1]);

    return free(cpuidBuf);   // was unk_412FC7 — matches CRT free() signature
}

Loaded Module Enumeration

Immediately after the HWID payload is transmitted, the anti-cheat performs a full enumeration of every module loaded into the client process. Using a Toolhelp32 snapshot, it walks the entire module list and reports each module's base address, size, and file path back to the server. This gives Warmane a complete inventory of every DLL mapped into the WoW process — a common technique for detecting injected cheats, third-party overlays, and unauthorized modifications by cross-referencing loaded modules against a known blacklist.

int __usercall ModuleScan@<eax>(int a1@<edi>)
{
    MODULEENTRY32 *modEntry;    // ebx - Toolhelp32 module entry buffer
    HANDLE         hSnapshot;   // esi - Toolhelp32 snapshot handle

    // Allocate a MODULEENTRY32 structure (sizeof == 568 on x86).
    modEntry     = (MODULEENTRY32 *)malloc(sizeof(MODULEENTRY32));
    modEntry->dwSize = sizeof(MODULEENTRY32);

    // Take a snapshot of all modules loaded in the current process.
    hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, 0);

    // Walk the module list and stream each entry into the outbound packet.
    if (Module32First(hSnapshot, modEntry))
    {
        do
        {
            CDataStore::PutInt32  (a1, (DWORD)modEntry->modBaseAddr);   // base address
            CDataStore::PutInt32  (a1, modEntry->modBaseSize);          // size in bytes
            CDataStore::PutCString(a1, (int)modEntry->szExePath);       // full path on disk
        }
        while (Module32Next(hSnapshot, modEntry));
    }

    CloseHandle(hSnapshot);
    return (int)free(modEntry);
}

Addon Check

On entering the world, the anti-cheat runs AddonCheck — a scan of every loaded addon that compares each addon name against a server-supplied target. When a match is found, it locates the addon's in-memory data block and overwrites strings within it, silently tampering with the loaded addon data without the client or player having any indication it occurred.

void __cdecl AddonCheck(int outPacket, int a2, const char *targetAddonName)
{
    int     addonCount;    // esi - number of addons in the linked list
    _DWORD *currentNode;   // edi - pointer to current list node
    _DWORD *nextNode;      // ebx - pointer to next list node
    _DWORD *namePtr;       // edi - pointer to addon name within node
    int     metadataPtr;   // edi - pointer to addon metadata block
    int     allocBuf;      // eax - server-allocated buffer for exfiltration
    int     metadataLen;   // eax - length of metadata block
    int     allocBufSaved; // [esp+Ch] - saved allocation pointer

    // Walk the global addon linked list at 0xDD0FD8
    if ( MEMORY[0xDD0FD8] )
    {
        addonCount  = *MEMORY[0xDD0FD8];         // first DWORD is the count
        currentNode = (_DWORD *)(MEMORY[0xDD0FD8] + 4);

        while ( addonCount )
        {
            --addonCount;
            nextNode = (_DWORD *)((char *)currentNode + *currentNode); // advance by node stride
            namePtr  = currentNode + 1;                                // name follows the stride DWORD

            // Compare this addon's name against the server-supplied target
            if ( !strcmp(targetAddonName, namePtr) )
            {
                // Metadata block sits immediately after the null-terminated name
                metadataPtr = (int)namePtr + strlen((char *)namePtr) + 1;

                // Allocate a server-side buffer for the metadata
                allocBuf = strstr(outPacket, metadataPtr);
                if ( allocBuf )
                {
                    allocBufSaved = allocBuf;
                    metadataLen   = strlen((char *)metadataPtr);

                    // Copy the metadata block into the exfiltration buffer
                    // (skips the length prefix: metadataPtr + metadataLen + 1)
                    memcpy(allocBufSaved, metadataLen + metadataPtr + 1, metadataLen);
                }
            }

            currentNode = nextNode;
        }
    }

    JUMPOUT(0x818E53); // tail-jump back to the client's Addon Execute Function
}

Active Session Checks

Memory Check

Another routine dispatched during the anti-cheat session is MemoryCheck — a targeted memory read that extracts raw bytes from specified client addresses and serializes them into the outbound packet. The server uses this to verify the integrity of specific memory regions, detecting patches, hooks, or any in-memory modifications to the game client.

int __usercall MemoryCheck@<eax>(int outPacket@<edi>, int inPacket@<esi>)
{
    int remainingChecks; // eax
    int memAddress;      // ebx
    int byteCount;       // eax
    int checkCounter;    // [esp+4h] [ebp-4h]

    remainingChecks = ((int (__fastcall *)(int))CDataStore::GetInt8)(inPacket);
    do
    {
        checkCounter = remainingChecks;
        memAddress   = ((int (__fastcall *)(int))CDataStore::GetInt32)(inPacket);
        byteCount    = ((int (__fastcall *)(int))CDataStore::GetInt8)(inPacket);
        ((void (__thiscall *)(int, int, int))CDataStore::PutBytes)(outPacket, memAddress, byteCount);
        remainingChecks = checkCounter - 1;
    }
    while ( checkCounter != 1 );

    return remainingChecks;
}

The iteration count is read first via GetInt8, then for each iteration the target address is pulled with GetInt32 and the byte count with GetInt8. CDataStore::PutBytes then copies that many bytes from the target address directly into the outbound packet — effectively letting the server read arbitrary regions of client memory.

The underlying CDataStore::PutBytes uses memcpy internally, streaming the raw memory contents into the packet buffer with bounds management handled by InternalFetchWrite:

CDataStore *__thiscall CDataStore::PutBytes(CDataStore *this, unsigned __int8 *a2, unsigned int a3)
{
    unsigned int v5;          // edi
    unsigned int *p_m_base;   // ebx
    unsigned int m_alloc;     // edx
    unsigned int m_size;      // eax
    unsigned __int8 *v9;      // eax

    if ( a2 )
    {
        v5 = a3;
        sub_401070(this->m_size, a3, 0, 0);
        if ( a3 )
        {
            p_m_base = &this->m_base;
            while ( 1 )
            {
                m_alloc = this->m_alloc;
                if ( v5 >= m_alloc )
                    v5 = this->m_alloc;
                if ( v5 <= 1 )
                    v5 = 1;
                m_size = this->m_size;
                if ( m_size < *p_m_base || m_size + v5 > m_alloc + *p_m_base )
                    ((void (__thiscall *)(CDataStore *, unsigned int, unsigned int,
                                         unsigned __int8 **, unsigned int *,
                                         unsigned int *, _DWORD, _DWORD))
                     this->vTable->InternalFetchWrite)(
                        this, m_size, v5,
                        &this->m_buffer, &this->m_base, &this->m_alloc, 0, 0);
                v9 = &this->m_buffer[this->m_size - *p_m_base];
                if ( v9 != a2 )
                    ((void (__cdecl *)(unsigned __int8 *, unsigned __int8 *))memcpy)(v9, a2);
                a2 += v5;
                this->m_size += v5;
                a3 -= v5;
                if ( !a3 )
                    break;
                v5 = a3;
            }
        }
    }
    else if ( a3 )
    {
        SErrSetLastError(87);
        return this;
    }

    return this;
}

The write path clamps the chunk size to the current allocation, calls InternalFetchWrite to grow the buffer if needed, then memcpys the source bytes in. The loop continues until all a3 bytes are consumed, making it a fully streaming copy with automatic buffer growth.


Breakpoint Detection

To prevent dynamic analysis, the anti-cheat checks for hardware breakpoints by calling GetThreadContext with the CONTEXT_DEBUG_REGISTERS flag. Rather than importing these functions through the standard IAT — which would be trivially detectable — the function resolves them at runtime by XORing a stored value against a hardcoded key, then walks a manual offset table to locate each API.

int __cdecl GetBreakpoints(int *a1)
{
    HANDLE        hThread;        // ebx - thread handle passed in via a1[0]
    CONTEXT      *ctx;            // edi - pointer to CONTEXT structure
    int           apiResolver;    // esi - obfuscated function pointer base
    int           contextResult;  // eax - return value from GetThreadContext
    int           closeResult;    // eax - return value from CloseHandle

    hThread     = *a1;
    *a1         = 65552;          // CONTEXT_DEBUG_REGISTERS (0x10010)
    ctx         = (_DWORD *)a1[2];
    apiResolver = a1[1] ^ 0xD348A6; // deobfuscate GetThreadContext pointer

    // Call GetThreadContext — populates ctx with DR0-DR7 debug registers
    contextResult = (*(int (__stdcall **)(int, int *))apiResolver)(hThread, a1);
    apiResolver  -= 252;          // step back to CloseHandle pointer
    *a1           = contextResult;

    // Advance into CONTEXT struct to the Dr7 field (offset 158)
    ctx           = (_DWORD *)((char *)ctx + 158);
    *ctx          = 0x90909090;   // overwrite Dr7 with NOPs — clears all breakpoints

    // CloseHandle(hThread)
    closeResult = (*(int (__stdcall **)(_DWORD *, int))apiResolver)(ctx, 4);

    // Final cleanup call (offset +212 — likely NtClose or similar)
    return (*(int (__stdcall **)(int))(apiResolver + 212))(closeResult);
}

The flag 0x10010 (CONTEXT_DEBUG_REGISTERS) tells GetThreadContext to only populate the debug register fields — DR0 through DR7. It then advances 158 bytes into the CONTEXT struct, landing directly on Dr7, the debug control register that governs which hardware breakpoints are active. Writing 0x90909090 there disables them all.

The API resolution is deliberately obfuscated. a1[1] ^ 0xD348A6 decodes the GetThreadContext pointer at runtime, and subsequent calls are reached by fixed offsets (-252, +212) from that base rather than named imports — making static signature scanning against the IAT ineffective.


Lua String Check

The Lua String Check delivers one or more encrypted Lua chunks from the server, decrypts them in place using a rolling XOR cipher, and executes each one directly inside the client's Lua VM. This is the mechanism that allows Warmane to execute Lua code inside your game client — querying game state, inspecting the environment, or running any arbitrary script expressible in Lua without shipping a new binary to the client.

int __cdecl LuaStringCheck(int a1, int a2, int a3, CDataStore *inPacket)
{
    char          xorKey;        // bl  - rolling XOR key, evolves each byte
    int           remainingBytes; // edi - bytes left in packet after string count
    _BYTE        *currentStr;    // esi - pointer to current string being decrypted
    _BYTE        *bufEnd;        // edi - end of the string buffer
    char          decryptedByte; // bh  - current decrypted byte
    int           result;        // eax
    int           savedTaintA;   // [esp+Ch]  - saved dword_D4139C (Lua taint counter)
    int           savedTaintB;   // [esp+10h] - saved dword_D413A0 (Lua exec depth)
    _BYTE        *strBuf;        // [esp+14h] - allocated string buffer BYREF

    // Read the initial XOR key and the packed string buffer from the packet
    xorKey       = CDataStore::GetInt8_4(inPacket);
    remainingBytes = inPacket->m_size - inPacket->m_read;
    CDataStore::GetStringCount(inPacket, (int)&strBuf, remainingBytes);

    currentStr = strBuf;
    bufEnd     = strBuf + remainingBytes;

    do
    {
        // Decrypt the next null-terminated string using a rolling XOR.
        // Each byte updates the key: key += decrypted + 1
        // This means decryption of any byte depends on all previous bytes.
        strBuf = (int)currentStr;
        do
        {
            decryptedByte  = xorKey ^ *currentStr;
            xorKey        += decryptedByte + 1;
            *currentStr++  = decryptedByte;
        }
        while ( decryptedByte );

        // Compile the decrypted string as a Lua chunk
        lua_loadbuffer(dword_D3F78C, strBuf, &currentStr[-strBuf - 1], 0);

        // Suppress Lua taint tracking and bump execution depth counter
        // to hide this execution from the client's error handling system
        savedTaintA    = dword_D4139C;
        savedTaintB    = dword_D413A0;
        dword_D4139C   = 0;
        ++dword_D413A0;

        // Execute the Lua chunk — no error handler, no return values
        lua_pcall(dword_D3F78C, 0, 0, 0);

        // Restore taint state and clean up the Lua stack
        dword_D4139C = savedTaintA;
        dword_D413A0 = savedTaintB;
        result = lua_settop(dword_D3F78C, -1);
    }
    while ( currentStr != bufEnd );

    return result;
}

Two details worth highlighting here.

The rolling XOR is not a simple single-key cipher. Each decrypted byte feeds back into the key via key += decrypted + 1, meaning the keystream is entirely dependent on the plaintext. Capturing one packet and replaying it against a patched client produces a different keystream and garbled output — a lightweight but effective replay deterrent.

The dword_D4139C and dword_D413A0 manipulation is deliberate suppression of Blizzard's Lua taint and execution depth tracking. Zeroing the taint counter and incrementing the depth counter before lua_pcall hides the server-injected execution from the client's own error handling and hook system — the Lua VM runs the chunk as if it were trusted first-party code with no visibility to the rest of the scripting environment.


Page Integrity Check

PageCheck walks the entire virtual address space of the client process using VirtualQuery, filters for committed executable pages within a specific size range, and SHA1-hashes 20 bytes at a fixed offset within each matching region. The digest is sent back to the server, which compares it against a known-good value. Any patch, hook, or code injection that lands in the target region will produce a mismatched hash.

int __usercall PageCheck@<eax>(char xorSeed@<cf>, unsigned int baseAddr@<eax>, unsigned int regionTable@<edi>, int outPacket@<esi>)
{
    int                    outPacketCtx; // edi - reconstructed output packet pointer
    MEMORY_BASIC_INFORMATION *mbi;      // ebx - VirtualQuery result + SHA1 workspace
    VirtualQuery_t         VirtualQuery; // eax - runtime-resolved VirtualQuery pointer
    int                    currentAddr; // esi - current address being walked
    unsigned int           queryResult; // eax - bytes returned by VirtualQuery
    int                    protect;     // eax - page protection flags
    unsigned int           regionSize;  // eax - current region size

    // Reconstruct outPacket pointer from obfuscated arguments
    outPacketCtx = (__PAIR64__(regionTable, baseAddr)
        - __PAIR64__(*(_DWORD *)(baseAddr - (xorSeed + *(_DWORD *)(regionTable + 2 * outPacket)) + 281),
                     (unsigned int)xorSeed + *(_DWORD *)(regionTable + 2 * outPacket))) >> 32;

    // Allocate buffer: 0x1C for MEMORY_BASIC_INFORMATION + 0x78 for SHA1 state
    mbi = (MEMORY_BASIC_INFORMATION *)malloc(0x94);
    memset(mbi, 0, 0x94);

    // Resolve VirtualQuery pointer from the inbound packet
    VirtualQuery     = *(VirtualQuery_t *)CDataStore::GetInt32(outPacket - 1);
    mbi->VirtualQuery = VirtualQuery;

    currentAddr = 0;
    for ( queryResult  = VirtualQuery(0, mbi, sizeof(MEMORY_BASIC_INFORMATION));
          queryResult >= 0x1C;
          queryResult  = VirtualQuery(currentAddr, mbi, sizeof(MEMORY_BASIC_INFORMATION)) )
    {
        if ( mbi->State == MEM_COMMIT )             // only committed pages
        {
            protect = mbi->Protect;
            if ( (protect & 0x700) == 0 &&          // no PAGE_GUARD / PAGE_NOCACHE
                 (protect & 0xF0)  != 0 )            // must have execute permissions
            {
                regionSize = mbi->RegionSize;
                if ( regionSize >= 0xB0000 && regionSize <= 0xB5000 ) // target size range
                {
                    // Report address and size of the matched region
                    CDataStore::PutInt32(outPacketCtx, currentAddr);
                    CDataStore::PutInt32(outPacketCtx, mbi->RegionSize);

                    // SHA1-hash 20 bytes at a fixed offset within the region
                    memset(&mbi->sha1State, 0, 116);
                    SHA1Broken::Init(&mbi->sha1State);
                    SHA1::Update(&mbi->sha1State, currentAddr + 3986, 20);
                    SHA1::Final(&mbi->sha1State, &mbi->sha1Digest);
                    CDataStore::PutBytes(outPacketCtx, &mbi->sha1Digest, 20);
                }
            }
        }
        currentAddr += mbi->RegionSize;
    }

    return free(mbi);
}

The memory walk is the standard VirtualQuery pattern — start at address 0, advance by RegionSize each iteration until the call returns fewer than 28 bytes, indicating the end of the address space. The committed + executable filter (MEM_COMMIT, protection mask 0xF0) narrows the scan to code pages only.

The size filter (0xB0000–0xB5000, roughly 700–740 KB) fingerprints a specific module by its known mapped size rather than by name or base address — a more robust target that survives ASLR and module renaming. Notably, this approach is also effective against manually mapped DLLs. A standard LoadLibrary injection will appear in the module list and can be caught by ModuleScan, but a manually mapped DLL bypasses the loader entirely and leaves no entry in the module list. PageCheck catches these by scanning the raw address space — a manually mapped executable region will show up as a committed executable page and, if it falls within the size range, will be hashed and reported regardless of whether any loader knows it exists.

The SHA1 hash at offset 3986 is a fixed-point integrity probe. The server knows exactly what those 20 bytes should contain in an unmodified client. Any cheat, hook, or patch that lands in that region — even a single byte change — produces a different digest and fails the check.

One detail worth flagging: the SHA1 is initialized via SHA1Broken::Init rather than a standard implementation. This suggests a deliberately modified or stripped-down SHA1 — likely to avoid matching known library signatures while still producing a stable, collision-resistant fingerprint for the server's comparison.

Credits

This research would not have been possible without the contributions of the following individuals who provided insight, assistance, and collaboration throughout the reverse engineering process.

  • Mike
  • Harvestxy
  • Lamani
  • Ox

⚠ Warning to Warmane

We have a full eye on your anti-cheat system. Every routine will be dumped, documented, and published. Nothing stays hidden.

← back to all posts