
Remote-Ausnutzung des Kernels über IPv6
Remote-Ausnutzung des Kernels über IPv6
CVE-2024-38063 - Remote-Ausnutzung des Kernels über IPv6 Marcus Hutchins
Seit dem letzten Windows-Patch vom 13. August bin ich tief in den Untiefen von tcpip.sys (dem Kernel-Treiber, der für die Verarbeitung von TCP/IP-Paketen zuständig ist) unterwegs. Eine Schwachstelle mit einem CVSS-Score von 9,8 im am einfachsten erreichbaren Teil des Windows-Kernels konnte ich einfach nicht ignorieren. Ich habe mich noch nie wirklich mit IPv6 beschäftigt (oder mit den Treibern, die es parsen), also wusste ich, dass der Versuch, diese Schwachstelle per Reverse Engineering zu analysieren, extrem herausfordernd sein würde, aber eine gute Lernerfahrung.
Zum größten Teil ist tcpip.sys kaum dokumentiert. Ich konnte ein paar Exploit-Beschreibungen für ältere Bugs finden: hier, hier und hier, aber sonst wenig. Wenn das top Suchergebnis meiner englischen Google-Suche auf Chinesisch ist, weiß ich sofort, dass ich völlig überfordert bin und eine schwere Zeit vor mir habe, aber lernen müssen wir. Obwohl Google Translate eine mittelmäßige Arbeit leistete, lieferte der Beitrag unglaublich detaillierte Einblicke in die IPv6-Fragmentierung und gab mir einen guten Vorsprung.
Später, als ich einige Funktionsnamen googelte, stieß ich auf eine weitere Analyse derselben Schwachstelle von 2021, geschrieben von Axel Souchet (alias 0vercl0k), die noch tiefer in die Interna von tcpip.sys eintauchte und mir genug Informationen lieferte, um mehrere undokumentierte Strukturen zu definieren. Die einfachste Patch-Analyse aller Zeiten
Normalerweise kann selbst das Reverse Engineering des Patches, um herauszufinden, welche Codeänderung der Schwachstelle entspricht, Tage oder sogar Wochen dauern, aber in diesem Fall war es sofort erledigt. Es war tatsächlich so einfach, dass mir mehrere Leute in den sozialen Medien sagten, ich läge falsch und der Bug sei woanders. Habe ich tatsächlich auf sie gehört und dann einen ganzen Tag damit verschwendet, den falschen Treiber zu reversieren? Wir werden es vielleicht nie erfahren.
Es gab genau eine Änderung in der gesamten Treiberdatei, die sich tatsächlich als der Bug herausstellte.
Eine Bindiff-Übersicht von tcpip.sys vor und nach der Installation des Patches.
Nur eine einzige Funktion im gesamten Treiber wurde geändert. Normalerweise könnte ich einen ganzen Tag damit verbringen, über 20 verschiedene Funktionsänderungen durchzugehen, nur um herauszufinden, welche diejenige ist, die ich mir ansehen sollte, aber diesmal nicht.
Ipv6pProcessOptions() vor dem Patch.
Ipv6pProcessOptions() nach dem Patch.
Es wurde nicht nur eine einzige Funktion geändert, sondern eine einzige Codezeile.
Die extrem langnamige Funktion Feature_2660322619__private_IsEnabledDeviceUsage_3() wird von Microsoft manchmal hinzugefügt, um teilweise Patch-Rollbacks zu ermöglichen. Der Aufruf prüft das Vorhandensein eines globalen Flags oder einer Registry-Einstellung, die, falls gesetzt, dazu führt, dass die Funktion false zurückgibt, wodurch der ursprüngliche Code anstelle der gepatchten Version ausgeführt wird.
Der Grund, warum Microsoft dies tut, ist, dass Sicherheitspatches manchmal unbeabsichtigt Dinge kaputt machen. Diese Einstellung ermöglicht es einem Administrator, eine einzelne Schwachstelle zu entpatchen, ohne das gesamte monatliche Patch-Rollup zu deinstallieren und die Systemsicherheit drastisch zu schwächen.
Unter Berücksichtigung dessen ist klar, dass dieser Patch lediglich einen Aufruf von IppSendErrorList() durch IppSendError() ersetzt, was uns einen Hinweis darauf gibt, dass das Problem mit einer Art Liste zusammenhängt. Einfachster Patch-Diff aller Zeiten (oder so dachte ich).
Schwachstellen optional, Ausnutzung obligatorisch
Das Reverse Engineering des Patches, um den geänderten Code zu finden, ist nur die Hälfte der Herausforderung (oder in diesem Fall weniger als 0,1 %). Der Rest des Prozesses besteht darin, genügend von der Codebasis zu reverse engineering, um zu verstehen, was überhaupt vor sich geht, herauszufinden, welche Art von Schwachstelle gepatcht wurde, wie man eine Anfrage formuliert, um den Zielcode zu erreichen, und welcher Zustand zu einer ausnutzbaren Bedingung führt.
Der erste Teil ist einfach genug. Die Änderung befindet sich in Ipv6pProcessOptions(), was uns sagt, dass es sich um IPv6 handelt und Optionen verarbeitet werden. Ein schneller Blick in die RFC verrät uns genau, was eine IPv6-Option ist und wo wir eine finden können.
Das Layout des Destination Options Headers von Wikipedia.
Ok, cool. Was wir suchen, scheint der Destination Options Header zu sein, der direkt nach dem Haupt-IPv6-Header sitzt. Verwenden wir die Python-Bibliothek 'scapy', um ein Test-IPv6-Paket zu basteln.
Hinweis: Um DDoS-Angriffe mit gefälschten IP-Adressen zu mildern, schränkt Windows die Fähigkeit ein, rohe IP-Pakete zu erstellen. Aus diesem Grund habe ich mich entschieden, Linux für die Entwicklung meines Proof-of-Concept zu verwenden. Während Linux Benutzern das Erstellen und Senden von rohen Layer-2- und Layer-3-Paketen erlaubt, erfordert dies, dass das Python-Skript als root ausgeführt wird.
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])
Nachdem ich einen Breakpoint auf tcpip!Ipv6pProcessOptions gesetzt und das Skript ausgeführt hatte, war klar, dass alles, was nötig war, um die verwundbare Funktion zu erreichen, das Senden eines IPv6-Pakets mit einer leeren Optionsstruktur war. Ich versuchte dann, einige ungültige Optionen zur Struktur hinzuzufügen, um zu sehen, ob ich den Aufruf von IppSendErrorList() erreichen konnte.
Eine kurze Code-Überprüfung deutete darauf hin, dass fast jede ungültige Optionsformatierung den Aufruf von IppSendErrorList auslösen könnte. Also entschied ich mich für die Jumbo-Packet-Option mit einer ungültigen Länge (weniger als 65535 Bytes).
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
Was macht IppSendErrorList() eigentlich? Nun, der Code ist ziemlich einfach.
Die gesamte IppSendErrorList-Funktion.
Der Code iteriert durch eine verkettete Liste und ruft IppSendError() für jedes Element in der Liste auf. Wieder einmal haben sich die Sterne günstig gefügt, und die Dinge waren bisher einfach. Wenn IppSendErrorList lediglich IppSendError für jedes Element in einer Liste aufruft und der Patch den Aufruf von IppSendErrorList durch IppSendError ersetzt, dann tritt das Problem auf, wenn IppSendError für ein Listenelement aufgerufen wird, das nicht das erste ist.
Was ist das also für eine Liste, und wie erstellt man eine? Er macht eine Liste, er überprüft sie … 52.567 Mal
Hier wurden die Dinge von offensichtlich zu abnormal schwierig, obwohl ich denke, dass ein großer Teil davon darauf zurückzuführen war, dass eine meiner beiden verfügbaren Gehirnzellen damit beschäftigt war, eine schlimme Covid-Infektion zu bekämpfen. Ich habe ein paar Tage damit verloren, Teile des Codes zu verstehen, einzuschlafen und dann zu vergessen, was ich herausgefunden hatte. Der gesamte Prozess erforderte über eine Woche Reverse Engineering von Teilen von tcpip.sys, um herauszufinden, was los war. Aber Axels Blogbeitrag war extrem hilfreich.
Wenn man sich die Funktionen und Strukturen ansieht, die Axel per Reverse Engineering ermittelt hat, und welche anderen Funktionen sie übergeben werden, wird klar, dass das einzige Argument, das an Ipv6pProcessOptions() übergeben wird, die gleiche packet_t-Struktur aus dem Artikel ist. Im Wesentlichen ist der Zeiger, der an Ipv6pProcessOptions übergeben und von IppSendErrorList iteriert wird, eine verkettete Liste von Paketen.
Also setzte ich einen Breakpoint auf Ipv6pProcessOptions() und inspizierte die Liste.
Der Eintrag list->Next ist NULL.
Jedes Mal, wenn mein Breakpoint ausgelöst wurde, enthielt die Liste nur ein einziges Paket. Ich habe viel länger als mir lieb ist damit verbracht, herauszufinden, warum und wie ich meine Liste zu einer echten Liste machen kann. Mein erster Gedanke war IPv6-Fragmentierung: IPv6 erlaubt es Sendern, große Pakete in separate kleinere Pakete aufzuteilen, was es sinnvoll erscheinen lässt, sie in einer Liste zusammenzuhalten.
Nach umfangreichem Reverse Engineering bestätigte ich meine Annahmen, obwohl die Fragmentliste nichts mit der zu tun hat, mit der wir es hier zu tun haben.
Ich bin eigentlich ganz zufällig auf die Antwort gestoßen. Gelegentlich füllte sich die Liste, aber der Grund war unklar. Nach viel Herumirren wurde mir klar, dass, wenn mein Kernel-Breakpoint ausgelöst wird, der gesamte Kernel angehalten wird, was dazu führt, dass der Netzwerkadapter Pakete ansammelt. Wenn der Kernel fortgesetzt wird, werden diese Pakete an den Stack an tcpip.sys in einer hübschen, ordentlichen Liste weitergegeben. Dies geschah nur, wenn die Pakete gesendet wurden, während der Kernel angehalten war, aber nicht verarbeitet wurden, bevor der nächste Breakpoint ausgelöst wurde.
Dieses Verhalten ist wahrscheinlich eine Leistungsoptimierung: Bei niedrigem Durchsatz verarbeitet der Kernel Pakete einzeln, aber bei höheren Volumina werden Pakete in Listen organisiert und stapelweise verarbeitet. Höchstwahrscheinlich werden Listen nach Faktoren wie Protokoll und Quelladresse getrennt, um die Verarbeitung zu beschleunigen, sodass unsere Liste nur die von uns gesendeten IPv6-Pakete enthalten sollte. Yo dawg, ich habe gehört, du magst DoS
Nachdem wir nun wissen, dass Pakete bei hohem Durchsatz zu Listen zusammengefasst werden, ist klar, was die einfachste Option wäre. Unser DoS-PoC wird ironischerweise DoS verwenden müssen, um die DoS-Bedingung auszulösen. Wenn wir das System mit Bursts von IPv6-Paketen überfluten, sollten wir in der Lage sein, eine schöne große Liste zu erhalten, die an IppSendErrorList() übergeben wird.
Zunächst konnte ich, egal wie viele Pakete ich sendete, die Liste nur dann auf n > 1 bekommen, wenn ich den Kernel anhielt. Aber … da wir Python (schmerzhaft langsam) in einer VM (doppelt schmerzhaft langsam) verwenden, müssen wir wahrscheinlich einige Einstellungen anpassen. Um der VM-Inception auf meinem Angriffssystem entgegenzuwirken, entschied ich mich, die Ziel-VM einfach so zu konfigurieren, dass sie nur einen einzigen CPU-Kern verwendet.
Prima! Die Paketliste ist jetzt eine Liste mit vielen Einträgen!
Es stellt sich also heraus, dass eine VM in einer VM nicht die beste Option für DoS ist – wer hätte das gedacht? Aber wir haben es am Ende geschafft. Jetzt müssen wir nur noch herausfinden, was IppSendError() tut und wo das Problem liegt.
Mehr Reverse Engineering … schon wieder … für immer …
Nach einigem umfangreichen Reverse Engineering wurde viel klarer, was IppSendError tut. Unter normalen Umständen deaktiviert es einfach das Paket, indem es net_buffer_list->Status auf 0xC000021B (STATUS_DATA_NOT_ACCEPTED) setzt. Dann sendet es eine ICMP-Fehlermeldung mit Informationen über das fehlerhafte Paket zurück an den Sender.
Zwei relevante Teile von IppSendError.
Mein erster Ansatz war zu prüfen, ob es in tcpip.sys Funktionen gibt, die den Wert von net_buffer_list->Status ignorieren. Dies würde dazu führen, dass der Treiber Pakete verarbeitet, die sich in undefinierten oder unerwarteten Zuständen befinden, was hoffentlich zu einer Ausnutzungsbedingung führt.
Die Hauptschleife, die für die Verarbeitung von Paketen verantwortlich ist.
Da die Schleife, die für den Aufruf aller Parsing-Funktionen verantwortlich ist, in eine Fehlerprüfung eingebettet ist (was bedeutet, dass wir nirgendwo hinkommen, sobald der Fehlercode gesetzt ist), dachte ich, dass dies das falsche Kaninchenloch ist. Stattdessen beschloss ich, zu IppSendError zurückzukehren und zu prüfen, ob es Codepfade gibt, die den Paketzustand vor dem Setzen des Fehlercodes modifizieren, was zu einer Race Condition führen könnte.
Nach noch viel mehr Reverse Engineering fand ich den folgenden Code ganz unten in IppSendError.
Ein Codepfad in IppSendError, der packet_size auf null setzt.
Wenn IppSendErrorList und damit IppSendError mit dem Argument always_send_icmp auf true aufgerufen wird, scheint es zu versuchen, die ICMP-Fehlermeldung an jedes Paket in der Liste zu senden.
Dann erreicht es aus Gründen, die wahrscheinlich nur Gott bekannt sind, einen Codeblock, in dem das Feld packet->packet_size auf null gesetzt wird.
Um always_send_icmp auf true zu setzen, müssen wir lediglich einen bestimmten Fehler in der Options-Header-Verarbeitung verursachen, indem wir den Wert des 'Option Type' auf eine beliebige Zahl größer als 0x80 setzen.
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)
Aber sollte das Setzen von packet_size auf null den Parser nicht kaputt machen?
Ein Ausschnitt aus der Hauptschleife, die für die Verarbeitung von Paketen verantwortlich ist.
Der Paket-Handler ruft einfach eine VTable-Funktion basierend auf dem Wert von packet->next_header auf, der unverändert bleibt, seit er während des Pre-Parsings gesetzt wurde. Dies ermöglicht es, die Paketverarbeitung fortzusetzen, und gibt uns sogar die Kontrolle darüber, welche Verarbeitung stattfindet.
Da der Wert packet->next_header aus dem Feld 'Next Header' des IPv6-Pakets stammt, können wir ihn auf jeden gültigen IPv6-Header-Wert setzen, und die Schleife ruft den entsprechenden Parser auf. Dies bietet uns eine Menge potenzieller Angriffsfläche.
Das IPv6-Paketformat.
Alles, was noch bleibt, ist, einen erreichbaren Teil des IPv6-Parsers zu finden, der etwas Dummes mit dem Feld packet_size macht.
Zurück zur Fragmentierung
Der erste Ort, den ich mir anzusehen beschloss, war der IPv6-Fragment-Parser, weil dort die alte Schwachstelle CVE-2021-24086 war, also schien es ein guter Ort zu sein, um noch mehr verrückten Code zu finden.
Äh … es ist so nah, aber auch so fern.
Wir haben hier zwar eine Schwachstelle, aber es ist keine RCE.
Im Wesentlichen sind Register auf den meisten CPUs zirkulär. Wenn man ein Register über seinen maximal möglichen Wert hinaus erhöht, springt es zurück auf null. Wenn man es unter seinen niedrigstmöglichen Wert verringert, springt es auf den höchstmöglichen Wert. Dies wird als Integer-Overflow bzw. Integer-Underflow bezeichnet. Dieses Verhalten unterscheidet sich geringfügig für vorzeichenbehaftete Ganzzahlen, aber mit denen haben wir es hier nicht zu tun.
Die erste Zeile, fragment_size = LOWORD(packet->packet_size) - 0x30, besteht aus folgendem ASM-Code:
Der ASM-Code, der die Fragmentgröße berechnet.
AX sind die unteren 16 Bits des EAX-Registers. Obwohl das EAX-Register 32 Bit lang ist, verhält sich AX so, als wäre es ein eigenes 16-Bit-Register, daher bleiben alle Überläufe oder Unterläufe auf AX beschränkt und beeinflussen nicht den Rest des EAX-Registers. Dies ist unglaublich praktisch, da ein Unterlauf des EAX-Registers zu einem Wert von 4 Milliarden führen würde, was zu einem Versuch führen würde, 4 GB Speicher zu allozieren, was höchstwahrscheinlich fehlschlagen würde.
Da der Wert von packet->packet_size null ist, setzt dieser Code ax auf null und subtrahiert dann 0x30 davon.
Unter normalen Bedingungen beträgt der Paket-Header 0x30 Bytes, also ist packet_size - 0x30 die Größe der Fragmentdaten.
In unserem Fall ist packet->packet_size 0, sodass bereits das Subtrahieren von 1 dazu führt, dass das Register auf den maximal möglichen 16-Bit-Ganzzahlwert (0xFFFF) zurückgesetzt wird. Da wir 0x30 subtrahieren, wird der Wert von AX unterlaufen und zu MAX_VALUE - 0x2F oder 0xFFD0, was 65.488 entspricht.
Leider wird dieselbe Berechnung sowohl für die Speicherallokation als auch für das Kopieren von Daten verwendet, sodass wir keinen Pufferüberlauf erhalten. Ich glaube, dass RtlCopyMdlToBuffer() auch eine Bereichsprüfung für den Quellpuffer durchführt, sodass wir nicht einmal einen Out-of-Bounds-Read bekommen. Allerdings gehen wir nicht ganz leer aus.
Da ExAllocatePoolWithTagPriority() den allokierten Speicher nicht nullt und RtlCopyMdlToBuffer() nur die tatsächlich verfügbare Datenmenge kopiert, erhalten wir etwa 65 KB nicht initialisierten Kernel-Speicher. Da Speicheradressen nach der Freigabe wiederverwendet werden, ist der Puffer wahrscheinlich mit dem gefüllt, was zuvor an dieser Adresse gespeichert war. Wenn wir mithilfe von Fragmentierung ein Paket konstruieren können, das an uns zurückgesendet wird, etwa eine ICMP-Echo-Anfrage, könnten wir potenziell zufälligen Kernel-Speicher leaken, was zu einer ASLR-Umgehung führt.
Darüber hinaus setzt der Code auch reassembly->fragment_size auf den unterlaufenen 16-Bit-Integer (65.488), sodass wir jetzt zwei separate Variablen haben, die wir möglicherweise verwenden könnten, um einen Pufferüberlauf zu verursachen.
Geschlagen, aber nicht besiegt
Leider (oder glücklicherweise, da es mir wahrscheinlich viel Zeit erspart hat) kam mir jemand zuvor. Bevor ich einen Ort finden konnte, an dem ich einen der unterlaufenen Integers verwenden konnte, um einen Pufferüberlauf auszulösen, fand @ynwarcs die Antwort und veröffentlichte einen PoC. Dies löst das letzte Stück meines Puzzles.
Die Lösung (oder zumindest eine davon) ist Ipv6pReassemblyTimeout(). Während wir beim anfänglichen Fragment-Handling keinen Überlauf verursachen können, können wir dies offenbar während der Bereinigung tun.
IPv6-Fragmente bleiben im Speicher, bis eine von drei Bedingungen eintritt:
Wir vermasseln unsere Fragmentierung so sehr, dass das System uns sagt, es sei Zeit aufzuhören.
Wir senden ein Fragment mit dem Feld 'More' auf 0, was anzeigt, dass dies das letzte Fragment ist, und das System beginnt mit dem Zusammenbau.
Wir senden das letzte Fragment nicht vor Ablauf der Timeout-Periode (60 Sekunden), und das System verwirft die Fragmente.
Ipv6pReassemblyTimeout() wird unter Bedingung 3 aufgerufen, also schauen wir uns an, wie dies ausgenutzt werden kann.
Das ist genau das, was wir brauchen!
Zuvor war unser Problem, dass der Code dieselbe Berechnung sowohl für die Speicherallokation als auch für den Kopiervorgang verwendete. Dieser Code hingegen tut das nicht. Schauen wir uns das ASM genauer an, um zu sehen, wie es ausnutzbar ist.
Der Assembler-Code, der für die Berechnung der Allokationsgröße verantwortlich ist.
Wie Sie hier sehen können, wird der erste Teil der Berechnung (fragment_list->net_buffer_length + reassembly->packet_length + 8) mit dem 16-Bit-DX-Register durchgeführt.
Wenn Sie sich von früher erinnern, haben wir reassembly->packet_length auf 0xFFD0 unterlaufen lassen. Das DX-Register ist also nach dem Hinzufügen der 8 Bytes 0xFFD8. Wenn fragment_list->net_buffer_length größer als 0x27 (39 Bytes) ist, läuft DX über und wird auf null zurückgesetzt.
fragment_list->net_buffer_length sollte etwa 0x38 Bytes betragen, sodass das DX-Register auf 8 überläuft. Nachdem die 0x28 Bytes hinzugefügt wurden, erhalten wir eine Speicherallokation von nur 48 Bytes.
Da die nachfolgenden memmove()-Aufrufe einfach den unveränderten Wert reassembly->packet_length für die Größe verwenden, führt dies dazu, dass 65.488 Bytes von reassembly->payload in einen 30-Byte-Puffer kopiert werden. Ein toller zusätzlicher Vorteil ist, dass ein Großteil der kopierten Daten aus der Fragment-Nutzlast stammt, die wir kontrollieren und die beliebige Daten in jedem Format sein kann, sodass wir einen schönen, relativ kontrollierbaren Kernel-Pool-basierten Pufferüberlauf erhalten.
Um eine Chance zu haben, die Schwachstelle auszulösen, müssen wir zum Zeitpunkt des Aufrufs von IppSendErrorList ein oder mehrere Fragmentpakete hinter dem fehlerhaften Optionspaket in der verketteten Liste haben. Aus meinen Tests scheint dies jedoch keine Garantie für eine Ausnutzung zu sein. Ich glaube, dass es auch andere Bedingungen gibt, die erfüllt sein müssen. Ich vermute, aber habe nicht bestätigt, dass der Synchronisationscode in IppSendError bedeutet, dass wir auch eine Race Condition gewinnen müssen.