From 4988b11fd28afc93c9c88a0947fb02ea0eb5aa30 Mon Sep 17 00:00:00 2001 From: linmeideli <2558527373@qq.com> Date: Fri, 2 Oct 2026 22:09:23 +0800 Subject: [PATCH] Update Anticheat --- src/Empostor.Api/CheatCategory.cs | 4 +- src/Empostor.Api/Config/AntiCheatConfig.cs | 8 +- .../Http/IpRateLimitService.cs | 21 +- .../Net/Anticheat/PlayerTimeline.cs | 16 +- .../Net/Anticheat/RateLimitWindow.cs | 37 +++ src/Empostor.Server/Net/Client.cs | 2 +- .../Net/Inner/InnerNetObject.Anticheat.cs | 216 ++++++++++-------- .../Net/Inner/Objects/InnerPlayerControl.cs | 9 +- src/Empostor.Server/Net/Matchmaker.cs | 2 +- src/Empostor.Server/Net/State/Game.Data.cs | 9 +- src/Empostor.Server/config.json | 7 +- 11 files changed, 204 insertions(+), 127 deletions(-) create mode 100644 src/Empostor.Server/Net/Anticheat/RateLimitWindow.cs diff --git a/src/Empostor.Api/CheatCategory.cs b/src/Empostor.Api/CheatCategory.cs index 466e42b..0025325 100644 --- a/src/Empostor.Api/CheatCategory.cs +++ b/src/Empostor.Api/CheatCategory.cs @@ -47,8 +47,8 @@ public enum CheatCategory /// A vote cast outside a meeting, or by a player who is already dead. Voting, - /// Vent and pipe use the current game state cannot produce. - Venting, + /// A client sent more messages in a window than the room can legitimately produce. + RateLimit, /// Legacy category for unsorted anticheat checks. Other, diff --git a/src/Empostor.Api/Config/AntiCheatConfig.cs b/src/Empostor.Api/Config/AntiCheatConfig.cs index a3b43b7..82f5f2d 100644 --- a/src/Empostor.Api/Config/AntiCheatConfig.cs +++ b/src/Empostor.Api/Config/AntiCheatConfig.cs @@ -36,18 +36,12 @@ namespace Empostor.Api.Config public bool EnableVotingChecks { get; set; } = true; - public bool EnableVentExploitCheck { get; set; } = false; + public bool EnableRateLimits { get; set; } = true; public bool ForbidProtocolExtensions { get; set; } = true; public bool EnablePacketSizeChecks { get; set; } = true; public int PacketSizeLimit { get; set; } = 1203; - - public bool EnableIpRateLimit { get; set; } = true; - - public int IpRateLimitRequests { get; set; } = 30; - - public int IpRateLimitWindowMinutes { get; set; } = 1; } } diff --git a/src/Empostor.Server/Http/IpRateLimitService.cs b/src/Empostor.Server/Http/IpRateLimitService.cs index 39d5444..2262699 100644 --- a/src/Empostor.Server/Http/IpRateLimitService.cs +++ b/src/Empostor.Server/Http/IpRateLimitService.cs @@ -5,16 +5,15 @@ using Empostor.Api.Config; using Microsoft.Extensions.Options; namespace Empostor.Server.Http; - -// Per-IP request counter used by the anticheat "Ip request rate limit". -// Every real IP gets a fixed window (default 5 minutes); when the IP sends -// more TCP (HTTP) requests than configured (default 30) it is blocked until -// the window slides away. public sealed class IpRateLimitService { // Language key of the localized hint, see Languages/*.json. public const string MessageKey = "ratelimit.too_frequent"; + public const int MaxRequestsPerWindow = 30; + + public const int WindowMinutes = 1; + private static readonly TimeSpan CleanupInterval = TimeSpan.FromSeconds(60); private readonly IOptions _options; @@ -34,7 +33,7 @@ public sealed class IpRateLimitService var config = _options.Value; var window = GetWindow(config); - if (ip == null || window == null || config.IpRateLimitRequests <= 0) + if (ip == null || window == null) { return true; } @@ -54,7 +53,7 @@ public sealed class IpRateLimitService } entry.Count++; - if (entry.Count <= config.IpRateLimitRequests) + if (entry.Count <= MaxRequestsPerWindow) { return true; } @@ -79,7 +78,7 @@ public sealed class IpRateLimitService var config = _options.Value; var window = GetWindow(config); - if (ip == null || window == null || config.IpRateLimitRequests <= 0) + if (ip == null || window == null) { return false; } @@ -92,7 +91,7 @@ public sealed class IpRateLimitService var now = DateTime.UtcNow; lock (entry) { - if (now - entry.StartUtc >= window.Value || entry.Count <= config.IpRateLimitRequests) + if (now - entry.StartUtc >= window.Value || entry.Count <= MaxRequestsPerWindow) { return false; } @@ -109,12 +108,12 @@ public sealed class IpRateLimitService private static TimeSpan? GetWindow(AntiCheatConfig config) { - if (!config.EnableIpRateLimit) + if (!config.EnableRateLimits) { return null; } - return TimeSpan.FromMinutes(Math.Max(1, config.IpRateLimitWindowMinutes)); + return TimeSpan.FromMinutes(WindowMinutes); } // Whole minutes to wait, always at least one so the hint never says diff --git a/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs b/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs index a438804..7a9b1fe 100644 --- a/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs +++ b/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs @@ -20,8 +20,19 @@ namespace Empostor.Server.Net.Anticheat /// Gets or sets the system of the last sabotage this player triggered. public int LastSabotageSystem { get; set; } = -1; - /// Gets or sets the moment of the vent in of a possible vent kick exploit. - public double VentExploitPendingAt { get; set; } = -1; + private RateLimitWindow _rpcWindow; + + private RateLimitWindow _taskWindow; + + public bool CountRpc(double now, int maxPerSecond) + { + return _rpcWindow.Hit(now, maxPerSecond, 1); + } + + public bool CountTask(double now, int maxCount, double windowSeconds) + { + return _taskWindow.Hit(now, maxCount, windowSeconds); + } public void NoteKill(byte victimId, double now) { @@ -37,7 +48,6 @@ namespace Empostor.Server.Net.Anticheat DeadTargetKills = 0; LastSabotageAt = -1; LastSabotageSystem = -1; - VentExploitPendingAt = -1; } } } diff --git a/src/Empostor.Server/Net/Anticheat/RateLimitWindow.cs b/src/Empostor.Server/Net/Anticheat/RateLimitWindow.cs new file mode 100644 index 0000000..4b9a841 --- /dev/null +++ b/src/Empostor.Server/Net/Anticheat/RateLimitWindow.cs @@ -0,0 +1,37 @@ +namespace Empostor.Server.Net.Anticheat +{ + internal struct RateLimitWindow + { + private const int WindowSize = 64; + + private readonly double[] _stamps; + + private int _next; + + public RateLimitWindow() + { + _stamps = new double[WindowSize]; + } + + public bool Hit(double now, int maxCount, double windowSeconds) + { + if (maxCount <= 0 || windowSeconds <= 0) + { + return false; + } + + var used = 0; + for (var i = 0; i < WindowSize; i++) + { + if (_stamps[i] > 0 && now - _stamps[i] < windowSeconds && ++used >= maxCount) + { + return true; + } + } + + _stamps[_next] = now; + _next = (_next + 1) % WindowSize; + return false; + } + } +} diff --git a/src/Empostor.Server/Net/Client.cs b/src/Empostor.Server/Net/Client.cs index 24cbecb..209366e 100644 --- a/src/Empostor.Server/Net/Client.cs +++ b/src/Empostor.Server/Net/Client.cs @@ -93,7 +93,7 @@ namespace Empostor.Server.Net CheatCategory.Sabotage => _antiCheatConfig.EnableSabotageChecks, CheatCategory.Meeting => _antiCheatConfig.EnableMeetingChecks, CheatCategory.Voting => _antiCheatConfig.EnableVotingChecks, - CheatCategory.Venting => _antiCheatConfig.EnableVentExploitCheck, + CheatCategory.RateLimit => _antiCheatConfig.EnableRateLimits, CheatCategory.Other => true, _ => LogUnknownCategory(category), }; diff --git a/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs b/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs index 81efe44..159159e 100644 --- a/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs +++ b/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs @@ -7,11 +7,18 @@ using Empostor.Api.Net.Inner; using Empostor.Api.Net.Inner.Objects; using Empostor.Server.Net.Anticheat; using Empostor.Server.Net.Inner.Objects; +using Empostor.Server.Net.State; namespace Empostor.Server.Net.Inner { internal abstract partial class InnerNetObject { + private const int RpcRateLimitPerSecond = 20; + + private const int TaskRateLimitCount = 3; + + private const double TaskRateLimitWindowSeconds = 2; + protected async ValueTask ValidateOwnership(CheatContext context, IClientPlayer sender) { if (!sender.IsOwner(this)) @@ -137,7 +144,7 @@ namespace Empostor.Server.Net.Inner return true; } - protected async ValueTask ValidateMurderTiming(CheatContext context, IClientPlayer sender, byte killerPlayerId, IInnerPlayerControl? target) + protected async ValueTask ValidateMurder(CheatContext context, IClientPlayer sender, byte killerPlayerId, IInnerPlayerControl? target) { if (!Game.AntiCheat.Config.EnableMurderChecks || target == null) { @@ -170,34 +177,23 @@ namespace Empostor.Server.Net.Inner return true; } + var now = AntiCheatState.Now; var window = options.KillCooldown / 2; - if (IsFastKill(timeline, target.PlayerId, window, AntiCheatState.Now, out var reason)) + if (window > 0 && + timeline.LastKillAt >= 0 && + timeline.LastKillTarget != target.PlayerId && + now - timeline.LastKillAt < window) { - return await sender.Client.ReportCheatAsync(context, CheatCategory.Murder, reason!); + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.Murder, + $"Killed {target.PlayerId} only {now - timeline.LastKillAt:0.###}s after killing {timeline.LastKillTarget}"); } return true; } - private static bool IsFastKill(PlayerTimeline timeline, byte victimId, double windowSeconds, double now, out string? reason) - { - reason = null; - - if (windowSeconds <= 0 || timeline.LastKillAt < 0 || timeline.LastKillTarget == victimId) - { - return false; - } - - if (now - timeline.LastKillAt >= windowSeconds) - { - return false; - } - - reason = $"Killed {victimId} only {now - timeline.LastKillAt:0.###}s after killing {timeline.LastKillTarget}"; - return true; - } - protected async ValueTask ValidateMeetingTiming(CheatContext context, IClientPlayer sender) { if (!Game.AntiCheat.ShipLoaded) @@ -231,9 +227,14 @@ namespace Empostor.Server.Net.Inner try { - if (!TryReadUpdateSystem(reader, out var systemType, out var playerControl, out var isVent, out var sequenceId, out var state, out var ventId)) + if (!TryReadUpdateSystem(reader, out var systemType, out var playerControl, out var isSabotageRoute, out var state)) { - return await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, "Client sent a malformed UpdateSystem RPC"); + if (await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, "Client sent a malformed UpdateSystem RPC")) + { + return false; + } + + return true; } if (!sender.IsHost && !playerControl.IsOwnedBy(sender)) @@ -246,22 +247,22 @@ namespace Empostor.Server.Net.Inner var actorId = ResolveActorId(playerControl, sender); - if (isVent) - { - return await ValidateVentExploit(context, sender, actorId, sequenceId, state, ventId); - } - if (!Game.AntiCheat.ShipGraceOver) { return true; } + if (!await ValidateSystemAmount(context, sender, systemType, state)) + { + return false; + } + if (!await ValidateSabotageInMeeting(context, sender, systemType)) { return false; } - if (!await ValidateRapidSabotage(context, sender, actorId, systemType)) + if (!await ValidateRapidSabotage(context, sender, actorId, systemType, state)) { return false; } @@ -275,38 +276,22 @@ namespace Empostor.Server.Net.Inner } } - protected async ValueTask ValidateVentExploit(CheatContext context, IClientPlayer sender, byte playerId, ushort sequenceId, byte state, byte ventId) + protected async ValueTask ValidateSystemAmount(CheatContext context, IClientPlayer sender, SystemTypes system, byte amount) { - if (!Game.AntiCheat.Config.EnableVentExploitCheck) + if (system != SystemTypes.Electrical || (amount & 0x80) != 0 || amount <= 4) { return true; } - const byte EnterVentState = 2; - const byte BootFromVentState = 5; - const ushort ExploitSequenceId = 1; - - var timeline = Game.AntiCheat.For(playerId); - var now = AntiCheatState.Now; - - if (state == EnterVentState && sequenceId == 0 && ventId == 0) + if (await sender.Client.ReportCheatAsync( + context, + CheatCategory.ProtocolExtension, + $"Client sent an out of range state {amount} for {system}")) { - timeline.VentExploitPendingAt = now; - return true; + return false; } - if (state != BootFromVentState || sequenceId != ExploitSequenceId || ventId != 0 || - timeline.VentExploitPendingAt < 0 || - now - timeline.VentExploitPendingAt > 1) - { - return true; - } - - timeline.VentExploitPendingAt = -1; - return await sender.Client.ReportCheatAsync( - context, - CheatCategory.Venting, - "Client sent the vent kick exploit pair (vent 0, sequence id 0 then 1)"); + return true; } protected async ValueTask ValidateSabotageInMeeting(CheatContext context, IClientPlayer sender, SystemTypes system) @@ -316,15 +301,20 @@ namespace Empostor.Server.Net.Inner return true; } - return await sender.Client.ReportCheatAsync( - context, - CheatCategory.Sabotage, - $"Client sabotaged {system} while a meeting is in progress"); + if (await sender.Client.ReportCheatAsync( + context, + CheatCategory.Sabotage, + $"Client sabotaged {system} while a meeting is in progress")) + { + return false; + } + + return true; } - protected async ValueTask ValidateRapidSabotage(CheatContext context, IClientPlayer sender, byte playerId, SystemTypes system) + protected async ValueTask ValidateRapidSabotage(CheatContext context, IClientPlayer sender, byte playerId, SystemTypes system, byte state) { - if ((int)system == 16) + if ((int)system == 16 || (state & 0x80) == 0) { return true; } @@ -333,13 +323,19 @@ namespace Empostor.Server.Net.Inner var now = AntiCheatState.Now; if (timeline.LastSabotageAt >= 0 && - now - timeline.LastSabotageAt < 0.1 && + now - timeline.LastSabotageAt < 3 && timeline.LastSabotageSystem != (int)system) { - var reason = $"Sabotaged {system} {(now - timeline.LastSabotageAt) * 1000:0.###}ms after {timeline.LastSabotageSystem}"; + var reason = $"Sabotaged {system} {now - timeline.LastSabotageAt:0.###}s after {timeline.LastSabotageSystem}"; timeline.LastSabotageAt = now; timeline.LastSabotageSystem = (int)system; - return await sender.Client.ReportCheatAsync(context, CheatCategory.Sabotage, reason); + + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Sabotage, reason)) + { + return false; + } + + return true; } timeline.LastSabotageAt = now; @@ -364,9 +360,13 @@ namespace Empostor.Server.Net.Inner ? $"Non-impostor started a sabotage of {system} (state 0x{state:X2})" : $"Non-impostor triggered {system}"; - return await sender.Client.ReportCheatAsync(context, CheatCategory.Sabotage, reason); - } + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Sabotage, reason)) + { + return false; + } + return true; + } protected async ValueTask ValidateVoteCast(CheatContext context, IClientPlayer sender) { if (!Game.AntiCheat.Config.EnableVotingChecks) @@ -419,6 +419,56 @@ namespace Empostor.Server.Net.Inner $"Client sent a VotingComplete with {voters} voters (max {limit})"); } + protected async ValueTask ValidateTaskCompletion(CheatContext context, IClientPlayer sender, byte playerId) + { + if (!Game.AntiCheat.Config.EnableRateLimits) + { + return true; + } + + var config = Game.AntiCheat.Config; + if (!Game.AntiCheat.For(playerId).CountTask(AntiCheatState.Now, TaskRateLimitCount, TaskRateLimitWindowSeconds)) + { + return true; + } + + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.RateLimit, + $"Client completed more than {TaskRateLimitCount} tasks in {TaskRateLimitWindowSeconds}s"); + } + + internal static async ValueTask ValidateRpcRate(Game game, CheatContext context, IClientPlayer sender, InnerNetObject obj, bool toPlayer) + { + if (!game.AntiCheat.Config.EnableRateLimits) + { + return true; + } + + var ownerId = obj.OwnerId >= 0 ? obj.OwnerId : sender.Client.Id; + + if (!toPlayer && ownerId != sender.Client.Id) + { + return true; + } + + var playerId = game.GetClientPlayer(ownerId)?.Character?.PlayerId; + if (playerId == null) + { + return true; + } + + if (!game.AntiCheat.For(playerId.Value).CountRpc(AntiCheatState.Now, RpcRateLimitPerSecond)) + { + return true; + } + + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.RateLimit, + $"Client sent more than {RpcRateLimitPerSecond} RPCs in one second"); + } + internal static async ValueTask ValidateGameDataTag(CheatContext context, IClientPlayer sender, byte tag) { if (await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, $"Client sent an unknown game data tag {tag}")) @@ -433,17 +483,13 @@ namespace Empostor.Server.Net.Inner IMessageReader reader, out SystemTypes systemType, out InnerPlayerControl playerControl, - out bool isVent, - out ushort sequenceId, - out byte state, - out byte ventId) + out bool isSabotageRoute, + out byte state) { - systemType = SystemTypes.Hallway; + systemType = default; playerControl = null!; - isVent = false; - sequenceId = 0; + isSabotageRoute = false; state = 0; - ventId = byte.MaxValue; if (reader.Position >= reader.Length) { @@ -460,36 +506,20 @@ namespace Empostor.Server.Net.Inner playerControl = control; - if (systemType == SystemTypes.Ventilation) - { - if (reader.Length - reader.Position < 4) - { - return false; - } - - isVent = true; - sequenceId = reader.ReadUInt16(); - state = reader.ReadByte(); - ventId = reader.ReadByte(); - return true; - } - if (reader.Position >= reader.Length) { return false; } + state = reader.ReadByte(); + if (systemType == SystemTypes.Sabotage) { - if (reader.Length - reader.Position < 2) - { - return false; - } - - systemType = (SystemTypes)reader.ReadByte(); + isSabotageRoute = true; + systemType = (SystemTypes)state; + state = 0x80; } - state = reader.ReadByte(); return true; } diff --git a/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs b/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs index e0bcca5..ad075d2 100644 --- a/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs +++ b/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading.Tasks; using Empostor.Api; @@ -616,6 +616,11 @@ namespace Empostor.Server.Net.Inner.Objects private async ValueTask HandleCompleteTask(ClientPlayer sender, uint taskId) { + if (!await ValidateTaskCompletion(RpcCalls.CompleteTask, sender, PlayerId)) + { + return; + } + TaskInfo? task = null; if (PlayerInfo == null) { @@ -1055,7 +1060,7 @@ namespace Empostor.Server.Net.Inner.Objects IsMurdering = target; } - if (!await ValidateMurderTiming(RpcCalls.CheckMurder, sender, PlayerId, target)) + if (!await ValidateMurder(RpcCalls.CheckMurder, sender, PlayerId, target)) { return false; } diff --git a/src/Empostor.Server/Net/Matchmaker.cs b/src/Empostor.Server/Net/Matchmaker.cs index ea1ae7c..8e021e6 100644 --- a/src/Empostor.Server/Net/Matchmaker.cs +++ b/src/Empostor.Server/Net/Matchmaker.cs @@ -338,7 +338,7 @@ namespace Empostor.Server.Net // requests were counted with). private string BuildRateLimitMessage(IPAddress? ip, Language language) { - var minutes = Math.Max(1, _antiCheatOptions.Value.IpRateLimitWindowMinutes); + var minutes = IpRateLimitService.WindowMinutes; if (ip != null && _rateLimit.IsLimited(ip, out var retryAfter)) { diff --git a/src/Empostor.Server/Net/State/Game.Data.cs b/src/Empostor.Server/Net/State/Game.Data.cs index 0c89e1e..82830c1 100644 --- a/src/Empostor.Server/Net/State/Game.Data.cs +++ b/src/Empostor.Server/Net/State/Game.Data.cs @@ -126,7 +126,7 @@ namespace Empostor.Server.Net.State try { - result = await HandleGameDataInnerAsync(reader, sender, target); + result = await HandleGameDataInnerAsync(reader, sender, target, toPlayer); } catch (Exception ex) { @@ -150,7 +150,7 @@ namespace Empostor.Server.Net.State return parent.Length > startPosition; } - private async ValueTask HandleGameDataInnerAsync(IMessageReader reader, ClientPlayer sender, ClientPlayer? target) + private async ValueTask HandleGameDataInnerAsync(IMessageReader reader, ClientPlayer sender, ClientPlayer? target, bool toPlayer) { switch (reader.Tag) { @@ -174,6 +174,11 @@ namespace Empostor.Server.Net.State var netId = reader.ReadPackedUInt32(); if (_allObjects.TryGetValue(netId, out var obj)) { + if (!await InnerNetObject.ValidateRpcRate(this, new CheatContext(nameof(GameDataTag.RpcFlag)), sender, obj, toPlayer)) + { + return GameDataResult.Abort; + } + if (!await obj.HandleRpcAsync(sender, target, (RpcCalls)reader.ReadByte(), reader)) { return GameDataResult.Remove; diff --git a/src/Empostor.Server/config.json b/src/Empostor.Server/config.json index 4cfb5d2..fa15008 100644 --- a/src/Empostor.Server/config.json +++ b/src/Empostor.Server/config.json @@ -31,13 +31,10 @@ "EnableSabotageChecks": true, "EnableMeetingChecks": true, "EnableVotingChecks": true, - "EnableVentExploitCheck": false, + "EnableRateLimits": true, "ForbidProtocolExtensions": true, "EnablePacketSizeChecks": true, - "PacketSizeLimit": 1203, - "EnableIpRateLimit": true, - "IpRateLimitRequests": 30, - "IpRateLimitWindowMinutes": 1 + "PacketSizeLimit": 1203 }, "Timeout": { "SpawnTimeout": 5000,