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, noLogout. 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.
- Size: at most 1200 bytes. A datagram that the system truncated to fit the receive buffer counts as too large.
- Header (section 2.4): at least 18 bytes, then the
magic, the version and the type. A type of
0or8to255is dropped like any other bad header: unlike the values inside a payload, a packet type cannot be skipped, since its payload cannot be read. - Direction: the type MUST travel towards this receiver (2.3). A server drops
AcceptandSnapshot; a client dropsConnectandInput. - Source (section 3.4): on the server, the
source endpoint MUST be bound to the session named by
session, except for aConnect; on the client, the datagram MUST come from the server'sudp_portwith the client's token, and nothing butAcceptis accepted before the firstAccept. - 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.
- 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
Eventdatagram, 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
0or7to255MUST 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 is0or5to255; - 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
LoginRequestfirst, a secondLoginRequest, or anything butLogoutafter the login (3.3); - on the server, no
LoginRequestarrives 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
Inputper tick, plusEventandHeartbeatdatagrams), 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
Logoutand the reasonProtocolError(3.2), telling the other players withPlayerLeftand the reasonKicked(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.