Partie 4 - Le tcache - fonctionnement et objectifs (1/4)
Le tcache : fonctionnement et objectifs (1/4)
Le tcache est un type de corbeille qui a été introduit dans la version 2.26 (2017). Autant vous dire que dans les programmes récents, le tcache est omniprésent.
Nous allons entamer une série de chapitres consacrée aux différents types de corbeilles présents dans la glibc. Nous tâcherons de faire en sorte que ces chapitres suivent la structure suivante :
1️⃣ Gestion des blocs libres
2️⃣ Organisation des corbeilles
3️⃣ Structure et métadonnées d’un bloc
4️⃣ Protections selon les versions
5️⃣ Exploitation des vulnérabilités liées au type de corbeille étudiéLes chapitres peuvent parfois être structurés dans un ordre différent. Par exemple, dans ce chapitre, nous nous intéresserons aux protections avant les métadonnées.
Son utilité
Avant que le tcache ne soit introduit, il n’y avait pas de corbeille propre à chaque thread. Ainsi, dans un programme utilisant plusieurs fils d’exécution, il fallait à chaque fois contrôler les accès aux corbeilles par chaque fil d’exécution afin d’éviter des accès concurrents. Cela se faisait notamment via un mécanisme de verrouillage de l’arène.
Désormais, avec le système de tcache, l’utilisation du tas dans un programme multithreadé est optimisée.
Pas de panique, nous n’allons pas découvrir comment est gérée la heap dans le cas d’un programme multithreadé. Comme cela a été précédemment souligné, la majorité des challenges de heap en pwn n’utilise qu’un seul fil d’exécution.
Gestion des blocs libres
Taille des blocs gérés
Les tailles des blocs indiquées ci-dessous tiennent compte de l’alignement ainsi que de l’espace réservé aux métadonnées. Elles ne correspondent donc pas à la quantité réelle de données que le programme peut exploiter dans un bloc retourné par
malloc().
Le tcache gère des blocs de petite et moyenne taille :
| x32 | i386 | x86_64 | |
|---|---|---|---|
| Taille min du bloc | 0x10 | 0x10 | 0x20 |
| Taille max du bloc | 0x208 | 0x400 | 0x410 |
x32 est une ABI (Application Binary Interface) pour les processeurs amd64/x86_64 utilisant des entiers, des
longet notamment des pointeurs en 32 bits, visant à combiner une utilisation réduite de la mémoire tout en utilisant les avantages des processeurs 64 bits (taille des registres …).En d’autres termes, x32 permet de compiler un programme en tirant parti de certaines capacités des systèmes 64 bits, tout en maintenant une empreinte mémoire comparable à celle d’un binaire conçu pour une architecture 32 bits.
Nous avons spécifié les différentes tailles pour x32 car il s’agit d’une occasion d’en parler même si vous risquez de rencontrer seulement des programmes i386 (32 bits) et x86_64 (64 bits). Personnellement j’ai mis du temps à comprendre la différence entre x32 et i386 car je pensais que les programmes compilés avec l’ABI x32 étaient également des programmes 32 bits alors que non 🤯.
Par la suite, lorsque l’on parlera de 32 bits nous ferons référence à l’architecture i386 tandis que 64 bits désignera x86_64.
Lorsque l’on parlera des fastbins, vous constaterez que certains blocs libérés peuvent à la fois être stockés dans le tcache et dans la fastbin adéquate en raison de la petite taille du bloc. Cependant, c’est le tcache qui prend en priorité la gestion de ces blocs. Si le tcache atteint sa capacité maximale pour cette taille de bloc, la fastbin prend alors le relais en stockant le bloc concerné.
Organisation des corbeilles
Quand on parle du tcache, il ne faut pas s’imaginer qu’il s’agit d’une seule et unique corbeille qui traite, par exemple, tous les blocs de 0x10 à 0x400 octets. Il s’agit plus précisément d’un type de corbeille qui contient des corbeilles de différentes tailles.
Le nombre de corbeilles est déterminé par la constante TCACHE_MAX_BINS définie dans le code source de la glibc . Généralement cette constante vaut 64. Il y a au total 64 bins, chacune pouvant contenir 7 (valeur de la constante TCACHE_FILL_COUNT) blocs libérés.
Donc pour récapituler :
- le
tcacheest un type de corbeilles qui gèrent, en gros, les blocs de0x10à0x410octets ; - le
tcachedispose de plusieurs corbeilles ; - chacune de ces corbeilles peut gérer jusqu’à 7 blocs.
Concrètement, chaque corbeille du tcache prend en charge des blocs d’une taille bien précise :
| Index de la corbeille | Taille des blocs (32 bits) | Taille des blocs(64 bits) | Capacité maximale de blocs libres |
|---|---|---|---|
| 0 | 0x10 | 0x20 | 7 |
| 1 | 0x20 | 0x30 | 7 |
| n | n*0x10 + 0x10 | n*0x10 + 0x20 | 7 |
| 62 | 0x3f0 | 0x400 | 7 |
| 63 | 0x400 | 0x410 | 7 |
La principale différence entre les corbeilles du
tcacheen 32 et 64 bits est liée à la taille des blocs de la première corbeille. EtaÉtantnt donné que la taille minimum d’un bloc en 32 bits est0x10et que celle en 64 bits est0x20, c’est logique.
Considérons les allocations et libérations suivantes dans un programme 64 bits :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void *a = malloc(1);
void *b = malloc(1);
void *c = malloc(1);
void *d = malloc(1);
void *e = malloc(1);
void *f = malloc(1);
void *g = malloc(1);
free(a);
free(b);
free(c);
free(d);
free(e);
free(f);
free(g);
Voici ce qui se passe dans le tas et le tcache, au fur et à mesure que free est appelé :
- état initial : étant donné qu’il s’agit d’un programme de 64 bits, le plus petit bloc qu’il est possible d’allouer est de
0x20octets. free(0x500000000000): le premier blocAde0x20octets est libéré. Comme il s’agit d’une taille prise en charge par letcache, plus précisément la corbeille d’index 0, il y est inséré ;free(0x500000000020): le deuxième blocBest libéré. Pour les mêmes raisons, il est inséré dans letcache n°0. Les corbeilles dutcachesont des listes chaînées de type LIFO, c’est-à-dire, dernier arrivé, premier servi. C’est pourquoi le premier bloc dutcache n°0n’est plusAmaisB;free(0x500000000040) ... free(0x5000000000e0): les autres blocs sont libérés. Ils sont insérés les uns à la suite des autres en gardant toujours en tête que c’est le bloc qui est inséré en dernier qui pointe vers le bloc qui était là avant lui, et non l’inverse.
Quelques remarques :
- comme il s’agit de petits blocs et qu’il n’y a pas de consolidations de blocs au sein du
tcache, les blocs ne sont ni fusionnés entre eux ni avec le bloc du sommet. - une fois le dernier bloc
Glibéré, letcache n°0devient rempli et ne peut plus accepter de nouveau bloc libre. Ainsi, si un bloc de0x20octets venait à être libéré, il ira dans une des corbeilles de lafastbindont on découvrira le fonctionnement un peu plus loin.
Encore une fois, les blocs ne sont pas déplacés du tas vers leur corbeille. Qu’un bloc soit alloué ou libéré (et pris en charge par une bin), il reste sur le tas. Seules ses métadonnées changent afin de pointer vers le bloc précédent.
Ainsi, une modélisation plus réaliste de ce qui se passe dans le tas serait ceci :
Le tcache, comme n’importe quelle autre corbeille, ne contient pas physiquement les blocs. Les blocs libérés restent dans le tas et, ici, leur corbeille correspondante pointe vers l’un des éléments en l’occurrence le dernier. Sachant que le tcache fonctionne sous forme de liste chaînée, en ayant le premier élément, il est possible de récupérer les autres.
Comment sont chaînés les blocs libres ? En utilisant les métadonnées ?
Oui, c’est ça. Plus précisément, c’est le champ fd qui est utilisé.
Protections selon les versions
Avant d’approfondir notre étude du tcache, il est nécessaire de distinguer quatre cas en fonction de la version de la libc utilisée. En effet, après l’introduction du tcache dans la version 2.26, des mécanismes de sécurité supplémentaires ont été introduits dans les versions 2.29 et 2.32 :
- 2.29 : ajout d’une protection contre les doubles appels à
free(double free) ; - 2.32 : mise en place du safe linking, une mesure visant à renforcer la sécurité des listes chaînées ;
- 2.34 : aléatoirisation du champ
key.
Nous détaillerons un à un ces mécanismes de sécurité au moment opportun. En fonction des protections mises en place, les métadonnées utilisées par le tcache ne sont pas les mêmes, c’est pourquoi nous allons voir au fil des versions de la glibc la structure d’un bloc libre du tcache.
Les différentes protections explicitées ci-dessous ne sont pas à apprendre par cœur. Le but global est de savoir comment fonctionne le
tcacheainsi que les blocs libres qu’il gère.Beaucoup de détails sont donnés afin que vous sachiez où trouver une information en particulier le jour où vous en aurez besoin. Par exemple, si vous analysez un programme utilisant la glibc 2.34, il est important d’avoir une idée globale des protections mises en place dans les versions précédentes.
Version 2.26 - Introduction du tcache
Pour rappel, le tcache a été introduit lors de la version 2.26, inutile de remonter plus loin dans le temps ⏳. Prenons notre loupe 🔎 et analysons de plus près le contenu des blocs dans le tas :
Faites-moi confiance pour cet exemple, nous verrons un peu plus bas ce que cela donne enfin dans gdb . Encore un peu de patience 😉.
Nous remarquons que les champs prev_size ne sont pas utilisés. Logique, nous avons affaire à des blocs de petite taille. Ainsi, le bit PREV_INUSE des différents champs size vaut toujours 1.
Néanmoins, nous voyons clairement que le champ fd de chacun des 7 blocs libérés est utilisé. Chaque champ fd pointe vers le champ fd du bloc suivant, sauf le dernier bloc qui n’a pas de successeur.
Chaque bloc du
tcachene pointe pas vers l’adresse du bloc suivant mais vers l’adresse du champfddu bloc suivant.Ce détail a son importance car dans d’autres corbeilles, les blocs d’une même corbeille pointent généralement directement vers l’adresse du bloc suivant. Sachez faire la différence 🧐 !
⚒️ Débogage du tas
Alors dans gdb ça donne quoi 🙄 ?
Chose promise, chose due. Avant de mettre directement les mains dans le cambouis, il est préférable de souligner quelques prérequis :
- Quelle version de gdb choisir : la version que nous utiliserons pour déboguer les programmes / challenges utilisant le tas sera la version améliorée de
gdb-gef, que l’on nommeragdb-gef++. Il s’agit d’un fork degdb-gefavec plein de nouvelles fonctionnalités. Un chapitre, en annexe, est dédié à l’installation des diverses versions de gdb afin que vous puissiez choisir celle qui vous convient le mieux. - Commandes gdb liées à la heap : nous allons expliciter petit à petit les commandes utilisées. N’hésitez pas à consulter ce chapitre en annexe pour avoir une liste des commandes de base.
- Comment compiler avec libc en particulier : Un long chapitre est dédié à la compilation de programme en utilisant des versions spécifiques de la libc. Ce chapitre contient différentes méthodes, cela peut être long de vouloir toutes les tester. Quoi qu’il en soit, si une méthode fonctionne et vous permet de compiler votre programme avec une libc en particulier, vous pouvez poursuivre la lecture de ce chapitre.
C’est bon, tous les prérequis sont validés ? C’est parti !
Voici le programme dont nous allons analyser le fonctionnement :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdlib.h>
int main()
{
void *a = malloc(1);
void *b = malloc(1);
void *c = malloc(1);
void *d = malloc(1);
void *e = malloc(1);
void *f = malloc(1);
void *g = malloc(1);
free(a);
free(b);
free(c);
free(d);
free(e);
free(f);
free(g);
return 0;
}
Voici la configuration qui sera utilisée :
- version de la libc : la version exacte de la libc utilisée est
2.27-3ubuntu1_amd64; - débogueur :
gdb-gef++(fork degdb-gefpar bata24) ; - architecture cible : 64 bits.
Nous compilons le programme avec la commande suivante, sans oublier de créer le lien symbolique libc.so.6 :
1
2
3
4
5
gcc -g main.c -o exe \
-Wl,--dynamic-linker=./ld-2.27.so \
-L. -Wl,-rpath=. -l:./libc-2.27.so
ln -s libc-2.27.so libc.so.6
L’option
-gajoute des informations de débogage supplémentaires qui facilitent l’analyse avec gdb. Cela permet d’avoir le code source du programme intégré dans gdb.
Un conteneur Docker est aussi disponible si vous ne souhaitez pas le compiler vous-mêmes :
- ⬇️ Téléchargement : pwn-tcache-exemple-1.zip
- 🔎 SHA256 & Analyse Virus Total : 290b214b49eb23ecebcb7391b33c137aeb4d7060df7cfcb6a0bc60d9826fdd83
- ⚙️ Construction et lancement du conteneur :
1
2
docker build -t pwn-tcache-exemple-1 .
docker run -it --rm -p 1234:1234 --cap-add=SYS_PTRACE --security-opt seccomp=unconfined pwn-tcache-exemple-1
Seul
pwndbgest installé dans le conteneur. Si vous souhaitez utilisergdb-gef++, vous pouvez l’installer sur l’hôte et le déboguer à distance avecgdbserver.
Ouvrons le programme dans gdb : gdb-gef++ ./exe.
Astuce gdb : il est possible d’utiliser la commande
set listsize unlimitedafin de pouvoir afficher toute la fonctionmaind’un coup avec la commandelist main.
Mettons un point d’arrêt à la ligne 15, avant que le premier free soit appelé :
Une fois arrivés au point d’arrêt, affichons les différents blocs présents sur le tas avec la commande : heap chunks.
Ça change des schémas 😅. Bon, utilisons une commande qui permet d’afficher le tas de manière plus agréable avec visual-heap -d -n :
Pas mal hein 😎 ?
On retrouve les 7 blocs de 0x20 octets alloués sur le tas. Vous remarquerez que le bloc du sommet a une taille initiale de 0x21000, ce qui est exactement la taille de la heap que vous pouvez lire en utilisant la commande vmmap.
C’est quoi ce premier bloc de
0x250? Je ne l’ai pas alloué et je ne me rappelle pas l’avoir vu dans les précédents schémas 🤔.
Il s’agit de la structure tcache_perthread_struct qui contient les informations concernant les différentes corbeilles du tcache :
1
2
3
4
5
typedef struct tcache_perthread_struct
{
char counts[TCACHE_MAX_BINS];
tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;
counts: contient le nombre de blocs libres présents dans chacune des 64 (TCACHE_MAX_BINS) corbeilles dutcache;entries: contient un pointeur vers le premier bloc de chacune des corbeilles.
Cette structure est initialisée puis allouée dans la fonction tcache_init, avant l’appel de main. Elle est propre à chaque fil d’exécution. Contrairement aux autres corbeilles (fastbins , unsorted bin etc.) les informations du tcache ne sont pas stockées dans l’arène.
La taille de cette structure peut varier en fonction de la libc. Par exemple, dans la version 2.27 sa taille vaut
0x240alors que dans la version 2.40 sa taille vaut0x280.
Il est parfois possible de trouver, en plus du bloc de la structure
tcache_perthread_struct, deux autres blocs qui correspondent à deux buffers utilisés respectivement parstdoutetstdin.
Libération des blocs
En libérant un des blocs précédemment alloués, cela va l’insérer dans le tcache.
Dire qu’un bloc est inséré “dans le
tcache” est un abus de langage. Plus précisément, il est placé dans la corbeille dutcachecorrespondant à sa taille, ici celle qui gère les blocs de0x20octets.La formulation complète étant plus lourde, nous utiliserons souvent simplement le terme
tcache. Selon le contexte, il désignera soit le type de corbeille dans son ensemble, soit une corbeille précise dutcache.
Bon, mettons un point d’arrêt au deuxième free(b) afin que le premier free(a) soit appelé :
Le premier bloc est bien dans le tcache et ne pointe vers aucun autre bloc vu qu’il est, pour l’instant, tout seul.
Astuce gdb : Il est possible de voir le contenu des différentes corbeilles du
tcacheavec la commande :tcache.
Nous constatons d’ailleurs que la structure tcache_perthread_struct (premier bloc du tas) contient désormais les valeurs suivantes :
counts[0] = 1;entries[0] = 0x0000555555559260(champfddu premier bloc libéré).
Bien. Après avoir observé ce qui se passe après la libération du premier bloc, voyons le résultat final lorsque tous les blocs sont libérés. Pour cela, mettons un point d’arrêt au return 0; du main et continuons l’exécution du programme :
Normalement, vous ne devriez pas avoir trop de mal à comprendre ce qui s’est passé si vous avez bien saisi les précédents schémas :
- chaque champ
fdd’un bloc pointe vers le champfddu bloc suivant ; - le premier bloc inséré devient le dernier bloc de la liste chaînée.
La structure tcache_perthread_struct a été également mise à jour avec les valeurs suivantes :
counts[0] = 7;entries[0] = 0x0000555555559320(champfddu premier bloc de la liste).
Les corbeilles du tcache étant des listes LIFO, lorsqu’un bloc de taille 0x20 sera alloué, ce sera le bloc n°1/7 du tcache qui sera réutilisé en premier. Si une autre allocation de 0x20 octets est demandée, ce sera alors le bloc n°2/7 et ainsi de suite.
Bien que le
tcacheait été introduit lors de la version 2.26,callocn’utilise jamais letcachejusqu’à la version 2.41 où cela a été corrigé après que plusieurs personnes se soient plaintes.Ainsi,
mallocpermettait de réutiliser des blocs libres dutcachealors quecallocfaisait comme si letcachen’existait pas 🫣.
📋 Synthèse
Le tcache, introduit avec la glibc 2.26, est un cache de blocs libres propre à chaque thread, conçu pour accélérer les allocations et libérations mémoire.
À retenir :
- il gère les petits et moyens blocs ;
- il est composé de 64 corbeilles, chacune associée à une taille précise ;
- chaque corbeille peut contenir jusqu’à 7 blocs libres ;
- les blocs y sont réutilisés selon un ordre LIFO (dernier libéré, premier réutilisé) ;
- les blocs stockés dans le
tcachene sont ni fusionnés entre eux, ni consolidés avec le sommet du tas ; - si une corbeille est pleine, les blocs supplémentaires sont généralement redirigés vers les
fastbins.







