
Codeausführungs-/Injektionstechnik durch Manipulation der DLL-PEB-Modulstruktur
Verdeckte Codeausführung durch Modifikation des EntryPoint geladener Module zur Laufzeit.
Windows-Prozesse haben zur Laufzeit verschiedene Module geladen. Jedes dieser Module definiert eine DllMain()-Funktion, die bei der Erstellung bzw. Zerstörung von Prozessen oder Threads aufgerufen wird (vier mögliche Szenarien).
Um diese Funktionen während der Lebensdauer des Prozesses ordnungsgemäß aufzurufen, beziehen sich die Windows-Loader-Funktionen (ntdll!Ldrp*) auf eine Liste von Einträgen, die für jedes Modul wichtige Parameter (einschließlich des EntryPoint-Felds) enthalten.
Indem wir diesen EntryPoint einer DLL überschreiben, stellen wir sicher, dass die Codeausführung an eine von uns gewählte Stelle umgeleitet wird.
Dies kann sowohl als Codeausführungs-Primitive als auch für API-Proxying verwendet werden, also um bestimmte APIs mit einem unverdächtigen Callstack auszuführen, da sie von legitimen Windows-Funktionen aufgerufen werden.
Dies kann auch verwendet werden, um die Ausführung in einem entfernten Prozess auszulösen, sofern der Angreifer in der Lage ist, Speicher in diesem Zielprozess zu lesen und zu schreiben. Ähnlich wie bei der Threadless Injection ermöglicht dies die Ausführung von Code in einem Prozess, ohne klassische APIs zur Ausführung aufzurufen (CreateRemoteThread, QueueUserAPC).
Das Laden/Entladen von Modulen innerhalb eines Windows-Prozesses ist ein komplexes Thema, das viele Herausforderungen mit sich bringt: potenzielle Instabilität, Race Conditions und Abstürze. Eine bekannte Hürde bei der Ausführung von Code innerhalb einer DllMain()-Funktion liegt beispielsweise darin, dass eine Loader Lock aktiv ist und wir in einem Thread laufen, der nicht vollständig eingerichtet wurde oder sich gerade im Prozess der Beendigung befindet.
Deshalb habe ich versucht, ordentlich zu dokumentieren, was möglich ist und was nicht. Während beispielsweise die meisten üblichen API-Aufrufe ausgeführt werden können, bringt der Betrieb eines vollwertigen Beacons bestimmte Anforderungen mit sich, die einen separaten Prozess erfordern, um Deadlocks durch die in wininet.dll oder winhttp.dll verwendeten Funktionen zu vermeiden.
Jeder Prozess verwaltet zur Laufzeit eine Liste von _LDR_DATA_TABLE_ENTRY-Strukturen. Diese Strukturen enthalten viele Details, die für die DLL relevant sind, wie etwa ihren EntryPoint (den wir überschreiben werden), ihren Namen, bestimmte Hashes, Zeitstempel, verschiedene Flags usw. Einige dieser Strukturen sind dokumentiert, andere nicht.
Diese können mit dem folgenden WinDbg-Befehl visualisiert werden:
dt nt!_LDR_DATA_TABLE_ENTRY 0xdeadbeef
0:006> dt nt!_LDR_DATA_TABLE_ENTRY 0x18d1c4838c0
ntdll!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485de0 - 0x0000018d`1c4832b0 ]
+0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485df0 - 0x0000018d`1c4832c0 ]
+0x020 InInitializationOrderLinks : _LIST_ENTRY [ 0x0000018d`1c4832d0 - 0x0000018d`1c482c80 ]
+0x030 DllBase : 0x00007ffe`e87e0000 Void
+0x038 EntryPoint : 0x00007ffe`e8838d00 Void
+0x040 SizeOfImage : 0x2fe000
+0x048 FullDllName : _UNICODE_STRING "C:\WINDOWS\System32\KERNELBASE.dll"
+0x058 BaseDllName : _UNICODE_STRING "KERNELBASE.dll"
+0x068 FlagGroup : [4] "???"
+0x068 Flags : 0x8a2cc
+0x068 PackagedBinary : 0y0
+0x068 MarkedForRemoval : 0y0
+0x068 ImageDll : 0y1
(...)
+0x0e0 MappingInfoIndexNode : _RTL_BALANCED_NODE
+0x0f8 OriginalBase : 0x00007ffe`e87e0000
+0x100 LoadTime : _LARGE_INTEGER 0x01db5f86`2fa735fc
+0x108 BaseNameHashValue : 0x235bec4
+0x10c LoadReason : 0 ( LoadReasonStaticDependency )
+0x110 ImplicitPathOptions : 0x4000
+0x114 ReferenceCount : 1
+0x118 DependentLoadFlags : 0x800
+0x11c SigningLevel : 0 ''
Die Adresse der Struktur kann ermittelt werden, indem man eine doppelt verknüpfte Struktur durchläuft, auf die in der Prozess-PEB innerhalb einer PEB_LDR_DATA-Struktur verwiesen wird.
dt nt!_PEB_LDR_DATA 0xb4b4c3c3
Beachten Sie das DontCallForThreads-Flag. Wie der Name schon sagt, wird das Betriebssystem bei gesetztem Flag die DllMain() dieses Moduls für Thread-Ereignisse (d.h. DLL_THREAD_ATTACH oder DLL_THREAD_DETACH) NICHT aufrufen.
Beim Erstellen einer DLL muss die folgende Vorlage befolgt werden, um mit den OS-Loader-Funktionen zusammenzuarbeiten:
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved ) // reserved
{
// Perform actions based on the reason for calling.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Initialize once for each new process.
// Return FALSE to fail DLL load.
break;
case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;
case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // do not do cleanup if process termination scenario
}
// Perform any necessary cleanup.
break;
}
return TRUE; // Successful DLL_PROCESS_ATTACH.
}
Wie oben beschrieben, überschreibt die Technik vorübergehend den EntryPoint einer DLL, um die Ausführung umzuleiten. Da wir keine Kontrolle über mehr als die Umleitung der Ausführung haben, müssen auf der Seite einige Vorkehrungen getroffen werden, um zu handhaben, was wir ausführen möchten, mit welchen Argumenten und wie wir den Rückgabewert zurückerhalten.
Dies geschieht durch das Definieren einer DATA_T-Struktur auf dem Heap, so dass sie während der verschiedenen Schritte zugänglich bleibt.
Diese Struktur ist wie folgt definiert:
typedef struct _DATA_T {
// LDR structures manipulation
ULONG_PTR runner; // malicious entry point to execute
ULONG_PTR bakOriginalBase; // backup of overwritten OriginalBase
ULONG_PTR bakEntryPoint; // backup of overwritten EntryPoint
HANDLE event; // event signalling that the Runner has executed
// function call
ULONG_PTR ret; // return value
DWORD createThread; // run this API call in a new thread (required for wininet/winhttp)
ULONG_PTR function; // Windows API to call
DWORD dwArgs; // number of args
ULONG_PTR args[MAX_ARGS]; // array of args
} DATA_T, * PDATA_T;
Um eine API-Ausführung einzurichten, müssen diese Felder vorbereitet werden. Der ret-Wert ist derjenige, der nach der Ausführung den Rückgabewert aufnimmt. Das event wird zur Synchronisation verwendet, um zu signalisieren, dass die Ausführung abgeschlossen wurde. Alle anderen Felder sind Eingaben, die festlegen, welche API aufgerufen werden soll (function), mit welchen Argumenten (dwArgs und args[]), die Adresse der Runner()-Funktion, auf die die Ausführung umgeleitet wird, sowie Sicherungen der überschriebenen ursprünglichen DLL-Einträge (bakOriginalBase und bakEntryPoint).
Das Feld createThread muss für diejenigen komplexen API-Funktionen auf 1 gesetzt werden, die in einer DllMain()-Umgebung nicht gut laufen (dazu gehören viele wininet- und winhttp-Bibliotheken).
Hier ist ein Beispiel für die Einrichtung eines Aufrufs von MessageBoxA(), wie im PoC sichtbar:
pDataT->dwArgs = 4;
pDataT->runner = (ULONG_PTR)Runner;
pDataT->function = (ULONG_PTR)MessageBoxA;
pDataT->args[0] = (ULONG_PTR)0;
pDataT->args[1] = (ULONG_PTR)"Hello";
pDataT->args[2] = (ULONG_PTR)"LDRSHUFFLE";
pDataT->args[3] = (ULONG_PTR)MB_OKCANCEL;
pDataT->event = CreateEventA(NULL, FALSE, FALSE, "ExecEvt");
Die Funktion UpdateLdr() ist dafür verantwortlich, die richtige Modifikation in der _LDR_DATA_TABLE_ENTRY des Zielmoduls durchzuführen.
RestoreLdr() stellt diese Änderungen zu einem späteren Zeitpunkt wieder her (aufgerufen von Runner()).
Diese Funktionen lokalisieren im Wesentlichen die PEB und durchlaufen die Modulstrukturen, um die richtige DLL und ihre Felder zu identifizieren. In den Header-Dateien verwende ich Definitionen, die Batsec in seiner DarkLoadLibrary verwendet, und ich empfehle den Lesern, sich dieses Projekt und den zugehörigen MDSec-Blogpost anzusehen, um von der großartigen Arbeit zu profitieren, die er zu den Interna des Modulladens in Windows geleistet hat.
Hinweis: Dieser PoC lädt eine Opfer-DLL (SACRIFICIAL_DLL_NAME) und führt diese Änderungen an dieser DLL durch. Es ist jedoch ohne Weiteres möglich, eine bereits geladene DLL zu ändern. Dies ist sogar der Ansatz, der für die prozessübergreifende Injektion verwendet wird. Aus Stabilitätsgründen würde ich empfehlen, wichtige DLLs wie ntdll oder kernel32 nicht anzufassen, da diese auch eher von Sicherheitslösungen unter die Lupe genommen werden.
Bei der Erstellung oder Zerstörung von Threads wird die Ausführung an Runner() umgeleitet, das als gefälschtes DllMain() für das Modul fungiert. Diese Funktion wird dann:
PDATA_T-Struktur)RestoreLdr() wiederherstellenDllMain()-Aufruf ausführen (im Wesentlichen wird der normale DLL-Aufruf durchgereicht).An diesem Punkt wurde die „normale“ Betriebssystem-Ausführung durchgeführt. Danach fährt sie mit unseren Payloads fort:
DATA_T-Struktur gespeichert sind. Wenn diese API für die Ausführung in einem neuen Thread markiert wurde (createThread = 1), wird dieser Aufruf in einem neuen Thread ausgeführt.pDataT->event), damit unser Hauptcode weiß, dass der Aufruf ausgeführt wurde.Wenn Windows schließlich unseren gefälschten EntryPoint aufruft (die Adresse der Runner()-Funktion), sieht der Callstack wie folgt aus:

Der bereitgestellte PoC enthält ein Beispiel, das MessageBoxA() aufruft.
Er enthält außerdem eine Demonstration eines HTTP-Downloads mit wininet. Definieren Sie die Variable HTTP, um diesen Code zu aktivieren.

Die oben beschriebenen Prinzipien laufen im Kern darauf hinaus, im Speicherbereich des Prozesses zu lesen und zu schreiben, um zu einem beliebigen zukünftigen Zeitpunkt Codeausführung auszulösen.
Mit ein paar Anpassungen können diese Lese- und Schreiboperationen auf einen entfernten Prozess angewendet werden, um den EntryPoint einer seiner DLLs zu überschreiben.
Eine Voraussetzung ist die Fähigkeit, im Speicherbereich des Prozesses lesen und schreiben zu können, d.h.:
OpenProcess(PROCESS_VM_READ, FALSE, dwPid) und OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, PID)
Im PoC ist ein zusätzliches Projekt namens LdrInject enthalten, das demonstriert, wie diese Schritte durchgeführt werden. Kurz gesagt führt es Folgendes aus:
ReadPEB() durchläuft es die _LDR_DATA_TABLE_ENTRY-Liste im Zielprozess, um eine geeignete DLL zum Überschreiben zu identifizieren. Beachten Sie, dass diese DLL DontCallForThreads == 0 haben muss, da wir möchten, dass Windows den EntryPoint dieser DLL bei der Thread-Erstellung aufruft. Wir wählen auch nicht die ersten DLLs in der Liste, da diese eher von Sicherheitsprodukten unter die Lupe genommen werden (ntdll.dll, kernel32.dll...).PEBINJ_DATA-Datenstruktur gespeichert.InjectShellcodeToRemoteProcess() in den Adressraum des entfernten Prozesses geschrieben.WriteProcessMemory()-Aufrufe überschreiben den EntryPoint der DLL und sichern ihn in OriginalBase, sodass er später wiederhergestellt werden kann.Ab diesem Zeitpunkt führt das nächste DLL_THREAD_ATTACH- oder DLL_THREAD_DETACH-Ereignis dazu, dass der Shellcode aufgerufen wird. Dies bringt bestimmte Einschränkungen und Vorbehalte im Zusammenhang mit dem Betrieb eines Beacons mit sich, die im nächsten Abschnitt beschrieben werden.
Diese Technik führt zur Ausführung eines Shellcodes in einer sehr speziellen Situation. Die Loader Lock ist aktiv (da das Betriebssystem davon ausgeht, dass es sich gerade im Prozess des Ladens/Entladens einer DLL befindet); ein Thread wird entweder gerade erstellt oder zerstört; und generell besteht Potenzial für Thread-Synchronisationsprobleme, Deadlocks usw.
Während der Tests wurden zwei Herausforderungen beobachtet:
wininet.dll oder winhttp.dll zu einem Deadlock.Um die Stabilität zu erhöhen, müssen wir:
CreateThread aufrufen, bevor der übliche reflective DLL-Einstiegspunkt von Cobalt Strike aufgerufen wird.fdwReason == DLL_THREAD_ATTACH ist, wenn der EntryPoint vom Betriebssystem aufgerufen wird:winApi.CreateThread(NULL, 4096, (LPTHREAD_START_ROUTINE)&runner, (LPVOID)&ct_data, 0, &dwThId);
anstelle des üblichen
((DLLMAIN)entryPoint)((HINSTANCE)loaderStart, 4, NULL);
ULONG_PTR __cdecl ReflectiveLoader(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
// only run for a Thread creation event
if (fdwReason != DLL_THREAD_ATTACH) {
return TRUE;
}
...
}
Diese beiden zusätzlichen Schritte wurden in einer Demo für ein UDRL eingebettet.
VirtualAlloc
VirtualProtect
CreateThread
Sleep
MessageBoxA
InternetOpenW (muss mit createThread = 1 ausgeführt werden)
InternetOpenUrlA (muss mit createThread = 1 ausgeführt werden)