Skip to content

6. Malformed and unknown packets

Everything a peer receives may be truncated, forged or sent by a broken or hostile program. The earlier sections give the rules of each message where it is specified; this section gathers them: the size limits, the order of the checks, what a receiver does with a packet that fails one, and what it logs. Section 6.8 maps each check to the Aegis2D implementation.

6.1 Principles

  • Check before use. A receiver MUST check a whole packet before it changes any state (section 1.5). A packet that fails a check is dropped: it is not acknowledged (5.1), its acknowledgements are not read, it does not keep the session alive (5.5), and none of its content reaches the game.
  • Never answer a dropped datagram. A receiver MUST NOT send anything in reply to a datagram it dropped: no error packet, no Accept, no Logout. A UDP source address can be forged, so an answer would let a third party aim the server at someone else, and would tell an attacker which check failed.
  • UDP drops, TCP closes. A bad datagram is dropped and the next one is read normally, since each datagram stands alone. A bad TCP message closes the connection, since the rest of the stream cannot be trusted (6.5).
  • Malformed, unknown, foreign. The rules depend on what is wrong:
Case Meaning Rule
Malformed The bytes do not follow the layout of their type: a field past the end, bytes left after the last field, an invalid value The packet is dropped (6.3, 6.4, 6.5)
Unknown A field holds a value this document reserves for later versions (an entity kind, an event type, a reason) The field's own rule applies: ignore the item, or treat it as a given value (6.3, 6.4)
Foreign A valid datagram that is not for this receiver: another session, an unbound endpoint, the wrong direction The datagram is dropped (6.3)

A value outside the range a field allows, when the range is not marked as reserved for later versions (a player of 7, a player_count of 5, a non-finite f32), is malformed.

6.2 Size limits

Limit Value Specified in When exceeded
Datagram, header included 1200 bytes 1.3 The datagram is dropped before its header is read
Datagram, at least 18 bytes (the header) 2.4 The datagram is dropped
UDP payload 1182 bytes 2.1 Follows from the two limits above
Events in one Event datagram 1 to 255 5.2 count of 0: the datagram is dropped
One event 1177 bytes 5.2 Follows from the datagram limit
Events in flight 256 5.3 The sender considers the session lost
TCP message, prefix excluded 4096 bytes 1.3 The connection is closed before the message is read
Player name 1 to 16 bytes 3.2 LoginRejected with BadName; an invalid name in a PlayerJoined is ignored (6.4)
Player records in a Snapshot 0 to 4 4.3 The datagram is dropped
Scores in a GameOver 0 to 4 4.4 The event is ignored (6.4)

A length or a count is checked against the bytes that remain before anything is allocated or read: a string announcing 60000 bytes in a 30-byte datagram fails without allocating 60000 bytes.

Every payload has an exact size, given by its type and its counts, and a receiver MUST drop a datagram whose payload is shorter or longer:

Type Payload size (bytes)
Connect, Accept, Heartbeat, Disconnect 0
Input 1
Snapshot Its bits (4.3) rounded up to a whole byte
Event 1 + the 4 + length bytes of each event

6.3 UDP datagrams

A receiver MUST run the checks below in this order, and drop the datagram at the first one that fails, without reading further.

  1. Size: at most 1200 bytes. A datagram that the system truncated to fit the receive buffer counts as too large.
  2. Header (section 2.4): at least 18 bytes, then the magic, the version and the type. A type of 0 or 8 to 255 is dropped like any other bad header: unlike the values inside a payload, a packet type cannot be skipped, since its payload cannot be read.
  3. Direction: the type MUST travel towards this receiver (2.3). A server drops Accept and Snapshot; a client drops Connect and Input.
  4. Source (section 3.4): on the server, the source endpoint MUST be bound to the session named by session, except for a Connect; on the client, the datagram MUST come from the server's udp_port with the client's token, and nothing but Accept is accepted before the first Accept.
  5. Payload: a compressed payload is decompressed first (section 2.6); the datagram is dropped when that fails. The whole payload is then read and checked against its type (table below) before anything is applied.
  6. Sequence (section 5.1): only now is the sequence recorded and the acknowledgements read. A duplicate or a datagram too old is valid, but its payload is ignored (except the events of an Event datagram, filtered by their ids, 5.3).

A Connect is checked like the others up to its payload; whether its token opens a binding is the server's decision of section 3.4, and a Connect it does not answer is dropped as foreign.

The payload of each type is malformed when:

Type Malformed when
Connect, Accept, Heartbeat, Disconnect Any byte follows the header
Input The payload is not exactly 1 byte, or one of bits 5 to 7 is set (4.2)
Snapshot A field or a record goes past the end, bytes or padding bits other than 0 remain after the last record, player_count is above 4, a varint has another encoding than the shortest, an entity index goes past 24 bits, baseline_age is 0 or above tick, a change is 3, an updated record has fields 0, or a full snapshot has an updated or removed record (4.3)
Event count is 0, an event goes past the end, or bytes remain after the last event (5.2); the bytes of each event are checked afterwards (6.4)

Inside a valid payload, unknown values are not errors:

Field Unknown values Rule
Entity kind of a Snapshot record 0, 5 to 7 The client ignores the record and reads the next one (4.1)
Snapshot record of an id the client does not know, or destroyed The client ignores the record (4.3, 4.5)
Baseline of a Snapshot the client does not have, or an updated or removed record of an id it does not have The client drops the datagram without acknowledging it (4.8)
Input older than the last one applied The server ignores it (4.2)
ack more recent than the last sequence sent The sender ignores the acknowledgement (5.1)

6.4 Events

The bytes of each event are checked once the Event datagram itself is valid (5.2), and an event is handled on its own:

  • Framing. When the list of events is malformed (6.3), the whole datagram is dropped and none of its events is handed over.
  • Content. When the list is valid, the datagram is acknowledged and its events are handed over once each (5.3). An event whose bytes do not match its type, too short, too long or with an invalid value, MUST then be ignored on its own, and the other events of the datagram are applied. Dropping the datagram instead would not help: the sender would resend the same bytes until it gave up on the session (5.5).
  • Unknown type. An event of type 0 or 7 to 255 MUST be ignored.

An event's content is malformed when:

Event Malformed when
Any Shorter than 5 bytes (type and tick)
Spawn Not exactly 27 bytes, a non-finite position or velocity, or an owner other than 0 to 3 and 255
Destroy Not exactly 18 bytes, or a non-finite position
Death Not exactly 15 bytes, a player above 3, or a non-finite position
PlayerJoined A name past the end, bytes after it, a player above 3, or a name that breaks the rules of LoginRequest (1 to 16 bytes of UTF-8, no control characters)
PlayerLeft Not exactly 7 bytes, or a player above 3
GameOver A score past the end, bytes after the last one, a count above 4, or a player above 3

Unknown values in a valid event follow their field's rule: an entity kind the client does not know makes it ignore the Spawn (4.1), and unknown reasons are read as Killed for Destroy and as Left for PlayerLeft (4.4).

In version 1 only the server sends events. The server checks the framing of an Event datagram from a client like any other (6.3), acknowledges it, and ignores its events.

6.5 TCP messages

A peer MUST close the connection, without sending anything first, when:

  • a length prefix announces more than 4096 bytes: it closes before reading the message (1.3);
  • a message is empty (it has no kind), or its kind is 0 or 5 to 255;
  • a message is malformed: a field past its end, bytes left after its last field, or an invalid value;
  • a message is unexpected: anything but LoginRequest first, a second LoginRequest, or anything but Logout after the login (3.3);
  • on the server, no LoginRequest arrives within 5 seconds of the connection.

A well-formed LoginRequest that the server refuses is not malformed: a version other than 1 gets LoginRejected with BadVersion, and a name that is empty, longer than 16 bytes, not valid UTF-8 or with control characters gets LoginRejected with BadName (3.2). Unknown reasons in LoginRejected and Logout are read as section 3.2 says, and the connection goes on.

6.6 Floods and broken clients

  • A server SHOULD count dropped datagrams per source address and per reason, to tell a broken client from noise and detect floods.
  • A server MAY ignore, for a while, a source address that sends more datagrams than a client ever needs (an Input per tick, plus Event and Heartbeat datagrams), before running any check but the size.
  • A client whose session keeps sending malformed datagrams (bound endpoint, right token, bad payload) is broken. The server MUST NOT end a session for a single malformed datagram, but MAY end it with Logout and the reason ProtocolError (3.2), telling the other players with PlayerLeft and the reason Kicked (4.4). Ending it after 100 malformed datagrams within 5 seconds is RECOMMENDED: more than a corrupted datagram now and then, fewer than two seconds of bad inputs.

6.7 Logging

A flood of bad datagrams MUST NOT become a flood of log lines: one line per dropped datagram would fill the disk and slow the server down. A receiver therefore:

  • MUST NOT log a dropped datagram above the debug level;
  • SHOULD log, at the info level and at most once every 10 seconds per source address, how many datagrams it dropped from that source and why, when it dropped any;
  • SHOULD log at the info level each TCP connection it closes for a bad message, with the peer's address and the reason;
  • SHOULD log at the warn level each session it ends with ProtocolError, with the player number and the address;
  • MUST NOT log a session token, which is enough to take over a session from the same address: it names the session by its player number;
  • MUST NOT log more than the first 32 bytes of a bad packet, in hexadecimal, and only at the debug level; a player name it logs MUST have its control characters escaped.
What Level Content
Datagram dropped debug Source address, reason (datagram too large, truncated header, bad magic, bad version, unknown type, foreign, malformed <type>), size
Drops of one source info, at most every 10 s Source address, count per reason
TCP connection closed for a bad message info Peer address, reason (message too large, malformed <kind>, unexpected <kind>, login timeout)
Login refused info Peer address, the LoginRejected reason
Session ended with ProtocolError warn Player number, address, count of malformed datagrams

6.8 The checks in Aegis2D

The net module implements the checks that do not depend on the game; the server and the client implement the others with net::BinaryReader.

Check Rule Implementation Tests (tests/net/)
Datagram above 1200 bytes, dropped and counted, debug log 6.2, 6.3 step 1 net::UdpSocket (receive buffer of 1201 bytes, stats().dropped) udp_socket_test.cpp
Every read within the bytes, the first failure fails all later reads 1.5 net::BinaryReader binary_test.cpp
bool other than 0 or 1 1.4 net::BinaryReader::read_bool() binary_test.cpp
string length checked before allocating 6.2 net::BinaryReader::read_string() binary_test.cpp
Bytes left after the last field 6.3 net::BinaryReader::at_end() binary_test.cpp
Header: 18 bytes, magic, version, type, in this order 2.4, 6.3 step 2 net::read_header(), net::HeaderError packet_test.cpp
Sequence: duplicates and datagrams too old not acknowledged again 5.1, 6.3 step 6 net::AckTracker::record() reliability_test.cpp
ack beyond the last sequence sent ignored 5.1 net::ReliableChannel::receive() reliability_test.cpp
Event framing: count of 0, an event past the end, bytes after the last one; nothing changes 5.2, 6.4 net::ReliableChannel::read_events() reliability_test.cpp
Event ids already received, or 256 or more behind 5.3 net::ReliableChannel::read_events() reliability_test.cpp
TCP prefix above 4096 bytes: closed before reading, debug log 1.3, 6.5 net::TcpConnection, net::TcpCloseReason::MessageTooLarge tcp_connection_test.cpp

malformed_packet_test.cpp runs these checks together, in the order of 6.3, on forged datagrams: every truncation, datagrams over 1200 bytes, every single-bit change of the magic, every other version and every reserved type, wrong event counts, every wrong event length, bytes after the payload, every single-byte corruption of a valid datagram, and random bytes. Each is dropped without changing the receiver or, for a corruption that still follows the layout, read whole; the sanitizers run them on every pull request.

tests/fuzz/packet_fuzz.cpp hands the same checks inputs generated by libFuzzer, with the same promises, plus a free sequence of BinaryReader reads: a minute on every pull request, and 10 minutes with tools/fuzz.sh for each change to the parser. Every build replays its seed corpus (tests/fuzz/corpus/packet) as the packet_fuzz_corpus test. tests/fuzz/bits_fuzz.cpp does the same for BitReader, the reader of the compact payloads: reads of every width, invalid ones included, chosen by the input, the values written back with BitWriter to give the bits they came from; its corpus (tests/fuzz/corpus/bits) is replayed as bits_fuzz_corpus.

The checks of direction and source (6.3 steps 3 and 4), of each payload and event content (6.3, 6.4), of TCP messages (6.5), and the counting and logging of drops (6.6, 6.7) belong to the R-Type server and client.