Red Team · Recherche
CVE-2026-69451 : de la confusion de type à SYSTEM dans le service WMI
Analyse de la CVE-2026-69451
- Auteur
- SNS - Équipe pentest
- Publié
- Lecture
- 12 min de lecture
Lors de travaux de R&D en sécurité offensive menés chez SNS Security, nous avons exploré plusieurs composants Windows chargés de transporter des objets COM d’un processus à l’autre. Cette recherche nous a progressivement conduit jusqu’au traitement des requêtes WMI et à la désérialisation de l’interface IWbemContext dans fastprox.dll.
Le résultat : une confusion de type exploitable localement qui, combinée à une fuite d’adresse dans le service WMI, permet à un utilisateur standard d’exécuter une commande sous NT AUTHORITY\SYSTEM.
Cet article revient sur les mécanismes essentiels de la vulnérabilité et sur la chaîne d’exploitation, pour avoir un point de vue global de la vulnérabilité et des composant affectés.
En bref
WMI permet aux applications d’associer à leurs requêtes un petit dictionnaire d’options nommé IWbemContext. Lorsqu’un tel objet traverse une frontière COM, fastprox.dll le sérialise côté client puis le reconstruit dans le processus destinataire.
Le problème se situe dans cette reconstruction. Le désérialiseur accepte un tableau de VARIANT qui ne fait pas partie des types prévus par le protocole, puis copie ses éléments sans vérifier récursivement leur type. Il devient alors possible de placer un faux VT_RECORD dans le tableau. Lorsque Windows copie ou libère ensuite cette valeur, oleaut32.dll interprète des pointeurs contrôlés comme une véritable interface IRecordInfo et effectue un appel indirect à travers sa vtable.

L’exploitation complète repose sur quatre éléments :
- fabriquer un flux
IWbemContextcontenant leVT_RECORDcaché ; - obtenir une adresse valide dans le tas de Winmgmt grâce à une seconde faiblesse de WMI ;
- réoccuper le bloc correspondant avec un faux objet
IRecordInfo; - détourner l’appel indirect vers une cible compatible avec Control Flow Guard (
CFG) afin d’exécuter une commande dans Winmgmt, qui fonctionne sousLocalSystem.
Où intervient fastprox.dll ?
WMI expose les composants administrables de Windows au moyen de classes, de méthodes et d’événements. Pour la suite, une distinction suffit : Winmgmt porte l’infrastructure WMI dans un processus svchost exécuté sous LocalSystem, tandis que les providers sont généralement isolés dans des processus WmiPrvSE.exe dont l’identité dépend du modèle d’hébergement (architecture de WMI, documentation de winmgmt). Ici, la confusion de type se produit dans Winmgmt lui-même, ce qui explique l’impact final en SYSTEM.

Les méthodes d’IWbemServices peuvent recevoir un paramètre optionnel pCtx de type IWbemContext. Ce contexte est un conteneur de paires nom/valeur utilisé pour joindre des options à une requête, par exemple __ProviderArchitecture. Chaque valeur est stockée dans un VARIANT.
Lorsque le client et l’objet WMI vivent dans des processus différents, COM transporte les paramètres sous forme sérialisée. fastprox.dll participe à ce marshalling spécifique à WMI : côté Winmgmt, COM sélectionne fastprox!CWbemContext et lui demande de reconstruire l’IWbemContext reçu.

Trois types OLE Automation interviennent ensuite. Un VARIANT est une union étiquetée dont le champ vt indique comment interpréter la valeur. Un SAFEARRAY décrit un tableau, notamment son nombre d’éléments et leur taille. Avec VT_ARRAY | VT_VARIANT, chaque élément du tableau est lui-même un VARIANT complet et possède donc sa propre étiquette.
VT_RECORD représente enfin 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 COM IRecordInfo. Des opérations apparemment anodines deviennent alors des appels indirects : pour copier un record, oleaut32.dll appelle notamment pRecInfo->RecordCopy(...) à travers la vtable de l’interface. Tout va bien tant que pRecInfo désigne un objet légitime.
Une boîte dans la boîte
Le format MS-WMI limite les propriétés d’IWbemContext à une liste de types simples, à VT_UNKNOWN et aux tableaux construits à partir des types autorisés. VT_VARIANT et VT_RECORD n’en font pas partie.
Le constructeur CWbemContext::CContextObj(IStream *, ...) lit le type de chaque propriété, traite séparément les chaînes et les interfaces, puis délègue les tableaux à UnmarshalSafeArray. Le pseudo-code suivant conserve les décisions utiles à la vulnérabilité :
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);
UnmarshalSafeArray possède deux chemins particuliers. Pour VT_BSTR, chaque chaîne est recréée ; pour VT_UNKNOWN, chaque interface passe par CoUnmarshalInterface. Tous les autres types empruntent 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);
}
Cette copie brute est cohérente pour des octets ou des entiers, qui ne contiennent aucun pointeur à traduire. Elle ne l’est plus pour un tableau de VARIANT.
L’implémentation applique par ailleurs plusieurs contrôles et rejette notamment un VT_RECORD présenté directement comme valeur d’une propriété. Le problème est que ces vérifications s’arrêtent au type extérieur.

Le désérialiseur accepte VT_ARRAY | VT_VARIANT (0x200C) et transmet son type de base, VT_VARIANT (12), à UnmarshalSafeArray. Aucun cas particulier ne lui correspond : l’exécution tombe donc dans le chemin générique. Sur x64, chacun des blocs de 24 octets copiés depuis le flux est ensuite traité comme un véritable VARIANT, avec un champ vt et des pointeurs provenant intégralement de l’appelant.
Le contrôle peut se résumer ainsi :
if (outerVariant.vt == VT_RECORD)
reject_or_neutralize();
Pour la valeur malveillante, outerVariant.vt vaut VT_ARRAY | VT_VARIANT. Le VT_RECORD se trouve un niveau plus bas, à l’intérieur du tableau, et n’est jamais inspecté par fastprox.
Après sa réception, le contexte peut être cloné avant d’être transmis à un provider. Cette copie utilise VariantCopy. À la destruction, VariantClear libère les ressources associées. Pour un tableau de VARIANT, ces deux fonctions parcourent récursivement chaque élément.
En rencontrant l’élément forgé, oleaut32.dll fait confiance à son étiquette VT_RECORD, traite pRecInfo comme une interface valide, puis appelle ses méthodes à travers la vtable contrôlée. Le chemin de copie observé avec le PoC atteint IRecordInfo::RecordCopy ; celui de destruction utilise notamment RecordClear, puis Release.
Copie du contexte
└─ VariantCopy(VT_ARRAY | VT_VARIANT)
└─ SafeArrayCopy
└─ VariantCopy(élément VT_RECORD)
└─ pRecInfo->RecordCopy(...)
Le crash apparaît alors dans oleaut32.dll, mais cette bibliothèque ne crée pas l’état incohérent. Elle applique les règles normales d’Automation à une valeur que fastprox a reconstruite depuis un flux non fiable. La cause racine se situe bien dans le marshaller d’IWbemContext.
Construire une valeur que l’API refuse
Un appel direct à IWbemContext::SetValue avec un VT_RECORD échoue. Le PoC commence donc par construire un véritable contexte contenant une valeur bénigne : un SAFEARRAY de VARIANT dont l’unique élément est un entier VT_I8. Sa valeur, 0xCAFEBABEF00D1234, sert de marqueur.
COM sérialise ce contexte avec CoMarshalInterface. Le PoC récupère sa propre copie du flux, localise le marqueur puis modifie les 24 octets du VARIANT déjà marshallé :
if (rd64(&blob[i]) == PLACEHOLDER)
off = (int)i;
blob[off - 8] = 0x24; // VT_I8 devient VT_RECORD
blob[off - 7] = 0x00;
wr64(&blob[off], g + OBJ_CMD); // pvRecord -> commande
wr64(&blob[off + 8], g + OBJ_VP); // pRecInfo -> faux objet
Sur x64, le champ vt se trouve huit octets avant la valeur entière. Après le patch, la zone qui contenait le VT_I8 devient pvRecord, tandis que les huit octets suivants deviennent pRecInfo :
Avant le patch
+--------+--------+-------------------------------------------+
| Offset | Taille | Interprétation |
+--------+--------+-------------------------------------------+
| -0x08 | 2 o | vt = 0x0014 (VT_I8) |
| 0x00 | 8 o | 0xCAFEBABEF00D1234 |
| 0x08 | 8 o | padding |
+--------+--------+-------------------------------------------+
Après le patch
+--------+--------+-------------------------------------------+
| Offset | Taille | Interprétation par oleaut32 |
+--------+--------+-------------------------------------------+
| -0x08 | 2 o | vt = 0x0024 (VT_RECORD) |
| 0x00 | 8 o | pvRecord = G + 0xB0 |
| 0x08 | 8 o | pRecInfo = G + 0x10 |
+--------+--------+-------------------------------------------+
Il reste à remettre ce flux modifié à COM. Un faux objet IMarshal annonce le CLSID officiel de CWbemContext et écrit la charge utile altérée. Le service reçoit donc bien l’argument d’une véritable méthode WMI, avec un en-tête qui désigne l’unmarshaller officiel d’IWbemContext. Seul le contenu a été modifié après sa validation par l’API.
Cette technique permet de présenter à Winmgmt une valeur que le chemin normal de SetValue n’aurait jamais acceptée. L’enveloppe COM reste légitime ; c’est la charge utile spécifique à IWbemContext qui a été altérée après coup.
De la primitive au contrôle du flot d’exécution
Contrôler pRecInfo permet de choisir l’adresse à laquelle oleaut32.dll cherchera une vtable. Encore faut-il placer le faux objet à cette adresse dans Winmgmt. ASLR rend l’adresse du tas imprévisible, et un spray classique resterait très probabiliste sans information supplémentaire.
La chaîne utilise donc une seconde faiblesse dans le provider de self-instrumentation WMI,wbemess.dll. Ce provider publie des événements internes liés au sous-système d’événements WMI. Dans les tests réalisés, CTempFilter::ComputeThisKey construit le champ Name sous la forme $%p. La valeur formatée est l’adresse de l’objet interne utilisé comme clé du filtre : une adresse vivante dans le tas de Winmgmt.
Le PoC s’abonne à MSFT_WmiEssEvent, vérifie que la propriété Name commence par $, puis interprète la suite comme une adresse hexadécimale. Il crée jusqu’à 25 abonnements temporaires à Win32_DeviceChangeEvent et associe chaque abonnement à l’adresse annoncée juste après sa création. Cette correspondance est importante : le code sait ensuite exactement quels objets libérer et n’utilise pas une adresse provenant d’un événement plus ancien arrivé en retard.
Les adresses sont triées et dédupliquées. DenseRunCenter sépare les séries lorsqu’il rencontre un écart supérieur à 0x2a0, conserve la série la plus dense et choisit son élément central comme adresse cible G. Le PoC libère ensuite les abonnements associés afin de rendre ces blocs au tas. Il ne calcule donc pas G au moyen d’un décalage fixe : il vise directement l’un des blocs qu’il vient d’observer puis de libérer.
L’exploitation suit alors le déroulement suivant :
- créer des filtres WMI temporaires et collecter leurs adresses ;
- sélectionner une adresse cible
G, puis libérer les filtres associés ; - provoquer de nombreuses allocations contenant toutes le même faux objet
IRecordInfo, construit pour fonctionner à l’adresseG; - déclencher le traitement du
VT_RECORD, dontpRecInfopointe versG; - laisser
VariantCopysuivre la fausse vtable et atteindre le pont d’appel prévu par le PoC.
La taille du bloc compte autant que son adresse. Le PoC estime le gabarit d’allocation à partir de l’écart entre les adresses divulguées. Il laisse les 16 premiers octets disponibles pour les métadonnées du tas, place le faux objet à G + 0x10, sa vtable à G + 0x30 et la commande à G + 0xB0.
Adresse Contenu
──────────────────────────────────────────────────────────────
G + 0x00 métadonnées de l'allocateur
G + 0x10 faux IRecordInfo
└─ lpVtbl -> G + 0x30
G + 0x30 fausse vtable
├─ RecordCopy -> g_bridge
└─ autres méthodes -> g_noop
G + 0xB0 "cmd.exe /c ..."
Le slot RecordCopy, situé à l’offset 0x28 dans la vtable, pointe vers g_bridge. Ce pont d’appel respecte CFG et charge ensuite WinExec avec l’adresse de la commande. Les autres entrées visent une petite fonction qui renvoie zéro afin que les appels auxiliaires puissent se terminer sans détourner le flot prévu.
Un seul contexte pour le spray et le déclencheur
Un point important de la fiabilité du PoC est que le spray et le déclencheur sont envoyés dans un seul IWbemContext.
Les premières propriétés, nommées spray000000, spray000001, etc., sont des tableaux d’octets VT_ARRAY | VT_UI1. Elles transportent environ 4 Mio de données formées de copies de la fausse structure. Avec un gabarit de 0x120 ou 0x130 octets, cela représente de l’ordre de quatorze mille propriétés. La dernière, zz_record, est le tableau de VARIANT contenant l’unique élément transformé en VT_RECORD.
Chaque propriété de spray est un tableau d’octets ordinaire du point de vue de WMI. Son contenu reproduit pourtant le motif préparé pour l’adresse absolue G : pointeur de vtable à +0x10, vtable à +0x30, pont CFG, adresse de WinExec et commande à +0xB0. Le VT_RECORD n’apparaît que dans zz_record.
IWbemContext construit côté client
│
├─ spray000000 : [ copie du faux objet construit pour G ]
├─ spray000001 : [ copie du même objet ]
├─ ...
└─ zz_record : [ VARIANT transformé en VT_RECORD ]
Cette organisation maintient les allocations de placement en mémoire pendant le déclenchement. Elle évite aussi une attente arbitraire entre deux appels distincts. Si aucune copie ne réoccupe le bloc visé, la tentative échoue et le PoC recommence avec une nouvelle série d’allocations.
Dans une tentative réussie, une copie du motif réoccupe le bloc qui commence à G. zz_record est désérialisé dans une autre allocation, mais son pRecInfo pointe vers G + 0x10 et son pvRecord vers G + 0xB0. Lorsque VariantCopy traite le record, la chaîne de pointeurs est alors complète :
pRecInfo -> G+0x10 -> lpVtbl = G+0x30
└─ RecordCopy -> g_bridge -> WinExec
pvRecord -> G+0xB0 -> "cmd.exe /c ..."
Le faux contexte est finalement transmis comme paramètre de ExecNotificationQueryAsync avec une requête SELECT * FROM __ClassModificationEvent :
IWbemContext* fakeCtx =
reinterpret_cast<IWbemContext*>(static_cast<IMarshal*>(&g_marshaler));
g_blob = BuildContextBlob(g, fullCmd); // spray et VT_RECORD dans le même flux
svc->ExecNotificationQueryAsync(lang, query, 0, fakeCtx, &g_sink);
Cet événement intrinsèque est traité par le sous-système d’événements de Winmgmt. La fuite d’adresse, les allocations du spray et l’utilisation du faux objet ont donc lieu dans le même processus.

Impact et versions concernées
Une exploitation locale réussie permet à un utilisateur standard d’exécuter une commande dans Winmgmt, sous NT AUTHORITY\SYSTEM. La vulnérabilité a été observée sur une plage allant de Windows 10 version 1709 à Windows 11 25H2, ainsi que sur les versions Windows Server étudiées.
Le bug est théoriquement déclenchable à distance par un utilisateur disposant des droits d’activation DCOM, par exemple sur un serveur AD CS. Les recherches menées n’ont toutefois pas permis de reproduire à distance la fuite d’adresse indispensable sans disposer de privilèges administrateur sur la machine cible. La chaîne d’exploitation distante reste donc théorique.
Le code source du PoC et une version compilée sont disponibles sur le dépôt CVE-2026-69451-PoC.
Timeline
- Juin 2026 : découverte de la vulnérabilité ;
- 21 juillet 2026 : signalement initial au MSRC ;
- 16 août 2026 : confirmation de la vulnérabilité par Microsoft ;
- 16 août 2026 : Microsoft indique avoir reçu un signalement similaire visant la même vulnérabilité ;
- 8 septembre 2026 : correction dans les mises à jour de sécurité de septembre 2026.
Cette recherche illustre surtout l’importance d’une validation récursive lorsque des formats conteneurs transportent eux-mêmes des valeurs typées. Le type extérieur peut sembler parfaitement acceptable ; le véritable problème se cache parfois dans la boîte rangée à l’intérieur.