Linux Embedded

Le blog des technologies libres et embarquées

Survol des outils de debug

Introduction

Dans le développement de logiciels, le débogage consiste à localiser et à corriger les erreurs de code dans un programme informatique. Le débogage fait partie du processus de test logiciel et fait partie intégrante du cycle de vie du développement.

Le débogage est souvent difficile pour les programmeurs, Norman Matloff et Peter Jay Salzman disent:

Déboguer, c'est chercher une aiguille dans une botte de foin. Et dans de nombreux cas, le bug s'avère être dans la partie du programme dont vous êtes le plus sûr

Nous verrons dans cet article les outils (souvent méconnus) qui permettent de déboguer l'applicatif ou même le noyau d'un système sous Linux.

Cet article est articulé autour de deux grandes lignes; l'espace utilisateur et l'espace noyau.

Débogage en espace utilisateur

Déboguer en espace utilisateur consiste à déboguer dans l'environnement d'exécution d'une application, et donc dans un environnement non privilégié. On associe souvent l'espace utilisateur à l'espace mémoire qui lui est attribué, espace mémoire bénéficiant des différentes protections; protection mémoire, protection temporelle, etc.

Il existe plusieurs façons de déboguer dans l’espace utilisateur, nous présentons, dans cette partie, les méthodes les plus répandues.

Méthodes de traçage statique

Le débogage le plus simple (mais loin d'être le meilleur) est l'usage des fonctions de conversion de sortie formatée comme : printf, fprintf et autres dans le code. Cette méthode est connue sous le nom de USDT (user-level statically defined tracing) qui peut être traduit par “Méthode de traçage statique en espace utilisateur”.

Exemple d'application :

  1. printf : Nous allons utiliser la fonction "printf" pour s'assurer de la bonne réception des signaux SIGINT et SIGUSR1.
  2. Ecrire le code source (signal_handler.c) : voici un exemple de code simpliste ;
    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <signal.h>
    
    /* handler SIGUSR1 and SIGINT signals   */
    static void sigusr1_handler(int signo)
    {
            /*
              In practice, never call printf within a signal handler
            */
    
    	if(signo == SIGUSR1) 
    		printf("I caught SIGUSR1\n"); // USDT tracing SIGUSR1 signal reception 
    
    	else if (signo == SIGINT)
    		printf("I caught SIGINT\n"); // USDT tracing SIGINT signal reception 
    }
    
    int main (void)
    {
    	if (signal(SIGUSR1, sigusr1_handler) == SIG_ERR) { // Register To handle SIGUSR1
    		printf ("Cannot catch SIGUSR1\n");
    		exit (EXIT_FAILURE);
    	}
    
    	if (signal(SIGINT, sigusr1_handler) == SIG_ERR) { // Register To handle SIGINT
    		printf ("Cannot catch SIGINT\n");
    		exit (EXIT_FAILURE);
    	}
    
    	while (1)
    		pause(); 
    	
    	return EXIT_SUCCESS;/
    }
    

    Remarque : En pratique, la fonction signal est déprécié (usage pour des fins académique). Elle doit être remplacée par sigaction.

  3. Tester le programme : on peut utiliser la commande Linux ≪kill (kill -l pour avoir la liste des signaux et le numéro de chaque) pour envoyer des signaux au programme :

    kill -NUMERO_SIGNAL ./program

    Voici une série de test qu'on peut faire avec le programme (voir l'image ci-dessous).

    Catching SIGINT and SIGUSR1

    Grâce a printf, on a pu tester la réception des signaux dans notre programme.

    Finalement, on a vérifié que les signaux ont été bien reçus par l'application avec printf.
    Important : kill -9 (SIGKILL) est un signal qui ne peut pas être capté et géré par le processus. En effet, ce signal, lorsqu'il est reçu par le noyau, n'est pas transmis au processus par ce dernier; le noyau détruit simplement le processus.

Le système de fichiers

Les systèmes de fichiers /proc et /sys sont des pseudo système de fichiers (ils n'existent pas sur le disque), ils sont créés lors du démarrage du système.

ProcFs

Fournit des informations sur la configuration matérielle et des processus en cours d'exécution (d’où le nom proc pour “processes”).

On peut diviser le contenu de ce fichier ($ ls /proc) en deux :

Les processus

Chaque processus en cours d'exécution possède un dossier dans /proc, ces derniers sont nommés avec le PID de chaque processus (l'utilitaire "ps" parse le fichier /proc à la recherche de dossier processus).
On peut accéder au détail de chaque processus:

  • /proc/pid/maps : permet de voir l’organisation de l’espace virtuel d’un processus. Nous pouvons démontrer l'usage de ce fichier avec un exemple :
    1. L'exemple suivant hello-smile.c : affiche un message puis appelle la fonction pause().
      int main(){
      	printf("%s", "Hello Smile! I Love Open Source!\n");
      	for(;;)
      		pause();
      	return EXIT_SUCCESS;
      }
      

      On peut maintenant lire le fichier maps qui correspond à notre programme :

      $ cat /proc/`pidof hello-smile`/maps
      00400000-00401000 r-xp 00000000 fc:03 6562304                            /home/jugbe/hello-smile
      00600000-00601000 r--p 00000000 fc:03 6562304                            /home/jugbe/hello-smile
      00601000-00602000 rw-p 00001000 fc:03 6562304                            /home/jugbe/hello-smile
      00e1a000-00e3b000 rw-p 00000000 00:00 0                                  [heap]
      7fec97264000-7fec97424000 r-xp 00000000 fc:01 1704110                    /lib/x86_64-linux-gnu/libc-2.23.so
      7fec97424000-7fec97624000 ---p 001c0000 fc:01 1704110                    /lib/x86_64-linux-gnu/libc-2.23.so
      7fec97624000-7fec97628000 r--p 001c0000 fc:01 1704110                    /lib/x86_64-linux-gnu/libc-2.23.so
      7fec97628000-7fec9762a000 rw-p 001c4000 fc:01 1704110                    /lib/x86_64-linux-gnu/libc-2.23.so
      7fec9762a000-7fec9762e000 rw-p 00000000 00:00 0 
      7fec9762e000-7fec97654000 r-xp 00000000 fc:01 1704106                    /lib/x86_64-linux-gnu/ld-2.23.so
      7fec9782f000-7fec97832000 rw-p 00000000 00:00 0 
      7fec97853000-7fec97854000 r--p 00025000 fc:01 1704106                    /lib/x86_64-linux-gnu/ld-2.23.so
      7fec97854000-7fec97855000 rw-p 00026000 fc:01 1704106                    /lib/x86_64-linux-gnu/ld-2.23.so
      7fec97855000-7fec97856000 rw-p 00000000 00:00 0 
      7ffe48e21000-7ffe48e42000 rw-p 00000000 00:00 0                          [stack]
      7ffe48f3e000-7ffe48f41000 r--p 00000000 00:00 0                          [vvar]
      7ffe48f41000-7ffe48f43000 r-xp 00000000 00:00 0                          [vdso]
      ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]
      jugbe@F-NAN-HIPPOPOTAME:~$ 
      

      Remarque : La commande "pidof" renvoie le PID du processus donné en paramètre.

    2. Interpréter le fichier maps :
      • Ligne 1 : 00400000-00401000 r-xp 00000000 fc:03 6562304 /home/jugbe/hello-smile est le segment texte de l'exécutable, en effet; on peut le reconnaître grâce à ses permissions (r = read, x = execute).
      • Ligne 2 : 00600000-00601000 r--p 00000000 fc:03 6562304 /home/jugbe/hello-smile est la première partie du segment de données (permission : r=read).
      • Ligne 3 : 00601000-00602000 rw-p 00001000 fc:03 6562304 /home/jugbe/hello-smile est la deuxième partie du segment de données (avec des autorisations de lecture et d'écriture).
      • Ligne 4 : 00e1a000-00e3b000 rw-p 00000000 00:00 0 [heap] est le segment de tas (mémoire dynamique).
      • Les lignes suivantes :
        7fec97264000-7fec97424000 r-xp 00000000 fc:01 1704110                    /lib/x86_64-linux-gnu/libc-2.23.so
        7fec97424000-7fec97624000 ---p 001c0000 fc:01 1704110                    /lib/x86_64-linux-gnu/libc-2.23.so
        7fec97624000-7fec97628000 r--p 001c0000 fc:01 1704110                    /lib/x86_64-linux-gnu/libc-2.23.so
        7fec97628000-7fec9762a000 rw-p 001c4000 fc:01 1704110                    /lib/x86_64-linux-gnu/libc-2.23.so
        

        sont la bibliothèque C.

      • Les lignes :
        7fec9762e000-7fec97654000 r-xp 00000000 fc:01 1704106                    /lib/x86_64-linux-gnu/ld-2.23.so
        7fec97853000-7fec97854000 r--p 00025000 fc:01 1704106                    /lib/x86_64-linux-gnu/ld-2.23.so
        7fec97854000-7fec97855000 rw-p 00026000 fc:01 1704106                    /lib/x86_64-linux-gnu/ld-2.23.so
        

        est l'éditeur de liens dynamique.

      • Le segment de la pile : 7ffe48e21000-7ffe48e42000 rw-p 00000000 00:00 0 [stack]
      • La ligne : 7ffe48f41000-7ffe48f43000 r-xp 00000000 00:00 0 [vdso] (virtual dynamic shared object) qui permet d'exécuter certains appels système dans l'espace utilisateur
      • Ligne : ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall] fait la même chose que "vdso" sur les architectures 32 bits (mais il est obsolète sur x86_64, il n'existe que pour la rétrocompatibilité).

      Note : On remarquera que les bibliothèques sont chargées sur plusieurs segments car les permissions sont différentes (r-xp, ---p, r--p, ..., etc).

  • /proc/pid/pagemap : permet d'obtenir le mapping des pages (espace virtuel) vers les cadres dans la mémoire physique.
    Attention : ce fichier est binaire, il faut le parser avec une application dédiée.

    Parser le fichier /proc/pid/pagemap

    Il existe plusieurs façon de le faire, on peut utiliser le script écrit par JEFF LI

    https://blog.jeffli.me/blog/2014/11/08/pagemap-interface-of-linux-explained/

    Nous allons voir le mapping de la page 0x54adf000 vers la mémoire comme le montre la figure ci dessous.

    Le script parse le fichier pagemap et nous renvoi que la page est mappé vers le cadre mémoire 0x38c5bl (PFN = Page Frame Number). Le script parse aussi d'autres fichiers (/proc/kpagecount et /proc/kpageflags) pour nous renvoyer le nombre de références qui pointe vers le cadre mémoire et les flags associés.

  • /proc/pid/limits : retourne les limites des processus en terme de ressources (Linux définit 16 ressources).
    On peut voir les limites du processus "init" :
    $ cat /proc/1/limits 
    Limit                     Soft Limit           Hard Limit           Units     
    Max cpu time              unlimited            unlimited            seconds   
    Max file size             unlimited            unlimited            bytes     
    Max data size             unlimited            unlimited            bytes     
    Max stack size            8388608              unlimited            bytes     
    Max core file size        0                    unlimited            bytes     
    Max resident set          unlimited            unlimited            bytes     
    Max processes             31583                31583                processes 
    Max open files            1048576              1048576              files     
    Max locked memory         65536                65536                bytes     
    Max address space         unlimited            unlimited            bytes     
    Max file locks            unlimited            unlimited            locks     
    Max pending signals       31583                31583                signals   
    Max msgqueue size         819200               819200               bytes     
    Max nice priority         0                    0                    
    Max realtime priority     0                    0                    
    Max realtime timeout      unlimited            unlimited        
    

    Chaque processus peut utiliser un maximum de ressources définit dans Soft Limit. Si cela n'est pas suffisant, il peut augmenter jusqu'a la limite définit par Hard Limit.

    Remarque : Seul les processus root peuvent augmenter leur Hard Limit, les processus conventionnels peuvent seulement la diminuer (le processus est irréversible). Aussi ne pas utiliser directement /proc/<pid>/limits pour réaliser ces ajustements, nous verrons la fonction setrlimit() plus loin dans cet article.

  • /proc/pid/status : Informations sur le statut du processus.
Le système

L'autre partie exposée par le ProcFs est la configuration du système. Voici quelques exemples :

  • /proc/meminfo : affiche l'état de la mémoire du système (ce fichier est parsé par les utilitaire free et vmstat).
  • /proc/cpuinfo : renvoi les informations liées à chaque CPU.

SysFs

Le système de fichiers /proc n'a ni structure ni règles pour l'organiser. Les informations sont exposées d'une manière chaotique (les structures de données qui ne sont pas liées se trouvent au même endroit).

SysFs est un pseudo-système de fichiers structuré (http://man7.org/linux/man-pages/man5/sysfs.5.html).

Comme le but de l'article n'est pas le fichier /sys, un seul dossier nous intéresse c'est le : /sys/module (voir l'image ci-dessous). Ce dernier permet d'obtenir toutes les informations liées aux modules (Analyser le contenu de ce dossier est la première étape avant de commencer à déboguer un module).

Remarque : La commande "lsmod" permet de lister les modules en cours d’exécution. Cette dernière est très utile pour vérifier si le module est bien chargé, sa taille et plus encore. Cependant, elle est très limité (on préfère utiliser le SysFS).

Les appels système et appels aux bibliothèques

Ptrace

Ptrace est le mécanisme le plus connu pour déboguer les applications de l'espace utilisateur.

La plupart des utilitaires de débogage (strace, ltrace et même GDB) s'appuient sur ptrace en arrière-plan (sans ce dernier ils seront inutiles).

En raison de problèmes de sécurité, certaines distributions comme Ubuntu désactivent ptrace par défaut. Dans ce cas, il faut l'activer comme suit:

$ sudo echo 0 > /proc/sys/kernel/yama/ptrace_scope

Une page détaillée sur Yama (patch de sécurité) est disponible sur ce lien : https://www.kernel.org/doc/Documentation/security/Yama.txt

Les appels système

L'appel système est la manière programmatique avec laquelle un programme demande un service au noyau.

strace est l'utilitaire qui permet de tracer les appels système

Nous allons apprendre à utiliser strace avec un exemple :

    1. Programme de test (file-reader.c) : Le programme suivant lit le contenu d'un fichier (disponible ici : https://github.com/jugurthab/Linux_kernel_debug/tree/master/debug-examples/Chap1-userland-debug/strace) :
      #define FILE_PATH "smile-stats.txt"
      
      int main(int argc,char *argv[]){    
          FILE *fileDescriptorSmile=NULL;
          char c;    
          fileDescriptorSmile = fopen(FILE_PATH,"r");
          if(fileDescriptorSmile != NULL){
              while (c != EOF){
                  printf ("%c", c);
                  c = fgetc(fileDescriptorSmile);
              }
              printf("\n");
              fclose(fileDescriptorSmile);
          }
          else {
              printf("Cannot open the file !\n");
          }     
          return EXIT_SUCCESS;
      }
      
    2. Executer le programme : avec et sans traceur.
      • Executer le programme sans strace : (voir l'image ci dessous)

        N.B : Le contenu du fichier a été bien lu.

      • Executer le programme avec strace (le fichier "file-reader" est présent dans le répertoire courant) : strace affiche (par défaut) tous les appels système (open, close, read, mmap, ..., etc).

        On voit clairement la ligne “open("smile-stats.txt", O_RDONLY) = 3”, cela veut dire dire que le fichier a été bien ouvert et l'appel système retourne le descripteur "3" pour le manipuler ultérieurement.

        Mais pourquoi le descripteur est 3 et pas 1 ou 0

        Par convention, chaque processus a au moins trois descripteurs de fichiers ouverts : 0 (stdin), 1 (stdout) et 2 (stderr). Ce qui fait que le prochain descripteur qu'un processus peut ouvrir est 3.

      • Executer le program avec strace (le fichier "file-reader" est absent du répertoire courant) : nous allons essayer de supprimer le fichier "smile-stats.txt" de son emplacement puis d'appliquer strace sur notre programme (voir l'image ci-dessous).
        Tracing program with strace - file does not exist

        Cette fois ci, strace retourne -1 (descripteur de fichier invalide), et affiche la raison de l’échec (fichier ou dossier introuvable).

    3. Quelques options utiles de strace :
      • Sélectionner les appels système à tracer

        $ strace -e trace=SysCall1, SysCall2, SysCall3 ./monProgram

      • Sélectionner les types d'appels système à tracer

        $ strace -e open,close ./monProgram

        Par exemple :

        Filtering strace output by system calls
      • Filtrer les appels système par catégories : le rapport de strace peut rapidement devenir trop grand, on peut filtrer par catégorie :
        System call Categories Related system calls
        file open, chmod, stat, truncate, ...,etc.
        process Process management : clone, exit_group, execve, wait4, ..., etc.
        network Network syscalls : socket, linsten, bind, ..., etc.
        memory mmap, mprotect, ..., etc

        Pour récupérer les appels système liés a la manipulation de fichiers, nous devons utiliser l'option suivante :

        $ strace -e trace=file ./file-reader

        Filtering strace output by categories

        On peut tracer plusieurs catégories, pour tracer les appels système liés aux fichiers et à la mémoire :

        $ strace -e trace=file,memory ./file-reader

      • Sauvegarder la trace dans un fichier : on peut rediriger le rapport de strace vers un fichier.

        $ strace -o fichier ./file-reader

        Par exemple, pour récupérer les appels système fstat et les sauvegarder dans un fichier (log.txt) :

        $ strace -o log.txt -e fstat ./file-reader

Le secret de la commande "free" ?

Nous allons découvrir l'origine des informations fournis par free avec strace :
Free uses /proc/meminfo
free parse le fichier /proc/meminfo

Les appels aux bibliothèques

Les programmes font appel à des fonctions se trouvant dans des bibliothèques partagées. Et cela signifie simplement que le chemin d'une bibliothèque est recherché au moment de l'exécution pour déterminer l'emplacement de la fonction à appeler. Ces appels ne sont pas en destination du noyau (ne peuvent pas être tracé pas strace).

ltrace est l'utilitaire qui permet de tracer les appels entre un exécutable et des bibliothèques

Pour illustrer le fonctionnement de ltrace, nous allons créer notre propre bibliothèque :

  • Création de la bibliothèque : notre bibliothèque va définir une fonction "helloOpenSource" qui affiche un message passé en argument (Les sources sont aussi disponible sur : https://github.com/jugurthab/Linux_kernel_debug/tree/master/debug-examples/Chap1-userland-debug/ltrace).
    1. smile-hello-open-source.h : fichier entête de la bibliothèque.
      	void helloOpenSource(const char *message);
      
    2. smile-hello-open-source.cpp : fichier source qui contient l'implémentation de la fonction définit dans smile-hello-open-source.h.
      void helloOpenSource(const char *message){
      	printf("%s",message);
      }
      
    3. Compilation de la bibliothèque : (voir ci-dessous)

      $ gcc -fPIC --shared -o libsmile-hello-open-source.so smile-hello-open-source.cpp

  • Le programme de test (ltrace-hello.cpp) :
    #include "smile-hello-open-source.h"
    
    int main(){
    
        helloOpenSource("The world is better when source code is open!\n");    
    
        return EXIT_SUCCESS;
    }
    
  • Lier la bibliothèque avec le programme de test : nous devons compiler notre programme avec la bibliothèque :
    $ export LD_LIBRARY_PATH=.
    $ gcc -o ltrace-hello ltrace-hello.cpp -lsmile-hello-open-source -L.
    Voila un résumé des étapes :
    Linking program executable to library
  • Tester le programme : avant d'utiliser ltrace, nous allons nous assurer que le programme est bien compilé.
    • Vérifier si la bibliothèque est bien attaché à l'exécutable.
      Checking executable to library linking

      On voit bien que libsmile-hello-open-source.so possède un chemin valide (./libsmile-hello-open-source.so).

    • Executer le programme : Nous pouvons exécuter le programme a présent :
      Checking executable working
  • Tracer les appels à la bibliothèque avec ltrace : nous allons enfin utiliser ltrace. Commençons par l'installer (si il n'est pas déjà présent) :

    $ sudo apt-get install ltrace

    Puis on peut lancer ltrace avec notre programme comme ceci :

    Tracing library calls using ltrace

    ltrace a capturé l'appel de la fonction main (__libc_start_main de notre programme) vers la fonction helloOpenSource (qui se trouve dans notre bibliothèque).

    Limitations de ltrace

    ltrace capture tous les appels entre un exécutable et des bibliothèques. Cependant; il ne peut pas suivre les appels entre les bibliothèques (d'ailleurs notre fonction helloOpenSource fait appel à la fonction printf qui se trouve dans la bibliothèque C).

  • Tracer même les appels entre bibliothèques : comme ltrace devient vite limité, latrace est la solution à notre problème.

    $ sudo apt-get install latrace

    Nous allons appliquer latrace sur notre programme :

    Tracing library calls using latrace

    on voit bien : __libc_start_main qui appelle helloOpenSource et qui elle même appelle printf.

    latrace affiche les appels sous forme d'un arbre, on peut facilement voir la relation entre les appels.

    N.B : Il est aussi important de rappeler que ltrace ou latrace ne peuvent pas suivre les fonctions si l'executable inclut statiquement les bibliothèques partagées (compilation sans le paramètre -static avec gcc) et les fonctions ne doivent pas être déclaré avec l'attribut inline (certains compilateurs utilisent la technologie Link-Time Code Generation qui peux mettre les fonctions en mode inline même si elles ne sont pas déclarées de la sorte), en outre les symboles des fonctions doivent apparaitre avec l'utilitaire nm.

Pour finir, voici une image qui récapitule les outils nécessaires pour tracer différents appels :

Comparing strace, ltrace and latrace

Chasse aux erreurs avec Valgrind

Valgrind est un framework de débogage, d'intrumentation et de profilage de mémoire pour les applications de l'espace utilisateur.

Les sources des démos

Les sources sont disponibles sur le lien suivant :
https://github.com/jugurthab/Linux_kernel_debug/tree/master/debug-examples/Chap1-userland-debug/valgrind

Le framework Valgrind propose plusieurs outils; Memcheck, Cachegrind, Callgrind, Massif, Helgrind, DRD, etc.

 

Memcheck

C'est l'outil le plus connu sous Valgrind, memcheck est un détecteur d'erreur mémoire. Il permet de traquer les problèmes suivant :

  • Les fuites mémoire : Le code suivant sera utilisé comme exemple :
    int main(){
    	char *str;
    	
    	str = (char*)calloc(40, sizeof(char)); // Try to avoid malloc
    					       // do not use memset after malloc (use calloc)
    	return EXIT_SUCCESS;
    }
    

    Nous pouvons l'analyser avec memcheck comme suit :

    pi@raspberrypi:~/userspace/valgrind/memcheck$ gcc memcheck-memory-leak.c -o memcheck-memory-leak -g
    pi@raspberrypi:~/userspace/valgrind/memcheck$ valgrind --tool=memcheck --leak-check=full ./memcheck-memory-leak 
    ==7369== Memcheck, a memory error detector
    ........................
    ==7369== Command: ./memcheck-memory-leak
    ==7369== 
    ==7369== 
    ==7369== HEAP SUMMARY:
    ==7369==     in use at exit: 40 bytes in 1 blocks
    ==7369==   total heap usage: 1 allocs, 0 frees, 40 bytes allocated
    ==7369== 
    ==7369== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
    ==7369==    at 0x4C2FB55: calloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
    ==7369==    by 0x40053C: main (memcheck-memory-leak.c:8)
    ==7369== 
    ........................
    ........................
    

    Memcheck a détecté la fuite mémoire (40 bytes in 1 blocks are definitely lost in loss record 1 of 1) et pointe vers son emplacement (by 0x40053C: main (memcheck-memory-leak.c:8)).

  • Les variables non initialisées : Une illustration de l'outil peut se faire comme suit :
    int main(){
    	int loveOpenSource;
    
    	if(loveOpenSource)
    		printf("Welcome to SMILE!\n");
    	else
    		printf("Let's change your mind!\n");
    
    	return EXIT_SUCCESS;
    }
    

    On peut essayer d'appliquer memcheck sur l'executable :

    pi@raspberrypi:~/userspace/valgrind/memcheck$ gcc memcheck-uninitialized-variable.c -o memcheck-uninitialized-variable -g
    pi@raspberrypi:~/userspace/valgrind/memcheck$ valgrind --tool=memcheck ./memcheck-uninitialized-variable 
    ........................
    ==7594== Command: ./memcheck-uninitialized-variable
    ==7594== 
    ==7594== Conditional jump or move depends on uninitialised value(s)
    ==7594==    at 0x400532: main (memcheck-uninitialized-variable.c:8)
    ==7594== 
    Let's change your mind!
    ==7594== 
    ........................
    ........................
    

    memcheck signale le jump conditionnel (main (memcheck-uninitialized-variable.c:8)) qui dépend d'une variable non-initialisée.

  • Les fonctions d'allocation et de désallocation en discordance : L'allocation en C est différente du C++ (en effet le dernier retroune un objet et pas seulement une adresse), il arrive parfois que leurs syntaxes se chevauchent (surtout qu'on travaille en équipe) comme ceci :
    int main(){
    	int *nbElements = NULL;
    	nbElements = (int *) calloc(20, sizeof(int));
    		
    	delete(nbElements); // Use C++ delete	
    	return EXIT_SUCCESS;
    }
    

    Memcheck peut facilement détecter ce genre de problème.

    pi@raspberrypi:~/userspace/valgrind/memcheck$ g++ memcheck-mismatch-alloc-free.c -o memcheck-mismatch-alloc-free -g
    pi@raspberrypi:~/userspace/valgrind/memcheck$ valgrind --tool=memcheck ./memcheck-mismatch-alloc-free 
    ........................
    ==7896== Mismatched free() / delete / delete []
    ==7896==    at 0x4C2F24B: operator delete(void*) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
    ==7896==    by 0x400674: main (memcheck-mismatch-alloc-free.c:9)
    ==7896==  Address 0x5ab6c80 is 0 bytes inside a block of size 80 alloc'd
    ==7896==    at 0x4C2FB55: calloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
    ==7896==    by 0x400664: main (memcheck-mismatch-alloc-free.c:7)
    ==7896== 
    ........................
    ........................
    

    On voit clairement que la faute se trouve dans memcheck-mismatch-alloc-free.c:9 et memcheck-mismatch-alloc-free.c:7.

  • Lire (ou écrire) à la fin d'un tampon mémoire : c'est une source d'erreur très commune comme on peut l'illustrer :
    int main(){
    
    	char *str;
    	str = (char *) calloc(10, sizeof(char));
    	
    	printf("%c\n", *(str+20));
    
    	free(str);
    
    	return EXIT_SUCCESS;
    }
    

    Memcheck est encore capable de traquer ces erreurs :

    pi@raspberrypi:~/userspace/valgrind/memcheck$ gcc memcheck-read-over-buffer.c -o memcheck-read-over-buffer -g
    pi@raspberrypi:~/userspace/valgrind/memcheck$ valgrind --tool=memcheck ./memcheck-read-over-buffer 
    ........................
    ==8037== Command: ./memcheck-read-over-buffer
    ==8037== 
    ==8037== Invalid read of size 1
    ==8037==    at 0x4005D9: main (memcheck-read-over-buffer.c:10)
    ==8037==  Address 0x5204054 is 10 bytes after a block of size 10 alloc'd
    ==8037==    at 0x4C2FB55: calloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
    ==8037==    by 0x4005CC: main (memcheck-read-over-buffer.c:8)
    ==8037== 
    ........................
    ........................
    

Helgrind

Helgrind détecte les erreurs de synchronisation dans les programmes C/C++ et Fortran qui utilisent les primitives de threading POSIX pthreads.
Une démo sera plus parlante :

Les sources de la démo

Un exemple de threads qui calculent un produit scalaire est fourni sur le lien suivant :
https://github.com/jugurthab/Linux_kernel_debug/tree/master/debug-examples/Chap1-userland-debug/valgrind/helgrind

On peut l'analyser avec helgrind comme suit :

$ gcc helgrind-threads-file-writer.c -o helgrind-threads-file-writer -pthread -g
$ valgrind --tool=helgrind ./helgrind-threads-file-writer
==15238== Possible data race during read of size 4 at 0x601084 by thread #3
==15238== Locks held: none
==15238==    at 0x40084B: writeToSharedVariable (helgrind-threads-file-writer.c:14)
==15238==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==15238==    by 0x4E476B9: start_thread (pthread_create.c:333)
==15238== 
==15238== This conflicts with a previous write of size 4 by thread #2
==15238== Locks held: none
==15238==    at 0x400845: writeToSharedVariable (helgrind-threads-file-writer.c:15)
==15238==    by 0x4C34DB6: ??? (in /usr/lib/valgrind/vgpreload_helgrind-amd64-linux.so)
==15238==    by 0x4E476B9: start_thread (pthread_create.c:333)
==15238==  Address 0x601084 is 0 bytes inside data symbol "sharedVariable"

Callgrind

Callgrind est un profileur CPU. Le lecteur est probablement familier avec GPROF. ce dernier est devenu obsolète car il ne supporte pas les threads et ne sait pas interpréter les appels système.

Un exemple sera mieux :

Les sources de la démo

Un exemple de générateur de nombre pseudo-aléatoire est disponible sur le lien suivant :
https://github.com/jugurthab/Linux_kernel_debug/blob/master/debug-examples/Chap1-userland-debug/valgrind/callgrind/callgrind-test.c

Scanner son code avec callgrind se fait en deux étapes :

  • Sauvegarder le profilage de code dans un fichier :
    pi@raspberrypi:~/userspace/valgrind/callgrind$ gcc callgrind-cpu-profile.c -o callgrind-cpu-profile -g
    pi@raspberrypi:~/userspace/valgrind/callgrind$ valgrind --tool=callgrind ./callgrind-cpu-profile 
    ==6886== Callgrind, a call-graph generating cache profiler
    ==6886== Copyright (C) 2002-2015, and GNU GPL'd, by Josef Weidendorfer et al.
    ==6886== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
    ==6886== Command: ./callgrind-cpu-profile
    ==6886== 
    ==6886== For interactive control, run 'callgrind_control -h'.
    ==6886== 
    ==6886== Events    : Ir
    ==6886== Collected : 183388
    ==6886== 
    ==6886== I   refs:      183,388
    
    

    Callgrind signale qu'il a recueilli 183388 événements.

  • Lire le fichier profilage : La lecture se fait avec la commande suivante :

    $ callgrind_annotate −−auto=yes callgrind.out.pid

       
         .  #define NUMBER_IN_SET 500
         .  #define MAX_NUMBER_VALUE 100
         .  #define MIN_NUMBER_VALUE 2
         .  
         .  int generateRandomNumber();
         .  
         6  int main(){
         .  	
         .  	int numberSet1[NUMBER_IN_SET], numberSet2[NUMBER_IN_SET], i;
         .  	int totalSet[NUMBER_IN_SET];
        14  	srand(time(NULL));
    12,286  => /build/glibc-Cl5G7W/glibc-2.23/elf/../sysdeps/x86_64/dl-trampoline.h:_dl_runtime_resolve_avx'2 (2x)
         .  	
     1,504  	for(i=0; i<NUMBER_IN_SET; i++){ 3,000 numberSet1[i] = generateRandomNumber(); 33,833 => callgrind-cpu-profile.c:generateRandomNumber (500x)
     3,000  		numberSet2[i] = generateRandomNumber();
    32,984  => callgrind-cpu-profile.c:generateRandomNumber (500x)
         .  		// add the 2 generated numbers and store them
     5,000  		totalSet[i] = numberSet1[i] + numberSet2[i];
         .  	}
         .  	
         1  	return EXIT_SUCCESS;
         5  }
         .  /* Function that generates a random integer */
     2,000  int generateRandomNumber(){
    15,004      return (rand() % (MAX_NUMBER_VALUE - MIN_NUMBER_VALUE)) + MIN_NUMBER_VALUE;
    46,921  => /build/glibc-Cl5G7W/glibc-2.23/stdlib/rand.c:rand (999x)
       892  => /build/glibc-Cl5G7W/glibc-2.23/elf/../sysdeps/x86_64/dl-trampoline.h:_dl_runtime_resolve_avx'2 (1x)
     2,000  }
    
    

    Callgrind affiche le nombre d'instructions (assembleur) dans le code source.

Cachegrind

Cachegrind simule comment notre programme interagit avec la hiérarchie de cache d'une machine.

Les sources de la démo

Le code de l'exemple suivant écrit une structure dans un fichier binaire puis le relit et sauvegarde son contenu dans une autre structure :
https://github.com/jugurthab/Linux_kernel_debug/tree/master/debug-examples/Chap1-userland-debug/valgrind/cachegrind

Cachegrind peut mettre en évidence les échanges avec les caches, ce qui peut aider dans l'optimisation.

pi@raspberrypi:~/userspace/valgrind/cachegrind$ valgrind --tool=cachegrind ./cachegrind-cache-stats 
==7153== Cachegrind, a cache and branch-prediction profiler
==7153== Copyright (C) 2002-2015, and GNU GPL'd, by Nicholas Nethercote et al.
==7153== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==7153== Command: ./cachegrind-cache-stats
==7153== 
--7153-- warning: L3 cache found, using its data for the LL simulation.
----- The new content of structure employeeInfoInput ------
		Name : Jugurtha BELKALEM
		Age : 26
==7153== 
==7153== I   refs:      164,821
==7153== I1  misses:      1,114
==7153== LLi misses:      1,097
==7153== I1  miss rate:    0.68%
==7153== LLi miss rate:    0.67%
==7153== 
==7153== D   refs:       55,056  (41,954 rd   + 13,102 wr)
==7153== D1  misses:      3,011  ( 2,437 rd   +    574 wr)
==7153== LLd misses:      2,481  ( 1,963 rd   +    518 wr)
==7153== D1  miss rate:     5.5% (   5.8%     +    4.4%  )
==7153== LLd miss rate:     4.5% (   4.7%     +    4.0%  )
==7153== 
==7153== LL refs:         4,125  ( 3,551 rd   +    574 wr)
==7153== LL misses:       3,578  ( 3,060 rd   +    518 wr)
==7153== LL miss rate:      1.6% (   1.5%     +    4.0%  )

Ou I = Instruction et D = Données.

Important : Cachegrind simule toujours deux niveaux de caches (même si votre machine en possède plus). Pour éviter les ambiguités LL veut dire (Last level), qui peut être interprété comme le dernier cache (ce qui veut dire : I1, D1 représent le premier cache et LLi, LLd le dernier).

Après ce petit exemple, nous laissons le lecteur prolonger l'étude de ces profilers par lui-même.

Nous en avons terminé avec ce survol de Valgrind, nous présentons maintenant ce qu'est un fichier "core".

Fichiers core

C'est un fichier contenant l'espace d'adresse mémoire d'un processus lorsque ce dernier se termine de façon inattendue.

Un exemple d'erreur classique consiste à référencer une adresse nulle (qui n'existe pas) comme suit :

int main(){

    *(int *)NULL = 0; // dereferencing NULL pointer

    printf("SMILE, This is a dereferenced NULL pointer!!\n");
           
    return EXIT_SUCCESS;
}

Nous allons compiler le programme et le lancer comme suit:

pi@raspberrypi:~/debugUserSpace/core-dump $ ls
userland-core-dump-analysis.c
pi@raspberrypi:~/debugUserSpace/core-dump $ gcc userland-core-dump-analysis.c -o userland-core-dump-analysis -g
pi@raspberrypi:~/debugUserSpace/core-dump $ ./userland-core-dump-analysis 
Segmentation fault
pi@raspberrypi:~/debugUserSpace/core-dump $ ls
userland-core-dump-analysis  userland-core-dump-analysis.c
pi@raspberrypi:~/debugUserSpace/core-dump $

Problème : Mais pourquoi le fichier dump n'est pas généré?

  1. Activer les fichiers dump : Les fichiers dump ne sont plus générés par défaut lorsqu'on installe un système. La majorité des utilisateurs ne savent pas les utiliser, et souvent prennent de la place. Les linuxiens ont décidé de les désactiver. Nous pouvons voir la configuration ressources associée a chaque utilisateur comme ci-dessous :
    pi@raspberrypi:~/debugUserSpace/core-dump $ ulimit -a
    core file size          (blocks, -c) 0
    data seg size           (kbytes, -d) unlimited
    scheduling priority             (-e) 0
    file size               (blocks, -f) unlimited
    pending signals                 (-i) 7336
    max locked memory       (kbytes, -l) unlimited
    max memory size         (kbytes, -m) unlimited
    open files                      (-n) 65536
    pipe size            (512 bytes, -p) 8
    POSIX message queues     (bytes, -q) 819200
    real-time priority              (-r) 95
    stack size              (kbytes, -s) 8192
    cpu time               (seconds, -t) unlimited
    max user processes              (-u) 7336
    virtual memory          (kbytes, -v) unlimited
    file locks                      (-x) unlimited
    pi@raspberrypi:~/debugUserSpace/core-dump $ 
    

    La ligne : "core file size : (blocks, -c) 0" représente la taille du fichier dump qui sera généré. Dans ce cas, 0 veut dire aucun fichier ne sera généré. Pour corriger ce problème, il suffit de saisir la commande suivante :

    $ ulimit -c unlimited

  2. Récupérer le fichier dump : Maintenant, il suffit de lancer notre programme et le fichier dump sera écrit si une erreur survient comme montré ci-dessous.
    pi@raspberrypi:~/debugUserSpace/core-dump $ ls
    userland-core-dump-analysis.c
    pi@raspberrypi:~/debugUserSpace/core-dump $ ulimit -c unlimited
    pi@raspberrypi:~/debugUserSpace/core-dump $ gcc userland-core-dump-analysis.c -o userland-core-dump-analysis -g
    pi@raspberrypi:~/debugUserSpace/core-dump $ ./userland-core-dump-analysis 
    Segmentation fault (core dumped)
    pi@raspberrypi:~/debugUserSpace/core-dump $ ls
    core  userland-core-dump-analysis  userland-core-dump-analysis.c
    pi@raspberrypi:~/debugUserSpace/core-dump $ 
    
  3. Lire le fichier dump : Le fichier dump peut être lu avec gdb :
    reading core dumps

    GDB indique que le problème se trouve dans la ligne 6 (*(int *)NULL = 0 ) qui correspond a notre erreur.

    Attention : GDB a besoin de la table des symboles, il faut toujours les inclures lors de la compilation (gcc -g)

    Activer les fichiers dump programmatiquement

    On peut changer les ressources logicielles d'un processus programmatiquement (seul root peut augmenter ses ressouces materiels). La structure concernée est “struct rlimit”.

    int main(){
    
    	struct rlimit rlimProcess; // declare resource structure
    	int returnValue; 
    	rlimProcess.rlim_cur = RLIM_INFINITY; // Set software resource limits to unlimited 
    	rlimProcess.rlim_max = RLIM_INFINITY; // Set hardware resource limits to unlimited
    	
    	returnValue = setrlimit (RLIMIT_CORE, &rlimProcess); // Change core dump size to unlimited
    
    	if (returnValue == -1){
    		perror("setlimit Error ");
    		exit(EXIT_FAILURE);
    	}
    
    
    	int testOverflow[10];
    
    	
    	testOverflow[11] = 60; // OVERFLOW because location does not exist
    
    	return EXIT_SUCCESS;	
    }
    

Débogage coté noyau

Le débogage noyau est différent de l'espace utilisateur, voici quelques méthodes qui permettent de le faire :

Linux kernel debugging methodologies

Dans cet article, nous verrons les fautes systèmes et comment déboguer des modules avec KGDB/KDB.

Fautes système

Les fautes système ne désignent pas un Kernel Panic, ce dernier est le résultat d'une faute grave ou d'un effet en cascade de fautes système.

Lorsqu'un programme en espace utilisateur viole un accès mémoire, un SIGSEGV est généré et le processus défectueux est détruit (n'oubliez pas d'activer les fichiers core dump pour les analyser). La même chose est vraie pour le noyau, lorsqu'un driver (ou module) essaie de "déréférencer un pointeur nul invalide" ou "déborde le tampon de destination", il va être tué.

Si le Kernel Panic ne se manifeste pas (à cause d'un erreur grave), le code défectueux dans un pilote ou un module peut conduire à l'un des états kernel oops, kernel hang.

Kernel Oops

Parfois appelé panique douce (par opposition à panique kernel). Généralement, c'est le résultat d'un déréférencement vers un pointeur nul. Dans ce cas le noyau écrit des messages d'erreurs appelés oops dans son tampon.

Module défectueux
  • Le code du module : le module fait un déréférencement vers un pointeur nul.
    static void createOops(void){
    	*(int *)0 =0;
    }
    
    static int __init initializeModule(void){
    	printk("Hey SMILE!This is a kernel oops test module!!!\n");				
    	createOops();		
    	return 0;
    }
    
    static void __exit cleanModule(void){
    	printk("Goodby SMILE! Module exited!\n");
    }
    
    module_init(initializeModule);
    module_exit(cleanModule);
    
  • Création du Makefile : pour compiler le module.
    obj-m += kernel-oops-mod.o
    MY_CFLAGS += -g -DDEBUG
    ccflags-y += ${MY_CFLAGS}
    CC += $(MY_CFLAGS)
    
    
    default:
    	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
    
    debug:
    	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules 
    	EXTRA_CFLAGS="$(MY_CFLAGS)"
    clean:
    	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
    
  • Compiler le module : $ make debug
  • Insérer le module dans le noyau :
    Inserting faulty module to the kernel
Comprendre les oops

Nous pouvons lire du tampon noyau les messages oops avec la commande dmesg : $ dmesg

Pleins de messages s’afficheront dont la majorité ne sont pas des oops (mais plutôt un rapport du système).

  1. Récupérer les oops : La première ligne de l'erreur est la plus importante comme illustré ci dessous :
    Reading kernel oops
    • BUG : désigne le type de l'erreur.
    • IP (Instruction Pointer) : indique l'emplacement de l'erreur (nous y reviendrons plus tard).
  2. Interprétation du code oops :
    Kernel oops generated reason

    Nous pouvons regarder de plus près le code d'erreur 0002. Le nombre est en décimal, nous devons le convertir en binaire:

    0002 (décimal) = 0010 (binaire)

    Pour comprendre la signification du code, il faut voir le schéma suivant :

    kernel oops code signification

    Maintenant on est capable de lire le code :

    0 0 0 0 1 = la faute est le résultat d'un accès en écriture à une page inexistante dans l'espace noyau, cependant l'instruction n'est pas une instruction fetch.

  3. Contenu des registres : on peut jeter un coup d’œil sur l'état des registres.
    register dumping - kernel oops
  4. Contenu de la trace : peut être intéressante.
    stack trace - kernel oops

    Remarque : Le ? présent a coté de certaines fonctions signifie que les informations sur cette entrée ne sont probablement pas fiables (voir https://stackoverflow.com/questions/13113384/what-is-the-meaning-of-question-marks-in-linux-kernel-panic-call-traces).

  5. Instruction en cours d'exécution : Le code machine de l'instruction en cours d’exécution est affiché.
    decimal instruction executing while kernel oops

    Nous ne décortiquerons pas ce code machine, nous laisson le lecteur motivé s'en occuper.

  6. Registre IP : Nous allons enfin apprendre à lire la signification du registre RIP.

    IP : fonction + offset/taille_fonction

    Cela veut dire que la fonction "initializeModule" est d'une taille de 0x1000 et l'erreur se produit à l'offset 0x10 (par rapport au début de la fonction).

  7. Adresse du module :
    Get address of the module
  8. Lancer une session GDB : démarrer une session GDB, ajouter à GDB l'emplacement du module comme indiqué ci dessous :
    Launching GDB to debug module - kernel oops
  9. L'assembleur entre en jeu : Nous devons désassembler la fonction qui a conduit à l'erreur. La fonction "initializeModule" commence à : 0x0000000000000024. Le registre IP a rapporté que le mauvais code est à 0x10 du début de cette fonction, donc l'erreur se trouve ci-dessous :
    Disassemble faulty function - kernel oops

    0x0000000000000024 + 0x10 = 0x0000000000000034 = movl $0x0, 0x0

  10. Localiser la ligne de code erronée : enfin, jetons un coup d'oeil à cette ligne avec GDB :
    Faulty line detected - kernel oops

    GDB pointe vers la ligne /kernel-oops-mod.c :6 qui est à l'origine du problème.

Touches Magique SysRQ

Il peut arriver que les systèmes se figent (par exemple l'interface graphique qui ne répond plus). Dans ce cas, le réflexe adopté est de redémarrer la machine. Cela peut avoir de lourdes conséquences, en effet, les fichiers ne sont pas synchronisés avec le disque.

Les touches magique SysRq permettent d’exécuter des fonctions de bas niveau avec une combinaison de touches comme suit :

ALT + touche Imp écran (SysRq) + touche commande

Ces touches peuvent aussi renvoyer la liste des processus en cours d’exécution, l’état des CPU, la trace inverse qu'on peut récupérer par voix série. Cependant, cette fonctionnalité peut être désactivée, si c'est le cas il faut l'activer :

# echo 1 > /proc/sys/kernel/sysrq

Quelques touches à retenir :

  • ALT + touche Imp écran (SysRq) + chiffre : pour changer la verbosité (ALT+SysRq+9 est le maximum).
    Change log level using SysRq

    Remarque : changer le niveau de verbosité est la première chose à faire avant de déboger.

  • ALT + touche Imp écran (SysRq) + l : affiche la trace inverse de tous les CPU.
    Shows the status of all CPUs
  • ALT + touche Imp écran (SysRq) + m : dumper la mémoire virtuelle.
    Shows memory status - sysrq
  • ALT + touche Imp écran (SysRq) + p : retourne l'état des registres.
    Shows registers status - sysrq
  • ALT + touche Imp écran (SysRq) + c : génération d'un Kernel PANIC (très utile pour les tests).

Au secours des Modules

Les modules peuvent être difficiles à déboguer, une configuration est requise avant de pouvoir les déboguer avec GDB. Le problème provient du code relogeable du module, ce qui signifie que l'adresse du module est décidée au moment du chargement. Sous Linux, nous pouvons obtenir l'emplacement du module en utilisant l'interface sysfs. Les développeurs doivent lire l'emplacement de chaque section de :

/sys/module/nom_du_module/sections

  1. Code module : Les sources sont disponible sur https://github.com/jugurthab/Linux_kernel_debug/tree/master/debug-examples/Chap2-kernel-debug/debug-module
  2. Compilation du module :

    $ make

  3. Insérer le module dans le noyau :

    $ sudo insmod basic-module-debug.ko

  4. Récupérer le nombre majeur : il faut lire les messages du tampon $ dmesg (voir l'image ci-dessous).
    Retrive major number from kernel logs
  5. Créer une entrée dans /dev :
    Create a character device file entry in /dev
  6. Vérifiez le bon fonctionnement du module :
    • Interagir avec le module :

      # cat /dev/basictestdriver

    • Vérifier les messages du tampon noyau :
      Check correct working of the module
  7. Obtenir la localisation du module en mémoire :
    Get the location of module in memory
  8. Établir une communication série avec la cible.
  9. Configurer KGDB : il faut activer KGDB/KDB sur la cible (la console à droite de l'image ci-dessous) et GDB sur une autre machine (la console à gauche de l'image ci-dessous).
    GDB session launched from client to debug module on target
  10. Déboguer le module : A présent, nous pouvons déboguer comme on a l'habitude de faire avec GDB. on peut par exemple :
    • Obtenir l'adresse des fonctions du module :
      Displaying addresses of function's module using GDB
    • Positionner des points d'arrêt :
      Setting breakpoints on a module using GDB
    • Reprendre l'exécution sur la cible : il suffit de taper "c" dans GDB :
      Giving back control to target using GDB

Débogage JTAG

C'est la méthode de débogage la plus puissante (mais la plus complexe) car, à la différence des autres solutions (qui sont purement logiciel), c'est un débogage au niveau matériel (JTAG, SWD et autres).
OpenOCD est une solution Open Source qui nous permet de mener un débogage matériel, un article entier lui a été réservé sur : http://www.linuxembedded.fr/2018/08/openocd-from-scratch/.

L’Anti-débogage existe aussi!

Ptrace

strace, ltrace ou même GDB utilise ptrace en arrière plan, sans ce dernier; ces outils deviennent presque inutile.

Utiliser ptrace pour l'anti débogage

Une règle importante du débogage dit : “il ne peut y avoir qu'un seul débogueur attaché à un programme”, une erreur sera générée si nous essayons d'en connecter plus. Voici un exemple :

  1. (ptrace-anti-debug.c) : Voici un programme qui utilise ptrace pour signaler qu'il sera tracé par son parent (Le code est aussi disponible sur ce lien : https://github.com/jugurthab/Linux_kernel_debug/tree/master/debug-examples/Chap6-kernel-security/userspace/ptrace-antidebug). Ainsi, nous ne pourrons pas déboguer ce programme.
    int main(){
    
        if(ptrace(PTRACE_TRACEME , 0) < 0 ){
    	printf("You cannot debug me!\n");
    	exit(EXIT_FAILURE);
        }
    
    
        getchar();
        printf("No debugger detected\n");
        return EXIT_SUCCESS;
    }
    
  2. Débogage avec GDB : Tentons d'attacher GDB et créer une session de débogage comme suit :
    • Attacher GDB au programme : Cela veut dire qu'on doit lancer le programme puis attacher GDB.
      Shows registers status
    • Démarrer le programme avec GDB : (voir l'image ci-dessous)
      GDB not able to debug a program

GDB n'est pas capable de se connecter, car le processus distant est déjà en cours de débogage.

Contourner l’anti-débogage "ptrace"

Le débogage devient très difficile quand l'Anti-debug est employé. Cependant, dans ce cas; nous pouvons facilement contourner cette protection en sautant directement à la fonction getchar().

  1. Placez un point d'arrêt : afin de sauter à un emplacement donné, le programme doit être en cours d'exécution ce qui signifie que nous avons besoin d'au moins un point d'arrêt. Placez-le au début de la fonction ptrace et exécutez le programme :
    circumventing ptrace anti-debug by jumping to another location
  2. il suffit d'afficher l'emplacement de getchar() en mémoire et d'y sauter en utilisant GDB:
    Display getchar memory address location using gdb
  3. Poursuivre l'exécution du programme : Au moment où nous avons fait le saut, un curseur clignotant attendait une entrée de caractère (c'est le comportement de getchar()), nous pouvons lui donner un caractère comme montré ci dessous :
    ptrace anti-debug defeated

LD_PRELOAD

est une variable d’environnement qui permet de lister les bibliothèques qu'on doit charger (ainsi que nos propres bibliothèques) pour le bon fonctionnement d'un exécutable. Lors de l'édition des liens, nous pouvons remplacer une bibliothèque par notre propre bibliothèque. Cette variable est à utiliser avec précaution pour des raisons que nous évoquons dans la suite.

Hacker avec LD_PRELOAD

Nous pouvons créer notre bibliothèque qui va ré-implémenter une fonctionnalité déjà fournie par la bibliothèque standard C mais dont le comportement est différent.

  • Création d'un programme d'authentification
    1. programme de test (secure-program.c) : les sources sont disponible sur github.
    2. Compilation et tests : (voir l'image ci-dessous).
      Compile and test authentification program

      Notre programme fonctionne correctement et répond par "Hello SMILE admin" seulement quand le bon mot de passe est fourni.

  • Création d'une bibliothèque malicieuse
    1. library-code-strcmp.c : définit un autre comportement de strcmp :
      int strcmp ( const char * str1, const char * str2 ){
          printf("You have been Hacked with success\n");
          return 0;
      }
      
    2. Compilation de la bibliothèque

      $ gcc -fPIC --shared -o libhijack-strcmp.so library-code-strcmp.c

    3. Usage de LD_PRELOAD : nous pouvons à présent charger notre bibliothèque avec LD_PRELOAD comme suit :
      Loading fake library using LD_PRELOAD

      On peut noter que le programme accepte n'importe quel mot de passe.

Détecter l'usage de LD_PRELOAD

Il est possible de traquer l'usage de LD_PRELOAD grâce aux artefacts que l'on peut trouver sur le système.

  • Fichier Maps : le fichier maps retourne le chemin à partir duquel les bibliothèques sont chargées.
    Tracking LD_PRELOAD using maps file

    On constate rapidement que la bibliothèque libhijack-strcmp.so n'est pas chargée comme les autres (à partir de /lib/), ce qui est une forte indication de l'usage de LD_PRELOAD.

  • Fichier variable d’environnement : affiche toutes les variables d’environnement associées à un processus comme ceci :
    Tracking LD_PRELOAD using Environment variables

    LD_PRELOAD est présente, ce qui confirme son usage.

Modules en feu

Le noyau est la cible la plus intéressante, on peut l'attaquer par plusieurs moyens mais on va présenter une manière d'infecter un module avec du code malicieux.

Création des modules

Nous devons créer deux modules. Le premier est bon (ne contient pas de code malicieux) , mais le deuxième est dangereux et on va l'injecter dans le premier.

  • Module bon (kernel-module-safe.c) : un simple module qui affiche un message lors de son insertion ($ sudo insmod kernel-module-safe.ko) et lors de son arrêt ($ sudo rmmod kernel-module-safe.ko)
    static int __init mon_module_init(void)
    {
    	printk(KERN_DEBUG "Hello SMILE, teach us OPEN SOURCE !\n");
    	return 0;
    }
    
    static void __exit mon_module_cleanup(void)
    {
            printk(KERN_DEBUG "Thank you SMILE!\n");
    }
    
    module_init(mon_module_init);
    module_exit(mon_module_cleanup);
    
  • Module malicieux (kernel-module-to-inject.c) :
    static int fake_init(void) __attribute__((used));
    static int fake_init(void)
    {
        printk(KERN_DEBUG "Hacking is great!\n");
        return 0;
    }
    

    Remarque : __attribute__((used)) force gcc à compiler une fonction qui n'est pas utilisée (sinon gcc va optimiser le code et supprimer notre méchante fonction)

Fusionner les deux modules

ld permet de combiner des modules comme suit :

$ ld -r kernel-module-safe.ko kernel-module-to-inject.ko -o kernel-module-infected.ko

Combining modules using ld

Analyser le résultat de la fusion

La fusion génère un troisième module (kernel-module-infected.ko) qui contient les code des deux modules (kernel-module-safe.c et kernel-module-to-inject.c).

On peut l'analyser comme tous les fichiers ELF :

$ objdump -t kernel-module-infected.ko
kernel-module-infected.ko:     format de fichier elf32-i386

SYMBOL TABLE:
............
00000000 l     F .init.text	00000014 mon_module_init
00000000 l     F .exit.text	00000012 mon_module_cleanup
............
............
00000014 l     F .init.text	00000014 fak_module_init
............
............
00000000 g     F .exit.text	00000012 cleanup_module
00000000 g     F .init.text	00000014 init_module
00000000         *UND*	00000000 printk

Forcer init_module à exécuter fak_module_evil

Nous pouvons le faire manuellement (mais cela exige une bonne connaissance du format ELF), il existe un utilitaire qui permet de le faire "elfchger" (disponible sur : https://github.com/jugurthab/Linux_kernel_debug/tree/master/debug-examples/Chap6-kernel-security/kernel/module-tampering).

$ ./elfchger -s init_module -v 00000014 kernel-module-infected.ko

Cela produit le résultat suivant :

fake_init function will be called by module_init

La ligne 00000014 g F .init.text 00000014 init_module indique que init_module pointe vers fak_module_init.

Insérer le module infecté dans le noyau

Il est temps d'injecter le module dans le noyau comme suit:

Inserting malicious module into the kernel

Eureka! La fonction malicieuse s'est exécutée et le message "Hacking is great!" est affiché.

Conclusion

Dans cet article, nous avons parcouru les outils de débogage dans l'espace utilisateur et noyau. Nous avons bien appréhendé l'utilité et l'utilisation de chaque méthode. Nous avons présenté également quelques éléments de sécurité, des techniques malicieuses qui doivent vous permettre de prendre consience de certains dangers, et d'adapter vos logiciels en conséquence.

N'oublions pas que la qualité logicielle s'appuie sur un processus de développement reproductible, de la documentation, des tests, de la relecture de code, etc.

A bientôt pour de nouvelles aventures, nous irons plus loin avec les traceurs Linux.

    • le 05 décembre 2018 à 12:11

      Article intéressant et bien détaillé. Le domaine du débug "classique" est d'ailleurs assez connexe avec celui de l'analyse du comportement et des performances (cf. article de 2011 sur Ftrace).

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.