diff --git a/contrib/warden/pointer_chain_examples.sql b/contrib/warden/pointer_chain_examples.sql new file mode 100644 index 000000000..23e347aaf --- /dev/null +++ b/contrib/warden/pointer_chain_examples.sql @@ -0,0 +1,213 @@ +-- ============================================================================ +-- POINTER_CHAIN_CHECK (type 244 / 0xF4) example seeds for the `warden` table. +-- +-- Wire format on the client side is identical to MEM_CHECK (243 / 0xF3); the +-- server walks a multi-hop pointer dereference chain across consecutive Warden +-- cycles and `memcmp`-validates the bytes at the final resolved address. +-- +-- Schema reminder (see WardenCheckMgr::LoadWardenChecks): +-- id uint16 -- check id +-- build uint16 -- client build (vanilla 1.12.1 enUS = 5875) +-- type uint8 -- 244 for POINTER_CHAIN_CHECK +-- data string -- unused for this type (leave empty) +-- result string -- hex of the EXPECTED final-hop bytes (length must +-- match the `length` column) +-- address uint32 -- chain base address (32-bit, x86) +-- length uint8 -- bytes to read at the final hop (1..N) +-- str string -- comma-separated hex offsets, e.g. '0x10,0x24,0x8'. +-- Empty string = zero hops (degenerate single-read). +-- Each offset is added to the pointer dereferenced at +-- the previous hop to produce the next hop's address. +-- A leading '!' flips the terminal compare: instead of +-- "fail on mismatch" (verify expected bytes), the row +-- becomes "fail on match" (detect a forbidden cheat +-- signature, e.g. PQR landing in a dynamically +-- resolved memory region). The '!' is consumed before +-- the offsets are parsed, so '!0x4,0x8' is a 2-hop +-- chain in signature-detect mode. +-- comment string -- free text +-- +-- Walk semantics for offsets `o1, o2, ..., oN` and base `B`: +-- hop 0 (intermediate): read 4 bytes at B -> P0 +-- hop 1 (intermediate): read 4 bytes at P0 + o1 -> P1 +-- hop 2 (intermediate): read 4 bytes at P1 + o2 -> P2 +-- ... +-- hop N (terminal): read `length` bytes at P_{N-1} + oN +-- +-- IMPORTANT — addresses below are templates calibrated for the canonical +-- vanilla 1.12.1 enUS WoW.exe (build 5875, +-- MD5 5fea0d4eed95002f436200a16a4f4795). Image base 0x00400000. They are +-- consistent with the function addresses already used in WardenWin.cpp's +-- module-init block (SFileOpenFile = 0x002485F0 + image base, etc.) and with +-- offsets widely documented in vanilla emulation/cheat communities (e.g. +-- ownedcore vanilla reverse-engineering threads, public vanilla bot/cheat +-- repos). +-- +-- BEFORE GOING TO PRODUCTION: +-- 1. Re-confirm every address against your actual binary disassembly. A +-- different localisation (frFR, deDE, etc.) shifts addresses. +-- 2. Capture the real expected bytes at the resolved address from a known +-- clean client and paste them into the `result` column. Placeholder +-- values are noted with `-- TODO` below. +-- 3. Pick check ids that don't collide with your existing rows. +-- ============================================================================ + + +-- ---------------------------------------------------------------------------- +-- Example 1 — Vtable hook detection on the Client Object Manager +-- +-- Detects: cheats that hook a virtual function on the global Object Manager +-- singleton (a common technique for "object dumper" cheats that swap +-- a vtable slot for a thunk that filters returned objects, leaks +-- GUIDs, or injects fake updates). +-- +-- Chain (3 hops, terminal reads 5 bytes of the first virtual function's +-- prologue): +-- base = 0x00B41414 ; s_curMgr — pointer to ClntObjMgr instance +-- hop 0: read [0x00B41414] -> objMgrInstance +-- hop 1: read [objMgrInstance + 0x00] -> vtable +-- hop 2: read [vtable + 0x00] -> first virtual function pointer +-- hop 3 (terminal): read 5 bytes at that function -> expected prologue +-- +-- A `jmp` detour is 5 bytes (E9 XX XX XX XX), which guarantees the prologue +-- bytes change if the function is hooked. +-- ---------------------------------------------------------------------------- +INSERT INTO `warden` + (`id`, `build`, `type`, `data`, `result`, `address`, `length`, `str`, `comment`) +VALUES + (10001, 5875, 244, '', + '0000000000', -- TODO: replace with the real first 5 prologue bytes from clean WoW.exe + 0x00B41414, 5, '0x0,0x0,0x0', + 'Pointer chain: ClntObjMgr -> vtable -> vtable[0] -> prologue (detect vtable hook)'); + + +-- ---------------------------------------------------------------------------- +-- Example 2 — Hook detection on import-resolved GetTickCount +-- +-- Detects: cheats that patch the WoW import for kernel32!GetTickCount to a +-- thunk returning bogus timestamps, defeating the TIMING_CHECK +-- (87/0x57). The IAT slot lives in WoW's `.idata`; following it +-- lands inside kernel32.dll's loaded copy. +-- +-- Chain (1 hop, terminal reads 5 bytes of the resolved function prologue): +-- base = 0x00C2D154 ; IAT slot for kernel32!GetTickCount +-- ; (TODO: confirm RVA on your binary) +-- hop 0 (terminal): read 5 bytes at [0x00C2D154] +-- +-- Caveat: kernel32.dll is part of the OS, so the prologue bytes vary across +-- Windows versions. In practice you'd seed `result` per-OS or use a small +-- whitelist via multiple check rows. Listed here as a textbook IAT-hook +-- pattern; consider it a template, not a drop-in. +-- ---------------------------------------------------------------------------- +INSERT INTO `warden` + (`id`, `build`, `type`, `data`, `result`, `address`, `length`, `str`, `comment`) +VALUES + (10002, 5875, 244, '', + '0000000000', -- TODO: per-OS captured bytes of kernel32!GetTickCount prologue + 0x00C2D154, 5, '', + 'Pointer chain: WoW IAT[GetTickCount] -> kernel32 prologue (detect IAT detour)'); + + +-- ---------------------------------------------------------------------------- +-- Example 3 — Sanity check on Local Player object type field +-- +-- Detects: object-replace cheats that swap the local player's object type at +-- its UnitFields descriptor block to confuse server-side validation. +-- Reads the object type byte, expected to be 4 (TYPEID_PLAYER) for +-- the local player object. +-- +-- Chain (3 hops, terminal reads 1 byte): +-- base = 0x00B41414 ; s_curMgr +-- hop 0: read [0x00B41414] -> objMgrInstance +-- hop 1: read [objMgrInstance + 0xAC] -> first object in linked list +-- (publicly documented offset) +-- hop 2: read [object + 0x14] -> object type id field +-- (TYPEID layout is documented +-- in Object.h) +-- +-- Note: the linked list head at +0xAC is not always the local player; it +-- iterates by +0x3C until matching local GUID. For a simple template we use +-- the head — adjust if you want strict local-player semantics. This is +-- primarily useful as a presence/structural-integrity check. +-- ---------------------------------------------------------------------------- +INSERT INTO `warden` + (`id`, `build`, `type`, `data`, `result`, `address`, `length`, `str`, `comment`) +VALUES + (10003, 5875, 244, '', + '04', -- TYPEID_PLAYER + 0x00B41414, 1, '0x0,0xAC,0x14', + 'Pointer chain: ObjMgr -> first object -> typeId byte (detect object spoof)'); + + +-- ---------------------------------------------------------------------------- +-- Example 4 — Zero-hop sanity (degenerate chain) +-- +-- Equivalent in semantics to a plain MEM_CHECK, but routed through the +-- POINTER_CHAIN_CHECK code path. Useful as a smoke test when bringing the +-- feature up: pick a known stable byte run in WoW.exe's `.text` (any +-- function whose prologue you already trust) and seed the expected bytes. +-- +-- Reads 5 bytes of the SFileOpenFile prologue at 0x006485F0 +-- (image base 0x00400000 + RVA 0x002485F0, taken straight from the +-- WardenInitModuleRequest in src/game/Warden/WardenWin.cpp). +-- ---------------------------------------------------------------------------- +INSERT INTO `warden` + (`id`, `build`, `type`, `data`, `result`, `address`, `length`, `str`, `comment`) +VALUES + (10004, 5875, 244, '', + '0000000000', -- TODO: replace with real first 5 bytes of SFileOpenFile prologue + 0x006485F0, 5, '', + 'Zero-hop pointer-chain smoke test (SFileOpenFile prologue)'); + + +-- ---------------------------------------------------------------------------- +-- Example 5 — Signature-detect mode (third-party allocation scan) +-- +-- Inspired by Krilliac/AdvancedWarden's MEM2_CHECK / GAGARIN pair pattern, +-- which targets cheats that allocate executable memory in well-known +-- dynamic regions and place their payload at a fixed offset within that +-- region. Approach there: one MEM_CHECK reads the dynamic base address and +-- caches it on the session; a paired MEM_CHECK then scans `base + small +-- offset` and fails when bytes != 0 (i.e. the region is not empty as it +-- should be on a clean client). +-- +-- Our generalised equivalent: a single POINTER_CHAIN_CHECK row that walks +-- to the suspect address and uses signature-detect mode (leading `!`) to +-- fail when a known cheat-signature pattern appears. +-- +-- Chain (1 hop, terminal reads 4 bytes; fails if pattern found): +-- base = 0x009F348 ; static slot that holds the dynamic +-- ; allocation pointer for this cheat +-- ; family. (TODO: confirm against your +-- ; binary; the AdvancedWarden seed used +-- ; address=652040=0x9F348, length=4.) +-- hop 0 (terminal): read 4 bytes at *base + 2 (or other small offset) +-- expected = the cheat's 4-byte signature +-- fail when read == expected (signature present) +-- +-- The leading '!' on the offset string flips the terminal compare into +-- signature-detect mode. Pick the offset value (here 0x2) to match the +-- byte-window where the cheat is known to land. Length must equal the +-- length of the signature in `result`. +-- ---------------------------------------------------------------------------- +INSERT INTO `warden` + (`id`, `build`, `type`, `data`, `result`, `address`, `length`, `str`, `comment`) +VALUES + (10005, 5875, 244, '', + '00003000', -- TODO: replace with the real cheat signature bytes + 0x0009F348, 4, '!0x2', + 'Signature detect: dynamic 3rd-party allocation scan (PQR-class)'); + + +-- ---------------------------------------------------------------------------- +-- Optional: action override per check id (only if you want non-default +-- penalty for these). Default action comes from +-- CONFIG_UINT32_WARDEN_CLIENT_FAIL_ACTION (0=LOG, 1=KICK, 2=BAN). The +-- override lives in the characters DB. +-- ---------------------------------------------------------------------------- +-- INSERT INTO `warden_action` (`wardenId`, `action`) VALUES +-- (10001, 1), -- kick on vtable-hook detection +-- (10002, 0), -- log only on IAT check (more false-positive prone) +-- (10003, 1), -- kick on object-type spoof +-- (10004, 0), -- log only on smoke test +-- (10005, 2); -- ban on confirmed signature match diff --git a/src/game/Warden/Warden.h b/src/game/Warden/Warden.h index 4eb4788e7..84dd7bcd5 100644 --- a/src/game/Warden/Warden.h +++ b/src/game/Warden/Warden.h @@ -75,7 +75,8 @@ enum WardenCheckType DRIVER_CHECK = 0x71, ///< Driver check (113: uint Seed + byte[20] SHA1 + byte driverNameIndex - check to ensure driver isn't loaded) TIMING_CHECK = 0x57, ///< Timing check (87: empty - check to ensure GetTickCount() isn't detoured) PROC_CHECK = 0x7E, ///< Procedure check (126: uint Seed + byte[20] SHA1 + byte moluleNameIndex + byte procNameIndex + uint Offset + byte Len - check to ensure proc isn't detoured) - MODULE_CHECK = 0xD9 ///< Module check (217: uint Seed + byte[20] SHA1 - check to ensure module isn't injected) + MODULE_CHECK = 0xD9, ///< Module check (217: uint Seed + byte[20] SHA1 - check to ensure module isn't injected) + POINTER_CHAIN_CHECK = 0xF4 ///< 244: SERVER-SIDE ONLY. Wire format identical to MEM_CHECK (0xF3). Walks a pointer-deref chain across multiple Warden cycles and memcmp-validates the bytes at the final resolved address. Never appears in any byte sent to or from the client module. }; #if defined(__GNUC__) diff --git a/src/game/Warden/WardenCheckMgr.cpp b/src/game/Warden/WardenCheckMgr.cpp index 0e469636b..650147b15 100644 --- a/src/game/Warden/WardenCheckMgr.cpp +++ b/src/game/Warden/WardenCheckMgr.cpp @@ -141,21 +141,21 @@ void WardenCheckMgr::LoadWardenChecks() } } - if (checkType == MEM_CHECK || checkType == PAGE_CHECK_A || checkType == PAGE_CHECK_B || checkType == PROC_CHECK) + if (checkType == MEM_CHECK || checkType == PAGE_CHECK_A || checkType == PAGE_CHECK_B || checkType == PROC_CHECK || checkType == POINTER_CHAIN_CHECK) { wardenCheck->Address = address; wardenCheck->Length = length; } // PROC_CHECK support missing - if (checkType == MEM_CHECK || checkType == MPQ_CHECK || checkType == LUA_STR_CHECK || checkType == DRIVER_CHECK || checkType == MODULE_CHECK) + if (checkType == MEM_CHECK || checkType == MPQ_CHECK || checkType == LUA_STR_CHECK || checkType == DRIVER_CHECK || checkType == MODULE_CHECK || checkType == POINTER_CHAIN_CHECK) { wardenCheck->Str = str; } CheckStore.insert(std::pair(build, wardenCheck)); - if (checkType == MPQ_CHECK || checkType == MEM_CHECK) + if (checkType == MPQ_CHECK || checkType == MEM_CHECK || checkType == POINTER_CHAIN_CHECK) { WardenCheckResult* wr = new WardenCheckResult(); wr->Id = id; @@ -312,7 +312,7 @@ void WardenCheckMgr::GetWardenCheckIds(bool isMemCheck, uint16 build, std::list< { if (isMemCheck) { - if ((it->second->Type == MEM_CHECK) || (it->second->Type == MODULE_CHECK)) + if ((it->second->Type == MEM_CHECK) || (it->second->Type == MODULE_CHECK) || (it->second->Type == POINTER_CHAIN_CHECK)) { idl.push_back(it->second->CheckId); } diff --git a/src/game/Warden/WardenWin.cpp b/src/game/Warden/WardenWin.cpp index 5dc4cd50a..487bf3877 100644 --- a/src/game/Warden/WardenWin.cpp +++ b/src/game/Warden/WardenWin.cpp @@ -63,13 +63,100 @@ * * Initializes the Windows-specific Warden implementation. */ -WardenWin::WardenWin() : Warden(), _serverTicks(0) {} +WardenWin::WardenWin() : Warden(), _serverTicks(0), _pointerChainActive(false) +{ + _pointerChainInFlight.checkId = 0; + _pointerChainInFlight.hopIndex = 0; + _pointerChainInFlight.currentAddress = 0; + _pointerChainInFlight.finalLength = 0; + _pointerChainInFlight.invertMatch = false; +} /** * @brief WardenWin destructor */ WardenWin::~WardenWin() {} +bool WardenWin::ParseChainOffsets(const std::string& str, std::vector& out) +{ + out.clear(); + + std::string s; + s.reserve(str.size()); + for (char c : str) + { + if (c != ' ' && c != '\t' && c != '\r' && c != '\n') + s.push_back(c); + } + + if (s.empty()) + return true; // zero-hop chain is legal + + size_t pos = 0; + while (pos <= s.size()) + { + size_t comma = s.find(',', pos); + std::string token = s.substr(pos, (comma == std::string::npos) ? std::string::npos : comma - pos); + if (token.empty()) + return false; + + const char* cstr = token.c_str(); + int base = 10; + if (token.size() > 2 && token[0] == '0' && (token[1] == 'x' || token[1] == 'X')) + { + base = 16; + cstr += 2; + if (*cstr == '\0') + return false; + } + + char* endp = nullptr; + unsigned long val = strtoul(cstr, &endp, base); + if (!endp || *endp != '\0') + return false; + + out.push_back(static_cast(val)); + + if (comma == std::string::npos) + break; + pos = comma + 1; + } + + return true; +} + +void WardenWin::StartPointerChain(WardenCheck* wd) +{ + _pointerChainInFlight.checkId = wd->CheckId; + _pointerChainInFlight.offsets.clear(); + _pointerChainInFlight.hopIndex = 0; + _pointerChainInFlight.currentAddress = wd->Address; + _pointerChainInFlight.finalLength = wd->Length; + _pointerChainInFlight.invertMatch = false; + + // Optional leading '!' on the offset string flips the terminal compare from + // "fail on mismatch" (verify expected bytes) to "fail on match" (detect a + // forbidden cheat-signature pattern, e.g. PQR landing in a dynamically + // resolved memory region). + std::string chain = wd->Str; + size_t firstNonSpace = chain.find_first_not_of(" \t\r\n"); + if (firstNonSpace != std::string::npos && chain[firstNonSpace] == '!') + { + _pointerChainInFlight.invertMatch = true; + chain.erase(0, firstNonSpace + 1); + } + + if (!ParseChainOffsets(chain, _pointerChainInFlight.offsets)) + { + sLog.outWarden("POINTER_CHAIN_CHECK CheckId %u has malformed offset chain '%s'; skipping", + wd->CheckId, wd->Str.c_str()); + _pointerChainActive = false; + return; + } + + _pointerChainActive = true; +} + /** * @brief Initialize Windows Warden * @param session Client session @@ -242,16 +329,36 @@ void WardenWin::RequestData() // Build check request for (uint16 i = 0; i < sWorld.getConfig(CONFIG_UINT32_WARDEN_NUM_MEM_CHECKS); ++i) { + // If a POINTER_CHAIN_CHECK chain is mid-walk, consume one slot for it (do not pop a new id). + if (_pointerChainActive) + { + _currentChecks.push_back(_pointerChainInFlight.checkId); + continue; + } + // If todo list is done break loop (will be filled on next Update() run) if (_memChecksTodo.empty()) { break; } - // Get check id from the end and remove it from todo + // Peek the next id; if it's a POINTER_CHAIN_CHECK and a chain is already active, defer it. id = _memChecksTodo.back(); + WardenCheck* peek = sWardenCheckMgr->GetWardenDataById(build, id); + + // Pop and schedule. _memChecksTodo.pop_back(); + if (peek && peek->Type == POINTER_CHAIN_CHECK) + { + StartPointerChain(peek); + if (!_pointerChainActive) + { + // Malformed chain; loader should have filtered it. Skip without scheduling. + continue; + } + } + // Add the id to the list sent in this cycle _currentChecks.push_back(id); } @@ -306,7 +413,9 @@ void WardenWin::RequestData() wd = sWardenCheckMgr->GetWardenDataById(build, *itr); type = wd->Type; - buff << uint8(type ^ xorByte); + // POINTER_CHAIN_CHECK is server-only; emit it on the wire as a MEM_CHECK so the client module accepts it. + uint8 wireType = (type == POINTER_CHAIN_CHECK) ? uint8(MEM_CHECK) : type; + buff << uint8(wireType ^ xorByte); switch (type) { case MEM_CHECK: @@ -316,6 +425,17 @@ void WardenWin::RequestData() buff << uint8(wd->Length); break; } + case POINTER_CHAIN_CHECK: + { + uint32 addr = _pointerChainInFlight.currentAddress; + uint8 len = (_pointerChainInFlight.hopIndex < _pointerChainInFlight.offsets.size()) + ? uint8(4) // intermediate pointer hop + : _pointerChainInFlight.finalLength; // terminal hop + buff << uint8(0x00); + buff << uint32(addr); + buff << uint8(len); + break; + } case PAGE_CHECK_A: case PAGE_CHECK_B: { @@ -461,6 +581,79 @@ void WardenWin::HandleData(ByteBuffer &buff) sLog.outWarden("RESULT MEM_CHECK passed CheckId %u account Id %u", *itr, _session->GetAccountId()); break; } + case POINTER_CHAIN_CHECK: + { + uint8 status; + buff >> status; + + if (status != 0) + { + sLog.outWarden("RESULT POINTER_CHAIN_CHECK status not 0x00, CheckId %u account Id %u (hop %zu)", + *itr, _session->GetAccountId(), _pointerChainInFlight.hopIndex); + checkFailed = *itr; + _pointerChainActive = false; + continue; + } + + if (!_pointerChainActive || _pointerChainInFlight.checkId != *itr) + { + sLog.outWarden("POINTER_CHAIN_CHECK response for CheckId %u with no active chain", *itr); + checkFailed = *itr; + _pointerChainActive = false; + buff.rpos(buff.wpos()); // can't safely know read length; drain to avoid desync + return; + } + + if (_pointerChainInFlight.hopIndex < _pointerChainInFlight.offsets.size()) + { + // Intermediate hop: read 4-byte LE pointer, advance chain, await next cycle. + uint32 ptr; + memcpy(&ptr, buff.contents() + buff.rpos(), sizeof(uint32)); + buff.rpos(buff.rpos() + sizeof(uint32)); + + if (ptr == 0) + { + sLog.outWarden("RESULT POINTER_CHAIN_CHECK NULL deref at hop %zu, CheckId %u account Id %u", + _pointerChainInFlight.hopIndex, *itr, _session->GetAccountId()); + checkFailed = *itr; + _pointerChainActive = false; + continue; + } + + _pointerChainInFlight.currentAddress = ptr + _pointerChainInFlight.offsets[_pointerChainInFlight.hopIndex]; + ++_pointerChainInFlight.hopIndex; + sLog.outWarden("POINTER_CHAIN_CHECK hop advanced to %zu (next addr 0x%08X), CheckId %u", + _pointerChainInFlight.hopIndex, _pointerChainInFlight.currentAddress, *itr); + break; + } + + // Terminal hop: memcmp the bytes at the resolved address against the expected + // result. Two interpretations driven by invertMatch: + // invertMatch == false : verify-clean — fail on mismatch + // invertMatch == true : signature detect — fail on match + int cmp = memcmp(buff.contents() + buff.rpos(), rs->Result.AsByteArray(0, false), + _pointerChainInFlight.finalLength); + bool matched = (cmp == 0); + bool failed = _pointerChainInFlight.invertMatch ? matched : !matched; + + if (failed) + { + sLog.outWarden("RESULT POINTER_CHAIN_CHECK fail CheckId %u account Id %u (%s)", + *itr, _session->GetAccountId(), + _pointerChainInFlight.invertMatch ? "signature matched" : "bytes mismatch"); + checkFailed = *itr; + buff.rpos(buff.rpos() + _pointerChainInFlight.finalLength); + _pointerChainActive = false; + continue; + } + + buff.rpos(buff.rpos() + _pointerChainInFlight.finalLength); + sLog.outWarden("RESULT POINTER_CHAIN_CHECK passed CheckId %u account Id %u (%s)", + *itr, _session->GetAccountId(), + _pointerChainInFlight.invertMatch ? "no signature" : "bytes match"); + _pointerChainActive = false; + break; + } case PAGE_CHECK_A: case PAGE_CHECK_B: case DRIVER_CHECK: diff --git a/src/game/Warden/WardenWin.h b/src/game/Warden/WardenWin.h index 2570c08e6..87f1671f5 100644 --- a/src/game/Warden/WardenWin.h +++ b/src/game/Warden/WardenWin.h @@ -27,6 +27,7 @@ #define _WARDEN_WIN_H #include "Warden.h" +#include #if defined(__GNUC__) #pragma pack(1) @@ -129,10 +130,25 @@ class WardenWin : public Warden void HandleData(ByteBuffer &buff) override; private: + struct PointerChainState + { + uint16 checkId; + std::vector offsets; + size_t hopIndex; + uint32 currentAddress; + uint8 finalLength; + bool invertMatch; ///< true = fail when terminal bytes MATCH expected (signature detect) + }; + + static bool ParseChainOffsets(const std::string& str, std::vector& out); + void StartPointerChain(WardenCheck* wd); + uint32 _serverTicks; ///< Server ticks std::list _otherChecksTodo; ///< Other checks to do std::list _memChecksTodo; ///< Memory checks to do std::list _currentChecks; ///< Current checks + PointerChainState _pointerChainInFlight; ///< in-flight pointer-chain walk state + bool _pointerChainActive; ///< whether a pointer-chain walk is in progress }; #endif