Update Anticheat

This commit is contained in:
linmeideli committed 2026-10-02 22:09:23 +08:00
1 parent 4609653438
commit 4988b11fd2
11 files changed
+204 -127

No files matched your search

+2 -2
View File
@@ -47,8 +47,8 @@ public enum CheatCategory
/// <summary>A vote cast outside a meeting, or by a player who is already dead.</summary>
Voting,
/// <summary>Vent and pipe use the current game state cannot produce.</summary>
Venting,
/// <summary>A client sent more messages in a window than the room can legitimately produce.</summary>
RateLimit,
/// <summary>Legacy category for unsorted anticheat checks.</summary>
Other,
+1 -7
View File
@@ -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;
}
}
+10 -11
View File
@@ -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<AntiCheatConfig> _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
@@ -20,8 +20,19 @@ namespace Empostor.Server.Net.Anticheat
/// <summary>Gets or sets the system of the last sabotage this player triggered.</summary>
public int LastSabotageSystem { get; set; } = -1;
/// <summary>Gets or sets the moment of the vent in of a possible vent kick exploit.</summary>
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;
}
}
}
@@ -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;
}
}
}
+1 -1
View File
@@ -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),
};
@@ -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<bool> ValidateOwnership(CheatContext context, IClientPlayer sender)
{
if (!sender.IsOwner(this))
@@ -137,7 +144,7 @@ namespace Empostor.Server.Net.Inner
return true;
}
protected async ValueTask<bool> ValidateMurderTiming(CheatContext context, IClientPlayer sender, byte killerPlayerId, IInnerPlayerControl? target)
protected async ValueTask<bool> 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<bool> 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<bool> ValidateVentExploit(CheatContext context, IClientPlayer sender, byte playerId, ushort sequenceId, byte state, byte ventId)
protected async ValueTask<bool> 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<bool> 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<bool> ValidateRapidSabotage(CheatContext context, IClientPlayer sender, byte playerId, SystemTypes system)
protected async ValueTask<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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;
}
@@ -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;
}
+1 -1
View File
@@ -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))
{
+7 -2
View File
@@ -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<GameDataResult> HandleGameDataInnerAsync(IMessageReader reader, ClientPlayer sender, ClientPlayer? target)
private async ValueTask<GameDataResult> 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;
+2 -5
View File
@@ -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,