Post

Partie 13 - Pistes générales d’exploitation dans la heap - mise en œuvre pratique (2/2)

Pistes générales d’exploitation dans la heap : mise en œuvre pratique (2/2)

Exploiter les __exit_funcs - post glibc 2.34

Les hooks c’est comme le cheval, c’est trop génial ! Ça permet de facilement contrôler les arguments et la fonction à exécuter, un régal 😇. Néanmoins depuis la version 2.34 de la glibc, les hooks ne sont plus utilisables 😕. RIP.

Tant pis, il va falloir faire autrement, on ne baisse pas les bras 🤓 !

Que sont les __exit_funcs

Lorsqu’un programme termine son exécution, en quittant la fonction main ou autre, certaines fonctions peuvent être appelées pour assurer une sortie en bonne et due forme, notamment en libérant certains pointeurs et en effectuant d’autres opérations de nettoyage. Cela se fait via __exit_funcs.

__exit_funcs est une variable globale qui contient un pointeur vers une structure exit_function_list :

1
2
3
4
5
6
struct exit_function_list
{
	struct exit_function_list *next;
	size_t idx;
	struct exit_function fns[32];
};

Il s’agit d’une liste chaînée contenant un tableau de fonctions fns qui seront exécutées lors de la sortie du programme, plus précisément lorsque la fonction exit est appelée :

1
2
3
4
void exit (int status)
{
  __run_exit_handlers (status, &__exit_funcs, true, true);
}

Vous sentez le truc 😏 ? Une liste de pointeurs de fonctions appelées par exit : la liste exit_function_list semble être une cible idéale pour exécuter du code via une écriture arbitraire.

Bon, sur le papier ça a l’air d’être facile, en pratique, va falloir un peu d’huile de coude 😅.

Le tableau fns contient directement les structures exit_function et non pas un pointeur vers celles-ci !

Format des fonctions fns

Les fonctions fns de type exit_function suivent un format bien précis :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
enum
{
  ef_free,	/* `ef_free' MUST be zero!  */
  ef_us,
  ef_on,
  ef_at,
  ef_cxa
};

struct exit_function
  {
    /* `flavour' should be of type of the `enum' above but since we need
       this element in an atomic operation we have to use `long int'.  */
    long int flavor;
    union
      {
		void (*at) (void);
		
		struct
		  {
		    void (*fn) (int status, void *arg);
		    void *arg;
		  } on;
		  
		struct
		  {
		    void (*fn) (void *arg, int status);
		    void *arg;
		    void *dso_handle;
		  } cxa;
		  
      } func;
  };

Le premier membre de cette structure est flavor qui est une enum convertie en long int. Ce membre permet de savoir quel est le type de la fonction car il y en a plusieurs possibles comme le souligne le terme union :

  • soit ef_at ➡️ at ;
  • soit ef_on ➡️ on ;
  • ou bien ef_cxa ➡️ cxa.

On aura tendance à utiliser flavor == ef_cxa, car cela permet d’exploiter le type cxa, dont la fonction est appelée avec un pointeur en premier argument : void (*fn) (void *arg, int status);.

Autre chose à savoir, le tableau de pointeurs de fonction fns contient toujours au moins une entrée : la fonction _dl_fini qui est toujours appelée lorsque le programme termine son exécution.

_dl_fini est appelée même si exit n’est pas explicitement appelé dans le code car exit est appelée au retour de l’exécution du main.

Fonctionnement global

Y a 36 000 structures différentes je comprends pas comment elles sont liées les unes les autres 🤕.

Bon, voyons globalement le lien entre ces différentes structures avant de voir comment déclencher une exécution de code arbitraire. Pour que ce soit plus simple à comprendre, plaçons-nous dans le cas habituel où la seule fonction appelée est _dl_fini :

Reprenons ce qui a été dit plus haut en utilisant ce schéma comme support :

  1. la variable globale __exit_funcs est le point d’entrée du processus d’exécution des fonctions à la sortie du programme. Cette variable pointe vers une structure exit_function_list. Comme les 32 fonctions du tableau fns ne sont pas toutes remplies, la liste chaînée se limite à un seul maillon d’où *next == NULL. En l’occurrence, il n’y a qu’une seule fonction, à savoir : fns[0] ;
  2. pour rappel, fns[0] n’est pas un pointeur mais contient directement la structure exit_function. _dl_fini est une fonction de type cxa d’où flavor == ef_cxa. Le second membre est l’adresse de la fonction _dl_fini, enfin pas exactement 🫣 ;
  3. Si tout se passe bien à partir de la variable globale __exit_funcs, la fonction _dl_fini est appelée !

Tu ne nous as pas dit ce qu’est le gros PTR_MANGLE, ça a un lien avec le PROTECT_PTR du safe linking ?

Il y a effectivement une ressemblance entre ce qui est fait ici et le safe linking. Si vous vous rappelez, le safe linking permet notamment de chiffrer une partie du pointeur pour éviter de pouvoir le modifier trop facilement.

Ici, PTR_MANGLE permet également de chiffrer le pointeur mais en utilisant une clé de chiffrement totalement aléatoire, c’est là que réside la principale différence avec la macro PROTECT_PTR où la clé de chiffrement était partiellement aléatoire.

Voyons à quoi ressemble la macro PTR_MANGLE :

1
2
3
4
// Syntaxe utilisée AT&T

#  define PTR_MANGLE(reg)       xor %fs:POINTER_GUARD, reg;                   \
                                rol $2*LP_SIZE+1, reg

En d’autres termes, l’opération de chiffrement est la suivante rol(ptr ^ pointer_guard, 0x11) avec :

  • rol : une rotation à gauche de 0x11 (2*8+1) bits ;
  • pointer_guard : la clé de chiffrement (de 8 octets en x86_64) ;
  • ptr : l’adresse de la fonction à chiffrer (ex : _dl_fini).

pointer_guard où es-tu caché ?

Comme vous pouvez le constater, l’algorithme de chiffrement en lui-même n’est pas particulièrement complexe. L’enjeu principal réside dans l’utilisation de la clé de chiffrement pointer_guard et dans la manière d’en déterminer la valeur.

Pour comprendre d’où vient ce pointer_guard nous allons revenir en arrière de plusieurs chapitres pour arriver au moment où nous avons évoqué pour la première fois le TLS qui contient cette structure :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct
{
  void *tcb;		/* Pointer to the TCB.    */
  dtv_t *dtv;
  void *self;		/* Pointer to the thread descriptor.  */
  int multiple_threads;
  int gscope_flag;
  uintptr_t sysinfo;
  uintptr_t stack_guard; // 🐥
  uintptr_t pointer_guard; // 🔐
  unsigned long int unused_vgetcpu_cache[2];

  unsigned int feature_1;
  int __glibc_unused1;
  void *__private_tm[4];
  void *__private_ss;
  unsigned long long int ssp_base;
  __128bits __glibc_unused2[8][4] __attribute__ ((aligned (32)));

  void *__padding[8];
} tcbhead_t;

N’hésitez pas à revenir au chapitre 10 pour vous rafraîchir la mémoire sur le TLS même si nous n’en avions pas parlé en détails.

On y retrouve notre bon vieux canari 🐥, lui aussi généré aléatoirement, mais ce n’est pas ce qui nous intéresse. Ce qui nous intéresse est le membre pointer_guard qui est la seule inconnue dans le chiffrement via rol(ptr ^ pointer_guard, 0x11).

Vous pouvez aussi le voir dans gdb-gef++ avec la commande tls :

Déterminer la valeur de pointer_guard

Pour déterminer la valeur du pointer_guard, il existe principalement deux approches, selon les primitives d’exploitation dont vous disposez :

  1. lecture arbitraire : étant donné que _dl_fini est toujours présent dans les __exit_funcs, en faisant fuiter l’adresse de _dl_fini et de la valeur chiffrée PTR_MANGLE(&_dl_fini) on peut retrouver avec un simple calcul la valeur de pointer_guard ;
  2. écriture arbitraire : dans le cas où vous ne pouvez pas faire fuiter à la fois &_dl_fini et PTR_MANGLE(&_dl_fini) nous allons devoir faire quelque chose d’un peu moins propre. Sachant que la zone mémoire TLS est modifiable, il va falloir que l’on écrase le champ pointer_guard avec une valeur arbitraire afin de maîtriser le chiffrement de la fonction que l’on souhaite exécuter in fine.

Primitive : lecture arbitraire

Voici le déroulement des opérations lorsque l’on dispose d’une primitive de lecture arbitraire :

  1. récupérer l’adresse de _dl_fini (que l’on appellera ptr_dl_fini) ;
  2. récupérer l’adresse chiffrée de _dl_fini par PTR_MANGLE qui est le champ func de la structure exit_function (que l’on appellera enc_dl_fini)
  3. récupérer la valeur de pointer_guard en calculant ror(enc_dl_fini,0x11,64) ^ ptr_dl_fini ;
  4. sélectionner une fonction que l’on souhaite exécuter à la sortie du programme (exemple : system) et récupérer son adresse (notons-la : ptr_func)
  5. mettre la valeur chiffrée rol(ptr_func ^ pointer_guard, 0x11) à la place de celle de _dl_fini dans fns[0]

Voici les implémentations de ror et rol en python si cela peut vous aider (source) :

1
2
3
4
5
6
7
8
9
# Rotate left: 0b1001 --> 0b0011
rol = lambda val, r_bits, max_bits: \
    (val << r_bits%max_bits) & (2**max_bits-1) | \
    ((val & (2**max_bits-1)) >> (max_bits-(r_bits%max_bits)))

# Rotate right: 0b1001 --> 0b1100
ror = lambda val, r_bits, max_bits: \
    ((val & (2**max_bits-1)) >> r_bits%max_bits) | \
    (val << (max_bits-(r_bits%max_bits)) & (2**max_bits-1))

Exemple d’utilisation : ror(0xbe2433889b961ae1,0x11,64) ^ 0x7ffff7fc9040.

La complexité de cette méthode repose sur la possibilité, plus ou moins difficile, de faire fuiter ptr_dl_fini et enc_dl_fini.

Primitive : écriture arbitraire

Cette méthode est un peu plus compliquée à mettre en place car il est nécessaire d’utiliser de force brute afin de trouver où se trouve exactement pointer_guard. En effet, l’adresse de pointer_guard n’est pas fixe relativement à l’adresse de base de la libc. Néanmoins, on remarque que l’offset de pointer_guard par rapport à la libc n’est pas totalement aléatoire.

Pour vous en convaincre, utilisons le programme suivant (merci chatGPT 🦾 ) qui :

  1. récupère l’adresse de base de la libc et l’affiche ;
  2. récupère l’adresse du pointer_guard au sein du TLS ;
  3. affiche la différence entre les deux.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <link.h>
#include <pthread.h>
#include <stdint.h>
#include <stdlib.h>

int main() {
    // Recuperation de l'adresse de base de la libc 
    void *handle = dlopen("libc.so.6", RTLD_LAZY);
    if (!handle) {
        perror("dlopen");
        return 1;
    }

    struct link_map *libc_map;
    if (dlinfo(handle, RTLD_DI_LINKMAP, &libc_map) != 0) {
        perror("dlinfo");
        return 1;
    }
    // Adresse de la base de la libc
    void *libc_base = (void *)libc_map->l_addr;
    dlclose(handle);

    uintptr_t *tls = (uintptr_t *)pthread_self();  // adresse de base du TLS
    uintptr_t *pointer_guard_addr = (uintptr_t *)((char *)tls + 0x28);  // Offset du pointer_guard

    printf("libc base @ %p\n", libc_base);
    printf("pointer_guard @ %p\n", pointer_guard_addr);
    printf("Offset de [pointer_guard] : 0x%lx\n", (uintptr_t)pointer_guard_addr - (uintptr_t)libc_base);

    return 0;
}

On le compile avec gcc -g main.c -o exe -ldl -pthread. Exécutons-le plusieurs fois d’affilée :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 ➜ ./exe
libc base @ 0x765323c00000
pointer_guard @ 0x765323fd9768
Offset de [pointer_guard] : 0x3d9768

 ➜ ./exe
libc base @ 0x7f8751200000
pointer_guard @ 0x7f87515b8768
Offset de [pointer_guard] : 0x3b8768

 ➜ ./exe
libc base @ 0x747e56a00000
pointer_guard @ 0x747e56d04768
Offset de [pointer_guard] : 0x304768

 ➜ ./exe
libc base @ 0x745362600000
pointer_guard @ 0x745362a13768
Offset de [pointer_guard] : 0x413768

Si l’offset semble avoir une trop grande valeur commençant par 0xffff... vous pouvez inverser les termes de la soustraction car selon la version de la libc, la zone mémoire TLS peut se situer avant ou après la zone mémoire de la libc.

On remarque que les 12 bits de poids faible ne changent pas tandis que les 12 bits de poids fort semblent varier. En réalité les 12 bits de poids fort de la différence ne sont pas totalement aléatoire.

En lançant plusieurs centaines de milliers de fois le programme et en enregistrant la différence, on remarque deux choses :

  1. la différence minimale est de 0x229768 tandis que la valeur maximale est de 0x425768 ;
  2. certains nombres apparaissent plusieurs fois, en faisant un petit calcul, on s’aperçoit qu’il y a probabilité d’environ 1/500 de retomber sur le même nombre.

Cela signifie que pour un offset donné (exemple : 0x234768), en lançant un exploit 500 fois on devrait tomber au moins une fois, selon les statistiques, sur cet offset.

Ainsi, dans le cas où vous souhaitez exploiter un programme en changeant manuellement la valeur de pointer_guard, il suffit de se fixer en amont un offset et relancer plusieurs fois le script d’exploitation.

L’offset entre la libc et pointer_guard n’est pas toujours sous la forme 0x...768. En fonction du programme que vous analysez et de la libc utilisée, observez dans gdb la différence entre les deux.

Voici comment fonctionne cette méthode :

  1. sélectionner une fonction que l’on souhaite exécuter à la sortie du programme (exemple : system) et récupérer son adresse (notons-la : ptr_func) ;
  2. modifier la valeur de pointer_guard avec une valeur connue (exemple : 0xdeadbeefcafebabe) ;
  3. mettre la valeur chiffrée rol(ptr_func ^ 0xdeadbeefcafebabe, 0x11) à la place de celle de _dl_fini dans fns[0]

Et voilà, le tour est joué !

Récupérer des leaks

Comme pour l’exploitation de la pile, lorsqu’un programme est protégé par l’ASLR, il est très souvent nécessaire d’obtenir des fuites d’adresses pour contourner l’aléatoirisation de l’espace mémoire.

Dans le cas du tas, c’est la même logique : sans fuite d’adresse, impossible de prédire où se trouvent les structures utiles. Si vous avez réalisé les exercices précédents, vous avez déjà été confronté à cette problématique. Et si on voyait ensemble un petit résumé des fuites d’adresse qu’il est possible de faire dans le tas ?

Type de corbeilleNom de la méthode utiliséeOrigine de l’adresse fuitée
tcachetcache leakTas.
fastbinsfastbin leakTas.
unsorted binunsorted bin leakLibc ou tas.
small bins Libc ou tas.
large bins Libc ou tas.

Nous avons vu comment il est possible de faire fuiter une adresse pour les 3 premiers types de corbeille. Pour ce qui est des small bins et large bins, cela se réalise plus ou moins de la même façon.

Ce qui doit principalement attirer notre attention est le type d’adresse que nous voulons faire fuiter. Ainsi, il est inutile de s’acharner sur un tcache ou une fastbin si notre objectif est de faire fuiter une adresse provenant de la libc (sauf s’il est possible de rebondir vers une autre attaque qui, elle, permet de le faire).

Retourner dans la pile

Il peut arriver, lors de l’exploitation du tas, d’avoir envie de retourner exploiter notre bonne vieille pile. Par exemple, lorsque les hooks de malloc ne sont pas disponibles et que l’on n’arrive pas à exploiter les __exit_funcs. Ou lorsque l’on arrive à rediriger une allocation de bloc mais que l’on ne trouve pas de structure intéressante à écraser au niveau du tas.

Retourner dans la pile peut être intéressant dans le cas où l’on a une écriture arbitraire, assez large si possible, afin de faire du ROP. Le souci, vous l’aurez deviné : comment faire pour passer du tas vers la pile ?

Contrairement à certaines adresses qu’il peut être possible de deviner, ou bruteforcer, l’adresse de la pile est assez capricieuse 🫤. Mais pas de panique ! Il y a une solution : la variable globale environ ✨.

Exploiter la variable globale environ

📝 Prérequis :

  • primitive d’écriture arbitraire
  • primitive de lecture arbitraire

En tant que telle, la variable environ ne nous dit pas où est exactement située la pile. En revanche, elle pointe vers le tableau des variables d’environnement qui, comme vous le savez, se situent dans la pile !

Ainsi, en ayant accès en lecture à cette variable, nous aurons une idée d’où se situe grosso modo la pile.

Voici un programme assez simple que vous pouvez compiler et exécuter afin de comprendre comment fonctionne environ et ce qu’elle contient :

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

extern char **environ;

int main() 
{
    for (char **env = environ; *env; env++) 
    {
        printf("%s\n", *env);
    }
    return 0;
}

D’accord, mais comment réaliser le ROP dont tu nous parles avec pour seule information la localisation de la pile ?

Patience, ça arrive 😉.

Ce qui est intéressant avec environ est que cette variable a un offset fixe depuis l’adresse de base de la libc. Il est possible d’utiliser pwntools pour récupérer ce décalage :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ ipython
Python 3.13.3 (main, Jun 16 2025, 18:15:32) [GCC 14.2.0]
Type 'copyright', 'credits' or 'license' for more information
IPython 8.30.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from pwn import *

In [2]: libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
[*] '/usr/lib/x86_64-linux-gnu/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    FORTIFY:    Enabled
    SHSTK:      Enabled
    IBT:        Enabled

In [3]: hex(libc.symbols["environ"])
Out[3]: '0x217e28'

Voici comment réaliser le ROP en ayant une primitive de lecture et une primitive d’écriture arbitraires :

  1. exploiter la primitive de lecture arbitraire : faire fuiter la libc et afficher, grâce à son offset, la valeur de environ qui est une adresse qui se situe dans la zone mémoire de la pile ;
  2. sélectionner une adresse de retour à corrompre : identifier une fonction dont on peut provoquer le retour (i.e. : quitter la fonction) via une action contrôlée (par exemple une entrée utilisateur). À défaut, repérer un point du programme où une fonction retourne après l’exploitation de la primitive d’écriture arbitraire ; sans retour effectif, la corruption de l’adresse n’aura aucun impact ;
  3. trouver où se situe l’adresse de retour sélectionnée : en utilisant un débogueur, trouver où se situe cette adresse de retour. Plus le nombre de données qu’il est possible d’écrire avec la primitive d’écriture arbitraire est large, plus on aura de marge quant à la valeur exacte de la localisation de l’adresse de retour sélectionnée ;
  4. exploiter la primitive d’écriture arbitraire : écrire la chaîne de ROP de telle sorte à écraser la valeur de retour de la fonction dont on est sûr de provoquer le retour.

Le plus difficile dans ce scénario est évidemment de trouver les primitives de lecture et écriture. L’autre défi sera de réécrire l’adresse de retour choisie en ayant assez de marge pour ne pas la louper dans le cas où son offset par rapport à la pile n’est pas totalement fixe.

Les appels implicites à malloc

Imaginez que vous réussissiez à exploiter une vulnérabilité, qu’elle soit dans le tas ou non, vous permettant de modifier __malloc_hook. Vous vous imaginez déjà dans votre exécution de code arbitraire mais … le programme exploité ne semble jamais appeler malloc et encore moins free.

Comment faire pour ne pas s’arrêter en si bonne route ? Le sous-titre devrait déjà vous donner un indice 🧐.

Intéressons-nous au programme suivant :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

extern void* __malloc_hook;

int main() 
{
	puts("[+] Modification de __malloc_hook");
	
	void **malloc_hook = (void **)&__malloc_hook;
	*malloc_hook = (void *)0xdeadbeefcafebabe;
	
	puts("[+] Travail termineeee !");
	return 0;
}

On le compile, par exemple, avec la libc 2.27-3ubuntu1_amd64 afin que les hooks de malloc soient disponibles.

Le programme modifie __malloc_hook avec la valeur 0xdeadbeefcafebabe. En exécutant le programme, rien de particulier. Par contre, en ajoutant les lignes suivantes :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

extern void* __malloc_hook;

int main() 
{
	puts("[+] Modification de __malloc_hook");
	
	void **malloc_hook = (void **)&__malloc_hook;
	*malloc_hook = (void *)0xdeadbeefcafebabe;
	
	printf("%65510c"); // ???
	
	puts("[+] Travail termineeee !");
	return 0;
}

Le programme plante : [1] 54962 segmentation fault (core dumped) ./exe.

Que s’est-il passé ? La chaîne de format "%65510c" est utilisée lors de l’appel à printf. Étant donné que cette valeur est très élevée, le programme va allouer dynamiquement de l’espace dans le tas, via malloc. Or, comme nous avons modifié __malloc_hook avec une valeur incorrecte, le programme plante.

Il me semble que scanf réalise également des appels à malloc dans certains cas. L’exercice est laissé aux plus motivés de trouver quand cela est réalisé 🤓.

This post is licensed under CC BY 4.0 by the author.