
Hardware-Breakpoint-Hooking-Engine für Windows, die Debug-Register nutzt, um Funktionen zu hooken, ETW/AMSI zu umgehen und der EDR-Überwachung im User-Mode zu entgehen.
Dieser Artikel war ursprünglich für VX-Underground Black Mass Halloween Edition 2022.
Hooking-Engines:
Generische x64-Userland-Evasionstechnik mit Debug-Registern:
Beispiel-ETW/AMSI-Hooks verfügbar
Unsere Aufgabe ist es, Funktionen auf triviale Weise zu hooken und den Codefluss bei Bedarf umzuleiten, und schließlich den Hook zu entfernen, sobald er nicht mehr benötigt wird.
Wir können nicht darauf setzen, IAT-Hooks anzuwenden, da sie nicht immer aufgerufen werden und daher unzuverlässig sind. Inline-Hooking ist eine mächtige Technik; jedoch erfordert sie, dass wir den Speicher patchen, in dem sich der Code befindet. Dies ist eine mächtige Technik, aber Werkzeuge wie PE-Sieve und Moneta können den Unterschied zwischen der im Speicher residenten und der auf der Festplatte befindlichen Kopie eines Moduls erkennen und dies kennzeichnen. Das lässt uns mit dem perfekten Werkzeug für diese Aufgabe zurück: Debug-Register, obwohl sie von Malware-Autoren ziemlich unterschätzt werden!
Unter Windows ist ein Prozess, auf hoher Ebene betrachtet, im Wesentlichen eine Kapselung von Threads, und jeder dieser Threads pflegt einen Kontext, der den Zustand des Threads darstellt: die Register und den Stack usw. Debug-Register sind eine privilegierte Ressource, und das gilt auch für das Setzen derselben; Windows stellt jedoch verschiedene Syscalls bereit, die es uns ermöglichen, zu fordern, dass der Kernel eine privilegierte Aktion in unserem Namen ausführt; dies umfasst auch das Setzen von Debug-Registern, was perfekt für uns ist. NtSetThreadContext und NtGetThreadContext legen Funktionalität offen, um jeden Thread-Kontext zu ändern, für den wir ein Handle mit den erforderlichen Berechtigungen öffnen können. Wir können sehen, wie man Debug-Register mit der Win32-API setzt.```c CONTEXT context = { .ContextFlags = CONTEXT_DEBUG_REGISTERS }; GetThreadContext(thd, &context);
// set our debug information in the Dr registers
SetThreadContext(thd, &context);
Es gibt 8 Debug-Register, von Dr0 bis Dr7. Die für uns interessanten sind nur
Dr0-3, in denen wir Adressen speichern, an denen wir anhalten möchten, und Dr6 ist nur der Debug-
Status. Am wichtigsten ist Dr7, das die Bedingungen für die Haltepunkte beschreibt, unter denen der
Prozessor eine Ausnahme auslöst. Es gibt verschiedene Einschränkungen bei der Verwendung von Debug-
Registern, wie eine begrenzte Anzahl (4) und die fehlende Anwendung auf alle Threads/neu
erzeugte Threads. Wir werden versuchen, einige dieser Einschränkungen zu beheben!
Wenn die Ausnahme ausgelöst wird, sucht sie nach einem Ausnahmehandler, den wir definieren
und in unserem Programm registrieren können [1]. In unserem definierten Ausnahmehandler möchten wir, dass unser zugehöriger
Code (verschiedene Codeabläufe) ausgeführt wird, wenn der entsprechende Haltepunkt ausgelöst wird.```c
LONG WINAPI ExceptionHandler(PEXCEPTION_POINTERS ExceptionInfo)
{
if (ExceptionInfo->ExceptionRecord->ExceptionCode == STATUS_SINGLE_STEP)
{
// Look for our associated code flow relative to our RIP
if (HWBP_ADDRESS_MAP.contains(ExceptionInfo->ContextRecord->Rip)) {
HWBP_ADDRESS_MAP.at(ExceptionInfo->ContextRecord->Rip).func(ExceptionInfo);
return EXCEPTION_CONTINUE_EXECUTION;
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
Dies wird durch eine Konstruktorfunktion erreicht, die die Zuordnung zwischen einer "Callback"-Lambda-Funktion und einer Adresse festlegt. using EXCEPTION_FUNC = std::function <void(PEXCEPTION_POINTERS)>;```c typedef struct { UINT pos; EXCEPTION_FUNC func; } HWBP_CALLBACK;
// Global std::unordered_map<uintptr_t, HWBP_CALLBACK> HWBP_ADDRESS_MAP{ 0 };
// Create our mapping HWBP_ADDRESS_MAP[address].func = function; HWBP_ADDRESS_MAP[address].pos = pos;
Wir müssen alle unsere Prozess-Threads durchlaufen und die entsprechenden Anpassungen am Kontext für sie vornehmen. Dies kann mithilfe der ToolHelp32-Hilfsfunktionen erreicht werden: CreateToolhelp32Snapshot und Thread32Next. Das ist nichts Besonderes, aber es geht auf eine unserer Einschränkungen ein, nämlich dass wir uns nicht an alle Threads anhängen können.```c
VOID SetHWBPS(const uintptr_t address, const UINT pos, const bool init = true)
{
DWORD pid{ GetCurrentProcessId() };
HANDLE h{ CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
if (h != INVALID_HANDLE_VALUE) {
THREADENTRY32 te{ .dwSize = sizeof(THREADENTRY32) };
if (Thread32First(h, &te)) {
do {
if ((te.dwSize >= FIELD_OFFSET(THREADENTRY32, th32OwnerProcessID) +
sizeof(te.th32OwnerProcessID)) && te.th32OwnerProcessID == pid) {
HANDLE thd = OpenThread(THREAD_ALL_ACCESS, FALSE, te.th32ThreadID);
if (thd != INVALID_HANDLE_VALUE) {
SetHWBP(thd, address, pos, init);
CloseHandle(thd);
}
}
te.dwSize = sizeof(te);
} while (Thread32Next(h, &te));
}
CloseHandle(h);
}
}
Das Setzen von Hardware-Breakpoints ist wohl verdächtig, da es auf bösartige Aktivität hindeuten kann (obwohl meines Wissens keine EDRs aktiv danach suchen). Sie können gegen uns als potenzieller IoC verwendet werden, daher müssen wir ihre Spuren entfernen, sobald wir sie nicht mehr benötigen.
Wir können dies in unserer Dekonstruktor-Funktion implementieren!! Diese wird alle Threads durchlaufen und prüfen, ob das Register (&context.Dr0)[pos] auf die Adresse zeigt, an der wir den Hardware-Breakpoint ursprünglich gesetzt haben (pos ist nur ein Index % 4, der uns Zugriff auf context.Dr0-Dr3 gibt). Wir können auch die Bedingungen im Dr7-Register entfernen. Wir müssen auch daran denken, unseren Mapping-Eintrag zu entfernen. Daher wird unser Hardware-Breakpoint nur für die erforderliche Dauer vorhanden sein!```c SetHWBPS(address, pos, false); HWBP_ADDRESS_MAP.erase(address);
Ein Beispiel für einen Hardware-Breakpoint wäre Sleep, bei dem wir einfach die Schlafdauer
durch 0 ersetzen.```c
HWBP HWBPSleep{ (uintptr_t)&Sleep, 0, // Set Dr 0
([&](PEXCEPTION_POINTERS ExceptionInfo) {
ExceptionInfo->ContextRecord->Rcx = 0;
ExceptionInfo->ContextRecord->EFlags |= (1 << 16); // continue execution
}) };
Wir wissen, dass wir RCX setzen müssen, aufgrund der x64-Windows-Vier-Register-Fastcall-Aufrufkonvention[1]. Das erste Argument des Konstruktors ist die Adresse, an der angehalten werden soll, das zweite ist, in welchem Dr0- 3-Register gespeichert werden soll (beachte: Wir können gleichzeitig nur 4 Adressen als Haltepunkte verwenden), und das dritte ist eine Lambda-Funktion, die per Referenz PEXCEPTION_POINTERS erfasst, welche die Informationen sind, die ein Ausnahmehandler empfängt. Dies wird uns letztendlich ermöglichen, den Programmfluss je nach ausgelöstem Breakpoint unterschiedlich zu steuern.
Wenn ein neuer Thread erstellt wird, erbt er den zugehörigen Debug-Register-Satz nicht es sei denn, wir schaffen es irgendwie, die Erstellung eines neuen Threads abzufangen! Ein netter Trick, den wir verwenden können, wäre, die tatsächliche Startadresse zu erfassen und den neuen Thread umzuleiten, um unseren eigenen Thread zu erstellen. Die meisten neuen Threads rufen letztendlich NtCreateThreadEx auf.```c // Global Variable PVOID START_THREAD{ 0 };