Der BND hat vor einigen Wochen eine Reverse Engineering Challenge veröffentlicht. Da ich mich zur Zeit ein wenig in RE von x86-Binaries übe habe ich mich mal an die Challenge gesetzt. Level 1 ist eine einfache .NET-Application, die mit den entsprechenden Tools einfach decompiliert werden kann. Im Grunde keine Herausforderung - mit ein wenig google-fu und einfachen Programmierkenntnissen.
Im Level 2 fängt dann der Spaß an - zumindest für mich als Assembler-, RE- und IDA Pro-Neuling. Vor allem eine Windows-Binary habe ich noch nie disassembliert.
Die Challenge simuliert die Situation eines Crypto-Lockers: Man wird infiziert (evil.exe) und diese Malware hat ein Foto (Urlaubsphoto.png.crypt) verschlüsselt. Ziel ist es dieses Urlaubsfoto zu entschlüsseln. Diese beiden Dateien sind der Ausgangspunkt der Challenge.
Das entschlüsselte Bild sieht so aus:
Da es beim RE vor allem darum geht Muster zu erkennen statt einzelne Mnemonics zu verstehen, versuche ich diesen Beitrag weniger auf das Erklären einzelner Instruktionen auszurichten, sondern darauf Muster an den bestimmten, zur Lösung vielleicht notwendigen Stellen, zu besprechen. Mein Hauptziel des Beitrags ist mir selbst diese Muster und die kritischen Stellen nochmal bewusst zu machen und zu verfestigen - und vielleicht ist das ja für den einen oder anderen da draußen hilfreich. :)
** Kommentare oder Fragen zu der Challenge gerne per Mail an mich.**
Folgende Teilbereiche will ich beschreiben:
- Parameterübergabe in der Kommandozeile
- Layout der verschlüsselten Dateien
- Verschlüsselung
Die Klarnamen der Funktionen und Variablen stammen natürlich von mir - es sind keine Debug-Symbole in der exe.
Parameterübergabe in der Kommandozeile
Nachdem man die WinMain-Funktion gefunden hat, kann es direkt los gehen. In den ersten Instruktionen sieht man direkt: Es werden 4 Argumente erwartet und verarbeitet:
Die Adresse von argv (&argv) wird in esi gespeichert und im abgebildeten Block als Basis genutzt - die einzelnen Parameter über Offsets im Array referenziert. Zur Erinnerung die Parameter der Kommandozeile werden als argv**-Array übergeben. D.h. [esi + 4], [esi + 8] und [esi + C] sind jeweils char*. argv[0] ([esi]) ist der Name der ausführbaren Datei und wird in diesem Fall nicht beachtet.
argv[1]
Der erste Parameter wird direkt in die Funktion checkAndCopyKey(key, key_copy) übergeben. Einige Erkenntnisse, die ich hier gewonnen habe, könnt ihr im Bereich Things Learned nachlesen. Hier nur die Zusammenfassung:
- Länge des übergebenen Key-Strings: 20, sonst abbruch.
- Prüfe ob alle Zeichen Hex sind
- Konvertiere den übergebenen Key-String als Bytes nach nach key_copy
Die 2. Prüfung, ob alle Zeichen Hex-Werte sind, geschieht wie folgt:
for n in (0,2,4,...,30)
if (printf("%2X",key[n,n+1] == 1)) continue;
else Abbruch
Anschließend wird der Key "verschlüsselt": Die 16 Bytes des Key werden einer Zweierreihe und 0x55 gexored.
for n in (0...15):
key_copy[n] = *(esi + 4 + n) xor 0x55 xor 2n
Es wird sich später herausstellen: Der verschlüsselte Key wird im Dateiheader gespeichert und vermutlich daher etwas obfuskiert. :)
argv[2] und argv[3]
Die Dateien werden geöffnet - man sieht sehr schön an den auf den Stack gepushten Strings was passiert: argv[2] wird im Modus "rb" geöffnet, argv[3] als "wb" - diese Strings liegen hier in der Section .rdata und eine Referenz auf diese wird jeweils vor dem open()-call auf den Stack gepushed.
Ergebnis
Als Ergebnis haben wir also
./evil.exe key pfadZurQuelldatei pfadZurZieldatei
Wobei key ein Hex-String sein muss, 32 Zeichen und damit 16 Byte lang.
Layout der verschlüsselten Dateien
Die verschlüsselten Dateien sehen so aus:
dimi@hermes ~/bnd/Level 2 % hexdump -C b.out H
00000000 54 6f 74 61 6c 20 74 6f 6c 6c 20 76 65 72 73 63 |Total toll versc|
00000010 68 6c 75 65 73 73 65 6c 74 21 05 00 10 00 00 02 |hluesselt!......|
00000020 04 06 08 0a 0c 0e 10 12 14 16 18 1a 1c 1e 6b 0a |..............k.|
00000030 8e ba bc e0 b1 a1 11 48 ac 8d e7 17 8d 1d 04 ac |.......H........|
00000040 63 1b 52 05 72 0e 15 14 0f 0b 98 c2 13 7d c0 9a |c.R.r........}..|
00000050 cd a9 42 ba 8e 8b 36 74 53 36 1d 07 67 39 b3 08 |..B...6tS6..g9..|
00000060 06 8c 83 64 b5 6a cf 78 12 4b d0 70 00 f1 13 a0 |...d.j.x.K.p....|
00000070 de 0f a4 30 fb ea 80 df 90 22 2b ce c0 2c c0 6f |...0....."+..,.o|
00000080 65 7e ba 9c bb 6c 84 ad bd 4c 88 a7 eb 0e 82 07 |e~...l...L......|
00000090 48 6e 75 21 53 6c 41 91 f1 95 72 14 06 89 30 ec |Hnu!SlA...r...0.|
Ganz offensichtlich ist der String "Total toll verschluesselt!", der an den Anfang der Datei gepackt wird - ich nenn sie mal die BND-Magic-Bytes. ;) Weiterhin zeigt sich aber, dass insgesamt 3 Header vor die eigentliche verschlüsselte Datei geschrieben werden.
Wenn man die Funktion writeFile identifiziert hat sieht man sehr schön was passiert: writeFile wird 3 mal aufgerufen. In diesem Fall ist die Calling-Convention offensichtlich cdecl, alle Parameter werden auf dem Stack übergeben. Wir sehen: das vorletzte gepushte Argument ist jeweils die Länge, das letzte der char*, der geschrieben werden soll. Aus dieser Erkentniss ergibt sich folgendes Dateilayout:
- Der erste Teil, der in die Datei geschrieben wird, hat die Länge, die in ecx bestimmt wurde (inline strlen() nicht auf der Abbildung). Das ist im Grunde sinnlos, weil "Total toll verschluesselt!" immer 26 Byte hat.
- Während des Ablaufs wurde die Dateigröße in Byte bestimmt und in sourceFileSizeByte gespeichert. Diese wird als zweiter Wert in den Dateiheader geschrieben.
- Als drittes wird key_copy (wir erinnern uns an die Prüfung und vor allem an die Obfuskierung von argv[1], die ich im vorherigen Abschnitt beschrieben habe) in den Dateiheader geschrieben. Immer 16 Byte.
Ergebnis
Das Layout der Datei ist wie folgt:
1. 26 Byte BND-Magic-Bytes: Total toll verschluesselt!
2. 4 Byte, Dateigröße in Byte: Der Trojaner kann also nur bis zu 4 GB große
Dateien verschlüsseln
3. 16 Byte: Der verschlüsselte Encryption-Key
Verschlüsselung
Kommen wir zum spannendsten Teil: Der Verschlüsselung. Lässt man eine Datei bestehend nur aus Nullen verschlüsselt erhält man den oben teilweise abgebildeten Hexdump. ent sagt zu der Datei: 7,99842 Bit per Byte Entropy. Die hohe Entropie deutet darauf hin, dass es sich um eine gute Cipher handelt. Wäre beispielsweise die Datei einfach mit dem Key gexored worden, wäre hier ein deutlich niedrigerer Wert herausgekommen.
In hier nicht besprochenen Teilen des Disassembly prüft die Malware ob es sich bei der Größe der Datei um ein Vielfaches von 16 Byte handelt. Die Cipher scheint also die Blockgröße von 16 zu haben. Weiterhin wird die Quelldatei in einen Heap-Speicherbeich kopiert, der ein Vielfaches der Blockgröße ist. Zudem wird nochmal ein gleichgroßer Bereich alloziert, in den die verschlüsselten Daten kopiert werden (die allocs dazu sieht man in dem oben abgebildeten Bild, sind aber für das für diesen Artikel weniger wichtig).
Hat man die allocs mal erkannt sieht man sehr schön: Die in eax zurückgegebenen Pointer auf den reservierten Speicher (Quell- und Zieldatei) werden als erster und zweiter Parameter an eine Funktion übergeben. Kurz danach ist die Malware fertig mit verschlüsseln - das legt die Annahme nahe, dass es sich hierbei um die Verschlüsselungsfunktion handelt:
Zur Verschlüsselungsfunkion kommen wir gleich. Zunächst ein Blick in die expandKey-Funktion. Diese liefert den ersten deutlichen Hinweis auf die verwendete Chiffe: AES. Hier wird heftig gexored, geshifted und auf in der Binary fest hinterlegte Werte zugegriffen. Diese Werte sind Sboxen, die innerhalb von AES verwendet werden. In der Variablen expandedKey landen am Ende 240 Byte aus dem Key Schedule. Dieser Wert gibt einen falschen Hinweis auf die verwendete Schlüssellänge: 256 Bit. Wäre die Schlüssellänge 128 Bit wäre die Größe des expanded Keys 176 Byte, 208 bei 192-bit Keys. Offensichtlich wird der komplette Keyschedule gemacht, aber nur die für AES-128 Bytes notwendigen genutzt.
Eine tolle Ressource für einen Überblick über AES (den in in der Tiefe bis zu dieser Challenge nicht hatte) findet ihr hier. Von da stammt auch folgender AES-Pseudocode:
Rijndael(Data, Key) {
KeyExpansion(Key, ExpandedKey);
AddRoundKey(Data, ExpandedKey[0]);
for (i = 0; i < N; i++) {
Round(Data, ExpandedKey[i]);
}
FinalRound(Data, ExpandedKey[N]);
)
Round(Data, ExpandedKey) {
SubBytes(Data);
ShiftRows(Data);
MixColums(Data);
AddRoundKey(Data, ExpandedKey);
}
FinalRound(Data, ExpandedKey) {
SubBytes(Data);
ShiftRows(Data);
AddRoundKey(Data, ExpandedKey);
}
Den Aufruf KeyExpansion() haben wir eben schon gesehen. Alle weiteren Teile von AES finden wir innerhalb der Funktion encryptFile, beziehungsweise von dort aus aufgerufenen Funktion AES(). Zunächst zum AES-Betriebsmodus, der in der Funktion encryptFile zu finden ist.
Man sieht sehr schön, dass die große Schleife innerhalb der encryptFile-Funktion als Abbruchbedingung die Anzahl der Blöcke der Datei hat. ( ganz unten, "dec [ebp + blockCount]"). Nach einigem Reverse Engineering erkennt man auch den Operationsmodus der Chiffre: CBC. Es wandert jeweils ein 16-ByteBlock als Feedback in die große, blockverarbeitende Schleife. Dieser Block wird zunächst mit dem neuen Quelldaten-Block gexored. Das Gesamtbild:
Jeder gexorte Block wandert in die Funktion AES(), welche die im obigen Pseudocode beschriebenen Funktionen implementiert zeigt. Der Compiler(?) macht aus den Funktionen AddRoundKey, Round und FinalRound eine Funktion, die ich AES() genannt habe (00E0180A im Bild). Man erkennt alle drei Teile von AES sehr schön: die Schleife für die Runden, den AddRoundKey-Aufruf, der vor der ersten AES-Runde passiert und die FinalRound. Schaut den Disassembly an, ein Bild spare ich mir aufgrund der Größe.
Für den Betriebsmodus CBC fehlt uns noch ein wichtiger Parameter: Der Initialisierungsvektor. Dieser wird noch in WinMain() berechnet und basiert auf folgendem, einfachen Algorithmus:
for n in (0..15):
iv[n] = Das letzte Byte der Dateigröße in Byte xor n
Ergebnis
Die verwendete Verschlüsselung lässt sich einfach zusammenfassen: AES-128-CBC mit einem IV basierend auf der Dateigröße und dem übergebenen Key.
Denkt dran: Die Dateigröße der entschlüsselten Datei steht im Header der verschlüsselten Daten. Genau wie der obfuskierte Key.
Decryptor
Folgender Befehl entschlüsselt das obige Urlaubsfoto des BND.
openssl enc -d -in Urlaubsphoto2.png.crypt.headerless -out Urlaubsphoto2.png.crypt.decrypted -iv f8f9fafbfcfdfefff0f1f2f3f4f5f6f7 -K df892ceecabd40b1ea5151b930ae9bee -aes-128-cbc -nosalt -nopad
Der folgende Decryptor gibt ein fertiges openssl-Kommando zum entschlüsseln jeder beliebigen Datei zurück. Die verschlüsselte Datei ist einziger Parameter.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
Lessons learned
Folgende beiden Punkte sind mir insbesondere aufgefallen. Falls jemand tieferes Wissen zu diesen Themen dazu hat, nehme ich Hinweise gerne über die üblichen Kanäle entgegen! Inbesondere wenn die Feststellungen nicht stimmen... ;)
Mehrere Calling Conventions
Da die Malware offensichtlich statisch hineinkopmpilierte Teile enthält findet man scheinbar mehrere Calling Conventions innerhalb der einen Binary. Der Compiler selbst nutzt für die vom Autor geschriebenen Funktionen fastcall:
Man könnte, wenn die statisch gelinkte Library eine andere Calling Convention nutzt als der verwendete Compiler, also relativ elegant herausfinden ab welcher Stelle eine Library genutzt wird - in der Abbildung werden die Parameter per cdecl übergeben, die Funktion selbst wird per fastcall aufgerufen.
Inline-Funktionen
An mehreren Stellen wird der Key ent- und verschlüsselt / obfuskiert mittels einer Funktion, die ich xorKey genannt habe. Der Compiler scheint diese Funktion an einer Stelle inline eingebettet zu haben, an den anderen Stellen aber den klassischen Funktionsaufruf beibehalten zu haben. Eventuell wurde das auch so programmiert, scheint mir aber eher unwahrscheinlich. Ist das "normales" Compiler-Verhalten, wenn Optimierung beim Kompilieren aktiviert werden?