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,