Partie 12 - Pistes générales d’exploitation dans la heap - concepts clés (1/2)
Pistes générales d’exploitation dans la heap : concepts clés (1/2)
On peut parler d’autre chose que des corbeilles 🤯 ?
C’est bon, j’ai compris, on va faire autre chose 😆 ! Il reste encore quelques types de corbeilles à voir mais laissons-les pour plus tard. Nous avons déjà vu le tcache et les fastbins ce qui vous a permis d’être à l’aise avec le fonctionnement global du tas, des métadonnées et des corbeilles.
On a vu pas mal de choses effectivement, mais jusqu’à présent tu ne nous as toujours pas montré comment ouvrir un shell en exploitant les programmes via le tas 🤨.
Ça tombe bien, c’est exactement ce dont nous allons parler dans ce chapitre : les différentes pistes d’exploitation !
Heap feng shui
Le heap feng shui n’est pas une technique d’exploitation en tant que telle, il s’agit plutôt d’une approche que l’on adopte souvent lorsque l’on tente d’exploiter un programme utilisant le tas.
Pour ce qui est de l’origine du terme feng shui, cela vient d’une manière d’organiser l’espace, pratiquée principalement en Chine.
À l’instar de l’organisation d’une ville où des bâtiments sont agencés d’une manière très précise, le heap feng shui consiste à organiser les blocs, via des allocations et libérations, de telle sorte que l’on puisse réussir à exploiter le tas.
Pour illustrer cela, imaginons un programme (64 bits) permettant d’allouer les structures suivantes en mémoire :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct bloc_0x20 {
char buf[8];
void *ptr;
unsigned long long hash;
};
struct bloc_0x30 {
char buf[0x18];
void *ptr;
unsigned long long hash;
};
struct bloc_0x60 {
char buf[0x48];
void *ptr;
unsigned long long hash;
};
La structure
struct bloc_0x20a une taille de0x18octets en mémoire. Néanmoins, appelermalloc(0x18)revient à allouer un bloc de0x20octets. Idem pour les deux autres structures.
Supposons désormais que le programme gère très bien l’allocation et libération de mémoire de toutes les structures, sauf la dernière : une vulnérabilité permet d’utiliser la zone mémoire d’un bloc de type bloc_0x60 même après sa libération, entraînant ainsi un cas de use-after-free.
L’objectif est de modifier le pointeur ptr d’un bloc de type bloc_0x60 en utilisant seulement des allocations et libérations, sachant que l’on peut contrôler totalement le contenu des buffers de n’importe quel bloc. En revanche, nous ne contrôlons pas le contenu des pointeurs ptr ni du hash. Comment faire ?
De manière générale, n’hésitez pas à faire des schémas de la structure du tas en fonction des différents types de blocs qu’il est possible d’allouer. Vous verrez que cela permet de trouver plus rapidement un agencement permettant d’atteindre l’objectif.
Si vous essayez de tout faire de tête, vous risquez d’avoir une migraine, et c’est du vécu 😅.
Pour que ce soit plus simple à expliquer, je vous propose de regarder ensemble ce qui se passe au cours des allocations et libérations suivantes :
Voici ce qui s’y passe :
malloc(sizeof(struct bloc_0x60)): une allocation de0x58octets est demandée, le programme retourne alors un bloc mémoire de taille0x60;free(0x500000000010): le précédent bloc alloué est libéré. A priori, cela n’implique pas de souci en particulier. Cependant, rappelez-vous, nous avons supposé que ce programme contient un use-after-free, ce qui permet de toujours accéder aux champsbuf,ptrethashdu bloc de taille0x60;malloc(sizeof(struct bloc_0x20)): le but est de modifier le pointeurptr(celui dubloc_0x60) avec une valeur arbitraire. Il faut donc trouver un agencement permettant d’avoir une variable que l’on contrôle (commebuf) à cet emplacement. Pour y parvenir, nous allons allouer deux blocs de0x20octets et un bloc de0x30octets ;malloc(sizeof(struct bloc_0x20)): allocation du second bloc de taille0x20;malloc(sizeof(struct bloc_0x30)): allocation d’un bloc de taille0x30. Nous observons que le champbufdu bloc de taille0x30coïncide avec le champptr🟣 du bloc de taille0x60libéré. Nous contrôlons donc ce pointeur viabuf! En exploitant le use-after-free, nous pouvons appeler n’importe quelle fonction de manière arbitraire !
Mais tu as fait comment pour savoir qu’il fallait faire les allocations précisément dans cet ordre 🧐 ?
En faisant un schéma 😊, je ne vous ai pas menti quand j’ai dit qu’il est plus facile de trouver un bon agencement en faisant un schéma.
J’insiste encore une fois, lorsque vous démarrez l’exploitation d’un programme utilisant le tas, prenez bien le temps de voir les différentes tailles de blocs que vous pouvez allouer, la manière dont elles peuvent se chevaucher.
Cela vous permet de savoir à l’avance quelles sont les corbeilles que vous allez sans doute devoir utiliser et celles qui, a priori, ne seront pas utilisées (sauf si vous bidouillez la taille des blocs 😉).
Les malloc hooks - pre glibc 2.34
Dans ce chapitre, nous allons enfin voir comment convertir une écriture arbitraire depuis le tas en exécution de code. On avait déjà vu comment faire cela lorsqu’il existe des pointeurs de fonctions que l’on peut modifier. La question se pose lorsqu’il n’y a, a priori, pas de pointeurs de fonctions utilisables.
Malheureusement, depuis la version 2.34 de la glibc, ces hooks ne sont plus disponibles 😔. Cela rend l’exploitation plus compliquée dans le tas, mais pas impossible 😉 !
Nous verrons un peu plus loin comment faire en utilisant la variable globale
__exit_funcs.
Que sont les malloc hooks ?
Les hooks de malloc sont des variables globales définies dans le fichier malloc.c de la glibc. Il y en a 5 mais les deux premiers sont les plus utilisés :
__free_hook;__malloc_hook;__realloc_hook;__memalign_hook;__after_morecore_hook.
Chaque hook a initialement la valeur NULL. Tant que leur valeur est NULL, le programme fait comme si ces hooks n’existaient pas.
A la base, ces hooks sont mis à la disposition des développeurs afin qu’ils puissent fournir une implémentation personnalisée de free, malloc etc. en fonction des besoins et du contexte de leur programme en les utilisant comme des pointeurs de fonction.
Que se passe-t-il lorsqu’un hook ne vaut pas
NULL?
Pour répondre à la question, voyons ce qu’il se passe dans la fonction malloc lorsque __malloc_hook ne vaut pas NULL :
1
2
3
4
5
6
7
8
void * __libc_malloc (size_t bytes)
{
mstate ar_ptr;
void *victim;
void *(*hook) (size_t, const void *) = atomic_forced_read (__malloc_hook);
if (__builtin_expect (hook != NULL, 0))
return (*hook)(bytes, RETURN_ADDRESS (0));
Comme vous pouvez le voir à la dernière ligne, si __malloc_hook contient une valeur non nulle, a priori un pointeur de fonction, ladite fonction est alors appelée et remplace le mécanisme d’allocation de malloc car le malloc “classique” ne poursuivra pas son exécution.
En termes de pwn deux choses attirent notre attention :
- le moment à partir duquel la fonction est appelée : au tout début de
malloc. Ainsi, il n’y a pas de vérifications réalisées auxquelles nous devrons faire attention ; - les arguments utilisés lors de l’appel de
__malloc_hook: le premier argument est le nombre d’octets à allouer. Il s’agit du paramètre classique utilisé dansmalloc. Le second argument est l’adresse de retour mais n’est généralement pas utile.
Idem pour le __free_hook appelé dès le début de la fonction free.
Ok mais concrètement on fait comment pour ouvrir un shell avec ça ?
C’est très simple, voyons un exemple de scénario d’exploitation. Nous supposons que nous avons déjà une primitive permettant de réaliser une écriture arbitraire, que l’on a contourné l’ASLR etc. :
- utiliser l’écriture arbitraire pour écrire dans
__free_hookl’adresse de la fonctionsystem; - allouer un bloc et y écrire
"/bin/sh"(par exemple, l’adresse du bloc retourné peut être0x5000000010) ; - libérer ce bloc.
free(0x5000000010)sera appelé ➡️ cela déclenche l’appel à__free_hook(0x5000000010)qui n’est autre que ➡️system(0x5000000010)ce qui revient à appeler ➡️system("/bin/sh").
Pas mal non 😎 ? D’ailleurs, si vous souhaitez voir le contenu de ces hooks dans gdb, vous pouvez utiliser ce type de commande :
1
2
gef> x/xg &__free_hook
0x7ffff7fc2e40 <__free_hook>: 0x0000000000000000
Pour savoir s’il vaut mieux écraser
__free_hookou__malloc_hook, il faut savoir quel type d’argument on souhaite utiliser.Par exemple avec
__free_hook, c’est un pointeur qui lui sera généralement donné en paramètre tandis qu’avec__malloc_hookil s’agit d’un nombre d’octets, ce qui n’est pas commode à utiliser si on souhaite spécifier une adresse en paramètre (sauf si le programme vous permet de choisir une taille d’allocation énorme).Ainsi, c’est généralement
__free_hookqui est utilisé pour rediriger l’exécution vers une fonction arbitraire.
Les one gadgets
Dans le cas où je souhaite appeler une fonction avec plusieurs arguments (comme
execve("/bin/sh",argv,env)), je fais comment ?
Si la fonction à exécuter est execve il est possible d’utiliser, ce que l’on appelle, un one gadget.
Il est parfois possible d’appeler également
execlavec cette méthode.
Comme son nom l’indique, il s’agit d’un gadget comme ceux que l’on a déjà utilisé en ROP. C’est-à-dire que c’est une instruction à laquelle il est possible de sauter, dans le cadre d’une chaîne de ROP, afin d’exécuter du code arbitraire.
Néanmoins lorsque l’on utilise les malloc hooks, nous ne contrôlons pas une chaîne de ROP mais seulement l’adresse à laquelle le programme ira une fois que la fonction idoine (free, malloc …) sera appelée.
Ça s’annonce compliqué cette histoire : faire du ROP avec une seule adresse 😖 …
Attendez ! Je n’ai pas fini d’expliquer ce qu’est un one gadget. J’ai expliqué la partie “gadget” mais pas la partie “one” 😏. Ce type de gadget est qualifié de “one gadget” car il permet d’exécuter plusieurs instructions en même temps, en un saut.
Pour être plus précis, il s’agit de gadgets qui permettent d’exécuter directement execve en ayant, plus ou moins, la main sur les arguments. Leur présence s’explique par le fait que la libc a parfois besoin d’appeler execve("/bin/sh",...,...) pour diverses raisons. L’idée derrière l’utilisation d’un one gadget est d’exploiter des appels déjà présents dans libc pour exécuter execve, sans avoir à gérer manuellement la construction des arguments comme c’est le cas dans une chaîne de ROP classique.
Généralement, le premier argument sera toujours "/bin/sh". Cela est très pratique dans le cas où l’on ne peut pas utiliser __free_hook pour une raison ou une autre. Ainsi, en utilisant __malloc_hook à la place, on n’aura plus à se soucier de gérer le premier argument ; le one gadget le fait pour nous.
Par contre, pour argv et envp c’est plus compliqué, il faut l’avouer.
Comment les trouver ?
Et on les trouve comment ces fameux “one gadgets” 🧐 ?
Il existe un outil, éponyme, qui permet de lister les offsets de ces différents one gadgets présents dans une libc en particulier. Utilisons cet outil sur deux libc différentes :
2.27-3ubuntu1_amd64;2.31-0ubuntu9_amd64.
Voici ce que ça donne avec la libc 2.27 :
L’outil a trouvé plusieurs offsets pouvant faire office de one gadgets. Toutefois, pour chacun des candidats, nous avons une liste de contraintes visibles sous le mot-clé constraints.
L’un des inconvénients des one gadgets est qu’ils ne sont pas tous toujours utilisables dans un contexte donné. Il est nécessaire de faire attention à satisfaire les conditions requises par le one gadget avant de l’utiliser.
Vous avez également pour chaque candidat l’origine des arguments argv (ex : rsp+0x40) et envp dans le cas où vous souhaitez qu’il aient une valeur en particulier.
Les contraintes à satisfaire
Ces contraintes permettent notamment aux pointeurs argp et envp d’être valides afin que le programme ne déréférence pas des pointeurs invalides, ce qui réduirait à néant l’utilité de cette technique.
Ces contraintes peuvent être de différents types :
- un ou plusieurs registres doivent être nuls (ou pointer vers
NULL) ; - le contenu de certaines zones de la pile doivent être nulles ;
- l’adresse de
rspdoit être alignée avec une certaine valeur etc.
En fonction de la version de la libc utilisée par le programme à exploiter, les conditions ne sont pas les mêmes de même que la difficulté à les satisfaire. Par exemple, voici ce que cela donne avec la version 2.31 :
Mais si on a besoin de satisfaire ces conditions, c’est que l’on a besoin de contrôler ces registres ? Or comme on ne peut pas faire de ROP, nous ne pouvons pas les contrôler. Quelle est donc la plus-value de cette méthode ?
Malheureusement, les one gadgets ne constituent pas une solution miracle en pwn. Très souvent, nous ne cherchons pas à satisfaire les conditions d’un gadget en particulier nous-mêmes mais c’est l’inverse : on choisit un gadget parce que l’on constate, dans gdb par exemple, que les conditions sont satisfaites au moment de l’appel du hook.
Ainsi, la méthodologie pour utiliser un one gadget pourrait se résumer ainsi :
- récupérer/télécharger la libc utilisée par le programme à exploiter ;
- utiliser l’outil
one_gadgetsur la libc ; - vérifier dans gdb, lors de l’appel du hook, si les conditions d’appel de l’un des gadgets proposés sont satisfaites ;
- si oui, récupérer l’offset ainsi que l’adresse de base de la libc (avec un leak par exemple). Il suffit alors d’écrire l’adresse du one gadget choisi dans le hook et faire en sorte que ce dernier soit appelé.
Et si l’on se rend compte qu’aucun des one gadgets proposés ne peut être utilisé à cause des contraintes qu’on ne parvient pas à satisfaire ?
Le cas échéant, il n’y a pas 36 000 solutions :
- soit on trouve une astuce pour satisfaire les conditions d’au moins un des gadgets proposés ;
- soit on sort l’artillerie lourde 💪, à savoir :
setcontext.
L’avantage des one gadgets est qu’ils permettent d’appeler execve("/bin/sh", ...) sans avoir à construire une chaîne ROP, à condition que les contraintes spécifiques du gadget soient respectées.
Inversement, leur inconvénient est que nous n’avons pas toujours la marge nécessaire pour satisfaire ces contraintes. Ainsi, dans le meilleur des cas, l’exécution du programme implique que les contraintes d’un gadget en particulier soient satisfaites. Dans le pire des cas, cette méthode n’est pas utilisable. Evidemment, il est toujours possible de chercher à les satisfaire soi-même, mais je ne vous garantis rien 😅.
Dans le cas où il n’est pas possible d’utiliser cette méthode, nous pouvons avoir recours à une fonction très puissante qui est setcontext.
setcontext
La fonction setcontext(ucontext_t *ucp); est une fonction du standard POSIX utilisée pour restaurer un contexte d’exécution sauvegardé, permettant ainsi de reprendre l’exécution d’un programme à partir d’un point donné, comme si une interruption ou un basculement de contexte avait eu lieu.
Pour faire simple, cette fonction va modifier la valeur de tous les registres à partir d’une zone mémoire pointée par son unique argument : ucontext_t *ucp. Dieu merci, cette fonction fait partie de la libc !
Vous vous en doutez, le contenu de cette fonction diffère en fonction des architectures, vous pouvez voir à quoi cela ressemble ici pour l’architecture x86_64.
L’appel de la fonction est simple : elle ne prend qu’un seul paramètre, un pointeur, ce qui la rend facilement compatible avec __free_hook. En revanche, la disposition des registres en mémoire peut s’avérer un peu complexe 😅.
Pour connaître l’agencement que vous devez réaliser en mémoire afin d’avoir les bonnes valeurs dans les bons registres, vous pouvez jeter un œil à la structure sigcontext. Si ça ne vous aide pas ou que vous êtes bloqués, vous pouvez toujours utiliser des contenus du type AAAAAAAA,BBBBBBBB etc. et voir quelle lettre sera stockée dans quel registre.
Waaah j’ai tellement la flemme de renseigner le contenu de chaque registre 😮💨. Ça veut dire que je dois savoir à l’avance le contenu des registres que je ne veux pas changer, la galère 😣.
Il y a une astuce pour éviter de devoir faire attention au contenu de chaque registre. Généralement ce que l’on souhaite c’est avoir le contrôle des premiers arguments ainsi que de l’adresse de retour pour y mettre la fonction à appeler (ex: execve). Il suffit de ne pas utiliser dans le hook l’adresse de setcontext, mais plutôt l’adresse de la fin de la fonction, ici (syntaxe Intel) :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mov rcx, qword ptr [rdx + oRIP]
push rcx
mov rsi, qword ptr [rdx + oRSI]
mov rdi, qword ptr [rdx + oRDI]
mov rcx, qword ptr [rdx + oRCX]
mov r8, qword ptr [rdx + oR8]
mov r9, qword ptr [rdx + oR9]
; Setup finally rdx.
mov rdx, qword ptr [rdx + oRDX]
; End FDE here, we fall into another context.
cfi_endproc
cfi_startproc
; Clear rax to indicate success.
xor eax, eax
ret
Les deux premières instructions mettent l’adresse de retour sur la pile : c’est l’adresse de la fonction à exécuter à la sortie de setcontext. Les instructions suivantes permettent de contrôler les valeurs de rdi, rsi, rdx, rcx, r8 et r9. Ça tombe bien, il s’agit des 6 premiers registres utilisés dans la convention d’appel en x86_64 sous Linux !
Si la mise en place du buffer ucontext_t *ucp dans le tas peut s’avérer fastidieuse, cette méthode reste une véritable bouée de secours en cas de blocage.
📋 Synthèse
- Le heap feng shui consiste à organiser les allocations/libérations pour placer des blocs à des emplacements stratégiques ;
- Les malloc hooks permettent de transformer une écriture arbitraire en exécution de code (ex :
__free_hook → system) ; - Les one gadgets offrent un raccourci pour exécuter
execve("/bin/sh", ...), sous conditions ; setcontextpermet un contrôle quasi total des registres, utile quand les autres techniques échouent.



