From 1c76031080a8eddc2a693e2a9f7b0b75bb8d919d Mon Sep 17 00:00:00 2001 From: Krill Date: Sat, 20 Jun 2026 17:56:48 -0500 Subject: [PATCH 1/2] Add server-side anti-cheat framework (movement/physics/spell/item detection + enforcement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a config-gated, server-side anti-cheat / movement-validation framework. OFF by default (AntiCheat.Enable = 0) — fully inert until enabled. Core pieces: - AntiCheatMgr: central scoring manager. Detectors only observe and score via a single RecordViolation() ingress; the manager owns ALL enforcement. Score decays over time and escalates through capped actions (log -> GM alert -> rubberband to last validated position -> kick), with an optional account-level autoban accumulator (escalating ban durations, slow hourly decay). - MovementAnticheat: per-player validator running cheap, event-driven detectors on each movement packet — fly/flag-spoof (water-walk/hover/slow-fall/transport/ swim, aura- or grant-aware), teleport/blink, speed, acceleration gate, vertical climb, root/stun-break, opcode-legality, no-clip (VMap LoS), jump (infinite/ mid-air), fall-damage suppression, packet burst, client-timestamp regression, bot-movement heuristic, and spell-cast timing (GCD bypass + cast spam). - PhysicsValidator: side-effect-free terrain-plausibility stage (float-above / below-surface), invoked only on suspicion to stay off the hot path. Integration: movement validation in the movement opcode handler; spell-injection, item-in-trade, GO-use and NPC interaction-distance hooks; cast-timing hook; server-granted movement flags recorded in the Player Set* setters so the flag-spoof detectors don't fire on legit grants; a rolling latency EWMA fed from CMSG_PING for latency-aware tolerances. GM commands: .anticheat status / report / reload / warn / jail / unjail / delete. Violations persist to character_anticheat_violation; account autoban state to account_anticheat. Time-sync, the spoof/test GM tooling, and the violation-marker visualizer are follow-ups that build additively on this core. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmake/MangosParams.cmake | 2 +- src/game/AntiCheat/AntiCheatDefines.h | 66 +++ src/game/AntiCheat/AntiCheatMgr.cpp | 357 ++++++++++++ src/game/AntiCheat/AntiCheatMgr.h | 160 ++++++ src/game/AntiCheat/MovementAnticheat.cpp | 538 ++++++++++++++++++ src/game/AntiCheat/MovementAnticheat.h | 115 ++++ src/game/AntiCheat/PhysicsValidator.cpp | 65 +++ src/game/AntiCheat/PhysicsValidator.h | 28 + src/game/AntiCheat/sql/account_anticheat.sql | 12 + .../sql/character_anticheat_violation.sql | 22 + src/game/CMakeLists.txt | 6 + src/game/ChatCommands/AntiCheatCommands.cpp | 167 ++++++ src/game/Object/Player.cpp | 61 ++ src/game/Object/Player.h | 9 + src/game/Object/Unit.h | 2 +- src/game/Server/WorldSession.cpp | 31 +- src/game/Server/WorldSession.h | 14 + src/game/Server/WorldSocket.cpp | 1 + src/game/WorldHandlers/Chat.cpp | 13 + src/game/WorldHandlers/Chat.h | 9 + src/game/WorldHandlers/MovementHandler.cpp | 11 + src/game/WorldHandlers/SpellHandler.cpp | 57 +- src/game/WorldHandlers/World.cpp | 53 ++ src/game/WorldHandlers/World.h | 30 + src/mangosd/mangosd.conf.dist.in | 112 ++++ 25 files changed, 1936 insertions(+), 5 deletions(-) create mode 100644 src/game/AntiCheat/AntiCheatDefines.h create mode 100644 src/game/AntiCheat/AntiCheatMgr.cpp create mode 100644 src/game/AntiCheat/AntiCheatMgr.h create mode 100644 src/game/AntiCheat/MovementAnticheat.cpp create mode 100644 src/game/AntiCheat/MovementAnticheat.h create mode 100644 src/game/AntiCheat/PhysicsValidator.cpp create mode 100644 src/game/AntiCheat/PhysicsValidator.h create mode 100644 src/game/AntiCheat/sql/account_anticheat.sql create mode 100644 src/game/AntiCheat/sql/character_anticheat_violation.sql create mode 100644 src/game/ChatCommands/AntiCheatCommands.cpp diff --git a/cmake/MangosParams.cmake b/cmake/MangosParams.cmake index 52d74bce7..549626ca5 100644 --- a/cmake/MangosParams.cmake +++ b/cmake/MangosParams.cmake @@ -1,5 +1,5 @@ set(MANGOS_EXP "CLASSIC") set(MANGOS_PKG "Mangos Zero") -set(MANGOS_WORLD_VER 2026061702) +set(MANGOS_WORLD_VER 2026062002) set(MANGOS_REALM_VER 2026060300) set(MANGOS_AHBOT_VER 2021010100) diff --git a/src/game/AntiCheat/AntiCheatDefines.h b/src/game/AntiCheat/AntiCheatDefines.h new file mode 100644 index 000000000..9a38e6a2a --- /dev/null +++ b/src/game/AntiCheat/AntiCheatDefines.h @@ -0,0 +1,66 @@ +/* + * Anti-Cheat / Movement-Validation framework — shared definitions. + * + * This header carries only enums and small constants so it can be included + * widely without pulling heavy dependencies. + */ + +#ifndef MANGOS_ANTICHEAT_DEFINES_H +#define MANGOS_ANTICHEAT_DEFINES_H + +#include "Common.h" + +// Types of detected violations. Persisted as the `type` column and used as the +// index into the (config-driven) scoring weights. +enum AntiCheatViolationType +{ + AC_VIOLATION_NONE = 0, + AC_VIOLATION_SPEED = 1, // horizontal speed beyond allowed + tolerance + AC_VIOLATION_TELEPORT = 2, // single-packet jump beyond latency-adjusted max + AC_VIOLATION_VERTICAL = 3, // unexplained upward Z movement (no jump/levitate/fly) + AC_VIOLATION_FLAG_CONTRADICT = 4, // movement flags that the server never granted + AC_VIOLATION_PHYSICS = 5, // position implausible vs terrain/liquid/collision + AC_VIOLATION_DESYNC = 6, // latency drift / time-sync anomaly (informational) + AC_VIOLATION_JUMP = 7, // illegal mid-air / infinite jump (re-jump while airborne) + AC_VIOLATION_FALL = 8, // fall-damage suppression (big drop, no FALL_LAND) + AC_VIOLATION_BURST = 9, // abnormal burst of movement packets (flood/timing) + AC_VIOLATION_PACKETTIMING = 10, // client movement timestamp inconsistency + AC_VIOLATION_SPELL = 11, // cast of a spell the player does not have (injection) + AC_VIOLATION_ITEM = 12, // illegitimate item use (e.g. using an item in the trade window) + AC_VIOLATION_INTERACT = 13, // interaction beyond range (remote loot/use/interact attempt) + AC_VIOLATION_BOT = 14, // bot-like movement (snap-to-waypoint + metronomic timing) + + AC_VIOLATION_MAX +}; + +// Escalation actions. Also the meaning of the AntiCheat.Action config ceiling: +// the manager never applies an action above the configured maximum. +enum AntiCheatAction +{ + AC_ACTION_NONE = 0, // disabled + AC_ACTION_LOG = 1, // record only (log + DB) + AC_ACTION_GM_ALERT = 2, // notify online GMs + AC_ACTION_RUBBERBAND = 3, // teleport player back to last validated position + AC_ACTION_KICK = 4 // disconnect the session +}; + +// Result of the physics-plausibility stage. +enum AntiCheatPhysicsResult +{ + AC_PHYS_OK = 0, // position is plausible for the current move state + AC_PHYS_SUSPECT = 1, // borderline; raise confidence but light weight + AC_PHYS_IMPOSSIBLE = 2 // cannot legitimately occupy this position +}; + +// Normalised movement state derived once from MovementInfo flags, so every +// detector reasons over the same interpretation rather than re-reading flags. +enum AntiCheatMoveState +{ + AC_MOVE_GROUND = 0, + AC_MOVE_SWIM = 1, + AC_MOVE_FALL = 2, + AC_MOVE_FLY = 3, + AC_MOVE_TRANSPORT = 4 +}; + +#endif // MANGOS_ANTICHEAT_DEFINES_H diff --git a/src/game/AntiCheat/AntiCheatMgr.cpp b/src/game/AntiCheat/AntiCheatMgr.cpp new file mode 100644 index 000000000..096d6de49 --- /dev/null +++ b/src/game/AntiCheat/AntiCheatMgr.cpp @@ -0,0 +1,357 @@ +/* + * Anti-Cheat / Movement-Validation framework — central manager implementation. + */ + +#include "AntiCheatMgr.h" +#include "MovementAnticheat.h" +#include "Player.h" +#include "World.h" +#include "Log.h" +#include "Timer.h" +#include "Database/DatabaseEnv.h" + +#include +#include + +AntiCheatMgr::AntiCheatMgr() + : m_enabled(false), m_movementEnabled(false), m_physicsEnabled(false), + m_exemptBots(true), m_persist(true), m_exemptGmLevel(1), + m_actionCeiling(AC_ACTION_LOG), m_speedTolerancePct(110), + m_teleportDistance(50), m_scoreWarn(30), m_scoreRubberband(60), + m_scoreKick(120), m_decayPerSec(2), + m_autobanEnable(false), m_autobanKickPoints(10), m_autobanThreshold(30), + m_autobanDecayPerHour(1) +{ + m_autobanDur[0] = 86400; m_autobanDur[1] = 604800; m_autobanDur[2] = 0; +} + +void AntiCheatMgr::Init() +{ + LoadConfig(); + if (m_autobanEnable) + LoadAccounts(); + if (m_enabled) + sLog.outString("AntiCheat: enabled (action ceiling=%u, movement=%u, physics=%u)", + m_actionCeiling, MovementEnabled() ? 1 : 0, PhysicsEnabled() ? 1 : 0); + else + sLog.outString("AntiCheat: disabled (AntiCheat.Enable = 0)"); +} + +void AntiCheatMgr::LoadConfig() +{ + m_enabled = sWorld.getConfig(CONFIG_BOOL_ANTICHEAT_ENABLE); + m_movementEnabled = sWorld.getConfig(CONFIG_BOOL_ANTICHEAT_MOVEMENT); + m_physicsEnabled = sWorld.getConfig(CONFIG_BOOL_ANTICHEAT_PHYSICS); + m_exemptBots = sWorld.getConfig(CONFIG_BOOL_ANTICHEAT_EXEMPT_BOTS); + m_persist = sWorld.getConfig(CONFIG_BOOL_ANTICHEAT_PERSIST); + m_exemptGmLevel = sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_EXEMPT_GM); + m_actionCeiling = sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_ACTION); + m_speedTolerancePct = sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_SPEED_TOL); + m_teleportDistance = sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_TELE_DIST); + m_scoreWarn = sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_SCORE_WARN); + m_scoreRubberband = sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_SCORE_RUBBER); + m_scoreKick = sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_SCORE_KICK); + m_decayPerSec = sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_DECAY); + + m_autobanEnable = sWorld.getConfig(CONFIG_BOOL_AC_AUTOBAN_ENABLE); + m_autobanKickPoints = sWorld.getConfig(CONFIG_UINT32_AC_AUTOBAN_KICKPOINTS); + m_autobanThreshold = sWorld.getConfig(CONFIG_UINT32_AC_AUTOBAN_THRESHOLD); + m_autobanDecayPerHour = sWorld.getConfig(CONFIG_UINT32_AC_AUTOBAN_DECAY_PER_HOUR); + m_autobanDur[0] = sWorld.getConfig(CONFIG_UINT32_AC_AUTOBAN_DUR1); + m_autobanDur[1] = sWorld.getConfig(CONFIG_UINT32_AC_AUTOBAN_DUR2); + m_autobanDur[2] = sWorld.getConfig(CONFIG_UINT32_AC_AUTOBAN_DUR3); +} + +bool AntiCheatMgr::IsExempt(Player* player) const +{ + if (!player) + return true; + + // GMs at or above the configured security level are not validated. + // ExemptGMLevel == 0 means "exempt nobody by level" (every account is >= 0, + // so without this guard a value of 0 would exempt everyone). + if (m_exemptGmLevel > 0 && player->GetSession() && + player->GetSession()->GetSecurity() >= (AccountTypes)m_exemptGmLevel) + return true; + + // A GM with .gm on is also exempt regardless of level. + if (player->isGameMaster()) + return true; + +#ifdef ENABLE_PLAYERBOTS + if (m_exemptBots && player->GetPlayerbotAI()) + return true; +#endif + + return false; +} + +float AntiCheatMgr::DecayedScore(ScoreState& s, uint32 nowMS) const +{ + if (s.lastUpdateMS && m_decayPerSec) + { + uint32 elapsedMS = getMSTimeDiff(s.lastUpdateMS, nowMS); + float decay = (float(elapsedMS) / 1000.0f) * float(m_decayPerSec); + s.score = s.score > decay ? s.score - decay : 0.0f; + } + s.lastUpdateMS = nowMS; + return s.score; +} + +void AntiCheatMgr::RecordViolation(Player* player, AntiCheatViolationType type, + float weight, AntiCheatContext const& ctx) +{ + if (!player) + return; + if (!m_enabled || IsExempt(player)) + return; + + // Clamp weight defensively so a single buggy detector can't spike the score. + if (weight < 0.0f) weight = 0.0f; + if (weight > 100.0f) weight = 100.0f; + + uint32 nowMS = getMSTime(); + uint32 lowGuid = player->GetGUIDLow(); + float score; + { + std::lock_guard guard(m_lock); + ScoreState& s = m_scores[lowGuid]; + DecayedScore(s, nowMS); + s.score += weight; + ++s.violations; + score = s.score; + } + + if (m_persist) + Persist(player, type, score, ctx); + + Apply(player, score, type, ctx); +} + +void AntiCheatMgr::Apply(Player* player, float score, AntiCheatViolationType type, + AntiCheatContext const& ctx) +{ + // Highest warranted action, capped by the configured ceiling. This is the + // ONLY place a countermeasure is applied; detectors never punish directly. + if (m_actionCeiling >= AC_ACTION_KICK && score >= float(m_scoreKick)) + { + sLog.outError("AntiCheat: KICK guid=%u type=%u score=%.0f map=%u pos=(%.1f,%.1f,%.1f) %s", + player->GetGUIDLow(), type, score, ctx.mapId, ctx.x, ctx.y, ctx.z, ctx.detail); + AlertGMs(player, type, score, ctx); + // Anti-gaming autoban: count this kick against the account first. + if (m_autobanEnable) + AccumulateKick(player); + if (player->GetSession()) + player->GetSession()->KickPlayer(); + return; + } + + if (m_actionCeiling >= AC_ACTION_RUBBERBAND && score >= float(m_scoreRubberband)) + { + // Rubberband to the last server-validated position tracked by the + // per-player movement validator. + MovementAnticheat* mac = player->GetMovementAnticheat(); + if (mac && mac->HasValid()) + { + player->NearTeleportTo(mac->ValidX(), mac->ValidY(), mac->ValidZ(), mac->ValidO()); + sLog.outDetail("AntiCheat: rubberband guid=%u score=%.0f -> (%.1f,%.1f,%.1f)", + player->GetGUIDLow(), score, mac->ValidX(), mac->ValidY(), mac->ValidZ()); + } + AlertGMs(player, type, score, ctx); + return; + } + + if (m_actionCeiling >= AC_ACTION_GM_ALERT && score >= float(m_scoreWarn)) + { + AlertGMs(player, type, score, ctx); + return; + } + + if (m_actionCeiling >= AC_ACTION_LOG) + { + sLog.outDetail("AntiCheat: log guid=%u type=%u score=%.0f map=%u pos=(%.1f,%.1f,%.1f) speed=%.1f lat=%u %s", + player->GetGUIDLow(), type, score, ctx.mapId, ctx.x, ctx.y, ctx.z, + ctx.speed, ctx.latency, ctx.detail); + } +} + +void AntiCheatMgr::AlertGMs(Player* player, AntiCheatViolationType type, float score, + AntiCheatContext const& ctx) +{ + // Log to the server console/log. Broadcasting to online GMs in-game is a + // planned enhancement (needs the GM-notify helper) — kept out for now so this + // stays low-risk. + sLog.outBasic("AntiCheat: ALERT guid=%u type=%u score=%.0f map=%u pos=(%.1f,%.1f,%.1f) speed=%.1f lat=%u %s", + player->GetGUIDLow(), type, score, ctx.mapId, ctx.x, ctx.y, ctx.z, + ctx.speed, ctx.latency, ctx.detail); +} + +void AntiCheatMgr::Persist(Player* player, AntiCheatViolationType type, float score, + AntiCheatContext const& ctx) +{ + uint32 account = player->GetSession() ? player->GetSession()->GetAccountId() : 0; + // detail is always a static string literal from the detectors — safe to embed. + CharacterDatabase.PExecute( + "INSERT INTO `character_anticheat_violation` " + "(`guid`,`account`,`type`,`score`,`map`,`x`,`y`,`z`,`speed`,`latency`,`detail`) " + "VALUES (%u,%u,%u,%u,%u,%f,%f,%f,%f,%u,'%s')", + player->GetGUIDLow(), account, uint32(type), uint32(score), ctx.mapId, + ctx.x, ctx.y, ctx.z, ctx.speed, ctx.latency, ctx.detail ? ctx.detail : ""); +} + +void AntiCheatMgr::Update(uint32 /*diff*/) +{ + if (!m_enabled) + return; + + // Apply any queued autobans here (world thread): AccumulateKick runs on the + // map thread, but BanAccount touches the session list, so it is deferred. + std::vector bans; + { + std::lock_guard guard(m_lock); + if (!m_pendingBans.empty()) + bans.swap(m_pendingBans); + } + for (std::vector::const_iterator it = bans.begin(); it != bans.end(); ++it) + { + BanReturn r = sWorld.BanAccount(BAN_CHARACTER, it->charName, it->durationSecs, it->reason, "AntiCheat"); + sLog.outError("AntiCheat: AUTOBAN account of '%s' for %us (%s) -> result %u", + it->charName.c_str(), it->durationSecs, it->reason.c_str(), uint32(r)); + } + + // Prune fully-decayed idle entries so the score map doesn't grow unbounded + // for players who never offend again. + uint32 nowMS = getMSTime(); + std::lock_guard guard(m_lock); + for (std::map::iterator it = m_scores.begin(); it != m_scores.end();) + { + if (DecayedScore(it->second, nowMS) <= 0.0f) + m_scores.erase(it++); + else + ++it; + } +} + +float AntiCheatMgr::DecayedKickScore(AccountState& s, uint32 nowSec) const +{ + if (s.lastUpdate && m_autobanDecayPerHour && nowSec > s.lastUpdate) + { + float hours = float(nowSec - s.lastUpdate) / 3600.0f; + float decay = hours * float(m_autobanDecayPerHour); + s.kickScore = s.kickScore > decay ? s.kickScore - decay : 0.0f; + } + s.lastUpdate = nowSec; + return s.kickScore; +} + +void AntiCheatMgr::AccumulateKick(Player* player) +{ + if (!player || !player->GetSession()) + return; + + uint32 accountId = player->GetSession()->GetAccountId(); + uint32 nowSec = uint32(sWorld.GetGameTime()); + std::string charName = player->GetName(); + + bool queueBan = false; + uint32 duration = 0; + AccountState snapshot; + { + std::lock_guard guard(m_lock); + AccountState& s = m_accounts[accountId]; + DecayedKickScore(s, nowSec); + s.kickScore += float(m_autobanKickPoints); + + if (s.kickScore >= float(m_autobanThreshold)) + { + // Escalating duration by prior ban count (last tier is sticky). + uint32 tier = s.banCount < 3 ? s.banCount : 2; + duration = m_autobanDur[tier]; + ++s.banCount; + s.kickScore = 0.0f; // reset accumulator after a ban + queueBan = true; + } + snapshot = s; + + if (queueBan) + { + PendingBan pb; + pb.charName = charName; + pb.durationSecs = duration; + pb.reason = "Automated: repeated anti-cheat kicks"; + m_pendingBans.push_back(pb); + } + } + + PersistAccount(accountId, snapshot); + + if (queueBan) + sLog.outError("AntiCheat: account %u queued for autoban (%us) after repeated kicks (char '%s')", + accountId, duration, charName.c_str()); +} + +void AntiCheatMgr::PersistAccount(uint32 accountId, AccountState const& s) +{ + // Account-level aggregate lives in the realm DB (spans characters/realms). + LoginDatabase.PExecute( + "REPLACE INTO `account_anticheat` (`account`,`kick_score`,`ban_count`,`last_update`) " + "VALUES (%u, %f, %u, %u)", + accountId, s.kickScore, s.banCount, s.lastUpdate); +} + +void AntiCheatMgr::LoadAccounts() +{ + std::lock_guard guard(m_lock); + m_accounts.clear(); + QueryResult* result = LoginDatabase.Query( + "SELECT `account`,`kick_score`,`ban_count`,`last_update` FROM `account_anticheat`"); + if (!result) + return; + do + { + Field* f = result->Fetch(); + AccountState s; + s.kickScore = f[1].GetFloat(); + s.banCount = f[2].GetUInt32(); + s.lastUpdate = f[3].GetUInt32(); + m_accounts[f[0].GetUInt32()] = s; + } + while (result->NextRow()); + delete result; + sLog.outString("AntiCheat: loaded %u account autoban records.", uint32(m_accounts.size())); +} + +void AntiCheatMgr::RemovePlayer(uint32 lowGuid) +{ + std::lock_guard guard(m_lock); + m_scores.erase(lowGuid); +} + +void AntiCheatMgr::BuildStatus(Player* target, std::string& out) +{ + if (!target) + { + out = "AntiCheat: no target."; + return; + } + + uint32 nowMS = getMSTime(); + float score = 0.0f; + uint32 violations = 0; + { + std::lock_guard guard(m_lock); + std::map::iterator it = m_scores.find(target->GetGUIDLow()); + if (it != m_scores.end()) + { + score = DecayedScore(it->second, nowMS); + violations = it->second.violations; + } + } + + char buf[256]; + snprintf(buf, sizeof(buf), + "AntiCheat status for %s: score=%.0f, lifetime violations=%u, %s", + target->GetName(), score, violations, + m_enabled ? "framework ENABLED" : "framework disabled"); + out = buf; +} diff --git a/src/game/AntiCheat/AntiCheatMgr.h b/src/game/AntiCheat/AntiCheatMgr.h new file mode 100644 index 000000000..66d046fd7 --- /dev/null +++ b/src/game/AntiCheat/AntiCheatMgr.h @@ -0,0 +1,160 @@ +/* + * Anti-Cheat / Movement-Validation framework — central manager. + * + * The manager is the single ingress for all detected violations and the ONLY + * place countermeasures (rubberband / kick) are applied. Detectors never punish + * directly: they call RecordViolation() and the manager decides. + * + * Per-player score is held here in a guid-keyed map rather than on the Player + * object, which keeps the manager self-contained and independently testable. + */ + +#ifndef MANGOS_ANTICHEATMGR_H +#define MANGOS_ANTICHEATMGR_H + +#include "Common.h" +#include "AntiCheatDefines.h" + +#include +#include +#include +#include + +class Player; + +// Snapshot of where/what when a violation fired. Cheap to build; passed by const ref. +struct AntiCheatContext +{ + uint32 mapId; + float x, y, z; + float speed; // observed horizontal speed (yd/s), 0 if N/A + uint32 latency; // session EWMA latency in ms at time of event + const char* detail; // short static description, never user input + + AntiCheatContext() + : mapId(0), x(0.f), y(0.f), z(0.f), speed(0.f), latency(0), detail("") {} +}; + +class AntiCheatMgr +{ + public: + static AntiCheatMgr* instance() + { + static AntiCheatMgr inst; + return &inst; + } + + // Read config snapshot + open log channel. Called from World startup. + void Init(); + // Re-read config (on .reload config). Safe to call repeatedly. + void LoadConfig(); + + bool IsEnabled() const { return m_enabled; } + + // True if this player should not be validated (GM, exempt bot, etc.). + bool IsExempt(Player* player) const; + + // The single ingress for detectors. Adds weighted score, persists, + // and evaluates escalation. weight is clamped to a sane range. + void RecordViolation(Player* player, AntiCheatViolationType type, + float weight, AntiCheatContext const& ctx); + + // World-tick maintenance: prune idle score entries + apply queued autobans. + void Update(uint32 diff); + + // Drop per-player state on logout so memory and scores don't leak. + void RemovePlayer(uint32 lowGuid); + + // GM review: append a human-readable status line for target (or all). + void BuildStatus(Player* target, std::string& out); + + // Config getters (cached snapshot). + uint32 GetSpeedTolerancePct() const { return m_speedTolerancePct; } + uint32 GetTeleportDistance() const { return m_teleportDistance; } + bool MovementEnabled() const { return m_enabled && m_movementEnabled; } + bool PhysicsEnabled() const { return m_enabled && m_physicsEnabled; } + + private: + AntiCheatMgr(); + ~AntiCheatMgr() {} + AntiCheatMgr(AntiCheatMgr const&); + AntiCheatMgr& operator=(AntiCheatMgr const&); + + struct ScoreState + { + float score; + uint32 lastUpdateMS; + uint32 violations; + ScoreState() : score(0.f), lastUpdateMS(0), violations(0) {} + }; + + // Per-account anti-gaming state for autoban. Decays slowly (hours) using + // wall-clock time so it survives restarts and so spacing offences out + // does not evade the ban. + struct AccountState + { + float kickScore; + uint32 banCount; + uint32 lastUpdate; // unix seconds (sWorld.GetGameTime) + AccountState() : kickScore(0.f), banCount(0), lastUpdate(0) {} + }; + + // A ban decided on a worker/map thread, applied later on the world thread. + struct PendingBan + { + std::string charName; // BAN_CHARACTER bans the owning account + uint32 durationSecs; // 0 = permanent + std::string reason; + }; + + // Lazily decay the score to "now" using the configured decay rate. + float DecayedScore(ScoreState& s, uint32 nowMS) const; + + // Apply the highest escalation the (decayed) score warrants, capped by + // the configured action ceiling. The ONLY place punishment happens. + void Apply(Player* player, float score, AntiCheatViolationType type, + AntiCheatContext const& ctx); + + void Persist(Player* player, AntiCheatViolationType type, float score, + AntiCheatContext const& ctx); + void AlertGMs(Player* player, AntiCheatViolationType type, float score, + AntiCheatContext const& ctx); + + // Anti-gaming autoban: accumulate a kick against the player's account and, + // if the decayed account score crosses the threshold, queue an escalating + // ban (applied on the world thread in Update()). Called from Apply() on KICK. + void AccumulateKick(Player* player); + float DecayedKickScore(AccountState& s, uint32 nowSec) const; + void LoadAccounts(); + void PersistAccount(uint32 accountId, AccountState const& s); + + bool m_enabled; + bool m_movementEnabled; + bool m_physicsEnabled; + bool m_exemptBots; + bool m_persist; + uint32 m_exemptGmLevel; + uint32 m_actionCeiling; // AntiCheatAction + uint32 m_speedTolerancePct; + uint32 m_teleportDistance; + uint32 m_scoreWarn; + uint32 m_scoreRubberband; + uint32 m_scoreKick; + uint32 m_decayPerSec; + + // Autoban config + bool m_autobanEnable; + uint32 m_autobanKickPoints; + uint32 m_autobanThreshold; + uint32 m_autobanDecayPerHour; + uint32 m_autobanDur[3]; + + std::map m_scores; // keyed by character low-guid + std::map m_accounts; // keyed by account id (autoban) + std::vector m_pendingBans; + std::mutex m_lock; +}; + +#define sAntiCheatMgr AntiCheatMgr::instance() + +#endif // MANGOS_ANTICHEATMGR_H diff --git a/src/game/AntiCheat/MovementAnticheat.cpp b/src/game/AntiCheat/MovementAnticheat.cpp new file mode 100644 index 000000000..7ab414733 --- /dev/null +++ b/src/game/AntiCheat/MovementAnticheat.cpp @@ -0,0 +1,538 @@ +/* + * Anti-Cheat / Movement-Validation framework — per-player movement validator. + */ + +#include "MovementAnticheat.h" +#include "AntiCheatMgr.h" +#include "PhysicsValidator.h" +#include "Player.h" +#include "World.h" +#include "Unit.h" +#include "SpellAuraDefines.h" +#include "Map.h" +#include "Opcodes.h" +#include "Timer.h" +#include "Log.h" + +#include + +namespace +{ + const float VERT_CLIMB_SUSPECT = 5.0f; // yd upward in one packet on ground + const float SPEED_SLACK_YD = 2.0f; // constant distance fudge per packet + const uint32 GAP_RESET_MS = 3000; // packet gap implying load/teleport + const float FALL_SUPPRESS_YD = 20.0f; // drop beyond this should incur fall damage + const uint32 BURST_PER_SEC = 50; // movement packets/sec beyond this = burst + const uint32 CLIENT_TIME_BACK_MS = 500; // client timestamp regression tolerance + const uint32 CAST_WINDOW_MS = 1000; // cast-spam counting window + const uint32 CAST_GCD_SLACK_MS = 150; // tolerance below GCD on top of latency + const float NOCLIP_MIN_STEP = 4.0f; // min ground step (yd) to run the LoS no-clip test + + // Active locomotion-START opcodes — illegal to issue while rooted/stunned (a + // legit client suppresses them in that state). Stop/heartbeat/turn opcodes are + // allowed (turning in place is fine while rooted). + bool IsActiveMoveStart(uint16 op) + { + switch (op) + { + case MSG_MOVE_START_FORWARD: + case MSG_MOVE_START_BACKWARD: + case MSG_MOVE_START_STRAFE_LEFT: + case MSG_MOVE_START_STRAFE_RIGHT: + case MSG_MOVE_START_SWIM: + case MSG_MOVE_JUMP: + return true; + default: + return false; + } + } +} + +MovementAnticheat::MovementAnticheat(Player* owner) + : m_player(owner), m_hasLast(false), m_trustNext(false), + m_lastX(0.f), m_lastY(0.f), m_lastZ(0.f), m_lastO(0.f), + m_lastMS(0), m_lastFlags(0), + m_hasValid(false), m_validX(0.f), m_validY(0.f), m_validZ(0.f), m_validO(0.f), + m_airborne(false), m_fallApexZ(0.f), + m_burstWinStartMS(0), m_burstCount(0), m_lastClientTime(0), m_hasClientTime(false), + m_hasLastCast(false), m_lastCastMS(0), m_lastCastGcd(0), + m_castWinStartMS(0), m_castCount(0), + m_hasKin(false), m_lastSpeed(0.f), + m_grantedFlags(0), + m_botWinStartMS(0), m_botSamples(0), m_botCleanCycles(0), m_botRunDist(0.f), + m_botHasHeading(false), m_botLastHeading(0.f), + m_botHasPkt(false), m_botLastPktMS(0), m_botIntervalN(0), m_botIntMean(0.f), m_botIntM2(0.f) +{ +} + +AntiCheatMoveState MovementAnticheat::NormalizeState(MovementInfo const& mi) const +{ + if (mi.HasMovementFlag(MOVEFLAG_ONTRANSPORT)) + return AC_MOVE_TRANSPORT; + if (mi.HasMovementFlag(MOVEFLAG_SWIMMING)) + return AC_MOVE_SWIM; + if (mi.HasMovementFlag(MovementFlags(MOVEFLAG_FALLING | MOVEFLAG_FALLINGFAR))) + return AC_MOVE_FALL; + if (mi.HasMovementFlag(MovementFlags(MOVEFLAG_FLYING | MOVEFLAG_CAN_FLY | MOVEFLAG_LEVITATING))) + return AC_MOVE_FLY; + return AC_MOVE_GROUND; +} + +void MovementAnticheat::HandlePositionUpdate(uint16 opcode, MovementInfo const& mi) +{ + if (!m_player || !sAntiCheatMgr->MovementEnabled()) + return; + + uint32 nowMS = getMSTime(); + Position const* pos = mi.GetPos(); + AntiCheatMoveState state = NormalizeState(mi); + + // (Re)establish baseline on first packet, after a server relocation, after a + // long gap (loading screen / teleport), or while on a taxi spline. + if (!m_hasLast || m_trustNext || + getMSTimeDiff(m_lastMS, nowMS) > GAP_RESET_MS || m_player->IsTaxiFlying()) + { + m_trustNext = false; + m_hasLast = true; + m_lastX = pos->x; m_lastY = pos->y; m_lastZ = pos->z; m_lastO = pos->o; + m_lastMS = nowMS; m_lastFlags = mi.GetMovementFlags(); + m_hasValid = true; + m_validX = pos->x; m_validY = pos->y; m_validZ = pos->z; m_validO = pos->o; + m_airborne = (state == AC_MOVE_FALL); + m_fallApexZ = pos->z; + m_hasClientTime = false; + m_burstWinStartMS = nowMS; + m_burstCount = 0; + return; + } + + // --- Detector: movement-packet burst (flood / timing manipulation) --- + if (getMSTimeDiff(m_burstWinStartMS, nowMS) >= 1000) + { + m_burstWinStartMS = nowMS; + m_burstCount = 0; + } + ++m_burstCount; + if (m_burstCount == BURST_PER_SEC + 1) // fire once when first exceeding the cap + { + AntiCheatContext bctx; + bctx.mapId = m_player->GetMapId(); + bctx.x = pos->x; bctx.y = pos->y; bctx.z = pos->z; + bctx.latency = m_player->GetSession() ? m_player->GetSession()->GetLatencyEWMA() : 0; + bctx.detail = "movement packet burst"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_BURST, 15.0f, bctx); + } + + // --- Detector: client movement-timestamp regression --- + { + uint32 ct = mi.GetTime(); + if (m_hasClientTime) + { + if (m_lastClientTime > ct && (m_lastClientTime - ct) > CLIENT_TIME_BACK_MS) + { + AntiCheatContext tctx; + tctx.mapId = m_player->GetMapId(); + tctx.x = pos->x; tctx.y = pos->y; tctx.z = pos->z; + tctx.latency = m_player->GetSession() ? m_player->GetSession()->GetLatencyEWMA() : 0; + tctx.detail = "client timestamp regression"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_PACKETTIMING, 10.0f, tctx); + } + } + m_lastClientTime = ct; + m_hasClientTime = true; + } + + uint32 dtMS = getMSTimeDiff(m_lastMS, nowMS); + if (dtMS == 0) + dtMS = 1; + float dtSec = float(dtMS) / 1000.0f; + + float dx = pos->x - m_lastX; + float dy = pos->y - m_lastY; + float dz = pos->z - m_lastZ; + float horiz = sqrtf(dx * dx + dy * dy); + + uint32 latency = m_player->GetSession() ? m_player->GetSession()->GetLatencyEWMA() : 0; + + // Choose the relevant speed for the current state. + UnitMoveType mtype = MOVE_RUN; + if (state == AC_MOVE_SWIM) + mtype = MOVE_SWIM; + else if (mi.HasMovementFlag(MOVEFLAG_WALK_MODE)) + mtype = MOVE_WALK; + float allowed = m_player->GetSpeed(mtype); + + AntiCheatContext ctx; + ctx.mapId = m_player->GetMapId(); + ctx.x = pos->x; ctx.y = pos->y; ctx.z = pos->z; + ctx.speed = horiz / dtSec; + ctx.latency = latency; + + bool cheapTrip = false; + + // --- Detector: flag contradiction (vanilla players never legitimately fly, + // unless the server granted it e.g. via GM tooling). --- + if (mi.HasMovementFlag(MovementFlags(MOVEFLAG_FLYING | MOVEFLAG_CAN_FLY)) && + !(m_grantedFlags & (MOVEFLAG_FLYING | MOVEFLAG_CAN_FLY))) + { + ctx.detail = "fly movement flag set"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_FLAG_CONTRADICT, 40.0f, ctx); + cheapTrip = true; + } + + // --- Detectors: movement-flag spoofing (the client asserts a capability flag + // it has no aura/grant to back). Legit if a backing aura OR a server grant is + // present; only judged for the living player. --- + if (m_player->IsAlive()) + { + if (mi.HasMovementFlag(MOVEFLAG_WATERWALKING) && !(m_grantedFlags & MOVEFLAG_WATERWALKING) && + !m_player->HasAuraType(SPELL_AURA_WATER_WALK)) + { + ctx.detail = "water-walk flag without aura"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_FLAG_CONTRADICT, 25.0f, ctx); + cheapTrip = true; + } + if (mi.HasMovementFlag(MOVEFLAG_HOVER) && !(m_grantedFlags & MOVEFLAG_HOVER) && + !m_player->HasAuraType(SPELL_AURA_HOVER)) + { + ctx.detail = "hover flag without aura"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_FLAG_CONTRADICT, 25.0f, ctx); + cheapTrip = true; + } + // Rogue "Safe Fall" is a class passive (no feather-fall aura) — excluded. + if (mi.HasMovementFlag(MOVEFLAG_SAFE_FALL) && !(m_grantedFlags & MOVEFLAG_SAFE_FALL) && + !m_player->HasAuraType(SPELL_AURA_FEATHER_FALL) && m_player->getClass() != CLASS_ROGUE) + { + ctx.detail = "slow-fall flag without aura"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_FLAG_CONTRADICT, 15.0f, ctx); + } + } + + // --- Detector: transport-flag spoof (claims ONTRANSPORT with no transport) — + // closes the bypass where the speed/teleport detectors skip transport state. --- + if (state == AC_MOVE_TRANSPORT && !m_player->GetTransport()) + { + ctx.detail = "transport flag without transport (bypass)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_FLAG_CONTRADICT, 20.0f, ctx); + } + + // --- Detector: swim-flag spoof (swimming while not in liquid) --- + if (state == AC_MOVE_SWIM && !m_player->IsInWater()) + { + ctx.detail = "swim flag while not in water"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_FLAG_CONTRADICT, 20.0f, ctx); + } + + // --- Detector: teleport / blink (single-packet displacement) --- + float teleMax = float(sAntiCheatMgr->GetTeleportDistance()) + + allowed * (float(latency) / 1000.0f); + if (state != AC_MOVE_TRANSPORT && !m_player->IsTaxiFlying() && horiz > teleMax) + { + ctx.detail = "teleport/blink jump"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_TELEPORT, 25.0f, ctx); + cheapTrip = true; + } + else if (state != AC_MOVE_TRANSPORT) + { + // --- Detector: speed (distance vs time, latency-tolerant) --- + float tol = float(sAntiCheatMgr->GetSpeedTolerancePct()) / 100.0f; + float expectMax = allowed * tol * dtSec + + allowed * (float(latency) / 1000.0f) + SPEED_SLACK_YD; + if (horiz > expectMax * 1.05f && allowed > 0.0f) + { + float ratio = horiz / (expectMax > 0.01f ? expectMax : 0.01f); + float weight = (ratio - 1.0f) * 50.0f; + if (weight < 5.0f) weight = 5.0f; + if (weight > 25.0f) weight = 25.0f; + ctx.detail = "speed over allowed"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_SPEED, weight, ctx); + cheapTrip = true; + } + } + + // --- Detector: acceleration / velocity-delta gate (config-gated, OFF by + // default — FP-prone). Catches a sudden implausible speed increase to a real + // speed within one packet (instant 0->fast stutter / oscillating speedhacks + // that average legitimately) that the steady-state speed check misses. Only on + // ground, not after teleport, not when another detector already tripped. --- + if (sWorld.getConfig(CONFIG_BOOL_ANTICHEAT_ACCEL_CHECK) && m_hasKin && !cheapTrip && + !m_trustNext && state == AC_MOVE_GROUND && allowed > 0.0f) + { + float dv = ctx.speed - m_lastSpeed; // accelerating only + if (dv > 0.0f) + { + float accel = dv / dtSec; + float cap = allowed * float(sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_ACCEL_MULT)); + if (accel > cap && ctx.speed > allowed * 0.5f) + { + float ratio = accel / (cap > 0.01f ? cap : 0.01f); + float weight = (ratio - 1.0f) * 20.0f; + if (weight < 5.0f) weight = 5.0f; + if (weight > 25.0f) weight = 25.0f; + ctx.detail = "implausible acceleration (velocity delta)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_SPEED, weight, ctx); + } + } + } + + // --- Detector: opcode legality by state — an active locomotion-START command + // issued while the player cannot move (rooted/stunned) is illegal; a legit + // client never sends one in that state. --- + if (IsActiveMoveStart(opcode) && m_player->hasUnitState(UNIT_STAT_ROOT | UNIT_STAT_STUNNED)) + { + ctx.detail = "move-start opcode while rooted/stunned"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_PHYSICS, 15.0f, ctx); + } + + // --- Detector: movement while rooted (root-break) --- + // A rooted unit may turn/jump in place but never translate horizontally. A + // clear horizontal step while UNIT_STAT_ROOT is set is a root-break hack. + // Restricted to grounded, non-flagged packets to exclude knockback/transport. + if (state == AC_MOVE_GROUND && horiz > 3.0f && !cheapTrip && + m_player->hasUnitState(UNIT_STAT_ROOT | UNIT_STAT_STUNNED)) + { + // Fear/confuse are server-driven movement, so only root/stun are judged. + ctx.detail = "horizontal movement while rooted/stunned"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_PHYSICS, 20.0f, ctx); + cheapTrip = true; + } + + // --- Detector: unexplained vertical climb on the ground --- + if (state == AC_MOVE_GROUND && dz > VERT_CLIMB_SUSPECT && horiz < dz && + !mi.HasMovementFlag(MovementFlags(MOVEFLAG_FALLING | MOVEFLAG_FALLINGFAR))) + { + ctx.detail = "vertical climb without cause"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_VERTICAL, 15.0f, ctx); + cheapTrip = true; + } + + // --- Physics plausibility (moderate cost): only on suspicion or vertical move --- + if (sAntiCheatMgr->PhysicsEnabled() && state == AC_MOVE_GROUND && + (cheapTrip || fabs(dz) > 2.0f)) + { + const char* reason = NULL; + AntiCheatPhysicsResult r = PhysicsValidator::Validate(m_player, state, mi, &reason); + if (r == AC_PHYS_IMPOSSIBLE) + { + ctx.detail = reason ? reason : "physics impossible"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_PHYSICS, 30.0f, ctx); + } + else if (r == AC_PHYS_SUSPECT) + { + ctx.detail = reason ? reason : "physics suspect"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_PHYSICS, 10.0f, ctx); + } + } + + // --- Detector: wall-clip / no-clip (moved through solid geometry) --- + // A legitimate step keeps line-of-sight between the previous and the new + // position; a sizable ground step with NO LoS between them means the client + // walked through world geometry. VMap query, so gated behind the physics + // module + a step floor (bounds cost and corner false-positives). Skipped + // right after a server relocation and when another detector already tripped. + if (sAntiCheatMgr->PhysicsEnabled() && m_hasLast && !m_trustNext && !cheapTrip && + state == AC_MOVE_GROUND && horiz > NOCLIP_MIN_STEP) + { + Map* map = m_player->GetMap(); + if (map && !map->IsInLineOfSight(m_lastX, m_lastY, m_lastZ + 1.5f, + pos->x, pos->y, pos->z + 1.5f)) + { + ctx.detail = "moved through geometry (no-clip)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_PHYSICS, 15.0f, ctx); + cheapTrip = true; + } + } + + // --- Detectors: illegal jump (infinite/double jump) + fall-damage suppression --- + if (opcode == MSG_MOVE_JUMP) + { + // A jump issued while already airborne (no FALL_LAND since the last jump + // or fall) is an illegal mid-air / infinite jump. + if (m_airborne) + { + ctx.detail = "mid-air / infinite jump"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_JUMP, 30.0f, ctx); + cheapTrip = true; + } + m_airborne = true; + m_fallApexZ = pos->z; + } + else if (state == AC_MOVE_FALL) + { + // In a fall/jump arc: track the episode and its apex. + m_airborne = true; + if (pos->z > m_fallApexZ) + m_fallApexZ = pos->z; + } + else if (m_airborne) + { + // Episode ended this packet. A legit landing sends MSG_MOVE_FALL_LAND and + // the core applies fall damage. Becoming grounded WITHOUT a FALL_LAND after + // a damaging drop (and not into water) means the client suppressed fall damage. + float drop = m_fallApexZ - pos->z; + if (opcode != MSG_MOVE_FALL_LAND && state != AC_MOVE_SWIM && drop >= FALL_SUPPRESS_YD) + { + ctx.detail = "fall-damage suppressed (no FALL_LAND)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_FALL, 25.0f, ctx); + cheapTrip = true; + } + m_airborne = false; + m_fallApexZ = pos->z; + } + else + { + // Grounded: keep the apex tracking current so the next fall measures from here. + m_fallApexZ = pos->z; + } + + // --- Detector: bot-like movement (snap-to-waypoint + metronomic timing) --- + // Classic third-party bots path in straight lines between waypoints, turning + // SHARPLY at each node, on a metronomic clock. Humans wander and have jittery + // timing. Accumulated over a 30s window so a one-off human sharp turn (e.g. a + // both-mouse-button look-behind flip) can't trip it — only a sustained, repeated, + // regular pattern does. Config-gated (heuristic), OFF by default. + if (sWorld.getConfig(CONFIG_BOOL_ANTICHEAT_BOT_DETECT) && state != AC_MOVE_TRANSPORT) + { + if (m_botWinStartMS == 0) + m_botWinStartMS = nowMS; + + // Inter-packet timing regularity (Welford running variance). + if (m_botHasPkt) + { + float iv = float(getMSTimeDiff(m_botLastPktMS, nowMS)); + ++m_botIntervalN; + float d = iv - m_botIntMean; + m_botIntMean += d / float(m_botIntervalN); + m_botIntM2 += d * (iv - m_botIntMean); + } + m_botLastPktMS = nowMS; m_botHasPkt = true; + + if (horiz > 1.0f) + { + ++m_botSamples; + const float PI_F = 3.14159265f; + float heading = atan2f(dy, dx); + if (m_botHasHeading) + { + float turn = fabs(heading - m_botLastHeading); + if (turn > PI_F) turn = 2.0f * PI_F - turn; // normalise to 0..pi + if (turn > 1.4f) // ~80deg: a sharp "snap" + { + // a snap that ended a sustained straight run = waypoint cycle + if (m_botRunDist >= 15.0f) ++m_botCleanCycles; + m_botRunDist = 0.0f; + } + else if (turn < 0.35f) // ~20deg: still a straight line + m_botRunDist += horiz; + else + m_botRunDist = 0.0f; // gentle curve, not a clean straight run + } + m_botLastHeading = heading; m_botHasHeading = true; + } + + if (getMSTimeDiff(m_botWinStartMS, nowMS) >= 30000) + { + if (m_botSamples >= 50 && m_botIntervalN >= 30) + { + float var = m_botIntM2 / float(m_botIntervalN); + float sd = sqrtf(var > 0.0f ? var : 0.0f); + float cv = m_botIntMean > 1.0f ? sd / m_botIntMean : 1.0f; + // bot = repeated clean snap->straight cycles AND metronomic timing + if (m_botCleanCycles >= 4 && cv < 0.15f) + { + ctx.detail = "bot-like movement (snap-to-waypoint + regular timing)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_BOT, 12.0f, ctx); + } + } + m_botWinStartMS = nowMS; m_botSamples = 0; m_botCleanCycles = 0; m_botRunDist = 0.0f; + m_botIntervalN = 0; m_botIntMean = 0.0f; m_botIntM2 = 0.0f; + } + } + + // Update the rolling baseline. Track last clean position for rubberband use + // by the enforcement path (a non-teleport, non-impossible packet). + m_lastX = pos->x; m_lastY = pos->y; m_lastZ = pos->z; m_lastO = pos->o; + m_lastMS = nowMS; m_lastFlags = mi.GetMovementFlags(); + m_lastSpeed = ctx.speed; m_hasKin = true; // for the acceleration gate + if (!cheapTrip) + { + m_hasValid = true; + m_validX = pos->x; m_validY = pos->y; m_validZ = pos->z; m_validO = pos->o; + } +} + +void MovementAnticheat::PeriodicCheck() +{ + if (!m_player || !m_player->IsInWorld()) + return; + if (sAntiCheatMgr->IsExempt(m_player)) + return; + + // Idle terrain re-validation needs the physics module enabled. + if (!sAntiCheatMgr->PhysicsEnabled()) + return; + + // Re-validate the player's current (idle) position against terrain — catches + // static exploits with no movement packets. Only the grounded case is judged. + AntiCheatMoveState state = NormalizeState(m_player->m_movementInfo); + if (state != AC_MOVE_GROUND) + return; + + const char* reason = NULL; + if (PhysicsValidator::Validate(m_player, state, m_player->m_movementInfo, &reason) == AC_PHYS_IMPOSSIBLE) + { + AntiCheatContext ctx; + ctx.mapId = m_player->GetMapId(); + ctx.x = m_player->GetPositionX(); ctx.y = m_player->GetPositionY(); ctx.z = m_player->GetPositionZ(); + ctx.latency = m_player->GetSession() ? m_player->GetSession()->GetLatencyEWMA() : 0; + ctx.detail = reason ? reason : "physics impossible (idle)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_PHYSICS, 20.0f, ctx); + } +} + +void MovementAnticheat::NotifySpellCast(uint32 /*spellId*/, uint32 /*castTimeMs*/, uint32 gcdMs) +{ + if (!m_player || !sAntiCheatMgr->MovementEnabled() || sAntiCheatMgr->IsExempt(m_player)) + return; + + uint32 now = getMSTime(); + uint32 latency = m_player->GetSession() ? m_player->GetSession()->GetLatencyEWMA() : 0; + + AntiCheatContext ctx; + ctx.mapId = m_player->GetMapId(); + ctx.x = m_player->GetPositionX(); ctx.y = m_player->GetPositionY(); ctx.z = m_player->GetPositionZ(); + ctx.latency = latency; + + // --- Detector: cast spam (too many cast requests per second) --- + if (m_castWinStartMS == 0 || now - m_castWinStartMS > CAST_WINDOW_MS) + { + m_castWinStartMS = now; + m_castCount = 0; + } + ++m_castCount; + if (m_castCount > sWorld.getConfig(CONFIG_UINT32_ANTICHEAT_CAST_BURST)) + { + ctx.detail = "spell cast spam"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_SPELL, 12.0f, ctx); + } + + // --- Detector: GCD bypass (two GCD-triggering casts closer than the global + // cooldown, minus latency + slack, allows) — a cast-speed / no-GCD hack. --- + if (m_hasLastCast && gcdMs > 0 && m_lastCastGcd > 0) + { + uint32 interval = now - m_lastCastMS; + uint32 floorMs = (m_lastCastGcd > latency + CAST_GCD_SLACK_MS) + ? m_lastCastGcd - latency - CAST_GCD_SLACK_MS : 0; + if (floorMs > 0 && interval < floorMs) + { + float ratio = float(floorMs - interval) / float(floorMs); // 0..1 + float weight = 8.0f + ratio * 17.0f; // 8..25 + ctx.detail = "cast faster than GCD (cast-speed hack)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_SPELL, weight, ctx); + } + } + + m_hasLastCast = true; + m_lastCastMS = now; + m_lastCastGcd = gcdMs; +} diff --git a/src/game/AntiCheat/MovementAnticheat.h b/src/game/AntiCheat/MovementAnticheat.h new file mode 100644 index 000000000..40aff3cf4 --- /dev/null +++ b/src/game/AntiCheat/MovementAnticheat.h @@ -0,0 +1,115 @@ +/* + * Anti-Cheat / Movement-Validation framework — per-player movement validator. + * + * One instance is owned by each Player. It tracks the last accepted movement + * snapshot, normalises move state, runs the cheap event-driven detectors on each + * movement packet, and reports trips to AntiCheatMgr (which owns all response). + * It NEVER punishes or rejects packets itself — it only observes + scores. + */ + +#ifndef MANGOS_ANTICHEAT_MOVEMENTANTICHEAT_H +#define MANGOS_ANTICHEAT_MOVEMENTANTICHEAT_H + +#include "Common.h" +#include "AntiCheatDefines.h" + +#include + +class Player; +class MovementInfo; + +class MovementAnticheat +{ + public: + explicit MovementAnticheat(Player* owner); + + // Main entry: called from the movement opcode handler after the packet + // is parsed and before it is applied. opcode is the movement opcode. + void HandlePositionUpdate(uint16 opcode, MovementInfo const& mi); + + // Periodic (timer-driven from Player::Update) re-validation of the player's + // current position — a second cadence that catches static exploits where no + // movement packets are sent (e.g. standing inside geometry). + void PeriodicCheck(); + + // Called when the SERVER relocates the player (teleport ack, map change) + // so the next client packet is trusted and the baseline is rebuilt + // instead of being scored as an impossible jump. + void NotifyServerRelocation() { m_trustNext = true; } + + // Record a server-GRANTED movement capability flag (water-walk, hover, + // slow-fall, levitate, fly) so the flag-spoof detectors treat the client + // asserting it as legitimate (aura OR granted), not a spoof. Wired into the + // Player Set* setters so all callers (GM commands, auras, scripts) agree. + void SetGrantedFlag(uint32 flag, bool on) + { + if (on) m_grantedFlags |= flag; else m_grantedFlags &= ~flag; + } + + // Time-based anti-cheat for the spell-cast path (CMSG_CAST_SPELL): detects + // GCD bypass (casts closer together than the spell's global cooldown allows) + // and cast spam. castTimeMs/gcdMs come from the spell entry. + void NotifySpellCast(uint32 spellId, uint32 castTimeMs, uint32 gcdMs); + + // Last position that passed the teleport/physics gates (rubberband target + // for enforcement). Valid only if HasValid() is true. + bool HasValid() const { return m_hasValid; } + float ValidX() const { return m_validX; } + float ValidY() const { return m_validY; } + float ValidZ() const { return m_validZ; } + float ValidO() const { return m_validO; } + + private: + AntiCheatMoveState NormalizeState(MovementInfo const& mi) const; + + Player* m_player; + + bool m_hasLast; + bool m_trustNext; + float m_lastX, m_lastY, m_lastZ, m_lastO; + uint32 m_lastMS; + uint32 m_lastFlags; + + bool m_hasValid; + float m_validX, m_validY, m_validZ, m_validO; + + // Jump / fall state machine (infinite-jump + fall-damage-suppression). + bool m_airborne; // in a jump/fall episode (no FALL_LAND yet) + float m_fallApexZ; // highest Z reached during the current airborne episode + + // Packet-burst + client-timestamp tracking. + uint32 m_burstWinStartMS; // start of the current 1s burst-count window + uint32 m_burstCount; // movement packets seen in the current window + uint32 m_lastClientTime; // last MovementInfo client timestamp + bool m_hasClientTime; + + // Spell-cast timing (GCD bypass + cast spam). + bool m_hasLastCast; + uint32 m_lastCastMS; + uint32 m_lastCastGcd; + uint32 m_castWinStartMS; + uint32 m_castCount; + + // Kinematics (acceleration / velocity-delta gate). + bool m_hasKin; + float m_lastSpeed; + + // Server-granted movement capability flags (water-walk/hover/etc.) — the + // flag-spoof detectors accept these as legitimate alongside auras. + uint32 m_grantedFlags; + + // Bot-movement heuristic (snap-to-waypoint + metronomic timing), windowed. + uint32 m_botWinStartMS; + uint32 m_botSamples; // moving packets this window + uint32 m_botCleanCycles; // snap-after-straight-run cycles this window + float m_botRunDist; // current straight-run distance since last snap + bool m_botHasHeading; + float m_botLastHeading; + bool m_botHasPkt; + uint32 m_botLastPktMS; + uint32 m_botIntervalN; // inter-packet interval count (Welford) + float m_botIntMean; + float m_botIntM2; +}; + +#endif // MANGOS_ANTICHEAT_MOVEMENTANTICHEAT_H diff --git a/src/game/AntiCheat/PhysicsValidator.cpp b/src/game/AntiCheat/PhysicsValidator.cpp new file mode 100644 index 000000000..3b8407fd9 --- /dev/null +++ b/src/game/AntiCheat/PhysicsValidator.cpp @@ -0,0 +1,65 @@ +/* + * Anti-Cheat / Movement-Validation framework — physics plausibility stage. + */ + +#include "PhysicsValidator.h" +#include "Player.h" +#include "Map.h" +#include "Unit.h" + +namespace +{ + // Tolerances are deliberately generous to keep false positives near zero; + // tuning happens from real logs before enforcement is enabled. + const float GROUND_FLOAT_SUSPECT = 5.0f; // yd above ground -> suspect + const float GROUND_FLOAT_IMPOSSIBLE = 18.0f; // yd above ground -> impossible + const float UNDERGROUND_IMPOSSIBLE = 6.0f; // yd below ground -> impossible + const float NO_TERRAIN_DATA = -50000.0f; +} + +namespace PhysicsValidator +{ + AntiCheatPhysicsResult Validate(Player* player, AntiCheatMoveState state, + MovementInfo const& mi, const char** reason) + { + if (!player || !player->IsInWorld()) + return AC_PHYS_OK; + + // Only the grounded case has a cheap, reliable terrain expectation. + // Falling/flying/swimming/transport legitimately deviate from the + // heightmap, so we do not judge them here. + if (state != AC_MOVE_GROUND) + return AC_PHYS_OK; + + Position const* pos = mi.GetPos(); + Map* map = player->GetMap(); + if (!map) + return AC_PHYS_OK; + + // Terrain + VMAP height (accounts for buildings/bridges via the existing + // nearest-surface logic). If no data, we cannot judge -> OK. + float groundZ = map->GetHeight(pos->x, pos->y, pos->z); + if (groundZ < NO_TERRAIN_DATA) + return AC_PHYS_OK; + + float dz = pos->z - groundZ; + + if (dz > GROUND_FLOAT_IMPOSSIBLE) + { + if (reason) { *reason = "floating far above terrain"; } + return AC_PHYS_IMPOSSIBLE; + } + if (dz < -UNDERGROUND_IMPOSSIBLE) + { + if (reason) { *reason = "below terrain surface"; } + return AC_PHYS_IMPOSSIBLE; + } + if (dz > GROUND_FLOAT_SUSPECT) + { + if (reason) { *reason = "hovering above terrain"; } + return AC_PHYS_SUSPECT; + } + + return AC_PHYS_OK; + } +} diff --git a/src/game/AntiCheat/PhysicsValidator.h b/src/game/AntiCheat/PhysicsValidator.h new file mode 100644 index 000000000..3e094cb7f --- /dev/null +++ b/src/game/AntiCheat/PhysicsValidator.h @@ -0,0 +1,28 @@ +/* + * Anti-Cheat / Movement-Validation framework — physics plausibility stage. + * + * Pure, side-effect-free validation: given a player, a normalised move state and + * the incoming MovementInfo, decide whether the position is physically plausible. + * Never mutates state. Cheap checks first; callers only invoke this on suspicion + * so the moderate-cost terrain queries stay off the hot path for honest players. + */ + +#ifndef MANGOS_ANTICHEAT_PHYSICSVALIDATOR_H +#define MANGOS_ANTICHEAT_PHYSICSVALIDATOR_H + +#include "Common.h" +#include "AntiCheatDefines.h" + +class Player; +class MovementInfo; + +namespace PhysicsValidator +{ + // Validate the destination position in the incoming packet against terrain + // for the given normalised move state. `reason` (optional out) receives a + // short static description when the result is not OK. + AntiCheatPhysicsResult Validate(Player* player, AntiCheatMoveState state, + MovementInfo const& mi, const char** reason = NULL); +} + +#endif // MANGOS_ANTICHEAT_PHYSICSVALIDATOR_H diff --git a/src/game/AntiCheat/sql/account_anticheat.sql b/src/game/AntiCheat/sql/account_anticheat.sql new file mode 100644 index 000000000..546db6ecd --- /dev/null +++ b/src/game/AntiCheat/sql/account_anticheat.sql @@ -0,0 +1,12 @@ +-- Anti-Cheat anti-gaming autoban +-- Realm (logon) database: per-account aggregate for the autoban accumulator. +-- Decays slowly (hours) so spacing offences out still accumulates toward a ban. +-- Apply to the realmd database. + +CREATE TABLE IF NOT EXISTS `account_anticheat` ( + `account` INT UNSIGNED NOT NULL COMMENT 'account id (realmd.account.id)', + `kick_score` FLOAT NOT NULL DEFAULT 0 COMMENT 'decaying accumulated kick weight', + `ban_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'how many autobans issued (escalation tier)', + `last_update` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'unix seconds of last update (for decay)', + PRIMARY KEY (`account`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Anti-cheat per-account autoban accumulator'; diff --git a/src/game/AntiCheat/sql/character_anticheat_violation.sql b/src/game/AntiCheat/sql/character_anticheat_violation.sql new file mode 100644 index 000000000..b51b5839a --- /dev/null +++ b/src/game/AntiCheat/sql/character_anticheat_violation.sql @@ -0,0 +1,22 @@ +-- Anti-Cheat / Movement-Validation framework +-- Character database: persisted detection events. +-- Consumed by AntiCheatMgr::RecordViolation (insert) and `.anticheat report` (select). +-- Apply to the characters database (e.g. character0). + +CREATE TABLE IF NOT EXISTS `character_anticheat_violation` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `guid` INT UNSIGNED NOT NULL COMMENT 'character low-guid', + `account` INT UNSIGNED NOT NULL DEFAULT 0, + `time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `type` TINYINT UNSIGNED NOT NULL COMMENT 'AntiCheatViolationType', + `score` SMALLINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'decayed score at event time', + `map` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `x` FLOAT NOT NULL DEFAULT 0, + `y` FLOAT NOT NULL DEFAULT 0, + `z` FLOAT NOT NULL DEFAULT 0, + `speed` FLOAT NOT NULL DEFAULT 0, + `latency` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `detail` VARCHAR(128) NOT NULL DEFAULT '', + PRIMARY KEY (`id`), + KEY `idx_guid_time` (`guid`, `time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='Anti-cheat detection events'; diff --git a/src/game/CMakeLists.txt b/src/game/CMakeLists.txt index f4784b20d..583d6447a 100644 --- a/src/game/CMakeLists.txt +++ b/src/game/CMakeLists.txt @@ -69,6 +69,10 @@ source_group("Tool" FILES ${SRC_GRP_TOOL}) file(GLOB SRC_GRP_VMAPS vmap/*.cpp vmap/*.h) source_group("vmaps" FILES ${SRC_GRP_VMAPS}) +#AntiCheat group +file(GLOB SRC_GRP_ANTICHEAT AntiCheat/*.cpp AntiCheat/*.h) +source_group("AntiCheat" FILES ${SRC_GRP_ANTICHEAT}) + #Warden group file(GLOB SRC_GRP_WARDEN Warden/*.cpp Warden/*.h) source_group("Warden" FILES ${SRC_GRP_WARDEN}) @@ -235,6 +239,7 @@ add_library(game STATIC ${SRC_GRP_TIME} ${SRC_GRP_TOOL} ${SRC_GRP_VMAPS} + ${SRC_GRP_ANTICHEAT} ${SRC_GRP_WARDEN} ${SRC_GRP_WARDEN_MODULES} ${SRC_GRP_WORLD_HANDLERS} @@ -245,6 +250,7 @@ add_library(game STATIC target_include_directories(game PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} + AntiCheat AuctionHouseBot BattleGround ChatCommands diff --git a/src/game/ChatCommands/AntiCheatCommands.cpp b/src/game/ChatCommands/AntiCheatCommands.cpp new file mode 100644 index 000000000..e1bcbe3a4 --- /dev/null +++ b/src/game/ChatCommands/AntiCheatCommands.cpp @@ -0,0 +1,167 @@ +/* + * GM chat commands for the Anti-Cheat framework: + * .anticheat status/report/reload/warn/jail/unjail/delete. + */ + +#include "Chat.h" +#include "AntiCheatMgr.h" +#include "MovementAnticheat.h" +#include "Player.h" +#include "World.h" +#include "ObjectMgr.h" +#include "Log.h" +#include "Config/Config.h" +#include "Database/DatabaseEnv.h" + +#include + +bool ChatHandler::HandleAntiCheatStatusCommand(char* /*args*/) +{ + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage("AntiCheat: select a player (or run in-game)."); + SetSentErrorMessage(true); + return false; + } + + std::string out; + sAntiCheatMgr->BuildStatus(target, out); + SendSysMessage(out.c_str()); + return true; +} + +bool ChatHandler::HandleAntiCheatReportCommand(char* /*args*/) +{ + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage("AntiCheat: select a player (or run in-game)."); + SetSentErrorMessage(true); + return false; + } + + QueryResult* result = CharacterDatabase.PQuery( + "SELECT `type`,`score`,`map`,`detail`,`time` FROM `character_anticheat_violation` " + "WHERE `guid`=%u ORDER BY `id` DESC LIMIT 10", target->GetGUIDLow()); + + if (!result) + { + PSendSysMessage("AntiCheat: no recorded violations for %s.", target->GetName()); + return true; + } + + PSendSysMessage("AntiCheat: last violations for %s:", target->GetName()); + do + { + Field* f = result->Fetch(); + PSendSysMessage(" type=%u score=%u map=%u %s [%s]", + f[0].GetUInt32(), f[1].GetUInt32(), f[2].GetUInt32(), + f[3].GetCppString().c_str(), f[4].GetCppString().c_str()); + } + while (result->NextRow()); + delete result; + return true; +} + +bool ChatHandler::HandleAntiCheatReloadCommand(char* /*args*/) +{ + sAntiCheatMgr->LoadConfig(); + SendSysMessage("AntiCheat: configuration reloaded."); + return true; +} + +bool ChatHandler::HandleAntiCheatWarnCommand(char* /*args*/) +{ + Player* target = getSelectedPlayer(); + if (!target) + { + SendSysMessage(".anticheat warn FAILED: no player selected. Select/target an online " + "player, then run .anticheat warn (sends them a warning)."); + SetSentErrorMessage(true); + return false; + } + if (target->GetSession()) + target->GetSession()->SendNotification("[AntiCheat] You have been warned by a GM for suspicious activity."); + PSendSysMessage("AntiCheat: warned %s.", target->GetName()); + sLog.outBasic("AntiCheat: GM %s warned %s (guid %u)", + m_session ? m_session->GetPlayerName() : "CONSOLE", target->GetName(), target->GetGUIDLow()); + return true; +} + +bool ChatHandler::HandleAntiCheatJailCommand(char* /*args*/) +{ + Player* target = getSelectedPlayer(); + if (!target) + { + SendSysMessage(".anticheat jail FAILED: no player selected. Select/target an online " + "player, then run .anticheat jail (teleports them to the configured jail)."); + SetSentErrorMessage(true); + return false; + } + // Configurable jail location (defaults to the GM transport map cell). + uint32 jmap = uint32(sConfig.GetIntDefault("AntiCheat.Jail.Map", 13)); + float jx = sConfig.GetFloatDefault("AntiCheat.Jail.X", -109.0f); + float jy = sConfig.GetFloatDefault("AntiCheat.Jail.Y", -1.0f); + float jz = sConfig.GetFloatDefault("AntiCheat.Jail.Z", -2.4f); + float jo = sConfig.GetFloatDefault("AntiCheat.Jail.O", 3.14f); + target->TeleportTo(jmap, jx, jy, jz, jo); + if (target->GetSession()) + target->GetSession()->SendNotification("[AntiCheat] You have been jailed by a GM."); + PSendSysMessage("AntiCheat: jailed %s (map %u: %.1f, %.1f, %.1f).", target->GetName(), jmap, jx, jy, jz); + sLog.outBasic("AntiCheat: GM %s jailed %s (guid %u)", + m_session ? m_session->GetPlayerName() : "CONSOLE", target->GetName(), target->GetGUIDLow()); + return true; +} + +bool ChatHandler::HandleAntiCheatUnjailCommand(char* /*args*/) +{ + Player* target = getSelectedPlayer(); + if (!target) + { + SendSysMessage(".anticheat unjail FAILED: no player selected. Select/target an online " + "player, then run .anticheat unjail (returns them to their homebind)."); + SetSentErrorMessage(true); + return false; + } + target->TeleportToHomebind(); + if (target->GetSession()) + target->GetSession()->SendNotification("[AntiCheat] You have been released."); + PSendSysMessage("AntiCheat: released %s to homebind.", target->GetName()); + return true; +} + +bool ChatHandler::HandleAntiCheatDeleteCommand(char* args) +{ + uint32 guid = 0; + std::string name; + if (Player* target = getSelectedPlayer()) + { + guid = target->GetGUIDLow(); + name = target->GetName(); + } + else if (args && *args) + { + name = args; + ObjectGuid og = sObjectMgr.GetPlayerGuidByName(name); + if (og) + guid = og.GetCounter(); + } + + if (!guid) + { + SendSysMessage(".anticheat delete FAILED: no player. Select/target a player OR pass a " + "character name (.anticheat delete ) to clear their violation records."); + SetSentErrorMessage(true); + return false; + } + + CharacterDatabase.PExecute("DELETE FROM `character_anticheat_violation` WHERE `guid`=%u", guid); + sAntiCheatMgr->RemovePlayer(guid); + PSendSysMessage("AntiCheat: cleared violation records for %s (guid %u).", name.empty() ? "?" : name.c_str(), guid); + return true; +} diff --git a/src/game/Object/Player.cpp b/src/game/Object/Player.cpp index 3182323c8..932b35ff7 100644 --- a/src/game/Object/Player.cpp +++ b/src/game/Object/Player.cpp @@ -29,6 +29,8 @@ #include "Opcodes.h" #include "SpellMgr.h" #include "World.h" +#include "AntiCheatMgr.h" +#include "MovementAnticheat.h" #include "WorldPacket.h" #include "WorldSession.h" #include "UpdateMask.h" @@ -533,6 +535,9 @@ Player::Player(WorldSession* session): Unit(), m_mover(this), m_camera(this), m_ m_playerbotMgr = 0; #endif + m_movementAnticheat = NULL; + m_acPosTimer = 5000; + m_transport = 0; m_speakTime = 0; @@ -758,6 +763,14 @@ Player::~Player() // Perform cleanup before deleting the player object CleanupsBeforeDelete(); + // Anti-Cheat: free the per-player movement validator and drop the score entry + if (m_movementAnticheat) + { + delete m_movementAnticheat; + m_movementAnticheat = NULL; + } + sAntiCheatMgr->RemovePlayer(GetGUIDLow()); + // Ensure the social object is unloaded (should already be done in PlayerLogout) // m_social = NULL; @@ -818,6 +831,14 @@ Player::~Player() } } +// Anti-Cheat: lazily create the per-player movement validator on first use. +MovementAnticheat* Player::GetMovementAnticheat() +{ + if (!m_movementAnticheat) + m_movementAnticheat = new MovementAnticheat(this); + return m_movementAnticheat; +} + /** * @brief Performs pre-destruction cleanup for trade, duel, zone, and unit state. */ @@ -1560,6 +1581,21 @@ void Player::Update(uint32 update_diff, uint32 p_time) return; } + // Anti-Cheat: periodic idle-position re-validation (second cadence). Only if a + // movement validator exists (created on first movement); gating is inside. + if (m_movementAnticheat) + { + if (m_acPosTimer <= update_diff) + { + m_acPosTimer = 5000; + m_movementAnticheat->PeriodicCheck(); + } + else + { + m_acPosTimer -= update_diff; + } + } + // Handle undelivered mail if (m_nextMailDelivereTime && m_nextMailDelivereTime <= time(NULL)) { @@ -2797,6 +2833,24 @@ Creature* Player::GetNPCIfCanInteractWith(ObjectGuid guid, uint32 npcflagmask) // not too far if (!unit->IsWithinDistInMap(this, INTERACTION_DISTANCE)) { + // Anti-Cheat: the core already blocks this interaction; a request to talk to + // an NPC well beyond interaction range (vendor/gossip/trainer/banker/...) is + // a remote-interaction attempt worth attributing. Score only, generous slack + // vs latency so a player who just walked up isn't flagged. + if (sAntiCheatMgr->IsEnabled() && !sAntiCheatMgr->IsExempt(this)) + { + uint32 lat = GetSession() ? GetSession()->GetLatencyEWMA() : 0; + float slack = INTERACTION_DISTANCE + 8.0f + GetSpeed(MOVE_RUN) * (float(lat) / 1000.0f); + if (GetDistance(unit) > slack) + { + AntiCheatContext ctx; + ctx.mapId = GetMapId(); + ctx.x = GetPositionX(); ctx.y = GetPositionY(); ctx.z = GetPositionZ(); + ctx.latency = lat; + ctx.detail = "npc interaction beyond range"; + sAntiCheatMgr->RecordViolation(this, AC_VIOLATION_INTERACT, 15.0f, ctx); + } + } return NULL; } @@ -5350,6 +5404,10 @@ void Player::SetWaterWalk(bool enable) data << GetPackGUID(); data << uint32(0); GetSession()->SendPacket(&data); + // Record the server grant so the anti-cheat treats the client asserting the + // water-walk flag as legitimate (single source of truth for all callers: + // GM commands, spell auras, scripts). + GetMovementAnticheat()->SetGrantedFlag(MOVEFLAG_WATERWALKING, enable); } /** @@ -5394,6 +5452,7 @@ void Player::SetCanFly(bool enable) } SendHeartBeat(); + GetMovementAnticheat()->SetGrantedFlag(MOVEFLAG_FLYING | MOVEFLAG_CAN_FLY, enable); } /** @@ -5422,6 +5481,7 @@ void Player::SetFeatherFall(bool enable) { SetFallInformation(0, GetPositionZ()); } + GetMovementAnticheat()->SetGrantedFlag(MOVEFLAG_SAFE_FALL, enable); } /** @@ -5444,6 +5504,7 @@ void Player::SetHover(bool enable) data << GetPackGUID(); data << uint32(0); SendMessageToSet(&data, true); + GetMovementAnticheat()->SetGrantedFlag(MOVEFLAG_HOVER, enable); } /** Preconditions: diff --git a/src/game/Object/Player.h b/src/game/Object/Player.h index f4f84bd25..f116657ce 100644 --- a/src/game/Object/Player.h +++ b/src/game/Object/Player.h @@ -80,6 +80,7 @@ class Creature; class PlayerMenu; class Transport; class UpdateMask; +class MovementAnticheat; class SpellCastTargets; class PlayerSocial; class DungeonPersistentState; @@ -3694,6 +3695,10 @@ class Player : public Unit bool canSeeSpellClickOn(Creature const* creature) const; + // Anti-Cheat: per-player movement validator (lazily created, never null + // after first call). Owned by the Player; freed in the destructor. + MovementAnticheat* GetMovementAnticheat(); + #ifdef ENABLE_PLAYERBOTS // Set the player bot AI void SetPlayerbotAI(PlayerbotAI* ai) { assert(!m_playerbotAI && !m_playerbotMgr); m_playerbotAI = ai; } @@ -4039,6 +4044,10 @@ class Player : public Unit // Map reference for the player MapReference m_mapRef; + // Anti-Cheat: per-player movement validator (NULL until first use) + MovementAnticheat* m_movementAnticheat; + uint32 m_acPosTimer; // countdown for periodic idle-position re-validation + #ifdef ENABLE_PLAYERBOTS // Player bot AI PlayerbotAI* m_playerbotAI; diff --git a/src/game/Object/Unit.h b/src/game/Object/Unit.h index 42ecda06c..9be5617b5 100644 --- a/src/game/Object/Unit.h +++ b/src/game/Object/Unit.h @@ -709,7 +709,7 @@ class MovementInfo } ObjectGuid const& GetTransportGuid() const { return t_guid; } Position const* GetTransportPos() const { return &t_pos; } - uint32 GetTime() + uint32 GetTime() const { return time; } diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index 7a393063a..6da38f90e 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -149,8 +149,11 @@ WorldSession::WorldSession(uint32 id, WorldSocket* sock, AccountTypes sec, time_ _player(NULL), m_Socket(sock), _security(sec), _accountId(id), _warden(NULL), _build(0), _logoutTime(0), m_inQueue(false), m_playerLoading(false), m_playerLogout(false), m_playerRecentlyLogout(false), m_playerSave(false), m_sessionDbcLocale(sWorld.GetAvailableDbcLocale(locale)), m_sessionDbLocaleIndex(sObjectMgr.GetIndexForLocale(locale)), - m_latency(0), m_clientTimeDelay(0), m_tutorialState(TUTORIALDATA_UNCHANGED), m_npcWatchLastGuid() + m_latency(0), m_latIdx(0), m_latCount(0), m_latEWMA(0), m_latMin(0), m_latMax(0), + m_clientTimeDelay(0), m_tutorialState(TUTORIALDATA_UNCHANGED), m_npcWatchLastGuid() { + memset(m_latSamples, 0, sizeof(m_latSamples)); + if (sock) { m_Address = sock->GetRemoteAddress(); @@ -158,6 +161,32 @@ WorldSession::WorldSession(uint32 id, WorldSocket* sock, AccountTypes sec, time_ } } +// Anti-Cheat: fold a fresh client-reported latency sample into the rolling window +// and EWMA. Fed from WorldSocket::HandlePing (CMSG_PING). +void WorldSession::UpdateLatencyStats(uint32 sampleMS) +{ + m_latSamples[m_latIdx] = sampleMS; + m_latIdx = (m_latIdx + 1) % LATENCY_WINDOW; + if (m_latCount < LATENCY_WINDOW) + ++m_latCount; + + uint32 mn = 0xFFFFFFFF, mx = 0; + for (uint32 i = 0; i < m_latCount; ++i) + { + uint32 v = m_latSamples[i]; + if (v < mn) { mn = v; } + if (v > mx) { mx = v; } + } + m_latMin = (mn == 0xFFFFFFFF) ? 0 : mn; + m_latMax = mx; + + // EWMA with a fixed 20% weight on the newest sample. + if (m_latEWMA == 0) + m_latEWMA = sampleMS; + else + m_latEWMA = (20 * sampleMS + 80 * m_latEWMA) / 100; +} + /// WorldSession destructor WorldSession::~WorldSession() { diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index 1052fabe7..cb71c9e4a 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -452,6 +452,12 @@ class WorldSession { m_latency = latency; } + + // Anti-Cheat: rolling latency window + EWMA fed from CMSG_PING. Used by the + // movement detectors for latency-aware tolerances. + void UpdateLatencyStats(uint32 sampleMS); + uint32 GetLatencyEWMA() const { return m_latEWMA ? m_latEWMA : m_latency; } + uint32 GetLatencyJitter() const { return m_latMax >= m_latMin ? m_latMax - m_latMin : 0; } void SetClientTimeDelay(uint32 delay) { m_clientTimeDelay = delay; } uint32 getDialogStatus(Player* pPlayer, Object* questgiver, uint32 defstatus); @@ -882,6 +888,14 @@ class WorldSession LocaleConstant m_sessionDbcLocale; int m_sessionDbLocaleIndex; uint32 m_latency; + // Anti-Cheat: rolling latency window + smoothed value. + static const uint32 LATENCY_WINDOW = 8; + uint32 m_latSamples[LATENCY_WINDOW]; + uint32 m_latIdx; + uint32 m_latCount; + uint32 m_latEWMA; + uint32 m_latMin; + uint32 m_latMax; uint32 m_Tutorials[8]; TutorialDataState m_tutorialState; uint32 m_clientTimeDelay; diff --git a/src/game/Server/WorldSocket.cpp b/src/game/Server/WorldSocket.cpp index 5b338249c..53a691961 100644 --- a/src/game/Server/WorldSocket.cpp +++ b/src/game/Server/WorldSocket.cpp @@ -1021,6 +1021,7 @@ int WorldSocket::HandlePing(WorldPacket& recvPacket) if (m_Session) { m_Session->SetLatency(latency); + m_Session->UpdateLatencyStats(latency); // Anti-Cheat rolling window/EWMA m_Session->SetClientTimeDelay(0); // recalculated on next movement packet } else diff --git a/src/game/WorldHandlers/Chat.cpp b/src/game/WorldHandlers/Chat.cpp index 374cb18a7..3ba661771 100644 --- a/src/game/WorldHandlers/Chat.cpp +++ b/src/game/WorldHandlers/Chat.cpp @@ -241,6 +241,18 @@ ChatCommand* ChatHandler::getCommandTable() { NULL, 0, false, NULL, "", NULL } }; + static ChatCommand anticheatCommandTable[] = + { + { "status", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatStatusCommand, "", NULL }, + { "report", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatReportCommand, "", NULL }, + { "reload", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatReloadCommand, "", NULL }, + { "warn", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatWarnCommand, "", NULL }, + { "jail", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatJailCommand, "", NULL }, + { "unjail", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatUnjailCommand, "", NULL }, + { "delete", SEC_ADMINISTRATOR, true, &ChatHandler::HandleAntiCheatDeleteCommand, "", NULL }, + { NULL, 0, false, NULL, "", NULL } + }; + static ChatCommand debugCommandTable[] = { { "anim", SEC_GAMEMASTER, false, &ChatHandler::HandleDebugAnimCommand, "", NULL }, @@ -757,6 +769,7 @@ ChatCommand* ChatHandler::getCommandTable() { "account", SEC_PLAYER, true, NULL, "", accountCommandTable }, { "auction", SEC_ADMINISTRATOR, false, NULL, "", auctionCommandTable }, { "ahbot", SEC_ADMINISTRATOR, true, NULL, "", ahbotCommandTable }, + { "anticheat", SEC_GAMEMASTER, true, NULL, "", anticheatCommandTable}, { "cast", SEC_ADMINISTRATOR, false, NULL, "", castCommandTable }, { "character", SEC_GAMEMASTER, true, NULL, "", characterCommandTable}, { "debug", SEC_MODERATOR, true, NULL, "", debugCommandTable }, diff --git a/src/game/WorldHandlers/Chat.h b/src/game/WorldHandlers/Chat.h index 3dd0a7a9b..a0cc3bb31 100644 --- a/src/game/WorldHandlers/Chat.h +++ b/src/game/WorldHandlers/Chat.h @@ -253,6 +253,15 @@ class ChatHandler bool HandleAHBotReloadCommand(char* args); bool HandleAHBotStatusCommand(char* args); + // Anti-Cheat framework commands + bool HandleAntiCheatStatusCommand(char* args); + bool HandleAntiCheatReportCommand(char* args); + bool HandleAntiCheatReloadCommand(char* args); + bool HandleAntiCheatWarnCommand(char* args); + bool HandleAntiCheatJailCommand(char* args); + bool HandleAntiCheatUnjailCommand(char* args); + bool HandleAntiCheatDeleteCommand(char* args); + bool HandleAuctionAllianceCommand(char* args); bool HandleAuctionGoblinCommand(char* args); bool HandleAuctionHordeCommand(char* args); diff --git a/src/game/WorldHandlers/MovementHandler.cpp b/src/game/WorldHandlers/MovementHandler.cpp index 68b5d4d94..21420e2d7 100644 --- a/src/game/WorldHandlers/MovementHandler.cpp +++ b/src/game/WorldHandlers/MovementHandler.cpp @@ -58,6 +58,8 @@ #include "Opcodes.h" #include "Log.h" #include "Player.h" +#include "AntiCheatMgr.h" +#include "MovementAnticheat.h" #include "MapManager.h" #include "Transports.h" #include "BattleGround/BattleGround.h" @@ -159,6 +161,8 @@ void WorldSession::HandleMoveWorldportAckOpcode() GetPlayer()->SetMap(map); GetPlayer()->Relocate(loc.coord_x, loc.coord_y, loc.coord_z, loc.orientation); + // Anti-Cheat: server-initiated far teleport; trust the next client packet. + GetPlayer()->GetMovementAnticheat()->NotifyServerRelocation(); GetPlayer()->SendInitialPacketsBeforeAddToMap(); // the CanEnter checks are done in TeleporTo but conditions may change @@ -288,6 +292,8 @@ void WorldSession::HandleMoveTeleportAckOpcode(WorldPacket& recv_data) WorldLocation const& dest = plMover->GetTeleportDest(); plMover->SetPosition(dest.coord_x, dest.coord_y, dest.coord_z, dest.orientation, true); + // Anti-Cheat: server-initiated near teleport; trust the next client packet. + plMover->GetMovementAnticheat()->NotifyServerRelocation(); uint32 newzone, newarea; plMover->GetZoneAndAreaId(newzone, newarea); @@ -344,6 +350,11 @@ void WorldSession::HandleMovementOpcodes(WorldPacket& recv_data) return; } + // Anti-Cheat: per-player movement validation (observe + score; never rejects + // packets). Inert unless the framework is enabled in config. + if (plMover && sAntiCheatMgr->MovementEnabled() && !sAntiCheatMgr->IsExempt(plMover)) + plMover->GetMovementAnticheat()->HandlePositionUpdate(opcode, movementInfo); + // fall damage generation (ignore in flight case that can be triggered also at lags in moment teleportation to another map). if (opcode == MSG_MOVE_FALL_LAND && plMover && !plMover->IsTaxiFlying()) { diff --git a/src/game/WorldHandlers/SpellHandler.cpp b/src/game/WorldHandlers/SpellHandler.cpp index 8b66ee3f6..fe8935bf9 100644 --- a/src/game/WorldHandlers/SpellHandler.cpp +++ b/src/game/WorldHandlers/SpellHandler.cpp @@ -47,6 +47,8 @@ #include "Log.h" #include "Opcodes.h" #include "Spell.h" +#include "AntiCheatMgr.h" +#include "MovementAnticheat.h" #include "ScriptMgr.h" #include "Totem.h" #include "SpellAuras.h" @@ -110,6 +112,17 @@ void WorldSession::HandleUseItemOpcode(WorldPacket& recvPacket) // not allow use item from trade (cheat way only) if (pItem->IsInTrade()) { + // Anti-Cheat: using an item from the trade window is an exploit the core + // itself flags as "cheat way only". + if (sAntiCheatMgr->IsEnabled() && !sAntiCheatMgr->IsExempt(pUser)) + { + AntiCheatContext ctx; + ctx.mapId = pUser->GetMapId(); + ctx.x = pUser->GetPositionX(); ctx.y = pUser->GetPositionY(); ctx.z = pUser->GetPositionZ(); + ctx.latency = pUser->GetSession() ? pUser->GetSession()->GetLatencyEWMA() : 0; + ctx.detail = "use of item in trade window"; + sAntiCheatMgr->RecordViolation(pUser, AC_VIOLATION_ITEM, 30.0f, ctx); + } recvPacket.rpos(recvPacket.wpos()); // prevent spam at not read packet tail pUser->SendEquipError(EQUIP_ERR_ITEM_NOT_FOUND, pItem, NULL); return; @@ -303,6 +316,25 @@ void WorldSession::HandleGameObjectUseOpcode(WorldPacket& recv_data) if (!obj->IsWithinDistInMap(_player, obj->GetInteractionDistance())) { + // Anti-Cheat: the core already blocks this use, but a request well beyond + // the object's interaction distance is a remote-interact attempt worth + // scoring (a legit client never sends one). Generous slack vs latency so a + // moving/laggy player at the edge isn't flagged. + if (sAntiCheatMgr->IsEnabled() && !sAntiCheatMgr->IsExempt(_player)) + { + uint32 lat = GetLatencyEWMA(); + float slack = obj->GetInteractionDistance() + 8.0f + + _player->GetSpeed(MOVE_RUN) * (float(lat) / 1000.0f); + if (_player->GetDistance(obj) > slack) + { + AntiCheatContext ctx; + ctx.mapId = _player->GetMapId(); + ctx.x = _player->GetPositionX(); ctx.y = _player->GetPositionY(); ctx.z = _player->GetPositionZ(); + ctx.latency = lat; + ctx.detail = "gameobject use beyond interaction distance"; + sAntiCheatMgr->RecordViolation(_player, AC_VIOLATION_INTERACT, 15.0f, ctx); + } + } return; } @@ -362,11 +394,23 @@ void WorldSession::HandleCastSpellOpcode(WorldPacket& recvPacket) if (mover->GetTypeId() == TYPEID_PLAYER) { + Player* plMover = (Player*)mover; + bool hasSpell = plMover->HasActiveSpell(spellId); // not have spell in spellbook or spell passive and not casted by client - if (!((Player*)mover)->HasActiveSpell(spellId) || IsPassiveSpell(spellInfo)) + if (!hasSpell || IsPassiveSpell(spellInfo)) { sLog.outError("World: Player %u casts spell %u which he shouldn't have", mover->GetGUIDLow(), spellId); - // cheater? kick? ban? + // Anti-Cheat: casting a spell not in the spellbook is spell-ID injection + // (the passive-spell case is excluded to avoid false positives). + if (!hasSpell && sAntiCheatMgr->IsEnabled() && !sAntiCheatMgr->IsExempt(plMover)) + { + AntiCheatContext ctx; + ctx.mapId = plMover->GetMapId(); + ctx.x = plMover->GetPositionX(); ctx.y = plMover->GetPositionY(); ctx.z = plMover->GetPositionZ(); + ctx.latency = plMover->GetSession() ? plMover->GetSession()->GetLatencyEWMA() : 0; + ctx.detail = "cast of unknown spell (injection)"; + sAntiCheatMgr->RecordViolation(plMover, AC_VIOLATION_SPELL, 40.0f, ctx); + } recvPacket.rpos(recvPacket.wpos()); // prevent spam at ignore packet return; } @@ -382,6 +426,15 @@ void WorldSession::HandleCastSpellOpcode(WorldPacket& recvPacket) } } + // Anti-Cheat: spell-cast timing (GCD bypass + cast spam). Only validated casts + // (known, non-passive) reach here. Time-based vector akin to the move detectors. + if (mover->GetTypeId() == TYPEID_PLAYER && sAntiCheatMgr->MovementEnabled() && + !sAntiCheatMgr->IsExempt((Player*)mover)) + { + ((Player*)mover)->GetMovementAnticheat()->NotifySpellCast( + spellId, GetSpellCastTime(spellInfo), spellInfo->StartRecoveryTime); + } + // client provided targets SpellCastTargets targets; diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index f09c406de..db8925b94 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -109,6 +109,9 @@ // WARDEN #include "WardenCheckMgr.h" +// ANTICHEAT +#include "AntiCheatMgr.h" + #include #include @@ -930,6 +933,41 @@ void World::LoadConfigSettings(bool reload) setConfig(CONFIG_BOOL_REALM_RECOMMENDED_OR_NEW_ENABLED, "Realm.RecommendedOrNew.Enabled", false); setConfig(CONFIG_BOOL_REALM_RECOMMENDED_OR_NEW, "Realm.RecommendedOrNew", false); + // Anti-Cheat / Movement-Validation framework. Master switch off by default: + // with AntiCheat.Enable = 0 the framework is fully inert. + setConfig(CONFIG_BOOL_ANTICHEAT_ENABLE, "AntiCheat.Enable", false); + setConfig(CONFIG_BOOL_ANTICHEAT_MOVEMENT, "AntiCheat.Movement.Enable", true); + setConfig(CONFIG_BOOL_ANTICHEAT_PHYSICS, "AntiCheat.Physics.Enable", true); + setConfig(CONFIG_BOOL_ANTICHEAT_EXEMPT_BOTS, "AntiCheat.ExemptBots", true); + setConfig(CONFIG_BOOL_ANTICHEAT_PERSIST, "AntiCheat.Persist", true); + setConfigMinMax(CONFIG_UINT32_ANTICHEAT_EXEMPT_GM, "AntiCheat.ExemptGMLevel", 1, 0, 4); + setConfigMinMax(CONFIG_UINT32_ANTICHEAT_ACTION, "AntiCheat.Action", 1, 0, 4); + setConfigMinMax(CONFIG_UINT32_ANTICHEAT_SPEED_TOL, "AntiCheat.Speed.Tolerance", 110, 100, 500); + setConfigMinMax(CONFIG_UINT32_ANTICHEAT_TELE_DIST, "AntiCheat.Teleport.Distance", 50, 10, 1000); + setConfig(CONFIG_UINT32_ANTICHEAT_SCORE_WARN, "AntiCheat.Score.Warn", 30); + setConfig(CONFIG_UINT32_ANTICHEAT_SCORE_RUBBER, "AntiCheat.Score.Rubberband", 60); + setConfig(CONFIG_UINT32_ANTICHEAT_SCORE_KICK, "AntiCheat.Score.Kick", 120); + setConfigMinMax(CONFIG_UINT32_ANTICHEAT_DECAY, "AntiCheat.Score.DecayPerSec", 2, 0, 100); + // Spell-cast timing: more than this many cast requests per second = cast spam. + setConfigMinMax(CONFIG_UINT32_ANTICHEAT_CAST_BURST, "AntiCheat.CastBurstPerSec", 8, 2, 100); + // Acceleration/velocity-delta gate (FP-prone; OFF by default). Flags a sudden + // speed increase beyond allowed-speed * this multiplier per second. + setConfig(CONFIG_BOOL_ANTICHEAT_ACCEL_CHECK, "AntiCheat.AccelCheck", false); + setConfigMinMax(CONFIG_UINT32_ANTICHEAT_ACCEL_MULT, "AntiCheat.AccelMaxMult", 6, 2, 50); + // Bot-movement heuristic (snap-to-waypoint + metronomic packet timing over a + // 30s window). Heuristic/FP-prone, so OFF by default. + setConfig(CONFIG_BOOL_ANTICHEAT_BOT_DETECT, "AntiCheat.BotDetect", false); + // Anti-gaming autoban: account-level kick accumulation with slow (hours) decay + // so spacing offences out still accumulates; ban duration escalates. Off by + // default (needs AntiCheat.Enable too). + setConfig(CONFIG_BOOL_AC_AUTOBAN_ENABLE, "AntiCheat.Autoban.Enable", false); + setConfigMinMax(CONFIG_UINT32_AC_AUTOBAN_KICKPOINTS, "AntiCheat.Autoban.KickPoints", 10, 1, 1000); + setConfigMinMax(CONFIG_UINT32_AC_AUTOBAN_THRESHOLD, "AntiCheat.Autoban.Threshold", 30, 1, 100000); + setConfigMinMax(CONFIG_UINT32_AC_AUTOBAN_DECAY_PER_HOUR, "AntiCheat.Autoban.DecayPerHour", 1, 0, 1000); + setConfig(CONFIG_UINT32_AC_AUTOBAN_DUR1, "AntiCheat.Autoban.Duration1", 86400); // 1 day + setConfig(CONFIG_UINT32_AC_AUTOBAN_DUR2, "AntiCheat.Autoban.Duration2", 604800); // 7 days + setConfig(CONFIG_UINT32_AC_AUTOBAN_DUR3, "AntiCheat.Autoban.Duration3", 0); // 0 = permanent + m_relocation_ai_notify_delay = sConfig.GetIntDefault("Visibility.AIRelocationNotifyDelay", 1000u); m_relocation_lower_limit_sq = pow(sConfig.GetFloatDefault("Visibility.RelocationLowerLimit", 10), 2); @@ -1537,6 +1575,9 @@ void World::SetInitialWorldSettings() // for AhBot m_timers[WUPDATE_AHBOT].SetInterval(20 * IN_MILLISECONDS); // every 20 sec + // for Anti-Cheat maintenance (idle score pruning) + m_timers[WUPDATE_ANTICHEAT].SetInterval(30 * IN_MILLISECONDS); // every 30 sec + // for AutoBroadcast sLog.outString("Starting AutoBroadcast System"); if (m_broadcastEnable) @@ -1590,6 +1631,11 @@ void World::SetInitialWorldSettings() sWardenCheckMgr->LoadWardenOverrides(); sLog.outString(); + // Initialize Anti-Cheat / Movement-Validation framework (inert unless enabled) + sLog.outString("Initializing Anti-Cheat framework..."); + sAntiCheatMgr->Init(); + sLog.outString(); + sLog.outString("Deleting expired bans..."); LoginDatabase.Execute("DELETE FROM `ip_banned` WHERE `unbandate`<=UNIX_TIMESTAMP() AND `unbandate`<>`bandate`"); sLog.outString(); @@ -1853,6 +1899,13 @@ void World::Update(uint32 diff) m_timers[WUPDATE_AHBOT].Reset(); } + ///
  • Handle Anti-Cheat maintenance (prune idle scores + apply autobans) + if (m_timers[WUPDATE_ANTICHEAT].Passed()) + { + sAntiCheatMgr->Update(m_timers[WUPDATE_ANTICHEAT].GetCurrent()); + m_timers[WUPDATE_ANTICHEAT].Reset(); + } + #ifdef ENABLE_PLAYERBOTS sRandomPlayerbotMgr.UpdateAI(diff); sRandomPlayerbotMgr.UpdateSessions(diff); diff --git a/src/game/WorldHandlers/World.h b/src/game/WorldHandlers/World.h index 1032e4309..695567615 100644 --- a/src/game/WorldHandlers/World.h +++ b/src/game/WorldHandlers/World.h @@ -86,6 +86,7 @@ enum WorldTimers WUPDATE_EVENTS, WUPDATE_DELETECHARS, WUPDATE_AHBOT, + WUPDATE_ANTICHEAT, WUPDATE_COUNT }; @@ -215,6 +216,24 @@ enum eConfigUInt32Values CONFIG_UINT32_PLAYERBOT_MINBOTLEVEL, #endif CONFIG_UINT32_AUTOBROADCAST_INTERVAL, + // Anti-Cheat / Movement-Validation framework + CONFIG_UINT32_ANTICHEAT_EXEMPT_GM, + CONFIG_UINT32_ANTICHEAT_ACTION, + CONFIG_UINT32_ANTICHEAT_SPEED_TOL, + CONFIG_UINT32_ANTICHEAT_TELE_DIST, + CONFIG_UINT32_ANTICHEAT_SCORE_WARN, + CONFIG_UINT32_ANTICHEAT_SCORE_RUBBER, + CONFIG_UINT32_ANTICHEAT_SCORE_KICK, + CONFIG_UINT32_ANTICHEAT_DECAY, + CONFIG_UINT32_ANTICHEAT_CAST_BURST, + CONFIG_UINT32_ANTICHEAT_ACCEL_MULT, + // Anti-Cheat anti-gaming autoban + CONFIG_UINT32_AC_AUTOBAN_KICKPOINTS, + CONFIG_UINT32_AC_AUTOBAN_THRESHOLD, + CONFIG_UINT32_AC_AUTOBAN_DECAY_PER_HOUR, + CONFIG_UINT32_AC_AUTOBAN_DUR1, + CONFIG_UINT32_AC_AUTOBAN_DUR2, + CONFIG_UINT32_AC_AUTOBAN_DUR3, CONFIG_UINT32_VALUE_COUNT }; @@ -383,6 +402,17 @@ enum eConfigBoolValues // Recommended Or New Flag CONFIG_BOOL_REALM_RECOMMENDED_OR_NEW_ENABLED, CONFIG_BOOL_REALM_RECOMMENDED_OR_NEW, + + // Anti-Cheat / Movement-Validation framework + CONFIG_BOOL_ANTICHEAT_ENABLE, + CONFIG_BOOL_ANTICHEAT_MOVEMENT, + CONFIG_BOOL_ANTICHEAT_PHYSICS, + CONFIG_BOOL_ANTICHEAT_EXEMPT_BOTS, + CONFIG_BOOL_ANTICHEAT_PERSIST, + CONFIG_BOOL_ANTICHEAT_ACCEL_CHECK, + CONFIG_BOOL_ANTICHEAT_BOT_DETECT, + CONFIG_BOOL_AC_AUTOBAN_ENABLE, + CONFIG_BOOL_VALUE_COUNT }; diff --git a/src/mangosd/mangosd.conf.dist.in b/src/mangosd/mangosd.conf.dist.in index 9d4705aa8..0aed3be9f 100644 --- a/src/mangosd/mangosd.conf.dist.in +++ b/src/mangosd/mangosd.conf.dist.in @@ -1944,3 +1944,115 @@ Eluna.CompatibilityMode = false Eluna.OnlyOnMaps = "" Eluna.TraceBack = false Eluna.ScriptPath = "lua_scripts" + +################################################################################################### +# ANTI-CHEAT / MOVEMENT-VALIDATION FRAMEWORK +# +# Server-side movement/physics/spell/item validation. Detectors observe and +# score; the central manager owns all enforcement (log -> GM alert -> +# rubberband -> kick), with an optional account-level autoban. OFF by default: +# with AntiCheat.Enable = 0 the framework is fully inert. +# +# AntiCheat.Enable +# Description: Master switch for the framework. +# Default: 0 - (disabled) +# +# AntiCheat.Movement.Enable / AntiCheat.Physics.Enable +# Description: Toggle the movement detectors and the (moderate-cost) physics +# terrain-plausibility stage. Only matter when Enable = 1. +# Default: 1 - (both on) +# +# AntiCheat.ExemptBots +# Description: Do not validate playerbots (when built with playerbots). +# Default: 1 - (exempt) +# +# AntiCheat.ExemptGMLevel +# Description: Accounts at or above this security level are not validated +# (0 = exempt nobody by level). Range 0-4. +# Default: 1 +# +# AntiCheat.Persist +# Description: Persist detection events to character_anticheat_violation. +# Default: 1 - (persist) +# +# AntiCheat.Action +# Description: Maximum enforcement action the manager may apply. +# 1 = log, 2 = GM alert, 3 = rubberband, 4 = kick. Range 0-4. +# Default: 1 - (log only) +# +# AntiCheat.Speed.Tolerance +# Description: Allowed horizontal speed as a percent of the granted speed +# before the speed detector trips. Range 100-500. +# Default: 110 +# +# AntiCheat.Teleport.Distance +# Description: Single-packet displacement (yards, latency-adjusted) beyond +# which a move is treated as a teleport/blink. Range 10-1000. +# Default: 50 +# +# AntiCheat.Score.Warn / Rubberband / Kick +# Description: Decayed-score thresholds at which the matching action fires +# (each capped by AntiCheat.Action). +# Default: 30 / 60 / 120 +# +# AntiCheat.Score.DecayPerSec +# Description: Points the per-player score decays each second. Range 0-100. +# Default: 2 +# +# AntiCheat.CastBurstPerSec +# Description: Spell cast requests per second above which casting is treated +# as cast spam. Range 2-100. +# Default: 8 +# +# AntiCheat.AccelCheck / AntiCheat.AccelMaxMult +# Description: Optional acceleration/velocity-delta gate (FP-prone, OFF by +# default). Flags a sudden speed increase beyond granted-speed +# times the multiplier per second. Mult range 2-50. +# Default: 0 / 6 +# +# AntiCheat.BotDetect +# Description: Optional bot-movement heuristic (snap-to-waypoint + metronomic +# packet timing over a 30s window). Heuristic/FP-prone, OFF by +# default. +# Default: 0 - (disabled) +# +# AntiCheat.Autoban.Enable +# Description: Account-level autoban accumulator. A kick adds KickPoints to +# the account; crossing Threshold queues an escalating ban +# (Duration1 -> Duration2 -> Duration3). The accumulator decays +# by DecayPerHour. Needs AntiCheat.Enable too. +# Default: 0 - (disabled) +# +# AntiCheat.Autoban.KickPoints / Threshold / DecayPerHour +# Default: 10 / 30 / 1 +# +# AntiCheat.Autoban.Duration1 / Duration2 / Duration3 +# Description: Escalating ban durations in seconds (0 = permanent). +# Default: 86400 / 604800 / 0 (1 day, 7 days, permanent) +# +################################################################################################### + +AntiCheat.Enable = 0 +AntiCheat.Movement.Enable = 1 +AntiCheat.Physics.Enable = 1 +AntiCheat.ExemptBots = 1 +AntiCheat.ExemptGMLevel = 1 +AntiCheat.Persist = 1 +AntiCheat.Action = 1 +AntiCheat.Speed.Tolerance = 110 +AntiCheat.Teleport.Distance = 50 +AntiCheat.Score.Warn = 30 +AntiCheat.Score.Rubberband = 60 +AntiCheat.Score.Kick = 120 +AntiCheat.Score.DecayPerSec = 2 +AntiCheat.CastBurstPerSec = 8 +AntiCheat.AccelCheck = 0 +AntiCheat.AccelMaxMult = 6 +AntiCheat.BotDetect = 0 +AntiCheat.Autoban.Enable = 0 +AntiCheat.Autoban.KickPoints = 10 +AntiCheat.Autoban.Threshold = 30 +AntiCheat.Autoban.DecayPerHour = 1 +AntiCheat.Autoban.Duration1 = 86400 +AntiCheat.Autoban.Duration2 = 604800 +AntiCheat.Autoban.Duration3 = 0 From 38ed01270d70b155ab17d8718933c3a19802220e Mon Sep 17 00:00:00 2001 From: Krill Date: Sat, 20 Jun 2026 23:16:05 -0500 Subject: [PATCH 2/2] Add anti-cheat time-sync subsystem (clock-offset service, desync detector, move-time-skip handling) Builds additively on the anti-cheat core. Adds a vanilla-compatible time-sync layer; fully inert until TimeSync.Enable=1 (the core latency EWMA stays always-on so movement detectors keep their latency-aware tolerances either way). - Per-session client<->server clock-offset service in MovementAnticheat (smoothed serverMs - clientTime; its drift is the desync signal). - DESYNC detection feeding the core scorer: per-packet client/server elapsed-time divergence (incl. zero-client-time-while-moving) + a latency-spike check raised on the network thread and recorded on the safe world thread. - CMSG_MOVE_TIME_SKIPPED handling: relays the skip to nearby observers (kills observer-side warp after a client clock jump), re-baselines the clock service + graces the desync detector for the legit case, and scores oversized/spammed/ accumulated skips as a time-based movement-masking vector. - Move-ack timestamp validation (force-speed / water-walk acks): flags client timestamp regression and folds the sample into the clock-offset service. - Optional movement-timestamp correction (default OFF) normalising the relayed timestamp to the server clock so observers interpolate on one timebase. - Optional desync auto-resync (default OFF): rubberbands a sustained-desync client to its authoritative position (the only vanilla resync lever), cooldown-limited. - GM commands: .timesync status / config / set / resync / skip. Full TimeSync.* config block in mangosd.conf.dist.in; all OFF by default. The spoof/test GM tooling (.timesync desync, SimulateCheat) and the violation marker visualizer remain separate follow-ups and are intentionally not included. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmake/MangosParams.cmake | 2 +- src/game/AntiCheat/MovementAnticheat.cpp | 165 +++++++++++++++++++- src/game/AntiCheat/MovementAnticheat.h | 39 +++++ src/game/ChatCommands/AntiCheatCommands.cpp | 124 +++++++++++++++ src/game/Server/WorldSession.cpp | 42 ++++- src/game/Server/WorldSession.h | 4 + src/game/WorldHandlers/Chat.cpp | 11 ++ src/game/WorldHandlers/Chat.h | 7 + src/game/WorldHandlers/MovementHandler.cpp | 35 +++++ src/game/WorldHandlers/World.cpp | 20 +++ src/game/WorldHandlers/World.h | 10 ++ src/mangosd/mangosd.conf.dist.in | 58 +++++++ 12 files changed, 513 insertions(+), 4 deletions(-) diff --git a/cmake/MangosParams.cmake b/cmake/MangosParams.cmake index 549626ca5..235ed9136 100644 --- a/cmake/MangosParams.cmake +++ b/cmake/MangosParams.cmake @@ -1,5 +1,5 @@ set(MANGOS_EXP "CLASSIC") set(MANGOS_PKG "Mangos Zero") -set(MANGOS_WORLD_VER 2026062002) +set(MANGOS_WORLD_VER 2026062003) set(MANGOS_REALM_VER 2026060300) set(MANGOS_AHBOT_VER 2021010100) diff --git a/src/game/AntiCheat/MovementAnticheat.cpp b/src/game/AntiCheat/MovementAnticheat.cpp index 7ab414733..1b372da99 100644 --- a/src/game/AntiCheat/MovementAnticheat.cpp +++ b/src/game/AntiCheat/MovementAnticheat.cpp @@ -24,6 +24,8 @@ namespace const float FALL_SUPPRESS_YD = 20.0f; // drop beyond this should incur fall damage const uint32 BURST_PER_SEC = 50; // movement packets/sec beyond this = burst const uint32 CLIENT_TIME_BACK_MS = 500; // client timestamp regression tolerance + const uint32 SKIP_WINDOW_MS = 10000; // move-time-skip abuse counting window + const uint32 SKIP_MAX_PER_WINDOW = 10; // legit clients rarely skip this often const uint32 CAST_WINDOW_MS = 1000; // cast-spam counting window const uint32 CAST_GCD_SLACK_MS = 150; // tolerance below GCD on top of latency const float NOCLIP_MIN_STEP = 4.0f; // min ground step (yd) to run the LoS no-clip test @@ -55,8 +57,12 @@ MovementAnticheat::MovementAnticheat(Player* owner) m_hasValid(false), m_validX(0.f), m_validY(0.f), m_validZ(0.f), m_validO(0.f), m_airborne(false), m_fallApexZ(0.f), m_burstWinStartMS(0), m_burstCount(0), m_lastClientTime(0), m_hasClientTime(false), + m_hasClockOffset(false), m_clockOffsetMs(0), + m_timeSkipGraceUntilMS(0), m_desyncStreak(0), m_lastResyncMS(0), + m_skipWinStartMS(0), m_skipCount(0), m_skipAccumMs(0), m_hasLastCast(false), m_lastCastMS(0), m_lastCastGcd(0), m_castWinStartMS(0), m_castCount(0), + m_hasAckTime(false), m_lastAckTime(0), m_hasKin(false), m_lastSpeed(0.f), m_grantedFlags(0), m_botWinStartMS(0), m_botSamples(0), m_botCleanCycles(0), m_botRunDist(0.f), @@ -123,11 +129,16 @@ void MovementAnticheat::HandlePositionUpdate(uint16 opcode, MovementInfo const& sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_BURST, 15.0f, bctx); } - // --- Detector: client movement-timestamp regression --- + // --- Detector: client movement-timestamp regression --- (also capture the + // client-reported time delta for the time-sync divergence check below) + uint32 clientDt = 0; + bool haveClientDt = false; { uint32 ct = mi.GetTime(); if (m_hasClientTime) { + haveClientDt = true; + clientDt = (ct >= m_lastClientTime) ? (ct - m_lastClientTime) : 0; if (m_lastClientTime > ct && (m_lastClientTime - ct) > CLIENT_TIME_BACK_MS) { AntiCheatContext tctx; @@ -140,6 +151,13 @@ void MovementAnticheat::HandlePositionUpdate(uint16 opcode, MovementInfo const& } m_lastClientTime = ct; m_hasClientTime = true; + + // Movement-sync clock-offset service: smoothed (serverMs - clientTime). + // Absolute value is arbitrary (different epochs); its drift over time is + // the desync signal, and it backs the optional relay correction. + int64 sampleOffset = int64(nowMS) - int64(ct); + if (!m_hasClockOffset) { m_clockOffsetMs = sampleOffset; m_hasClockOffset = true; } + else { m_clockOffsetMs = (sampleOffset * 20 + m_clockOffsetMs * 80) / 100; } } uint32 dtMS = getMSTimeDiff(m_lastMS, nowMS); @@ -297,6 +315,41 @@ void MovementAnticheat::HandlePositionUpdate(uint16 opcode, MovementInfo const& cheapTrip = true; } + // --- Detector: time-sync divergence (client vs server elapsed time) --- + // The client's reported elapsed time should track the server's measured + // elapsed time within latency jitter. Zero client-time while moving, or a + // large divergence, is time-manipulation desync (fake-slow movement / speed + // via clock control) — the vanilla-compatible equivalent of WotLK time sync. + // The grace window after a client-reported MOVE_TIME_SKIPPED suppresses this + // (the client already told us its clock jumped — not a cheat). + if (sWorld.getConfig(CONFIG_BOOL_TIMESYNC_ENABLE) && + haveClientDt && horiz > 1.0f && state != AC_MOVE_TRANSPORT && + nowMS >= m_timeSkipGraceUntilMS) + { + uint32 tol = sWorld.getConfig(CONFIG_UINT32_TIMESYNC_DESYNC) + latency; + if (clientDt == 0) + { + ctx.detail = "zero client time while moving (time hack)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_DESYNC, 20.0f, ctx); + cheapTrip = true; + ++m_desyncStreak; + } + else + { + uint32 div = clientDt > dtMS ? clientDt - dtMS : dtMS - clientDt; + if (div > tol) + { + ctx.detail = "client/server time divergence (desync)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_DESYNC, 8.0f, ctx); + ++m_desyncStreak; // feeds optional auto-resync + } + else if (m_desyncStreak > 0) + { + --m_desyncStreak; // decay on clean, in-sync packets + } + } + } + // --- Detector: unexplained vertical climb on the ground --- if (state == AC_MOVE_GROUND && dz > VERT_CLIMB_SUSPECT && horiz < dz && !mi.HasMovementFlag(MovementFlags(MOVEFLAG_FALLING | MOVEFLAG_FALLINGFAR))) @@ -468,6 +521,27 @@ void MovementAnticheat::PeriodicCheck() if (sAntiCheatMgr->IsExempt(m_player)) return; + // --- Desync auto-resync (gated, OFF by default) --- + // When the per-packet desync detector has tripped repeatedly, the client clock + // has drifted out of sync. There is no vanilla TIME_SYNC opcode to correct it, + // so the only reliable lever is to rubberband the client to its current + // server-authoritative position; NotifyServerRelocation re-baselines so the + // correction itself isn't re-scored. Cooldown-limited to avoid yo-yoing. + if (sWorld.getConfig(CONFIG_BOOL_TIMESYNC_AUTORESYNC) && + m_desyncStreak >= sWorld.getConfig(CONFIG_UINT32_TIMESYNC_RESYNC_TRIPS)) + { + uint32 now = getMSTime(); + if (now - m_lastResyncMS >= sWorld.getConfig(CONFIG_UINT32_TIMESYNC_RESYNC_COOLDOWN)) + { + m_player->NearTeleportTo(m_player->GetPositionX(), m_player->GetPositionY(), + m_player->GetPositionZ(), m_player->GetOrientation()); + NotifyServerRelocation(); + m_lastResyncMS = now; + m_desyncStreak = 0; + sLog.outDetail("TimeSync: resync guid=%u (sustained desync)", m_player->GetGUIDLow()); + } + } + // Idle terrain re-validation needs the physics module enabled. if (!sAntiCheatMgr->PhysicsEnabled()) return; @@ -536,3 +610,92 @@ void MovementAnticheat::NotifySpellCast(uint32 /*spellId*/, uint32 /*castTimeMs* m_lastCastMS = now; m_lastCastGcd = gcdMs; } + +void MovementAnticheat::NotifyClientTimeSkip(uint32 skippedMs) +{ + if (!m_player) + return; + + uint32 now = getMSTime(); + + // --- Anti-cheat: CMSG_MOVE_TIME_SKIPPED abuse (time-based movement masking) --- + // Cheats inflate or spam the reported skip to claim extra movement budget + // (covering speed/teleport distance "in skipped time"). Score by magnitude, + // frequency and accumulation within a rolling window. + if (m_skipWinStartMS == 0 || now - m_skipWinStartMS > SKIP_WINDOW_MS) + { + m_skipWinStartMS = now; + m_skipCount = 0; + m_skipAccumMs = 0; + } + ++m_skipCount; + m_skipAccumMs += skippedMs; + + if (sAntiCheatMgr->MovementEnabled() && !sAntiCheatMgr->IsExempt(m_player)) + { + uint32 maxSkip = sWorld.getConfig(CONFIG_UINT32_TIMESYNC_MAX_SKIP); + AntiCheatContext ctx; + ctx.mapId = m_player->GetMapId(); + ctx.x = m_player->GetPositionX(); ctx.y = m_player->GetPositionY(); ctx.z = m_player->GetPositionZ(); + ctx.latency = m_player->GetSession() ? m_player->GetSession()->GetLatencyEWMA() : 0; + + if (skippedMs > maxSkip) + { + float ratio = float(skippedMs) / float(maxSkip ? maxSkip : 1); + float weight = ratio * 8.0f; + if (weight > 30.0f) weight = 30.0f; + ctx.detail = "oversized move-time-skip (time hack)"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_DESYNC, weight, ctx); + } + if (m_skipCount > SKIP_MAX_PER_WINDOW) + { + ctx.detail = "move-time-skip spam"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_PACKETTIMING, 12.0f, ctx); + } + if (m_skipAccumMs > maxSkip * 3) + { + ctx.detail = "excessive accumulated time-skip"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_DESYNC, 10.0f, ctx); + } + } + + // --- Legit handling: a real skip means the client clock jumped, so re-baseline + // the clock service and grace the per-packet desync detector so the same event + // isn't double-counted as divergence. --- + uint32 grace = skippedMs + 1000; + if (grace > 5000) grace = 5000; + m_timeSkipGraceUntilMS = now + grace; + m_hasClockOffset = false; // re-seed offset from the new client timebase + m_hasClientTime = false; // re-seed the client-timestamp baseline + m_trustNext = true; // the skip itself moves nothing; trust the next packet +} + +void MovementAnticheat::NotifyMoveAckTime(uint32 clientTime) +{ + if (!m_player || !sAntiCheatMgr->MovementEnabled() || sAntiCheatMgr->IsExempt(m_player)) + return; + + uint32 now = getMSTime(); + + // Timestamp regression in an ACK means a manipulated client clock. Suppressed + // during the grace window after a legitimate reported time skip. + if (m_hasAckTime && m_lastAckTime > clientTime && + (m_lastAckTime - clientTime) > CLIENT_TIME_BACK_MS && + now >= m_timeSkipGraceUntilMS) + { + AntiCheatContext ctx; + ctx.mapId = m_player->GetMapId(); + ctx.x = m_player->GetPositionX(); ctx.y = m_player->GetPositionY(); ctx.z = m_player->GetPositionZ(); + ctx.latency = m_player->GetSession() ? m_player->GetSession()->GetLatencyEWMA() : 0; + ctx.detail = "ack client timestamp regression"; + sAntiCheatMgr->RecordViolation(m_player, AC_VIOLATION_PACKETTIMING, 10.0f, ctx); + } + m_lastAckTime = clientTime; + m_hasAckTime = true; + + // Fold the ack sample into the clock-offset service (independent of the + // movement per-packet delta, so it can't cause a false desync). + int64 sampleOffset = int64(now) - int64(clientTime); + if (!m_hasClockOffset) { m_clockOffsetMs = sampleOffset; m_hasClockOffset = true; } + else { m_clockOffsetMs = (sampleOffset * 20 + m_clockOffsetMs * 80) / 100; } +} diff --git a/src/game/AntiCheat/MovementAnticheat.h b/src/game/AntiCheat/MovementAnticheat.h index 40aff3cf4..5f5ffc1d2 100644 --- a/src/game/AntiCheat/MovementAnticheat.h +++ b/src/game/AntiCheat/MovementAnticheat.h @@ -32,6 +32,14 @@ class MovementAnticheat // movement packets are sent (e.g. standing inside geometry). void PeriodicCheck(); + // Movement-sync clock service: smoothed offset between the server clock + // (getMSTime) and the client's reported movement timestamps. Its drift over + // time is the desync signal; consumers (detection + optional relay + // correction) read it. Valid once HasClockOffset() is true. + bool HasClockOffset() const { return m_hasClockOffset; } + int64 GetClockOffsetMs() const { return m_clockOffsetMs; } + uint32 GetDesyncStreak() const { return m_desyncStreak; } + // Called when the SERVER relocates the player (teleport ack, map change) // so the next client packet is trusted and the baseline is rebuilt // instead of being scored as an impossible jump. @@ -46,11 +54,23 @@ class MovementAnticheat if (on) m_grantedFlags |= flag; else m_grantedFlags &= ~flag; } + // Called when the client reports a movement time skip + // (CMSG_MOVE_TIME_SKIPPED) — a legitimate client clock jump after a freeze. + // Re-baselines the clock service + arms a grace window so the same event + // isn't double-scored as desync, AND scores the skip itself for abuse + // (oversized / spammed skips are a time-based speed/teleport-mask vector). + void NotifyClientTimeSkip(uint32 skippedMs); + // Time-based anti-cheat for the spell-cast path (CMSG_CAST_SPELL): detects // GCD bypass (casts closer together than the spell's global cooldown allows) // and cast spam. castTimeMs/gcdMs come from the spell entry. void NotifySpellCast(uint32 spellId, uint32 castTimeMs, uint32 gcdMs); + // Time-based anti-cheat for movement ACK packets (force-speed-change, + // water-walk): flags client-timestamp regression in the ack (manipulated + // ack timing) and folds the sample into the clock-offset service. + void NotifyMoveAckTime(uint32 clientTime); + // Last position that passed the teleport/physics gates (rubberband target // for enforcement). Valid only if HasValid() is true. bool HasValid() const { return m_hasValid; } @@ -83,6 +103,20 @@ class MovementAnticheat uint32 m_lastClientTime; // last MovementInfo client timestamp bool m_hasClientTime; + // Movement-sync clock-offset service (smoothed server-vs-client clock). + bool m_hasClockOffset; + int64 m_clockOffsetMs; + + // Time-sync / desync-resync state. + uint32 m_timeSkipGraceUntilMS; // suppress per-packet desync scoring until here + uint32 m_desyncStreak; // consecutive desync trips (auto-resync trigger) + uint32 m_lastResyncMS; // last auto-resync server time (cooldown) + + // CMSG_MOVE_TIME_SKIPPED abuse window (frequency + accumulation). + uint32 m_skipWinStartMS; + uint32 m_skipCount; + uint32 m_skipAccumMs; + // Spell-cast timing (GCD bypass + cast spam). bool m_hasLastCast; uint32 m_lastCastMS; @@ -90,6 +124,11 @@ class MovementAnticheat uint32 m_castWinStartMS; uint32 m_castCount; + // Movement-ACK client-timestamp tracking (kept separate from the movement + // path's m_lastClientTime so it can't skew the per-packet desync delta). + bool m_hasAckTime; + uint32 m_lastAckTime; + // Kinematics (acceleration / velocity-delta gate). bool m_hasKin; float m_lastSpeed; diff --git a/src/game/ChatCommands/AntiCheatCommands.cpp b/src/game/ChatCommands/AntiCheatCommands.cpp index e1bcbe3a4..1d07935cd 100644 --- a/src/game/ChatCommands/AntiCheatCommands.cpp +++ b/src/game/ChatCommands/AntiCheatCommands.cpp @@ -165,3 +165,127 @@ bool ChatHandler::HandleAntiCheatDeleteCommand(char* args) PSendSysMessage("AntiCheat: cleared violation records for %s (guid %u).", name.empty() ? "?" : name.c_str(), guid); return true; } + +// --- Time-sync / desync subsystem commands (get/set/inspect + actions) --- + +bool ChatHandler::HandleTimeSyncStatusCommand(char* /*args*/) +{ + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage(".timesync status FAILED: no player. Select/target a player or run in-game."); + SetSentErrorMessage(true); + return false; + } + + uint32 latency = target->GetSession() ? target->GetSession()->GetLatencyEWMA() : 0; + MovementAnticheat* mac = target->GetMovementAnticheat(); + PSendSysMessage("TimeSync %s: latency(EWMA)=%ums clockOffset=%s%lldms desyncStreak=%u", + target->GetName(), latency, + (mac && mac->HasClockOffset()) ? "" : "(none) ", + (mac && mac->HasClockOffset()) ? (long long)mac->GetClockOffsetMs() : 0LL, + mac ? mac->GetDesyncStreak() : 0); + return true; +} + +bool ChatHandler::HandleTimeSyncConfigCommand(char* /*args*/) +{ + PSendSysMessage("TimeSync config: enable=%u alpha=%u desyncThreshold=%ums maxSkip=%ums", + (uint32)sWorld.getConfig(CONFIG_BOOL_TIMESYNC_ENABLE), + sWorld.getConfig(CONFIG_UINT32_TIMESYNC_ALPHA), + sWorld.getConfig(CONFIG_UINT32_TIMESYNC_DESYNC), + sWorld.getConfig(CONFIG_UINT32_TIMESYNC_MAX_SKIP)); + PSendSysMessage(" moveCorrection=%u autoResync=%u resyncTrips=%u resyncCooldown=%ums", + (uint32)sWorld.getConfig(CONFIG_BOOL_TIMESYNC_MOVE_CORRECTION), + (uint32)sWorld.getConfig(CONFIG_BOOL_TIMESYNC_AUTORESYNC), + sWorld.getConfig(CONFIG_UINT32_TIMESYNC_RESYNC_TRIPS), + sWorld.getConfig(CONFIG_UINT32_TIMESYNC_RESYNC_COOLDOWN)); + SendSysMessage("Set at runtime with .timesync set (reverts on restart/reload)."); + return true; +} + +bool ChatHandler::HandleTimeSyncSetCommand(char* args) +{ + char* f = strtok(args, " "); + char* v = strtok(NULL, " "); + if (!f || !v) + { + SendSysMessage(".timesync set FAILED. Usage: .timesync set . Fields:"); + SendSysMessage(" bool: enable, movecorrection, autoresync"); + SendSysMessage(" uint: alpha, desyncthreshold, maxskip, resynctrips, resynccooldown"); + SetSentErrorMessage(true); + return false; + } + std::string field = f; + for (size_t i = 0; i < field.size(); ++i) field[i] = (char)tolower(field[i]); + uint32 val = uint32(atoi(v)); + + if (field == "enable") sWorld.setConfig(CONFIG_BOOL_TIMESYNC_ENABLE, val != 0); + else if (field == "movecorrection") sWorld.setConfig(CONFIG_BOOL_TIMESYNC_MOVE_CORRECTION, val != 0); + else if (field == "autoresync") sWorld.setConfig(CONFIG_BOOL_TIMESYNC_AUTORESYNC, val != 0); + else if (field == "alpha") sWorld.setConfig(CONFIG_UINT32_TIMESYNC_ALPHA, val); + else if (field == "desyncthreshold")sWorld.setConfig(CONFIG_UINT32_TIMESYNC_DESYNC, val); + else if (field == "maxskip") sWorld.setConfig(CONFIG_UINT32_TIMESYNC_MAX_SKIP, val); + else if (field == "resynctrips") sWorld.setConfig(CONFIG_UINT32_TIMESYNC_RESYNC_TRIPS, val); + else if (field == "resynccooldown") sWorld.setConfig(CONFIG_UINT32_TIMESYNC_RESYNC_COOLDOWN, val); + else + { + PSendSysMessage(".timesync set FAILED: unknown field '%s'.", field.c_str()); + SetSentErrorMessage(true); + return false; + } + PSendSysMessage("TimeSync: set %s = %u (runtime; reverts on restart/reload).", field.c_str(), val); + return true; +} + +bool ChatHandler::HandleTimeSyncResyncCommand(char* /*args*/) +{ + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage(".timesync resync FAILED: no player. Select/target a player or run in-game. " + "It rubberbands them to their current authoritative position (clock resync)."); + SetSentErrorMessage(true); + return false; + } + + target->NearTeleportTo(target->GetPositionX(), target->GetPositionY(), + target->GetPositionZ(), target->GetOrientation()); + if (MovementAnticheat* mac = target->GetMovementAnticheat()) + mac->NotifyServerRelocation(); + PSendSysMessage("TimeSync: resynced %s to current position.", target->GetName()); + return true; +} + +bool ChatHandler::HandleTimeSyncSkipCommand(char* args) +{ + char* tok = strtok(args, " "); + if (!tok) + { + SendSysMessage(".timesync skip FAILED: needs milliseconds. Usage: .timesync skip . " + "Feeds a synthetic client time-skip to the target (drives the time-sync " + "service; large values score as a time hack)."); + SetSentErrorMessage(true); + return false; + } + uint32 ms = uint32(atoi(tok)); + + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage(".timesync skip FAILED: no player. Select/target a player or run in-game."); + SetSentErrorMessage(true); + return false; + } + + if (MovementAnticheat* mac = target->GetMovementAnticheat()) + mac->NotifyClientTimeSkip(ms); + PSendSysMessage("TimeSync: fed a %u ms time-skip to %s.", ms, target->GetName()); + return true; +} diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index 6da38f90e..e272699e6 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -53,6 +53,7 @@ #include "WorldPacket.h" #include "WorldSession.h" #include "Player.h" +#include "AntiCheatMgr.h" #include "ObjectMgr.h" #include "Group.h" #include "CinematicFlyover.h" @@ -150,6 +151,7 @@ WorldSession::WorldSession(uint32 id, WorldSocket* sock, AccountTypes sec, time_ m_inQueue(false), m_playerLoading(false), m_playerLogout(false), m_playerRecentlyLogout(false), m_playerSave(false), m_sessionDbcLocale(sWorld.GetAvailableDbcLocale(locale)), m_sessionDbLocaleIndex(sObjectMgr.GetIndexForLocale(locale)), m_latency(0), m_latIdx(0), m_latCount(0), m_latEWMA(0), m_latMin(0), m_latMax(0), + m_desyncPending(false), m_desyncValue(0), m_clientTimeDelay(0), m_tutorialState(TUTORIALDATA_UNCHANGED), m_npcWatchLastGuid() { memset(m_latSamples, 0, sizeof(m_latSamples)); @@ -180,11 +182,30 @@ void WorldSession::UpdateLatencyStats(uint32 sampleMS) m_latMin = (mn == 0xFFFFFFFF) ? 0 : mn; m_latMax = mx; - // EWMA with a fixed 20% weight on the newest sample. + // Time-sync desync detection (opt-in): a sample deviating wildly from the + // established baseline (latency spoofing / time manipulation). Once the window + // is full, compare against the PREVIOUS smoothed value before folding the new + // sample. Flagged here (network thread); recorded later on the safe thread. + if (sWorld.getConfig(CONFIG_BOOL_TIMESYNC_ENABLE) && + m_latCount >= LATENCY_WINDOW && m_latEWMA > 0) + { + uint32 dev = sampleMS > m_latEWMA ? sampleMS - m_latEWMA : m_latEWMA - sampleMS; + if (dev > sWorld.getConfig(CONFIG_UINT32_TIMESYNC_DESYNC)) + { + m_desyncPending = true; + m_desyncValue = dev; + } + } + + // EWMA with alpha (percent) from config (default 20): ewma = a*sample + (1-a)*ewma. + // Always maintained so the core movement detectors have latency-aware tolerances + // regardless of TimeSync.Enable. + uint32 alpha = sWorld.getConfig(CONFIG_UINT32_TIMESYNC_ALPHA); + if (alpha > 100) { alpha = 100; } if (m_latEWMA == 0) m_latEWMA = sampleMS; else - m_latEWMA = (20 * sampleMS + 80 * m_latEWMA) / 100; + m_latEWMA = (alpha * sampleMS + (100 - alpha) * m_latEWMA) / 100; } /// WorldSession destructor @@ -470,6 +491,23 @@ bool WorldSession::Update(PacketFilter& updater) _warden->Update(); } + // Anti-Cheat: consume a pending latency-desync flag (raised on the network + // thread in UpdateLatencyStats) here on the safe map/world thread. + if (m_desyncPending) + { + m_desyncPending = false; + Player* p = GetPlayer(); + if (p && p->IsInWorld() && sAntiCheatMgr->MovementEnabled() && !sAntiCheatMgr->IsExempt(p)) + { + AntiCheatContext ctx; + ctx.mapId = p->GetMapId(); + ctx.x = p->GetPositionX(); ctx.y = p->GetPositionY(); ctx.z = p->GetPositionZ(); + ctx.latency = m_latEWMA; + ctx.detail = "latency desync spike"; + sAntiCheatMgr->RecordViolation(p, AC_VIOLATION_DESYNC, 5.0f, ctx); + } + } + // check if we are safe to proceed with logout // logout procedure should happen only in World::UpdateSessions() method!!! if (updater.ProcessLogout()) diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index cb71c9e4a..514e2b889 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -896,6 +896,10 @@ class WorldSession uint32 m_latEWMA; uint32 m_latMin; uint32 m_latMax; + // Time-sync desync detection: flagged on the network thread (HandlePing), + // consumed on the safe map/world thread in WorldSession::Update. + bool m_desyncPending; + uint32 m_desyncValue; uint32 m_Tutorials[8]; TutorialDataState m_tutorialState; uint32 m_clientTimeDelay; diff --git a/src/game/WorldHandlers/Chat.cpp b/src/game/WorldHandlers/Chat.cpp index 3ba661771..e8e1a0284 100644 --- a/src/game/WorldHandlers/Chat.cpp +++ b/src/game/WorldHandlers/Chat.cpp @@ -253,6 +253,16 @@ ChatCommand* ChatHandler::getCommandTable() { NULL, 0, false, NULL, "", NULL } }; + static ChatCommand timesyncCommandTable[] = + { + { "status", SEC_GAMEMASTER, true, &ChatHandler::HandleTimeSyncStatusCommand, "", NULL }, + { "config", SEC_GAMEMASTER, true, &ChatHandler::HandleTimeSyncConfigCommand, "", NULL }, + { "set", SEC_ADMINISTRATOR, true, &ChatHandler::HandleTimeSyncSetCommand, "", NULL }, + { "resync", SEC_GAMEMASTER, true, &ChatHandler::HandleTimeSyncResyncCommand, "", NULL }, + { "skip", SEC_GAMEMASTER, true, &ChatHandler::HandleTimeSyncSkipCommand, "", NULL }, + { NULL, 0, false, NULL, "", NULL } + }; + static ChatCommand debugCommandTable[] = { { "anim", SEC_GAMEMASTER, false, &ChatHandler::HandleDebugAnimCommand, "", NULL }, @@ -770,6 +780,7 @@ ChatCommand* ChatHandler::getCommandTable() { "auction", SEC_ADMINISTRATOR, false, NULL, "", auctionCommandTable }, { "ahbot", SEC_ADMINISTRATOR, true, NULL, "", ahbotCommandTable }, { "anticheat", SEC_GAMEMASTER, true, NULL, "", anticheatCommandTable}, + { "timesync", SEC_GAMEMASTER, true, NULL, "", timesyncCommandTable }, { "cast", SEC_ADMINISTRATOR, false, NULL, "", castCommandTable }, { "character", SEC_GAMEMASTER, true, NULL, "", characterCommandTable}, { "debug", SEC_MODERATOR, true, NULL, "", debugCommandTable }, diff --git a/src/game/WorldHandlers/Chat.h b/src/game/WorldHandlers/Chat.h index a0cc3bb31..52fc0e8bc 100644 --- a/src/game/WorldHandlers/Chat.h +++ b/src/game/WorldHandlers/Chat.h @@ -262,6 +262,13 @@ class ChatHandler bool HandleAntiCheatUnjailCommand(char* args); bool HandleAntiCheatDeleteCommand(char* args); + // Anti-Cheat time-sync subsystem commands + bool HandleTimeSyncStatusCommand(char* args); + bool HandleTimeSyncConfigCommand(char* args); + bool HandleTimeSyncSetCommand(char* args); + bool HandleTimeSyncResyncCommand(char* args); + bool HandleTimeSyncSkipCommand(char* args); + bool HandleAuctionAllianceCommand(char* args); bool HandleAuctionGoblinCommand(char* args); bool HandleAuctionHordeCommand(char* args); diff --git a/src/game/WorldHandlers/MovementHandler.cpp b/src/game/WorldHandlers/MovementHandler.cpp index 21420e2d7..3e87846b1 100644 --- a/src/game/WorldHandlers/MovementHandler.cpp +++ b/src/game/WorldHandlers/MovementHandler.cpp @@ -58,6 +58,8 @@ #include "Opcodes.h" #include "Log.h" #include "Player.h" +#include "Timer.h" +#include "World.h" #include "AntiCheatMgr.h" #include "MovementAnticheat.h" #include "MapManager.h" @@ -369,6 +371,13 @@ void WorldSession::HandleMovementOpcodes(WorldPacket& recv_data) plMover->UpdateFallInformationIfNeed(movementInfo, opcode); } + // Movement-sync: optionally normalise the timestamp relayed to other players to + // the server clock, so all observers interpolate movement on one consistent + // timebase (reduces other-player warp/stutter). Gated, OFF by default; only + // affects the broadcast copy, not the applied/stored state. + if (sWorld.getConfig(CONFIG_BOOL_TIMESYNC_MOVE_CORRECTION)) + movementInfo.UpdateTime(getMSTime()); + WorldPacket data(opcode, uint16(recv_data.size() + 2)); data << mover->GetPackGUID(); // write guid movementInfo.Write(data); // write data @@ -426,6 +435,10 @@ void WorldSession::HandleForceSpeedChangeAckOpcodes(WorldPacket& recv_data) return; } + // Anti-Cheat: validate the ACK's client timestamp (regression = manipulated clock). + if (sAntiCheatMgr->MovementEnabled() && !sAntiCheatMgr->IsExempt(_player)) + _player->GetMovementAnticheat()->NotifyMoveAckTime(movementInfo.GetTime()); + // client ACK send one packet for mounted/run case and need skip all except last from its // in other cases anti-cheat check can be fail in false case UnitMoveType move_type; @@ -639,6 +652,10 @@ void WorldSession::HandleMoveWaterWalkAck(WorldPacket& recv_data) recv_data.read_skip(); // unk recv_data >> movementInfo; recv_data >> Unused(); // unk2 + + // Anti-Cheat: validate the ACK's client timestamp (regression = manipulated clock). + if (sAntiCheatMgr->MovementEnabled() && !sAntiCheatMgr->IsExempt(_player)) + _player->GetMovementAnticheat()->NotifyMoveAckTime(movementInfo.GetTime()); } /** @@ -671,6 +688,24 @@ void WorldSession::HandleMoveTimeSkippedOpcode(WorldPacket& recv_data) recv_data >> guid; recv_data >> time_skipped; DEBUG_LOG("WORLD: Received opcode CMSG_MOVE_TIME_SKIPPED for %s, time_skipped: %u", guid.GetString().c_str(), time_skipped); + + Unit* mover = _player->GetMover(); + // Anti-spoof: the reported guid must be this session's active mover. + if (!mover || mover->GetObjectGuid() != guid) + return; + + // Relay to nearby players so their interpolation of this mover stays aligned + // after the mover's client clock skip (fixes observer-side warp/stutter). + WorldPacket data(MSG_MOVE_TIME_SKIPPED, mover->GetPackGUID().size() + 4); + data << mover->GetPackGUID(); + data << uint32(time_skipped); + mover->SendMessageToSetExcept(&data, _player); + + // Feed the skip into the per-player time-sync / anti-cheat service: re-baseline + // for the legitimate case + score abuse (oversized/spammed skips = time hacks). + Player* plMover = mover->GetTypeId() == TYPEID_PLAYER ? (Player*)mover : NULL; + if (plMover && sAntiCheatMgr->MovementEnabled()) + plMover->GetMovementAnticheat()->NotifyClientTimeSkip(time_skipped); } /** diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index db8925b94..6999dfb2d 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -957,6 +957,26 @@ void World::LoadConfigSettings(bool reload) // Bot-movement heuristic (snap-to-waypoint + metronomic packet timing over a // 30s window). Heuristic/FP-prone, so OFF by default. setConfig(CONFIG_BOOL_ANTICHEAT_BOT_DETECT, "AntiCheat.BotDetect", false); + + // Time-sync subsystem (clock-offset service + desync detection + move-time-skip + // handling). Master switch OFF by default; the latency EWMA itself is always + // maintained for the core movement detectors regardless of this switch. + setConfig(CONFIG_BOOL_TIMESYNC_ENABLE, "TimeSync.Enable", false); + setConfigMinMax(CONFIG_UINT32_TIMESYNC_ALPHA, "TimeSync.EWMA.Alpha", 20, 1, 100); + setConfigMinMax(CONFIG_UINT32_TIMESYNC_DESYNC, "TimeSync.Desync.Threshold", 1000, 100, 60000); + // CMSG_MOVE_TIME_SKIPPED handling: skips above this many ms are treated as a + // time-based cheat signal (legit lag-freezes are usually well under this). + setConfigMinMax(CONFIG_UINT32_TIMESYNC_MAX_SKIP, "TimeSync.MaxSkipMs", 2000, 200, 60000); + // Optional desync auto-resync: after this many sustained desync trips, rubberband + // the client to its authoritative position (the only vanilla resync lever). + setConfig(CONFIG_BOOL_TIMESYNC_AUTORESYNC, "TimeSync.AutoResync", false); + setConfigMinMax(CONFIG_UINT32_TIMESYNC_RESYNC_TRIPS, "TimeSync.ResyncDesyncTrips", 5, 1, 100); + setConfigMinMax(CONFIG_UINT32_TIMESYNC_RESYNC_COOLDOWN, "TimeSync.ResyncCooldownMs", 10000, 1000, 600000); + // Movement-sync corrective option: normalise relayed movement timestamps to + // the server clock so observers interpolate other players on one timebase. + // Higher-risk movement netcode — OFF by default for A/B testing. + setConfig(CONFIG_BOOL_TIMESYNC_MOVE_CORRECTION, "TimeSync.MovementCorrection", false); + // Anti-gaming autoban: account-level kick accumulation with slow (hours) decay // so spacing offences out still accumulates; ban duration escalates. Off by // default (needs AntiCheat.Enable too). diff --git a/src/game/WorldHandlers/World.h b/src/game/WorldHandlers/World.h index 695567615..f1c548bae 100644 --- a/src/game/WorldHandlers/World.h +++ b/src/game/WorldHandlers/World.h @@ -227,6 +227,12 @@ enum eConfigUInt32Values CONFIG_UINT32_ANTICHEAT_DECAY, CONFIG_UINT32_ANTICHEAT_CAST_BURST, CONFIG_UINT32_ANTICHEAT_ACCEL_MULT, + // Anti-Cheat time-sync subsystem + CONFIG_UINT32_TIMESYNC_ALPHA, + CONFIG_UINT32_TIMESYNC_DESYNC, + CONFIG_UINT32_TIMESYNC_MAX_SKIP, + CONFIG_UINT32_TIMESYNC_RESYNC_TRIPS, + CONFIG_UINT32_TIMESYNC_RESYNC_COOLDOWN, // Anti-Cheat anti-gaming autoban CONFIG_UINT32_AC_AUTOBAN_KICKPOINTS, CONFIG_UINT32_AC_AUTOBAN_THRESHOLD, @@ -412,6 +418,10 @@ enum eConfigBoolValues CONFIG_BOOL_ANTICHEAT_ACCEL_CHECK, CONFIG_BOOL_ANTICHEAT_BOT_DETECT, CONFIG_BOOL_AC_AUTOBAN_ENABLE, + // Anti-Cheat time-sync subsystem + CONFIG_BOOL_TIMESYNC_ENABLE, + CONFIG_BOOL_TIMESYNC_AUTORESYNC, + CONFIG_BOOL_TIMESYNC_MOVE_CORRECTION, CONFIG_BOOL_VALUE_COUNT }; diff --git a/src/mangosd/mangosd.conf.dist.in b/src/mangosd/mangosd.conf.dist.in index 0aed3be9f..69ff19832 100644 --- a/src/mangosd/mangosd.conf.dist.in +++ b/src/mangosd/mangosd.conf.dist.in @@ -2056,3 +2056,61 @@ AntiCheat.Autoban.DecayPerHour = 1 AntiCheat.Autoban.Duration1 = 86400 AntiCheat.Autoban.Duration2 = 604800 AntiCheat.Autoban.Duration3 = 0 + +################################################################################################### +# ANTI-CHEAT TIME-SYNC SUBSYSTEM +# +# Builds additively on the anti-cheat core. Provides a per-session client<->server +# clock-offset service, time-based desync detection, CMSG_MOVE_TIME_SKIPPED handling, +# an optional movement-timestamp correction, and an optional desync auto-resync. +# GM commands: .timesync status / config / set / resync / skip. +# +# NOTE: the per-session latency window + EWMA (used by the core movement detectors +# for latency-aware tolerances) is ALWAYS maintained regardless of TimeSync.Enable; +# this switch only governs the time-sync desync detection layer. +# +# TimeSync.Enable +# Description: Master switch for the desync-detection layer (latency-spike +# desync + client/server elapsed-time divergence). The skip +# relay and abuse scoring run under AntiCheat.Movement.Enable. +# Default: 0 - (disabled) +# +# TimeSync.EWMA.Alpha +# Description: Latency EWMA smoothing weight, percent (20 = 0.20). +# Default: 20 +# +# TimeSync.Desync.Threshold +# Description: Latency/elapsed-time deviation (ms) from baseline treated as a +# desync (added to measured latency as tolerance). +# Default: 1000 +# +# TimeSync.MaxSkipMs +# Description: CMSG_MOVE_TIME_SKIPPED above this many ms scores as a time-based +# cheat signal (legit lag-freezes are usually well under this). +# Default: 2000 +# +# TimeSync.MovementCorrection +# Description: Normalise the movement timestamp relayed to OTHER players to the +# server clock so observers interpolate on one timebase (reduces +# other-player warp/stutter). Higher-risk netcode; A/B test it. +# Default: 0 - (disabled) +# +# TimeSync.AutoResync +# Description: After ResyncDesyncTrips sustained desync trips, rubberband the +# client to its authoritative position (the only vanilla resync +# lever). Cooldown-limited by ResyncCooldownMs. +# Default: 0 - (disabled) +# +# TimeSync.ResyncDesyncTrips / TimeSync.ResyncCooldownMs +# Default: 5 / 10000 +# +################################################################################################### + +TimeSync.Enable = 0 +TimeSync.EWMA.Alpha = 20 +TimeSync.Desync.Threshold = 1000 +TimeSync.MaxSkipMs = 2000 +TimeSync.MovementCorrection = 0 +TimeSync.AutoResync = 0 +TimeSync.ResyncDesyncTrips = 5 +TimeSync.ResyncCooldownMs = 10000