Abenteuer mit ptrace (2)

Über Habré wurde bereits über das Abfangen von Systemaufrufen mittels ptrace ; Alexa schrieb über diesen viel detaillierteren Beitrag, den ich übersetzen wollte.


Wo soll ich anfangen?


Die Kommunikation zwischen dem debuggten Programm und dem Debugger erfolgt über Signale. Dies erschwert die ohnehin schwierigen Dinge erheblich; Zum Spaß können Sie den Abschnitt BUGS von man ptrace .

Es gibt mindestens zwei verschiedene Möglichkeiten, um mit dem Debuggen zu beginnen:

  1. ptrace(PTRACE_TRACEME, 0, NULL, NULL) macht das übergeordnete ptrace(PTRACE_TRACEME, 0, NULL, NULL) des aktuellen Prozesses zu einem Debugger dafür. Es ist keine Unterstützung durch die Eltern erforderlich. man rät sanft: "Ein Prozess sollte diese Anfrage wahrscheinlich nicht stellen, wenn sein Elternteil nicht erwartet, sie zu verfolgen." (An anderer Stelle im Mans haben Sie den Satz "wahrscheinlich nicht" gesehen ?) Wenn der aktuelle Prozess bereits einen Debugger hatte, schlägt der Aufruf fehl.
  2. ptrace(PTRACE_ATTACH, pid, NULL, NULL) macht den aktuellen Prozess zu einem Debugger für pid . Wenn pid bereits einen Debugger hatte, schlägt der Aufruf fehl. SIGSTOP an den debuggten Prozess gesendet und funktioniert erst weiter, wenn der Debugger ihn abtaut.

Diese beiden Methoden sind völlig unabhängig; Sie können entweder das eine oder das andere verwenden, aber es macht keinen Sinn, sie zu kombinieren. Es ist wichtig zu beachten, dass PTRACE_ATTACH nicht sofort erfolgt: Nach dem ptrace(PTRACE_ATTACH) wird normalerweise waitpid(2) PTRACE_ATTACH , um zu warten, bis PTRACE_ATTACH „funktioniert“.

Sie können den PTRACE_TRACEME Prozess unter Debugging mit PTRACE_TRACEME wie folgt PTRACE_TRACEME :

 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"); } 

Ohne einen raise(SIGSTOP) Aufruf raise(SIGSTOP) kann sich herausstellen, dass execvp(3) wird, bevor der übergeordnete Prozess dafür bereit ist. und dann beginnen die Aktionen des Debuggers (zum Beispiel das Abfangen von Systemaufrufen) nicht am Anfang des Prozesses.

Wenn das Debuggen gestartet wird, "taut" jeder ptrace(PTRACE_SYSCALL, pid, NULL, NULL) den debuggten Prozess bis zum ersten Eintrag in den Systemaufruf und dann bis zum Verlassen des Systemaufrufs auf.

Telekinetischer Assembler


ptrace(PTRACE_SYSCALL) gibt keine Informationen an den Debugger zurück. Er verspricht lediglich, dass der zu debuggende Prozess bei jedem Systemaufruf zweimal gestoppt wird. Um Informationen darüber zu erhalten, was mit dem debuggten Prozess geschieht - zum Beispiel, in welchem ​​Systemaufruf er gestoppt wurde -, müssen Sie in eine Kopie seiner Register klettern, die vom Kernel in einem struct user in einem Format gespeichert wird, das von der spezifischen Architektur abhängt. (Unter x86_64 befindet sich die Rufnummer beispielsweise im Feld regs.orig_rax , der erste übergebene Parameter befindet sich in regs.rdi usw.) Alexa kommentiert: „Es fühlt sich an, als würden Sie Assembler-Code in C schreiben, der mit den Registern des Remote-Prozessors funktioniert.“

Anstelle der in sys/user.h beschriebenen Struktur kann es bequemer sein, die in sys/reg.h definierten 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 } 

In diesem Fall unterscheiden sich zwei Stopps des Debugging-Prozesses - am Eingang des Systemaufrufs und am Ausgang des Systemaufrufs - in keiner Weise aus Sicht des Debuggers. Damit sich der Debugger selbst daran erinnern muss, in welchem ​​Zustand sich jeder der debuggten Prozesse befindet: Wenn mehrere vorhanden sind, garantiert niemand, dass ein Signalpaar von einem Prozess hintereinander kommt.

Nachkommen


Eine der ptrace Optionen, nämlich PTRACE_O_TRACECLONE , stellt sicher, dass alle PTRACE_O_TRACECLONE ptrace des ptrace Prozesses beim Beenden von fork(2) automatisch debuggt werden. Ein weiterer subtiler Punkt hierbei ist, dass Nachkommen, die zum Debuggen waitpid , zu „ waitpid “ des Debuggers werden und waitpid nicht nur auf das Stoppen von „unmittelbaren Kindern“ reagiert, sondern auch auf das Debuggen von „ waitpid “. Man warnt davor: "Das Setzen des WCONTINUED-Flags beim Aufrufen von waitpid (2) wird nicht empfohlen: Der Status" Fortsetzung "ist pro Prozess und verbraucht kann das eigentliche übergeordnete Element des Trace verwirren." - d.h. "Pseudokinder" haben zwei Eltern, die darauf warten können, dass sie aufhören. Für den Debugger-Programmierer bedeutet dies, dass waitpid(-1) nicht nur darauf wartet, dass unmittelbare Kinder gestoppt werden, sondern auch auf einen der debuggten Prozesse.

Signale


(Bonusinhalt eines Übersetzers: Diese Informationen sind nicht im englischsprachigen Artikel enthalten.)
Wie bereits eingangs erwähnt, erfolgt die Kommunikation zwischen dem debuggten Programm und dem Debugger über Signale. Ein Prozess empfängt SIGSTOP wenn ein Debugger mit ihm verbunden ist, und dann SIGTRAP jedes Mal, wenn im Debugging-Prozess etwas Interessantes passiert - beispielsweise ein Systemaufruf oder der Empfang eines externen Signals. Der Debugger wiederum erhält SIGCHLD jedes Mal, wenn einer der debuggten Prozesse (nicht unbedingt das unmittelbare Kind) "einfriert" oder "einfriert".

ptrace(PTRACE_SYSCALL) zu debuggenden Prozesses wird durch Aufrufen von ptrace(PTRACE_SYSCALL) (vor dem ersten Signal oder Systemaufruf) oder ptrace(PTRACE_CONT) (vor dem ersten Signal) durchgeführt. Wenn SIGSTOP/SIGCONT Signale auch für Zwecke verwendet werden, die nicht mit dem Debuggen zusammenhängen, können Probleme mit ptrace auftreten: Wenn der Debugger den Debug-Prozess, der SIGSTOP empfangen hat, „auftaut“, sieht es von außen so aus, als ob das Signal ignoriert wurde. Wenn der Debugger den zu debuggenden Prozess nicht "auftaut", kann der externe SIGCONT ihn nicht "auftauen".

Nun zum lustigen Teil: Linux verbietet es Prozessen, sich selbst zu debuggen , verhindert jedoch nicht die Erstellung von Schleifen, wenn sich Eltern und Kind gegenseitig debuggen. In diesem Fall „friert“ einer der Prozesse, wenn er ein externes Signal empfängt, über SIGTRAP - dann wird SIGCHLD an den zweiten Prozess gesendet und er friert auch über SIGTRAP . Es ist unmöglich, solche „Co-Debugger“ aus dem Stillstand zu bringen, indem SIGCONT von außen SIGCONT wird. Die einzige Möglichkeit besteht darin, das Kind zu töten ( SIGKILL ). Dann beendet das Elternteil das Debuggen und friert ein. (Wenn Sie den Elternteil töten, stirbt das Kind mit ihm.) Wenn das Kind die Option PTRACE_O_EXITKILL , stirbt der von ihm PTRACE_O_EXITKILL Elternteil mit seinem Tod.

Jetzt wissen Sie, wie Sie zwei Prozesse implementieren, die beim Empfang eines Signals für immer einfrieren und nur zusammen sterben. Warum dies in der Praxis notwendig sein kann, werde ich nicht erklären :-)

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


All Articles