https://www.atredis.com/blog/2026/1/26/generals 0 Skip to Content Atredis Partners About Ownership Team Pentesting Embedded OCP SAFE Risk Contact Blog Open Menu Close Menu Atredis Partners About Ownership Team Pentesting Embedded OCP SAFE Risk Contact Blog Open Menu Close Menu About Ownership Team Pentesting Embedded OCP SAFE Risk Contact Blog General Graboids: Worms and Remote Code Execution in Command & Conquer Jan 27 Written By Bryan Alexander [this work was conducted collaboratively by Bryan Alexander and Jordan Whitehead] This post details several vulnerabilities discovered in the 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 8088. Network Architecture C&C:G Network Architecture 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'T\x11' frag += b"C" + struct.pack("