
Exploitation à distance du noyau via IPv6
Exploitation à distance du noyau via IPv6
CVE-2024-38063 - Exploitation à distance du noyau via IPv6 Marcus Hutchins
Depuis la dernière mise à jour Windows publiée le 13 août, je me suis plongé dans les tréfonds de tcpip.sys (le pilote du noyau responsable du traitement des paquets TCP/IP). Une vulnérabilité avec un score CVSS de 9,8 dans la partie la plus facilement accessible du noyau Windows était quelque chose que je ne pouvais tout simplement pas laisser passer. Je n'avais jamais vraiment regardé IPv6 auparavant (ni les pilotes responsables de son analyse), donc je savais que tenter de rétro-ingénier cette vulnérabilité serait extrêmement difficile, mais une bonne expérience d'apprentissage.
Pour la plupart, tcpip.sys est largement non documenté. J'ai pu trouver quelques écrits d'exploitation pour des bugs plus anciens : ici, ici, et ici, mais peu d'autres. Lorsque le premier résultat de recherche pour ma requête Google en anglais est écrit en chinois, je sais immédiatement que je suis largement dépassé et que je vais passer un mauvais moment, mais il faut apprendre. Malgré le travail médiocre de Google Traduction, l'article fournissait un aperçu incroyablement détaillé du fonctionnement de la fragmentation IPv6, et m'a donné une bonne longueur d'avance.
Plus tard, en googlant certains noms de fonctions, je suis tombé sur une autre analyse de la même vulnérabilité de 2021, écrite par Axel Souchet (alias 0vercl0k), qui allait encore plus loin dans les entrailles de tcpip.sys et m'a donné suffisamment d'informations pour définir plusieurs structures non documentées. L'analyse de patch la plus facile de tous les temps
Habituellement, même le simple rétro-ingénierie du patch pour déterminer quelle modification de code correspond à la vulnérabilité peut prendre des jours, voire des semaines, mais dans ce cas, ce fut instantané. C'était si facile en fait que plusieurs personnes sur les réseaux sociaux m'ont dit que je me trompais et que le bug était ailleurs. Ai-je vraiment écouté et perdu une journée entière à analyser le mauvais pilote ? On ne le saura jamais.
Il y avait exactement une modification dans l'ensemble du fichier du pilote, qui s'est avérée être effectivement le bug.
Une vue d'ensemble de bindiff de tcpip.sys avant et après l'installation du patch.
Une seule fonction dans tout le pilote a été modifiée. Typiquement, je pourrais passer une journée entière à parcourir plus de 20 modifications de fonctions différentes juste pour déterminer laquelle je devrais examiner, mais pas cette fois.
Ipv6pProcessOptions() avant le patch.
Ipv6pProcessOptions() après le patch.
Non seulement une seule fonction a été modifiée, mais une seule ligne de code.
La fonction au nom extrêmement long Feature_2660322619__private_IsEnabledDeviceUsage_3() est quelque chose que Microsoft ajoute parfois pour permettre des rollbacks partiels de patch. L'appel vérifie la présence d'un indicateur global, ou d'un paramètre de registre, qui, s'il est défini, fait que la fonction renvoie false, entraînant l'exécution du code d'origine au lieu de la version patchée.
La raison pour laquelle Microsoft fait cela est que les correctifs de sécurité cassent parfois involontairement des choses, donc ce paramètre permet à un administrateur de dépatcher une seule vulnérabilité, sans désinstaller l'ensemble du correctif mensuel et affaiblir considérablement la sécurité du système.
En tenant compte de cela, il est clair que tout ce que ce patch fait est de remplacer un appel à IppSendErrorList() par IppSendError(), nous donnant un indice que le problème concerne une sorte de liste. Le diff de patch le plus facile de tous les temps (du moins je le pensais). Vulnérabilités facultatives, exploitation obligatoire
Rétro-ingénierie du patch pour trouver le code modifié n'est que la moitié du défi (ou dans ce cas moins de 0,1%). Le reste du processus consiste à rétro-ingénier suffisamment de la base de code pour comprendre ce qui se passe, déterminer quel type de vulnérabilité a été patché, comment élaborer une requête pour atteindre le code cible, et quel état résulte en une condition exploitable.
La première partie est assez facile. La modification est dans Ipv6pProcessOptions(), ce qui nous indique qu'il s'agit d'IPv6 et implique le traitement des options. Donc, un rapide appel à la RFC nous dit exactement ce qu'est une option IPv6 et où en trouver une.
Le schéma de l'en-tête des options de destination de Wikipédia.
Ok, cool. Ce que nous cherchons semble être l'en-tête des options de destination, qui se trouve juste après l'en-tête IPv6 principal. Utilisons la bibliothèque Python 'scapy' pour construire un paquet IPv6 de test.
Remarque : Pour atténuer les attaques DDoS utilisant des adresses IP usurpées, Windows restreint la capacité à construire des paquets IP bruts. Pour cette raison, j'ai choisi d'utiliser Linux pour développer ma preuve de concept. Bien que Linux permette aux utilisateurs de construire et d'envoyer des paquets bruts de couche 2 et 3, cela nécessite que le script Python soit exécuté en tant que root.
import sys import struct from scapy.all import *
def send_ipv6_option_packet(dest_ip): ethernet_header = Ether() ip_header = IPv6(dst=dest_ip) options_header = IPv6ExtHdrDestOpt() sendp(ethernet_header / ip_header / options_header)
if len(sys.argv) < 2: print('Use: python3 script.py <target_ipv6_address>') exit(-1)
send_ipv6_option_packet(sys.argv[1])
Après avoir placé un point d'arrêt sur tcpip!Ipv6pProcessOptions, puis exécuté le script, il était clair que tout ce qui était nécessaire pour atteindre la fonction vulnérable était d'envoyer un paquet IPv6 avec une structure d'options vide. J'ai ensuite essayé d'ajouter des options invalides à la structure pour voir si je pouvais atteindre l'appel à IppSendErrorList().
Une brève revue de code a indiqué que presque tout formatage d'option invalide pouvait déclencher l'appel à IppSendErrorList. J'ai donc décidé d'utiliser l'option Jumbo Packet avec une longueur invalide (moins de 65535 octets).
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
Alors, que fait réellement IppSendErrorList() ? Eh bien, le code est assez simple.
La fonction entière IppSendErrorList.
Le code itère une liste chaînée et appelle IppSendError() sur chaque élément de la liste. Encore une fois, les étoiles se sont alignées et les choses ont été faciles jusqu'à présent. Si IppSendErrorList appelle simplement IppSendError pour chaque élément d'une liste, et que le patch remplace l'appel à IppSendErrorList par IppSendError, alors le problème se produit lorsque IppSendError est appelé sur un élément de liste autre que le premier.
Alors, qu'est-ce que cette liste, et comment en fabriquer une ? Il fait une liste, il la vérifie….52 567 fois
C'est là que les choses sont passées d'évidentes à anormalement difficiles, bien que je pense qu'une grande partie était due au fait qu'une de mes deux cellules cérébrales disponibles était occupée à combattre une mauvaise infection au covid. J'ai perdu quelques jours à comprendre des parties du code, à m'endormir, puis à oublier ce que j'avais compris. L'ensemble du processus a nécessité plus d'une semaine de rétro-ingénierie de parties de tcpip.sys pour comprendre ce qui se passait. Mais le blog d'Axel a été extrêmement utile.
En regardant les fonctions et la structure qu'Axel a rétro-ingéniées, et à quelles autres fonctions elles sont passées, il est clair que le seul et unique argument passé à Ipv6pProcessOptions() est la même structure packet_t définie dans l'article. Essentiellement, le pointeur passé à Ipv6pProcessOptions, et itéré par IppSendErrorList, est une liste chaînée de paquets.
J'ai donc placé un point d'arrêt sur Ipv6pProcessOptions() et inspecté la liste.
L'entrée list->Next est NULL.
Chaque fois que mon point d'arrêt était atteint, la liste ne contenait qu'un seul paquet. J'ai passé beaucoup plus de temps que je ne voudrais l'admettre à essayer de comprendre pourquoi et comment faire en sorte que ma liste soit réellement une liste. Ma première pensée a été la fragmentation IPv6 : IPv6 permet aux expéditeurs de diviser des paquets volumineux en paquets plus petits séparés, ce qui aurait du sens de les regrouper dans une liste.
Après une rétro-ingénierie approfondie, j'ai confirmé que mes hypothèses étaient correctes, bien que la liste de fragments ne soit pas liée à celle dont nous traitons ici.
J'ai en fait trouvé la réponse complètement par accident. Parfois, la liste se remplissait, mais la raison n'était pas claire. Après avoir tourné en rond, j'ai réalisé que lorsque mon point d'arrêt du noyau est déclenché, il suspend tout le noyau, ce qui fait que la carte réseau accumule les paquets. Lorsque le noyau reprend, ces paquets sont transmis en bas de la pile à tcpip.sys dans une belle liste bien ordonnée. Cela ne se produisait que si les paquets étaient envoyés pendant que le noyau était suspendu, mais pas traités avant que le prochain point d'arrêt ne soit atteint.
Ce comportement est probablement une optimisation de performance : à faible débit, le noyau traite les paquets individuellement, mais à des volumes plus élevés, les paquets sont organisés en listes et traités par lots. Très probablement, les listes sont séparées en fonction de facteurs tels que le protocole et l'adresse source pour accélérer le traitement, donc notre liste devrait contenir uniquement les paquets IPv6 que nous avons envoyés. Yo dawg, j'ai entendu que tu aimais le DoS
Maintenant que nous savons que les paquets sont fusionnés en listes lors d'un débit élevé, il est clair quelle serait l'option la plus simple. Notre PoC de DoS va ironiquement devoir utiliser un DoS pour déclencher la condition de DoS. Si nous inondons le système avec des rafales de paquets IPv6, nous devrions pouvoir obtenir une belle grande liste passée à IppSendErrorList().
Au début, peu importe combien de paquets j'envoyais, je ne pouvais toujours obtenir une liste n > 1 qu'en suspendant le noyau. Mais… puisque nous utilisons Python (péniblement lent), dans une VM (doublement péniblement lent), nous allons probablement devoir ajuster certains paramètres. Afin de contrer la mise en abyme de VM qui se produisait sur mon système d'attaque, j'ai décidé de simplement reconfigurer la VM cible pour utiliser un seul cœur de CPU.
Super ! La liste de paquets est maintenant une liste contenant beaucoup d'entrées !
Donc, il s'avère qu'une VM dans une VM n'est pas la meilleure option pour un DoS, qui l'eût cru ? Mais nous avons fini par y arriver. Maintenant, il faut juste déterminer ce que fait IppSendError() et où se situe le problème. Plus de rétro-ingénierie… encore… pour toujours…
Après une rétro-ingénierie extensive, il est devenu beaucoup plus clair ce que fait IppSendError. Dans des circonstances normales, il désactive simplement le paquet en définissant net_buffer_list->Status à 0xC000021B (STATUS_DATA_NOT_ACCEPTED). Ensuite, il transmet une erreur ICMP contenant des informations sur le paquet erroné à l'expéditeur.
Deux parties pertinentes de IppSendError.
Mon premier réflexe a été de voir s'il y avait des fonctions dans tcpip.sys qui ignorent la valeur de net_buffer_list->Status. Cela entraînerait le traitement par le pilote de paquets dans des états indéfinis ou inattendus, espérant conduire à une condition d'exploitation.
La boucle principale responsable du traitement des paquets.
Puisque la boucle responsable d'appeler toutes les fonctions d'analyse est encapsulée dans une vérification d'erreur (ce qui signifie que nous ne pouvons aller nulle part une fois le code d'erreur défini), j'ai pensé que ce n'était pas la bonne voie à suivre. Au lieu de cela, j'ai décidé de revenir à IppSendError et de voir s'il existe des chemins de code qui modifient l'état du paquet avant de définir le code d'erreur, ce qui pourrait conduire à une condition de concurrence.
Après beaucoup plus de rétro-ingénierie, j'ai trouvé le code suivant près du bas de IppSendError.
Un chemin de code dans IppSendError qui met packet_size à zéro.
Lorsque IppSendErrorList, et donc IppSendError, est appelé avec l'argument always_send_icmp défini à vrai, il semble tenter d'envoyer l'erreur ICMP à chaque paquet de la liste.
Ensuite, pour des raisons probablement connues de Dieu seul, il atteint un bloc de code où le champ packet->packet_size est mis à zéro.
Pour définir always_send_icmp à vrai, tout ce dont nous avons besoin est de provoquer une erreur spécifique dans le traitement de l'en-tête d'options en définissant la valeur 'Option Type' à n'importe quel nombre supérieur à 0x80.
def build_malicious_option(next_header, header_length, option_type, option_length): dest_options_header = 60
options_header = struct.pack('BBBB', next_header, header_length, option_type, option_length) + b'1337'
return Ether(dst=mac_addr) / IPv6(dst=ip_addr, nh=dest_options_header) / raw(options_header)
packet = build_malicious_option(next_header=59, header_length=0, option_type=0x81, option_length=0) sendp(packet)
Mais, mettre packet_size à zéro ne devrait-il pas casser l'analyseur ?
Un extrait de la boucle principale responsable du traitement des paquets.
Le gestionnaire de paquets appelle simplement une fonction de la VTable basée sur la valeur packet->next_header, qui reste inchangée depuis son réglage lors du pré-analyse. cela permet au traitement des paquets de continuer et nous donne même le contrôle sur le traitement qui a lieu.
Parce que la valeur packet->next_header est obtenue à partir du champ 'Next Header' du paquet IPv6, nous pouvons la définir à n'importe quelle valeur valide d'en-tête IPv6, et la boucle appellera l'analyseur correspondant. Cela nous donne beaucoup de surface d'attaque potentielle.
Le format du paquet IPv6.
Il ne reste plus qu'à trouver une partie accessible de l'analyseur IPv6 qui fait quelque chose de stupide avec le champ packet_size. Retour à la fragmentation
Le premier endroit où j'ai décidé de regarder était l'analyseur de fragments IPv6, car c'est là que se trouvait l'ancienne vulnérabilité CVE-2021-24086, cela semblait donc un bon endroit pour trouver plus de code loufoque.
Eh... c'est tellement proche, mais aussi tellement loin.
Nous avons bien une vulnérabilité ici, mais ce n'est pas une RCE.
Essentiellement, sur la plupart des CPU, les registres sont circulaires. Si vous incrémentez un registre au-delà de sa valeur maximale possible, il boucle à zéro. De même, si vous le décrémentez en dessous de sa valeur minimale possible, il boucle à la valeur maximale possible. Ce sont ce qu'on appelle respectivement des dépassements d'entier et des sous-dépassements d'entier. Ce comportement est légèrement différent pour les entiers signés, mais nous n'en traitons pas ici.
La première ligne, fragment_size = LOWORD(packet->packet_size) - 0x30, consiste en le code ASM suivant :
Le code ASM calculant la taille du fragment.
AX est les 16 bits de poids faible du registre EAX. Bien que le registre EAX soit de 32 bits, AX se comporte comme son propre registre 16 bits, donc tous les dépassements ou sous-dépassements sont confinés à AX et n'affectent pas le reste du registre EAX. C'est incroyablement pratique car un sous-dépassement sur le registre EAX résulterait en une valeur de 4 milliards, ce qui entraînerait une tentative d'allocation de 4 Go de mémoire, ce qui échouerait probablement.
Puisque la valeur de packet->packet_size est zéro, ce code met ax à zéro, puis soustrait 0x30 de celui-ci.
Dans des conditions normales, l'en-tête du paquet fait 0x30 octets, donc packet_size - 0x30 est la taille des données de fragment.
Dans notre cas, packet->packet_size vaut 0, donc soustraire même 1 fera boucler le registre à la valeur maximale possible d'un entier 16 bits (0xFFFF). Comme nous soustrayons 0x30, la valeur de AX va sous-dépasser et devenir MAX_VALUE - 0x2F, soit 0xFFD0, soit 65 488.
Malheureusement, comme le même calcul est utilisé à la fois pour l'allocation mémoire et la copie de données, nous n'obtenons pas de débordement de tampon. Je pense que RtlCopyMdlToBuffer() effectue également une vérification des limites sur le tampon source, donc nous n'avons même pas de lecture hors limites. Cependant, nous ne repartons pas complètement les mains vides.
Parce que ExAllocatePoolWithTagPriority() ne met pas à zéro la mémoire allouée, et que RtlCopyMdlToBuffer() ne copie que la quantité réelle de données disponibles, nous obtenons environ 65 ko de mémoire noyau non initialisée. Comme les adresses mémoire sont recyclées après désallocation, le tampon est susceptible de contenir ce qui se trouvait auparavant à cette adresse avant la réallocation. Si nous pouvons utiliser la fragmentation pour construire un paquet qui nous est renvoyé, comme une requête ICMP Echo, nous pourrions potentiellement fuiter de la mémoire noyau aléatoire, conduisant à un contournement d'ASLR.
En plus de cela, le code définit également reassembly->fragment_size sur l'entier 16 bits sous-dépassé (65 488), nous avons donc maintenant deux variables distinctes que nous pourrions potentiellement utiliser pour provoquer un débordement de tampon. Battu, mais pas vaincu
Malheureusement (ou heureusement, car cela m'a probablement fait gagner beaucoup de temps), quelqu'un m'a devancé. Avant que je puisse trouver un endroit où utiliser l'un des entiers sous-dépassés pour déclencher un débordement de tampon, @ynwarcs a trouvé la réponse et publié un PoC. Cela résout la dernière pièce de mon puzzle.
La solution (ou du moins l'une d'elles) est Ipv6pReassemblyTimeout(). Alors que nous ne pouvons pas provoquer de débordement dans le traitement initial des fragments, nous pouvons apparemment le faire lors du nettoyage.
Les fragments IPv6 restent en mémoire jusqu'à ce que l'une des trois conditions se produise :
Nous gâchons suffisamment notre fragmentation pour que le système nous dise d'arrêter.
Nous envoyons un fragment avec le champ 'More' défini à 0, ce qui indique qu'il s'agit du dernier fragment, et le système commence le réassemblage.
Nous n'envoyons pas le dernier fragment avant l'expiration du délai (60 secondes), et le système supprime les fragments.
Ipv6pReassemblyTimeout() est appelée dans la condition 3, examinons donc comment cela peut être exploité.
C'est exactement ce dont nous avons besoin !
Auparavant, notre problème était que le code utilisait le même calcul exact pour l'allocation mémoire et l'opération de copie. Ce code, en revanche, ne le fait pas. Regardons de plus près l'ASM pour voir comment il est exploitable.
Le code assembleur responsable du calcul de la taille d'allocation.
Comme vous pouvez le voir ici, la première partie du calcul (fragment_list->net_buffer_length + reassembly->packet_length + 8) est effectuée en utilisant le registre 16 bits DX.
Si vous vous souvenez plus tôt, nous avons sous-dépassé reassembly->packet_length pour qu'il soit 0xFFD0. Donc le registre DX, après avoir ajouté les 8 octets, est 0xFFD8. Si fragment_list->net_buffer_length est supérieur à 0x27 (39 octets), DX va déborder et se remettre à zéro.
fragment_list->net_buffer_length devrait être d'environ 0x38 octets, donc cela entraînera un débordement du registre DX à 8. Après avoir ajouté les 0x28 octets, nous obtiendrons une allocation mémoire de seulement 48 octets.
Puisque les appels memmove() suivants utilisent simplement la valeur non modifiée de reassembly->packet_length pour la taille, cela entraînera la copie de 65 488 octets de reassembly->payload vers un tampon de 30 octets. Un excellent bonus est qu'une grande partie des données copiées proviennent de la charge utile du fragment, que nous contrôlons, et peut être des données arbitraires de tout format, nous obtenons donc un joli débordement de tampon basé sur le pool du noyau, assez contrôlable.
Pour avoir une chance de déclencher la vulnérabilité, nous devons avoir un ou plusieurs paquets fragments situés après le paquet d'options malformé dans la liste chaînée au moment où IppSendErrorList est appelé. Cependant, d'après mes tests, cela ne semble pas garantir l'exploitation. Je pense qu'il existe également d'autres conditions qui doivent être remplies. Je soupçonne, sans l'avoir confirmé, que le code de synchronisation dans IppSendError signifie que nous devons également gagner une condition de concurrence.