Fuzzing a Minecraft server’s packet validation
Send a Minecraft server a player position of NaN and it kicks you on the spot. Send it +Infinity and it says nothing, quietly rounds you to the edge of the world, and lets you play on. Same packet, same field, two values that should both be impossible, two completely different answers. That gap is the whole post.
The server here is open source (Purpur, a fork of Paper), which makes it a good target for seeing how carefully a game server validates what players send it. A game server is the textbook case for not trusting the client: it runs on someone else's machine, its code is effectively public, and sooner or later someone sends bytes the real game never would.
A client that sends the unsendable
The real client is useless here, because it only ever sends well-formed packets. A small custom client that speaks the protocol at the byte level can hand-assemble any packet with any field, including values the real client would never emit. (It also does genuine Microsoft login: nothing past the handshake can be probed anonymously, so the door has to open before anything can be tested.) From there the method is one loop, and every finding came from the disagreements between its three outcomes:
- The server rejects the value and kicks you.
- It silently clamps the value into range.
- It accepts the value as-is.
NaN versus infinity
When the client reports a position, the handler guards against NaN, the floating-point "not a number" that poisons every comparison it touches. But watch how the coordinates and the rotations are checked differently:
// position (x/y/z): NaN only -> +Infinity slips through
boolean invalid =
Double.isNaN(x) || Double.isNaN(y) || Double.isNaN(z)
|| !Floats.isFinite(yRot) || !Floats.isFinite(xRot);
if (invalid) {
// kick reason: multiplayer.disconnect.invalid_player_movement
disconnect(INVALID_PLAYER_MOVEMENT);
return;
}
// otherwise the coordinate is "repaired":
x = Mth.clamp(x, -3.0E7, 3.0E7); // y: +/- 2.0E7
The rotations use Floats.isFinite, which rejects both NaN and infinity. The coordinates use only Double.isNaN. So whoever wrote this knew infinity was a hazard and guarded the angles against it, yet the same check still lets an infinite x, y or z fall through to the clamp, which rounds it to the world border.
Clamping itself is fine: rounding a wild coordinate to the border is what that code is for, and it's the reason NaN needs its own check: every comparison against NaN is false, so clamping would leave it NaN, while infinity clamps cleanly. The point is the inconsistency. An impossible rotation drops the connection; an impossible coordinate is silently normalized and waved on, so the forged packet the rotation path would have surfaced leaves no trace on the position path.
The one that actually took the server down
The gaps above are about values the server shouldn't accept. This one is about a request it trusts to be cheap, and isn't. Every container click sends a tiny packet carrying a stateId, a counter that keeps client and server agreed on the inventory's contents. If the stateId doesn't match, the server assumes they've drifted and re-sends the entire container, every slot and its contents, in one packet. A full resync.
Here's the ratio: the click is about fifteen bytes, and the resync is the whole inventory serialized in one shot. Send a click with a stale stateId and a slot that points nowhere, so the click itself does nothing, and those fifteen bytes buy a full re-serialization in return. Then make the inventory costly to serialize: fill it with written books, a hundred pages each. The one catch is that the connection compresses, so repeated text shrinks to nothing on the wire; use random characters on every page instead and each resync comes back on the order of a megabyte.
Throughput measured against a local instance of the same server build; on a public server the ceiling is the uplink itself, and everything past it queues.
That saturates a gigabit uplink several times over; everyone else's connection stalls because the pipe is full. That's the lag, and shortly after, the server is effectively offline.
The tick rate never dropped. Every normal health check said the server was fine while it was drowning.
All that serialization lands on the networking threads, not the main game loop, so the tick rate, a /tps reading, a profiler sampling the main thread, all read normal. A denial-of-service invisible to the monitoring everyone uses is worse than a loud one. The built-in flood protection doesn't help either: its ~500-per-second cap counts every packet, and a few hundred clicks a second stays under it, though only just. The attacker's own movement packets count against the same budget, so the test works best standing perfectly still. The rate was never the problem; the response size was. The fix isn't a bigger rate limit, it's to stop letting a cheap request imply a cheap response, throttle container clicks on their own, cap resync per player per tick, and alert on outbound bytes per player.
What the server trusted
Both findings are the same mistake: the server trusted the client to behave. Once that meant sane values, once a cheap request. The wire guarantees neither, and the bugs live in the gap between what a client would send and what the protocol allows.
A normal client only ever produces values its own code already thinks are reasonable, so it can never reach that gap. A byte-level harness can, by sending exactly what no real client would.