diff --git a/cmake/MangosParams.cmake b/cmake/MangosParams.cmake index 52d74bce7..f0063da36 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 2026062004) 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..25459c80c --- /dev/null +++ b/src/game/AntiCheat/AntiCheatMgr.cpp @@ -0,0 +1,432 @@ +/* + * 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_testBypass(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; + // m_testBypass lets `.spoof` simulations score on an exempt GM / with AC off. + if (!m_testBypass && (!m_enabled || IsExempt(player))) + return; + DoRecord(player, type, weight, ctx); +} + +void AntiCheatMgr::TestInject(Player* player, AntiCheatViolationType type, + float weight, AntiCheatContext const& ctx) +{ + // Deliberately bypasses the enabled/exempt gate so `.anticheat test` can drive + // the whole pipeline (scoring, decay, persist, escalation) on a GM. + if (!player) + return; + DoRecord(player, type, weight, ctx); +} + +void AntiCheatMgr::DoRecord(Player* player, AntiCheatViolationType type, + float weight, AntiCheatContext const& ctx) +{ + // 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::GetTopScores(uint32 limit, std::vector >& out) +{ + uint32 nowMS = getMSTime(); + std::vector > all; + { + std::lock_guard guard(m_lock); + for (std::map::iterator it = m_scores.begin(); it != m_scores.end(); ++it) + { + float s = DecayedScore(it->second, nowMS); + if (s > 0.0f) + all.push_back(std::make_pair(it->first, s)); + } + } + std::sort(all.begin(), all.end(), + [](std::pair const& a, std::pair const& b) + { return a.second > b.second; }); + if (all.size() > limit) + all.resize(limit); + out.swap(all); +} + +void AntiCheatMgr::SetScore(Player* player, float score) +{ + if (!player) + return; + if (score < 0.0f) score = 0.0f; + + uint32 nowMS = getMSTime(); + { + std::lock_guard guard(m_lock); + ScoreState& s = m_scores[player->GetGUIDLow()]; + DecayedScore(s, nowMS); // settle decay before overwriting + s.score = score; + } + + AntiCheatContext ctx; + ctx.mapId = player->GetMapId(); + ctx.x = player->GetPositionX(); ctx.y = player->GetPositionY(); ctx.z = player->GetPositionZ(); + ctx.detail = "GM set score"; + Apply(player, score, AC_VIOLATION_NONE, ctx); +} + +void AntiCheatMgr::BuildDiag(std::string& out) +{ + char buf[512]; + snprintf(buf, sizeof(buf), + "AntiCheat config: enabled=%u movement=%u physics=%u accelCheck=%u | " + "actionCeiling=%u warn=%u rubber=%u kick=%u decay/s=%u | " + "speedTol=%u%% teleDist=%u | autoban=%u (kickPts=%u thr=%u) | " + "persist=%u exemptGmLvl=%u exemptBots=%u", + (uint32)m_enabled, (uint32)m_movementEnabled, (uint32)m_physicsEnabled, + (uint32)sWorld.getConfig(CONFIG_BOOL_ANTICHEAT_ACCEL_CHECK), + m_actionCeiling, m_scoreWarn, m_scoreRubberband, m_scoreKick, m_decayPerSec, + m_speedTolerancePct, m_teleportDistance, + (uint32)m_autobanEnable, m_autobanKickPoints, m_autobanThreshold, + (uint32)m_persist, m_exemptGmLevel, (uint32)m_exemptBots); + out = buf; +} + +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..2d954bd85 --- /dev/null +++ b/src/game/AntiCheat/AntiCheatMgr.h @@ -0,0 +1,189 @@ +/* + * 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); + + // GM/dev TEST ingress: runs the full scoring + persist + escalation + // pipeline while BYPASSING the enabled/exempt gate, so `.anticheat test` + // can exercise every capability on a GM. Not used by detectors. + void TestInject(Player* player, AntiCheatViolationType type, + float weight, AntiCheatContext const& ctx); + + // Append a human-readable snapshot of the live AC config (for diagnostics). + void BuildDiag(std::string& out); + + // GM tool: set a player's live AC score directly and evaluate escalation + // (lets a GM drive the score to a threshold to exercise warn/rubberband/kick). + void SetScore(Player* player, float score); + + // When set, RecordViolation bypasses the enabled/exempt gate and the + // *Enabled() getters report true — so `.spoof` simulations drive the real + // detectors and apply results even on an exempt GM / with AC disabled. + // Set only briefly around a synchronous simulation on the world thread. + void SetTestBypass(bool on) { m_testBypass = on; } + + // 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); + + // GM triage: the highest live (decayed) scores, sorted desc, as + // (characterLowGuid, score) pairs, capped at `limit`. + void GetTopScores(uint32 limit, std::vector >& out); + + // Config getters (cached snapshot). + uint32 GetSpeedTolerancePct() const { return m_speedTolerancePct; } + uint32 GetTeleportDistance() const { return m_teleportDistance; } + bool MovementEnabled() const { return m_testBypass || (m_enabled && m_movementEnabled); } + bool PhysicsEnabled() const { return m_testBypass || (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; + }; + + // Shared post-gate body for RecordViolation/TestInject: score, persist, + // escalate. + void DoRecord(Player* player, AntiCheatViolationType type, + float weight, AntiCheatContext const& ctx); + + // 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_testBypass; // transient: `.spoof` simulation in progress + 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..1c685bb11 --- /dev/null +++ b/src/game/AntiCheat/MovementAnticheat.cpp @@ -0,0 +1,793 @@ +/* + * 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 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 + + // 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_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), + 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 --- (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; + 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; + + // 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); + 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: 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))) + { + 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; + + // --- 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; + + // 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; +} + +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 +} + +bool MovementAnticheat::SimulateCheat(const std::string& kind, float mag, std::string& outDesc) +{ + if (!m_player || !m_player->IsInWorld()) + { + outDesc = "player not in world"; + return false; + } + + // Snapshot the whole validator state (plain-copyable) so the simulation can't + // perturb live tracking; restored at the end. + MovementAnticheat saved = *this; + + float cx = m_player->GetPositionX(); + float cy = m_player->GetPositionY(); + float cz = m_player->GetPositionZ(); + float co = m_player->GetOrientation(); + uint32 now = getMSTime(); + float allowed = m_player->GetSpeed(MOVE_RUN); + if (allowed <= 0.0f) allowed = 7.0f; + + // Craft a "previous" baseline at the current position a short tick ago, so the + // crafted packet below produces the cheat's delta. + m_hasLast = true; + m_lastX = cx; m_lastY = cy; m_lastZ = cz; m_lastO = co; + m_lastMS = now - 200; // 0.2s ago + m_lastFlags = 0; + m_trustNext = false; + m_airborne = false; m_fallApexZ = cz; + m_grantedFlags = 0; // ensure flag spoofs aren't excused by a grant + + MovementInfo mi = m_player->m_movementInfo; // start from the real, valid state + mi.SetMovementFlags(MOVEFLAG_NONE); + mi.ChangePosition(cx, cy, cz, co); + mi.UpdateTime(now); + uint16 opcode = MSG_MOVE_HEARTBEAT; + bool known = true; + + if (kind == "speed") + { + float d = allowed * (mag > 0.f ? mag : 4.0f) * 0.2f; // mag x run-speed over 0.2s + mi.ChangePosition(cx + cosf(co) * d, cy + sinf(co) * d, cz, co); + outDesc = "speed (exceeds allowed)"; + } + else if (kind == "teleport") + { + float d = mag > 0.f ? mag : 60.0f; + mi.ChangePosition(cx + cosf(co) * d, cy + sinf(co) * d, cz, co); + outDesc = "teleport / blink"; + } + else if (kind == "fly") + { + mi.AddMovementFlag(MovementFlags(MOVEFLAG_FLYING | MOVEFLAG_CAN_FLY)); + outDesc = "fly flag"; + } + else if (kind == "waterwalk") { mi.AddMovementFlag(MOVEFLAG_WATERWALKING); outDesc = "water-walk flag (no aura)"; } + else if (kind == "hover") { mi.AddMovementFlag(MOVEFLAG_HOVER); outDesc = "hover flag (no aura)"; } + else if (kind == "slowfall") { mi.AddMovementFlag(MOVEFLAG_SAFE_FALL); outDesc = "slow-fall flag (no aura)"; } + else if (kind == "swim") { mi.AddMovementFlag(MOVEFLAG_SWIMMING); outDesc = "swim flag (not in water)"; } + else if (kind == "transport") { mi.AddMovementFlag(MOVEFLAG_ONTRANSPORT); outDesc = "transport flag (no transport)"; } + else if (kind == "vertical") { mi.ChangePosition(cx, cy, cz + 8.0f, co); outDesc = "vertical climb"; } + else if (kind == "jump") + { + m_airborne = true; m_fallApexZ = cz; // already airborne -> re-jump + opcode = MSG_MOVE_JUMP; + outDesc = "mid-air / infinite jump"; + } + else if (kind == "desync") + { + m_hasClientTime = true; m_lastClientTime = now; + mi.UpdateTime(now + 5000); // client clock 5s ahead of server + mi.ChangePosition(cx + cosf(co) * 2.0f, cy + sinf(co) * 2.0f, cz, co); + outDesc = "client/server time divergence"; + } + else if (kind == "noclip") + { + float d = mag > 0.f ? mag : 6.0f; + mi.ChangePosition(cx + cosf(co) * d, cy + sinf(co) * d, cz, co); + outDesc = "no-clip (only trips if a wall lies between you and the point)"; + } + else + { + known = false; + outDesc = "unknown cheat kind"; + } + + if (known) + HandlePositionUpdate(opcode, mi); + + *this = saved; // restore live tracking + return known; +} + +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 new file mode 100644 index 000000000..3e63267ba --- /dev/null +++ b/src/game/AntiCheat/MovementAnticheat.h @@ -0,0 +1,161 @@ +/* + * 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); + + // GM/dev `.spoof` tooling: craft the packet signature of a named cheat and + // run it through the REAL detectors (a live end-to-end test of detection + + // response), restoring the validator baseline afterwards so live tracking + // is unaffected. Requires the manager's test-bypass to be set by the caller. + // Returns false (with reason in `outDesc`) for an unknown kind. + bool SimulateCheat(const std::string& kind, float mag, std::string& outDesc); + + // 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(); + + // 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. + 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; + } + + // 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; } + 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; + + // 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; + uint32 m_lastCastGcd; + 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; + + // 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..6c88150c7 --- /dev/null +++ b/src/game/ChatCommands/AntiCheatCommands.cpp @@ -0,0 +1,714 @@ +/* + * GM chat commands for the Anti-Cheat framework: + * .anticheat status/report/reload/warn/jail/unjail/delete + GM test tooling + * (.anticheat test/top/set/score/rubberband, .spoof, .timesync desync). + */ + +#include "Chat.h" +#include "AntiCheatMgr.h" +#include "MovementAnticheat.h" +#include "Player.h" +#include "World.h" +#include "ObjectMgr.h" +#include "ObjectAccessor.h" +#include "Map.h" +#include "Log.h" +#include "Config/Config.h" +#include "Database/DatabaseEnv.h" + +#include +#include +#include +#include +#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::HandleAntiCheatTopCommand(char* args) +{ + uint32 limit = 10; + if (char* tok = strtok(args, " ")) + { + int n = atoi(tok); + if (n > 0) limit = uint32(n); + } + if (limit > 50) limit = 50; + + std::vector > top; + sAntiCheatMgr->GetTopScores(limit, top); + if (top.empty()) + { + SendSysMessage("AntiCheat: no players currently carry a live violation score."); + return true; + } + + PSendSysMessage("AntiCheat: top %u by live score:", uint32(top.size())); + for (size_t i = 0; i < top.size(); ++i) + { + Player* p = sObjectAccessor.FindPlayer(ObjectGuid(HIGHGUID_PLAYER, top[i].first)); + PSendSysMessage(" %2u. %s (guid %u): %.0f", + uint32(i + 1), p ? p->GetName() : "", top[i].first, top[i].second); + } + return true; +} + +bool ChatHandler::HandleAntiCheatReloadCommand(char* /*args*/) +{ + sAntiCheatMgr->LoadConfig(); + SendSysMessage("AntiCheat: configuration reloaded."); + return true; +} + +bool ChatHandler::HandleAntiCheatSetCommand(char* args) +{ + char* f = strtok(args, " "); + char* v = strtok(NULL, " "); + if (!f || !v) + { + SendSysMessage(".anticheat set FAILED. Usage: .anticheat set . Fields:"); + SendSysMessage(" bool: enable, movement, physics, accelcheck, exemptbots, persist, autoban"); + SendSysMessage(" uint: action(1-4), warn, rubberband, kick, decay, speedtol, teledist,"); + SendSysMessage(" castburst, accelmult, exemptgm"); + 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_ANTICHEAT_ENABLE, val != 0); + else if (field == "movement") sWorld.setConfig(CONFIG_BOOL_ANTICHEAT_MOVEMENT, val != 0); + else if (field == "physics") sWorld.setConfig(CONFIG_BOOL_ANTICHEAT_PHYSICS, val != 0); + else if (field == "accelcheck") sWorld.setConfig(CONFIG_BOOL_ANTICHEAT_ACCEL_CHECK, val != 0); + else if (field == "exemptbots") sWorld.setConfig(CONFIG_BOOL_ANTICHEAT_EXEMPT_BOTS, val != 0); + else if (field == "persist") sWorld.setConfig(CONFIG_BOOL_ANTICHEAT_PERSIST, val != 0); + else if (field == "autoban") sWorld.setConfig(CONFIG_BOOL_AC_AUTOBAN_ENABLE, val != 0); + else if (field == "action") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_ACTION, val); + else if (field == "warn") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_SCORE_WARN, val); + else if (field == "rubberband") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_SCORE_RUBBER, val); + else if (field == "kick") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_SCORE_KICK, val); + else if (field == "decay") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_DECAY, val); + else if (field == "speedtol") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_SPEED_TOL, val); + else if (field == "teledist") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_TELE_DIST, val); + else if (field == "castburst") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_CAST_BURST, val); + else if (field == "accelmult") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_ACCEL_MULT, val); + else if (field == "exemptgm") sWorld.setConfig(CONFIG_UINT32_ANTICHEAT_EXEMPT_GM, val); + else + { + PSendSysMessage(".anticheat set FAILED: unknown field '%s'.", field.c_str()); + SetSentErrorMessage(true); + return false; + } + + // Refresh the manager's cached config snapshot so the change takes effect now. + sAntiCheatMgr->LoadConfig(); + PSendSysMessage("AntiCheat: set %s = %u (runtime; reverts on restart/reload from file).", + field.c_str(), val); + 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; +} + +// Names <-> violation types for the `.anticheat test` dev/debug command. +namespace +{ + struct AcTypeName { const char* name; AntiCheatViolationType type; }; + static const AcTypeName s_acTypeNames[] = + { + { "speed", AC_VIOLATION_SPEED }, + { "teleport", AC_VIOLATION_TELEPORT }, + { "vertical", AC_VIOLATION_VERTICAL }, + { "flag", AC_VIOLATION_FLAG_CONTRADICT }, + { "physics", AC_VIOLATION_PHYSICS }, + { "desync", AC_VIOLATION_DESYNC }, + { "jump", AC_VIOLATION_JUMP }, + { "fall", AC_VIOLATION_FALL }, + { "burst", AC_VIOLATION_BURST }, + { "packettiming", AC_VIOLATION_PACKETTIMING }, + { "spell", AC_VIOLATION_SPELL }, + { "item", AC_VIOLATION_ITEM }, + { "interact", AC_VIOLATION_INTERACT }, + { "bot", AC_VIOLATION_BOT }, + }; +} + +bool ChatHandler::HandleAntiCheatTestCommand(char* args) +{ + char* tok = strtok(args, " "); + if (!tok) + { + SendSysMessage(".anticheat test FAILED: needs a subcommand. Usage:"); + SendSysMessage(" .anticheat test list - list violation type names"); + SendSysMessage(" .anticheat test config - dump live AntiCheat config"); + SendSysMessage(" .anticheat test [weight] - inject one violation (default 25)"); + SendSysMessage(" .anticheat test all [weight] - inject every type (default 10)"); + SendSysMessage("Injects on your target (or yourself). Bypasses enabled/exempt so the full"); + SendSysMessage("pipeline runs: scoring, decay, DB persist, marker, warn/rubberband/kick/autoban."); + SetSentErrorMessage(true); + return false; + } + + std::string sub = tok; + for (size_t i = 0; i < sub.size(); ++i) sub[i] = (char)tolower(sub[i]); + + const uint32 count = uint32(sizeof(s_acTypeNames) / sizeof(s_acTypeNames[0])); + + if (sub == "list") + { + SendSysMessage("AntiCheat violation types:"); + for (uint32 i = 0; i < count; ++i) + PSendSysMessage(" %u = %s", uint32(s_acTypeNames[i].type), s_acTypeNames[i].name); + return true; + } + + if (sub == "config") + { + std::string out; + sAntiCheatMgr->BuildDiag(out); + SendSysMessage(out.c_str()); + return true; + } + + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage(".anticheat test FAILED: no player. Select/target a player or run in-game."); + SetSentErrorMessage(true); + return false; + } + + AntiCheatContext ctx; + ctx.mapId = target->GetMapId(); + ctx.x = target->GetPositionX(); ctx.y = target->GetPositionY(); ctx.z = target->GetPositionZ(); + ctx.latency = target->GetSession() ? target->GetSession()->GetLatencyEWMA() : 0; + ctx.detail = "manual test injection"; + + if (!sAntiCheatMgr->IsEnabled()) + SendSysMessage("AntiCheat note: framework is DISABLED in config — live detection is off, " + "but this test still drives scoring/punishment (markers need it enabled)."); + + if (sub == "all") + { + char* w = strtok(NULL, " "); + float weight = w ? float(atof(w)) : 10.0f; + for (uint32 i = 0; i < count; ++i) + sAntiCheatMgr->TestInject(target, s_acTypeNames[i].type, weight, ctx); + PSendSysMessage("AntiCheat test: injected all %u types at weight %.0f on %s.", + count, weight, target->GetName()); + } + else + { + AntiCheatViolationType type = AC_VIOLATION_NONE; + int numeric = atoi(sub.c_str()); + for (uint32 i = 0; i < count; ++i) + if (sub == s_acTypeNames[i].name || (numeric && numeric == int(s_acTypeNames[i].type))) + { + type = s_acTypeNames[i].type; + break; + } + if (type == AC_VIOLATION_NONE) + { + PSendSysMessage(".anticheat test FAILED: unknown type '%s'. Use .anticheat test list.", sub.c_str()); + SetSentErrorMessage(true); + return false; + } + char* w = strtok(NULL, " "); + float weight = w ? float(atof(w)) : 25.0f; + sAntiCheatMgr->TestInject(target, type, weight, ctx); + PSendSysMessage("AntiCheat test: injected type %u weight %.0f on %s.", + uint32(type), weight, target->GetName()); + } + + std::string st; + sAntiCheatMgr->BuildStatus(target, st); + SendSysMessage(st.c_str()); + return true; +} + +// Live-fire SPOOF simulator: activates the actual cheat signature through the real +// detectors. Doubles as GM tooling and an end-to-end anti-cheat self-test. The +// legitimate counterpart is `.legit` (applies the real effect; should NOT detect). +bool ChatHandler::HandleSpoofCommand(char* args) +{ + static const char* kinds[] = { + "speed", "teleport", "fly", "waterwalk", "hover", "slowfall", "swim", + "transport", "vertical", "jump", "desync", "noclip" + }; + const uint32 kindCount = uint32(sizeof(kinds) / sizeof(kinds[0])); + + char* tok = strtok(args, " "); + std::string kind = tok ? tok : ""; + for (size_t i = 0; i < kind.size(); ++i) kind[i] = (char)tolower(kind[i]); + if (!tok || kind == "list" || kind == "help") + { + SendSysMessage("Spoof simulator (live anti-cheat test + GM tooling). Usage:"); + SendSysMessage(" .spoof |all [magnitude] - run a cheat signature (or all) through detectors"); + std::string line = " kinds: "; + for (uint32 i = 0; i < kindCount; ++i) { line += kinds[i]; if (i + 1 < kindCount) line += ", "; } + SendSysMessage(line.c_str()); + SendSysMessage("Runs on your target (or you). Bypasses GM-exempt/disabled so the result shows."); + SendSysMessage("Legit versions use the real commands (.fly, .waterwalk, .modify speed) / spell"); + SendSysMessage("auras - those record a server grant so the AC does NOT flag them."); + SendSysMessage("Fleet test: .spoof bots |all [n] - make nearby PLAYERBOTS run the cheats."); + if (!tok) { SetSentErrorMessage(true); return false; } + return true; + } + + // Fleet red-team: drive PlayerBots (normally exempt + packetless) through real + // cheat signatures so the anti-cheat catches them - a live, watchable self-test. + if (kind == "bots") + { + char* subTok = strtok(NULL, " "); + std::string sub = subTok ? subTok : "all"; + for (size_t i = 0; i < sub.size(); ++i) sub[i] = (char)tolower(sub[i]); + char* cTok = strtok(NULL, " "); + uint32 maxN = cTok ? uint32(atoi(cTok)) : 10; + if (maxN < 1) maxN = 1; + if (maxN > 200) maxN = 200; + + // Iterate ALL online players (works from in-game, the server console, and + // SOAP — all of which run the command on the world thread). Drives the + // PlayerBots through real cheat signatures so the AC scores them. + uint32 done = 0; + std::string desc; + sAntiCheatMgr->SetTestBypass(true); + sObjectAccessor.DoForAllPlayers([&](Player* b) + { + if (done >= maxN || !b || !b->IsInWorld()) + return; +#ifdef ENABLE_PLAYERBOTS + if (!b->GetPlayerbotAI()) + return; +#else + return; +#endif + if (sub == "all") + for (uint32 k = 0; k < kindCount; ++k) + b->GetMovementAnticheat()->SimulateCheat(kinds[k], 0.0f, desc); + else + b->GetMovementAnticheat()->SimulateCheat(sub, 0.0f, desc); + ++done; + }); + sAntiCheatMgr->SetTestBypass(false); + + if (!done) + { + SendSysMessage(".spoof bots: no PlayerBots online (or playerbots not built in)."); + return true; + } + PSendSysMessage("Spoof: ran '%s' on %u PlayerBot(s). Use .anticheat top to see them ranked.", + sub.c_str(), done); + return true; + } + + char* w = strtok(NULL, " "); + float mag = w ? float(atof(w)) : 0.0f; + + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage(".spoof FAILED: no player. Select/target a player or run in-game."); + SetSentErrorMessage(true); + return false; + } + + // Drive the real detectors and force the result to apply (target may be an + // exempt GM, and AC may be off). Bypass is set only around this synchronous call. + std::string desc; + if (kind == "all") + { + sAntiCheatMgr->SetTestBypass(true); + for (uint32 i = 0; i < kindCount; ++i) + target->GetMovementAnticheat()->SimulateCheat(kinds[i], mag, desc); + sAntiCheatMgr->SetTestBypass(false); + PSendSysMessage("Spoof: ran all %u cheat signatures on %s. Detector result below:", + kindCount, target->GetName()); + std::string allst; + sAntiCheatMgr->BuildStatus(target, allst); + SendSysMessage(allst.c_str()); + return true; + } + + sAntiCheatMgr->SetTestBypass(true); + bool ok = target->GetMovementAnticheat()->SimulateCheat(kind, mag, desc); + sAntiCheatMgr->SetTestBypass(false); + + if (!ok) + { + PSendSysMessage(".spoof FAILED: %s. Try .spoof list.", desc.c_str()); + SetSentErrorMessage(true); + return false; + } + + PSendSysMessage("Spoof simulated on %s: %s. Detector result below (also logged/persisted):", + target->GetName(), desc.c_str()); + std::string status; + sAntiCheatMgr->BuildStatus(target, status); + SendSysMessage(status.c_str()); + return true; +} + +// --- Dedicated AC-vector GM tools (manipulate the mechanics directly) --- + +bool ChatHandler::HandleAntiCheatRubberbandCommand(char* /*args*/) +{ + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage(".anticheat rubberband FAILED: no player. Select/target a player or run " + "in-game. It yanks them back to their last AC-validated position."); + SetSentErrorMessage(true); + return false; + } + + MovementAnticheat* mac = target->GetMovementAnticheat(); + if (mac && mac->HasValid()) + { + target->NearTeleportTo(mac->ValidX(), mac->ValidY(), mac->ValidZ(), mac->ValidO()); + PSendSysMessage("AntiCheat: rubberbanded %s to last valid (%.1f, %.1f, %.1f).", + target->GetName(), mac->ValidX(), mac->ValidY(), mac->ValidZ()); + } + else + { + SendSysMessage("AntiCheat: no validated position yet for that player (they haven't moved " + "since login). Nothing to rubberband to."); + } + return true; +} + +// --- Time-sync / desync system 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::HandleTimeSyncDesyncCommand(char* args) +{ + char* tok = strtok(args, " "); + uint32 n = tok ? uint32(atoi(tok)) : 5; + if (n < 1) n = 1; + if (n > 50) n = 50; + + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage(".timesync desync FAILED: no player. Select/target a player or run in-game. " + "Usage: .timesync desync [count]. Fires the desync detector then resyncs."); + SetSentErrorMessage(true); + return false; + } + + // Fire the real desync detector n times (visible: score climbs, markers, log)... + std::string desc; + sAntiCheatMgr->SetTestBypass(true); + for (uint32 i = 0; i < n; ++i) + target->GetMovementAnticheat()->SimulateCheat("desync", 0.0f, desc); + sAntiCheatMgr->SetTestBypass(false); + + // ...then show the resync correction in action (the rubberband). + target->NearTeleportTo(target->GetPositionX(), target->GetPositionY(), + target->GetPositionZ(), target->GetOrientation()); + if (MovementAnticheat* mac = target->GetMovementAnticheat()) + mac->NotifyServerRelocation(); + + PSendSysMessage("TimeSync: simulated %u desync events on %s and performed a resync. " + "(For fully-automatic resync, enable TimeSync.AutoResync and trip a non-GM char.)", + n, target->GetName()); + std::string st; + sAntiCheatMgr->BuildStatus(target, st); + SendSysMessage(st.c_str()); + 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; +} + +bool ChatHandler::HandleAntiCheatScoreCommand(char* args) +{ + Player* target = getSelectedPlayer(); + if (!target) + target = m_session ? m_session->GetPlayer() : NULL; + if (!target) + { + SendSysMessage(".anticheat score FAILED: no player. Select/target a player or run in-game. " + "Usage: .anticheat score [value] (no value = show current)."); + SetSentErrorMessage(true); + return false; + } + + char* tok = strtok(args, " "); + if (tok) + { + float val = float(atof(tok)); + sAntiCheatMgr->SetScore(target, val); + PSendSysMessage("AntiCheat: set %s score to %.0f (escalation evaluated).", target->GetName(), val); + } + + std::string st; + sAntiCheatMgr->BuildStatus(target, st); + SendSysMessage(st.c_str()); + 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..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" @@ -149,8 +150,12 @@ 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_desyncPending(false), m_desyncValue(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 +163,51 @@ 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; + + // 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 = (alpha * sampleMS + (100 - alpha) * m_latEWMA) / 100; +} + /// WorldSession destructor WorldSession::~WorldSession() { @@ -441,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 1052fabe7..514e2b889 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,18 @@ 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; + // 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/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..6dde5a0b1 100644 --- a/src/game/WorldHandlers/Chat.cpp +++ b/src/game/WorldHandlers/Chat.cpp @@ -241,6 +241,34 @@ ChatCommand* ChatHandler::getCommandTable() { NULL, 0, false, NULL, "", NULL } }; + static ChatCommand anticheatCommandTable[] = + { + { "status", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatStatusCommand, "", NULL }, + { "top", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatTopCommand, "", NULL }, + { "report", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatReportCommand, "", NULL }, + { "reload", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatReloadCommand, "", NULL }, + { "set", SEC_ADMINISTRATOR, true, &ChatHandler::HandleAntiCheatSetCommand, "", 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 }, + { "test", SEC_ADMINISTRATOR, true, &ChatHandler::HandleAntiCheatTestCommand, "", NULL }, + { "rubberband", SEC_GAMEMASTER, true, &ChatHandler::HandleAntiCheatRubberbandCommand, "", NULL }, + { "score", SEC_ADMINISTRATOR, true, &ChatHandler::HandleAntiCheatScoreCommand, "", NULL }, + { 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 }, + { "desync", SEC_GAMEMASTER, true, &ChatHandler::HandleTimeSyncDesyncCommand, "", NULL }, + { NULL, 0, false, NULL, "", NULL } + }; + static ChatCommand debugCommandTable[] = { { "anim", SEC_GAMEMASTER, false, &ChatHandler::HandleDebugAnimCommand, "", NULL }, @@ -757,6 +785,9 @@ 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}, + { "spoof", SEC_GAMEMASTER, true, &ChatHandler::HandleSpoofCommand, "", NULL }, + { "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 3dd0a7a9b..a5a40755d 100644 --- a/src/game/WorldHandlers/Chat.h +++ b/src/game/WorldHandlers/Chat.h @@ -253,6 +253,30 @@ 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); + // Anti-Cheat GM test tooling + bool HandleAntiCheatTopCommand(char* args); + bool HandleAntiCheatSetCommand(char* args); + bool HandleAntiCheatScoreCommand(char* args); + bool HandleAntiCheatTestCommand(char* args); + bool HandleAntiCheatRubberbandCommand(char* args); + bool HandleSpoofCommand(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 HandleTimeSyncDesyncCommand(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..3e87846b1 100644 --- a/src/game/WorldHandlers/MovementHandler.cpp +++ b/src/game/WorldHandlers/MovementHandler.cpp @@ -58,6 +58,10 @@ #include "Opcodes.h" #include "Log.h" #include "Player.h" +#include "Timer.h" +#include "World.h" +#include "AntiCheatMgr.h" +#include "MovementAnticheat.h" #include "MapManager.h" #include "Transports.h" #include "BattleGround/BattleGround.h" @@ -159,6 +163,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 +294,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 +352,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()) { @@ -358,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 @@ -415,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; @@ -628,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()); } /** @@ -660,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/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..6999dfb2d 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,61 @@ 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); + + // 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). + 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 +1595,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 +1651,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 +1919,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..f1c548bae 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,30 @@ 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 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, + 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 +408,21 @@ 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, + // 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 9d4705aa8..69ff19832 100644 --- a/src/mangosd/mangosd.conf.dist.in +++ b/src/mangosd/mangosd.conf.dist.in @@ -1944,3 +1944,173 @@ 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 + +################################################################################################### +# 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