From 86a522df038dfdcc6dde15e1c071fdd82f667df0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 15:58:19 +0000 Subject: [PATCH 1/3] Warden: add CUSTOM_CHECK (244) for pointer-chain scanning Introduces a server-only check type CUSTOM_CHECK = 0xF4 that piggybacks on the existing MEM_CHECK (0xF3) wire format so the unmodified client module accepts it, but routes responses through a separate handler that walks a multi-hop pointer dereference chain and validates the final target bytes. Keeps MEM_CHECK validation logic untouched. - Warden.h: add CUSTOM_CHECK enum value - WardenCheckMgr: load Address/Length/Str/Result for CUSTOM_CHECK rows; group with MEM_CHECK in the mem-check id list - WardenWin: per-session chain state (single chain in flight), chain offset parser, hop scheduler in RequestData (emits MEM_CHECK on the wire), separate CUSTOM_CHECK case in HandleData that advances the chain on intermediate hops and memcmp-validates on the terminal hop --- src/game/Warden/Warden.h | 5 +- src/game/Warden/WardenCheckMgr.cpp | 8 +- src/game/Warden/WardenWin.cpp | 176 ++++++++++++++++++++++++++++- src/game/Warden/WardenWin.h | 15 +++ 4 files changed, 196 insertions(+), 8 deletions(-) diff --git a/src/game/Warden/Warden.h b/src/game/Warden/Warden.h index 4521c54e4..d107f81e3 100644 --- a/src/game/Warden/Warden.h +++ b/src/game/Warden/Warden.h @@ -65,7 +65,10 @@ enum WardenCheckType DRIVER_CHECK = 0x71, // 113: uint Seed + byte[20] SHA1 + byte driverNameIndex (check to ensure driver isn't loaded) TIMING_CHECK = 0x57, // 87: empty (check to ensure GetTickCount() isn't detoured) PROC_CHECK = 0x7E, // 126: uint Seed + byte[20] SHA1 + byte moluleNameIndex + byte procNameIndex + uint Offset + byte Len (check to ensure proc isn't detoured) - MODULE_CHECK = 0xD9 // 217: uint Seed + byte[20] SHA1 (check to ensure module isn't injected) + MODULE_CHECK = 0xD9, // 217: uint Seed + byte[20] SHA1 (check to ensure module isn't injected) + CUSTOM_CHECK = 0xF4 // 244: SERVER-SIDE ONLY. Wire format identical to MEM_CHECK (0xF3). + // Drives multi-hop pointer-chain reads and custom validation; + // 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 fda9aaf96..3a9f162a5 100644 --- a/src/game/Warden/WardenCheckMgr.cpp +++ b/src/game/Warden/WardenCheckMgr.cpp @@ -106,21 +106,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 == CUSTOM_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 == CUSTOM_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 == CUSTOM_CHECK) { WardenCheckResult* wr = new WardenCheckResult(); wr->Id = id; @@ -251,7 +251,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 == CUSTOM_CHECK)) { idl.push_back(it->second->CheckId); } diff --git a/src/game/Warden/WardenWin.cpp b/src/game/Warden/WardenWin.cpp index fdf39714f..2a5963ee0 100644 --- a/src/game/Warden/WardenWin.cpp +++ b/src/game/Warden/WardenWin.cpp @@ -41,10 +41,83 @@ #include "WardenCheckMgr.h" #include "GameTime.h" -WardenWin::WardenWin() : Warden(), _serverTicks(0) {} +WardenWin::WardenWin() : Warden(), _serverTicks(0), _customChainActive(false) +{ + _customChainInFlight.checkId = 0; + _customChainInFlight.hopIndex = 0; + _customChainInFlight.currentAddress = 0; + _customChainInFlight.finalLength = 0; +} 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::StartCustomChain(WardenCheck* wd) +{ + _customChainInFlight.checkId = wd->CheckId; + _customChainInFlight.offsets.clear(); + _customChainInFlight.hopIndex = 0; + _customChainInFlight.currentAddress = wd->Address; + _customChainInFlight.finalLength = wd->Length; + + if (!ParseChainOffsets(wd->Str, _customChainInFlight.offsets)) + { + sLog.outWarden("CUSTOM_CHECK CheckId %u has malformed offset chain '%s'; skipping", + wd->CheckId, wd->Str.c_str()); + _customChainActive = false; + return; + } + + _customChainActive = true; +} + void WardenWin::Init(WorldSession* session, BigNumber* k) { _session = session; @@ -187,16 +260,36 @@ void WardenWin::RequestData() // Build check request for (uint16 i = 0; i < sWorld.getConfig(CONFIG_UINT32_WARDEN_NUM_MEM_CHECKS); ++i) { + // If a CUSTOM_CHECK chain is mid-walk, consume one slot for it (do not pop a new id). + if (_customChainActive) + { + _currentChecks.push_back(_customChainInFlight.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 CUSTOM_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 == CUSTOM_CHECK) + { + StartCustomChain(peek); + if (!_customChainActive) + { + // 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); } @@ -251,7 +344,9 @@ void WardenWin::RequestData() wd = sWardenCheckMgr->GetWardenDataById(build, *itr); type = wd->Type; - buff << uint8(type ^ xorByte); + // CUSTOM_CHECK is server-only; emit it on the wire as a MEM_CHECK so the client module accepts it. + uint8 wireType = (type == CUSTOM_CHECK) ? uint8(MEM_CHECK) : type; + buff << uint8(wireType ^ xorByte); switch (type) { case MEM_CHECK: @@ -261,6 +356,17 @@ void WardenWin::RequestData() buff << uint8(wd->Length); break; } + case CUSTOM_CHECK: + { + uint32 addr = _customChainInFlight.currentAddress; + uint8 len = (_customChainInFlight.hopIndex < _customChainInFlight.offsets.size()) + ? uint8(4) // intermediate pointer hop + : _customChainInFlight.finalLength; // terminal hop + buff << uint8(0x00); + buff << uint32(addr); + buff << uint8(len); + break; + } case PAGE_CHECK_A: case PAGE_CHECK_B: { @@ -400,6 +506,70 @@ void WardenWin::HandleData(ByteBuffer &buff) sLog.outWarden("RESULT MEM_CHECK passed CheckId %u account Id %u", *itr, _session->GetAccountId()); break; } + case CUSTOM_CHECK: + { + uint8 status; + buff >> status; + + if (status != 0) + { + sLog.outWarden("RESULT CUSTOM_CHECK status not 0x00, CheckId %u account Id %u (hop %zu)", + *itr, _session->GetAccountId(), _customChainInFlight.hopIndex); + checkFailed = *itr; + _customChainActive = false; + continue; + } + + if (!_customChainActive || _customChainInFlight.checkId != *itr) + { + sLog.outWarden("CUSTOM_CHECK response for CheckId %u with no active chain", *itr); + checkFailed = *itr; + _customChainActive = false; + buff.rpos(buff.wpos()); // can't safely know read length; drain to avoid desync + return; + } + + if (_customChainInFlight.hopIndex < _customChainInFlight.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 CUSTOM_CHECK NULL deref at hop %zu, CheckId %u account Id %u", + _customChainInFlight.hopIndex, *itr, _session->GetAccountId()); + checkFailed = *itr; + _customChainActive = false; + continue; + } + + _customChainInFlight.currentAddress = ptr + _customChainInFlight.offsets[_customChainInFlight.hopIndex]; + ++_customChainInFlight.hopIndex; + sLog.outWarden("CUSTOM_CHECK hop advanced to %zu (next addr 0x%08X), CheckId %u", + _customChainInFlight.hopIndex, _customChainInFlight.currentAddress, *itr); + break; + } + + // Terminal hop: compare finalLength bytes against expected result. + if (memcmp(buff.contents() + buff.rpos(), rs->Result.AsByteArray(0, false), + _customChainInFlight.finalLength) != 0) + { + sLog.outWarden("RESULT CUSTOM_CHECK fail CheckId %u account Id %u", + *itr, _session->GetAccountId()); + checkFailed = *itr; + buff.rpos(buff.rpos() + _customChainInFlight.finalLength); + _customChainActive = false; + continue; + } + + buff.rpos(buff.rpos() + _customChainInFlight.finalLength); + sLog.outWarden("RESULT CUSTOM_CHECK passed CheckId %u account Id %u", + *itr, _session->GetAccountId()); + _customChainActive = 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 e1d5c2894..0179900f1 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) @@ -87,10 +88,24 @@ class WardenWin : public Warden void HandleData(ByteBuffer &buff) override; private: + struct CustomCheckState + { + uint16 checkId; + std::vector offsets; + size_t hopIndex; + uint32 currentAddress; + uint8 finalLength; + }; + + static bool ParseChainOffsets(const std::string& str, std::vector& out); + void StartCustomChain(WardenCheck* wd); + uint32 _serverTicks; std::list _otherChecksTodo; std::list _memChecksTodo; std::list _currentChecks; + CustomCheckState _customChainInFlight; + bool _customChainActive; }; #endif From 5a1d7a695d56d3c93622033c9eba3ba5a04fd652 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 16:06:15 +0000 Subject: [PATCH 2/3] Warden: rename CUSTOM_CHECK to POINTER_CHAIN_CHECK + example seeds Renames the type-244 check across enum, struct, members, helpers, and log strings to reflect what it actually does. Adds contrib/warden/pointer_ chain_examples.sql with four annotated INSERTs demonstrating the feature against vanilla 1.12.1 (build 5875): vtable-hook detection on the Client Object Manager, IAT-detour detection on kernel32!GetTickCount, an object- type spoof check, and a zero-hop smoke test against the SFileOpenFile prologue. Addresses are templates calibrated against publicly documented disassemblies and the addresses already used in WardenWin's module-init block; expected `result` bytes are TODO placeholders to be filled from a clean client capture before deployment. --- contrib/warden/pointer_chain_examples.sql | 166 ++++++++++++++++++++++ src/game/Warden/Warden.h | 7 +- src/game/Warden/WardenCheckMgr.cpp | 8 +- src/game/Warden/WardenWin.cpp | 100 ++++++------- src/game/Warden/WardenWin.h | 8 +- 5 files changed, 228 insertions(+), 61 deletions(-) create mode 100644 contrib/warden/pointer_chain_examples.sql diff --git a/contrib/warden/pointer_chain_examples.sql b/contrib/warden/pointer_chain_examples.sql new file mode 100644 index 000000000..7f381f635 --- /dev/null +++ b/contrib/warden/pointer_chain_examples.sql @@ -0,0 +1,166 @@ +-- ============================================================================ +-- 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. +-- 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)'); + + +-- ---------------------------------------------------------------------------- +-- 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 diff --git a/src/game/Warden/Warden.h b/src/game/Warden/Warden.h index d107f81e3..14f6f7acb 100644 --- a/src/game/Warden/Warden.h +++ b/src/game/Warden/Warden.h @@ -66,9 +66,10 @@ enum WardenCheckType TIMING_CHECK = 0x57, // 87: empty (check to ensure GetTickCount() isn't detoured) PROC_CHECK = 0x7E, // 126: uint Seed + byte[20] SHA1 + byte moluleNameIndex + byte procNameIndex + uint Offset + byte Len (check to ensure proc isn't detoured) MODULE_CHECK = 0xD9, // 217: uint Seed + byte[20] SHA1 (check to ensure module isn't injected) - CUSTOM_CHECK = 0xF4 // 244: SERVER-SIDE ONLY. Wire format identical to MEM_CHECK (0xF3). - // Drives multi-hop pointer-chain reads and custom validation; - // never appears in any byte sent to or from the client module. + 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 3a9f162a5..374e123eb 100644 --- a/src/game/Warden/WardenCheckMgr.cpp +++ b/src/game/Warden/WardenCheckMgr.cpp @@ -106,21 +106,21 @@ void WardenCheckMgr::LoadWardenChecks() } } - if (checkType == MEM_CHECK || checkType == PAGE_CHECK_A || checkType == PAGE_CHECK_B || checkType == PROC_CHECK || checkType == CUSTOM_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 || checkType == CUSTOM_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 || checkType == CUSTOM_CHECK) + if (checkType == MPQ_CHECK || checkType == MEM_CHECK || checkType == POINTER_CHAIN_CHECK) { WardenCheckResult* wr = new WardenCheckResult(); wr->Id = id; @@ -251,7 +251,7 @@ void WardenCheckMgr::GetWardenCheckIds(bool isMemCheck, uint16 build, std::list< { if (isMemCheck) { - if ((it->second->Type == MEM_CHECK) || (it->second->Type == MODULE_CHECK) || (it->second->Type == CUSTOM_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 2a5963ee0..5a97b7f42 100644 --- a/src/game/Warden/WardenWin.cpp +++ b/src/game/Warden/WardenWin.cpp @@ -41,12 +41,12 @@ #include "WardenCheckMgr.h" #include "GameTime.h" -WardenWin::WardenWin() : Warden(), _serverTicks(0), _customChainActive(false) +WardenWin::WardenWin() : Warden(), _serverTicks(0), _pointerChainActive(false) { - _customChainInFlight.checkId = 0; - _customChainInFlight.hopIndex = 0; - _customChainInFlight.currentAddress = 0; - _customChainInFlight.finalLength = 0; + _pointerChainInFlight.checkId = 0; + _pointerChainInFlight.hopIndex = 0; + _pointerChainInFlight.currentAddress = 0; + _pointerChainInFlight.finalLength = 0; } WardenWin::~WardenWin() { } @@ -99,23 +99,23 @@ bool WardenWin::ParseChainOffsets(const std::string& str, std::vector& o return true; } -void WardenWin::StartCustomChain(WardenCheck* wd) +void WardenWin::StartPointerChain(WardenCheck* wd) { - _customChainInFlight.checkId = wd->CheckId; - _customChainInFlight.offsets.clear(); - _customChainInFlight.hopIndex = 0; - _customChainInFlight.currentAddress = wd->Address; - _customChainInFlight.finalLength = wd->Length; + _pointerChainInFlight.checkId = wd->CheckId; + _pointerChainInFlight.offsets.clear(); + _pointerChainInFlight.hopIndex = 0; + _pointerChainInFlight.currentAddress = wd->Address; + _pointerChainInFlight.finalLength = wd->Length; - if (!ParseChainOffsets(wd->Str, _customChainInFlight.offsets)) + if (!ParseChainOffsets(wd->Str, _pointerChainInFlight.offsets)) { - sLog.outWarden("CUSTOM_CHECK CheckId %u has malformed offset chain '%s'; skipping", + sLog.outWarden("POINTER_CHAIN_CHECK CheckId %u has malformed offset chain '%s'; skipping", wd->CheckId, wd->Str.c_str()); - _customChainActive = false; + _pointerChainActive = false; return; } - _customChainActive = true; + _pointerChainActive = true; } void WardenWin::Init(WorldSession* session, BigNumber* k) @@ -260,10 +260,10 @@ void WardenWin::RequestData() // Build check request for (uint16 i = 0; i < sWorld.getConfig(CONFIG_UINT32_WARDEN_NUM_MEM_CHECKS); ++i) { - // If a CUSTOM_CHECK chain is mid-walk, consume one slot for it (do not pop a new id). - if (_customChainActive) + // 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(_customChainInFlight.checkId); + _currentChecks.push_back(_pointerChainInFlight.checkId); continue; } @@ -273,17 +273,17 @@ void WardenWin::RequestData() break; } - // Peek the next id; if it's a CUSTOM_CHECK and a chain is already active, defer it. + // 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 == CUSTOM_CHECK) + if (peek && peek->Type == POINTER_CHAIN_CHECK) { - StartCustomChain(peek); - if (!_customChainActive) + StartPointerChain(peek); + if (!_pointerChainActive) { // Malformed chain; loader should have filtered it. Skip without scheduling. continue; @@ -344,8 +344,8 @@ void WardenWin::RequestData() wd = sWardenCheckMgr->GetWardenDataById(build, *itr); type = wd->Type; - // CUSTOM_CHECK is server-only; emit it on the wire as a MEM_CHECK so the client module accepts it. - uint8 wireType = (type == CUSTOM_CHECK) ? uint8(MEM_CHECK) : type; + // 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) { @@ -356,12 +356,12 @@ void WardenWin::RequestData() buff << uint8(wd->Length); break; } - case CUSTOM_CHECK: + case POINTER_CHAIN_CHECK: { - uint32 addr = _customChainInFlight.currentAddress; - uint8 len = (_customChainInFlight.hopIndex < _customChainInFlight.offsets.size()) + uint32 addr = _pointerChainInFlight.currentAddress; + uint8 len = (_pointerChainInFlight.hopIndex < _pointerChainInFlight.offsets.size()) ? uint8(4) // intermediate pointer hop - : _customChainInFlight.finalLength; // terminal hop + : _pointerChainInFlight.finalLength; // terminal hop buff << uint8(0x00); buff << uint32(addr); buff << uint8(len); @@ -506,30 +506,30 @@ void WardenWin::HandleData(ByteBuffer &buff) sLog.outWarden("RESULT MEM_CHECK passed CheckId %u account Id %u", *itr, _session->GetAccountId()); break; } - case CUSTOM_CHECK: + case POINTER_CHAIN_CHECK: { uint8 status; buff >> status; if (status != 0) { - sLog.outWarden("RESULT CUSTOM_CHECK status not 0x00, CheckId %u account Id %u (hop %zu)", - *itr, _session->GetAccountId(), _customChainInFlight.hopIndex); + sLog.outWarden("RESULT POINTER_CHAIN_CHECK status not 0x00, CheckId %u account Id %u (hop %zu)", + *itr, _session->GetAccountId(), _pointerChainInFlight.hopIndex); checkFailed = *itr; - _customChainActive = false; + _pointerChainActive = false; continue; } - if (!_customChainActive || _customChainInFlight.checkId != *itr) + if (!_pointerChainActive || _pointerChainInFlight.checkId != *itr) { - sLog.outWarden("CUSTOM_CHECK response for CheckId %u with no active chain", *itr); + sLog.outWarden("POINTER_CHAIN_CHECK response for CheckId %u with no active chain", *itr); checkFailed = *itr; - _customChainActive = false; + _pointerChainActive = false; buff.rpos(buff.wpos()); // can't safely know read length; drain to avoid desync return; } - if (_customChainInFlight.hopIndex < _customChainInFlight.offsets.size()) + if (_pointerChainInFlight.hopIndex < _pointerChainInFlight.offsets.size()) { // Intermediate hop: read 4-byte LE pointer, advance chain, await next cycle. uint32 ptr; @@ -538,36 +538,36 @@ void WardenWin::HandleData(ByteBuffer &buff) if (ptr == 0) { - sLog.outWarden("RESULT CUSTOM_CHECK NULL deref at hop %zu, CheckId %u account Id %u", - _customChainInFlight.hopIndex, *itr, _session->GetAccountId()); + sLog.outWarden("RESULT POINTER_CHAIN_CHECK NULL deref at hop %zu, CheckId %u account Id %u", + _pointerChainInFlight.hopIndex, *itr, _session->GetAccountId()); checkFailed = *itr; - _customChainActive = false; + _pointerChainActive = false; continue; } - _customChainInFlight.currentAddress = ptr + _customChainInFlight.offsets[_customChainInFlight.hopIndex]; - ++_customChainInFlight.hopIndex; - sLog.outWarden("CUSTOM_CHECK hop advanced to %zu (next addr 0x%08X), CheckId %u", - _customChainInFlight.hopIndex, _customChainInFlight.currentAddress, *itr); + _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: compare finalLength bytes against expected result. if (memcmp(buff.contents() + buff.rpos(), rs->Result.AsByteArray(0, false), - _customChainInFlight.finalLength) != 0) + _pointerChainInFlight.finalLength) != 0) { - sLog.outWarden("RESULT CUSTOM_CHECK fail CheckId %u account Id %u", + sLog.outWarden("RESULT POINTER_CHAIN_CHECK fail CheckId %u account Id %u", *itr, _session->GetAccountId()); checkFailed = *itr; - buff.rpos(buff.rpos() + _customChainInFlight.finalLength); - _customChainActive = false; + buff.rpos(buff.rpos() + _pointerChainInFlight.finalLength); + _pointerChainActive = false; continue; } - buff.rpos(buff.rpos() + _customChainInFlight.finalLength); - sLog.outWarden("RESULT CUSTOM_CHECK passed CheckId %u account Id %u", + buff.rpos(buff.rpos() + _pointerChainInFlight.finalLength); + sLog.outWarden("RESULT POINTER_CHAIN_CHECK passed CheckId %u account Id %u", *itr, _session->GetAccountId()); - _customChainActive = false; + _pointerChainActive = false; break; } case PAGE_CHECK_A: diff --git a/src/game/Warden/WardenWin.h b/src/game/Warden/WardenWin.h index 0179900f1..9369c3a94 100644 --- a/src/game/Warden/WardenWin.h +++ b/src/game/Warden/WardenWin.h @@ -88,7 +88,7 @@ class WardenWin : public Warden void HandleData(ByteBuffer &buff) override; private: - struct CustomCheckState + struct PointerChainState { uint16 checkId; std::vector offsets; @@ -98,14 +98,14 @@ class WardenWin : public Warden }; static bool ParseChainOffsets(const std::string& str, std::vector& out); - void StartCustomChain(WardenCheck* wd); + void StartPointerChain(WardenCheck* wd); uint32 _serverTicks; std::list _otherChecksTodo; std::list _memChecksTodo; std::list _currentChecks; - CustomCheckState _customChainInFlight; - bool _customChainActive; + PointerChainState _pointerChainInFlight; + bool _pointerChainActive; }; #endif From 4d14198276534be8b5f1dedc85865a64942d78dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 16:26:17 +0000 Subject: [PATCH 3/3] Warden: add signature-detect mode to POINTER_CHAIN_CHECK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired by Krilliac/AdvancedWarden's MEM2_CHECK pattern (fail when bytes match a known cheat signature) and its GAGARIN_CHECK_ID pair (carry a runtime-discovered dynamic address across two checks). Their C++ doesn't drop in (different fork: TrinityCore + boost + different DB schema), but the inverted-match capability is a small generic addition that lets one POINTER_CHAIN_CHECK row express either: - verify-clean — fail when terminal bytes don't match expected - signature detect — fail when terminal bytes DO match expected Encoded as an optional leading '!' on the chain string: '!0x2,0x4' means "2-hop chain in signature-detect mode". '!' is consumed before offset parsing, leaving the rest of the chain syntax unchanged. Result column carries the signature bytes when in detect mode. Adds a fifth example (id 10005) to contrib/warden/pointer_chain_ examples.sql modeling the AdvancedWarden 3rd-party-allocation scan case. Updates the schema-reminder comment block to document the '!' prefix. --- contrib/warden/pointer_chain_examples.sql | 49 ++++++++++++++++++++++- src/game/Warden/WardenWin.cpp | 39 ++++++++++++++---- src/game/Warden/WardenWin.h | 1 + 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/contrib/warden/pointer_chain_examples.sql b/contrib/warden/pointer_chain_examples.sql index 7f381f635..23e347aaf 100644 --- a/contrib/warden/pointer_chain_examples.sql +++ b/contrib/warden/pointer_chain_examples.sql @@ -18,6 +18,13 @@ -- 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`: @@ -153,6 +160,45 @@ VALUES '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 @@ -163,4 +209,5 @@ 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 +-- (10004, 0), -- log only on smoke test +-- (10005, 2); -- ban on confirmed signature match diff --git a/src/game/Warden/WardenWin.cpp b/src/game/Warden/WardenWin.cpp index 5a97b7f42..40f1cc939 100644 --- a/src/game/Warden/WardenWin.cpp +++ b/src/game/Warden/WardenWin.cpp @@ -47,6 +47,7 @@ WardenWin::WardenWin() : Warden(), _serverTicks(0), _pointerChainActive(false) _pointerChainInFlight.hopIndex = 0; _pointerChainInFlight.currentAddress = 0; _pointerChainInFlight.finalLength = 0; + _pointerChainInFlight.invertMatch = false; } WardenWin::~WardenWin() { } @@ -106,8 +107,21 @@ void WardenWin::StartPointerChain(WardenCheck* wd) _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(wd->Str, _pointerChainInFlight.offsets)) + if (!ParseChainOffsets(chain, _pointerChainInFlight.offsets)) { sLog.outWarden("POINTER_CHAIN_CHECK CheckId %u has malformed offset chain '%s'; skipping", wd->CheckId, wd->Str.c_str()); @@ -552,12 +566,20 @@ void WardenWin::HandleData(ByteBuffer &buff) break; } - // Terminal hop: compare finalLength bytes against expected result. - if (memcmp(buff.contents() + buff.rpos(), rs->Result.AsByteArray(0, false), - _pointerChainInFlight.finalLength) != 0) + // 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", - *itr, _session->GetAccountId()); + 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; @@ -565,8 +587,9 @@ void WardenWin::HandleData(ByteBuffer &buff) } buff.rpos(buff.rpos() + _pointerChainInFlight.finalLength); - sLog.outWarden("RESULT POINTER_CHAIN_CHECK passed CheckId %u account Id %u", - *itr, _session->GetAccountId()); + 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; } diff --git a/src/game/Warden/WardenWin.h b/src/game/Warden/WardenWin.h index 9369c3a94..f9723d631 100644 --- a/src/game/Warden/WardenWin.h +++ b/src/game/Warden/WardenWin.h @@ -95,6 +95,7 @@ class WardenWin : public Warden 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);