On the clock: Escaping VMware Workstation at Pwn2Own Berlin 2025
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
vmware-vmxlinux/drivers/scsi/vmw_pvscsi.cPVSCSISGElement
struct PVSCSISGElement {
u64 addr;
u32 length;
u32 flags;
} __packed;
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;
}
// [...]
}
UtilsSafeRealloc1()
addrlengthUn Tas de problèmes

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
A Tale of Two Objects
Shaders
URBs
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 |
+---------------------------------------------------------------+
Une partie de Ping-Pong





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 :
- Allocation : Nous allouons 15 URB pour remplir le bucket B1.
- Corruption : Nous déclenchons la vulnérabilité (Ping-Pong) pour mettre à zéro le champ
actual_lende l'URB situé immédiatement après Hole0. Ensuite, nous allouons deux shaders placeholders pour réutiliser Hole0 et PONG. - 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.
- Identification : Lorsque nous récupérons un URB avec une
actual_lenmodifié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 (Hole0–Hole3), 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
// [...]
res = ((__int64 (__fastcall *)(pvscsi_vmx *, ScsiCmd *, sgi *))scsi->pvscsi->fillSGI)(// PVSCSI_FillSGI
scsi->pvscsi,
scsi_cmd,
&scsi_cmd->sgi);
LODWORD(real_seg_count) = 0;
if ( !res )
return 0;
seg_count = (unsigned int)scsi_cmd->sgi.seg_count;
if ( (int)seg_count <= 0 )
{
numBytes_1 = 0LL;
}
else
{
pEntries = scsi_cmd->sgi.pEntries;
numBytes_1 = pEntries->entry_size;
if ( (_DWORD)seg_count != 1 )
{
next_entry = (SGI_Entry *)((char *)pEntries + 0x18);
LODWORD(real_seg_count) = 0;
for ( i = 1LL; i != seg_count; ++i )
{
idx = (int)real_seg_count;
entry_size = pEntries[idx].entry_size;
addr = ADJ(next_entry)->addr;
if ( entry_size + pEntries[idx].addr == addr )
{
pEntries[idx].entry_size = ADJ(next_entry)->entry_size + entry_size;
}
else
{
real_seg_count = (unsigned int)(real_seg_count + 1);
if ( i != real_seg_count )
{
real_seg_idx = (int)real_seg_count;
pEntries[real_seg_idx].addr = addr;
pEntries[real_seg_idx].entry_size = ADJ(next_entry)->entry_size;
}
}
numBytes_1 += ADJ(next_entry++)->entry_size;
}
}
}
// [...]
Entry 1: {.addr = 0x11000, .size = 0x4000}
Entry 2: {.addr = 0x15000, .size = 0x2000}
Entry 3: {.addr = 0x30000, .size = 0x1000}
Entry 1′: {.addr = 0x11000, .size = 0x6000}
Entry 2′: {.addr = 0x30000, .size = 0x1000}
0xFFFFFFFFConstruire un overflow contrôlé
{.addr=0, .len=0}entry[1023]
entry[2048]entry[1024]
AddrA+LenA==AddrBLenA=0AddrA==AddrB0x4141414141414141 0x42424242424242420x4343434343434343 0x4444444444444444
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 }
Leak d'un URB
actual_lenJiangÉtape 1 : La mise en place

actual_len0x0
Étape 2 : L'overflow
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 intacts. Nous nous retrouvons avec l'état suivant :

Étape 3 : Le coalescing
0xFFFFFFFF*0x401.actual_len0x400
0x400
actual_len
Primitives de lecture, écriture et d'exécution
Nous réutilisons l' 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
URB.actual_lenURB.data_ptr
URB.pipe
Primitive d'appel
RCX+0x90
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").
On the Clock
Deux jours avant la compétition—et trois jours après notre inscription—nous 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" :
- Nous déclenchions la commande 8 fois pour collecter un lot de mesures.
- 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).
- 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 :
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