
Preuve de concept d'exploit pour CVE-2023-21768, une vulnérabilité d'écriture arbitraire dans le noyau du pilote de fonction auxiliaire Windows (AFD.sys) permettant une élévation de privilèges locale via I/O ring.
Selon la description détaillée de CVE-2023-21768 publiée par le Microsoft Security Response Center (MSRC), la vulnérabilité existe dans le Ancillary Function Driver (AFD), dont le nom de fichier système est afd.sys. Le module AFD est le point d'entrée noyau de l'API WinSock. Dans cette analyse, je l'utiliserai pour effectuer une escalade de privilèges sous Windows 11.
Téléchargez deux versions de afd.sys depuis Winbindex, une version proche avant le correctif et une version après le correctif. Utilisez ensuite Bindiff pour comparer ces deux versions.

En comparant globalement les deux versions, on voit qu'une seule fonction est différente : AfdNotifyRemoveIoCompletion. Examinez plus en détail les différences de cette fonction entre les deux versions.

Il n'y a pas trop de différences entre les deux versions. Dans la version post-correctif, des instructions assembleur supplémentaires sont ajoutées pour définir les paramètres et appeler la fonction ProbeForWrite. Selon la documentation de Microsoft, cette fonction sert à vérifier qu'une adresse est bien en mode utilisateur, accessible en écriture et correctement alignée. Analysons ce code plus en détail :
Version pré-correctif : afd.sys version 10.0.22621.608

Version post-correctif : afd.sys version 10.0.22621.1105

Les deux vérifient la valeur de r15_1 ; si elle est égale à 0, la valeur de var_304 est écrite dans le pointeur spécifié à un champ de struct_1. Si différente de 0, ProbeForWrite est appelée pour s'assurer que le pointeur pointe vers une adresse valide. Dans la version pré-correctif, la valeur de var_304 est ensuite écrite dans le pointeur, mais cette vérification manque. Grâce à ce correctif, on peut supposer que nous pouvons appeler ce code avec la valeur de arg3_1->field_18 contrôlée. Si l'on peut définir une adresse noyau dans field_18, on peut écrire var_304 à une adresse mémoire noyau.
=> type de bug : écriture arbitraire dans le noyau (Write-Where)
Maintenant, il faut trouver comment déclencher le bug. La fonction AfdNotifyRemoveIoCompletion est appelée directement dans AfdNotifySock.

De même, en recherchant les références croisées de AfdNotifySock, on voit qu'elle n'est appelée directement par aucune autre fonction, mais son adresse est stockée à une adresse dans .rdata.

Cette adresse se trouve juste avant AfdIrpCallDispatch.

Pour déclencher le bug, j'appellerai DeviceIoControl avec IOCTL_AFD_NOTIFY_SOCK et AfdNotifySock sera appelée.
BOOL DeviceIoControl(
[in] HANDLE hDevice,
[in] DWORD dwIoControlCode,
[in, optional] LPVOID lpInBuffer,
[in] DWORD nInBufferSize,
[out, optional] LPVOID lpOutBuffer,
[in] DWORD nOutBufferSize,
[out, optional] LPDWORD lpBytesReturned,
[in, out, optional] LPOVERLAPPED lpOverlapped
);
Pour chaque pilote, un objet DRIVER_OBJECT est créé dans le noyau, défini comme suit :
typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;
PDEVICE_OBJECT DeviceObject;
ULONG Flags;
PVOID DriverStart;
ULONG DriverSize;
PVOID DriverSection;
PDRIVER_EXTENSION DriverExtension;
UNICODE_STRING DriverName;
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;
PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload;
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;
Le dernier élément MajorFunction est un tableau contenant les fonctions de dispatch du pilote pour gérer les communications entre le noyau et le mode utilisateur. La fonction de dispatch correspondant à l'appel DeviceIoControl est stockée dans MajorFunction[IRP_MJ_DEVICE_CONTROL].
#define IRP_MJ_DEVICE_CONTROL 0x0e
À partir de la fonction DriverEntry de afd.sys, nous pouvons voir que le pilote a créé l'objet de périphérique "\Device\Afd" :

Affecte MajorFunction[IRP_MJ_DEVICE_CONTROL] = AfdDispatchDeviceControl, donc lorsqu'on appelle DeviceIoControl pour communiquer avec le noyau, cette fonction est invoquée.

Dans AFD, il y a deux tables de dispatch : AfdIrpCallDispatch et AfdImmediateCallDispatch.

On voit facilement que AfdDispatchDeviceIoControl calcule un indice via IoControlCode et récupère la valeur correspondante à partir de AfdIoctlTable pour la vérifier avec IoControlCode.

À partir de la distance entre l'adresse de début de AfdImmediateCallDispatch et l'adresse où est stockée AfdNotifySock, on calcule un indice de 73, avec le code de contrôle 0x12127.

int main() {
WSADATA WSAData;
SOCKET s;
SOCKADDR_IN sa;
int ierr;
WSAStartup(0x2, &WSAData);
s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
memset(&sa, 0, sizeof(sa));
sa.sin_port = htons(135);
sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
sa.sin_family = AF_INET;
ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));
char outBuf[100];
DWORD bytesRet;
DWORD inbuf1[100];
memset(inbuf1, 0, sizeof(inbuf1));
DeviceIoControl((HANDLE)s, 0x12127, (LPVOID)inbuf1, 0x30, outBuf, 0, &bytesRet, NULL);
return 0;
}
Ça fonctionne !

Comme dit au début, la vulnérabilité survient lorsque nous pouvons passer un pointeur non validé via une structure. Cette structure est transmise directement depuis le mode utilisateur via lpInBuffer de DeviceIoControl. Elle est ensuite passée à AfdNotifySock en tant que 4ᵉ paramètre, puis à AfdNotifyRemoveIoCompletion en tant que 3ᵉ paramètre.

Comme on ne connaît pas encore la composition de la structure, je laisse IDA la créer automatiquement. Il faut maintenant trouver comment passer des données dans cette structure et contourner les vérifications nécessaires pour atteindre le code vulnérable. Commençons par la fonction AfdNotifySock :

D'abord, la taille de la structure doit être de 0x30 octets.

Les valeurs doivent être non nulles :

En outre, lors du débogage, j'ai vu qu'il revenait en échec à la vérification précédente de UserBuffer, donc lors de l'appel à DeviceIoControl, cette valeur doit être définie à NULL. Après avoir défini les valeurs ci-dessus, j'ai réussi à passer la vérification 2.

Vérification suivante à contourner :

ObReferenceObjectByHandle doit retourner STATUS_SUCCESS pour passer cette vérification. Il faut donc fournir un handle valide. En cherchant, je n'ai trouvé nulle part comment créer un IoCompletionObjectType. J'ai donc suivi l'analyse https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/. J'utilise la fonction NtCreateIoCompletion pour créer un IoCompletionObjectType et passer son handle à ObReferenceObjectByHandle.
Après avoir contourné cette vérification, le flux du programme entre dans une boucle. Dans cette boucle, il n'y a pas de chemin menant à un échec, donc j'ai simplement défini la valeur à dword20 à 0x1 pour sortir de la boucle.

Après être sorti de la boucle, le programme appelle AfdNotifyRemoveIoCompletion. Continuons l'analyse de la fonction AfdNotifyRemoveIoCompletion :

D'abord, le programme vérifie un autre champ de la structure, il doit être non nul. Ensuite, il est multiplié par 0x20, puis utilisé comme paramètre pour appeler ProbeForWrite avec un autre champ de la structure. Ici, il suffit d'utiliser une adresse en mémoire utilisateur accessible en écriture avec dwLen = 1. La dernière vérification avant de pouvoir déclencher le bug est que la valeur retournée par l'appel à IoRemoveCompletion doit être STATUS_SUCCESS. Après quelques recherches, j'ai appris que la fonction NtRemoveIoCompletion, une fois appelée, invoque IoRemoveCompletion. Selon cette documentation, la fonction NtRemoveIoCompletion est un "appel bloquant" qui se termine lorsqu'au moins un enregistrement est terminé dans un Io Completion Object spécifié. L'enregistrement est ajouté lorsque l'opération d'E/S est terminée.
NtRemoveIoCompletion(
IN HANDLE IoCompletionHandle,
OUT PULONG CompletionKey,
OUT PULONG CompletionValue,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER Timeout OPTIONAL );
De plus, il y a un paramètre optionnel Timeout ; lorsque le délai d'attente est atteint, la fonction se termine. Cependant, définir simplement timeout = 0 ne suffit pas pour que la fonction retourne, elle retournera un code d'erreur de timeout. Nous pouvons utiliser la fonction NtSetIoCompletion pour incrémenter le compteur des E/S en attente dans IoCompletionObjectType de 1 et terminer la fonction NtRemoveIoCompletion avant le timeout. Après plusieurs essais, j'ai constaté que la valeur écrite est toujours 0x1.
Avec la possibilité d'écrire la valeur 0x1 à une adresse mémoire noyau, nous pouvons utiliser ce bug pour obtenir une capacité complète de lecture/écriture arbitraire en exploitant l'anneau d'E/S (un nouveau mécanisme d'E/S introduit par Microsoft). Yarden Shafir a écrit une analyse très détaillée de cette méthode, que vous pouvez lire ici. L'une des opérations qu'une application peut effectuer est d'allouer tous les tampons pour ses futures opérations d'E/S, puis de les enregistrer avec l'anneau d'E/S. Les tampons pré-enregistrés sont référencés via l'objet I/O :
typedef struct _IORING_OBJECT
{
USHORT Type;
USHORT Size;
NT_IORING_INFO UserInfo;
PVOID Section;
PNT_IORING_SUBMISSION_QUEUE SubmissionQueue;
PMDL CompletionQueueMdl;
PNT_IORING_COMPLETION_QUEUE CompletionQueue;
ULONG64 ViewSize;
ULONG InSubmit;
ULONG64 CompletionLock;
ULONG64 SubmitCount;
ULONG64 CompletionCount;
ULONG64 CompletionWaitUntil;
KEVENT CompletionEvent;
UCHAR SignalCompletionEvent;
PKEVENT CompletionUserEvent;
ULONG RegBuffersCount;
PVOID RegBuffers;
ULONG RegFilesCount;
PVOID* RegFiles;
} IORING_OBJECT, *PIORING_OBJECT;
Si une vulnérabilité de sécurité, comme celle mentionnée dans cet article, permet de mettre à jour/modifier les champs RegBuffersCount et RegBuffers, il est possible d'utiliser l'API d'anneau d'E/S standard pour lire et écrire dans le noyau. Cependant, l'utilisation de la fonction NtQuerySystemInformation nécessite le privilège Medium IL. Pour une escalade de privilèges depuis Low IL, il faut un moyen de fuiter une adresse noyau.
Une fois que IoRing->RegBuffers pointe vers un faux tampon contrôlé par l'utilisateur, nous pouvons utiliser les opérations d'anneau d'E/S normales pour créer des lectures et écritures à n'importe quelle adresse en spécifiant un index dans le faux tampon comme tampon :
Pour mieux comprendre, vous pouvez lire l'analyse de Yarden Shafir au lien ci-dessus.
Après avoir essayé de créer un objet IO Ring et d'écrire avec le code POC ci-dessus, Windows a planté après avoir appelé DeviceIOControl /_ \ j'ai donc utilisé une méthode d'appel direct aux fonctions Ntfunctions (˘・_・˘) (voir [https://www.x86matthew.com/view_post?id=ntsockets]).
ProbeForWrite