Testeur de shellcode

Divulgâchage : Depuis quelques temps, les codes d’exemples de shellcodes se mettent à segfaulter… Pourquoi se faite-ce ? et Comment l’éviter !

Alors que nous étions en train de préparer nos cours de sécurité à l’UBS tranquillement, nous sommes tombés sur une régression dans certains exemples d’exécution des shellcode…

Habituellement, après avoir traduit notre assembleur en opcodes (sous forme de chaîne avec plein d’hexadécimal), on utilise le classissîme code en C qui place le shellcode dans une chaîne puis l’exécute comme s’il s’agissait d’une fonction.

Pour notre shellcode Linux 64 bits ça donne ça :

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

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 main(int argc, char **argv) {
    
    int (*ret)() = (int(*)())code;

    ret();
}

En compilant ce code puis en l’exécutant on peut alors vérifier que le shellcode est correct. Sans oublier les options -fno-stack-protector -z execstack bien sûr. Voici ce que ça a donné :

tbowan@testlinux:~$ gcc shellcode.c -o shellcode -fno-stack-protector -z execstack
tbowan@testlinux:~$ ./shellcode
Segmentation fault (core dumped)

SEGMENTATION FAULT. Mais comment se fait-ce ? D’où il me parle comme ça ! Ça marchait pourtant très bien la dernière fois qu’on a testé ! Qu’a-t-il bien pu arriver ?

Quel est le problème ?

L’intuition nous suggère un problème de droits d’accès en exécution à la mémoire où se trouve le shellcode mais comment s’en assurer ?

Comprendre l’erreur de segmentation

Avertissement : la suite utilise abondamment gdb et ne devrait donc être lue que par un public averti. Âmes sensibles vous êtes prévenus.

Plutôt que de récupérer un core dump, on va directement lancer le binaire dans gdb. Voici ce que ça donne chez nous (les portions inutiles sont remplacées par […], histoire d’être plus digeste). Comme prévu, l’erreur de segmentation se reproduit.

tbowan@testlinux:~$ gdb shellcode
[...]
(gdb) r
[...]
Program received signal SIGSEGV, Segmentation fault.
0x0000555555558020 in code ()

Sauf qu’avec gdb, on peut savoir quelle instruction pose problème. Puisqu’il nous fourni l’adresse de l’instruction (0x0000555555558020), on peut demander à gdb de désassembler la mémoire correspondante :

(gdb) disass 0x0000555555558020
Dump of assembler code for function code:
=> 0x0000555555558020 <+0>:     mov    $0x3b,%rax
   0x0000555555558027 <+7>:     mov    $0x0,%rdx
   0x000055555555802e <+14>:    movabs $0x68732f6e69622f,%r8
[...]

Pour les curieux, on peut écrire disass (comme tbowan) ou disas (comme aryliin). Chacun sa prononciation.

Mettre 59 dans le registre rax ne devrait pas poser de problème de segmentation car elle n’effectue aucun accès à la mémoire…

Puisque l’instruction en elle-même n’y est pour rien, peut être est-ce l’endroit où elle se trouve ? Demandons-donc à gdb la liste des zones mémoires avec leurs permissions…

(gdb) info proc mappings
[...]
          Start Addr           End Addr       Size     Offset  Perms  objfile
[...]
      0x555555558000     0x555555559000     0x1000     0x3000  rw-p   /home/tbowan/shellcode
[...]

Et effectivement, la zone mémoire correspondante (0x555555558000) n’a pas les droits d’exécution (elle est en rw-p, il manque le x).

Si vous ne vous rappelez pas que mov $0x3b, %rax est bien la première instruction du shellcode, on peut demander à gdb où il commence :

(gdb) p &code
$2 = (<data variable, no debug info> *) 0x555555558020 <code>

L’instruction qui génère l’erreur est bien la première du shellcode qui n’a donc pas pu s’exécuter du tout ; l’erreur de droit ayant été levée au moment où le shellcode est chargé dans la mémoire pour exécution (ce que ses permissions n’autorisent pas).

Mais pourquoi ?

Lorsqu’on rencontre un code pour la première fois et qu’il segfault, on peut se dire que l’auteur du blog ne l’a pas testé, qu’il ne s’y connait peut être pas tant que ça et par les temps qui courent on peut aller à se demander s’il ne s’agirait pas d’un contenu généré par IA.

Sauf que les auteurs, c’est nous. Et qu’on se souvient avoir vu ce type de code et l’avoir testé des centaines de fois… Alors pourquoi ça ne marche plus ?

Ça tombe bien, quelqu’un (f0rm2l1n) s’est déjà posé la question et a eu la gentillesse de documenter ses découvertes dans un article ; What is happened to execstack?

Pour vous résumer : le noyau linux a été modifié en mars 2020 pour que l’option execstack n’implique plus de rendre toutes les zones lisibles exécutables mais se contente de ne toucher qu’à la pile (ce qui est plus compréhensible).

Donc tous ces codes C qui testaient les shellcodes en les plaçant en variable globale ne fonctionnaient que parce que execstack faisait plus que ce qu’il devait faire. Sur un noyau corrigé, ces codes ne marchent plus.

Le temps que le changement se répande dans les versions diffusées puis qu’elles soient intégrées dans les distributions et que ces distributions soient enfin déployées un peu partout, ça explique le délais entre la correction et la régression.

Quelles solutions ?

Alors c’est bien gentil de constater que ça marche plus mais comment qu’on fait-ce maintenant ?

Déplacer dans la pile

La première variante est toute bête. Puisqu’on dispose déjà d’un code C et d’une chaîne de compilation utilisant execstack, déplaçons le shellcode des variables globales vers la pile…

#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();
}

Ce n’est pas la solution la plus belle mais elle a au moins deux avantages :

  1. Elle ne modifie le code d’exemple qu’un tout petit peut. Si quelqu’un arrive chez nous après avoir lu des documentations similaires, il retrouvera ses repères plus facilement.
  2. Vu que le but est souvent d’expliquer les débordements dans la pile, exécuter un shellcode stocké dans la pile est pédagogiquement intéressant.

Mais on n’est pas toujours en train de vouloir expliquer les débordements dans la pile et on n’a pas toujours une chaîne de compilation avec les options qui vont bien…

Autoriser l’exécution

Pour être un peu plus propre on peut vouloir se passer de execstack. Quand le but est de montrer qu’un shellcode est bien un bout de code exécutable, c’est un gros détour de devoir parler de la pile, de ses protections, de pourquoi il y en a, de comment on les désactives et autres détails inutiles.

Pour exécuter notre shellcode, on va donc marquer la zone mémoire qui le contient comme étant autorisée à être exécutée. Pour ça nous allons utiliser deux fonctions :

  1. mprotect() qui permet de modifier les permissions des pages mémoires et a donc besoin de l’adresse de la page à modifier (déclarée dans sys/mman.h)
  2. sysconf() qui permet, entre autre, de connaître la taille des pages, ce qui nous permettra de calculer l’adresse de la page à modifier (déclarée dans unistd.h).
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>

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 main(int argc, char **argv) {

    size_t pagesize = sysconf(_SC_PAGE_SIZE) ;
    mprotect(
        // Adresse de la zone (alignée)
        code - ((size_t) code % pagesize),
        // Taille de la zone
        pagesize,
        // Nouvelle permissions
        PROT_READ | PROT_WRITE | PROT_EXEC
    ) ;

    int (*ret)() = (int(*)())code;

    ret();
}

Cette fois la compilation est bien plus simple puisqu’on n’a plus besoin de supprimer des protections mémoires. Un simple make (qui utilisera les règles implicites) suffit.

tbowan@testlinux:~$ make shellcode
cc     shellcode.c   -o shellcode
tbowan@testlinux:~$ ./shellcode
$

Allouer une portion dédiée

On ne va pas se mentir, utiliser mprotect() c’est un peut comme tuer un moustique avec du napalm, il y a des effets collatéraux pour les trucs autour de la cible.

Alors plutôt que modifier toute la zone contenant le shellcode et potentiellement d’autres trucs qu’on voudrait pas toucher, on peut allouer une zone rien que pour lui. C’est que nous avions d’ailleurs fait pour notre shellcode pour Windows 10.

Cette version similaire pour GNU/Linux va donc utiliser deux fonctions :

  1. mmap() pour allouer une zone mémoire avec les permissions qui vont bien (déclarée dans sys/mman.h),
  2. memcpy() pour copier le shellcode vers cette zone douillette (déclarée dans string.h).
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

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 main(int argc, char **argv) {

    size_t pagesize = sysconf(_SC_PAGE_SIZE) ;

    void * buffer = mmap(
        // Adresse de la page,
        // NULL => le noyau se débrouille
        NULL,
        // Taille de la page
        pagesize,
        // Permissions (toutes)
        PROT_READ | PROT_WRITE | PROT_EXEC,
        // Drapeaux
        // * MAP_PRIVATE   : ne pas partager
        // * MAP_ANONYMOUS : ne pas lire de fichier
        MAP_PRIVATE | MAP_ANONYMOUS,
        // spécifiques pour MAP_ANONYMOUS
        -1, 0);
    
    memcpy(buffer, code, sizeof(code)) ;

    int (*ret)() = (int(*)())buffer;

    ret();
}

Et après ?

Avec ces changements on peut recommencer à tester nos shellcodes comme avant. Mais tant qu’à y être, ne pourrait-on pas améliorer le code un peut ?

Personnellement, je n’ai jamais aimé l’utilisation d’un pointeur de fonction pour exécuter le shellcode. Ça marche, c’est pas le problème, mais c’est moche et ça demande d’expliquer les pointeurs de fonctions.

Pédagogiquement, on veut juste expliquer qu’on peut passer le flux d’exécution sur le shellcode et on se retrouve à devoir expliquer la syntaxe des appels sur des pointeurs de fonctions en C :

int (*ret)() = (int(*)())code;
ret();

Et encore, là c’est la version « facile » qui fait un cast explicite du buffer (un unsigned char *) en pointeur de fonction (int(*)()) avant de l’appeler. Mais on peut aller plus loin dans l’horreur en faisant tout d’une seule ligne de code :

((int(*)()) code) () ;

Surtout que le shellcode ne retourne jamais de valeur… Il pop un shell, un client ou un serveur qui attend des ordres, ce genre de chose. Au pire, en cas de problème, il exit mais il ne retourne pas.

Alors, plutôt qu’un appel de fonction (call en assembleur), si on sautait directement dans le shellcode (avec un jmp) ? Ça tombe bien, il existe une instruction C pour ça1 :

goto *code ;

N’est-ce pas bien plus clair comme ça ?