mirror of
https://github.com/Empostor/Empostor.git
synced 2026-10-19 15:51:39 +00:00
Update Anticheat
This commit is contained in:
1 parent
1c8de1c5f8
commit
71d384c8e6
17 files changed
+626
-153
No files matched your search
@@ -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/**
|
||||
-89
@@ -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"]
|
||||
-50
@@ -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:
|
||||
@@ -150,7 +150,6 @@
|
||||
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
|
||||
}
|
||||
}
|
||||
},
|
||||
".NETStandard,Version=v2.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,21 @@ public enum CheatCategory
|
||||
/// <summary>A packet was sent with more items than possible in the game.</summary>
|
||||
ItemLimits,
|
||||
|
||||
/// <summary>A kill that the killer's own cooldown cannot explain, like a second victim inside the window or another hit on a dead one.</summary>
|
||||
Murder,
|
||||
|
||||
/// <summary>A sabotage the current game state cannot produce, like switching systems too fast or sabotaging during a meeting.</summary>
|
||||
Sabotage,
|
||||
|
||||
/// <summary>A meeting or a report that this round cannot produce yet.</summary>
|
||||
Meeting,
|
||||
|
||||
/// <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>Legacy category for unsorted anticheat checks.</summary>
|
||||
Other,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,6 @@
|
||||
"resolved": "4.5.0",
|
||||
"contentHash": "Xg4G4Indi4dqP1iuAiMSwpiWS54ZghzR644OtsRCm/m/lBMG8dUBhLVN7hLm8NNrNTR+iGbshCPTwrvxZPlm4g=="
|
||||
}
|
||||
},
|
||||
"net8.0/win-x64": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Empostor.Server.Net.Anticheat
|
||||
{
|
||||
internal sealed class PlayerTimeline
|
||||
{
|
||||
/// <summary>Gets or sets the moment the player's control was spawned.</summary>
|
||||
public double SpawnedAt { get; set; } = -1;
|
||||
|
||||
/// <summary>Gets or sets the moment of the last completed kill.</summary>
|
||||
public double LastKillAt { get; set; } = -1;
|
||||
|
||||
/// <summary>Gets or sets the victim of the last completed kill.</summary>
|
||||
public byte LastKillTarget { get; set; } = byte.MaxValue;
|
||||
|
||||
/// <summary>Gets or sets how many times in a row this player killed an already dead victim.</summary>
|
||||
public int DeadTargetKills { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the moment of the last sabotage this player triggered.</summary>
|
||||
public double LastSabotageAt { get; set; } = -1;
|
||||
|
||||
/// <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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<InnerPlayerControl>(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<bool> UnregisteredCall(CheatContext context, IClientPlayer sender)
|
||||
{
|
||||
if (await sender.Client.ReportCheatAsync(context, CheatCategory.ProtocolExtension, "Client sent unregistered call"))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<object, object>();
|
||||
AntiCheat = new AntiCheatState(options, serviceProvider.GetRequiredService<IOptions<AntiCheatConfig>>().Value);
|
||||
}
|
||||
|
||||
internal AntiCheatState AntiCheat { get; }
|
||||
|
||||
public IPEndPoint PublicIp { get; }
|
||||
|
||||
public GameCode Code { get; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user