RC RANDOM CHAOS

A crafted sprite overflows the blitter's heap

Attack-surface analysis of OpenTTD 160beta1: integer overflow in sprite decoding, untrusted savegame and packet parsing, and why EDR stays blind.

· 7 min read
A crafted sprite overflows the blitter's heap

OpenTTD ships from a C++ codebase whose lineage traces to a 2004 open-source reimplementation of a 1990s transport sim. Decades of manual memory management. Hand-rolled parsers. Chunk-based serialization written for correctness against well-formed input, not against hostility. 160beta1 is a pre-release. No CVE is assigned to it at the time of writing, and this is not a confirmed advisory - it is attack-surface analysis. The bug classes are real. The parsers are real. The question researchers are now working is where the next out-of-bounds write lives.

Start with what the binary actually trusts. A game client is not a passive consumer. It deserializes savegames authored by other people, parses NewGRF content shipped by third parties, runs a network protocol against servers it does not control, and decodes sprites into framebuffers at runtime. Each of those is an untrusted-input boundary feeding a C++ routine. The code assumes the file is valid. The attacker supplies a file that is not. That gap is the whole problem, and it predates every line of mitigation bolted on since.

The rendering path named in the premise is the sprite loader and blitter. A sprite carries dimension fields - width and height - an encoding flag, and a payload. The loader computes an allocation size from those dimensions and the bytes-per-pixel of the active blitter, plus extra planes for 32bpp colour and the recolour mask. The computation is a multiplication. If any intermediate product is held in a type too narrow to contain what the fields permit, the multiplication wraps. CWE-190. The allocator returns a buffer sized to the wrapped value - smaller than the decode loop expects. The loop then writes the full declared sprite into the undersized allocation. CWE-787, out-of-bounds write, on the heap. CWE-122. The attacker controls the dimensions, so the attacker controls both the size of the overflow and, with care, the bytes that land past the boundary. A heap corruption primitive sourced entirely from a content file.

Compressed sprites compound it. GRF sprites can be tile- or run-length-encoded. The decoder reads length and offset fields from the stream and copies runs into the output buffer. If those fields are not validated against remaining output capacity, a crafted run length writes past the buffer regardless of the headline dimensions. The decode loop is the write primitive. The compression metadata is the control surface.

Savegame loading is the same shape in a different format. The .sav container is chunk-based - typed chunks holding objects, arrays, and lists whose element counts are read straight from the file. The loader allocates and iterates against those counts. A count that disagrees with the bytes that follow it drives the parser past its buffer or into an allocation it cannot back. Untrusted deserialization into C++ structures, CWE-502-adjacent, with the file dictating loop bounds. Savegames move through forums, content sites, and direct shares. None of that delivery is authenticated against the parser.

The network protocol is the highest-value surface, because it removes the file-delivery step. OpenTTD packets are length-prefixed, carry a type byte, then a sequence of fields read through receive routines that pull strings and fixed-width integers off the wire. A malformed length, an unterminated string, a field count that overruns the packet - each is a parser-state problem in code that runs before any game logic validates intent. A malicious server reaches every client that joins it. A malicious client reaches a public server. Default transport is TCP and UDP on 3979. Server-side, that is T1190, exploitation of a public-facing application. Client-side, T1203, exploitation for client execution.

Reach is the part that makes this matter. A player opens the in-game server list and joins a community server. That is the delivery. No foothold required, no prior access, no second stage to set up the first. The same holds for content pulled through the content service and for a savegame opened from a download. T1204 for the file path, T1203 for the server path. The game fetches and parses hostile input exactly as designed. The attacker writes the input. The client does the rest.

A heap out-of-bounds write is not code execution on its own. Modern builds carry NX/DEP, ASLR, stack canaries, and on Windows frequently Control Flow Guard. Turning a controlled heap write into hijacked control flow requires a leak to defeat ASLR and a target worth overwriting - a vtable pointer, a function pointer, an allocator metadata field. Those techniques are public and old. Whether they land depends on allocator state, the determinism of the heap groom, and which mitigations the specific build enabled. The honest statement is that this bug class yields memory corruption. Weaponization is contingent and build-dependent. Anyone selling it as guaranteed RCE is skipping the hard half.

One precision point that changes the detection story. NewGRF and GameScript are not native code. NewGRF is a pseudo-instruction format interpreted by the engine. GameScript and AI run on Squirrel, a sandboxed VM. Neither loads a DLL nor executes shellcode by design. So the path to native execution is not script abuse - it is memory corruption in the C++ that parses and renders those formats. The compromise happens inside the game’s own address space, in the game’s own code. There is no malicious-module-load to catch on the way in.

Score the class, not an assigned number. A network-reachable heap overflow in client packet handling, triggered by joining an attacker-controlled server, maps to a vector along the lines of AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H - high, roughly 8.8, with UI:R because the user chose to join. A content-file path through a savegame or NewGRF shifts the access vector and the user-interaction term and lands somewhat lower, still in high territory. These are illustrative of the surface. No score is real until a specific bug is triaged against a specific build.

Telemetry is where defenders go blind. openttd.exe is a game binary. On an unmanaged endpoint it carries no EDR at all. On a managed one, the internal state of the renderer and the parser is invisible to the sensor - there is no hook into a sprite decode or a packet read. The first observable in most cases is a crash. Windows Error Reporting, Application Error, Event ID 1000, faulting module pointing at the blitter or the heap allocator. That is a reliability signal, not a security alert, and it fires identically whether the cause is a corrupt download or a live attempt. Most SOCs never see it, because the host was never in scope.

The high-signal event is second-stage behavior, not the corruption itself. A game binary that spawns cmd.exe or powershell.exe is anomalous - Sysmon Event ID 1, process creation, parent openttd.exe, child a shell or a LOLBin. T1059. Sysmon Event ID 3 carries the outbound game connections to 3979, and any deviation after the session - a connection to an unexpected host or port - is the network IOC worth a correlation rule. Sysmon Event ID 11 records content written into the OpenTTD download directories. Event ID 7, image load, is mostly noise here, since legitimate native modules do not arrive through game content. An unexpected module inside the game process is the exception worth flagging. T1055 process injection is the next event, if the attacker migrates out of openttd.exe into something that survives the game closing.

The gap is structural, not a tuning miss. The corruption is in-process, in the game’s own code, with no module load and no script signal to trip. The only telemetry that fires reliably is the behavior that comes after execution is already hijacked. By the time a process-spawn or an injection event reaches the SIEM, the memory-safety failure that opened the door is in the past and was never logged.

A patch to 160beta1 closes whatever specific bug gets reported. It does not close the class. The architecture trusts savegames, NewGRF, the content service, and network peers, and it parses every one of them in C++ with manual bounds management. Each fix is a single bounds check or a widened integer in one function. The surface that produced the bug - untrusted input into hand-written parsers, and a sprite pipeline that multiplies attacker-controlled dimensions before it allocates - persists until those paths are systematically bounds-checked or moved to memory-safe handling. CVEs will be assigned to this codebase. They will name specific functions. The reason they keep arriving is that the trust boundary sits decades deep in code that predates the assumption that the file might be an attack. That assumption is the vulnerability. The integer overflow is only where it surfaces.

Share

Keep Reading

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.