From 71d384c8e66efc76a443a38b1898ed259d7bd0b4 Mon Sep 17 00:00:00 2001 From: linmeideli <2558527373@qq.com> Date: Fri, 2 Oct 2026 20:37:20 +0800 Subject: [PATCH] Update Anticheat --- .dockerignore | 4 + Dockerfile | 89 ---- compose.yml | 50 --- .../packages.lock.json | 3 +- src/Empostor.Api/CheatCategory.cs | 15 + src/Empostor.Api/Config/AntiCheatConfig.cs | 12 +- src/Empostor.Api/packages.lock.json | 3 +- .../Net/Anticheat/AntiCheatState.cs | 92 +++++ .../Net/Anticheat/PlayerTimeline.cs | 43 ++ src/Empostor.Server/Net/Client.cs | 5 + .../Net/Inner/InnerNetObject.Anticheat.cs | 386 ++++++++++++++++++ .../Net/Inner/Objects/InnerMeetingHud.cs | 13 +- .../Net/Inner/Objects/InnerPlayerControl.cs | 24 +- .../Objects/ShipStatus/InnerShipStatus.cs | 7 +- src/Empostor.Server/Net/State/Game.Data.cs | 21 +- src/Empostor.Server/Net/State/Game.cs | 5 + src/Empostor.Server/config.json | 7 +- 17 files changed, 626 insertions(+), 153 deletions(-) delete mode 100644 Dockerfile delete mode 100644 compose.yml create mode 100644 src/Empostor.Server/Net/Anticheat/AntiCheatState.cs create mode 100644 src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs diff --git a/.dockerignore b/.dockerignore index ace52dd..051b9e3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -37,3 +37,7 @@ Data/ tmp/ temp/ *.tmp + +# Prebuilt Dockerfile needs the publish output (see Dockerfile.prebuilt) +!src/Empostor.Server/bin/Release/net8.0/linux-x64/publish/ +!src/Empostor.Server/bin/Release/net8.0/linux-x64/publish/** diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index fa13d87..0000000 --- a/Dockerfile +++ /dev/null @@ -1,89 +0,0 @@ -# ============================================ -# Empostor Server Docker Image -# ============================================ -# Multi-stage build: compiles from source, then produces a minimal runtime image. -# -# Build (local): -# docker build -t empostor-server . -# -# Build (multi-arch): -# docker buildx build --platform linux/amd64,linux/arm64 -t empostor-server . -# -# Run: -# docker compose up -d -# ============================================ - -# ── Build stage ──────────────────────────── -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build - -ARG TARGETARCH -ARG VERSIONSUFFIX=docker - -WORKDIR /source - -# Restore dependencies (layer caching) -COPY src/Empostor.Server/Empostor.Server.csproj ./src/Empostor.Server/ -COPY src/Empostor.Api/Empostor.Api.csproj ./src/Empostor.Api/ -COPY src/Empostor.Api.Innersloth.Generator/Empostor.Api.Innersloth.Generator.csproj ./src/Empostor.Api.Innersloth.Generator/ -COPY src/Empostor.Plugins.PlayerLog/Empostor.Plugins.PlayerLog.csproj ./src/Empostor.Plugins.PlayerLog/ -COPY src/Directory.Build.props ./src/ - -RUN case "$TARGETARCH" in \ - amd64) RID=linux-x64 ;; \ - arm64) RID=linux-arm64 ;; \ - arm) RID=linux-arm ;; \ - *) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \ - esac && \ - dotnet restore -r "$RID" ./src/Empostor.Server/Empostor.Server.csproj - -# Copy sources and publish -COPY src/. ./src/ - -RUN case "$TARGETARCH" in \ - amd64) RID=linux-x64 ;; \ - arm64) RID=linux-arm64 ;; \ - arm) RID=linux-arm ;; \ - *) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \ - esac && \ - [ "$VERSIONSUFFIX" = "none" ] && VERSIONSUFFIX="" ; \ - dotnet publish ./src/Empostor.Server/Empostor.Server.csproj \ - -c Release \ - -r "$RID" \ - --no-restore \ - --self-contained false \ - -p:PublishSingleFile=true \ - -p:PublishTrimmed=false \ - -p:VersionSuffix="$VERSIONSUFFIX" \ - -o /app - -# Copy default config (must be overridden in production) -RUN cp ./src/Empostor.Server/config.json /app/config.json 2>/dev/null || true - -# ── Runtime stage ────────────────────────── -FROM mcr.microsoft.com/dotnet/aspnet:8.0 - -WORKDIR /app - -# Copy published output from build stage -COPY --from=build /app ./ - -# Create writable runtime directories -RUN mkdir -p /app/plugins /app/libraries /app/Languages /app/Log /app/Data /app/marketplace \ - && chmod 777 /app/plugins /app/libraries /app/Languages /app/Log /app/Data /app/marketplace - -# Environment -ENV ASPNETCORE_URLS=http://0.0.0.0:80 -ENV EMPOSTOR_Server__PublicIp=0.0.0.0 -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 -ENV DOTNET_RUNNING_IN_CONTAINER=1 - -EXPOSE 80/tcp -EXPOSE 22023/tcp -# Game / matchmaker UDP (Server.ListenPort) — always bound, even in delta mode. -EXPOSE 22023/udp -# Delta UDP port pool (Server.DeltaPortStart/End). Keep in sync with config.json. -EXPOSE 22024-42024/udp - -VOLUME ["/app/config", "/app/plugins", "/app/libraries", "/app/Languages", "/app/Log", "/app/Data", "/app/marketplace"] - -ENTRYPOINT ["./Empostor.Server"] diff --git a/compose.yml b/compose.yml deleted file mode 100644 index f4bb39b..0000000 --- a/compose.yml +++ /dev/null @@ -1,50 +0,0 @@ -services: - empostor: - build: - context: . - dockerfile: Dockerfile - container_name: empostor-server - restart: unless-stopped - - ports: - # HTTP API / Admin panel (TCP) - - "22023:22023/tcp" - # Game / Matchmaker UDP — REQUIRED. The matchmaker always binds this UDP - # port (Server.ListenPort) so players can connect; without it the server - # is unreachable even in delta mode. - - "22023:22023/udp" - # Delta UDP port pool. With DeltaPortStart/End enabled, each player is - # assigned a unique UDP port from this range (Server.DeltaPortStart/End). - # Must stay in sync with config.json. - - "22024-42024:22024-42024/udp" - # Uncomment to expose admin HTTP panel: - # - "80:80/tcp" - - volumes: - # Server config. NOTE: if ./config.json does NOT exist on the host, Docker - # creates an EMPTY DIRECTORY at that path and mounts it over the baked-in - # /app/config.json, which breaks startup. Create the file first (or remove - # this line to use the image's default config) before running. - - ./config.json:/app/config.json:ro - # Writable runtime data - - empostor_plugins:/app/plugins - - empostor_libraries:/app/libraries - - empostor_languages:/app/Languages - - ./Log:/app/Log - - ./Data:/app/Data - - ./marketplace:/app/marketplace - # Per-component configs (writable from admin panel) - - ./discord_webhook.json:/app/discord_webhook.json - - environment: - - DOTNET_RUNNING_IN_CONTAINER=1 - - ASPNETCORE_URLS=http://0.0.0.0:80 - # PublicIp MUST be your real public IP / domain, or remote players can't - # connect. 0.0.0.0 / 127.0.0.1 will NOT work for anyone but localhost. - - EMPOSTOR_Server__PublicIp=0.0.0.0 - - EMPOSTOR_Server__ListenIp=0.0.0.0 - -volumes: - empostor_plugins: - empostor_libraries: - empostor_languages: diff --git a/src/Empostor.Api.Innersloth.Generator/packages.lock.json b/src/Empostor.Api.Innersloth.Generator/packages.lock.json index 5850ca3..fb68fad 100644 --- a/src/Empostor.Api.Innersloth.Generator/packages.lock.json +++ b/src/Empostor.Api.Innersloth.Generator/packages.lock.json @@ -150,7 +150,6 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } } - }, - ".NETStandard,Version=v2.0/win-x64": {} + } } } \ No newline at end of file diff --git a/src/Empostor.Api/CheatCategory.cs b/src/Empostor.Api/CheatCategory.cs index 795633e..466e42b 100644 --- a/src/Empostor.Api/CheatCategory.cs +++ b/src/Empostor.Api/CheatCategory.cs @@ -35,6 +35,21 @@ public enum CheatCategory /// A packet was sent with more items than possible in the game. ItemLimits, + /// A kill that the killer's own cooldown cannot explain, like a second victim inside the window or another hit on a dead one. + Murder, + + /// A sabotage the current game state cannot produce, like switching systems too fast or sabotaging during a meeting. + Sabotage, + + /// A meeting or a report that this round cannot produce yet. + Meeting, + + /// 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, + /// Legacy category for unsorted anticheat checks. Other, } diff --git a/src/Empostor.Api/Config/AntiCheatConfig.cs b/src/Empostor.Api/Config/AntiCheatConfig.cs index ad169dd..a3b43b7 100644 --- a/src/Empostor.Api/Config/AntiCheatConfig.cs +++ b/src/Empostor.Api/Config/AntiCheatConfig.cs @@ -28,6 +28,16 @@ namespace Empostor.Api.Config public bool EnableTargetChecks { get; set; } = true; + public bool EnableMurderChecks { get; set; } = true; + + public bool EnableSabotageChecks { get; set; } = true; + + public bool EnableMeetingChecks { get; set; } = true; + + public bool EnableVotingChecks { get; set; } = true; + + public bool EnableVentExploitCheck { get; set; } = false; + public bool ForbidProtocolExtensions { get; set; } = true; public bool EnablePacketSizeChecks { get; set; } = true; @@ -38,6 +48,6 @@ namespace Empostor.Api.Config public int IpRateLimitRequests { get; set; } = 30; - public int IpRateLimitWindowMinutes { get; set; } = 5; + public int IpRateLimitWindowMinutes { get; set; } = 1; } } diff --git a/src/Empostor.Api/packages.lock.json b/src/Empostor.Api/packages.lock.json index 21be9d2..9734f25 100644 --- a/src/Empostor.Api/packages.lock.json +++ b/src/Empostor.Api/packages.lock.json @@ -144,7 +144,6 @@ "resolved": "4.5.0", "contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g==" } - }, - "net8.0/win-x64": {} + } } } \ No newline at end of file diff --git a/src/Empostor.Server/Net/Anticheat/AntiCheatState.cs b/src/Empostor.Server/Net/Anticheat/AntiCheatState.cs new file mode 100644 index 0000000..244c5e8 --- /dev/null +++ b/src/Empostor.Server/Net/Anticheat/AntiCheatState.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using Empostor.Api.Config; +using Empostor.Api.Innersloth.GameOptions; + +namespace Empostor.Server.Net.Anticheat +{ + internal sealed class AntiCheatState + { + private const int TimelineCount = 256; + private const int UnknownTimelineIndex = TimelineCount - 1; + private readonly PlayerTimeline[] _timelines = new PlayerTimeline[TimelineCount]; + private readonly IGameOptions _options; + private double _roundStartedAt = -1; + private double _meetingOpenedAt = -1; + private bool _meetingActive; + + public AntiCheatState(IGameOptions options, AntiCheatConfig config) + { + _options = options; + Config = config; + + for (var i = 0; i < _timelines.Length; i++) + { + _timelines[i] = new PlayerTimeline(); + } + } + + public AntiCheatConfig Config { get; } + + public static double Now => (double)Stopwatch.GetTimestamp() / Stopwatch.Frequency; + + public bool ShipLoaded => _roundStartedAt >= 0; + + public bool ShipGraceOver + { + get + { + if (_roundStartedAt < 0) + { + return true; + } + + var cooldown = _options is NormalGameOptions normal ? normal.EmergencyCooldown : 0; + return cooldown <= 0 || Now - _roundStartedAt >= cooldown / 2; + } + } + + public bool InMeeting => _meetingActive; + + public double SecondsSinceRoundStarted => _roundStartedAt < 0 ? -1 : Now - _roundStartedAt; + + public bool MeetingGuardArmed => + _meetingActive && _meetingOpenedAt >= 0 && Now - _meetingOpenedAt >= 1; + + public PlayerTimeline For(byte playerId) + { + return playerId < TimelineCount ? _timelines[playerId] : _timelines[UnknownTimelineIndex]; + } + + public void NoteRoundStarted() + { + _roundStartedAt = Now; + _meetingActive = false; + _meetingOpenedAt = -1; + + foreach (var timeline in _timelines) + { + timeline.ResetRound(); + } + } + + public void NoteRoundEnded() + { + _roundStartedAt = -1; + _meetingActive = false; + _meetingOpenedAt = -1; + } + + public void NoteMeetingOpened() + { + _meetingActive = true; + _meetingOpenedAt = Now; + } + + public void NoteMeetingClosed() + { + _meetingActive = false; + _meetingOpenedAt = -1; + } + + } +} diff --git a/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs b/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs new file mode 100644 index 0000000..a438804 --- /dev/null +++ b/src/Empostor.Server/Net/Anticheat/PlayerTimeline.cs @@ -0,0 +1,43 @@ +namespace Empostor.Server.Net.Anticheat +{ + internal sealed class PlayerTimeline + { + /// Gets or sets the moment the player's control was spawned. + public double SpawnedAt { get; set; } = -1; + + /// Gets or sets the moment of the last completed kill. + public double LastKillAt { get; set; } = -1; + + /// Gets or sets the victim of the last completed kill. + public byte LastKillTarget { get; set; } = byte.MaxValue; + + /// Gets or sets how many times in a row this player killed an already dead victim. + public int DeadTargetKills { get; set; } + + /// Gets or sets the moment of the last sabotage this player triggered. + public double LastSabotageAt { get; set; } = -1; + + /// 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; + + public void NoteKill(byte victimId, double now) + { + LastKillAt = now; + LastKillTarget = victimId; + } + + public void ResetRound() + { + SpawnedAt = -1; + LastKillAt = -1; + LastKillTarget = byte.MaxValue; + DeadTargetKills = 0; + LastSabotageAt = -1; + LastSabotageSystem = -1; + VentExploitPendingAt = -1; + } + } +} diff --git a/src/Empostor.Server/Net/Client.cs b/src/Empostor.Server/Net/Client.cs index ef4d6e4..24cbecb 100644 --- a/src/Empostor.Server/Net/Client.cs +++ b/src/Empostor.Server/Net/Client.cs @@ -89,6 +89,11 @@ namespace Empostor.Server.Net CheatCategory.Target => _antiCheatConfig.EnableTargetChecks, CheatCategory.PacketSize => _antiCheatConfig.EnablePacketSizeChecks, CheatCategory.ItemLimits => _antiCheatConfig.EnableItemLimitChecks, + CheatCategory.Murder => _antiCheatConfig.EnableMurderChecks, + CheatCategory.Sabotage => _antiCheatConfig.EnableSabotageChecks, + CheatCategory.Meeting => _antiCheatConfig.EnableMeetingChecks, + CheatCategory.Voting => _antiCheatConfig.EnableVotingChecks, + CheatCategory.Venting => _antiCheatConfig.EnableVentExploitCheck, 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 5a8932e..81efe44 100644 --- a/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs +++ b/src/Empostor.Server/Net/Inner/InnerNetObject.Anticheat.cs @@ -1,6 +1,11 @@ +using System; using System.Threading.Tasks; using Empostor.Api; +using Empostor.Api.Innersloth.GameOptions; using Empostor.Api.Net; +using Empostor.Api.Net.Inner; +using Empostor.Api.Net.Inner.Objects; +using Empostor.Server.Net.Anticheat; using Empostor.Server.Net.Inner.Objects; namespace Empostor.Server.Net.Inner @@ -132,6 +137,387 @@ namespace Empostor.Server.Net.Inner return true; } + protected async ValueTask ValidateMurderTiming(CheatContext context, IClientPlayer sender, byte killerPlayerId, IInnerPlayerControl? target) + { + if (!Game.AntiCheat.Config.EnableMurderChecks || target == null) + { + return true; + } + + var timeline = Game.AntiCheat.For(killerPlayerId); + + if (target.PlayerInfo is { IsDead: true }) + { + timeline.DeadTargetKills++; + + if (timeline.DeadTargetKills >= 6 && + await sender.Client.ReportCheatAsync( + context, + CheatCategory.Murder, + $"Client murdered the already dead player {target.PlayerId} {timeline.DeadTargetKills} times")) + { + return false; + } + } + else + { + timeline.DeadTargetKills = 0; + } + + if (Game.Options is not NormalGameOptions options || + Game.Options.GameMode is not (GameModes.Normal or GameModes.NormalFools)) + { + return true; + } + + var window = options.KillCooldown / 2; + + if (IsFastKill(timeline, target.PlayerId, window, AntiCheatState.Now, out var reason)) + { + return await sender.Client.ReportCheatAsync(context, CheatCategory.Murder, reason!); + } + + 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) + { + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.Meeting, + "Client sent a meeting before the map of this round was spawned"); + } + + if (Game.AntiCheat.SecondsSinceRoundStarted >= 0 && + Game.AntiCheat.SecondsSinceRoundStarted < 10) + { + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.Meeting, + $"Client sent a meeting {Game.AntiCheat.SecondsSinceRoundStarted:0.###}s into the round (grace 10s)"); + } + + return true; + } + + protected async ValueTask ValidateUpdateSystem(CheatContext context, IClientPlayer sender, IMessageReader reader) + { + if (!Game.AntiCheat.Config.EnableSabotageChecks) + { + return true; + } + + var position = reader.Position; + + try + { + if (!TryReadUpdateSystem(reader, out var systemType, out var playerControl, out var isVent, out var sequenceId, out var state, out var ventId)) + { + return await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, "Client sent a malformed UpdateSystem RPC"); + } + + if (!sender.IsHost && !playerControl.IsOwnedBy(sender)) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.Ownership, $"Client sent {nameof(RpcCalls.UpdateSystem)} for a {nameof(InnerPlayerControl)} it does not own")) + { + return false; + } + } + + var actorId = ResolveActorId(playerControl, sender); + + if (isVent) + { + return await ValidateVentExploit(context, sender, actorId, sequenceId, state, ventId); + } + + if (!Game.AntiCheat.ShipGraceOver) + { + return true; + } + + if (!await ValidateSabotageInMeeting(context, sender, systemType)) + { + return false; + } + + if (!await ValidateRapidSabotage(context, sender, actorId, systemType)) + { + return false; + } + + var actorIsImpostor = ResolveActorPlayerInfo(playerControl, sender)?.IsImpostor ?? false; + return await ValidateSabotageRole(context, sender, actorIsImpostor, systemType, state); + } + finally + { + reader.Seek(position); + } + } + + protected async ValueTask ValidateVentExploit(CheatContext context, IClientPlayer sender, byte playerId, ushort sequenceId, byte state, byte ventId) + { + if (!Game.AntiCheat.Config.EnableVentExploitCheck) + { + 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) + { + timeline.VentExploitPendingAt = now; + return true; + } + + 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)"); + } + + protected async ValueTask ValidateSabotageInMeeting(CheatContext context, IClientPlayer sender, SystemTypes system) + { + if (!Game.AntiCheat.MeetingGuardArmed) + { + return true; + } + + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.Sabotage, + $"Client sabotaged {system} while a meeting is in progress"); + } + + protected async ValueTask ValidateRapidSabotage(CheatContext context, IClientPlayer sender, byte playerId, SystemTypes system) + { + if ((int)system == 16) + { + return true; + } + + var timeline = Game.AntiCheat.For(playerId); + var now = AntiCheatState.Now; + + if (timeline.LastSabotageAt >= 0 && + now - timeline.LastSabotageAt < 0.1 && + timeline.LastSabotageSystem != (int)system) + { + var reason = $"Sabotaged {system} {(now - timeline.LastSabotageAt) * 1000:0.###}ms after {timeline.LastSabotageSystem}"; + timeline.LastSabotageAt = now; + timeline.LastSabotageSystem = (int)system; + return await sender.Client.ReportCheatAsync(context, CheatCategory.Sabotage, reason); + } + + timeline.LastSabotageAt = now; + timeline.LastSabotageSystem = (int)system; + return true; + } + + protected async ValueTask ValidateSabotageRole(CheatContext context, IClientPlayer sender, bool actorIsImpostor, SystemTypes system, byte state) + { + if (actorIsImpostor) + { + return true; + } + + var startingSabotage = (state & 0x80) != 0; + if (!startingSabotage && system != SystemTypes.MushroomMixupSabotage) + { + return true; + } + + var reason = startingSabotage + ? $"Non-impostor started a sabotage of {system} (state 0x{state:X2})" + : $"Non-impostor triggered {system}"; + + return await sender.Client.ReportCheatAsync(context, CheatCategory.Sabotage, reason); + } + + protected async ValueTask ValidateVoteCast(CheatContext context, IClientPlayer sender) + { + if (!Game.AntiCheat.Config.EnableVotingChecks) + { + return true; + } + + if (!Game.AntiCheat.InMeeting && + await sender.Client.ReportCheatAsync(context, CheatCategory.Voting, "Client cast a vote while no meeting is in progress")) + { + return false; + } + + if (sender.Character?.PlayerInfo is { IsDead: true } && + await sender.Client.ReportCheatAsync(context, CheatCategory.Voting, "Client cast a vote while being dead")) + { + return false; + } + + return true; + } + + protected async ValueTask ValidateVoterCount(CheatContext context, IClientPlayer sender, IMessageReader reader) + { + var position = reader.Position; + int voters; + + try + { + voters = reader.ReadPackedInt32(); + } + catch (Exception) + { + return true; + } + finally + { + reader.Seek(position); + } + + var limit = Math.Max(Game.Options.MaxPlayers, Game.PlayerCount); + if (voters >= 0 && voters <= limit && voters <= reader.Length - position) + { + return true; + } + + return await sender.Client.ReportCheatAsync( + context, + CheatCategory.ProtocolExtension, + $"Client sent a VotingComplete with {voters} voters (max {limit})"); + } + + 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}")) + { + return false; + } + + return true; + } + + private bool TryReadUpdateSystem( + IMessageReader reader, + out SystemTypes systemType, + out InnerPlayerControl playerControl, + out bool isVent, + out ushort sequenceId, + out byte state, + out byte ventId) + { + systemType = SystemTypes.Hallway; + playerControl = null!; + isVent = false; + sequenceId = 0; + state = 0; + ventId = byte.MaxValue; + + if (reader.Position >= reader.Length) + { + return false; + } + + systemType = (SystemTypes)reader.ReadByte(); + + var control = reader.ReadNetObject(Game); + if (control == null) + { + return false; + } + + 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; + } + + if (systemType == SystemTypes.Sabotage) + { + if (reader.Length - reader.Position < 2) + { + return false; + } + + systemType = (SystemTypes)reader.ReadByte(); + } + + state = reader.ReadByte(); + return true; + } + + private byte ResolveActorId(InnerPlayerControl control, IClientPlayer sender) + { + if (control.OwnerId >= 0) + { + var owner = Game.GetClientPlayer(control.OwnerId); + if (owner?.Character != null) + { + return owner.Character.PlayerId; + } + } + + return sender.Character?.PlayerId ?? byte.MaxValue; + } + + private InnerPlayerInfo? ResolveActorPlayerInfo(InnerPlayerControl control, IClientPlayer sender) + { + if (control.PlayerInfo != null) + { + return control.PlayerInfo; + } + + var owner = control.OwnerId >= 0 ? Game.GetClientPlayer(control.OwnerId) : null; + return (InnerPlayerInfo?)(owner?.Character?.PlayerInfo ?? sender.Character?.PlayerInfo); + } + protected async ValueTask UnregisteredCall(CheatContext context, IClientPlayer sender) { if (await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, "Client sent unregistered call")) diff --git a/src/Empostor.Server/Net/Inner/Objects/InnerMeetingHud.cs b/src/Empostor.Server/Net/Inner/Objects/InnerMeetingHud.cs index fca6ded..612d591 100644 --- a/src/Empostor.Server/Net/Inner/Objects/InnerMeetingHud.cs +++ b/src/Empostor.Server/Net/Inner/Objects/InnerMeetingHud.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -140,6 +140,7 @@ namespace Empostor.Server.Net.Inner.Objects Rpc22Close.Deserialize(reader); _judgeOverrules.Clear(); + Game.AntiCheat.NoteMeetingClosed(); break; } @@ -150,6 +151,11 @@ namespace Empostor.Server.Net.Inner.Objects return false; } + if (!await ValidateVoterCount(call, sender, reader)) + { + return false; + } + if (sender.Client.GameVersion >= JudgeMinVersion) { Rpc23VotingComplete.Deserialize(reader, out var states, out var playerId, out var tie, out var wasOverruled, out var overrideId); @@ -247,6 +253,11 @@ namespace Empostor.Server.Net.Inner.Objects } } + if (!await ValidateVoteCast(RpcCalls.CastVote, sender)) + { + return false; + } + if (!sender.IsHost) { var playerVoteArea = _playerStates.Single(x => x.TargetPlayer.PlayerId == playerId); diff --git a/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs b/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs index 1c871e2..e0bcca5 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; @@ -13,6 +13,7 @@ using Empostor.Api.Net.Inner.Objects; using Empostor.Api.Net.Messages.Rpcs; using Empostor.Api.Utils; using Empostor.Server.Events.Player; +using Empostor.Server.Net.Anticheat; using Empostor.Server.Net.Inner.Objects.Components; using Empostor.Server.Net.State; using Microsoft.Extensions.DependencyInjection; @@ -255,10 +256,14 @@ namespace Empostor.Server.Net.Inner.Objects return false; } + if (!await ValidateMeetingTiming(call, sender)) + { + return false; + } + Rpc11ReportDeadBody.Deserialize(reader, out var targetId); break; } - case RpcCalls.MurderPlayer: { if (!await ValidateHost(call, sender) || @@ -291,6 +296,11 @@ namespace Empostor.Server.Net.Inner.Objects return false; } + if (!await ValidateMeetingTiming(call, sender)) + { + return false; + } + Rpc14StartMeeting.Deserialize(reader, out var targetId); await HandleStartMeeting(targetId); break; @@ -1045,6 +1055,11 @@ namespace Empostor.Server.Net.Inner.Objects IsMurdering = target; } + if (!await ValidateMurderTiming(RpcCalls.CheckMurder, sender, PlayerId, target)) + { + return false; + } + if (_game.IsHostAuthoritive) { return true; @@ -1074,6 +1089,11 @@ namespace Empostor.Server.Net.Inner.Objects { target.ProtectedOn = null; await ForceMurderPlayerAsync(target, evt.Result); + + if (!evt.Result.IsFailed()) + { + Game.AntiCheat.For(PlayerId).NoteKill(target.PlayerId, AntiCheatState.Now); + } } } diff --git a/src/Empostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs b/src/Empostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs index 8afa14f..8211d07 100644 --- a/src/Empostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs +++ b/src/Empostor.Server/Net/Inner/Objects/ShipStatus/InnerShipStatus.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Numerics; @@ -89,13 +89,12 @@ namespace Empostor.Server.Net.Inner.Objects.ShipStatus case RpcCalls.UpdateSystem: { - if (!await ValidateCmd(call, sender, target)) + if (!await ValidateCmd(call, sender, target) || + !await ValidateUpdateSystem(call, sender, reader)) { return false; } - // TODO: properly deserialize this RPC - // Rpc35UpdateSystem.Deserialize(reader, Game, out var systemType, out var playerControl, out var sequenceId, out var state, out var ventId); break; } diff --git a/src/Empostor.Server/Net/State/Game.Data.cs b/src/Empostor.Server/Net/State/Game.Data.cs index 3036fd4..0c89e1e 100644 --- a/src/Empostor.Server/Net/State/Game.Data.cs +++ b/src/Empostor.Server/Net/State/Game.Data.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -9,6 +9,7 @@ using Empostor.Api.Unity; using Empostor.Server.Events.Game.Player; using Empostor.Server.Events.Meeting; using Empostor.Server.Events.Player; +using Empostor.Server.Net.Anticheat; using Empostor.Server.Net.Inner; using Empostor.Server.Net.Inner.Objects; using Empostor.Server.Net.Inner.Objects.Components; @@ -367,6 +368,12 @@ namespace Empostor.Server.Net.State default: { _logger.LogWarning("{Code} - Bad GameData tag {Tag}", Code, reader.Tag); + + if (!await InnerNetObject.ValidateGameDataTag(new CheatContext(nameof(GameDataTag)), sender, reader.Tag)) + { + return GameDataResult.Abort; + } + return GameDataResult.Continue; } } @@ -411,6 +418,7 @@ namespace Empostor.Server.Net.State case InnerShipStatus shipStatus: { GameNet.ShipStatus = shipStatus; + AntiCheat.NoteRoundStarted(); break; } @@ -427,6 +435,8 @@ namespace Empostor.Server.Net.State await sender.Client.ReportCheatAsync(new CheatContext(nameof(GameDataTag.SpawnFlag)), CheatCategory.GameFlow, "Failed to find player that spawned the InnerPlayerControl"); } + AntiCheat.For(control.PlayerId).SpawnedAt = AntiCheatState.Now; + // Hook up InnerPlayerControl <-> InnerPlayerControl.PlayerInfo. var playerInfo = GameNet.GameData.GetPlayerById(control.PlayerId); @@ -466,6 +476,8 @@ namespace Empostor.Server.Net.State case InnerMeetingHud meetingHud: { + AntiCheat.NoteMeetingOpened(); + foreach (var player in _players.Values) { if (GameNet.ShipStatus != null) @@ -501,6 +513,13 @@ namespace Empostor.Server.Net.State case InnerShipStatus: { GameNet.ShipStatus = null; + AntiCheat.NoteRoundEnded(); + break; + } + + case InnerMeetingHud: + { + AntiCheat.NoteMeetingClosed(); break; } diff --git a/src/Empostor.Server/Net/State/Game.cs b/src/Empostor.Server/Net/State/Game.cs index 58f32a6..ce34f19 100644 --- a/src/Empostor.Server/Net/State/Game.cs +++ b/src/Empostor.Server/Net/State/Game.cs @@ -15,7 +15,9 @@ using Empostor.Api.Net.Inner.Objects; using Empostor.Api.Net.Manager; using Empostor.Api.Net.Messages.S2C; using Empostor.Server.Events; +using Empostor.Server.Net.Anticheat; using Empostor.Server.Net.Manager; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -68,8 +70,11 @@ namespace Empostor.Server.Net.State _compatibilityConfig = compatibilityConfig.Value; _timeoutConfig = timeoutConfig.Value; Items = new ConcurrentDictionary(); + AntiCheat = new AntiCheatState(options, serviceProvider.GetRequiredService>().Value); } + internal AntiCheatState AntiCheat { get; } + public IPEndPoint PublicIp { get; } public GameCode Code { get; } diff --git a/src/Empostor.Server/config.json b/src/Empostor.Server/config.json index 3719ae5..4cfb5d2 100644 --- a/src/Empostor.Server/config.json +++ b/src/Empostor.Server/config.json @@ -27,12 +27,17 @@ "EnableOwnershipChecks": true, "EnableRoleChecks": true, "EnableTargetChecks": true, + "EnableMurderChecks": true, + "EnableSabotageChecks": true, + "EnableMeetingChecks": true, + "EnableVotingChecks": true, + "EnableVentExploitCheck": false, "ForbidProtocolExtensions": true, "EnablePacketSizeChecks": true, "PacketSizeLimit": 1203, "EnableIpRateLimit": true, "IpRateLimitRequests": 30, - "IpRateLimitWindowMinutes": 5 + "IpRateLimitWindowMinutes": 1 }, "Timeout": { "SpawnTimeout": 5000,