
CVE-2024-38063 - Remote-Ausnutzung des Kernels über IPv6
Es gab genau eine Änderung in der gesamten Treiberdatei, und wie sich herausstellte, war das tatsächlich der Fehler.
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 die richtige ist – 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 Feature_2660322619__private_IsEnabledDeviceUsage_3()-Funktion 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, wenn gesetzt, dazu führt, dass die Funktion false zurückgibt, sodass der ursprüngliche Code anstelle der gepatchten Version ausgeführt wird.
Der Grund für diese Vorgehensweise von Microsoft ist, dass Sicherheitspatches manchmal unbeabsichtigt etwas 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 – ein Hinweis darauf, dass das Problem mit einer Art Liste zusammenhängt. Einfachster Patch-Diff aller Zeiten (oder so dachte ich)
Den Patch per Reverse Engineering zu analysieren, um den geänderten Code zu finden, ist nur die halbe Miete (oder in diesem Fall weniger als 0,1 %). Der Rest des Prozesses besteht darin, genügend Teile der Codebasis per Reverse Engineering zu verstehen, um zu begreifen, was überhaupt passiert, herauszufinden, welche Art von Schwachstelle gepatcht wurde, wie man eine Anfrage erstellt, um den Zielcode zu erreichen, und welcher Zustand zu einer ausnutzbaren Situation führt.
Der erste Teil ist einfach genug. Die Änderung befindet sich in Ipv6pProcessOptions(), was uns sagt, dass es sich um IPv6 handelt und die Verarbeitung von Optionen betrifft. Ein schneller Blick in den RFC verrät uns genau, was eine IPv6-Option ist und wo wir eine finden.
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 erstellen.
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. Obwohl Linux es Benutzern erlaubt, rohe Layer-2- und Layer-3-Pakete zu erstellen und zu senden, muss das Python-Skript als root ausgeführt werden.
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])
tcpip!Ipv6pProcessOptions gesetzt und dann das Skript ausgeführt hatte, war klar, dass alles, was nötig war, um die anfällige Funktion zu erreichen, das Senden eines IPv6-Pakets mit einer leeren Optionsstruktur war. Dann habe ich versucht, dem Paket einige ungültige Optionen hinzuzufügen, um zu sehen, ob ich den Aufruf von IppSendErrorList() erreichen kann.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)])
Also, was macht IppSendErrorList() eigentlich? Nun, der Code ist ziemlich einfach.
Die gesamte IppSendErrorList-Funktion.
Der Code iteriert eine verkettete Liste und ruft IppSendError() für jedes Element in der Liste auf. Wieder haben sich die Sterne günstig gefügt und die Dinge waren bisher einfach. Wenn IppSendErrorList nur IppSendError für jedes Element 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.
Hier wurde es von offensichtlich zu außergewöhnlich 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 verbracht, Teile des Codes zu verstehen, dann 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 vor sich geht. Aber Axels Blogbeitrag war extrem hilfreich.
Wenn man sich die Funktionen und Strukturen ansieht, die Axel per Reverse Engineering ermittelt hat, und an welche anderen Funktionen sie übergeben werden, wird klar, dass das einzige Argument, das an Ipv6pProcessOptions() übergeben wird, dieselbe packet_t-Struktur ist, die im Artikel definiert 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 untersuchte die Liste.

Der list->Next-Eintrag ist NULL.
Jedes Mal, wenn mein Breakpoint ausgelöst wurde, enthielt die Liste nur ein Paket. Ich habe viel länger als zugegebenermaßen angemessen damit verbracht, herauszufinden, warum und wie ich meine Liste dazu bringen kann, tatsächlich eine Liste zu sein. Mein erster Gedanke war IPv6-Fragmentierung: IPv6 erlaubt es Sendern, große Pakete in separate kleinere Pakete aufzuteilen, die es sinnvoll erscheinen ließe, in einer Liste zusammenzuhalten.
Nach umfangreichem Reverse Engineering bestätigte ich meine Annahmen, obwohl die Fragmentierungsliste nichts mit der Liste zu tun hat, mit der wir es hier zu tun haben.
Die Antwort fand ich tatsächlich völlig zufällig. Gelegentlich füllte sich die Liste, aber der Grund war unklar. Nach vielem Im-Kreis-Laufen wurde mir klar, dass das Anhalten des gesamten Kernels durch meinen Kernel-Breakpoint dazu führt, dass der Netzwerkadapter Pakete anhäuft. Wenn der Kernel fortgesetzt wird, werden diese Pakete in einer schönen, sauberen Liste an tcpip.sys weitergegeben. Dies geschah nur, wenn die Pakete gesendet wurden, während der Kernel angehalten war, aber nicht verarbeitet wurden, bevor der nächste Breakpoint erreicht wurde.
Dieses Verhalten ist wahrscheinlich eine Leistungsoptimierung: Bei niedrigem Durchsatz verarbeitet der Kernel Pakete einzeln, bei höheren Volumina werden Pakete jedoch 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 IPv6-Pakete enthalten sollte, die wir gesendet haben.
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 nutzen 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 bringen, wenn ich den Kernel anhielt. Aber … da wir Python (schmerzhaft langsam) in einer VM (doppelt schmerzhaft langsam) verwenden, müssen wir wahrscheinlich einige Einstellungen optimieren. Um die VM-in-VM-Situation auf meinem Angriffssystem zu kompensieren, habe ich mich entschieden, die Ziel-VM einfach so zu konfigurieren, dass sie nur einen einzelnen CPU-Kern verwendet.

Prima! Die Paketliste ist jetzt eine Liste mit vielen Einträgen!
IppSendError() macht und in welchem Teil das Problem liegt.Nach einigem umfangreichen Reverse Engineering wurde viel klarer, was IppSendError tut. Unter normalen Umständen deaktiviert es lediglich 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 an den Absender zurück.
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 in undefinierten oder unerwarteten Zuständen verarbeitet, was hoffentlich zu einer ausnutzbaren Situation führt.

Die Hauptschleife, die für die Paketverarbeitung zuständig ist.
Da die Schleife, die für das Aufrufen aller Parsing-Funktionen zuständig 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 entschied ich mich, zu IppSendError zurückzukehren und zu prüfen, ob es Codepfade gibt, die den Paketzustand vor dem Setzen des Fehlercodes ändern, was zu einer Race Condition führen könnte.
Nach viel mehr Reverse Engineering fand ich den folgenden Code ganz am Ende von 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 vermutlich 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 bei der Verarbeitung des Options-Headers auslösen, indem wir den 'Option Type'-Wert 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 zerstören?

Ein Ausschnitt aus der Hauptschleife, die für die Paketverarbeitung zuständig 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 Vorparsens gesetzt wurde. Dies ermöglicht es, die Paketverarbeitung fortzusetzen und gibt uns sogar Kontrolle darüber, welche Verarbeitung stattfindet.
Da der Wert von 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 wird den entsprechenden Parser aufrufen. Dies bietet uns eine Menge potenzielle Angriffsfläche.

Das IPv6-Paketformat.
Alles, was noch zu tun bleibt, ist, einen erreichbaren Teil des IPv6-Parsers zu finden, der etwas Dummes mit dem packet_size-Feld anstellt.

Äh … so nah, aber auch so fern
Wir haben hier zwar eine Schwachstelle, aber keine RCE.
Im Wesentlichen sind Register auf den meisten CPUs zirkulär. Wenn man ein Register über seinen maximal möglichen Wert erhöht, springt es zurück auf null. Wenn man es unter seinen niedrigsten möglichen Wert verringert, springt es auf den höchstmöglichen Wert. Dies wird als Integer-Overflow bzw. Integer-Underflow bezeichnet. Dieses Verhalten ist bei vorzeichenbehafteten Ganzzahlen etwas anders, aber damit haben wir es hier nicht zu tun.
Die erste Zeile, fragment_size = LOWORD(packet->packet_size) - 0x30, besteht aus dem folgenden ASM-Code:

Der ASM-Code zur Berechnung der Fragmentgröße.
AX sind die unteren 16 Bits des EAX-Registers. Obwohl das EAX-Register 32 Bits breit ist, verhält sich AX, als wäre es ein eigenes 16-Bit-Register, daher werden alle Overflows oder Underflows auf AX beschränkt und wirken sich nicht auf den Rest des EAX-Registers aus. Das ist unglaublich praktisch, da ein Underflow auf dem EAX-Register zu einem Wert von 4 Milliarden führen würde, was zu einem Versuch führen würde, 4 GB Arbeitsspeicher zu allozieren, was wahrscheinlich 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 ist der Paket-Header 0x30 Bytes lang, also ist packet_size - 0x30 die Größe der Fragmentdaten.
In unserem Fall ist packet->packet_size 0, sodass das Subtrahieren von sogar 1 dazu führt, dass das Register auf den maximal möglichen 16-Bit-Integer-Wert (0xFFFF) zurückgesetzt wird. Da wir 0x30 subtrahieren, wird der Wert von AX unterlaufen und zu MAX_VALUE - 0x2F oder 0xFFD0, was 65.488 ist.
Leider, da dieselbe Berechnung sowohl für die Speicherallokation als auch für das Kopieren von Daten verwendet wird, erhalten wir keinen Buffer-Overflow. Ich glaube, dass auch eine Bereichsprüfung für den Quellpuffer durchführt, sodass wir nicht einmal einen Out-of-Bounds-Read erhalten. Dennoch gehen wir nicht mit leeren Händen aus.
IPv6-Fragmente bleiben im Speicher, bis einer von drei Zuständen eintritt:
Ipv6pReassemblyTimeout() wird unter Bedingung 3 aufgerufen, also lasst uns untersuchen, wie dies ausgenutzt werden kann.

Das ist genau das, was wir brauchen!

Der Assembler-Code, der für die Berechnung der Allokationsgröße verantwortlich ist.
Wie Sie hier sehen, 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 an früher erinnern: Wir haben reassembly->packet_length auf 0xFFD0 underflowen lassen. Das DX-Register beträgt nach dem Hinzufügen der 8 Bytes also 0xFFD8. Wenn fragment_list->net_buffer_length größer als 0x27 (39 Bytes) ist, läuft DX über und setzt auf null zurück.
fragment_list->net_buffer_length sollte etwa 0x38 Bytes betragen, was zu einem Überlauf des DX-Registers auf 8 führt. Nachdem die 0x28 Bytes hinzugefügt wurden, erhalten wir eine Speicherallokation von nur 48 Bytes.
Da die nachfolgenden memmove()-Aufrufe nur den unveränderten reassembly->packet_length-Wert 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 großartiger zusätzlicher Bonus 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 netten, einigermaßen kontrollierbaren Kernel-Pool-basierten Buffer-Overflow erhalten.
Um eine Chance zu haben, die Schwachstelle auszulösen, müssen wir ein oder mehrere Fragmentpakete haben, die sich zum Zeitpunkt des Aufrufs von IppSendErrorList in der verketteten Liste hinter dem fehlerhaften Optionspaket befinden. Aus meinen Tests geht jedoch hervor, dass dies nicht die Ausnutzung garantiert. Ich vermute, aber habe nicht bestätigt, dass der Synchronisierungscode in IppSendError bedeutet, dass wir auch eine Race Condition gewinnen müssen.
RtlCopyMdlToBuffer()Da ExAllocatePoolWithTagPriority() den allozierten 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 der Adresse vor der Neuzuweisung gespeichert war. Wenn wir mithilfe von Fragmentierung ein Paket konstruieren können, das an uns zurückgesendet wird, wie z. B. 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 nutzen könnten, um einen Buffer-Overflow zu verursachen.
Die Lösung (oder zumindest eine davon) ist Ipv6pReassemblyTimeout(). Während wir im anfänglichen Fragment-Handling keinen Overflow verursachen können, können wir dies offenbar während der Bereinigung tun.