
Eine speicherbasierte Umgehungstechnik, die Shellcode vom Prozessstart bis zum Ende unsichtbar macht.
Eine speicherbasierte Ausweichtechnik, die Shellcode vom Prozessstart bis zum Ende unsichtbar macht.
Ich wollte diesen POC zur Shellcode-Selbstinjektion teilen, um einige AV/EDR-Umgehungskonzepte zu demonstrieren, die für Red Teaming nützlich sein können. Vor ein paar Wochen habe ich eine benutzerdefinierte In-Memory-Umgehungstechnik entwickelt, die ich ShellGhost genannt habe. Diese Technik entstand aus der Notwendigkeit, einen Code zu haben, der einen 'unsichtbaren' Shellcode vom Prozessstart bis zum Ende ausführt.
ShellGhost nutzt Vectored Exception Handling in Kombination mit Software-Breakpoints, um die Thread-Ausführung zyklisch zu stoppen, den ausgeführten Breakpoint durch eine RC4-verschlüsselte Shellcode-Anweisung zu ersetzen, die Anweisung zu entschlüsseln und die Ausführung nach Wiederherstellung des Speicherschutzes auf RX fortzusetzen. Wenn der nachfolgende EXCEPTION_BREAKPOINT ausgelöst wird, ersetzt der Exception-Handler die vorherige Shellcode-Anweisung durch einen neuen Breakpoint, sodass die Zuordnung den vollständigen Shellcode niemals im unverschlüsselten Zustand preisgibt. Dies geschieht innerhalb einer privaten Speicherseite, die zunächst als READ/WRITE markiert ist.
Eine RW-PRV-Zuordnung wird von Speicherscannern wie PE-Sieve und Moneta nicht als 'Indicator of Compromise' betrachtet. Wenn die Zuordnung zu RX wechselt und die Seite gescannt wird, werden nur Breakpoints gefunden. Dies geschieht, während der Shellcode tatsächlich ausgeführt wird. Das folgende Bild zeigt, dass eine Reverse Shell läuft, aber von Moneta kein IOC gefunden wird (abgesehen davon, dass die Binärdatei nicht signiert ist).

Der Versuch, den Prozess mit Pe-Sieve zu scannen, hat ein noch besseres Ergebnis:

Shellcode Mapping ist die Kernfunktionalität von ShellGhost. Diese Taktik ermöglicht es dem Thread, Anweisungen zeitweise auszuführen, ohne den gesamten Shellcode jemals im Speicher preiszugeben. Dies ist möglich, weil die Position jeder einzelnen Shellcode-Anweisung, die der Thread ausführt, der Position eines bestimmten Breakpoints innerhalb der zugewiesenen Speicherseite entspricht. ShellGhost löst diese Position, indem es die Relative Virtuelle Adresse (RVA) vom Thread-RIP zur Basisadresse der zugewiesenen Speicherseite berechnet und sie zur Basisadresse des verschlüsselten Shellcodes / der verschlüsselten Anweisungen addiert. Die Anzahl der zu ersetzenden Breakpoints ist nicht immer gleich, sondern variiert je nach Anzahl der Opcodes, die jede Anweisung benötigt, um korrekt generiert und interpretiert zu werden (QUOTA). So ist zum Beispiel die Anweisung 'POP RBP' gleich '5D', was bedeutet, dass nur ein Breakpoint ersetzt wird. Im Gegensatz dazu erfordert die Anweisung 'JMP RAX' die Opcodes 'FF E0', sodass zwei Breakpoints ersetzt werden. Aus diesem Grund habe ich die folgende C-Datenstruktur erstellt.
typedef struct CRYPT_BYTES_QUOTA {
DWORD RVA; // offset to encrypted instruction
DWORD quota; // number of opcodes that generate the instruction
} CRYPT_BYTES_QUOTA, * PCRYPT_BYTES_QUOTA;
Breakpoints werden nicht sofort durch ihre Anweisungsgegenstücke ersetzt. Dies liegt daran, dass die Anweisungen vor der Ausführung eine Entschlüsselungsroutine durchlaufen müssen. Hier kommt der DWORD quota ins Spiel. ShellGhost nutzt die mittlerweile beliebte 'SystemFunction032', um RC4-Entschlüsselung durchzuführen. Im Gegensatz zu XOR ist RC4 kein Einzelbyte-Verschlüsselungsschema. Das bedeutet, dass der Shellcode nicht auf einmal verschlüsselt und entschlüsselt werden kann. Dies ist ein weiterer Grund, warum jede Anweisung separat behandelt wird. Nachdem die Breakpoints ersetzt wurden, ist die Pufferlänge, die SystemFunction032 benötigt, gleich dem 'instruction quota', das wiederum die Anzahl der Opcodes darstellt, aus denen die spezifische Anweisung besteht. Betrachten Sie zum Beispiel das folgende Snippet.
CRYPT_BYTES_QUOTA instruction[200];
instruction[5].quota = 2
USTRING buf = { 0 }; // will contain the buffer to be decrypted and its length
USTRING key = { 0 }; // will contain the RC4 key and length
buf.Length = 2 // buffer length, or length of the instruction to be decrypted
Wir wissen, dass Shellcode-Anweisung Nummer 5 aus 2 Opcodes besteht, daher wird eine Pufferlänge von 2 an SystemFunction032 übergeben. Dies ist wichtig, da der Versuch, den gesamten Shellcode mit einem einzigen Aufruf von SystemFunction032 zu entschlüsseln, diesen vollständig beschädigen würde.
Der Shellcode muss vor der Kompilierung mit ShellGhost_mapping.py gemappt werden. Das Skript extrahiert jede einzelne Anweisung und behandelt sie als kleinen, unabhängigen Shellcode. Die Anweisungen werden eine nach der anderen verschlüsselt und alle zusammen im C-Format als unsigned char ausgegeben. Das Ergebnis kann in den C-Code hartcodiert werden. Nachfolgend ein Beispiel dafür, wie verschlüsselte MSF-Shellcode-Anweisungen für calc.exe aussehen.

Dieser Shellcode hat 98 Anweisungen, daher werden 98 CRYPT_BYTES_QUOTA-Strukturen deklariert. Wenn der Code ausgeführt wird, müssen diese Strukturen mit den entsprechenden Anweisungs-RVAs und QUOTAs gefüllt werden. Der Parameter '-1' weist das Mapping-Skript an, den Code-Abschnitt auszugeben, der dies erledigt.

Metasploit-x64-Shellcodes haben typischerweise WinAPI-String-Parameter, die zwischen den Anweisungen gespeichert sind. Mit anderen Worten, ein MSF-x64-Shellcode, der Winexec aufruft, schiebt keine Reihe von Bytes mit einem Nullbyte am Ende, um den ersten Parameter-String auf dem Stack zu haben. Stattdessen ist das RCX-Register (erster Parameter) ein Zeiger innerhalb des Shellcodes selbst, wie im folgenden Bild.

Dies bedeutet, dass die Breakpoints, deren Position sich auf den String bezieht, niemals aufgelöst werden, da der RIP diese Position nie erreicht. Tatsächlich löst dieser Code die tatsächlichen Shellcode-Anweisungen auf, die der RIP durchläuft, und nicht Parameter, die nie wie Anweisungen ausgeführt werden. Um dies zu beheben, habe ich festgestellt, dass MSF-Shellcodes immer einen Zeiger auf die aufgerufene WinAPI im RAX-Register speichern und dann einen Sprung zum Register selbst machen. Wenn ShellGhost VEH also erkennt, dass der aufgelöste Breakpoint 'JMP RAX' ist und das RCX-Register einen Zeiger auf eine Position innerhalb des Shellcodes enthält, versucht es, auch das von RCX referenzierte Objekt aufzulösen. Anschließend wird die Ausführung nicht an den zugewiesenen Speicher zurückgegeben. Stattdessen wird RAX (WinAPI-Adresse) in RIP kopiert und die Thread-Ausführung von der WinAPI fortgesetzt, wodurch der 'JMP RAX' überschrieben wird und der zugewiesene Speicher RW bleibt. Dies ist erforderlich für Reverse Shells, die WaitForSingleObject aufrufen, was dazu führen würde, dass der Thread nach dem 'JMP RAX' schläft, während der Speicher für die gesamte Lebensdauer der Shell RX bleibt. Das folgende Code-Snippet enthält die beiden Bedingungen, die erfüllt sein müssen, damit ShellGhost das RCX-Register anpasst, wenn es einen WinAPI-Parameter-String enthält, und dem MSF-Shellcode ermöglicht, den Funktionsaufruf korrekt durchzuführen (WinExec im Beispiel hier).
<snip>
if (*(PWORD)exceptionData->ContextRecord->Rip == 0xe0ff) // if RIP is 'JMP RAX'
<snip>
if ((contextRecord->Rcx >= (DWORD_PTR)allocation_base) && (contextRecord->Rcx <= ((DWORD_PTR)allocation_base + sizeof(sh)))) // if RCX is inside the allocation
<snip>
RDX, R8 und R9 (zweiter, dritter und vierter Parameter) werden noch nicht abgedeckt.
ShellcodeFluctuation ist ein sehr ähnliches In-Memory-Umgehungskonzept. Genau wie dieses 'fluktuiert' der zugewiesene Speicher hier von RW zu RX. Im Gegensatz dazu führt ShellGhost die folgenden Verbesserungen ein:
ShellGhost ist jedoch weit davon entfernt, eine perfekte Technik zu sein. Sie leidet immer noch unter dem größten Nachteil, den alle diese Techniken haben, nämlich der Notwendigkeit, irgendwann während der Ausführung privaten ausführbaren Speicher zu haben. Fortgeschrittenere Techniken wie Foliage haben bereits einen Weg gefunden, dies zu umgehen. Darüber hinaus kann eine Speicherzuordnung voller Software-Breakpoints von einer YARA-Regel erkannt werden. Das folgende Bild zeigt Moneta, das korrekt einen IOC für die RX-PRV-Zuordnung erkennt.

Wenn es darum geht, eine EDR-Lösung zu umgehen, ist die Speicherüberprüfung nur ein Teil eines größeren Bildes. Das vollständige Fehlen von IOCs bedeutet nicht unbedingt, dass eine Binärdatei, die diese Technik verwendet, sich gegen eine bestimmte EDR als wirksam erweist. Soweit ich sagen kann, habe ich Situationen erlebt, in denen die Lösung es nicht einmal erlaubt, die Binärdatei so zu starten, wie man es tut. Die andere Seite der Medaille ist, dass IOCs nicht immer präzise Indikatoren sind und einige von ihnen sich als Fehlalarme herausstellen können. Mit diesem Vorbehalt ist dies nur eine rohe Technik und eine Inspiration, die der Leser hoffentlich zu schätzen weiß. Der Red Teamer weiß, dass die In-Memory-Umgehung, genau wie die Komponenten einer EDR, nur eine Komponente der Engine ist.
Die Kompilierung erfordert das Deaktivieren des inkrementellen Linkens. Dieses VS-Projekt hat alle Compiler/Linker-Optionen bereits gesetzt.