Aventure avec ptrace (2)

On Habré a déjà écrit sur l'interception d'appels système au moyen de ptrace ; Alexa a écrit sur ce post beaucoup plus détaillé, que j'ai décidé de traduire.


Par où commencer


La communication entre le programme débogué et le débogueur se fait à l'aide de signaux. Cela complique grandement les choses déjà difficiles; pour le plaisir, vous pouvez lire la section BUGS de man ptrace .

Il existe au moins deux façons différentes de démarrer le débogage:

  1. ptrace(PTRACE_TRACEME, 0, NULL, NULL) fera du parent du processus actuel un débogueur pour lui. Aucune assistance n'est requise du parent; man conseille doucement: "Un processus ne devrait probablement pas faire cette demande si son parent ne s'attend pas à la retrouver." (Ailleurs dans le Mans, avez-vous vu la phrase «ne devrait probablement pas» ?) Si le processus actuel avait déjà un débogueur, alors l'appel échouera.
  2. ptrace(PTRACE_ATTACH, pid, NULL, NULL) fera du processus actuel un débogueur pour pid . Si pid avait déjà un débogueur, l'appel échouera. SIGSTOP envoyé au processus débogué et il ne continuera pas à fonctionner jusqu'à ce que le débogueur le dégivre.

Ces deux méthodes sont complètement indépendantes; Vous pouvez utiliser l'un ou l'autre, mais il est inutile de les combiner. Il est important de noter que PTRACE_ATTACH n'est pas instantané: après l' ptrace(PTRACE_ATTACH) de ptrace(PTRACE_ATTACH) , waitpid(2) est généralement PTRACE_ATTACH pour attendre que PTRACE_ATTACH "fonctionne".

Vous pouvez démarrer le processus enfant sous débogage à l'aide de PTRACE_TRACEME comme suit:

 static void tracee(int argc, char **argv) { if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0) die("child: ptrace(traceme) failed: %m"); /*   ,   . */ if (raise(SIGSTOP)) die("child: raise(SIGSTOP) failed: %m"); /*  . */ execvp(argv[0], argv); /*     . */ die("tracee start failed: %m"); } static void tracer(pid_t pid) { int status = 0; /* ,       . */ if (waitpid(pid, &status, 0) < 0) die("waitpid failed: %m"); if (!WIFSTOPPED(status) || WSTOPSIG(status) != SIGSTOP) { kill(pid, SIGKILL); die("tracer: unexpected wait status: %x", status); } /*      ptrace,    . */ /* *  ,      *  ,      . *    --  API  ptrace! */ /*       PTRACE_SYSCALL. */ } /* (argc, argv) --    ,    . */ void shim_ptrace(int argc, char **argv) { pid_t pid = fork(); if (pid < 0) die("couldn't fork: %m"); else if (pid == 0) tracee(argc, argv); else tracer(pid); die("should never be reached"); } 

Sans appel de raise(SIGSTOP) , il se peut que execvp(3) s'exécute avant que le processus parent ne soit prêt pour cela; puis les actions du débogueur (par exemple, intercepter les appels système) ne démarreront pas depuis le début du processus.

Lorsque le débogage est démarré, chaque ptrace(PTRACE_SYSCALL, pid, NULL, NULL) «décongèle» le processus débogué jusqu'à la première entrée dans l'appel système, puis jusqu'à ce que l'appel système quitte.

Assembleur télékinétique


ptrace(PTRACE_SYSCALL) ne renvoie aucune information au débogueur; il promet simplement que le processus débogué s'arrêtera deux fois à chaque appel système. Pour obtenir des informations sur ce qui se passe avec le processus débogué - par exemple, quel système l'appelle arrêté - vous devez monter dans une copie de ses registres stockée par le noyau dans un struct user dans un format qui dépend de l'architecture spécifique. (Par exemple, sur x86_64, le numéro d'appel sera dans le champ regs.orig_rax , le premier paramètre transmis sera dans regs.rdi , etc.) Alexa commente: "on dirait que vous écrivez du code assembleur en C qui fonctionne avec les registres du processeur distant."

Au lieu de la structure décrite dans sys/user.h , il peut être plus pratique d'utiliser les constantes d'index définies dans sys/reg.h :

 #include <sys/reg.h> /*    . */ long ptrace_syscall(pid_t pid) { #ifdef __x86_64__ return ptrace(PTRACE_PEEKUSER, pid, sizeof(long)*ORIG_RAX); #else // ... #endif } /*      . */ uintptr_t ptrace_argument(pid_t pid, int arg) { #ifdef __x86_64__ int reg = 0; switch (arg) { case 0: reg = RDI; break; case 1: reg = RSI; break; case 2: reg = RDX; break; case 3: reg = R10; break; case 4: reg = R8; break; case 5: reg = R9; break; } return ptrace(PTRACE_PEEKUSER, pid, sizeof(long) * reg, NULL); #else // ... #endif } 

Dans ce cas, deux arrêts du processus débogué - à l'entrée de l'appel système et à la sortie de celui-ci - ne diffèrent en aucune manière du point de vue du débogueur; de sorte que le débogueur lui-même doit se souvenir de l'état dans lequel se trouve chacun des processus débogués: s'il y en a plusieurs, alors personne ne garantit qu'une paire de signaux d'un processus viendra d'affilée.

Descendants


L'une des options ptrace , à savoir PTRACE_O_TRACECLONE , garantit que tous les enfants du processus débogué seront automatiquement débogués lorsqu'ils quitteront fork(2) . Un point subtil supplémentaire ici est que les descendants pris pour le débogage deviennent des «pseudo-enfants» du débogueur, et waitpid répondra non seulement à l'arrêt des «enfants immédiats», mais aussi à l'arrêt du débogage des «pseudo-enfants». Un homme met en garde contre ceci: "La définition de l'indicateur WCONTINUED lors de l'appel de waitpid (2) n'est pas recommandée: l'état" continu "est par processus et sa consommation peut confondre le vrai parent du suivi." - c'est-à-dire Les «pseudo-enfants» ont deux parents qui peuvent attendre qu'ils s'arrêtent. Pour le programmeur du débogueur, cela signifie que waitpid(-1) attendra non seulement l'arrêt immédiat des enfants, mais également tout processus débogué.

Signaux


(Contenu bonus d'un traducteur: cette information n'est pas dans l'article en anglais)
Comme déjà mentionné au tout début, la communication entre le programme débogué et le débogueur se fait à l'aide de signaux. Un processus reçoit SIGSTOP lorsqu'un débogueur lui est connecté, puis SIGTRAP chaque fois que quelque chose d'intéressant se produit dans le processus en cours de débogage - par exemple, un appel système ou la réception d'un signal externe. Le débogueur, à son tour, reçoit SIGCHLD chaque fois que l'un des processus débogués (pas nécessairement l'enfant immédiat) se "fige" ou "se fige".

ptrace(PTRACE_SYSCALL) processus débogué est effectuée en appelant ptrace(PTRACE_SYSCALL) (avant le premier signal ou appel système) ou ptrace(PTRACE_CONT) (avant le premier signal). Lorsque les signaux SIGSTOP/SIGCONT sont également utilisés à des fins non liées au débogage, des problèmes avec ptrace peuvent survenir: si le débogueur «débloque» le processus débogué qui a reçu SIGSTOP , de l'extérieur, il semblera que le signal a été ignoré; si le débogueur ne «dégivre» pas le processus en cours de débogage, le SIGCONT externe ne peut pas le «dégivrer».

Maintenant, pour la partie amusante: Linux interdit aux processus de se déboguer eux - mêmes , mais n'empêche pas la création de boucles lorsqu'un parent et un enfant se déboguent mutuellement. Dans ce cas, lorsqu'un des processus reçoit un signal externe, il "se fige" via SIGTRAP - puis SIGCHLD envoyé au deuxième processus, et il "se fige" également via SIGTRAP . Il est impossible de sortir de tels «co-débogueurs» de l'impasse en envoyant SIGCONT de l'extérieur; la seule façon est de tuer ( SIGKILL ) l'enfant, puis le parent quittera le débogage et «se fige». (Si vous tuez un parent, l'enfant mourra avec lui.) Si l'enfant active l'option PTRACE_O_EXITKILL , le parent débogué par lui mourra également.

Vous savez maintenant comment mettre en œuvre une paire de processus qui, lors de la réception d'un signal, se figent pour toujours et ne meurent qu'ensemble. Pourquoi cela peut être nécessaire dans la pratique, je ne vais pas expliquer :-)

Source: https://habr.com/ru/post/fr439882/


All Articles