Shellcode Injection Teil 4
In diesem Beitrag beschäftigen wir uns nur nebenher mit der Verschleierung von Shellcodes. An diesem Punkt wollte ich einen Custom-Shellcode entwickeln, um mehr über die Funktionsweise zu lernen.
Folgende Anforderungen sollten hierbei erfüllt sein:
- Start von
calc.exe
auf einem Windows Rechner - 64-Bit Code
- Vermeiden von Null-Bytes
Vorbereitungen
Shellcode? Nichts leichter als das!
Das dachte ich zumindest. Tatsächlich hat es einiges an Zeit gekostet die Funktionsweise zu verstehen. Dies lag zum Teil auch an fehlenden x64dbg
Kenntnissen. Gerade das Live-Debugging hat sehr viel Zeit gekostet, da ich viele nützliche Befehle noch nicht kannte.
Zum Glück gibt es eine hilfreiche Webseite, welche ich gerne als Referenz genutzt habe.1)
Hilfreiche Tools
Hilfreiche Webseiten
Es gibt viele gute Referenzen im Internet. Ein etwas jüngerer Blogpost hat mich schließlich auf die Idee für diesen Blogpost gebracht. Der entsprechende fertige Shellcode hierzu ist auf GitHub veröffentlicht. Dieser ist gut verständlich und nachvollziehbar.
Ein weiterer Blogpost von Red Team Notes habe ich für den Aufbau des Shellcodes genutzt.
Code: Schritt für Schritt
Den kompletten Code findet ihr auch auf Github.
Welche Schritte sind nun nötig, um calc.exe
aus einem Shellcode zu starten?
kernel32.dll
Basis Adresse findenWinAPI
im Speicher ermitteln- Funktion
WinExec
finden
Variablen und Stack
Im ersten Schritt reservieren wir uns Speicher für unsere Variablen:
sub rsp, 40h xor rax, rax mov [rbp - 08h], rax ; Number_of_Exported_Functions mov [rbp - 10h], rax ; Adress_Table mov [rbp - 18h], rax ; Name_Ptr_Table mov [rbp - 20h], rax ; Ordinal_Table mov [rbp - 28h], rax ; Pointer(WinExec-String) mov [rbp - 30h], rax ; Address(WinExec-Function) mov [rbp - 38h], rax ; reserved
Für den späteren Suchdurchlauf müssen wir den String WinExec\n
auf den Stack pushen und die Zeigeradresse speichern.
push rax mov rax, 0x00636578456E6957 ; 0x00 + c,e,x,E,n,i,W push rax ; push WinExec\n to stack mov [rbp - 28h], rsp ; Pointer(WinExec-String) -> Var
kernel32.dll Basis Adresse
Mit jedem Start eines Prozesses in Windows, werden Module in diesen Prozess geladen. Eines dieser Module ist unsere kernel32.dll
. Im Arbeitsspeicher werden durch Windows Datenstrukturen angelegt, welche alle für uns nötigen Informationen bereit halten.
Ähnlich wie in einem Buch, rufen wir ein Inhaltsverzeichnis (Pointer) auf, welches auf die richtige Seitenzahl (relevanter Speicherbereich) zeigt.
Die erste dieser Strukturen ist der TEB
(Thread Environment Block). Dieser beinhaltet einen Zeiger auf den PEB
(Process Environment Block), welcher uns Auskunft über die geladenen Module gibt.
Den Zeiger zur PEB
finden wir über das Register gs
am Offset 0x60
, sprich ab Byte 60. Wir laden nun die Speicheradresse in das Register rax
.
mov rax, gs:[0x60]
Jetzt befinden wir uns im PEB
und navigieren durch den Speicherbereich:
PEB + 18 Bytes -> Pointer(Ldr) Ldr + 20 Bytes -> Pointer(InMemoryModuleList) InMemoryModuleList(1) -> ProcessModule InMemoryModuleList(2) -> ntdll Module InMemoryModuleList(3) + 20 Bytes -> kernel32.dllbase
In unserem Code sieht dies dann so aus:
mov rax, [rax + 0x18] ; PEB + 18 Bytes -> Pointer(Ldr) mov rax, [rax + 0x20] ; Ldr + 20 Bytes -> Pointer(InMemoryModuleList) mov rax, [rax] ; InMemoryModuleList -> ProcessModule mov rax, [rax] ; InMemoryModuleList -> ntdll Module mov rax, [rax + 0x20] ; InMemoryModuleList -> kernel32 + 20 Bytes DllBase mov rbx, rax ; Save kernerl32.base to rbx
WinAPI
Mit der kernel32
Basis Adresse können wir die WinAPI suchen. Hierzu benötigen wir erneut ein paar Adressen:
kernel32.base + 0x3c Bytes -> Pointer(RVA_PE_Signature) RVA_PE_Signature + 0x88 Bytes -> Pointer(RVA_Export_Table) RVA_Export_Table + 0x14 Bytes -> Number_of_Exported_Functions RVA_Export_Table + 0x1c Bytes -> RVA_Address_Table RVA_Export_Table + 0x20 Bytes -> RVA_Name_Pointer_Table RVA_Export_Table + 0x24 Bytes -> RVA_Ordinal_Table
Setzen wir dies nun in ASM
um:
mov eax, [rbx + 0x3c] ; Pointer(RVA_PE_Signature) add rax, rbx ; rax = kernel32.base + RVA_PE_Signature mov eax, [rax + 0x88] ; Pointer(RVA_Export_Table) add rax, rbx ; rax = kernel32.base + RVA_Export_Table mov ecx, [rax + 0x14] ; Number_of_Exported_Functions mov [rbp - 8h], rcx ; Number_of_Exported_Functions -> Var mov ecx, [rax + 0x1c] ; RVA_Address_Table add rcx, rbx ; Adress_Table = kernel32.base + RVA_Address_Table mov [rbp - 10h], rcx ; Adress_Table -> Var mov ecx, [rax + 0x20] ; RVA_Name_Ptr_Table add rcx, rbx ; Name_Ptr_Table = kernel32.base + RVA_Name_Ptr_Table mov [rbp - 18h], rcx ; Name_Ptr_Table -> Var mov ecx, [rax + 0x24] ; RVA_Ordinal_Table add rcx, rbx ; Ordinal_Table = kernel32.base + RVA_Ordinal_Table mov [rbp - 20h], rcx ; Ordinal_Table -> Var
windir\syswow64\kernel32.dll
in PEView. So könnt ihr die gesuchten Speicherbereiche besser nachvollziehen.
WinExec
Iteration
Nun folgt eine Iteration, welche die Export-Funktionen solange abfragt, bis WinExec
gefunden wurde oder die Anzahl der Funktionen durchlaufen ist:
xor rax, rax xor rcx, rcx findWinExecPosition: mov rsi, [rbp - 28h] ; Pointer(WinExec-String) mov rdi, [rbp - 18h] ; Pointer(Name_Ptr_Table) cld ; clear direction, low -> high Adresses mov edi, [rdi + rax * 4] ; RVA_Next_Function from Name_Ptr_Table add rdi, rbx ; Adress_Next_Function mov cl, 8 ; compare first 8 Bytes repe cmpsb ; check if rsi == rdi jz WinExecFound ; if found -> jump inc rax ; else: increase the counter cmp rax, [rbp - 8h] ; counter = Number_of_Exported_Functions jne findWinExecPosition ; if not -> jump
Funktion gefunden
Wurde die Funktion gefunden, springt der Code zur WinExecFound
-Marke. Hier wird die reale, virtuelle Adresse der Funktion berechnet. Mit dieser sind wir schließlich in der Lage WinExec
zu adressieren. Der Vorgang wird von Red Team Notes genauer beleuchtet.
WinExecFound: mov rcx, [rbp - 20h] ; Ordinal_Table mov rdx, [rbp - 10h] ; Adress_Table mov ax, [rcx + rax * 2] ; Ordinal_WinExec mov eax, [rdx + rax * 4] ; RVA_WinExec add rax, rbx ; VA_WinExec jmp short InvokeWinExec
WinExec ausführen
Nun übergeben wir alle wichtigen Parameter an WinExec
und rufen die Funktion anschließend auf. Ein Blick in die Funktionsdokumentation ist hierbei hilfreich.6)
UINT WinExec( [in] LPCSTR lpCmdLine, [in] UINT uCmdShow );
Wir benötigen einen String für die Kommandozeile und einen Integer für die Anzeige des Fensters.
Es gibt jedoch noch mehr Dinge zu beachten, wenn wir die WinAPI aufrufen.7)8)
- Argument Register (von links nach rechts):
RCX (lpCmdLine), RDX (uCmdShow), R8, R9, Stack
- Der Stack muss auf 16 Bytes ausgerichtet werden:
and rsp, -16
- Shadow Space, ein leeres 32 Bytes Segment auf dem Stack, welches für interne WinAPI Zwecke benötigt wird:
sub rsp, 32
Unseren string calc.exe
übergeben wir wieder in umgekehrter Schreibweise an den Stack und anschließend füllen wir das untere Byte-Segment des RDX
Registers mit dem Wert 0x1
, was dem Wert SW_SHOWDEFAULT
entspricht.
Dann erfüllen wir noch die WinAPI Aufrufkonventionen, wie oben beschrieben und können WinExec
mit dem Befehl call rax
aufrufen.
InvokeWinExec: xor rdx, rdx xor rcx, rcx push rcx mov rcx, 0x6578652e636c6163 ; exe.clac push rcx mov rcx, rsp ; rcx = calc.exe mov dl, 0x1 ; uCmdSHow = SW_SHOWDEFAULT and rsp, -16 ; 16-byte Stack Alignment sub rsp, 32 ; STACK + 32 Bytes (shadow spaces) call rax ; call WinExec
Null-Bytes
Wir können den Code nun mit folgendem Befehl kompilieren:
nasm -f win64 calc-unsanitized.asm -o calc-unsanitized.o
Dann lohnt sich ein Blick in die kompilierte Datei:
objdump -d calc-unsanitized.o
Es fallen direkt einige Stellen auf, welche 0-Bytes enthalten. Diese können den Shellcode an der Ausführung hindern, somit müssen wir noch einige Änderungen machen.
WinExec Push
Wir pushen WinExec\n
auf den Stack. \n
entspricht 0-Byte und wir müssen unseren Code anpassen. Wir ändern 00
im String auf 11
. Nun können wir mit shl, shr
den Inhalt des Registers nach Links bzw. Rechts verschieben. Alle Werte außerhalb werden gelöscht.
mov rax, 0x00636578456E6957 ; 0x00 + c,e,x,E,n,i,W --> mov rax, 0x11636578456E6957 shl rax, 0x08 ; shift left 0x08 -> 0x636578456E695700 shr rax, 0x08 ; shift right 0x08 -> 0x00636578456E6957
GS Register + 0x60
Die Anweisung mov rax, gs:[0x60]
erzeugt ebenfalls 0-Bytes. Dies können wir umgehen, indem wir 0x60
einem niedrigerem Register zuordnen. Anschließend werden die Register addiert.
mov rax, gs:[0x60] --> mov al, 60h mov rax, gs:[rax]
Eine Übersicht der hohen und niedrigen Register findet ihr im 64 Bit Stack CheatSheet.
RVA_Table Pointer
Die gleiche Methode wenden wir am RVA_Table Pointer an:
mov eax, [rax + 0x88] ; Pointer(RVA_Export_Table) --> mov cl, 88h mov eax, [rax + rcx]
Letzte Handgriffe
Der Shellcode sieht nun fast gut aus. Wir müssen nur noch die relevanten Anweisungen rausfiltern. Ich nutze hierzu eine kleine Eigenprogrammierung namens ShenCode .
python shencode.py output -f calc-final.o -s c
Der Befehl liefert uns die Datei in C-Format Syntax. Wir wissen, unser Shellcode beginnt mit den Opcodes 55 48
. Diese finden sich ab Offset 60. Die letzten Anweisungen sind 5D C3
und dann befinden wir uns an Offset 310.
python shencode.py extract -f calc-final.o -o calc-final.sc -fb 60 -lb 310 python shencode.py output -f calc-final.sc -s c
Und hier ist unser fertiger Shellcode!
Repository
git clone https://github.com/psycore8/nosoc-shellcode
Diskussion