Comment fonctionne un débogueur?

Les exemples sont en langage C et spécifiques à l'architecture x86_64 (ne fonctionneront pas en 32 bits). N'étant pas codeur C, il y aura certainement plein de choses à redire sur mon code, n'hésitez pas à le faire.

Vous pouvez réagir par rapport à ce tutoriel sur le forum Programmation : 2 commentaires Donner une note à l'article (5).

Article lu   fois.

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Motivations

Un débogueur est un outil fabuleux : cette sensation de contrôle divin ! La possibilité de figer l'exécution d'un processus et d'inspecter les arcanes de sa mémoire.

C'était les deux phrases lyriques de cet article Image non disponible Nous verrons que le divin n'est qu'une machinerie bien huilée.

Le débogueur est un outil que j'utilise quotidiennement. Je trouve important d'en comprendre les mécanismes sous-jacents. Écrire un concurrent à GDB n'est certainement pas la meilleure façon d'utiliser son temps libre. En revanche, écrire un POC(1) de débogueur est certainement la manière la plus didactique d'apprendre ! Et c'est ce que je vous propose aujourd'hui : écrire un petit débogueur pas super pratique mais fonctionnel.

Concernant le fond, cet article ne traite que de Linux sous architecture x86_64. Il part du principe que vous avez de vagues notions sur ce qu'est :

II. Rapide rappel sur les appels système et les interruptions

Si vous savez déjà ce qu'est un appel système, vous pouvez sauter cette section.

Le processeur a plusieurs niveaux d'exécution :

  • Typiquement, le noyau Linux tourne dans un mode dit privilégié. Dans ce mode, il peut accéder à toute la mémoire, lire et écrire sur disque, …
  • Les autres processus, comme votre navigateur, tournent dans un mode non privilégié. Ils n'ont accès qu'à une certaine partie de la mémoire et ne peuvent pas écrire directement sur disque.

Tant qu'un processus se contente de faire des calculs et de lire et écrire en mémoire, il est autonome. Mais dès qu'il décide d'agir sur son environnement (side effect), comme écrire sur le disque, il doit utiliser un appel système (syscall).

Le processus effectuant un appel système donne la main au noyau et bloque jusqu'à ce que l'appel système soit effectué. Un appel système est en général une opération coûteuse en temps.

GNU/Linux implémente le standard POSIX qui définit un ensemble d'APIs dont font partie les appels systèmes suivants :

En voici un extrait :

%rax

syscall

%rdi

%rsi

%rdx

0

read

unsigned int file_descriptor

char * buffer

size_t length

1

write

unsigned int file_descriptor

char * buffer

size_t length

57

fork

     

59

execve

const char *filename

const char *const argv[]

const char *const envp[]

60

exit

int error_code

   

62

kill

pid_t pid

int signal

 

101

ptrace

long request

long pid

unsigned long data

Allez voir la liste complète des appels systèmes de Linux.

Chaque appel système a un identifiant qui est placé dans le registre RAX et peut avoir jusqu'à 6 paramètres passés par convention dans les registres RDI, RSI, RDX, RCX, R8, R9.

read et write permettent de lire et d'écrire dans un fichier. Nous aborderons les autres un peu plus tard.

L'exemple suivant est un typique « hello world » qui illustre un appel à l'appel système write :

 
Sélectionnez
mov    $0x1,%rax
mov    $0x1,%rdi
mov    $0x4000fe,%rsi
mov    $0xd,%rdx
syscall

Traduit en français, cela donne : « Appel système sys_write (RAX=1) pour écrire dans le descripteur de fichier 1 (RDI=1), alias la sortie standard, la chaîne de caractère à l'adresse 0x4000fe (RSI=0x4000fe) de longueur 13 (RDX=0xd). Notez l'instruction syscall qui est une vraie instruction assembleur x86_64.

Il existe un utilitaire très pratique, strace, qui permet de tracer tous les appels système effectués par un processus. Par exemple pour tracer tous les appels à write effectués par la commande echo :

 
Sélectionnez
$strace -o '| grep write' echo "Hello"
write(1, "Hello\n", 6)                  = 6
  • On écrit dans le descripteur de fichier 1 (sortie standard) ;
  • Une chaine de caractère qui contient « Hello\n » ;
  • On écrit 6 caractères ;
  • write a bien écrit 6 caractères.

Un processus prépare les paramètres de l'appel système dans les registres du CPU et fait exécuter l'instruction syscall au CPU. Et là, magiquement ; l'exécution du processus s'arrête (bloque) et ne reprend que lorsque l'appel système a été réalisé.

La tuyauterie permettant cela s'appelle une interruption. Une interruption permet au CPU d'appeler une fonction du noyau. Donc quand le CPU exécute l'instruction syscall, il redonne la main au noyau qui se débrouille pour mettre en pause le processus appelant, exécuter la commande syscall demandée avec ses paramètres, et relancer le processus.

III. Salut fiston, c'est papa !

Si vous savez déjà ce qu'est un fork, vous pouvez sauter cette section.

On peut imaginer qu'un débogueur a une certaine emprise sur le processus débogué. Sous Linux, ce genre d'abus de position s'exprime par une relation père / fils.

Si vous avez une console à proximité et que vous tapez pstree vous remarquerez que les processus sont organisés hiérarchiquement. La racine commune à tous est systemd (ou init sur des systèmes plus anciens) et votre navigateur est une feuille de l'arbre.

Pour créer un processus fils, un futur père utilise l'appel système fork. Voici un code typique :

 
Sélectionnez
int main()
{   pid_t child = fork();
    if(child == 0) {
        printf("I am the child\n")
    }
    else {
        printf("I am the father of %d\n", child);
    }
    return 0;
}

fork procède à une copie presque intégrale du processus appelant (mémoire, registres CPU, …). L'appelant devient le processus père du clone qui est donc son fils.
Quand fork rend la main, les deux processus continuent leur exécution juste après l'appel à fork, sur le if.

Je disais copie presque intégrale, car dans le processus père, fork renvoie le PID du fils, et dans le processus fils il renvoie 0. Le fils affichera donc « I am the child » et le père « I am the father of 1234 ».

En extrapolant, on peut voir le fork comme une mitose cellulaire. Avant la mitose on a une cellule et après la mitose on a deux cellules qui partagent exactement le même ADN (le code).

IV. Trace-moi si tu peux

Linux fournit un appel système appelé ptrace qui permet d'implémenter un débogueur.

Dorénavant nous parlerons de tracer (le débogueur) et de tracee (le processus à débugger), c'est le vocabulaire employé dans la page de man de ptrace.

Le tracee fait appel à la commande TRACEME pour signaler qu'il souhaite être tracé par son père. Dans ce mode, le processus peut être dans deux états possibles : soit il est actif, dans l'état RUNNING, soit il est inactif, dans l'état STOPPED.

En mode TRACEME, le tracee passe à l'état STOPPED quand il reçoit n'importe quel signal.

Le tracee passe à l'état RUNNING quand le père lance la commande ptrace CONT (continue).

Le code suivant illustre ce principe :

 
Sélectionnez
int main() {
    pid_t child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        child = getpid();
        printf("I am about to get STOPPED\n")
        kill(child, SIGUSR1);
        printf("I am RUNNING again\n");
    }
    else {
        printf("Waiting for the child to stop\n")
        waitpid(child, NULL, 0);
        printf("The tracee is stopped\n")
        ptrace(PTRACE_CONT, child, NULL, NULL);
        // wait for the child to exit
        waitpid(child, NULL, 0);
    }
    return 0;
}

Le tracee récupère son pid avec la fonction getpid et s'envoie un signal SIGUSR1 via kill. Notons que kill est un appel système qui permet d'envoyer des signaux à un processus. kill prend un PID comme premier paramètre. Le second paramètre est le signal à envoyer. On ne peut pas ajouter de données supplémentaires à un signal. À la réception de ce signal, le tracee passe à l'état STOPPED, car il est en mode TRACEME.

Le tracer fait un premier waitpid. waitpid permet d'attendre un changement d'état de son processus fils. Ici, il attend que son fils passe à l'état STOPPED. Notons que wait est aussi un appel système.

Une fois que wait redonne la main, le tracer utilise PTRACE_CONT pour que le tracee repasse à l'état RUNNING et continue de s'exécuter.

Le père fait un ultime wait. C'est un peu hors propos mais cela permet au tracee de terminer proprement son exécution, sans rester à l'état zombie.

Nous venons d'illustrer le mécanisme de signaux et de commandes ptrace qui permettent de changer l'état (RUNNING / STOPPED) du tracee.

V. Traçons

Lorsque le tracee est à l'état STOPPED, ptrace fournit au tracer des commandes qui permettent de l'inspecter et de l'exécuter pas à pas.

  • PEEKUSER permet d'inspecter les registres du CPU. Les valeurs de registre ne sont pas lues en live depuis le CPU. En fait, quand le noyau stoppe le tracée il enregistre le contexte du processus, dont les registres, afin que ce dernier puisse reprendre son exécution plus tard, comme si de rien n'était. Les valeurs renvoyées par ptrace sont issues de cet enregistrement.
  • PEEKTEXT permet d'examiner la mémoire.
  • SINGLESTEP exécute l'instruction pointée par le registre RIP et repasse le tracee à l'état STOPPED.

Le fonctionnement de ces deux commandes est illustré par le code suivant.
Il s'agit de compter le nombre d'embranchements par lesquels est passé le tracee.

 
Sélectionnez
void fizzbuzz() {
    for(int i = 0; i < 100; i++) {
        int fizz = i % 3 == 0;
        if(fizz) printf("Fizz");
        int buzz = i % 5 == 0;
        if(buzz) printf("Buzz");
        if(!(fizz||buzz)) printf("%d", i);
        printf(", ");
    }
}

int waitchild(pid_t pid) {
    int status;
    waitpid(pid, &status, 0);
    if(WIFSTOPPED(status)) {
        return 0;
    }
    else if (WIFEXITED(status)) {
        return 1;
    }
    else {
        printf("%d raised an unexpected status %d", pid, status);
        return 1;
    }
}

void trace(pid_t child) {
  unsigned long instruction, opcode1, opcode2, ip;
  unsigned long jmps = 0;
  do {
    ip = ptrace(PTRACE_PEEKUSER, child, 8 * RIP, NULL);
    instruction = ptrace(PTRACE_PEEKTEXT, child, ip, NULL);
    opcode1 = instruction & 0x00000000000000FF;
    opcode2 = (instruction & 0x000000000000FF00) >> 8;
    if((opcode1 >= 0x70 && opcode1 <= 0x7F) ||
       (opcode1 == 0x0F && (opcode2 >= 0x83 && opcode2 <= 0x87))) {
         jmps = jmps + 1;
    }
    ptrace(PTRACE_SINGLESTEP, child, NULL, NULL);
  } while(waitchild(child) < 1);
  printf("n=> There are %lu jumps\n", jmps);
}

int main() {
    long instruction;
    pid_t child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        child = getpid();
        kill(child, SIGUSR1);
        fizzbuzz();
    }
    else {
        // wait for the child to stop
        waitchild(child);
        trace(child);
    }
    return 0;
}

Le fichier compilable est ici.

À l'exécution on a :

 
Sélectionnez
FizzBuzz, 1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz, 16, 17, Fizz, 19, Buzz, Fizz, 22, 23, Fizz, Buzz, 26, Fizz, 28, 29, FizzBuzz, 31, 32, Fizz, 34, Buzz, Fizz, 37, 38, Fizz, Buzz, 41, Fizz, 43, 44, FizzBuzz, 46, 47, Fizz, 49, Buzz, Fizz, 52, 53, Fizz, Buzz, 56, Fizz, 58, 59, FizzBuzz, 61, 62, Fizz, 64, Buzz, Fizz, 67, 68, Fizz, Buzz, 71, Fizz, 73, 74, FizzBuzz, 76, 77, Fizz, 79, Buzz, Fizz, 82, 83, Fizz, Buzz, 86, Fizz, 88, 89, FizzBuzz, 91, 92, Fizz, 94, Buzz, Fizz, 97, 98, Fizz,
=> There are 23037 jumps

Plusieurs exécutions du programme retournent toujours le même nombre, ce qui est assez rassurant.

Détaillons le programme :

La fonction main reprend le même schéma que les exemples précédents :

  1. fork du processus ;
  2. le tracee se met en mode TRACEME et passe à l'état STOPPED en s'envoyant n'importe quel signal, puis exécutera fizzbuzz quand il passera à l'état RUNNING ;
  3. Le tracer attend que le tracee passe à l'état STOPPED puis exécute trace

fizzbuzz est une simple fonction qui implémente le célèbre FizzBuzz. C'est cette fonction qui sera auditée par le tracer.

waitchild encapsule un appel à waitpid. Si le tracee passe à l'état STOPPED, elle renvoie 0. Et si le tracee passe à l'état TERMINATED, elle renvoie 1.

trace est une boucle dont la condition d'arrêt est le tracee qui passe à l'état TERMINATED. Dans cette boucle, le tracer :

  1. Utilise la commande PEEKUSER afin de récupérer l'adresse de l'instruction courante stockée dans le registre RIP. PEEKUSER permet d'inspecter les registres du CPU ;
  2. Lit en mémoire, à l'adresse stockée dans RIP, l'instruction sur laquelle le tracee est arrêté, via la commande PEEKTEXT ;
  3. PEEKTEXT écrit les octets en mémoire dans un long de 8 octets. Notons que l'archi x86 est en little endian, cela signifie que l'octet à l'adresse pointée par RIP est récupéré dans l'octet de poids de plus faible du long. D'où les calculs binaires pour récupérer les deux premiers octets pointés par RIP ;
  4. On vérifie si l'instruction correspond à une instruction de saut conditionnel (instructions Jcc), auquel cas, on incrémente la variable jmps ;
  5. On exécute la commande SINGLESTEP qui exécute une seule instruction du tracee et lui envoie un signal SIGTRAP pour qu'il passe immédiatement à l'état STOPPED ;
  6. Après l'exécution de la boucle, on affiche le résultat.

23 000 sauts conditionnels est assez hallucinant, cela en fait 2300 par itération. fizzbuzz est assez simple, mais je pense que printf doit être assez compliqué et alourdir l'addition.

VI. Tracer n'importe quoi

Jusqu'ici, le tracee était un processus bien connu, que nous avions codé nous-mêmes. Ce que nous aimerions, c'est tracer n'importe quel programme.

L'appel système excecve permet de remplacer l'image du processus appelant par une autre. À l'issue de l'appel à execve, le processus n'a plus rien à voir avec le code d'origine, il est complètement remplacé par le programme passé à execve. D'ailleurs, il n'y a aucun moyen de récupérer le résultat d'execve.

execve a trois paramètres :

  • le chemin du programme ;
  • les arguments à passer au programme ;
  • les variables d'environnement à passer au programme.

Une subtilité d'execve intéressante dans notre cas, est qu'un signal SIGTRAP est automatiquement envoyé après l'exécution d'execve si le processus est en mode TRACEME. Ce qui signifie que l'on peut se passer d'envoyer manuellement un signal au tracee. Lorsque waitpid donne la main au tracer, l'image du tracee a été remplacée par celle du programme passé en paramètre d'execve.

 
Sélectionnez
int waitchild(pid_t pid) {
    int status;
    waitpid(pid, &status, 0);
    if(WIFSTOPPED(status)) {
        return 0;
    }
    else if (WIFEXITED(status)) {
        return 1;
    }
    else {
        printf("%d raised an unexpected status %d", pid, status);
        return 1;
    }
}

void trace(pid_t child) {
  unsigned long instruction, opcode1, opcode2, ip;
  unsigned long jmps = 0;
  do {
    ip = ptrace(PTRACE_PEEKUSER, child, 8 * RIP, NULL);
    instruction = ptrace(PTRACE_PEEKTEXT, child, ip, NULL);
    opcode1 = instruction & 0x00000000000000FF;
    opcode2 = (instruction & 0x000000000000FF00) >> 8;
    if((opcode1 >= 0x70 && opcode1 <= 0x7F) ||
       (opcode1 == 0x0F && (opcode2 >= 0x83 && opcode2 <= 0x87))) {
         jmps = jmps + 1;
    }
    ptrace(PTRACE_SINGLESTEP, child, NULL, NULL);
  } while(waitchild(child) < 1);
  printf("n=> There are %lu jumps\n", jmps);
}

int main(int argc, char ** argv) {
    long instruction;
    pid_t child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execve(argv[1], argv + 1, NULL);
    }
    else {
        // wait for the child to stop
        waitchild(child);
        trace(child);
    }
    return 0;
}

Le fichier compilable est ici.

waitpid et trace n'ont pas été modifiés, fizzbuzz a été supprimé.

main a subi quelques modifications :

  1. Le tracee s'attend à ce que soient passés le chemin du programme à tracer dans argv[1], les arguments du programme à tracer dans argv[2], etc. ( argv[3] ) ;
  2. Le tracee ne s'envoie plus de signal lui-même pour passer à l'état STOPPED ;
  3. Le tracee appelle execve qui envoie un signal SIGTRAP implicitement.

Le code du tracer n'a absolument pas changé.

À l'exécution, cela donne :

 
Sélectionnez
./ptrace_ex4 /usr/bin/ls /      
bin   dev  home  lib64       media  opt   root  sbin  sys  usr
boot  etc  lib   lost+found  mnt    proc  run   srv   tmp  var

=> There are 44633 jumps

VII. Points d'arrêt

Jusqu'ici, le tracer se contente de faire quelques calculs préprogrammés. L'une des fonctionnalités attendues d'un débogueur est de pouvoir poser des points d'arrêt à une adresse particulière.

La théorie est simple : il faut que le tracee passe à l'état STOPPED au moment où il exécute l'instruction à l'adresse choisie.

La pratique ressemble à un gros hack. Le tracer modifie le code du tracee pour qu'à l'adresse du point d'arrêt le tracee reçoive un signal qui le mette à l'état STOPPED.

Rappelez-vous, la plus petite instruction assembleur peut faire un seul octet. Il faut donc que le code qui déclenche le signal tienne sur un octet afin de ne pas modifier plusieurs instructions.

int 3 a pour opcode 0xCC et lève une interruption spécialement câblée dans le noyau pour envoyer un signal SIGTRAP à qui la lève.

Pour écrire dans la mémoire du tracee, ptrace fournit la commande POKETEXT. Nous verrons aussi la commande POKEUSER qui permet d'écrire dans un registre du CPU.

Voici à quoi ressemble une implémentation de point d'arrêt.

 
Sélectionnez
int waitchild(pid_t pid) {
    int status;
    waitpid(pid, &status, 0);
    if(WIFSTOPPED(status)) {
        return 0;
    }
    else if (WIFEXITED(status)) {
        return 1;
    }
    else {
        printf("%d raised an unexpected status %d", pid, status);
        return 1;
    }
}

unsigned long to_ulong(char * s) {
  return strtol(s, NULL, 16);
}

unsigned long readMemoryAt(pid_t tracee, unsigned long address) {
  return ptrace(PTRACE_PEEKTEXT, tracee, address, NULL);
}

void writeMemoryAt(pid_t tracee, unsigned long address, unsigned long instruction) {
  ptrace(PTRACE_POKETEXT, tracee, address, instruction);
}

unsigned long readRegister(pid_t tracee, int reg) {
  return ptrace(PTRACE_PEEKUSER, tracee, 8 * reg, NULL);
}

void writeRegister(pid_t tracee, int reg, unsigned long value) {
  ptrace(PTRACE_POKEUSER, tracee, 8 * reg, value);
}

unsigned long setbp(pid_t tracee, unsigned long address) {
    unsigned long original = readMemoryAt(tracee, address);
    unsigned long int3 = (original & 0xFFFFFFFFFFFFFF00) | 0x00000000000000CC;
    writeMemoryAt(tracee, address, int3);
    printf("Set breakpoint at %lx, new instruction is %lx instead of %lx\n",
          address, readMemoryAt(tracee, address), original);
    return original;
}

void removebp(pid_t tracee, unsigned long address, unsigned long original) {
  unsigned long previously = readMemoryAt(tracee, address);
  writeMemoryAt(tracee, address, original);
  printf("Unset breakpoint at %lx, new instruction is %lx, instead of %lx\n",
       address, readMemoryAt(tracee, address), previously);
}

void showregisters(pid_t tracee) {
  printf("RIP = %lx\n",
        readRegister(tracee, RIP));
}

void setIp(pid_t tracee, unsigned long address) {
  writeRegister(tracee, RIP, address);
}

void presskey() {
  getchar();
}

int main(int argc, char ** argv) {
    setbuf(stdout, NULL);
    unsigned long bpAddress = to_ulong(argv[1]);
    pid_t child = fork();
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execve(argv[2], argv + 2, NULL);
    }
    else {
        // wait for the child to stop
        waitchild(child);

        unsigned long originalInstruction = setbp(child, bpAddress);
        ptrace(PTRACE_CONT, child, NULL, NULL);

        while(waitchild(child) < 1) {
          printf("Breakpoint hit !\n");
          showregisters(child);
          presskey();

          removebp(child, bpAddress, originalInstruction);
          setIp(child, bpAddress);

          ptrace(PTRACE_SINGLESTEP, child, NULL, NULL);
          waitchild(child);

          setbp(child, bpAddress);

          ptrace(PTRACE_CONT, child, NULL, NULL);
        }
    }
    return 0;
}

Le fichier compilable est ici

Waouh ! Ca commence à être gros ! Décortiquons tout ça.

  • main suit le même modèle que d'habitude : fork, et TRACEME, execve pour le tracee. En revanche le code du tracer a pas mal changé :
  • Comme d'habitude, waitchild attend que le tracee passe à l'état STOPPED.
  • setbp pose le point d'arrêt à l'adresse demandée. Concrètement, il s'agit d'utiliser la commande POKETEXT pour écrire l'opcode 0xCC (int 3) à l'adresse mémoire du point d'arrêt. setbp retourne l'instruction originale (très important !).
  • La commande CONT permet de continuer l'exécution du tracee jusqu'à ce qu'il exécute cette fameuse int 3 et passe à l'état STOPPED
  • La boucle while termine quand le tracee passe à l'état TERMINATED
  • Si on est rentré dans la boucle, cela signifie que l'instruction int 3 correspondant au point d'arrêt a été exécutée.
  • On affiche quelques registres et on bloque tant que l'utilisateur n'a pas tapé Ctrl+D au clavier pour lui laisser le temps de lire.
  • À ce moment le registre RIP vaut bpAddress + 1, car on a exécuté int 3 qui fait un octet. Si on laisse filer le tracee il n'exécutera jamais l'instruction qui était prévue à l'adresse bpAddress. De plus, bpAddress + 1 contient certainement une instruction inintelligible.
  • Donc le tracer remet l'instruction originale à l'adresse bpAddress via POKETEXT.
  • Puis le tracer rembobine le fil d'exécution du tracee en mettant le registre RIP à bpAddress pour qu'il pointe sur l'instruction prévue. Pour cela, on utilise la commande POKEUSER pour affecter une valeur à un registre du CPU.
  • Avec la commande SINGLESTEP, le tracer commande au tracee d'exécuter l'instruction à bpAddress, la fameuse instruction qui avait été zappée au profit de int 3.
  • Enfin, le tracer repose le point d'arrêt et laisse filer le tracee jusqu'à ce que ce dernier passe à TERMINATED ou à STOPPED (s'il réexécute int 3 à l'adresse bpAddress)

Testons ce nouveau tracer sur le programme fizzbuzz suivant :

 
Sélectionnez
void fizzbuzz() {
    for(int i = 0; i < 100; i++) {
        int fizz = i % 3 == 0;
        if(fizz) printf("Fizz");
        int buzz = i % 5 == 0;
        if(buzz) printf("Buzz");
        if(!(fizz||buzz)) printf("%d", i);
        printf(", ");
        fflush(stdout);
    }
}

int main() {
    fizzbuzz();
}

On peut décompiler le programme et chercher la fonction fizzbuzz :

 
Sélectionnez
$ objdump -d fizzbuzz | grep -A200 '<fizzbuzz>:' | less

0000000000400576 <fizzbuzz>:
  400576:   55                      push   %rbp
  400577:   48 89 e5                mov    %rsp,%rbp
  40057a:   48 83 ec 10             sub    $0x10,%rsp
  40057e:   c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)
  400585:   e9 c3 00 00 00          jmpq   40064d <fizzbuzz+0xd7>
  40058a:   ...
  ... BLA BLA FIZZ BUZZ BLA BLA ...
  400649:   83 45 fc 01             addl   $0x1,-0x4(%rbp)
  40064d:   83 7d fc 63             cmpl   $0x63,-0x4(%rbp)
  400651:   0f 8e 33 ff ff ff       jle    40058a <fizzbuzz+0x14>
  400657:   90                      nop
  400658:   c9                      leaveq
  400659:   c3                      retq

La fonction fizzbuzz est mappée en mémoire à l'adresse 0x400576.

On reconnait notre boucle :

  1. en 0x40057e la variable i est initialisée à 0 ;
  2. en 0x400585 on saute en 0x40064d ;
  3. en 0x40064d on compare i à 99 ;
  4. en 0x400651 si i <= 99 on entre dans la boucle en 0x40058a, sinon la fonction se termine ;
  5. en 0x400649 qui est la dernière instruction de la boucle, i est incrémenté et retour en 4.

Lançons fizzbuzz avec un point d'arrêt sur l'adresse 0x400651 :

 
Sélectionnez
./ptrace_ex5 400651 ./fizzbuzz
Set breakpoint at 400651, new instruction is c990ffffff338ecc instead of c990ffffff338e0f
Breakpoint hit !
RIP = 400652

Unset breakpoint at 400651, new instruction is c990ffffff338e0f, instead of c990ffffff338ecc
Set breakpoint at 400651, new instruction is c990ffffff338ecc instead of c990ffffff338e0f
FizzBuzz, Breakpoint hit ! // On a passé la première itération
RIP = 400652

Unset breakpoint at 400651, new instruction is c990ffffff338e0f, instead of c990ffffff338ecc
Set breakpoint at 400651, new instruction is c990ffffff338ecc instead of c990ffffff338e0f
1, Breakpoint hit ! // 2ème itération
RIP = 400652

...

Après avoir pressé 100 fois la touche entrée, le tracer et le tracee terminent leur exécution sans problème. Ouf !

Cette implémentation des points d'arrêt est assez extraordinaire je trouve. Elle a l'avantage de ne pas ralentir le tracee en dehors des moments où il est à l'état STOPPED.

Il ne devrait pas être compliqué d'implémenter des points d'arrêt conditionnels. Je vous laisse faire ça chez vous tranquillement.

VIII. Points d'arrêt sans modifier le tracee

Il est possible d'implémenter les points d'arrêt sans devoir modifier le code du tracee.

 
Sélectionnez
int waitchild(pid_t pid) {
    int status;
    waitpid(pid, &status, 0);
    if(WIFSTOPPED(status)) {
        return 0;
    }
    else if (WIFEXITED(status)) {
        return 1;
    }
    else {
        printf("%d raised an unexpected status %d", pid, status);
        return 1;
    }
}

unsigned long to_ulong(char * s) {
  return strtol(s, NULL, 16);
}

unsigned long readRegister(pid_t tracee, int reg) {
  return ptrace(PTRACE_PEEKUSER, tracee, 8 * reg, NULL);
}

void showregisters(pid_t tracee) {
  printf("RIP = %lx\n",
        readRegister(tracee, RIP));
}

void presskey() {
  getchar();
}

int main(int argc, char ** argv) {
    unsigned long bpAddress = to_ulong(argv[1]);
    pid_t child = fork();
    unsigned long rip;
    if(child == 0) {
        ptrace(PTRACE_TRACEME, 0, NULL, NULL);
        execve(argv[2], argv + 2, NULL);
    }
    else {
        // wait for the child to stop
        waitchild(child);
        do {
          rip = readRegister(child, RIP);
          if(rip == bpAddress) {
            showregisters(child);
            presskey();
          }
          ptrace(PTRACE_SINGLESTEP, child, NULL, NULL);
        } while(waitchild(child) < 1);
    }
    return 0;
}

Le fichier compilable est ici.

Seul le code du tracer a changé. L'idée est de dérouler le tracee uniquement en pas à pas et de s'arrêter quand RIP vaut l'adresse du point d'arrêt.

 
Sélectionnez
./ptrace_ex6 400651 ./fizzbuzz
 RIP = 400651
 FizzBuzz, RIP = 400651
 1, RIP = 400651
 2, RIP = 400651

 ...

C'est extrêmement simple comparé à l'autre implémentation mais cela ralentit beaucoup trop le tracee. En effet, pour chaque instruction exécutée il faut faire deux appels système :

  1. ptrace SINGLESTEP qui fait passer le tracee de l'état STOPPED à l'état RUNNING puis à l'état STOPPED ;
  2. wait pour attendre que le tracee soit à l'état STOPPED

Sachant, comme nous l'avons vu, qu'un appel système passe par une interruption pour redonner la main au noyau, cette méthode ne fonctionne en pratique que sur des petits programmes comme fizzbuzz.

IX. Conclusion

C'était une bonne aventure ! Avant de m'y intéresser, un débogueur était pour moi un outil magique aux mécanismes impalpables.

Je connais désormais les rouages d'un débogueur sous Linux. Je pense que pour OSX, on doit avoir quelque chose de très similaire.

Écrire cet article me permet de mieux comprendre la documentation de GDB.

Aussi, j'ai une bien meilleure cartographie des interactions entre un processus et le noyau. Je comprends beaucoup mieux pourquoi on dit qu'un processus bloque quand on fait des entrées/sorties, et j'en comprends le mécanisme.

X. Aller plus loin

Il y a deux commandes ptrace que je n'ai pas présentées :

  • ATTACH permet de déboguer un processus existant, donc sans utiliser le mécanisme de fork ;
  • SYSCALL arrête le tracee à chaque appel système. Cela permet d'implémenter la commande strace.

Il existe aussi une troisième façon de poser des points d'arrêt : les hard breakpoints. Ce mécanisme est implémenté directement au niveau du CPU via des registres dédiés.

XI. Références

Cet article n'est pas tout à fait original. Ces quelques sources m'ont accompagnées. Si le sujet vous a intéressé, je vous en conseille vivement la lecture.

XII. Remerciements Developpez.com

Nous remercions Arolla qui nous a aimablement autorisé à publier ce tutoriel sur Developpez.com dont l'article original est disponible ici.

Merci également à Winjerome pour avoir mis le tutoriel au gabarit et à Jlliagre pour la relecture orthographique.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   


POC : Proof of concept, démonstrateur

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2016 Arolla. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.