On the clock: Escaping VMware Workstation at Pwn2Own Berlin 2025

Rédigé par Thomas Bouzerar, Etienne Helluy-Lafont - 23/01/2026 - dans Exploit, Reverse-engineering - Téléchargement

Lors du Pwn2Own Berlin 2025, nous avons exploité VMware Workstation en abusant d'un Heap-Overflow dans l'implémentation de son contrôleur PVSCSI. L'allocation vulnérable atterrissait dans l'allocateur LFH de Windows 11, dont les mitigations représentaient un défi majeur. Nous les avons surmontées grâce à une combinaison complexe de techniques : déjouer la randomisation du LFH via un side-channel ; façonner et préserver soigneusement un agencement de tas exploitable ; et abuser de comportements subtils de la fonction vulnérable pour créer des primitives puissantes. Au final, l'exploit a fonctionné du premier coup, bien que le chemin pour y parvenir fût tout sauf simple. 

Cet article est une traduction de l'article original en anglais.

Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus

Introduction

Lors du Pwn2Own Berlin 2025, nous avons exploité avec succès VMware Workstation en utilisant une unique vulnérabilité de Heap-Overflow dans l'implémentation du contrôleur PVSCSI. Bien que nous ayons été initialement confiants dans le potentiel du bug, la réalité nous a rapidement rattrapés lorsque nous nous sommes confrontés aux dernières mitigations du Low Fragmentation Heap (LFH) de Windows 11.

Ce post détaille notre parcours, de la découverte à l'exploitation. Nous commençons par analyser la vulnérabilité PVSCSI et les défis spécifiques posés par l'environnement LFH. Nous décrivons ensuite un état particulier du LFH qui permet un comportement déterministe, ainsi que les deux objets clés utilisés pour le spray et la corruption. En nous appuyant sur cette configuration, nous démontrons comment exploiter la vulnérabilité pour fabriquer de puissantes primitives de manipulation de mémoire, pour finalement obtenir une lecture, une écriture et une exécution de code arbitraires (Read/Write/Execute).

Enfin, nous révélons comment — à seulement deux jours de l'événement — nous avons exploité un timing-channel au sein du LFH pour déjouer totalement sa randomisation, assurant ainsi un succès du premier coup lors de la démonstration en direct.

Le Bug PVSCSI

Dans VMware Workstation, le processus vmware-vmx implémente la majeure partie de l'émulation de la machine invitée : CPU, mémoire, périphériques, etc. Les périphériques constituent une surface d'attaque bien connue pour les exploits d'évasion de VM, car le code de l'hyperviseur doit émuler une grande variété de composants complexes : accélérateur graphique, cartes réseau, lecteurs, et plus encore. Nous avons commencé notre audit en nous concentrant sur cette zone et avons assez rapidement trouvé une vulnérabilité intéressante.
 
La vulnérabilité principale (CVE-2025-41238) réside dans le code d'émulation du contrôleur PVSCSI (Paravirtualized SCSI). Ce contrôleur est responsable de la gestion des commandes SCSI et de leur transmission aux périphériques appropriés (disques, CD-ROM, etc.). Puisqu'il est utilisé pour transmettre de grandes quantités de données (comme la lecture ou l'écriture sur le disque), l'OS invité peut diviser les données en morceaux de tailles variables. Dans les commandes SCSI, ils sont représentés sous forme de segments Scatter-Gather (S/G), chacun spécifiant une adresse physique invitée ainsi que la taille du bloc de données.
 
Dans le pilote Linux PVSCSI (linux/drivers/scsi/vmw_pvscsi.c), les entrées de segments S/G sont représentées par la structure PVSCSISGElement :
struct PVSCSISGElement {
    u64 addr;
    u32 length;
    u32 flags;
} __packed;
La fonction vulnérable est celle qui récupère les entrées de segments S/G fournies par le pilote de l'invité. Elle copie les entrées dans un tableau interne, puis compacte le tableau en fusionnant (coalescing) les entrées contiguës.
 
Initialement, la fonction utilise un buffer alloué statiquement pour stocker les entrées. Il a de la place pour 512 segments (0x2000 octets au total). Si l'invité fournit plus de 512 entrées, la fonction alloue dynamiquement un buffer de 0x4000 octets pour stocker toutes les entrées, puis réalloue le buffer pour chaque nouvelle entrée ajoutée. Le design prévu est de doubler la taille du buffer interne chaque fois qu'il doit grandir, mais il y a des problèmes dans l'implémentation :
bool __fastcall PVSCSI_FillSGI(pvscsi_vmx *pvscsi_vmx, ScsiCmd *scsi_cmd, sgi *sgi)
{
  // [...]
  while ( 1 )
  {
    next_i = i + 1;
    if ( 0x10 * (unsigned __int64)(i + 1) > leftInPage )
    {
      Log("PVSCSI: Invalid s/g segment. Segment crosses a page boundary.\n");
      goto return_invalid;
    }
    idx = i;
    pInPage = &page[idx];
    if ( (page[idx].flags & 1) == 0 )
    {
      seg_len = page[idx].length;
      if ( sg_table_len_1 < seg_len )
        seg_len = sg_table_len_1;
      seg_count = sgi_1->seg_count;
      if ( seg_count > 0x1FF )
      {
        v13 = page;
        pEntries = (SGI_Entry *)UtilSafeRealloc1(
                                  sgi_2->entries_buffer,
                                  0x4000uLL,
                                  0xFFFFFFFF,
                                  "bora/devices/pvscsi/pvscsi.c",
                                  0xC5A);
        page = v13;
        sgi_1 = sgi_2;
        sgi_2->entries_buffer = pEntries;
        sgi_2->pEntries = pEntries;
        seg_count = sgi_2->seg_count;
      }
      else
      {
        pEntries = sgi_1->pEntries;
      }
      seg_idx = (int)seg_count;
      pEntries[seg_idx].addr = pInPage->addr;
      pEntries[seg_idx].entry_size = seg_len;
      sg_table_len_1 -= seg_len;
      sgi_1->seg_count = seg_count + 1;
      goto loop_over;
    }
  // [...]
}

Le problème principal est que la taille passée à UtilsSafeRealloc1() est fixée à 0x4000, au lieu d'être doublée lorsque nécessaire. Par conséquent, si l'invité envoie plus de 1024 entrées, les suivantes seront écrites en dehors des limites du buffer du tas.

Un problème secondaire (fonctionnel) est qu'une fois que 512 entrées ont été collectées, la fonction réalloue le tableau à chaque itération de la boucle au lieu de le faire uniquement lorsque sa capacité est dépassée.
 
En résumé, si l'invité envoie plus de 512 entrées, la fonction alloue un nouveau buffer de 0x4000 octets à chaque itération. Une fois que le nombre d'entrées dépasse 1024, chaque entrée supplémentaire est écrite au-delà de la fin du buffer nouvellement alloué. Cela résulte en des écritures hors-limites éparses, chacune ciblant un buffer de tas différent et se produisant à des intervalles de 16 octets.
 
Les données débordantes sont constituées d'éléments de 16 octets. L'invité définit chaque entrée comme {u64 addr; u32 length; u32 flags}, tandis que le contrôleur ne stocke que {u64 addr; u64 length} dans le buffer. Aucune validation n'est effectuée sur addr et length, de sorte que les deux champs sont entièrement contrôlés par l'invité. Comme le contrôleur représente length sous forme d'un champ 64 bits, la length 32 bits de l'invité est étendue à zéro sur 64 bits, laissant les 4 derniers octets de chaque entrée hors-limites toujours à zéro et non contrôlables.

Un Tas de problèmes

En raison des contraintes de la vulnérabilité, les buffers de tas ciblés étaient fixés à 0x4000 octets, les plaçant inévitablement dans le Low Fragmentation Heap (LFH) de Windows 11. Cet allocateur implémente des mitigations de sécurité réputées difficiles à contourner. Typiquement, les attaquants ciblent une classe de taille différente pour déplacer l'allocation vers un allocateur moins durci, évitant entièrement le LFH. Cependant, la nature de la vulnérabilité PVSCSI n'offrait aucune flexibilité de ce type. Cette section détaille les principaux défis auxquels nous avons été confrontés.
Notez que nous n'avons pas entièrement rétro-ingénieré (ni compris) les mécanismes internes du LFH. Pour garder cet article concis, nous omettons intentionnellement les détails d'implémentation et ne fournissons qu'un modèle simplifié suffisant pour comprendre notre méthode d'exploitation. Les lecteurs intéressés par une analyse complète devraient se référer à la littérature existante [1]. Enfin, par souci de simplicité, nous supposons tout au long de cette section que toutes les allocations sont de 0x4000 octets.
 
Le LFH regroupe les allocations en "buckets" (seaux). Chaque bucket peut contenir 16 éléments de 0x4000 octets. Un chunk est précédé de 16 octets de métadonnées, donc la taille d'un bucket est de 0x4010*16 octets au total.
16-elements bucket
Exploiter le LFH avec le bug PVSCSI fut extrêmement difficile en raison de la combinaison de deux mitigations : la vérification stricte des métadonnées des chunks et le mélange (shuffling) des allocations.
 
Les métadonnées contiennent une somme de contrôle (checksum) calculée à l'aide d'une clé secrète. Lorsqu'un chunk est alloué ou libéré d'un bucket, ce checksum est d'abord vérifié, et s'il a été corrompu, le programme s'arrête (abort). Donc, chaque fois que nous corrompons les métadonnées d'un chunk, nous devons nous assurer que ce chunk ne sera plus jamais alloué ou libéré. Pour y parvenir dans notre exploit, nous rêvions de stratégies simples et déterministes de façonnage du tas (heap shaping). Mais l'ordre aléatoire des allocations a transformé tout le processus en un véritable cauchemar.
 
Lorsque nous allouons un chunk, le LFH renvoie un chunk aléatoire parmi ceux qui ne sont pas alloués. Notez qu'il regarde d'abord dans le bucket contenant le chunk le plus récemment libéré. Si aucun chunk n'est disponible, le LFH crée un nouveau bucket et renvoie un chunk aléatoire à partir de celui-ci. Si plusieurs chunks sont disponibles, le LFH renvoie un chunk libre aléatoire.
 
Déclencher le bug de realloc() une fois se comporte grosso modo comme suit lors de la 1025ème itération :
p2 = malloc(0x4000);         // Allocate the new chunk
memcpy(p2, p1, 0x4000);
free(p1);                    // Free the current chunk

memcpy(p2+0x4000, elem, 16); // Write 16 bytes past the end, corrupting the new chunk's metadata 
Si nous envoyons plus de 1024 entrées S/G à la fonction vulnérable, la 1025ème entrée corrompt un header de chunk. La fonction continue alors à libérer et allouer des buffers de 0x4000 octets à chaque itération de la boucle. En raison de l'ordre d'allocation aléatoire du LFH, l'allocateur finira inévitablement par réutiliser le chunk corrompu. Dès que cela se produit, le processus détecte la corruption et s'arrête. Nous avons essayé diverses techniques pour contourner cela à l'aveugle, mais sans connaissance préalable de l'état du tas, aucune n'a réussi.

A Tale of Two Objects

Afin d'exploiter la vulnérabilité depuis l'intérieur de la VM, nous devons trouver des objets de tas intéressants de 0x4000 octets que nous pouvons allouer directement depuis l'invité. En pratique, nous devons combiner deux objets : un pour façonner l'agencement du tas, et un autre pour agir comme une cible de corruption durable.

Shaders

Sprayer des chunks de taille et de données contrôlées est assez facile à faire depuis l'intérieur de l'invité en utilisant des shaders. Ils contiennent des données contrôlées par l'utilisateur qui sont compilées par le composant d'accélération graphique de VMware. Malgré la compilation, de grandes parties de l'objet shader résultant restent contrôlées par l'attaquant [2, 3].
 
Des centaines d'objets shaders compilés peuvent être sprayés ; ils sont identifiés par des handles, ce qui nous permet de les libérer à volonté, ou de les garder en vie éternellement. Cet objet est parfait pour remplir quelques buckets LFH avec des données contrôlées non critiques. Notez que les objets shaders compilés sont parfaits pour sprayer des données contrôlées, mais ils ne peuvent pas être relus depuis l'invité.

URBs

Les objets URB sont une primitive de transport centrale dans la couche d'émulation USB de VMware. Ils gèrent les transferts de données entre la machine invitée et les périphériques USB (virtuels). Ils ont une structure de taille variable qui peut contenir des données ainsi que des pointeurs vers d'autres structures liées à l'émulation USB.
 
Dans notre cas, nous avons supposé que l'OS invité utilisait l'émulateur UHCI (Universal Host Controller Interface). C'est l'interface par défaut lors de la création d'une nouvelle VM Linux. Par défaut dans cette configuration, deux périphériques virtuels sont attachés au contrôleur : le hub racine (root hub) et une souris virtuelle.
 
Lors de l'initiation d'un nouveau transfert de données USB, l'invité fera allouer par VMware un URB de taille contrôlée. L'URB reste en vie pendant la durée du transfert et jusqu'à ce que toutes les données associées aient été récupérées par l'invité (statut du transfert et données optionnelles du périphérique). Lorsque les données sont reçues par l'invité, l'URB peut être reaped, ce qui amène VMware à copier les données de transfert disponibles vers l'invité. Le reap peut être effectué jusqu'à ce qu'il ne reste plus de données à recevoir, moment auquel l'objet URB est libéré par VMware.
 
La structure de l'en-tête URB est la suivante :
Offset   +0x00           +0x04           +0x08           +0x0C
       +---------------------------------------------------------------+
0x00   |    refcount   |  urb_datalen  |      size     |  actual_len   |
       +---------------------------------------------------------------+
0x10   |     stream    |     endpt     |             pipe              |
       +---------------------------------------------------------------+
0x20   |      pipe_urb_queue_next      |      pipe_urb_queue_prev      |
       +----------------------------///////----------------------------+
0x70   |           data_ptr            |              unk              |
       +---------------------------------------------------------------+
0x80   |           pDataCur            |              pad              |
       +---------------------------------------------------------------+
       |                         Variable-size                         |
0x90   |                                                               |
       |                           User Data                           |
       +---------------------------------------------------------------+
La capacité de maintenir les URB en vie jusqu'à ce que toutes les données aient été récupérées, combinée à une structure d'en-tête contenant des champs intéressants pour la corruption (tels que la longueur des données restantes et des listes chaînées vers d'autres objets internes), rend les URB particulièrement attractifs comme cibles de corruption.
 
Il n'est pas possible de sprayer beaucoup d'objets URB (quelques dizaines) et ils ne peuvent être libérés que dans leur ordre d'instanciation car ils sont stockés dans une file FIFO, ce qui les rend bien adaptés comme cibles de corruption, mais inadaptés au heap spray ou au shaping du tas.
 
Les objets shader et URB ont tous deux été utilisés dans de précédents exploits d'évasion VMware [4] et sont assez faciles à manipuler depuis l'invité. 

Une partie de Ping-Pong

En expérimentant avec la vulnérabilité, nous avons observé un comportement intéressant du LFH qui pourrait fournir un certain degré de déterminisme, nous permettant de construire des primitives plus puissantes. Cependant, il y avait un prérequis : connaître l'état initial du LFH, plus précisément le nombre de chunks disponibles dans les buckets de 0x4000. À ce moment-là, nous n'avions aucun moyen de récupérer cette information. Faute de meilleure alternative, nous avons décidé d'investiguer plus avant, en agissant comme si l'état initial était déjà connu.
 
L'idée centrale est la suivante : d'abord, nous allouons chaque chunk libre pour "aligner" le LFH et nous assurer que tous les buckets existants sont pleins. Ensuite, nous allouons 32 shaders pour créer deux buckets entièrement occupés, B1 et B2. Nous savons que les 16 premiers shaders atterriront dans B1, et les 16 suivants dans B2, mais nous ne savons pas dans quel ordre ils seront alloués à l'intérieur de leur bucket. L'agencement résultant est montré ci-dessous. Par souci de clarté, les figures affichent des buckets de 4 chunks :
B1 et B2 fully occupied by shaders
Ensuite, nous libérons tous les chunks de B1 sauf un, que nous appelons Hole_0. Cela empêche le bucket B1 d'être libéré.
B1 is almost entirely free, except for Hole_0 at a random position
Puis, nous allouons quinze URB. Ils utiliseront tous les chunks disponibles de B1
 
B1 is now fully occupied by URBs and Hole_0
Pour préparer l'exploitation, nous libérons un chunk de B2 (le buffer "PONG"), suivi immédiatement par Hole_0 de B1. Ce faisant, nous nous assurons que le pointeur "Last Freed" (Dernier Libéré) de l'allocateur LFH cible B1.
PONG (B2) and Hole_0 (B1) are freed
Comme mentionné précédemment, une fois que l'invité fournit plus de 512 entrées, la fonction commence sa boucle de réallocation, allouant et libérant un nouveau buffer de 0x4000 octets à chaque itération. C'est là que l'effet "Ping-Pong" se manifeste : pour toutes les itérations, l'allocateur rebondira incessamment entre nos deux emplacements disponibles, que nous appelons désormais PING (dans B1) et PONG (dans B2).
 
L'animation suivante illustre comment, à partir de l'index 512, les entrées sont écrites alternativement dans les buffers PING et PONG :
The Ping-Pong cycle
Parce que le LFH regarde toujours dans le bucket contenant le chunk le plus récemment libéré, la fonction choisira B1 (PING), puis B2 (PONG), puis B1 à nouveau, et ainsi de suite. Ce "Ping-Pong" continue tandis que l'offset d'écriture hors-limites augmente de 16 octets à chaque étape. La 1025ème allocation réclame l'emplacement du buffer PONG dans B2 et écrase l'en-tête de métadonnées du chunk adjacent dans B2. La 1026ème allocation utilise le buffer PING dans B1. Son écriture hors-limites cible maintenant les 16 premiers octets des données de l'URB adjacent à PING, corrompant effectivement les 16 premiers octets de l'URB sans affecter son en-tête de chunk. Immédiatement après avoir déclenché la corruption, nous allouons deux shaders de substitution (placeholders) pour réclamer les chunks PING et PONG, afin de maintenir les buckets dans un état connu.
 
Cette stratégie contourne les mitigations de sécurité du LFH. Nous avons seulement corrompu l'en-tête du chunk suivant PONG dans B2, mais puisque ce chunk n'est jamais libéré ou réalloué, il n'est jamais validé par l'allocateur. De plus, nous pouvons toujours libérer les URB et les buffers PING/PONG à volonté. En tenant une comptabilité précise de toutes les allocations et libérations, nous pouvons maintenir cet état et répéter la méthode Ping-Pong plusieurs fois.

Le Reap Oracle

Pour implémenter le reste de nos primitives, nous avions besoin de quatre chunks contigus dans un ordre connu dans B1. C'est là que le Reap Oracle entre en jeu. Comme mentionné précédemment, les URB alloués sont stockés dans une file FIFO. En appelant de manière répétée la méthode reap du contrôleur UHCI, nous pouvons récupérer le contenu du prochain URB dans la file et le libérer. Cela nous permet de détecter quel URB a été corrompu.

L'écrasement de 16 octets affecte les quatre champs suivants de la structure URB :

struct Urb {
  int refcount;
  uint32_t urb_datalen;
  uint32_t size;
  uint32_t actual_len;
  [...]
}

Le champ critique ici est actual_len. Rappelez-vous que pour l'overflow de 16 octets, nous contrôlons les 12 premiers octets, mais les 4 derniers octets sont toujours forcés à zéro. Par conséquent, l'overflow met invariablement actual_len à zéro. Cette corruption agit comme un marqueur, nous permettant d'identifier l'URB affecté.

La Stratégie du Reap Oracle :

  1. Allocation : Nous allouons 15 URB pour remplir le bucket B1.
  2. Corruption : Nous déclenchons la vulnérabilité (Ping-Pong) pour mettre à zéro le champ actual_len de l'URB situé immédiatement après Hole0. Ensuite, nous allouons deux shaders placeholders pour réutiliser Hole0 et PONG.
  3. Inspection & Remplacement : Nous itérons à travers la file d'URB. Pour chaque URB, nous le reapons et vérifions sa longueur. Nous allouons immédiatement un shader placeholder à sa place.
  4. Identification : Lorsque nous récupérons un URB avec une actual_len modifiée, nous savons que le shader que nous venons d'allouer pour le remplacer réside dans l'emplacement suivant Hole0. Nous étiquetons ce nouvel emplacement Hole1.

Nous répétons le processus pour localiser Hole2 et Hole3. Pour chaque itération, nous libérons les placeholders non essentiels (en gardant Hole0, Hole1, etc.), remplissons le bucket avec des URB, et utilisons le Hole précédent comme buffer PING. Une fois le tas préparé, nous réexécutons les étapes de corruption et d'identification pour localiser le prochain emplacement contigu. Finalement, nous obtenons une séquence de quatre chunks contigus (Hole0Hole3), actuellement occupés par des shaders, qui peuvent ensuite être libérés pour forcer l'adjacence pour les allocations ultérieures.

Coalescing Is All You Need

Comme indiqué plus tôt, après avoir copié tous les segments S/G dans son tableau interne, le contrôleur SCSI de VMware effectue une passe de coalescing destinée à fusionner les entrées adjacentes et réduire la fragmentation. À première vue, l'algorithme est simple et semble être purement une étape d'optimisation. Cependant, plusieurs comportements subtils fournissent des primitives intéressantes lorsqu'ils sont combinés avec la vulnérabilité.

Cette passe de coalescing s'exécute immédiatement après que tous les segments S/G ont été lus depuis l'invité, ce qui signifie que le bug a déjà été déclenché. Par conséquent, cette boucle est appliquée à toutes les entrées copiées, y compris celles qui sont hors-limites.

Pour illustrer le comportement de base de la passe de coalescing, considérons les trois entrées suivantes :
Entry 1: {.addr = 0x11000, .size = 0x4000}
Entry 2: {.addr = 0x15000, .size = 0x2000}
Entry 3: {.addr = 0x30000, .size = 0x1000}
Pendant la passe de coalescing, le contrôleur scanne le tableau séquentiellement et fusionne les entrées qui décrivent des régions mémoire contiguës. Dans ce cas, les deux premières entrées sont adjacentes en mémoire et sont donc repliées en une seule entrée.
Les entrées suivantes sont ensuite "déplacées vers le haut" (compactées) pour combler le vide créé par la fusion :
Entry 1′: {.addr = 0x11000, .size = 0x6000}
Entry 2′: {.addr = 0x30000, .size = 0x1000}
Ce comportement est particulièrement intéressant dans le contexte de notre vulnérabilité, car il permet à la mémoire hors-limites d'être "déplacée vers le haut" (Move Up) pendant le compactage.
 
De plus, bien que les entrées S/G copiées depuis l'invité aient un champ de taille limité à 32 bits, la passe de coalescing effectue les mises à jour de taille en utilisant un champ étendu sur 64 bits. En conséquence, l'addition de deux entrées de taille maximale (0xFFFFFFFF) provoque une retenue dans l'octet suivant, permettant le contrôle d'un bit supplémentaire dans l'overflow (le LSB du 13ème octet d'une entrée). En chaînant des entrées supplémentaires, il est théoriquement possible d'étendre ce contrôle plus loin, bien que cela nécessiterait un nombre impraticable d'entrées.
 
Enfin, si la liste S/G copiée depuis l'invité se termine par une entrée invalide, la fonction retourne prématurément, permettant de sauter l'étape de coalescing après que les entrées S/G hors-limites aient été copiées, ce qui peut être utile dans certains cas.
 
Toutes ces propriétés nous donnent plus de contrôle sur ce qui arrive aux données de l'overflow, rendant le bug assez puissant, même si la corruption initiale est très contrainte.

Construire un overflow contrôlé

Pour illustrer comment le mécanisme de coalescing peut être exploité, considérons l'agencement de tas (simplifié) suivant :
Coalescing step 1

Notre but ultime est d'écraser la structure URB1 entière avec des données entièrement contrôlées, y compris les octets récalcitrants qui sont habituellement mis à zéro par le bug. Pour y parvenir, nous utilisons l'algorithme de coalescing pour "déplacer vers le haut" un payload de données contrôlées préparé dans Shader2 sous forme d'une liste d'entrées S/G factices spécialement conçues.

Après cette configuration initiale, nous pouvons déclencher la vulnérabilité. Cependant, en raison de "l'effet Ping-Pong" du bug qui alterne les écritures OOB entre deux buffers, une seule passe laisse des trous de 16 octets. Nous devons la déclencher deux fois pour atteindre notre objectif.
 
D'abord, nous déclenchons la vulnérabilité en utilisant PING comme buffer de départ, écrivant toutes les entrées OOB à index impair jusqu'à la fin de URB1. À ce stade, nous forçons la fonction à sauter la phase de coalescing via une sortie prématurée. Nous nous retrouvons avec l'état suivant :
URB overflow (first pass)

Ensuite, nous déclenchons la vulnérabilité une seconde fois avec un plus grand nombre d'éléments et nous utilisons PONG comme buffer de départ. Cela écrase les entrées OOB restantes à index pair dans URB1 et continue plus loin dans Shader2.

Coalescing step 2

La mémoire contient maintenant une séquence d'entrées S/G factices prêtes pour l'algorithme de coalescing :

  1. Le Vide (entry[1023]...entry[2047]) : Ces entrées sont toutes définies à {.addr=0, .len=0}. L'algorithme les perçoit comme une longue séquence de blocs vides et contigus et les fusionne toutes en une seule entrée à entry[1023]. Cette fusion massive crée un "trou" logique dans le tableau.
  1. Le Payload (entry[2048]...) : Pour combler le vide créé par la fusion précédente, l'algorithme décale les entrées suivantes vers le "haut" de la mémoire. Par conséquent, le contenu de entry[2048] et au-delà (notre payload provenant de Shader2) est copié directement dans l'emplacement mémoire de entry[1024] (à l'intérieur de URB1).
Cependant, simplement déplacer des entrées S/G existantes n'est pas suffisant, car nous avons écrasé la moitié du payload du shader avec l'overflow contraint (laissant ainsi quatre octets nuls à la fin de toutes les entrées à index pair). Nous voulons écrire des valeurs complètement arbitraires dans URB1 (par exemple, pour forger des pointeurs) : nous y parvenons en abusant de la vérification d'adjacence de la logique de coalescing AddrA+LenA==AddrB.​
Si nous définissons LenA​=0, la vérification se simplifie en AddrA​==AddrB​. Cela nous permet de fabriquer des paires d'entrées qui semblent contiguës à l'algorithme mais qui portent en réalité des valeurs arbitraires à la fois pour les champs adresse et longueur.
 
Par exemple, pour écrire le motif 0x4141414141414141 0x4242424242424242 suivi de 0x4343434343434343 0x4444444444444444 dans URB1, nous arrangeons le payload dans Shader2 en paires d'entrées comme suit :

Paire 1 :

entry[i]   = { .addr = 0x4141414141414141, .size = 0 }
entry[i+1] = { .addr = 0x4141414141414141, .size = 0x4242424242424242 }

Paire 2 :

entry[i+2] = { .addr = 0x4343434343434343, .size = 0 }
entry[i+3] = { .addr = 0x4343434343434343, .size = 0x4444444444444444 }

Notez que les entrées d'index pair (avec la taille nulle) sont écrites par le heap-overflow, tandis que les entrées d'index impair sont celles qui étaient déjà présentes dans Shader2.

Chaque paire d'entrées est fusionnée en une seule en raison des adresses correspondantes et de la taille nulle :

entry[i]   = { .addr = 0x4141414141414141, .size = 0x4242424242424242 }
entry[i+1] = { .addr = 0x4343434343434343, .size = 0x4444444444444444 }
Par conséquent, alors que ces entrées OOB sont tirées vers le haut dans URB1, elles sont simultanément fusionnées avec les données déjà préparées dans le shader. Le contenu mémoire final de URB1 devient exactement ce que nous désirions :
Coalescing step 3

C'est une primitive très intéressante qui nous permet d'écraser complètement une structure URB avec des données arbitraires. Néanmoins, nous avons encore besoin d'une fuite d'information (infoleak) afin de forger des pointeurs dans notre structure factice, sinon toute utilisation de l'URB provoquera un crash de l'hyperviseur.

Leak d'un URB

Le chemin le plus direct vers une fuite d'information est de corrompre le champ actual_len d'un objet URB. Ce champ dicte combien d'octets VMware copie vers l'invité lorsque l'URB est reaped. Si nous pouvions augmenter cette valeur au-delà de la taille du buffer alloué, nous obtiendrions un classique Out-Of-Bounds Read, comme décrit précédemment par Jiang et Ying [4]. Mais encore une fois, en raison des contraintes de l'overflow, nous ne pouvons que mettre ce champ à zéro.
 
Pour contourner cela, nous avons conçu une stratégie qui exploite la logique de coalescing pour effectuer une série d'opérations sur le tas, plutôt qu'une écriture directe.

Étape 1 : La mise en place

Grâce au Reap Oracle, nous pouvons maintenant forcer quatre objets de tas de 0x4000 octets à être alloués de manière contiguë en libérant le shader Hole correspondant juste avant d'effectuer les allocations. L'agencement est le suivant :
 
URB leak initial step
  • Hole0 : Le buffer PING à partir duquel nous déclenchons notre overflow (initialement libre).
  • URB1 : L'objet que nous allons corrompre afin d'obtenir le leak.
  • URB2 : Un objet valide dont nous voulons "déplacer l'en-tête vers le haut" dans URB1. Notez que son actual_len est 0x0, car toutes ses données ont déjà été lues par l'invité, ce qui signifie que le pointeur du buffer de données a été avancé juste avant URB3.
  • URB3 : L'objet que nous avons l'intention de leaker.

Étape 2 : L'overflow

Nous déclenchons la vulnérabilité. Alors que Hole0 s'étend, il déborde dans les chunks adjacents. Nous forgeons soigneusement le payload pour écrire des entrées S/G factices dans l'espace mémoire de URB1. Ces entrées factices sont conçues avec des propriétés spécifiques :
  • Adresses : Elles sont contiguës (virtuellement), imitant un buffer segmenté.
  • Tailles : Nous les définissons toujours à 0xFFFFFFFF.

Tout comme dans la section précédente, nous déclenchons la vulnérabilité deux fois afin d'écraser à la fois les entrées à index impair et pair de URB1, et de corrompre seulement la moitié de URB2 tout en gardant ses champs critiques intacts. Nous nous retrouvons avec l'état suivant :

URB leak overflow step

Étape 3 : Le coalescing

C'est là que la magie opère. Après l'overflow, le contrôleur PVSCSI exécute sa passe de coalescing. Il scanne le tableau (qui s'étend maintenant dans URB1 et URB2) et trouve notre séquence d'entrées factices. Il procède comme suit :
  1. Fusion & Somme : L'algorithme détecte que toutes les entrées dans URB1 sont contiguës. Il les fusionne en une seule entrée située à l'offset 0 dans URB1.
  1. Sommation des tailles : Il calcule la taille de la nouvelle entrée : 0xFFFFFFFF*0x401. Le dword supérieur du résultat de l'addition est stocké dans les 32 bits supérieurs du champ, correspondant à l'offset de notre champ cible actual_len, le définissant effectivement à 0x400.
  1. Compactage (Move Up) : Pour finaliser la fusion, l'algorithme copie les données qui suivent toutes les entrées contiguës dans les emplacements suivants (à l'intérieur du buffer de URB1). En pratique, il copie l'en-tête de URB2 dans URB1.
URB leak coalescing step

Le résultat est un URB Hybride résidant à l'adresse de URB1 :

  • Header : Il contient une copie des pointeurs critiques de URB2 (spécifiquement le pointeur de l'objet pipe USB et les pointeurs de liste chaînée), ce qui en fait un objet valide qui ne fera pas crasher la VM.
  • Data Length : Le actual_len est maintenant le résultat de notre somme (0x400).
  • Data Pointer : Il pointe vers le buffer de données original de URB2, qui est déjà à la limite de son buffer original (pointant juste avant URB3).
Lorsque nous demandons à l'invité de reaper URB1, VMware croit devoir renvoyer 0x400 octets depuis son buffer de données. Puisque le buffer pointe maintenant à la fin du vrai buffer de données de URB2, la lecture continue dans le chunk suivant : URB3, créant effectivement une capacité OOB-Read.
Cela nous permet de dumper le contenu entier de URB3, y compris son header. Le header URB contient un pointeur vers l'objet pipe USB et des pointeurs s'auto-référençant. En les leakant, nous pouvons vaincre l'ASLR du tas en calculant les adresses précises de Hole0, 1, 2, et 3.

Pour conclure cette section : parce que nous ne pouvions pas contrôler directement actual_len pour obtenir un leak, nous avons à la place tiré parti de l'algorithme de coalescing pour fabriquer un URB Frankenstein en mémoire, composé de parties d'un autre URB et d'entrées S/G OOB

Note au lecteur : Un œil averti pourrait remarquer que nos figures et explications ignorent les headers de chunks LFH. Nous les avons délibérément omis par souci de clarté, ainsi que certaines étapes intermédiaires de la construction de l'URB, car ils n'affectent pas la logique centrale de l'exploit.

Primitives de lecture, écriture et d'exécution

Après avoir leaké un header URB et vaincu l'ASLR du tas, il devient assez facile de construire des primitives plus puissantes, telles qu'une lecture et une écriture, et enfin, un appel arbitraire.

Nous réutilisons l'agencement mémoire de notre phase de leak : [Hole0, URB1, URB2, Shader3]

À ce stade, URB1 et URB2 ont des métadonnées de tas corrompues et ne peuvent plus être libérés en toute sécurité. Cependant, Shader3 (l'ancien emplacement de URB3) reste non corrompu et peut être librement réalloué à volonté.

Nous obtenons un contrôle total sur la structure de URB1 en utilisant Shader3 comme notre buffer source. En plaçant une structure URB forgée à l'intérieur de Shader3 et en déclenchant la primitive Move Up, nous déplaçons nos données directement dans l'espace mémoire de URB1, remplaçant effectivement son contenu par des données arbitraires. Ayant précédemment leaké un header URB, nous possédons déjà tous les pointeurs nécessaires pour en forger un parfaitement valide.

Un URB arbitraire et persistant

Pour assurer une stabilité totale, nous visons à créer un URB factice persistant qui peut être contrôlé par de simples réallocations de tas, contournant le besoin de déclencher à nouveau la vulnérabilité. L'astuce consiste à changer le pointeur URB1.next pour pointer vers Hole0. Nous incrémentons également le compteur de références de URB1 pour nous assurer qu'il reste en mémoire malgré ses métadonnées corrompues.

Lorsque VMware reap URB1, il définit URB1.next comme la nouvelle tête de la file FIFO des URB. Cela place effectivement notre URB factice dans Hole0 au sommet de la FIFO. Nous pouvons maintenant contrôler cette structure factice à volonté en réallouant Hole0 avec un nouveau shader chaque fois que nécessaire, supprimant tout besoin ultérieur de déclencher la vulnérabilité PVSCSI.

Primitives de lecture & écriture

Pour les primitives de lecture et d'écriture, chaque fois que nous avons besoin d'utiliser l'une d'elles, nous allouons simplement un nouveau shader dans Hole0 contenant une structure URB factice telle que :
  • Primitive de lecture : définir URB.actual_len à la longueur à lire et URB.data_ptr à l'adresse à lire, puis reaper le faux URB pour relire les données dans l'invité.
  • Primitive d'écriture : définir le pointeur URB.pipe vers un emplacement contrôlé connu (ex : à l'intérieur de Hole0) et abuser du mécanisme d'écriture différée (writeback) du TDBuffer (selon les spécifications UHCI) pour écrire une valeur de 32 bits contrôlée à une adresse arbitraire.

Primitive d'appel

La dernière pièce du puzzle est la primitive d'appel. En ayant une primitive R/W arbitraire, c'est assez simple à construire. Nous avons décidé de corrompre le pointeur de fonction de callback dans la structure de l'objet pipe USB, qui est toujours appelé sur les URB nouvellement créés. Cela nous donne un appel indirect arbitraire, avec des données contrôlées dans RCX+0x90 (où résident les données utilisateur URB).

Pour garantir que notre exploit est portable à travers les versions de Windows, nous évitons les offsets codés en dur. À la place, nous utilisons notre primitive de lecture pour parser Kernel32 en mémoire et résoudre dynamiquement l'adresse de WinExec.

Le dernier obstacle est le contournement du Control Flow Guard (CFG). Nous ne pouvons pas sauter directement à WinExec, donc nous utilisons un gadget whitelisté par le CFG au sein de vmware-vmx. Ce gadget pivote les données de RCX+0x100 vers un argument entièrement contrôlé avant de sauter vers un second pointeur de fonction arbitraire, dans ce cas, WinExec("calc.exe").

À ce stade, nous sommes capables de démontrer une évasion de VM complètement stable dans VMware Workstation, à condition de connaître l'état initial du LFH. 

On the Clock

Deux jours avant la compétitionet trois jours après notre inscriptionnous avions enfin un exploit fonctionnel. Le seul problème mineur était qu'il reposait sur l'hypothèse que nous connaissions l'état initial du LFH – mais ce n'était pas le cas. Le nombre de chunks LFH libres était une cible mouvante. Juste après le démarrage de l'OS invité, la valeur était presque toujours la même, mais dès qu'une session graphique était lancée, elle commençait à changer de manière imprévisible. Pour aggraver les choses, nos différentes configurations de test avaient toutes des états initiaux de LFH proches mais distincts. En résumé, nous devions choisir un nombre parmi 16, en sachant seulement que les probabilités étaient quelque peu biaisées en faveur de certaines valeurs. À ce stade, notre meilleure stratégie consistait à lancer un dé à 16 faces légèrement pipé.

Nous avions précédemment envisagé une solution basée sur une hypothèse simple : lorsqu'un chunk est alloué depuis le LFH, si tous les buckets actuels sont pleins, le LFH doit créer un nouveau bucket, un processus qui devrait prendre un temps supplémentaire. En allouant plusieurs buffers de 0x4000 et en mesurant précisément la durée de chaque allocation, nous devrions être capables de détecter un délai légèrement plus long chaque fois qu'un nouveau bucket est créé. En théorie, ce comportement créerait un timing-channel capable de révéler l'état initial du LFH.

Le hic était que nous avions besoin d'une primitive d'allocation synchrone. Dans VMware, presque toutes les allocations sont effectuées de manière asynchrone. Par exemple, pour allouer des shaders, nous poussons des commandes dans la FIFO SVGA, qui sont ensuite traitées en arrière-plan, ne nous laissant aucun moyen de chronométrer précisément l'allocation. 

Par chance, VMware expose une fonctionnalité qui est parfaitement synchrone : le canal backdoor (backdoor channel). Ce canal est utilisé pour implémenter les fonctionnalités des VMware Tools, telles que le copier-coller. Il est implémenté via des instructions assembleur "magiques", qui ne retournent qu'après que la commande a été entièrement traitée. Voici un extrait d'Open VM Tools, qui fournit une implémentation open-source des VMware Tools : 

/** VMware backdoor magic instruction */
#define VMW_BACKDOOR "inl %%dx, %%eax"

static inline __attribute__ (( always_inline )) uint32_t
vmware_cmd_guestrpc ( int channel, uint16_t subcommand, uint32_t parameter,
              uint16_t *edxhi, uint32_t *ebx ) { 
    uint32_t discard_a;
    uint32_t status;
    uint32_t edx;

    /* Perform backdoor call */
    __asm__ __volatile__ ( VMW_BACKDOOR
                   : "=a" ( discard_a ), "=b" ( *ebx ),
                 "=c" ( status ), "=d" ( edx )
                   : "0" ( VMW_MAGIC ), "1" ( parameter ),
                 "2" ( VMW_CMD_GUESTRPC | ( subcommand << 16 )), 
                 "3" ( VMW_PORT | ( channel << 16 ) ) );
    *edxhi = ( edx >> 16 );

    return status;
}

Pour déclencher des allocations contrôlées en utilisant les VMware Tools, nous avons utilisé la commande vmx.capability.unified_loop [5]. En fournissant un argument chaîne de caractères de 0x4000 octets, nous pouvions forcer l'hôte à allouer exactement deux buffers de cette taille.

Puisqu'un bucket pour la classe de taille 0x4000 contient exactement 16 chunks, déclencher cette commande 8 fois (pour un total de 16 allocations) garantissait que nous traverserions une limite de bucket et observerions un événement de création de bucket.

Pour chronométrer les allocations, nous nous sommes appuyés sur l'appel système gettimeofday. Bien que le signal temporel fût subtil, il était définitivement perceptible. Pour nettoyer le bruit, nous avons implémenté un "traitement du signal du pauvre" :

  1. Nous déclenchions la commande 8 fois pour collecter un lot de mesures.
  2. Nous écartions tout lot contenant des valeurs aberrantes significatives (typiquement des mesures beaucoup plus longues causées par des commutations de contexte de l'hôte).
  3. Nous sommions plusieurs lots valides pour obtenir une estimation plus lisse et plus fiable.

Une fois correctement réglés, les résultats étaient clairs : parmi les 8 mesures, l'une était sensiblememnt plus longue, indiquant qu'un nouveau bucket avait été créé durant cet appel spécifique. Nous pouvions alors allouer un unique buffer de 0x4000 et répéter le processus pour déterminer précisément laquelle des deux allocations au sein de la commande avait déclenché la création du nouveau bucket.

Cette seconde passe nous permettait de déduire l'offset LFH exact : si le pic temporel apparaissait au même index qu'avant, cela signifiait que l'offset LFH était impair ; si le pic se décalait à l'index suivant, l'offset était pair. Tout autre résultat était signalé comme incohérent — généralement dû à une activité de tas en arrière-plan ou, plus couramment, à des mesures excessivement bruitées — signifiant que nous devions recommencer le processus depuis le début.

La course contre le bruit

En théorie, nous aurions pu utiliser un très grand nombre de lots pour augmenter arbitrairement le rapport signal-sur-bruit (SNR). En pratique, cependant, nous avons rencontré un inconvénient significatif dans la commande vmx.capability.unified_loop : les chaînes allouées par cette commande sont ajoutées à une liste globale et ne peuvent pas être libérées.

De plus, ces chaînes doivent être uniques. Cela signifie que chaque fois que nous émettions la commande, l'hôte devait d'abord comparer notre argument chaîne contre chaque chaîne déjà présente dans la liste, n'effectuant une nouvelle allocation que s'il ne trouvait aucune correspondance.

Cela a posé un défi majeur. Initialement, lorsque la liste était vide, la comparaison de chaînes était instantanée. Mais après quelques centaines d'allocations, la commande devait effectuer des centaines de comparaisons avant même d'atteindre la logique d'allocation. Cet overhead de recherche en $O(n)$ signifiait qu'à mesure que nous collections plus de lots pour améliorer notre SNR, le bruit de fond et la latence augmentaient.

Cela a créé une course contre la montre : chaque lot de mesures que nous collections pour augmenter notre précision élevait paradoxalement le plancher de bruit pour le suivant.

Nous savions que si l'algorithme ne convergeait pas assez vite, l'état de la VM deviendrait trop "pollué" pour obtenir une lecture claire. Heureusement, après avoir testé et réglé l'algorithme sur plusieurs ordinateurs, nous avons trouvé un équilibre fonctionnel. Durant la compétition, l'exploit a fonctionné dès la première tentative, à notre grand soulagement.

Démonstration

Voici une vidéo de l'exploit en action :

Video file

Conclusion

Cette recherche a occupé nos soirées et nos week-ends pendant trois mois. Le premier mois a été consacré à la rétro-ingénierie de VMware et à la découverte de la vulnérabilité. Convaincus que l'exploitation serait simple, nous avons passé le deuxième mois à procrastiner.

Le troisième mois fut un dur retour à la réalité. Alors que nous développions les drivers nécessaires pour allouer des objets intéressants, nous nous sommes heurtés au mur des mitigations du LFH de Windows 11, épuisant d'innombrables stratégies de contournement qui ont finalement échoué. Par conséquent, le cœur de l'exploit — incluant le leak, les primitives Lecture/Écriture/Exécution, et le timing-channel — a été développé dans les cinq derniers jours précédant la compétition.

Bien que ce sprint de dernière minute ait finalement payé, nous déconseillons formellement de reproduire notre chronologie — à moins que vous n'appréciez particulièrement la privation de sommeil.

Références

[1] Saar Amar, Low Fragmentation Heap (LFH) Exploitation - Windows 10 Userspace

[2] Zisis Sialveras, Straight outta VMware: Modern exploitation of the SVGA device for guest-to-host escape exploits

[3] Corentin Bayet, Bruno Pujos, SpeedPwning VMware Workstation: Failing at Pwn2Own, but doing it fast

[4] Yuhao Jiang, Xinlei Ying, URB Excalibur: The New VMware All-Platform VM Escapes

[5] nafod, Pwning VMware, Part 2: ZDI-19-421, a UHCI bug