Post

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 :

 x32i386x86_64
Taille min du bloc0x100x100x20
Taille max du bloc0x2080x4000x410

x32 est une ABI (Application Binary Interface) pour les processeurs amd64/x86_64 utilisant des entiers, des long et 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 tcache est un type de corbeilles qui gèrent, en gros, les blocs de 0x10 à 0x410 octets ;
  • le tcache dispose 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 corbeilleTaille des blocs (32 bits)Taille des blocs(64 bits)Capacité maximale de blocs libres
00x100x207
10x200x307
nn*0x10 + 0x10n*0x10 + 0x207
620x3f00x4007
630x4000x4107

La principale différence entre les corbeilles du tcache en 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 est 0x10 et que celle en 64 bits est 0x20, 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é :

  1. é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 0x20 octets.
  2. free(0x500000000000) : le premier bloc A de 0x20 octets est libéré. Comme il s’agit d’une taille prise en charge par le tcache, plus précisément la corbeille d’index 0, il y est inséré ;
  3. free(0x500000000020) : le deuxième bloc B est libéré. Pour les mêmes raisons, il est inséré dans le tcache n°0. Les corbeilles du tcache sont des listes chaînées de type LIFO, c’est-à-dire, dernier arrivé, premier servi. C’est pourquoi le premier bloc du tcache n°0 n’est plus A mais B ;
  4. 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 G libéré, le tcache n°0 devient rempli et ne peut plus accepter de nouveau bloc libre. Ainsi, si un bloc de 0x20 octets venait à être libéré, il ira dans une des corbeilles de la fastbin dont 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 tcache ainsi 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 tcache ne pointe pas vers l’adresse du bloc suivant mais vers l’adresse du champ fd du 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 nommera gdb-gef++. Il s’agit d’un fork de gdb-gef avec 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 de gdb-gef par 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 -g ajoute 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 :

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 pwndbg est installé dans le conteneur. Si vous souhaitez utiliser gdb-gef++, vous pouvez l’installer sur l’hôte et le déboguer à distance avec gdbserver.

Ouvrons le programme dans gdb : gdb-gef++ ./exe.

Astuce gdb : il est possible d’utiliser la commande set listsize unlimited afin de pouvoir afficher toute la fonction main d’un coup avec la commande list 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 du tcache ;
  • 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 0x240 alors que dans la version 2.40 sa taille vaut 0x280.

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 par stdout et stdin.

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 du tcache correspondant à sa taille, ici celle qui gère les blocs de 0x20 octets.

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 du tcache.

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 tcache avec 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 (champ fd du 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 fd d’un bloc pointe vers le champ fd du 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 (champ fd du 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 tcache ait été introduit lors de la version 2.26, calloc n’utilise jamais le tcache jusqu’à la version 2.41 où cela a été corrigé après que plusieurs personnes se soient plaintes.

Ainsi, malloc permettait de réutiliser des blocs libres du tcache alors que calloc faisait comme si le tcache n’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 tcache ne 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.
This post is licensed under CC BY 4.0 by the author.