Written by aaSSfxxx -
Ce writeup porte sur le challenge le plus difficile que j'ai fait sur le CTF (m'ayant occupé quelques heures pour le casser). A première vue, on a un binaire "obfusqué", qui fait des appels système à mmap et mprotect: on pense tout de suite à un packer, et il va donc falloir l'unpacker.
L'unpacking ne pose pas trop problème sous Linux lorsqu'on a l'habitude des packers de malware sous Windows, grand merci à radare2 et son mode visuel.
L'entrypoint du crackme ressemble donc à ceci:
;-- entry0:
;-- rip:
0x0046cbe8 50 push rax
0x0046cbe9 52 push rdx
0x0046cbea e8c8020000 call 0x46ceb7
0x0046cbef 55 push rbp
0x0046cbf0 53 push rbx
0x0046cbf1 51 push rcx
0x0046cbf2 52 push rdx
0x0046cbf3 4801fe add rsi, rdi
0x0046cbf6 56 push rsi
0x0046cbf7 4889fe mov rsi, rdi
0x0046cbfa 4889d7 mov rdi, rdx
0x0046cbfd 31db xor ebx, ebx
0x0046cbff 31c9 xor ecx, ecx
0x0046cc01 4883cdff or rbp, 0xffffffffffffffff
On a un appel à une fonction 0x46ceb7, suivi d'une fonction visiblement de décompression, qui ressemble fortement à aplib ou une de ses variantes. On a donc:
┌ 6: fcn.0046ceb7 ();
│ 0x0046ceb7 5d pop rbp
└ 0x0046ceb8 e840ffffff call fcn.0046cdfd
suivi de
; CALL XREF from fcn.0046ceb7 @ 0x46ceb8
┌ 186: fcn.0046cdfd (int64_t arg_0h);
│ ; var int64_t var_bh @ rbp-0xb
│ ; var int64_t var_10h @ rsp+0x30
│ ; var int64_t var_8h @ rsp+0x38
│ ; arg int64_t arg_0h @ rsp+0x40
│ 0x0046cdfd 5f pop rdi ; /proc/self/exe
│ 0x0046cdfe 29f6 sub esi, esi
│ 0x0046ce00 6a02 push 2 ; SYS_open
│ 0x0046ce02 58 pop rax
│ 0x0046ce03 0f05 syscall
│ 0x0046ce05 50 push rax
│ 0x0046ce06 488db70f0000. lea rsi, [rdi + 0xf]
│ 0x0046ce0d ad lodsd eax, dword [rsi]
│ 0x0046ce0e 83e0fe and eax, 0xfffffffe ; 4294967294
[.. snip ..]
│ 0x0046cea0 4190 xchg eax, r8d
│ 0x0046cea2 4889f7 mov rdi, rsi
│ 0x0046cea5 5e pop rsi
│ 0x0046cea6 ffd5 call rbp
│ 0x0046cea8 59 pop rcx
│ 0x0046cea9 5e pop rsi
│ 0x0046ceaa 5f pop rdi
│ 0x0046ceab 5d pop rbp
│ 0x0046ceac 6a05 push 5 ; 5
│ 0x0046ceae 5a pop rdx
│ 0x0046ceaf 6a0a push 0xa ; 10
│ 0x0046ceb1 58 pop rax
│ 0x0046ceb2 0f05 syscall
└ 0x0046ceb4 41ffe5 jmp r13
En debugguant dans les calls en mode visuel (F7), on voit des calculs d'alignement pour récupérer la taille du binaire à mapper et de la zone "anonymous" à créer. Puis, on a voit deux syscalls à mmap. Le premier sert à faire un mapping "anonymous" qui contiendra le code de décompression/mapping, puis un pour charger le binaire. On a ensuite un "call rbp" (qui appelle la fonction de décompression située en 0x0046cbef, avant de faire un mprotect sur la page allouée et sauter dessus.
Une fois le saut effectué, on atterrit ici:
0x7fb3749e3ed0 e84a000000 call 0x7fb3749e3f1f
0x7fb3749e3ed5 83f949 cmp ecx, 0x49 ; 73
┌─< 0x7fb3749e3ed8 7544 jne 0x7fb3749e3f1e
│ 0x7fb3749e3eda 53 push rbx
│ 0x7fb3749e3edb 57 push rdi
Même principe que tout à l'heure, on rentre dans le call (F7 en mode visuel), et on retombe sur une autre fonction qui effectue d'autres opérations, suivi d'un
0x7fb3749e401e 41ff66f8 jmp qword [r14 - 8]
On peut raisonnablement penser qu'on approche de notre OEP, on positionne donc un breakpoint (F2), et on continue l'exécution (F9), avant de repasser en mode pas à pas.
On se retrouve ensuite avec
;-- rip:
0x0048c48e 0f05 syscall
0x0048c490 5a pop rdx
0x0048c491 c3 ret
et une fois arrivé au ret, on arrive enfin à l'OEP du binaire unpacké ! Il ne nous reste plus qu'à faire un dump du programme, en listant d'abord les sections avec "dm":
[0x0044fd80]> dm
0x0000000000400000 - 0x000000000048d000 * usr 564K s r-x unk0 unk0 ; map.home_supersnail_Documents_hack_lab_AeroCTF_goaway.r_x
0x000000000048d000 - 0x0000000000525000 - usr 608K s r-- unk1 unk1
0x0000000000525000 - 0x000000000055a000 - usr 212K s rw- unk2 unk2
On peut donc dump le binaire unpacké avec la commande wtf goaway.unpack 0x15a000 @0x400000
, la taille du binaire étant de 0x55a000-0x400000.
En passant un coup de strings sur le crackme unpacké, on peut voir qu'il a été écrit en... Go, soit le début du cauchemar.
Malheureusement pour moi, le crackme était en Go: en effet, un runtime assez conséquent se retrouve embarqué dans les programmes Go, rendant difficile l'identification des fonctions "utiles" pour notre analyse du runtime. Néanmoins, le mécanisme de RTTI de Go nous permet malgré tout de nous en sortir, puisque le nom des fonctions (et visiblement des types de variables/arguments) est malgré tout préservé, ce qui nous facilite grandement la tâche.
Ma première tentative a été d'utiliser r2-gohelper, cependant le script ne renommait que quelques fonctions, le rendant complètement inutile. De plus, radare2 reste encore assez trop limité pour l'analyse statique, j'ai donc sorti mon bon vieux IDA Free, pour analyser la fonction "main.main" (une des rares identifiées par r2-gohelper).
Après une vaine tentative de comprendre tout le code du runtime, je finis par avoir la bonne idée de faire une recherche de strings dans IDA, qui trouve plein de noms de fonctions. N'ayant pas IDAPython (car édition Free, merci Ilfak \o/), je me lance donc dans la résolution manuelle des RTTI pour chaque fonction appelée:
J'arrive finalement à obtenir quelque chose ressemblant à ceci après quelques temps à tout renommer:
Un autre point difficile a été de comprendre la convention d'appel utilisée par Go. En effet, contrairement au C où un seul paramètre est retourné, Go, peut renvoyer plusieurs valeurs de retour, qui sont recopiées sur la stack frame de la fonction appelante. La pile ressemble donc à quelque chose comme ceci:
+-------------------+
| sauvegarde ebp |
+-------------------+
| adresse de retour |
+-------------------+
| argument 1 |
| ... |
| argument n |
| retour 1 |
| ... |
| retour n |
+-------------------+
| vars locales |
+-------------------+
Vient également le mécanisme de "slice" beaucoup utilisé par Go, qui n'est en réalité qu'une structure qui pourrait être définie comme ceci (en 64 bits):
struct slice {
void *pointer;
uint64_t size;
uint64_t allocated_size;
}
Une fois ceci compris, on peut enfin étudier le fonctionnement du programme dans de bonnes conditions.
Le crackme commence par afficher le message de bienvenue, puis lire et stocker l'entrée utilisateur (via bufio.Reader.ReadString). Le crackme supprime ensuite le caractère "\n", avant de créer une HashMap.
Cette HashMap contient une table de "permutations", qui nous sera utile pour la suite. Puis, le crackme crée un tableau de slices, pointant vers une "chaîne" de 16 octets, qui ressemble curieusement à un hash. Puis après une vérification de la taille du flag (qui doit aussi être de 16 octets), on arrive à une première boucle sur les caractères du flag: pour chaque caractère, on calcule le hash MD5 du caractère.
Puis ensuite on récupère la valeur de permutation correspondant à l'index du caractère de la chaîne, et compare le hash md5 au slice dont l'index est la permutation:
En pseudocode "pythonisé", on obtiendrait quelque chose comme ceci:
slice = <le tableau de md5>
permutations = [0, 1, 2, 3, 1, 4, 5, 1, 6, 5, 1, 5, 7, 8, 7, 9]
for i in range(len(serial)):
if md5(serial[i]) != slice[permutations[i]]
print "Bad boy"
exit(1)
print "Good boy"
Pour récupérer la clé, en théorie, il nous suffit donc de calculer le md5 chaque caractère de la table ASCII, et comparer son hash avec celui trouvé dans le programme. Mais comme toute solution théorique, ça ne fonctionne pas en pratique pour des raisons obscures...
Ne comprenant pas ce qu'il se passe, j'ai fini par opter pour une solution plus radicale: exécuter l'implémentation de MD5 du binaire directement, en pilotant le debugger de radare2 via r2pipe depuis mon script python. Après quelques essais, je suis donc parvenu à réaliser le petit script suivant:
#!/usr/bin/python
import r2pipe
import hashlib
import binascii
import os
flag_md5_offsets = [0xb, 0x06, 0x0a, 0x0c, 0x7, 0x5, 0x9, 0x22, 0x8, 0x2]
hashes = []
alphabet_dict = {}
r2 = r2pipe.open("goaway.unpack")
r2.cmd("ood")
r2.cmd("db 0x48c10f")
r2.cmd("db 0x48b812")
r2.cmd("db 0x48c21e")
r2.cmd("db 0x48c223")
for i in range(0, 0x7f):
r2.cmd("dc")
r2.cmd("dr edx=%d" % i)
r2.cmd("dr rip=0x%x" % 0x48c1ff)
r2.cmd("dc")
r2.cmd("s rsp+0x4f; wx 0x%x" % (i << 16 | 0x10))
print(r2.cmd("dc"))
md5 = binascii.hexlify(bytes(r2.cmdj("pxj 16 @rsp+0x18")))
alphabet_dict[md5] = chr(i)
r2.cmd("ood")
chars = []
for offset in flag_md5_offsets:
addr = 0x4bf20c + (offset * 16)
#print(hex(addr))
nochr = binascii.hexlify(bytes(r2.cmdj("pxj 16 @0x%x" % addr)))
hashes.append(nochr)
chars.append(alphabet_dict[nochr])
subst = [0, 1, 2, 3, 1, 4, 5, 1, 6, 5, 1, 5, 7, 8, 7, 9]
toto = ""
for i in subst:
toto += chars[i]
print(toto)
Le script nous donne directement le bon flag, "secretkeykeklol1". Néanmoins, le programme est capricieux, et lorsqu'on lui donne le bon flag, on obtient
hmmmm...... key is correct! But I changed my mind about printing you a flag
.....
Instead, I will display you a flag for the key 'testtesttesttest'
flag: <caractères non imprimables>
Le flag de validation est donc chiffré, et d'après notre travail de renommage chiffré en AES. La clé "testtesttesttest" encodée sous forme hexadécimale, est passée à la fonction main.ExampleNewCBCDecrypter (qui fait du AES CBC comme son nom l'indique, merci Captain Obvious). Comme on est en CTF, on va donc remplacer la clé à peine décodée par notre flag, puis continuer l'exécution via:
$ r2 -d ./goaway.unpack
-- Everybody hates warnings. Mr. Pancake, tear down this -Wall
[0x0044fd80]> db 0x48b642
[0x0044fd80]> dc
Go away I will not give you a flag!
But if you guess the key I'll print you a flag....
guess: secretkeykeklol1
hmmmm...... key is correct! But I changed my mind about printing you a flag
.....
Instead, I will display you a flag for the key 'testtesttesttest'
flag: hit breakpoint at: 48b642
[0x0048b642]> w secretkeykeklol1 @rax
[0x0048b642]> dc
Aero{3475964bdbfe31fbb40d812fa2f88114765baf72fd7ef0a912c746312bbdc07b}
[0x0044fd9b]>
On a ainsi récupéré notre flag de validation (et je fus le 1er à valider ce challenge 😎)