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 :
- 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.
- 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 :
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 danssys/mman.h
)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 dansunistd.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)
- ((size_t) code % pagesize),
code // Taille de la zone
,
pagesize// Nouvelle permissions
| PROT_WRITE | PROT_EXEC
PROT_READ ) ;
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 :
mmap()
pour allouer une zone mémoire avec les permissions qui vont bien (déclarée danssys/mman.h
),memcpy()
pour copier le shellcode vers cette zone douillette (déclarée dansstring.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_WRITE | PROT_EXEC,
PROT_READ // Drapeaux
// * MAP_PRIVATE : ne pas partager
// * MAP_ANONYMOUS : ne pas lire de fichier
| MAP_ANONYMOUS,
MAP_PRIVATE // spécifiques pour MAP_ANONYMOUS
-1, 0);
(buffer, code, sizeof(code)) ;
memcpy
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 ?