
Tutoriel sur CVE-2022-37969 axé sur la méthodologie d'exploitation du noyau, et non sur les causes internes de la CVE.
Ce document a été créé pour clarifier les aspects généraux concernant l'exploitation de Windows. Il explique les concepts de base appliqués à CVE-2022-37969. Le résultat final est un PoC fonctionnel. Il ne clarifie pas tous les aspects de la CVE, mais fournit des morceaux de code réutilisables et explique des mécanismes que l'on retrouve dans de nombreux exploits généraux.
L'utilisateur cible serait un ingénieur en rétro-ingénierie débutant, un développeur d'exploits cherchant un code source de preuve de concept fonctionnel pour tester et comprendre les bases des internals Windows. Il fournit un point de référence pour approfondir l'apprentissage.
Prérequis : débogage noyau de base, rétro-ingénierie de base, internals Windows de base, compétences en programmation c/c++
Un programme est un morceau de code qui s'exécute sur une machine. Généralement, un programme reçoit des données (entrée), effectue des calculs à partir des entrées et génère des données (sortie). La plupart des programmes sont écrits par des humains et contiennent donc des bogues. Un bogue est généré par un code source qui n'a pas été écrit correctement (le programmeur voulait faire quelque chose avec l'entrée, le code résultant était différent du résultat attendu). La plupart des bogues sont corrigés avant le lancement du produit, mais certains subsistent. Cela arrive car il existe différents types de bogues, certains plus difficiles à repérer que d'autres.
Windows est un programme informatique, écrit par des humains, et contient donc des bogues. Pourquoi est-ce important ? Parce que les systèmes Windows peuvent exécuter des programmes qui traitent des données sensibles comme des comptes bancaires, des bases de données de soins de santé, etc. Certains bogues peuvent être utilisés pour accéder illégalement à des données restreintes (c'est un bon cas d'usage pour un exploit).
Il existe de nombreux types de bogues, certains utiles, d'autres non. En général, les bogues sont générés par des entrées du programme qui, combinées aux lignes de code mal écrites, produisent une sortie ou un comportement anormal du programme. Trouver cette entrée est le travail du spécialiste en sécurité (ou du hacker). L'étape suivante consiste à évaluer la sortie/le comportement anormal obtenu et à répondre à la question « Peut-il être utilisé de manière utile ? ». C'est là que les bogues sont classés en différentes catégories. Par exemple, un bogue peut provoquer un comportement qui corrompt certaines structures de données et fait redémarrer l'ordinateur cible. Son utilité est limitée. Un bogue peut faire en sorte que l'entrée soit écrite dans une zone mémoire qui contrôle les permissions d'accès à des fichiers restreints. Ce type de bogue est plus utile.
Ainsi, parmi l'ensemble des bogues possibles, le hacker recherche le sous-ensemble le plus utile à son objectif. D'une manière générale, le problème est : « Puis-je fournir au programme cible une entrée spécialement conçue pour ne pas casser le système mais pour élever mon niveau d'accès et en tirer profit ? »
Après cette introduction non technique, le périmètre du tutoriel peut être formulé : Pouvons-nous trouver un programme Windows qui accepte une entrée malformée et qui, en raison d'un code développeur incorrect, peut élever illégalement nos permissions d'utilisateur standard à administrateur ?
Programme cible : Windows CLFS (Common Log File System Driver)
Nom de l'exploit : CVE-2022-37969
Type : Élévation de privilèges locale
TÉLÉCHARGEMENT DE L'ISO VULNÉRABLE : Télécharger ici
L'espace d'adressage de Windows est grossièrement divisé entre l'espace utilisateur (exécution des programmes généraux) et l'espace noyau (exécution du système d'exploitation lui-même et des logiciels de composants matériels --> pilotes). Un utilisateur standard ne doit pas accéder à l'espace noyau, mais il existe des mécanismes par lesquels les programmes utilisateur standard peuvent accéder à certaines parties du code noyau (appels système, procédures de pilotes). Pourquoi avons-nous besoin d'y accéder ? Pour interagir avec l'OS de manière sécurisée et contrôlée, comme prévu par les concepteurs de l'OS.
Certains pilotes utilisent des données d'entrée fournies par l'utilisateur pour opérer sur des structures de données de l'espace noyau. Si l'entrée génère un bogue, le noyau peut être corrompu. Un exemple est le Common Log File System Driver. En utilisant une entrée spéciale, nous pouvons forcer le pilote à modifier les structures de données du noyau qui contiennent le niveau de privilège d'accès de l'utilisateur et remplacer utilisateur standard par administrateur.
Que faut-il modifier pour élever le privilège au niveau administrateur ?
Nous commençons avec l'objectif final en tête. Windows stocke, dans une structure de données du noyau nommée _EPROCESS, des informations pour chaque processus en cours d'exécution sur le système. Exemple de _Eprocess
Un champ important est struct _EX_FAST_REF Token. C'est une autre structure de données qui pointe vers des données référençant le niveau de privilège du processus concerné. Sur l'image suivante, le processus System possède un jeton système et le processus Explorer possède un jeton d'utilisateur standard.

Ainsi, pour élever le privilège de Explorer.exe, il faudrait copier la valeur de _EPROCESS-->Token de System vers _EPROCESS-->Token de Explorer. Nous accomplirons quelque chose de similaire en copiant le jeton de System dans le jeton de notre propre programme et en lançant une invite de commandes depuis le processus élevé (les processus enfants héritent du jeton du processus parent).
Pour mener à bien ces actions, nous avons besoin de mécanismes pour :
Introduction : la nature de Windows au fil des ans : Comme pour la découverte de nouvelles vulnérabilités, Windows a eu besoin de correctifs pour les atténuer. De plus, avec l'émergence de nouvelles technologies, Windows a eu besoin de mises à jour pour rester compétitif. Une exigence cruciale était la rétrocompatibilité avec les versions précédentes. Et parfois, la sécurité était obtenue par l'obscurité. Des structures de données, des définitions de fonctions ont été retirées des documentations, mais la fonctionnalité est restée. Grâce à la rétro-ingénierie, les chercheurs ont pu utiliser ces fonctionnalités à diverses fins.
Pour trouver l'adresse noyau de _EPROCESS, nous utiliserons une fonction non documentée : NtQuerySystemInformation (voir le lien pour les paramètres). En utilisant le paramètre SystemInformationClass, nous pouvons spécifier le type d'informations que nous voulons récupérer. Nous récupérerons les informations générales des processus en spécifiant la valeur SystemExtendedHandleInformation (#define SystemExtendedHandleInformation 0x40).
Une limitation de l'utilisation de NtQuerySystemInformation est que nous ne connaissons pas à l'avance la longueur des données retournées, mais NtQuerySystemInformation dispose d'un mécanisme qui aide. Si elle est appelée avec un tableau de mauvaise taille pour les données requises, elle retourne une ERREUR et la taille correcte des données qui auraient dû être demandées. Cela peut être utilisé pour lire correctement les informations des processus de la manière suivante :
NtQuerySystemInformation avec un paramètre SystemInformationLength facticeReturnLengthNtQuerySystemInformation avec la valeur correcte de SystemInformationLength retournée précédemmentLa structure de données retournée est de type PSYSTEM_HANDLE_INFORMATION_EX. Il s'agit d'une structure de données non documentée (voir le lien) qui mène à une structure de données SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, laquelle contient dans le champ Object l'adresse noyau de la structure de données _Eprocess du processus correspondant.
La logique serait donc d'itérer sur tous les éléments de PSYSTEM_HANDLE_INFORMATION_EX, de comparer le champ UniqueProcessId de SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX avec le PID du processus souhaité et de sélectionner le champ Object correspondant pour trouver son adresse _Eprocess dans le noyau.
Un extrait de code :

NtQuerySystemInformation est déclarée comme un pointeur vers une fonction et son adresse est obtenue dynamiquement à l'exécution via loadlibrary et getprocaddress.
typedef NTSTATUS(WINAPI* ptr_NtQuerySystemInformation)(int, PVOID, ULONG, PULONG);ntdll=LoadLibrary(L"ntdll.dll");MyNtQuerySystemInformation = (ptr_NtQuerySystemInformation)GetProcAddress(ntdll, "NtQuerySystemInformation");Pour lire et écrire le Token, nous devrons nous appuyer sur des vulnérabilités dans clfsw32.sys et les NamedPipes
C'est en quelque sorte une boîte noire, détaillée dans d'autres articles, mais les connaissances minimales pour cette fonctionnalité seront expliquées afin d'acquérir une compréhension de base du processus d'exploitation. Les pipes sont des mécanismes de communication inter-processus. Les processus peuvent s'échanger des informations à l'aide de pipes. Les pipes sont représentées comme des structures de données du noyau dont certains champs peuvent être remplis depuis l'espace utilisateur. Un exemple serait les attributs de pipe (Pipe Attributes).
(Plus tard, nous relierons cela à la vulnérabilité CLSF pour obtenir une lecture/écriture arbitraire depuis l'espace utilisateur dans l'espace noyau)
Pour aller plus loin, veuillez consulter fengshui-spraying-big-kids-pool. Une explication simplifiée du mécanisme serait :
Le noyau dispose de 2 façons d'allouer de la mémoire selon la taille que nous voulons allouer : small-pool pour les objets <4KB+en-tête et big-pool pour les objets >4KB+en-tête . Les pages big-pool sont importantes car elles peuvent être énumérées depuis l'espace utilisateur. Cela signifie qu'un utilisateur standard peut trouver toutes les adresses noyau de départ qui contiennent une page Big-Pool.
Comment ? Chaque page Big-Pool possède un champ nommé Tag (qui peut être utilisé pour obtenir des informations sur le type de données qui y sont stockées). Toutes les pages Big-Pool du système peuvent être énumérées à l'aide de NtQuerySystemInformation avec SystemBigPoolInformation comme valeur pour le paramètre SystemInformationClass. Ensuite, parmi toutes les pages, nous pouvons filtrer par Tag et obtenir l'adresse des pages Big-Pool qui nous intéressent. Par exemple, CLFS utilise des pages Big-Pool avec le tag 'Clfs'. Nous pouvons obtenir l'adresse de toutes les pages du noyau où les objets CLFS sont alloués.

Revenons aux pipes, nous pouvons opérer de la même manière : allouer une pipe suffisamment grande pour utiliser le mécanisme Big-Pool, énumérer les pages big pool, rechercher le Tag spécifique de la pipe et filtrer ces pages.
Ainsi, nous pouvons divulguer dans l'espace utilisateur l'endroit où le noyau a alloué nos pipes. Qu'en est-il du contrôle des données qui vont dans le noyau et de leur lecture ?
Pour cela, nous nous appuyons sur les attributs de pipe (Pipe Attributes). Tout comme le tag Big-Pool, l'attribut de pipe est un tableau qui peut contenir des informations décrivant la pipe (rempli par l'utilisateur). En utilisant la fonction non documentée NtFsControlFile, nous pouvons lire et écrire arbitrairement dans la structure de données des attributs de pipe. Étant non documentée, seule une preuve de concept qui définit un vecteur PipeAttribute puis le lit est fournie. La seule modification autorisée par les primitives de lecture/écriture est le contrôle du contenu des tampons d'entrée/sortie et de leur taille.
Ici, nous allouons une pipe suffisamment grande pour utiliser les pages big-pool (0x2000), et définissons un tampon d'entrée et de sortie à une valeur contrôlée. Attention : les 2 premiers octets de la valeur d'entrée DOIVENT être 0x5a 0x00 pour que cela fonctionne

Ici, nous relisons ce que nous avons écrit précédemment dans le noyau, en modifiant uniquement le tampon de sortie

Et le résultat :

Voici le contenu de la page big-pool de la pipe dans le noyau : En interrogeant les pages big-pool, nous avons trouvé le début de la structure de données de la pipe dans le noyau. À l'adresse pipe_begin+0x20, nous trouvons un pointeur vers notre tampon d'entrée+0x2.

Lorsque nous appelons la fonction de lecture des attributs de pipe, l'OS effectuera les opérations suivantes :
Pourquoi est-ce utile ? Imaginez que nous puissions remplacer le pointeur à pipe_begin+0x20 par l'emplacement du jeton de sécurité du processus System et appeler la lecture des attributs de pipe. Nous divulguerions sa valeur dans l'espace utilisateur. Le remplacement est accompli via la vulnérabilité de CLFS.SYS.
Comprendre cette technique est crucial pour comprendre l'exploit.
La plupart des exploits ne sont pas déterministes mais probabilistes. Même si le code qui exploite la vulnérabilité est correct, l'exploit peut ne pas fonctionner. En plaçant le programme cible dans un état instable, le développeur de l'exploit doit s'assurer qu'après l'exécution de l'exploit, le système ne plante pas. Imaginez un programme qui, une fois exploité, donne la capacité de lire à partir d'une adresse qui dépend d'une valeur variable à l'intérieur d'un programme.
Ex. :
Disons que target=0x1000000+ var_1&0xff+ var_2&0xff00.
Le hacker ne peut pas contrôler target, var_1 ou var_2.
Mais l'exploit donne la capacité de lire à partir de l'adresse target.
Nous pourrions lire n'importe où entre 0x1000000 et 0x100FFFF, ce qui dans certains cas pourrait être utile ou non.
Ex. 2 :
Disons qu'un exploit donne la capacité de lire un QWORD à partir d'une adresse correspondant à un certain motif et de placer le contenu dans sa valeur de jeton de sécurité. Disons que nous avons précédemment obtenu la valeur du jeton de sécurité pour System.exe Comment pouvons-nous utiliser l'exploit pour élever les privilèges ?
read_addr=0x1000000+alfa&0xFFFF00
Nous ne pouvons pas contrôler le paramètre alfa
HORS SUJET MAIS TRÈS IMPORTANT : Le noyau peut accéder à l'espace utilisateur correspondant au processus qui exécute le code noyau à ce moment-là.
À quelle adresse pouvons-nous lire ? Eh bien 0x1000000, 0x1000100 (alfa=1), 0x1000200 (alfa=2), ...., 0x1FFFF00 (alfa=ffff00).
Pour être certain que l'exploit réussisse, le programmeur devrait procéder comme suit :
memory=virtualalloc(dest=0x1000000,size=0x1000000,....)for (i=0x1000000;i<0x2000000;i+=0x100) ((QWORD*)i)[0]=system_token_value;C'est en fait de la pulvérisation mémoire. Une manipulation de la mémoire suivant un motif spécifique qui correspond à toutes les valeurs possibles d'une expression probabiliste résultant d'un exploit.
Une condition pour que cela fonctionne dans notre cas est que la mémoire puisse être allouée à l'adresse 0x1000000. C'est une limitation avec laquelle nous devons également composer dans le cas de l'exploit réel.
Maintenant que certains aspects techniques sont clarifiés, une compréhension de base de CLFS est nécessaire. Lien Microsoft. CLFS est utilisé pour les journaux d'applications, les journaux de bases de données, les transactions, etc.
Les exploits nécessitent généralement une certaine disposition mémoire pour fonctionner. La forme de cette disposition est dictée par les valeurs des variables à l'intérieur du programme au moment précis de l'exploitation.
Ce tutoriel n'expliquera pas en détail le code vulnérable, ni le format du système de fichiers journal. Il offrira une compréhension de base des processus qui génèrent l'exploit.
Les fichiers journaux sont un type spécial de fichiers ayant un certain format et avec lesquels le pilote CLSF et les API dll peuvent interagir. Dans ce système, il existe également le concept de conteneurs de journaux. Les conteneurs de journaux sont aussi des fichiers journaux, mais ils sont liés en mémoire à un fichier journal principal (le fichier journal auquel ils sont ajoutés).
Cette construction fonctionne comme suit :
Cette opération force l'allocation d'un nouvel espace mémoire dans le fichier journal principal et modifie différents objets dans la disposition mémoire du fichier journal principal. La modification de la disposition mémoire ressemblerait conceptuellement à ceci :

Comme pour toute structure de données/fichier important, avant de l'utiliser, le pilote CLFS effectue des contrôles de validité sur le format du fichier journal :
Le deuxième point est le point de départ de la vulnérabilité. L'attaquant est capable de modifier un fichier journal préalablement créé, de recalculer son hash, et de modifier le champ de hash pour faire passer le test d'intégrité au pilote. Les modifications concernent les longueurs des en-têtes de fichier. Un ensemble de valeurs soigneusement conçu permet de réussir un contrôle de plage-longueur qui échouerait sinon et lèverait une erreur. Le pilote accepte donc la fausse longueur comme valide et continue l'exécution du code normalement. La fausse longueur est utilisée pour calculer un offset dans le fichier où une adresse codée en dur sera écrite. Cela donne à l'attaquant la capacité de contrôler l'adresse où l'opération d'écriture précédente aura lieu.
Cette vulnérabilité doit être chaînée avec la lecture-écriture noyau par pipe pour compléter l'exploit. Preuve visuelle :

Dans la figure précédente, nous voyons la fonction AllocSymbol dans CLFS.sys qui est responsable du faux contrôle de plage. Le contrôle de longueur vulnérable est celui qui retournerait le code d'erreur 0xC0000023 (BaseLogRecord + v9 + Size_1 + 0x1338 > v8 + *(0x68)). Son entrée dans la condition est en partie contrôlée par l'attaquant. Il est possible d'injecter une valeur dans la formule qui fait passer le contrôle alors qu'il échouerait normalement. En contrôlant v8 et v9, nous pouvons manipuler la valeur de vérité de la condition pour la mettre à FALSE, empêcher le retour d'erreur et conduire au calcul arbitraire de la valeur de la variable v10, en utilisant la valeur v9 contrôlée par l'attaquant.
V10 est ensuite utilisé pour un memset avec 0 comme valeur à définir. L'attaquant gagne donc la capacité de définir arbitrairement de la mémoire à 0. Ce n'est pas tout. Avant le retour final, *a3=v10. A3 est un paramètre envoyé par adresse à la fonction AllocSymbol. L'appelant d'AllocSymbol reçoit donc la valeur de V10 après le retour d'AllocSymbol.
L'appelant d'AllocSymbol est FindSymbol.

Dans FindSymbol, V33 est le paramètre nommé a3 dans AllocSymbol. v33 reçoit donc la valeur de v10. Ensuite, la valeur à l'adresse v33 est définie à une constante (0xc1fdf006) et préfixée en outre avec la valeur 0x30.

L'attaquant gagne donc la capacité de définir un emplacement mémoire arbitraire avec une constante.
Pourquoi cela serait-il important ? Si nous pouvons écraser la valeur par défaut d'un pointeur de fonction avec une adresse constante accessible à la fois depuis l'espace utilisateur et l'espace noyau, et avoir la garantie que le pointeur en question sera appelé, nous obtenons le contrôle de l'exécution de code. Bien que ce soit l'idée générale, il y a des obstacles techniques qui seront expliqués plus loin.
CClfsContainer* pContainer;. Lorsque le conteneur correspondant est désalloué, dans le fichier parent, des opérations de nettoyage sont effectuées, qui déréférencent pContainer et utilisent les valeurs et comme pointeurs de fonction.Ainsi, la capacité de contrôler pContainer et d’exécuter les API spécifiques au conteneur pour l’ajout et la suppression garantit une redirection du flux de contrôle.
Où se trouve pContainer ?
La structure du fichier CLFS n’est pas documentée, mais quelques tentatives individuelles de rétro-ingénierie de la structure du fichier existent.
Une vue d’ensemble de la structure d’un fichier journal :

Et une vue détaillée du bloc de base :

Différences entre la structure et la vue mémoire :
Les fichiers CLFS sont alloués dans des pages big-pool avec une valeur de tag Clfs. Lorsque l’adresse journal d’un CLFS est obtenue dans l’espace utilisateur (la même méthode que celle utilisée pour obtenir l’adresse de l’espace noyau d’un objet pipe), le noyau retourne L’ADRESSE OÙ COMMENCE LE BLOC DE BASE. (à l’offset 0x800 dans l’image précédente). À l’offset 0xb98, on voit une structure de données nommée regContainers. C’est un tableau de valeurs 32 bits. Chaque valeur est liée à un conteneur et représente l’offset (compté à partir de 0x870) où les structures de données internes d’un conteneur sont situées dans le fichier du bloc de base.
La disposition mémoire d’un fichier conteneur est la suivante :
Structure CLFS_CONTAINER_CONTEXT
À l’offset 0x18 de la structure CLFS_CONTAINER_CONTEXT se trouve notre cible pContainer.
Apparemment, l’offset vers pContainer est constant, c’est-à-dire le premier élément du tableau regContainers. Et sa valeur est 0x1468.
Déréférencer et accéder à pContainer comme pointeur de fonction est le cœur de l’exploit. Cela se produit à l’intérieur de la fonction Remove container dans le pilote CLFS.SYS. L’exploit sera donc déclenché lorsque le conteneur sera supprimé.
Preuve :

Passons en revue les étapes de la suppression d’un conteneur pour montrer qu’en effet le pointeur pContainer est accédé comme un pointeur vers une fonction
GetBaseLogRecord qui retourne l’adresse noyau du bloc de base + 0x70 (il déplace le pointeur de fichier au-delà de l’en-tête)BaseRecord_1) est initialiséea4 est initialisée comme v10LODWORD(a4) = *( (_DWORD*)BaseLogRecord_1 + StartingIndex_1 + 0xCA); S’il y a un seul conteneur ajouté au fichier, alors StartingIndex_1 est zéro. La valeur retournée par GetBaseLogRecord est castée en un vecteur DWORD (32 bits) et indexée par 0xCA éléments. Notez que (_DWORD*)BaseLogRecord_1 + 0 + 0xCA n’ajoute pas simplement la valeur 0xCA à BaseLogRecord_1. À cause du cast, cela équivaut à BaseLogRecord_1[0xCA]. Cela se reflète dans le code désassemblé correspondant, mov eax, [rdi+r12*4+328h] où rdi est la base, r12 est StartingIndex_1 (notez qu’il est multiplié par 4, ce qui correspond à la longueur d’un élément de 32 bits (DWORD)) et additionné avec 0x328, ce qui donne 0xCA*0x4.La valeur de a4 est au final BaseBlock+0x70+0+328=(BaseBlock+0x398), ce qui nous amène à la première valeur de RgConrainers (0x398+0x800=0xB98).
_CLFS_CONTAINER_CONTEXT. Donc v10 est le troisième champ de la structure, qui correspond à pContainer.82 et 83, pContainer est déréférencé, additionné avec 0x18, et 0x8, interprété comme un pointeur vers une fonction et exécuté. (call cs:__guard_dispatch_icall_fptr est en fait un appel à une instruction jmp eax)Cela prouve qu’en réécrivant la valeur de pConainter, nous pouvons modifier le flux de contrôle du pilote et le pointer vers des adresses contrôlées par l’attaquant.
Plus tôt, il a été expliqué que parfois vous devez remplir un morceau de mémoire avec un motif prédéterminé de valeurs afin de garantir le succès de l’exécution d’un exploit. C’est parce que le hacker ne peut contrôler qu’un sous-ensemble des variables qui composent l’état du programme au moment de l’exploitation. Dans l’exemple simple de memory spraying présenté plus tôt, nous n’avons utilisé que des valeurs écrites dans un vecteur pour satisfaire une certaine condition.
Dans le cas réel, les conditions sont plus compliquées. Nous devons organiser les fichiers journaux dans la mémoire du noyau dans un certain ordre avec un offset connu entre eux.
Tout d’abord, créons beaucoup de fichiers journaux dans une boucle et étudions comment le système d’exploitation alloue la mémoire pour eux. L’expérience utilisera le modèle suivant :
Le morceau de code suivant accomplit cela :

Et le résultat :

Ordonnons les adresses :

En inspectant les offsets entre les adresses, un pseudo-modèle concernant les allocations apparaît : Il y a des allocations continues qui ont un offset constant entre elles. Par exemple de ffffd80faf444000 à ffffd80faf4ee000, l’offset entre deux allocations consécutives est 0x11000.
Cette hypothèse est cruciale pour le fonctionnement de l’exploit. Elle sera utilisée.
Une autre hypothèse : Prenons quelques pages qui sont espacées de 0x11000. Par exemple : ffffd80faf444000, ffffd80faf455000, ffffd80faf466000, ffffd80faf477000, ffffd80faf488000, ffffd80faf499000. Toutes les pages correspondent à des fichiers CLFS ouverts. Si nous fermions un fichier, la page serait désallouée et la mémoire à l’adresse respective serait libre.
Cela ressemblerait à ceci en mémoire (disons que nous fermons le fichier correspondant à la page à ffffd80faf466000) :
ffffd80faf444000,ffffd80faf455000,xxxxxxxxxxxxxxxx,ffffd80faf477000,ffffd80faf488000,
SI nous rouvrons le fichier, le système d’exploitation allouera avec un degré élevé de certitude une page à la même adresse (ffffd80faf466000) pour combler le trou et rendre la mémoire continue. --> Ceci est également crucial pour l’exécution de l’exploit.
Nous introduisons maintenant un schéma de la stratégie que nous allons employer pour nous assurer que nous écrasons le pointeur *pConainter avec une adresse que nous contrôlons, dans l’espace utilisateur.


Dans l’image précédente, start(aux1)+0x11000=start(A),start(A)+0x11000=start(B),start(B)+0x11000=start(aux2)
Nous utiliserons Logfile A pour écraser le pointeur *pcontainer de Logfile B, et déclencherons l’exploit en fermant Logfile B à l’aide d’un code spécial de suppression après fermeture.
Ajouter un conteneur journal à Logfile B, afin d’allouer et de mettre à jour les champs qui signalent que B a un fichier conteneur correct. Nous pouvons ajouter aux2 comme conteneur à B.
Fermer LogfileA afin de pouvoir le modifier sur le disque. Il est important de choisir A et B au milieu d’une séquence maximale de fichiers espacés de 0x11000, afin de créer un trou en mémoire que le système d’exploitation priorisera pour le combler. Si ce n’est pas le cas, l’exploit échouera.
Recalculer le hash de A, modifier son champ de hash pour maintenir l’intégrité, puis rouvrir A en espérant que le noyau le place à la même adresse, sinon l’exploit échouera.
Appeler AddLogContainer sur A avec un fichier journal normal pour déclencher l’écrasement du pointeur *pContainer de B.
Supprimer le fichier B afin que RemoveConainter soit appelé et que l’exécution soit transférée au *pContainer de B, qui est maintenant corrompu et pointe vers du code alloué par l’utilisateur.
L’image suivante illustre les étapes mentionnées précédemment.

Nous commençons par allouer 50 fichiers CLFS. Après chaque allocation, nous interrogeons toutes les pages CLFS dans la mémoire du noyau et faisons une liste avec toutes les adresses allouées pour chaque fichier. Ensuite, nous identifions 2 fichiers espacés de 0x11000 l’un de l’autre (A et B dans le schéma). (et les stockons dans les variables first, second)

Après avoir identifié l’adresse, recherchons deux fichiers espacés de 0x11000 A->first , B->second

Ensuite, fermons A (first), modifions-le sur le disque (malformons ses en-têtes) et rouvrons-le.

Cette étape nécessite plus d’explications car nous devons calculer certaines valeurs exactes qui, lors du déclenchement du code exploitable dans AllocSymbol, mèneront à l’écrasement du pointeur *pConainter de B.
À partir de l’analyse précédente des fonctions AllocSymbol et FindSymbol, nous savons que nous devons modifier le fichier A de manière à ce qu’il remplace la valeur logique de la condition IF par FALSE et injecte une valeur dans la variable v9 qui mènera à une écriture à l’emplacement du pointeur *pContainer de B.
D’abord, quelle valeur doit prendre v9 ?
AllocSymbol calcule v10 (adresse cible de l’écriture) comme :
v10=BaseLogRecord + v9 + 0x1338. Ceci est calculé dans le contexte de l’espace d’adressage de A. Donc BaseLogRecord est : l’adresse de la page noyau de A + 0x70 . v9 est contrôlé par l’attaquant et 0x1338 est constant.
Où se trouve le *pContainer de B par rapport à son adresse de page noyau ?
Cela a été détaillé plus tôt, donc à partir de l’adresse de la page noyau de B, nous devons ajouter 0x398 pour atteindre le vecteur regContainter de B, puis indexer par le premier élément pour obtenir l’offset vers la première structure CONTEINER_CONTEXT. Comme indiqué précédemment, pour le premier conteneur, l’index a été déterminé comme étant 0x1468 (compté à partir du BaseBlock de B +0x70 )
Donc l’emplacement du *pContainer de B est adresse_noyau_de_B + 0x70 + 0x1468 + 0x18 (troisième élément de la structure CONTEINER_CONTEXT)
adresse_noyau_de_B=adresse_noyau_de_A+0x11000 (parce que nous avons construit la mémoire de cette façon)
adresse_BaseRecord_de_A=adresse_noyau_de_A+0x70
V10=adresse_noyau_de_A+0x70+v9+0x1338
v10 doit écraser le *pContainer de B
V10 doit être : v10=adresse_noyau_de_B + 0x70 + 0x1468 + 0x18 (troisième élément de la structure CONTEINER_CONTEXT)
En substituant l’adresse noyau de B : v10=adresse_noyau_de_A+0x110000+x70 + 0x1468 + 0x18
En réduisant v10 : adresse_noyau_de_A+0x70+v9+0x1338=adresse_noyau_de_A+0x11000+x70 + 0x1468 + 0x18
En résolvant pour v9 = 0x11000+0x70+0x1468+0x18-0x1338-0x70=0x11148
Maintenant, quels champs dans A doivent être écrasés ? --> Il y a 2 catégories de champs :
IF dans AllocSymbol à être évaluée à FALSELes champs de la catégorie 1. ne seront pas détaillés. V9 correspond au champ à l’offset 0x1b98 sur le disque dans le fichier A. Il n’y aura pas plus de détails sur la raison pour laquelle ces champs déclenchent l’exploit ; il appartient au lecteur de se renseigner à ce sujet, s’il est intéressé.
Voici les valeurs qui doivent être modifiées :

Notez qu’en (little endian), la valeur utilisée est 0x11149 au lieu de 0x11148. C’est parce que nous devons contrôler les octets de poids fort dans *pContainer. L’octet de poids faible sera de la forme X0 (10 ou 20 ou 30...). Nous pouvons faire un memory spray à chacune de ces valeurs pour couvrir cette condition.
Notez que v10 sera également utilisé pour faire un memset avec 00 sur une région mémoire de longueur 0xa0

Puis, à la même adresse, l’écrasement avec la constante

Notez la valeur constante de la forme 0x30c1fdf006X0
Cela se produit lorsque nous ajoutons un conteneur journal au fichier A, mais nous n’avons pas couvert le processus de recalcul du hash après la modification des champs de A.
La vue du fichier journal sur le disque est quelque peu différente de la vue en mémoire. Nous modifions uniquement le contenu du bloc de base, qui a une longueur de 0x7a00 et commence sur le disque à l’offset fichier 0x800. L’algorithme de hachage utilisé pour hacher le bloc de base est CRC32. Le champ qui contient la valeur du hash est également stocké à l’intérieur du bloc de base à l’offset 0x80c.
Procédure pour recalculer le hash :
0x80c0x800 avec une longueur de 0x7a00.Pour que l’exécution du code soit routée vers l’espace utilisateur, nous devons :
*pConainter de B (second) avec une valeur constante de la forme 0x30c1fdf006X0.RemoveContainer.
Il y a certaines conditions imposées à la mémoire utilisateur qui est la cible de la redirection du flux d’exécution via *pContainer. Nous ne pouvons pas simplement commencer à exécuter des instructions depuis l’espace utilisateur alors que le programme est en mode noyau.
Le but de cette section est de fuiter la valeur du SystemToken dans l’espace utilisateur, sans provoquer un crash système (BSOD).
Comment pouvez-vous lire depuis une adresse dans le noyau et stocker le résultat dans l’espace utilisateur ? Un petit rappel sur la section Pipe du tutoriel :

Ici, nous :
L’idée qui relie l’objet Pipe et notre objectif d’obtenir la valeur du jeton système est d’utiliser NtFsControlFile PipeReadAttribute pour lire depuis l’adresse du jeton système au lieu du début du tampon qui contient les informations que nous avons passées au noyau en utilisant PipeWrite Attribute.
Cela signifie que nous devons modifier la valeur du pointeur à l’adresse PIPE_BEGIN+=0x20 pour qu’elle contienne l’adresse à laquelle se trouve le jeton système.
Cette adresse est connue, obtenue dans les étapes précédentes du tutoriel, lorsque nous avons localisé et analysé la structure EPROCESS.
Ici, nous devons trouver un mécanisme qui nous permet d’écrire dans la structure du pipe à un emplacement fixe.
Pour cela, nous utilisons la redirection de code obtenue en exploitant la fonction CLFS AddLogConainer. On pourrait penser qu’il suffit d’écrire un shellcode qui effectue le remplacement brut, mais (bien que cela n’ait pas été testé), cela pourrait certainement ne pas fonctionner. C’est parce que le pilote s’exécute dans le contexte du noyau et exécute du code provenant d’une zone de processus utilisateur.
Pour contourner cette limitation, nous devons trouver des ROP du noyau qui accomplissent une écriture à une valeur arbitraire. C’est-à-dire trouver des morceaux de code noyau qui se trouvent à la fin d’une fonction et se terminent par une instruction ret, et fournir leurs adresses comme cibles de redirection. De cette façon, le code est toujours exécuté par le noyau.
C’est l’idée principale, mais des limitations surgissent de la manière dont le pilote CLFS appelle le code à l’intérieur de *pConainer :

Pour fabriquer un motif mémoire utilisable, nous devons étudier la manière dont la fonction RemoveContiner accède au code de *pContainer.
Dans l’image précédente, nous soulignons les portions de code qui déréférencent *pContainer et l’utilisent comme pointeur de fonction.
RDI est la constante qui a été écrite à l’intérieur de *pcontainer en exploitant AllocSymbol. Comme vous le voyez, la constante n’est pas entièrement fixe en valeur ; elle varie dans le premier octet (la forme est 6X0, X étant n’importe quoi).
mov rax, [rdi] déréférence la valeur constante. Afin de ne pas provoquer une lecture invalide (et un BSOD), nous devons nous assurer que rdi contient un pointeur vers une adresse valide. Pour ce faire, nous devons sprayer la mémoire utilisateur de 0x30C1FDF00000 au moins jusqu’à 0x30C1FDF006FF. Cela se fait en allouant un morceau de mémoire avec VirtualAlloc. Bien sûr, si le système ne peut pas allouer de mémoire pour une raison quelconque, l’exploit échoue.
LPVOID dirtySpray = VirtualAlloc((LPVOID)0x000030c1fdf006d0, 0x1000000, 0x3000, 0x4);
Ensuite, pour chaque valeur possible de X, nous stockons à 0x30C1FDF000X0 à 0x30C1FDF00FX0 une autre valeur qui correspond à une mémoire utilisateur valide, en choisissant une valeur arbitraire, disons 0x5000000. De cette façon, nous nous assurons que rax sera égal à 0x5000000 pour tout rdi de la forme 0x30C1FDF006X0
Après cette étape, nous voyons deux autres opérations de déréférencement : mov rax, [rax+0x18] et mov rax ,[rax+0x8]
Pour garantir que la mémoire est toujours cohérente à ces adresses, nous devons stocker à 0x5000008 et 0x5000018 des pointeurs vers des fonctions du noyau (ROP1 et ROP2, seront calculées plus tard.)
C’est l’algorithme pour générer le motif de spray.

Remarque : Le motif évoluera lorsque nous introduirons les ROP car elles ont aussi des paramètres qui seront pris en compte, mais c’est le minimum jusqu’à présent pour empêcher un adressage mémoire invalide.
Voici à quoi ressemble la première étape de la mémoire sprayée dans le débogueur (observez où la valeur 0x5000000 est placée--> alignée sur X0) :

Voici à quoi ressemble la mémoire à 0x5000000 (les ROP qui seront détaillées)

Et le processus de redirection de code dans le débogueur du noyau :

Notez la valeur du registre RDI et le pointeur d’instruction dans le débogueur. RDI déréférencé une fois donne 0x5000000 et est stocké dans RAX. [RAX+0x18] est la deuxième ROP, et plus bas [RAX+0x8] sera l’adresse de la première ROP.
À ce stade, nous avons les morceaux d’exploit suivants que nous devons relier :
L’objectif à ce stade est de relier la redirection de code à un morceau de code qui écrasera le pointeur du pipe vers le tampon d’attributs avec l’adresse du jeton système, et d’utiliser Pipe read attribute pour récupérer les informations dans l’espace utilisateur.Comme nous l'avons dit, le code qui est redirigé doit également se trouver dans le noyau. De plus, nous n'avons que 2 fonctions à utiliser. Ce tutoriel ne couvrira pas la façon dont ces 2 fonctions spécifiques ont été trouvées, mais il existe vraisemblablement une liste de candidats ROP couramment utilisés.
Nous allons étudier 2 fonctions :
SeSetAccessStateGenericMapping dans ntoskrnl.exe Appelée en second ([rax+0x8])ClfsEarlierLsn dans CLFS.SYS Appelée en premier ([rax+0x18])Analyse de ClfsEarlierLSn :

Le seul rôle de cette fonction, lorsqu'elle est appelée avec des paramètres incorrects, est de définir EDX à 0xFFFFFFFF et de retourner. Pourquoi cela est nécessaire sera clair lors de l'analyse du second ROP.
Analyse de SeSetAccessStateGenericMapping :

Cela doit être discuté ligne par ligne en détail.
Entrées : en entrant dans cette fonction :
0x30C1FDF006X0[RCX+0x48] est de la forme 0x30C1FDF00YX8, toujours aligné sur 0x8, et ne sera pas en conflit avec 0x30C1FDF006X0 qui est aligné sur 0 et contient 0x5000000Exécution du code de la fonction :
mov rax, [rcx+48h] déréférence depuis 0x30C1FDF00YX8 et place la valeur dans RAXmovups xmm0, xmmword ptr [rdx] déplace 16 octets de RDX vers le registre XMM0. Rappelez-vous qu'ici, depuis ClfsEarlierLSn, RDX vaut 0xFFFFFFFFmovdqu xmmword ptr [rax+8], xmm0 déplace vers [RAX+0x8] la valeur de XMMOEssentiellement, cela lit depuis une adresse et stocke dans le noyau. Cela peut être interprété comme suit :

Ainsi, pour écraser le pointeur du tampon d'attributs du pipe avec un pointeur vers le jeton système, nous devons allouer 16 octets à 0xFFFFFFFF et y stocker l'adresse du jeton système. Ensuite, nous devons pulvériser la mémoire à 0x30C1FDF00YX8 de manière répétée avec la valeur de l'adresse de destination -0x8. C'est-à-dire la valeur du décalage du tampon d'attributs du pipe -0x8. Autrement dit PIPE_KERNEL_PAGE_ADDRESS+0x20-0x8.
Voici comment cela est réalisé dans le code réel :

Un autre aspect qui n'a pas été expliqué : comment obtenir les adresses noyau des ROP dans l'espace utilisateur ?
C'est une astuce. Tout d'abord, vous pouvez obtenir l'adresse de base de n'importe quel module noyau en utilisant NtQuerySystemInformation avec les paramètres suivants : NtQuerySystemInformation(SystemModuleInformation, (HANDLE)ModuleInfo, ModuleInfoSize, &retlen)
Ensuite, vous filtrez par la valeur de ModuleInfo->Modules[i].Name pour correspondre à la bibliothèque requise. Cela permet d'obtenir l'adresse de base dans le noyau.
Ensuite, nous exploitons le fait que le décalage entre l'adresse de base et l'adresse de l'export est constant, quel que soit l'espace d'adressage où l'exécutable est chargé.
Nous chargeons le module dans l'espace utilisateur (vous pouvez le faire) en utilisant LoadLibrary et obtenons l'adresse de l'export en utilisant Getprocaddress. Calculez le delta entre l'export et la base utilisateur et ajoutez-le à la base du noyau (trouvée en utilisant NtQuerySystemInformation). Ainsi, nous obtenons dans l'espace utilisateur l'adresse de l'export qui est chargé dans l'espace noyau.
Une fois que le pointeur vers l'attribut a été corrompu et configuré pour pointer vers l'emplacement du jeton système, un appel à MyNtFsControlFile avec les bons paramètres devrait lire depuis l'adresse et divulguer la valeur du jeton système dans l'espace utilisateur.

Ayant la valeur correcte du jeton système, pour terminer l'exploit, nous devons l'écrire à la place de celui de notre propre processus. C'est-à-dire écraser le jeton du processus qui exécute l'exploit avec la valeur du jeton système.
Les étapes nécessaires pour réaliser ce changement n'incluent aucune nouvelle technique ou méthodologie, mais reposent sur la réutilisation du code précédent. L'algorithme pour écraser la valeur de notre propre jeton implique une opération d'écriture dans le noyau à une certaine adresse. Ceci est réalisé par les fonctions ROP SeSetAccessStateGenericMapping et ClfsEarlierLSn. Cela implique que NOUS DEVONS DÉCLENCHER L'EXPLOIT CLFS UNE SECONDE FOIS. C'est exact, nous devons refaire l'allocation du conteneur et la pulvérisation mémoire une seconde fois et espérer que l'OS ne plante pas.
Étapes pour écraser la valeur de notre propre jeton :
Comme nous n'avons pas besoin d'une lecture depuis le noyau la seconde fois, nous n'avons pas besoin du pipe cette fois.
Comme ces étapes ne nécessitent pas de connaissances supplémentaires, le tutoriel peut se terminer ici avec une preuve finale d'un cmd.exe avec nt authority\system

L'objectif principal de ce tutoriel n'était pas les détails internes du CVE lui-même, mais plutôt une description conviviale du processus de création d'un exemple d'élévation de privilèges Windows. De nombreux exploits partagent les mêmes méthodes et briques de base, comme la pulvérisation mémoire ou le travail avec certaines structures de données Windows. Et la plupart du temps, il y a un grand écart entre avoir des connaissances théoriques sur les internals Windows et écrire efficacement un algorithme qui exploite une vulnérabilité.
[[pContainer]+0x18][[pContainer]+0x8]ffffd80faf499000