Solution de roukmoute pour Aaarg

intro reverse linux x86/x64

2 janvier 2025

Plusieurs approches ont été suggérées, comme chercher directement la chaîne de caractères ou explorer le pointeur correspondant pour identifier la chaîne de sortie. Je vous encourage vivement à les consulter, car elles permettent d’apprendre une multitude de choses passionnantes.

Pour ma part, voici la démarche que j’ai suivie pour afficher le flag contenu dans le fichier aaarg. Mon objectif principal était de comprendre comment exécuter correctement le binaire afin qu’il produise la réponse attendue.

Étape 1 : Identifier le type de fichier

La première étape consiste à identifier le type du fichier. Sous Linux, cela est relativement simple avec la commande suivante :

$ file aaarg
aaarg: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=f5b07c01242cc5987bed7730c2762ae0491b5ddc, stripped

Un passage sur Wikipédia révèle que le format ELF (Executable and Linkable Format) est un standard pour les exécutables sous les systèmes Unix.

Étape 2 : Lancer le fichier

En exécutant le fichier, on constate qu’il ne produit aucun message d’erreur, mais qu’aucun contenu visible ne s’affiche non plus :

$ chmod +x aaarg
$ ./aaarg
$
$ ./aaarg foo
$
$ ./aaarg bar
$

Étape 3 : Analyse avec Cutter

Pour aller plus loin, j’ai utilisé Cutter, un outil de reverse engineering avec interface graphique, téléchargeable ici : Cutter v2.3.4. Une fois le binaire ouvert dans Cutter, je me suis concentré sur la fonction main, principale dans tout programme écrit en C.

Voici ce que j’ai découvert dès le début :

Image Cutter main

0x00401195      cmp     edi, 2     ; 2 ; argc

La ligne compare edi avec 2, et immédiatement après :

0x00401198      jl      0x401218

jl (Jump if Less) : Cela signifie “sauter si la valeur est inférieure”. Si edi (le nombre d’arguments) est strictement inférieur à 2, le programme redirige l’exécution à l’adresse 0x00401218 :

0x00401218      ret

Un simple return, arrêtant ainsi l’exécution. Cela m’a incité à examiner plus attentivement le code entre ces deux points. Cependant, pour simplifier l’analyse, j’ai décidé d’utiliser un décompilateur plus performant : Ghidra.

Étape 4 : Utilisation de Ghidra

Installation et configuration

Pour installer Ghidra, vous pouvez consulter le guide officiel disponible ici : Guide d’installation de Ghidra. Suivez ces étapes pour une première utilisation réussie :

  1. Téléchargez et décompressez l’archive correspondant à votre système d’exploitation.
  2. Lancez Ghidra via le script fourni (ghidraRun).
  3. Ouvrez le fichier binaire que vous souhaitez analyser.
  4. Acceptez de lancer l’analyse et laissez les options par défaut des analyzers activées.
  5. Cliquez sur le bouton “Analyze” pour démarrer l’analyse.

Découverte de la fonction principale

Une fois l’analyse terminée, dirigez-vous vers le menu Window > Decompiler. Cela ouvre une fenêtre qui permet de visualiser le code pseudo-C reconstitué à partir du binaire. Voici un aperçu de ce que vous pourriez obtenir :

Image décompilateur Ghidra

Dans la fenêtre Symbol Tree, vous remarquerez un dossier intitulé Functions. Ce dossier contient toutes les fonctions identifiées dans le programme. Cependant, la fonction principale (main) n’est pas toujours nommée explicitement comme telle. Voici un exemple :

Image fonction non nommée

Astuce pour identifier main avec Cutter

Pour les programmes où main n’est pas directement identifiable dans Ghidra, une autre méthode consiste à utiliser Cutter. En examinant les fonctions dans Cutter, vous pouvez remarquer que l’adresse 0x00401190 correspond à la fonction principale. Cette correspondance permet de cibler précisément main. Voici une capture d’écran pour illustrer :

Image Cutter fonction main

On selectionne donc dans Ghidra la fonction appelée “FUN_00401190”.
Une fois analysée, la fonction a été reconstruite sous cette forme :

undefined8 FUN_00401190(int param_1, long param_2)
{
  undefined8 uVar1;
  ulong uVar2;
  char *local_10;
  
  uVar1 = 1;
  if (1 < param_1) {
    uVar2 = strtoul(*(char **)(param_2 + 8), &local_10, 10);
    uVar1 = 1;
    if ((*local_10 == '\0') && (uVar1 = 2, uVar2 == (long)-param_1)) {
      uVar2 = 0;
      do {
        putc((int)(char)(&DAT_00402010)[uVar2], stdout);
        uVar2 = uVar2 + 4;
      } while (uVar2 < 0x116);
      putc(10, stdout);
      uVar1 = 0;
    }
  }
  return uVar1;
}

Merci pour la précision ! Voici une version plus développée et bien écrite de l’étape 5, tout en conservant les explications originales et les images :

Étape 5 : Comprendre les conditions clés

Pour simplifier la lecture et rendre le code plus compréhensible, il est conseillé de renommer les paramètres et les fonctions dans l’interface de Ghidra. Cela permet de clarifier les rôles de chaque élément analysé. Voici un exemple :

Image renommer les paramètres

Une fois cela fait, on identifie une condition particulièrement importante dans le code décompilé :

if ((*local_10 == '\0') && (uVar1 = 2, uVar2 == (long)-argc)) {

Cette condition peut être décomposée pour mieux comprendre son fonctionnement :

Première partie : (*local_10 == '\0')

Cette vérification assure que toute la chaîne passée en argument a été correctement convertie en entier. Autrement dit, il ne doit pas y avoir de caractères non numériques dans la chaîne. Pour confirmer cela, on se réfère à l’utilisation de la fonction strtoul et de son deuxième paramètre, local_10.

Deuxième partie : (uVar2 == (long)-argc)

Cette condition compare la valeur convertie (uVar2) avec -argc, qui représente l’opposé du nombre total d’arguments passés au programme.

Cela signifie qu’au moins un argument doit être fourni, et que cet argument doit être égal à -argc. Mais cela soulève une question : quel est cet argument ? Est-ce le deuxième ? Le troisième ?

Pour répondre à cette question, on examine une partie spécifique du code, visible ici :

Image compréhension des arguments

À première vue, on remarque un décalage de +8, ce qui pourrait laisser penser qu’il s’agit du huitième argument. Cependant, en explorant davantage, on découvre un cast avec (char **), ce qui modifie la logique :

  • Étape 1 : Prendre l’adresse de argv, qui contient les arguments passés au programme.
  • Étape 2 : Ajouter un offset de 8 octets, correspondant à la taille d’un pointeur en architecture x86-64.
  • Étape 3 : L’adresse obtenue pointe sur argv[1], soit le deuxième élément du tableau des arguments.

Ainsi, l’argument en question est bien argv[1].

Étape 6 : Tester l’hypothèse

Avec ces informations, j’ai tenté la commande suivante, qui semblait logique :

$ ./aaarg -2
FCSC{f9a38adace9dda3a9ae53e7aec180c5a73dbb7c364fe137fc6721d7997c54e8d}

Et voilà  😊