Skip to content

Validate client split message count before reserving buffer - #1088

Open
jimdroberts wants to merge 1 commit into
FirstGearGames:mainfrom
jimdroberts:fix/split-message-count-clamp
Open

jimdroberts wants to merge 1 commit into
FirstGearGames:mainfrom
jimdroberts:fix/split-message-count-clamp

Conversation

@jimdroberts

Copy link
Copy Markdown
Contributor

Problem

The server sizes the buffer for an incoming split message from the message count in the split header, and it
does this before the connection has authenticated. The count is not validated, so a client can make the server
reserve far more memory than TransportManager.MaximumClientPacketSize (20480 bytes by default) permits a client
to send in total: up to roughly 2 GB for a single split header.

Since split readers became per-connection and pooled, the reserved buffer also outlives the message: the
SplitReader is returned to ResettableObjectCaches<SplitReader> with its writer at the enlarged capacity
(Writer.Clear() does not shrink), so the memory is retained for the life of the process.

Found while reviewing the receive path.

Cause

  • ServerManager.ParseReceived (Runtime/Managing/Server/ServerManager.cs:767) reads expectedMessages with
    reader.ReadInt32() and passes it straight to connection.TryGetSplitReader(expectedMessages, ...) (line 769).
    This runs before the authentication check, which only happens per packet id inside the parse loop (line 810).
  • NetworkConnection.TryGetSplitReader (Runtime/Connection/NetworkConnection.Buffer.cs:59-60) retrieves a
    pooled SplitReader and calls Initialize(..., expectedMessages).
  • SplitReader.Initialize (Runtime/Managing/Transporting/SplitReader.cs:50-52) computes
    expectedMessages * 1500 and calls EnsureBufferCapacity, which is an Array.Resize to that size.
  • The per-client byte limit is only enforced later, in SplitReader.Write (line 64), after the buffer has already
    been resized.

A legitimate client never sends more than MaximumClientPacketSize bytes in a split:
TransportManager.SendSplitMessage (TransportManager.cs:605) refuses to send when
messageCount * maximumSegmentLength > _maximumClientPacketSize. So the largest count a client produces is
floor(MaximumClientPacketSize / maximumSegmentLength), and it is always at least 1.

Fix

  • TransportManager.GetMaximumClientSplitMessageCount() returns
    ceil(MaximumClientPacketSize / maximumSegmentLength), using the same _maximumSplitPacketSegmentLength the
    sender uses. Rounding up leaves one message of headroom over what the sender can produce.
  • ServerManager.ParseReceived kicks with KickReason.UnusualActivity when the count is below 1 or above that
    bound, logging at normal level like the neighbouring split checks (to avoid log spam).
  • SplitReader.Initialize never reserves more than maximumClientBytes when the sender is a client, so the
    reservation stays bounded even if another caller passes an unchecked count.

The server-to-client direction (ClientManager) is unchanged; the client already trusts the server.

Evidence

Verified with a small arithmetic model of SplitReader.Initialize and SendSplitMessage (default
MaximumClientPacketSize 20480, Tugboat MTU 1282 minus the default reserves, so a segment length of 1270):

  • Legitimate client split counts: 2 to 16. All are accepted (the bound is 17).
  • On main, the first split header alone reserves count * 1500 bytes before any byte limit is checked. For
    counts in the high hundreds of thousands, that is hundreds of MB to about 2 GB. With this change such
    counts are kicked before a reader is retrieved.
  • For accepted counts, the reservation is capped at 20480 bytes.

Behavior change

  • A client whose split header count is < 1 or > ceil(MaximumClientPacketSize / segment length) is kicked
    (UnusualActivity) instead of being accepted. A stock FishNet client cannot produce such a count, provided
    client and server report the same reliable-channel MTU and MTU reserve. The existing per-packet MTU kick in the
    same method already assumes this.
  • For client senders, the initial split buffer reservation is capped at MaximumClientPacketSize.

The split count is read from the wire before the connection is
authenticated and was used directly to size the SplitReader buffer.
Kick connections sending a count outside what a client can produce and
never reserve more than MaximumClientPacketSize for client senders.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant