Battlefield 2 the RAM eater

Matthias HosteMatthias Hoste
6 min read

After having spent some time with the Battlefield 2 client executable for my previous post, which you can find here, I got interested in what the server executable contains.

Lucky for me the linux executable is compiled with DWARF Debug Symbols, so we have a lot of names for classes and their methods instead of a bunch of sub_’s which are the default name given by Ida to unknown functions.

Now, we already know a few classes. For example the client had a NetClient class, so no doubt the server has a NetServer class.

dice::hfe::io::NetServer::NetServer(v4);
this->netServer = v4;
version = dice::hfe::BuildNrUtil::getNetVersionNumber();
(this->netServer->pVMT->setVersion)(this->netServer, version);
if ( !this->netServer )
{
    dice::hfe::formatString(v46, "Failed to create server instance", v39);
    std::string::string(v54, &filename, v55);
    std::string::string(v56, "Game/GameServer/GameServer.cpp", v57);
    dice::hfe::Debug::Debug(v47, 6, v56, 1354, v54);
    std::operator<<<char>(v48, v46);
    dice::hfe::Debug::~Debug(v47);
    v6 = v56[0] - 12;
    if ( _InterlockedExchangeAdd((v56[0] - 4), 0xFFFFFFFF) <= 0 )
      std::string::_Rep::_M_destroy(v6, v58);
    v7 = v54[0] - 12;
    if ( _InterlockedExchangeAdd((v54[0] - 4), 0xFFFFFFFF) <= 0 )
      std::string::_Rep::_M_destroy(v7, v58);
    v8 = v46[0] - 12;
    if ( _InterlockedExchangeAdd((v46[0] - 4), 0xFFFFFFFF) <= 0 )
      std::string::_Rep::_M_destroy(v8, v58);
    return 0;
}
Password = dice::hfe::ServerSettings::getPassword(dice::hfe::serverSettings);
(this->netServer->pVMT->setPassword)(this->netServer, Password);
MaxPlayers = dice::hfe::ServerSettings::getMaxPlayers(dice::hfe::serverSettings);
(this->netServer->pVMT->setMaxNumberOfConnections)(this->netServer, MaxPlayers);

This is an exert from dice::hfe::GameServer::startServer which neatly shows the NetServer class being initialized. Now this class has a lot of functions but the one that sounds the most interesting now is dice::hfe::io::NetServer::handleDataPacket as the others remain within the class. This one actually writes to a PacketBuffer.

v18 = a2;
std::_Rb_tree<int,std::pair<int const,dice::hfe::io::SWConnection *>,std::_Select1st<std::pair<int const,dice::hfe::io::SWConnection *>>,std::less<int>,std::allocator<std::pair<int const,dice::hfe::io::SWConnection *>>>::find(
    v19,
    &this->connections,
    &v18);
if ( v19[0] == this->connections )
    connection = 0;
else
    connection = *(v19[0] + 20);
if ( connection
    && (++connection->dw12,
        dice::hfe::io::readExtendedHeader(this->bitStreamIn, &v15, &v16, &v17),
        RemotePacketCounter = v3->RemotePacketCounter,
        v15 != RemotePacketCounter)
    && ((v15 - RemotePacketCounter) & 0x3Fu) <= 0x1F )
  {
      // in case we missed some packets, uninteresting
      return 14;
    }
    else
    {
      this->dataPacketSize.size = a3;
      bitStreamIn = this->bitStreamIn;
      if ( bitStreamIn->readWriteSwitch == 1 )
        currentReadPosition = bitStreamIn->currentReadPosition;
      else
        currentReadPosition = bitStreamIn->currentWritePosition;
      this->dataPacketSize.currBytePositionBitStream = currentReadPosition >> 3;
      v10 = v15;
      this->dataPacketSize.dword8 = v15;
      v11 = (v10 - v3->RemotePacketCounter) & 0x3F;
      if ( v11 > 1 )
      {
        connection->packetsBehindCount += v11 - 1;
        connection->delayScaleFactor <<= v11 - 1;
      }
      connection->delayScaleFactor = (2 * v3->delayScaleFactor) | 1;
      v12 = v15;
      ++v3->dw22;
      connection->RemotePacketCounter = v12;
      connection->dw42 += a3;
      this->dataPacketSize.connectionEventType = 0;
      dice::hfe::io::SWConnection::addPacketToRecvQueue(connection, &this->dataPacketSize);//<-- add packet to queue for later processing
      this->dataPacketSize.size = 0;
      return 0;
    }
}
else
{
    this->dataPacketSize.size = 0;
    return 11;
}

The NetServer class handles packet asynchrously on a different thread from the main code. After being added to the queue, packets will be handled synchrously based on order of connection.

After some more hours of reverse engineering I came across the function where the data packets are read further namely dice::hfe::ClientConnection::processReceivedPacket.

v4 = 0;
v3 = 1;
if ( a2 )
{
    if ( !(this->playerActionManager->pVMT->processReceivedPacket)(
            this->playerActionManager,
            a2) )
      v3 = 0;
    if ( !(this->gameEventManager->pVMT->processReceivedPacket)(this->gameEventManager, a2) )
      v3 = 0;
}
while ( 1 )
{
    (this->gameEventManager->pVMT->getNextRcvdEvent)(&event);
    if ( !event )
      break;
    (event->pVMT->logInfo)(event, event, 0, this->ping);
    if ( this->executeServer )
      (event->pVMT->executeServer)(event, this->ping);
    else
      (event->pVMT->executeClient)(event);
    v4 = 1;
    if ( event )
      (event->pVMT->release)(event);
}
if ( a2 )
{
    if ( !(this->ghostManager->pVMT->processReceivedPacket)(this->ghostManager, a2) )
      v3 = 0;
}
else if ( !v4 )
{
    return v3;
}
++this->nbProcessedPackets;
return v3;

We have 3 main processors, PlayerActionManager, GameEventManager and GhostManager. GameEventManager sounds like the most interesting function right now. What kind of events does this game send? Looking at a wireshark log GameEventManager is also the only one in use until you fully join the server. This is the flow of events at first connect.

ServerDirectionClient
ChallengeEventGameClient::challenge
GameServer::challengeResponseChallengeResponseEvent
DataBlockEvent(ServerInfo)
DataBlockEvent(MapList)
DataBlockEvent(MapInfo)
GameServer::handleClientInfoDataBlockEvent(ClientInfo)

DataBlockEvent, now that is extensively used. What could that be.

int __cdecl dice::hfe::DataBlockEvent::DataBlockEvent(dice::hfe::DataBlockEvent *this, void *src, unsigned int n)
{
  dice::hfe::GameEvent::GameEvent(this);
  this->pVMT = dice::hfe::DataBlockEvent::VMT;
  if ( n > 63 )
  {
    std::string::string(v10, &filename, v9);
    std::string::string(v8, "Game/GameEvents/DataBlockEvent.cpp", v7);
    dice::hfe::Debug::Debug(v11, 6, v8, 26, v10);
    std::operator<<<std::char_traits<char>>(v12, " Data out of range");
    dice::hfe::Debug::~Debug(v11);
    v4 = v8[0] - 12;
    if ( _InterlockedExchangeAdd((v8[0] - 4), 0xFFFFFFFF) <= 0 )
      std::string::_Rep::_M_destroy(v4, v6);
    v5 = v10[0] - 12;
    if ( _InterlockedExchangeAdd((v10[0] - 4), 0xFFFFFFFF) <= 0 )
      std::string::_Rep::_M_destroy(v5, v6);
  }
  this->isNewBlock = 0;
  this->size = n;
  return memcpy(this->data, src, n);
}

A DataBlockEvent is used to send a large amount of data over multiple packets. It will first tell the other end that it will send a new block and what it’s full size is. Then it will send the data in chunks. This is more easily seen in the deSerialize function.

signed int __cdecl dice::hfe::DataBlockEvent::deSerialize(
        dice::hfe::DataBlockEvent *this,
        dice::hfe::io::BitStream *stream)
{
  a2[0] = 0;
  dice::hfe::io::BitStream::readBits(stream, a2, 1u);
  v2 = a2[0] != 1;
  this->isNewBlock = a2[0] == 1;
  if ( v2 )
  {
    v5 = 0;
    dice::hfe::io::BitStream::readBits(stream, &v5, 8u);
    v4 = v5;
    this->size = v5;
    dice::hfe::io::BitStream::readBits(stream, this->data, (8 * v4) & 0x7F8);
  }
  else
  {
    v7 = 0;
    dice::hfe::io::BitStream::readBits(stream, &v7, 32u);
    this->blockEventId = v7;
    v6 = 0;
    dice::hfe::io::BitStream::readBits(stream, &v6, 32u);
    this->blockLength = v6;
  }
  if ( !stream->reachedEndOfStream )
    return 1;
  stream->error = 0;
  result = 0;
  stream->reachedEndOfStream = 0;
  return result;
}

Okay so the max data within any single packet is limited to 0x7F8. How does it process these?

signed int __cdecl dice::hfe::DataBlockEvent::executeServer(dice::hfe::DataBlockEvent *this, int connId)
{
  dice::hfe::GameServer *v2; // ecx
  signed int result; // eax
  dice::hfe::ClientConnection *v4; // eax
  dice::hfe::DataBlockManager *v5; // eax
  dice::hfe::DataBlockManager *v6; // eax

  v2 = (dice::hfe::game->pVMT->queryInterface)(dice::hfe::game, dice::hfe::IID_IGameServer);
  result = 0;
  if ( v2 )
  {
    v4 = (v2->pVMT->getConnection)(v2, connId);
    if ( v4 )
    {
      if ( this->isNewBlock )
      {
        v5 = (v4->pVMT->getDataBlockManager)(v4);
        (v5->pVMT->newDataBlock)(v5, this->blockEventId, this->blockLength);
      }
      else
      {
        v6 = (v4->pVMT->getDataBlockManager)(v4);
        (v6->pVMT->addDataChunk)(v6, this->data, this->size);
      }
    }
    return 1;
  }
  return result;
}

Alright there appears to be a previously unknown class named DataBlockManager. It makes sense that this class handles these events. So let us begin with the first function, newDataBlock.

signed int __cdecl dice::hfe::DataBlockManager::newDataBlock(
        dice::hfe::DataBlockManager *this,
        unsigned int eventId,
        unsigned int totalLength)
{
  signed int result; // eax
  dice::hfe::DataBlock *v4; // eax
  dice::hfe::DataBlock *v5; // ebx

  if ( this->dataChunks )
    return 0;
  v4 = operator new(0x10u);
  v4->position = 0;
  v5 = v4;
  v4->data = 0;
  v4->size = totalLength;
  v4->blockEventId = eventId;
  v4->data = operator new[](totalLength);
  result = 1;
  v5->position = 0;
  this->dataChunks = v5;
  return result;
}

Okay so here we have.. hang on. Did you notice that? Read it again, I will provide the explanation below.

v4->data = operator new[](totalLength);

There appears to be an unchecked allocation. Now I hear you say “how is this important?” well normally it can be ignored. It’s definitely not best practice but sure what is the worst that can happen?

Well this very value totalLength comes directly from the packet where we can see that it is a 32bit integer. Okay so a malicious client can allocate some memory. How much are we talking about? Well a 32bit unsigned integer can go up to 4,294,967,295. Or in computer terms 4 GB of memory. Well good thing that PC’s have more than 4GB of RAM nowadays. Or well it would be if this application wasn’t 32bit(linux server has a 64bit version) meaning that when it attempts to allocate this amount of memory the application will crash. The 64bit server would seem immune to this unless ofcourse multiple malicious clients are used all of whom make the server allocate 4GB until it exhausts all available RAM. At this time there is no available fix for this bug, I will write a windows and linux patch via dll/so before writing a PoC.

Puzzles are fun, but sometimes they uncover scary secrets buried in software. But now that it is found it can be fixed.

0
Subscribe to my newsletter

Read articles from Matthias Hoste directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Matthias Hoste
Matthias Hoste