
Proof-of-Concept-Exploit für CVE-2023-21768, eine Schwachstelle im Windows Ancillary Function Driver (AFD.sys), die einen beliebigen Kernel-Schreibvorgang ermöglicht und eine lokale Privilegienausweitung über den I/O-Ring erlaubt.
Laut der detaillierten Beschreibung von CVE-2023-21768, veröffentlicht vom Microsoft Security Response Center (MSRC), existiert die Schwachstelle im Ancillary Function Driver (AFD), dessen Dateiname im System afd.sys ist. Das AFD-Modul ist der Kernel-Einstiegspunkt der WinSock API. In dieser Analyse werde ich es verwenden, um eine Privilegieneskalation unter Windows 11 auszunutzen.
Laden Sie zwei Versionen von afd.sys von Winbindex herunter, eine Version kurz vor dem Patch und eine Version nach dem Patch. Verwenden Sie dann Bindiff, um diese beiden Versionen zu vergleichen.

Ein Vergleich der beiden Versionen zeigt, dass nur eine Funktion einen Unterschied aufweist: AfdNotifyRemoveIoCompletion. Sehen Sie sich die Unterschiede dieser Funktion zwischen den beiden Versionen genauer an.

Es gibt nicht allzu viele Unterschiede zwischen den beiden Versionen. In der Post-Patch-Version wurden zusätzliche Assembly-Anweisungen hinzugefügt, um die Parameter zu setzen und die Funktion ProbeForWrite aufzurufen. Laut Dokumentation von Microsoft wird diese Funktion verwendet, um zu überprüfen, ob eine Adresse tatsächlich zum User-Mode gehört, Schreibzugriff hat und korrekt ausgerichtet ist. Detailliertere Analyse dieses Codes:
afd.sys version 10.0.22621.608

-post-patch afd.sys version 10.0.22621.1105

Beide überprüfen den Wert von r15_1: Wenn er 0 ist, wird der Wert von var_304 in den Zeiger geschrieben, der in einem Feld von struct_1 angegeben ist. Wenn er nicht 0 ist, wird ProbeForWrite aufgerufen, um sicherzustellen, dass der Zeiger auf eine gültige Adresse zeigt. In der pre-patch-Version wird der Wert von var_304 danach in den Zeiger geschrieben – diese Überprüfung fehlt. Anhand dieses Patches können wir vermuten, dass wir diesen Codeabschnitt mit einem kontrollierten Wert von arg3_1->field_18 aufrufen können. Wenn wir eine Kernel-Adresse in field_18 setzen können, können wir var_304 in diesen Kernel-Speicherbereich schreiben.
=> Fehlertyp: beliebiges Kernel-Write-Where
Jetzt müssen wir einen Weg finden, den Bug auszulösen. Die Funktion AfdNotifyRemoveIoCompletion wird direkt in der Funktion AfdNotifySock aufgerufen.

Ebenso sehen wir bei der Suche nach Cross-Referenzen von AfdNotifySock, dass es nicht direkt von einer anderen Funktion aufgerufen wird, aber die Funktionsadresse wird an einer Adresse in .rdata gespeichert.

Diese Adresse liegt direkt vor AfdIrpCallDispatch.

Um den Bug auszulösen, werde ich DeviceIoControl mit IOCTL_AFD_NOTIFY_SOCK aufrufen, und dann wird AfdNotifySock aufgerufen.
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
);
Für jeden Treiber wird im Kernel ein DRIVER_OBJECT-Objekt erstellt, das wie folgt definiert ist:
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;
Das letzte Element MajorFunction ist ein Array, das die Dispatch-Funktionen des Treibers zur Verarbeitung der Kommunikation zwischen Kernel und Usermode enthält. Die Dispatch-Funktion, die dem Aufruf von DeviceIoControl entspricht, wird in MajorFunction[IRP_MJ_DEVICE_CONTROL] gespeichert.
#define IRP_MJ_DEVICE_CONTROL 0x0e
Aus der DriverEntry-Funktion von afd.sys können wir sehen, dass der Treiber das Device-Objekt "\Device\Afd" erstellt hat:

Setzt MajorFunction[IRP_MJ_DEVICE_CONTROL] = AfdDispatchDeviceControl, sodass beim Aufruf von DeviceIoControl zur Kommunikation mit dem Kernel diese Funktion aufgerufen wird.

In AFD gibt es zwei Dispatch-Tabellen: AfdIrpCallDispatch und AfdImmediateCallDispatch.

Es ist leicht zu sehen, dass AfdDispatchDeviceIoControl den Index über den IoControlCode berechnet und den entsprechenden Wert aus der AfdIoctlTable holt, um ihn mit dem IoControlCode zu verifizieren.

Aus dem Abstand zwischen der Startadresse von AfdImmediateCallDispatch und der Adresse, an der AfdNotifySock gespeichert ist, berechnen wir den Index 73, der den Control-Code 0x12127 hat.

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;
}
Es funktioniert!

Wie bereits zu Beginn erwähnt, tritt die Schwachstelle auf, wenn wir einen unvalidierten Pointer über eine Struktur übergeben können. Diese Struktur wird direkt aus dem Usermode über lpInBuffer von DeviceIoControl übergeben. Anschließend wird sie an AfdNotifySock als vierten Parameter und an AfdNotifyRemoveIoCompletion als dritten Parameter übergeben.

Da die Struktur unbekannt ist, lasse ich IDA die Struktur automatisch erstellen. Jetzt müssen wir einen Weg finden, Daten in diese Struktur zu übergeben und die erforderlichen Prüfungen zu umgehen, um zum fehlerhaften Codeabschnitt zu gelangen. Beginnen wir mit der Funktion AfdNotifySock:

Zunächst muss die Größe der Struktur 0x30 Bytes betragen.

Die folgenden Werte müssen ungleich 0 sein:

Ein weiterer Punkt: Beim Debuggen habe ich gesehen, dass es bei der vorherigen Prüfung von UserBuffer zum Fehler springt. Daher setze ich diesen Wert beim Aufruf von DeviceIoControl auf NULL. Nachdem ich die obigen Werte gesetzt hatte, konnte ich die Prüfung in check2 passieren.

Nächste zu umgehende Prüfung:

ObReferenceObjectByHandle muss STATUS_SUCCESS zurückgeben, um diese Prüfung zu bestehen. Das bedeutet, ich muss ein gültiges Handle übergeben. Bei der Suche habe ich keine Stelle gefunden, die erklärt, wie man einen IoCompletionObjectType erstellt. Also bin ich der Analyse https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/ gefolgt. Verwenden Sie die Funktion NtCreateIoCompletion, um einen IoCompletionObjectType zu erstellen und dessen Handle an ObReferenceObjectByHandle zu übergeben.
Nachdem diese Prüfung umgangen wurde, springt der Programmablauf in eine Schleife. In dieser Schleife gibt es keine Stelle, die zu einem Fehler führt, also setze ich einfach den Wert bei dword20 auf 0x1, um die Schleife zu verlassen.

Nachdem die Schleife verlassen wurde, ruft das Programm AfdNotifyRemoveIoCompletion auf. Fahren wir mit der Analyse der Funktion AfdNotifyRemoveIoCompletion fort:

Zunächst prüft das Programm ein weiteres Feld der Struktur, das ungleich 0 sein muss. Es wird dann mit 0x20 multipliziert und als Parameter für den Aufruf von ProbeForWrite zusammen mit einem anderen Feld der Struktur verwendet. Hier reicht es, eine Adresse im User-Mode-Speicher mit Schreibzugriff und dwLen = 1 zu verwenden. Die letzte Prüfung vor dem Auslösen des Bugs ist, dass der Rückgabewert des Aufrufs von IoRemoveCompletion STATUS_SUCCESS sein muss. Nach einiger Recherche habe ich herausgefunden, dass die Funktion NtRemoveIoCompletion nach dem Aufruf die Funktion IoRemoveCompletion aufruft. Laut dieser Dokumentation handelt es sich bei NtRemoveIoCompletion um einen "waiting call", der beendet wird, wenn mindestens ein Datensatz in einem bestimmten Io Completion Object abgeschlossen ist. Ein Datensatz wird hinzugefügt, wenn der E/A-Vorgang abgeschlossen ist.
NtRemoveIoCompletion(
IN HANDLE IoCompletionHandle,
OUT PULONG CompletionKey,
OUT PULONG CompletionValue,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER Timeout OPTIONAL );
Außerdem gibt es einen weiteren optionalen Parameter Timeout. Wenn der Timeout-Wert erreicht ist, wird die Funktion beendet. Allerdings reicht es nicht, Timeout = 0 zu setzen, damit die Funktion zurückkehrt; stattdessen wird ein Timeout-Fehlercode zurückgegeben. Wir können die Funktion NtSetIoCompletion verwenden, um den Zähler der ausstehenden E/A-Vorgänge im IoCompletionObjectType um 1 zu erhöhen und die Funktion NtRemoveIoCompletion vor dem Timeout zu beenden. Nach mehrmaligem Testen stellte ich fest, dass der geschriebene Wert immer 0x1 ist.
Da wir den Wert 0x1 an eine Kernel-Mode-Adresse schreiben können, können wir diesen Fehler nutzen, um vollständige Lese-/Schreibfähigkeiten auf beliebige Adressen zu erlangen, indem wir den I/O-Ring (einen neuen I/O-Mechanismus von Microsoft) ausnutzen. Yarden Shafir hat eine sehr detaillierte Analyse dieser Methode geschrieben, die Sie hier lesen können. Eine der Operationen, die eine Anwendung ausführen kann, ist die Zuweisung aller Puffer für ihre zukünftigen I/O-Operationen und deren Registrierung beim I/O-Ring. Die vorregistrierten Puffer werden über das I/O-Objekt referenziert:
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;
Wenn eine Sicherheitslücke, wie die in diesem Beitrag beschriebene, es Ihnen erlaubt, die Felder RegBuffersCount und RegBuffers zu aktualisieren/zu ändern, dann können Sie die standardmäßige I/O-Ring-API verwenden, um Kernel-Speicher zu lesen und zu schreiben. Allerdings erfordert die Verwendung der Funktion NtQuerySystemInformation die Medium IL-Berechtigung. Für eine LPE von Low IL aus ist ein anderer Weg erforderlich, um die Kernel-Adresse zu leaken.
Nachdem IoRing->RegBuffers auf den vom Benutzer kontrollierten fakeBuffer zeigt, können wir normale I/O-Ring-Operationen verwenden, um an jede beliebige Adresse zu lesen und zu schreiben, indem wir einen Index in den fakeBuffer als Puffer angeben:
Für ein besseres Verständnis können Sie die Analyse von Yarden Shafir unter dem obigen Link lesen.
Nachdem ich versucht habe, ein IO-Ring-Objekt zu erstellen und mit dem obigen POC-Code zu schreiben, stürzte Windows nach dem Aufruf von DeviceIOControl ab /_ , also habe ich die Methode verwendet, die Nt-Funktionen direkt aufzurufen (˘・_・˘)
ProbeForWrite aufruft.