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 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 :
- l'architecture x86 ;
- le langage assembleur x86 ;
- le système Linux ;
- un processus ;
- un signal Unix ;
- le langage C.
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 |
|
|
size_t length |
1 |
write |
|
|
size_t length |
57 |
fork |
|||
59 |
execve |
|
|
|
60 |
exit |
|
||
62 |
kill |
pid_t pid |
|
|
101 |
ptrace |
|
|
|
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 :
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 :
$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 :
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 :
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.
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 :
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 :
- fork du processus ;
- 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 ;
- 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 :
- 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 ;
- Lit en mémoire, à l'adresse stockée dans RIP, l'instruction sur laquelle le tracee est arrêté, via la commande PEEKTEXT ;
- 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 ;
- On vérifie si l'instruction correspond à une instruction de saut conditionnel (instructions Jcc), auquel cas, on incrémente la variable jmps ;
- 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 ;
- 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.
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 :
- 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] ) ;
- Le tracee ne s'envoie plus de signal lui-même pour passer à l'état STOPPED ;
- Le tracee appelle execve qui envoie un signal SIGTRAP implicitement.
Le code du tracer n'a absolument pas changé.
À l'exécution, cela donne :
./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.
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 :
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 :
$
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 :
- en 0x40057e la variable i est initialisée à 0 ;
- en 0x400585 on saute en 0x40064d ;
- en 0x40064d on compare i à 99 ;
- en 0x400651 si i <= 99 on entre dans la boucle en 0x40058a, sinon la fonction se termine ;
- 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 :
./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.
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.
./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 :
- ptrace SINGLESTEP qui fait passer le tracee de l'état STOPPED à l'état RUNNING puis à l'état STOPPED ;
- 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.
- man 2 ptrace
- Playing with ptrace Part I , Part II
- Why ptrace is awful ?
- How debuggers work part1, part2, part3. En plus l'auteur a mis des références en bas de ses articles, ce que j'apprécie beaucoup
- Explication sur l'implémentation des hardware breakpoints
- Un autre article sur le fonctionnement d'un débogueur
- Implémentation d'un point d'arrêt