Cet article fournit une analyse technique du nombre de pilotes en mode noyau Windows avec lesquels il est possible d'interagir à partir du mode utilisateur sans le matériel pour lequel ils ont été développés. Ce travail a été motivé par la recherche de vulnérabilités orientée pilote et par la nécessité d'évaluer l'exploitabilité des résultats individuels, qui affectent fréquemment le code dont l'accessibilité est dépendante du matériel. La méthodologie présentée ici devrait aider quiconque à déterminer si une vulnérabilité particulière du pilote en mode noyau Windows reste accessible - et donc potentiellement exploitable - même en l'absence du matériel pour lequel le pilote a été développé.
Le lecteur doit avoir des connaissances de base sur les pilotes Windows, notamment en ce qui concerne les objets périphériques. Le reste de cet article est rédigé en supposant que le lecteur est déjà familier avec les concepts décrits dans l'article d'introduction : Anatomie de l'accès : objets de périphérique Windows du point de vue de la sécurité.
Tout comme l'article d'introduction, cette ressource ne se concentre pas sur une classe de bogues spécifique, mais plutôt sur la surface d'attaque et, dans une certaine mesure, sur l'architecture Plug and Play de Windows.
Tous les tests présentés ici ont été effectués sur Windows 11 23H2 (winver 10.0.22631.3007).
Pour en savoir plus sur les dernières recherches sur les menaces et les avis sur les vulnérabilités, veuillez vous abonner aux blogs d'Atos Cyber Shield.
Outre le potentiel évident d'élévation des privilèges locaux, les pilotes vulnérables sont souvent exploités dans les attaques BYOVD - une technique de post-exploitation exploitée par les attaquants pour perturber les défenses du système telles que les composants EDR.
Deux critères principaux déterminent si une vulnérabilité de pilote est un bon candidat pour les attaques BYOVD : 1. L'exploitation permet une perturbation significative d'un composant de sécurité autrement inviolable. Les exemples incluent des vulnérabilités au niveau du noyau accordant un accès arbitraire en lecture/écriture à la mémoire, l'exécution de code arbitraire ou un abus arbitraire de ressources (par exemple, écrasement de fichiers, fermeture de descripteurs ou arrêt de processus). 2. Son exploitabilité est indépendante de conditions système rares, comme la présence de matériel spécifique.
Bien que les attaques de type BYOVD soient bien documentées depuis des années, avec de nombreux rapports publics et documents de recherche sur le sujet (par exemple https://www.ndss-symposium.org/wp-content/uploads/2026-s1491-paper.pdf, https://blackpointcyber.com/blog/qilin-ransomware-and-the-hidden-dangers-of-byovd/, https://www.sophos.com/en-us/blog/itll-be-back-attackers-still-abusing-terminator-tool-and-variants), aucun d'entre eux n'examine spécifiquement le rôle du contrôle matériel dans l'accessibilité des vulnérabilités des pilotes.
L'analyse fournie dans cette ressource est structurée autour des objets appareils, car ils constituent le vecteur d'attaque le plus viable. Cependant, les techniques démontrées ici ont un impact pratique sur l'accessibilité du code du pilote depuis l'espace utilisateur en général, et pas seulement via IRP.
Les obstacles les plus courants lors de l'attaque d'un pilote via son objet périphérique sont : 1. L'objet périphérique n'est pas créé. 2. L'état interne du pilote ne permet pas l'exercice du comportement vulnérable bien que l'objet périphérique soit accessible.
Les deux scénarios sont très courants lorsqu'il s'agit d'un pilote de périphérique déployé sur un système sans le matériel physique correspondant.
Dans le reste de l’article, je fais souvent référence aux piles de périphériques et aux nœuds de périphériques. J'ai couvert assez largement les piles de périphériques dans mon article d'introduction. Bien qu'un nœud de périphérique et une pile de périphériques ne soient pas la même chose, les termes sont souvent utilisés de manière interchangeable, car chaque nœud de périphérique possède exactement une pile de périphériques.
De nombreux pilotes, en particulier les pilotes non PnP, créent leurs objets périphérique soit directement à partir de leur fonction DriverEntry, soit à partir d'une autre fonction invoquée dans la chaîne d'appel direct provenant de DriverEntry.
Le pilote de démonstration Multidev_WDM illustre ce modèle. Nous pouvons voir la création de périphérique invoquée immédiatement dans DriverEntry :
| Création de CDO invoquée directement depuis DriverEntry |
Le pilote supprime également l'objet périphérique en appelant IoDeleteDevice, mais cela se produit uniquement lorsque DriverUnload est appelé (lorsque le pilote est déchargé) :
| Nettoyage CDO de DriverUnload |
Les pilotes construits de cette manière peuvent être interagis après un déploiement simple composé de seulement deux étapes :
sc.exe créer SampleDrv type= démarrage du noyau= demande binPath= System32\drivers\SampleDrv.sys
Si nous examinons un pilote choisi au hasard sur https://loldrivers.io/, nous verrons que sa commande de déploiement correspond à ce modèle :
| Pilotes LOL - Déploiement de zam64.sys |
Mais la plupart des pilotes de périphériques n’entrent pas dans cette catégorie, comme nous le verrons dans les sections suivantes de cet article.
Souvent, les routines d'initialisation du pilote effectuent des vérifications supplémentaires. Par exemple, les composants en mode noyau des logiciels de sécurité (EDR, antivirus, surveillance, authentification améliorée, etc.) ont tendance à vérifier les clés et entrées de registre spécifiques au produit, qui sont créées et initialisées lors du déploiement normal du produit.
Les pilotes de périphérique réels (créés pour piloter le matériel physique) ont tendance à créer leurs objets de périphérique uniquement en présence de ce matériel. Sans cela, ils non plus : - ne tentent pas de créer d'objets périphérique, - ils suppriment tous les objets périphérique peu de temps après leur création, en appelant IoDeleteDevice.
Concentrons-nous sur la façon dont cette logique est mise en œuvre et évaluons si et comment elle peut être contournée, en particulier du point de vue BYOVD - en opérant uniquement à partir de l'espace utilisateur (sans accès physique/hyperviseur).
À propos, le deuxième scénario, dans lequel un objet périphérique est d'abord créé puis supprimé peu de temps après, crée une situation qui pourrait être considérée comme une situation de concurrence critique, car il existe une courte fenêtre de temps pendant laquelle l'objet périphérique existe.
Dans les pilotes compatibles PnP (qui constituent la plupart des pilotes de périphérique), la logique d'initialisation s'étend au-delà de DriverEntry dans les routines spécifiques PnP suivantes : AddDevice et le gestionnaire IRP_MJ_PNP.
Cette section les explore tous les deux et explique pourquoi la plupart des pilotes compatibles PnP doivent être configurés de manière à garantir que ces fonctions sont appelées si nous voulons interagir avec le pilote.
Tous les pilotes compatibles PnP doivent définir cette routine. Il est chargé de créer des objets de périphérique fonctionnels (FDO) et des objets de périphérique de filtrage (filtre DO) pour les périphériques énumérés par le gestionnaire PnP. Cela explique pourquoi AddDevice est l'endroit où réside la majeure partie de la logique d'initialisation. Cela inclut : - la création d'objets périphériques (IoCreateDevice), - l'initialisation de diverses variables internes qui sont ensuite nécessaires pour atteindre le code vulnérable, - la gestion des files d'attente d'E/S dans les pilotes WDF (KMDF).
La page MSDN sur la gestion des files d'attente d'E/S dans les pilotes WDF indique : > Les pilotes appellent généralement WdfIoQueueCreate à partir d'une fonction de rappel EvtDriverDeviceAdd. L'infrastructure peut commencer à transmettre des requêtes d'E/S au pilote après le retour de la fonction de rappel EvtDriverDeviceAdd du pilote.
Dans le contexte des pilotes WDF (KMDF), AddDevice est appelé EvtDriverDeviceAdd (nom différent, même application).
AddDevice n'est pas appelé depuis la routine DriverEntry, ce qui signifie qu'il ne s'exécute pas automatiquement lors du chargement du pilote. Au lieu de cela, le gestionnaire PnP ne l'invoque qu'après avoir découvert un nouveau nœud de périphérique et déterminé que ce pilote doit soit contrôler le périphérique directement, soit servir de filtre dans la pile de périphériques.
Regardons du code. Remarque : tous les décalages spécifiques à la structure concernent l'architecture 64 bits.
Tant dans DriverEntry que dans AddDevice, le premier paramètre que la fonction reçoit est un pointeur vers la structure DRIVER_OBJECT. Comme on peut le lire sur la page MSDN, la structure est allouée par le gestionnaire d'E/S :
Le gestionnaire d'E/S alloue la structure DRIVER_OBJECT et la transmet comme paramètre d'entrée aux routines DriverEntry, AddDevice et facultatives Reinitialize d'un pilote et à sa routine Unload, le cas échéant.
DRIVER_OBJECT contient des pointeurs vers les routines de répartition du pilote, chacune à un décalage spécifique (par exemple 0xe0 pour IRP_MJ_DEVICE_CONTROL).
Cependant, le pointeur vers AddDevice n'est pas stocké directement dans la structure DRIVER_OBJECT, mais dans la structure DRIVER_EXTENSION, accessible via DriverObject->DriverExtension->AddDevice. Ce fait est mentionné sur la même page MSDN :
Pointeur vers l’extension du pilote. Le seul membre accessible de l'extension du pilote est DriverExtension->AddDevice, dans lequel la routine DriverEntry d'un pilote stocke la routine AddDevice du pilote.
Ainsi, dans le décompilateur, l'affectation AddDevice ressemble généralement à :
// DriverObject->DriverExtension->AddDevice = SomeFunction; *(*(param_1 + 0x30) + 8) = FUN_XXXXX ;Ainsi, une séquence d'initialisation typique pour les routines de répartition des pilotes et autres rappels standard que nous pouvons généralement trouver dans la fonction DriverEntry d'un pilote de périphérique ressemble à ceci (décompilé dans Ghidra, commentaires ajoutés manuellement) :
*(code **)(param_1 + 0x70) = FUN_00011a08; // Routine de répartition IRP_MJ_CREATE *(code **)(param_1 + 0x80) = FUN_00011a08; // Routine de répartition IRP_MJ_CLOSE *(code **)(param_1 + 0xe0) = FUN_00010614; // Routine de répartition IRP_MJ_DEVICE_CONTROL *(code **)(param_1 + 0xe8) = FUN_000104ac; // IRP_MJ_INTERNAL_DEVICE_CONTROL *(code **)(param_1 + 0x148) = FUN_00011c70; // Routine de répartition IRP_MJ_PNP *(code **)(param_1 + 0x120) = FUN_00011bc8; // Routine de répartition IRP_MJ_POWER *(code **)(*(longlong *)(param_1 + 0x30) + 8) = FUN_00011ad4; // AddDevice *(code **)(param_1 + 0x68) = FUN_00011b8c; // PiloteDéchargementAinsi, AddDevice est défini dans FUN_00011ad4 et lors du chargement du pilote (exécution de DriverEntry), son pointeur est écrit dans DriverObject->DriverExtension->AddDevice, tout comme tous les pointeurs de routine de répartition sont écrits dans leurs décalages pertinents. Mais aucune de ces fonctions n’a encore été invoquée. Par exemple, FUN_00010614 (IRP_MJ_DEVICE_CONTROL) ne s'exécutera qu'une fois que le pilote recevra un IRP avec le code MajorFunction = IRP_MJ_DEVICE_CONTROL (par exemple, en réponse à l'appel DeviceIoControl depuis l'espace utilisateur). De même, AddDevice n'est pas appelé par le pilote lui-même, mais plutôt par le gestionnaire PnP dans des circonstances spécifiques.
Examinons maintenant FUN_00011ad4 et voyons à quoi ressemble une implémentation typique d'AddDevice :
undéfini8 FUN_00011ad4(undéfini8 param_1,undéfini8 param_2) { longlong lVar1; longlong lVar2; non défini8 uVar3; non défini8 uVar4; non défini8 uVar5; non défini8 uVar6; longlong local_res18 [2]; local_res18[0] = 0; lVar1 = *(longlong *)(DAT_00011880 + 0x40); uVar3 = IoCreateDevice(param_1,0x100,0,0x22,0,0,local_res18); if (-1 < (int)uVar3) { lVar2 = *(longlong *)(local_res18[0] + 0x40); *(indéfini1 *)(lVar2 + 5) = 0; *(indéfini1 *)(lVar2 + 4) = 0; *(undefined8 *)(lVar2 + 0x18) = 0; *(undefined8 *)(lVar2 + 0x10) = param_2; *(longlong *)(lVar2 + 8) = local_res18[0]; *(non défini4 *)(lVar2 + 0x20) = 0x10000004; ExInterlockedInsertHeadList (lVar1, lVar2 + 0x28, lVar1 + 0x18); VERROUILLAGE(); *(int *)(lVar1 + 0x10) = *(int *)(lVar1 + 0x10) + 1; OUVRIR(); KeInitializeEvent(lVar2 + 0x50,1); *(non défini4 *)(lVar2 + 0x68) = 1; *(uint *)(local_res18[0] + 0x30) = *(uint *)(local_res18[0] + 0x30) & 0xffffff7f; uVar3 = IoAttachDeviceToDeviceStack(local_res18[0],param_2); *(undefined8 *)(lVar2 + 0x18) = uVar3; uVar3 = 0 ; local_res18[0] = 0; RtlInitUnicodeString(&DAT_00011870,u_\Device\SampleDrv_00012270); uVar4 = IoCreateDevice(param...
[Courte citation de 8% de l'article original]