Write-UP AeroCTF 2020: Go away

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.

Unpacking du crackme

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.

Reversing Go for fun and chocapicz

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.

Algorithme du crackme

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

Récupération de la clé et pwn du crackme

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 😎)