Harden the mod against hostile servers and players

- PacketReader for all network data: lengths are checked against the real data
  (Mono allocated the claimed size first - a fake 2 GB length crashed the game)
- FsmSync: remote players can no longer switch arbitrary FSMs - never PLAYER /
  Systems / MSCO objects, CLICK only on FSMs that wait for a click, bolt/assembly
  events only along a transition the FSM really has for that event
- Time/weather (msco.world) and '* ' system-style chat only from the host in P2P
  (the dedicated server filters them itself); other msco.* ids are reserved

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:44:03 +02:00
co-authored by Claude Opus 5.5
parent 9c933ed486
commit a499577b7c
7 changed files with 88 additions and 9 deletions
+34
View File
@@ -292,6 +292,11 @@ namespace MSCO
if (Debug) MscoLog.Warning("[MSCO] << not found: " + msg.Path + " [" + msg.FsmName + "]"); if (Debug) MscoLog.Warning("[MSCO] << not found: " + msg.Path + " [" + msg.FsmName + "]");
return; return;
} }
if (!Allowed(fsm, msg))
{
if (Debug) MscoLog.Warning("[MSCO] << refused: " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
return;
}
if (msg.EventName == VehicleAdmin.InstallEvent) { VehicleAdmin.InstallRemote(fsm); return; } // Admin hat ein Teil eingebaut if (msg.EventName == VehicleAdmin.InstallEvent) { VehicleAdmin.InstallRemote(fsm); return; } // Admin hat ein Teil eingebaut
FsmState state = fsm.Fsm.GetState(msg.State); FsmState state = fsm.Fsm.GetState(msg.State);
if (state == null || switchState == null) return; if (state == null || switchState == null) return;
@@ -304,6 +309,35 @@ namespace MSCO
finally { applying--; } finally { applying--; }
} }
/// <summary>
/// Darf ein Mitspieler diese FSM in diesen Zustand schalten? Sonst könnte ein manipulierter Client beliebige
/// Zustände bei allen anderen auslösen (z.B. Tod oder Speichern). Nie: der eigene Spieler (PLAYER), Systeme
/// (Tod, Speichern ...), MSCO selbst. Klicks nur auf FSMs, die auf einen Klick warten; Schrauben/Montage nur
/// über einen Übergang, den die FSM für genau dieses Event auch hat.
/// </summary>
static bool Allowed(PlayMakerFSM fsm, FsmEventMsg msg)
{
try
{
string root = fsm.transform.root.name;
if (root == "PLAYER" || root == "Systems" || root.StartsWith("MSCO_")) return false;
if (msg.EventName == VehicleAdmin.InstallEvent) return fsm.FsmName == "Assembly";
if (msg.EventName == "CLICK")
{
foreach (FsmState s in fsm.FsmStates) if (ChecksInput(s)) return fsm.Fsm.GetState(msg.State) != null;
return false;
}
if (Array.IndexOf(SyncedEvents, msg.EventName) < 0) return false;
foreach (FsmState s in fsm.FsmStates)
foreach (FsmTransition t in s.Transitions)
if (t.EventName == msg.EventName && t.ToState == msg.State) return true;
foreach (FsmTransition t in fsm.Fsm.GlobalTransitions)
if (t.EventName == msg.EventName && t.ToState == msg.State) return true;
return false;
}
catch { return false; }
}
/// <summary>FSM in einen Zustand schalten, ohne dass das als eigene Aktion erkannt und gesendet wird.</summary> /// <summary>FSM in einen Zustand schalten, ohne dass das als eigene Aktion erkannt und gesendet wird.</summary>
public static void SwitchStateSilently(PlayMakerFSM fsm, FsmState state) public static void SwitchStateSilently(PlayMakerFSM fsm, FsmState state)
{ {
+1
View File
@@ -99,6 +99,7 @@
<Compile Include="VehicleAdmin.cs" /> <Compile Include="VehicleAdmin.cs" />
<Compile Include="WorldAdmin.cs" /> <Compile Include="WorldAdmin.cs" />
<Compile Include="OilCheck.cs" /> <Compile Include="OilCheck.cs" />
<Compile Include="PacketReader.cs" />
<Compile Include="ModApi.cs" /> <Compile Include="ModApi.cs" />
<Compile Include="ModLoader.cs" /> <Compile Include="ModLoader.cs" />
<Compile Include="MenuBranding.cs" /> <Compile Include="MenuBranding.cs" />
+13 -6
View File
@@ -1731,7 +1731,13 @@ namespace MSCO
string modId = r.ReadString(); string modId = r.ReadString();
int len = r.ReadInt32(); int len = r.ReadInt32();
if (len < 0 || len > MaxModMessageBytes) return; if (len < 0 || len > MaxModMessageBytes) return;
if (modId == WorldAdmin.MessageId) { WorldAdmin.Receive(r.ReadBytes(len)); return; } // Admin: Uhrzeit/Wetter if (modId == WorldAdmin.MessageId)
{
// Admin: Uhrzeit/Wetter. Der dedizierte Server lässt das nur von Admins durch; im P2P-Spiel nur vom Host
if (dedicated || from == NetSession.HostId) WorldAdmin.Receive(r.ReadBytes(len));
return;
}
if (modId.StartsWith("msco.")) return; // reserviert für MSCO selbst
ModLoader.Message(modId, from, r.ReadBytes(len)); ModLoader.Message(modId, from, r.ReadBytes(len));
return; return;
} }
@@ -1740,7 +1746,8 @@ namespace MSCO
string text = r.ReadString(); string text = r.ReadString();
PlayerInfo sender; PlayerInfo sender;
if (dedicated && from == NetSession.HostId) overlays.AddChat("", text, true); // Server/Admin-Meldung if (dedicated && from == NetSession.HostId) overlays.AddChat("", text, true); // Server/Admin-Meldung
else if (text.StartsWith("* ")) overlays.AddChat("", text.Substring(2), true); // "* ..." = Meldung (z.B. Admin-Aktion): auf dem dedizierten Server filtert der Server, im P2P-Spiel nur vom Host
else if (text.StartsWith("* ") && (dedicated || from == NetSession.HostId)) overlays.AddChat("", text.Substring(2), true);
else overlays.AddChat(players.TryGetValue(from, out sender) ? sender.Name : "Player " + from, text); else overlays.AddChat(players.TryGetValue(from, out sender) ? sender.Name : "Player " + from, text);
return; return;
} }
@@ -2368,7 +2375,7 @@ namespace MSCO
/// </summary> /// </summary>
System.Collections.IEnumerator ApplyWorldSnapshot(byte[] snapshot) System.Collections.IEnumerator ApplyWorldSnapshot(byte[] snapshot)
{ {
var r = new BinaryReader(new MemoryStream(snapshot)); var r = new PacketReader(snapshot);
if (r.ReadBoolean()) if (r.ReadBoolean())
{ {
@@ -2401,7 +2408,7 @@ namespace MSCO
for (int i = 0; i < eventCount; i++) for (int i = 0; i < eventCount; i++)
{ {
byte[] payload = r.ReadBytes(r.ReadInt32()); byte[] payload = r.ReadBytes(r.ReadInt32());
FsmSync.Apply(FsmEventMsg.Deserialize(new BinaryReader(new MemoryStream(payload)))); FsmSync.Apply(FsmEventMsg.Deserialize(new PacketReader(payload)));
if ((i + 1) % SnapshotEventsPerFrame == 0) yield return null; // nicht alles in einem Frame if ((i + 1) % SnapshotEventsPerFrame == 0) yield return null; // nicht alles in einem Frame
} }
@@ -2426,7 +2433,7 @@ namespace MSCO
for (int i = 0; i < spawns; i++) for (int i = 0; i < spawns; i++)
{ {
byte[] payload = r.ReadBytes(r.ReadInt32()); byte[] payload = r.ReadBytes(r.ReadInt32());
try { if (ApplySpawn(new BinaryReader(new MemoryStream(payload)))) spawned++; } try { if (ApplySpawn(new PacketReader(payload))) spawned++; }
catch (System.Exception e) { MscoLog.Warning("Could not spawn a saved item: " + e.Message); } catch (System.Exception e) { MscoLog.Warning("Could not spawn a saved item: " + e.Message); }
} }
} }
@@ -2439,7 +2446,7 @@ namespace MSCO
for (int i = 0; i < appeared; i++) for (int i = 0; i < appeared; i++)
{ {
byte[] payload = r.ReadBytes(r.ReadInt32()); byte[] payload = r.ReadBytes(r.ReadInt32());
try { ApplyAppeared(new BinaryReader(new MemoryStream(payload))); } try { ApplyAppeared(new PacketReader(payload)); }
catch (System.Exception e) { MscoLog.Warning("Could not restore a bought item: " + e.Message); } catch (System.Exception e) { MscoLog.Warning("Could not restore a bought item: " + e.Message); }
} }
} }
+1 -1
View File
@@ -67,7 +67,7 @@ namespace MSCO
public BinaryReader Reader() public BinaryReader Reader()
{ {
return new BinaryReader(new MemoryStream(Payload)); return new PacketReader(Payload);
} }
} }
+37
View File
@@ -0,0 +1,37 @@
using System.IO;
namespace MSCO
{
/// <summary>
/// BinaryReader für Daten aus dem Netz (Server, Mitspieler). Mono legt bei ReadBytes(n) und ReadString() vorab ein
/// Array bzw. einen StringBuilder in der angegebenen Länge an - eine gefälschte Länge (z.B. 2 Milliarden) ließ das
/// Spiel mit "Out of Memory" abstürzen. Hier wird jede Länge gegen die tatsächlich vorhandenen Bytes geprüft.
/// </summary>
public sealed class PacketReader : BinaryReader
{
public PacketReader(byte[] data) : base(new MemoryStream(data ?? new byte[0], false)) { }
long Remaining { get { return BaseStream.Length - BaseStream.Position; } }
public override byte[] ReadBytes(int count)
{
if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count);
return base.ReadBytes(count);
}
public override char[] ReadChars(int count)
{
if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count);
return base.ReadChars(count);
}
public override string ReadString()
{
long start = BaseStream.Position;
int length = Read7BitEncodedInt();
if (length < 0 || length > Remaining) throw new EndOfStreamException("Invalid string length " + length);
BaseStream.Position = start;
return base.ReadString();
}
}
}
+1 -1
View File
@@ -244,7 +244,7 @@ namespace MSCO
byte[] payload = ReadExactly(stream, length - 1); byte[] payload = ReadExactly(stream, length - 1);
info.Ping = (int)watch.ElapsedMilliseconds; info.Ping = (int)watch.ElapsedMilliseconds;
using (var r = new BinaryReader(new MemoryStream(payload))) using (var r = new PacketReader(payload))
{ {
info.Name = r.ReadString(); info.Name = r.ReadString();
info.Description = r.ReadString(); info.Description = r.ReadString();
+1 -1
View File
@@ -204,7 +204,7 @@ namespace MSCO
public static void Receive(byte[] data) public static void Receive(byte[] data)
{ {
if (!MenuHelper.InGame || data == null || data.Length < 17) return; if (!MenuHelper.InGame || data == null || data.Length < 17) return;
using (var r = new BinaryReader(new MemoryStream(data))) using (var r = new PacketReader(data))
{ {
byte kind = r.ReadByte(); byte kind = r.ReadByte();
int value = r.ReadInt32(); int value = r.ReadInt32();