Aus Linux-Magazin 04/2008

Perl-Skript nutzt Ptrace zur Prozessüberwachung

© Yevgeniya Ponomareva, Fotolia

Einem Prozess bei der Arbeit auf die Finger sehen – unter Linux erlaubt das Ptrace. Das eingebaute Tool nutzen hilfreiche Debugger wie feindselige Prozess-Kidnapper gleichermaßen. Ein CPAN-Modul führt die Technik in Perl ein, und wo das nicht reicht, helfen in C geschriebene Erweiterungen weiter.

Neulich wollte ich die Schreib-Aktivitäten eines Linux-Prozesses etwas genauer untersuchen und entdeckte bei der Gelegenheit, dass es im CPAN sogar ein Modul für Ptrace gibt! Ptrace ist eine im Linux-Kernel verankerte Technik, die Prozesse schrittweise ablaufen lässt und Informationen über ihre Daten einholt. Debugger wie zum Beispiel der GDB und andere benutzen diese Technik und bauen ein komfortables Benutzer-Interface darum herum.

Um nur herauszufinden, welche Dateien ein Prozess zum Schreiben öffnet, reicht es jedoch schon, »ptrace« mit »PTRACE_SYSCALL« dazu zu überreden, jedes Mal anzuhalten, wenn der Prozess einen »open()«-Systemaufruf absetzt. Anschließend ruft er die in der Manpage von Open näher beschriebene Funktion der Standard-C-Bibliothek Libc im Schreibmodus auf. Was diese Bibliothek intern alles anstellt, damit der Kernel die angegebene Datei öffnet und einen File-Deskriptor zurückgibt, lässt sich mit »objdump -d /lib/libc.so.6« ergründen (siehe Abbildung 1).

Abbildung 1: Der Code der Libc, der den Kernel darum bittet, den Systemcall »open()« auszuführen. Über einen Interrupt wechselt der Prozessor dafür in den Kernel-Mode.

Abbildung 1: Der Code der Libc, der den Kernel darum bittet, den Systemcall »open()« auszuführen. Über einen Interrupt wechselt der Prozessor dafür in den Kernel-Mode.

Eingang zur Unterwelt

Was der Disassembler ausspuckt, ist nur Wenigen auf den ersten Blick verständlich. Das gilt auch für den x86-Assemblercode, der die Funktionsparameter für »open()« vom Stack (»%esp«) holt und mit der Anweisung »mov« (für Move) in die Prozessorregister EBX, ECX und EDX schreibt (im Assemblercode mit vorangestelltem Prozentzeichen).

Wie die Include-Datei »adm/unistd.h« (Listing 1) verrät, führt der Kernel den Systemcall »open()« intern unter der Nummer 5, die Libc schreibt diesen Wert mit »mov $0x5,%eax« ins Register EAX des Prozessors. Den Sprung in den Kernel führt dann die Anweisung »int $0x80« aus. Sie löst einen Software-Interrupt aus, der Prozessor wechselt in den Priviledged Mode und bearbeitet den Systemcall auf der anderen Seite der Mauer, im Kernel-Land. Die Parameter holt er sich aus den Prozessorregistern, in die sie die Libc vorher gelegt hat.

Listing 1: »uninstd.h« (Auszug)

01 #ifndef _ASM_I386_UNINSTD_H_ 
02 #define _ASM_I386_UNINSTD_H_
03 /* 
04 * This file contains the system call numbers. 
05 */
06 #define __NR_exit 1 
07 #define __NR_fork 2 
08 #define __NR_read 3 
09 #define __NR_write 4 
10 #define __NR_open 5 
11 #define __NR_close 6

Die Funktion »open()« nimmt bis zu drei Parameter entgegen: »int open(const char *pathname, int flags, mode_t mode)«. Der String, der den Dateipfad angibt, passt natürlich nicht in ein 32-Bit-Register, also liegt im Register EBX nur die Speicheradresse, an der die Zeichenkette zu finden ist.

Um nun zu untersuchen, ob ein zufällig aufgeschnappter Systemcall ein »open()« mit Schreiboption ist, muss der Überwachungscode prüfen, ob EAX den Wert 5 aufweist und ob die UND-Verknüpfung des Registers ECX mit der in »sys/fcntl.h« definierten Konstanten »O_WRONLY« den Wert »wahr« ergibt. Theoretisch ließe sich eine Datei zum Schreiben auch mit »O_RDWR« (Schreib-/Lesezugriff) oder »O_APPEND« (ans Dateiende anfügen) öffnen, aber das soll der Einfachheit halber unterbleiben.

Es ist übrigens egal, in welcher Sprache der zu überwachende Code geschrieben ist – unter der Haube verwenden C, Perl, Java, Ruby und wie sie alle heißen immer den Systemcall »open()« aus der Libc in »WriteTracer.pm«.

Der Überwacher dockt an

Listing 2 zeigt den Perl-Code, mit dem ein Skript alle Systemcalls eines Prozesses abfängt und auf »open()«-Requests mit Schreibmodus untersucht. Abbildung 2 illustriert das Zusammenspiel von Eltern- und Kindprozess während der Überwachung. Nach einem »fork()« setzt der neu entstandene Kindprozess das Ptrace-Kommando »PTRACE_TRACEME« ab und führt das zu überwachende Programm mit »exec()« aus.

Listing 2: »WriteTracer.pm«

001 ###########################################
002 package WriteTracer;
003 use strict;
004 use POSIX;
005 use Inline "C";
006 use Fcntl;
007
008 use Sys::Ptrace qw(ptrace
009  PTRACE_SYSCALL PTRACE_TRACEME);
010
011 ###########################################
012 sub run {
013 ###########################################
014   my($prg, @params) = @_;
015
016   my @files = ();
017   my %files = ();
018
019   if((my $pid = fork()) < 0) {
020       die "fork failed";
021
022   } elsif($pid == 0) {
023     # child
024     ptrace(PTRACE_TRACEME, $$, 0, 0);
025     exec($prg, @params);
026
027   } else {
028     # parent
029     {
030        my $rc = waitpid($pid, 0);
031        last if $rc < 0;
032
033        if( WIFSTOPPED($?) ) {
034          my($eax, $orig_eax, $ebx, $ecx,
035             $edx) = ptrace_getregs($pid);
036
037          if($eax == -ENOSYS()) {
038            if($orig_eax == 5 and
039               $ecx & O_WRONLY) {
040               my $str = ptrace_string_read(
041                                 $pid, $ebx);
042               push @files, $str
043                     unless $files{$str}++;
044          }
045        }
046
047        ptrace(PTRACE_SYSCALL, $pid,
048               undef, undef);
049        redo;
050        }
051      }
052    }
053    return @files;
054   }
055
056   1;
057
058 __DATA__
059 __C__
060 #include <sys/ptrace.h>
061 #include <linksm/user.h>
062
063 #define IVPUSH(x) Inline_Stack_Push( \
064   sv_2mortal(newSViv(x)));
065
066 /* ------------------------------------- */
067 void ptrace_getregs(int pid) {
068   int rc;
069   struct user_regs_struct registers;
070   Inline_Stack_Vars;
071
072   rc = ptrace(PTRACE_GETREGS, pid,
073                     0, &registers);
074   if(rc == -1) {
075      return;
076   }
077
078   if( registers.eax == -ENOSYS ) {
079     Inline_Stack_Reset;
080     IVPUSH(registers.eax);
081     IVPUSH(registers.orig_eax);
082     IVPUSH(registers.ebx);
083     IVPUSH(registers.ecx);
084     IVPUSH(registers.edx);
085     Inline_Stack_Done;
086   } 
087 }
088
089 /* ------------------------------------- */
090 int ptrace_aligned_word_read_c(int pid,
091     void *addr, char *buf, int *len) {
092 char *aligned_addr;
093 long word;
094 void *ptr;
095
096 aligned_addr = (char *) (
097 (long)addr & ~ (sizeof(long) - 1) );
098
099 word = ptrace(PTRACE_PEEKDATA, pid,
100   aligned_addr, NULL);
101
102 if(word == -1) {
103   return -1;
104 }
105
106 *len = sizeof(long) - ( (long) addr -
107                 (long) aligned_addr );
108 ptr = &word;
109 ptr += (sizeof(long) - *len);
110 memcpy(buf, ptr, *len);
111
112 return 0;
113 }
114
115 /* ------------------------------------- */
116 void ptrace_string_read(int pid,
117    void *addr) {
118 char word_buf[ sizeof(long) ];
119 int word_len;
120 SV *pv;
121 int rc;
122 int i;
123 Inline_Stack_Vars;
124
125 pv = newSVpv((const char *)"", 0);
126
127 while(1) {
128    rc = ptrace_aligned_word_read_c(pid,
129              addr, word_buf, &word_len);
130    if(rc < 0) {
131       return;
132    }
133
134    for(i=0; i<word_len; i++) {
135      if(word_buf[i] == '\0') {
136        goto FINISH;
137      }
138      sv_catpvn(pv, (const char *)
139                  &word_buf[i], 1);
140    }
141    addr += word_len;
142   }
143
144 FINISH:
145 Inline_Stack_Reset;
146 Inline_Stack_Push(sv_2mortal(pv));
147 Inline_Stack_Done;
148 }
Abbildung 2: Eltern- und Kindprozess im Zusammenspiel während einer Überwachung mit Ptrace.

Abbildung 2: Eltern- und Kindprozess im Zusammenspiel während einer Überwachung mit Ptrace.

Der Elternprozess wartet mit »waitpid()« darauf, dass der Kernel das Kind mit einem Stopp-Signal anhält. Danach schickt er es mit »PTRACE_SYSCALL« wieder ins Rennen, weist den Kernel aber damit an, das Kind beim nächsten Aufruf eines Systemcalls sofort wieder anzuhalten. Beim nächsten Stopp erwischt er das Kind dann beim Tuscheln mit dem Kernel und der Elternprozess kann in aller Ruhe mit weiteren Ptrace-Kommandos untersuchen, welcher Systemcall auszuführen war. Sogar dessen Parameter liegen offen.

Umwege der Überwachung

Im Normalfall ruft der Kernel sofort den zugehörigen Systemcall-Handler auf, sobald er einen Request für einen Systemcall empfangen hat. Stellt er aber fest, dass Ptrace den Prozess überwacht, springt er stattdessen die Kernelfunktion »tracesys()« an, die ihrerseits

  • den Prozess stoppt und den Elternprozess über den bevorstehenden Systemcall benachrichtigt und
  • nach dem Wiederanlaufen ein weiteres Mal stoppt und den Elternprozess über das Ergebnis des Systemcalls informiert.

Damit der Überwacher die beiden Fälle unterscheiden kann, setzt der Kernel das EAX-Register beim ersten Stopp auf den Wert »-ENOSYS«. Das EAX-Register enthält ja, wie schon erläutert, normalerweise die Nummer des auszuführenden Systemcalls. »-ENOSYS« hingegen ist die Fehlermeldung des Kernels bei einer nicht existierenden Systemcall-Nummer. Da dies ein unmöglicher Wert für einen Systemcall ist, weiß der überwachende Prozess nun, dass der überwachte Prozess kurz vor einem Systemcall steht, dessen Nummer der Kernel vorsorglich in »ORIG_EAX« gesichert hat.

Zeile 33 in »WriteTracer.pm« prüft mit dem Makro »WIFSTOPPED()« und Perls Statusvariablen »$?«, ob der Kindprozess tatsächlich anhielt oder ob »waitpid«() etwa deshalb anschlug, weil das Kind sich verabschiedet hat. Zeile 37 verifiziert, dass das vorher mit der Funktion »ptrace_getargs()« eingeholte Register EAX den Wert »-ENOSYS« enthält. Ist dies der Fall, prüft die nächste IF-Bedingung, ob »ORIG_EAX« auf 5 steht (die Systemcall-Nummer von Open) und ob eine Und-Verknüpfung mit »O_WRONLY« des ECX-Registers einen wahren Wert ergibt. Ist dies alles erfüllt, liest die Funktion »ptrace_string_read()« den String an der im Register EBX hinterlegten Speicheradresse aus und speichert den zurückkommenden Perl-Skalar im Array »@files«. Ein Hash »%files« stellt sicher, dass dies pro Dateiname genau einmal passiert.

Danach schickt »WriteTracer.pm« das »PTRACE_SYSCALL«-Kommando ab, worauf das Kind sich wieder in Bewegung setzt. Die »redo«-Anweisung in Zeile 49 des Elternprozesses springt wieder hoch zu »waitpid()«, das auf den nächsten Zustandswechsel des Kindes wartet. Listing 3 (»write-tracer«) demonstriert eine Anwendung des Tracers. Es nimmt ein Kommando mit Parametern auf der Kommandozeile entgegen und reicht es an »WriteTracer.pm« weiter.

Listing 3: »write-tracer«

01 #!/usr/bin/perl -w
02 use strict;
03 use WriteTracer;
04
05 die "usage: $0 program" unless @ARGV;
06
07 my @files = WriteTracer::run(@ARGV);
08
09 print "Written files: ",
10        join(", ", @files), "\n";

Abbildung 3 zeigt ein Perl-Programm, das zwei Files öffnet und die korrekte Ausgabe des überwachenden Tracers. Abbildung 4 führt das Gleiche mit einem in C geschriebenen und mit GCC übersetzten Programm vor.

Abbildung 3: Das Programm findet heraus, welche Dateien ein Perl-Skript zum Schreiben öffnet. Dafür greift er auf die Argumente des Syscall zu.

Abbildung 3: Das Programm findet heraus, welche Dateien ein Perl-Skript zum Schreiben öffnet. Dafür greift er auf die Argumente des Syscall zu.

Abbildung 4: Der Tracer arbeitet auch mit kompilierten C-Programmen.

Abbildung 4: Der Tracer arbeitet auch mit kompilierten C-Programmen.

Das für die Ptrace-Kommandos verwendete Perl-Modul Sys::Ptrace vom CPAN ist leider nicht ganz vollständig, daher definiert »WriteTracer.pm« mittels Inline::C einige in C geschriebene Erweiterungen. Die im Perl-Code aufgerufenen Funktionen »ptrace_getregs()« und »ptrace_string_read()« sind allesamt im »__DATA__«-Bereich hinter dem Perl-Code definiert. Inline::C kompiliert sie beim ersten Aufruf des Moduls »WriteTracer.pm« nebenbei.

Die Funktion »ptrace_getregs()« nimmt die Prozessnummer des Kindes entgegen, denn die Ptrace-Funktion »ptrace(PTRACE_GETREGS,…)« erfordert die Angabe des Prozesses, dessen Register sie abfragen soll. Die Registerwerte landen in einer C-Struktur des Typs »user_regs_struct«, die im Kernelheader »asm/user.h« definiert ist. Die Einzelwerte schiebt das weiter oben definierte Perl-Makro »IVPUSH()« dann auf den Perl-Stack, damit die in C geschriebene Perl-Funktion »ptrace_getregs()« eine Liste mit Registerwerten in die Perl-Welt zurückliefert.

Die mit »sv_2mortal(newSViv(x))« präparierten Werte sind sterbliche Skalare, die Perls Garbage-Collector ordentlich aufräumt, wenn die referenzierenden Perl-Variablen aus ihrem Gültigkeitsbereich verschwinden.

Problem Ausrichtung

Die in Listing 2 ab Zeile 116 definierte Funktion »ptrace_string_read()« liest mit dem Ptrace-Kommando »TRACE_PEEKDATA« einen C-String ab einer vorgegebenen Speicheradresse, muss sich aber mit Alignment-Problemen im Linux-Speicher herumschlagen. Denn wie Abbildung 5 zeigt, können Strings auf beliebigen Speicheradressen beginnen, doch abfragen kann man sie immer nur an 4-Byte-Wortgrenzen.

Abbildung 5: Obwohl der String bei 0x804848d beginnt, muss der Zugriff an der Wortgrenze (0x804848c) erfolgen. Daher bleiben am Anfang womöglich einige Bytes leer.

Abbildung 5: Obwohl der String bei 0x804848d beginnt, muss der Zugriff an der Wortgrenze (0x804848c) erfolgen. Daher bleiben am Anfang womöglich einige Bytes leer.

Dies führt die ab Zeile 90 definierte C-Funktion »ptrace_aligned_word_read_c()« aus, die eine PID sowie eine Speicheradresse empfängt und einen Puffer mit dessen Länge in »buf« und »len« zurückliefert. Fällt die Adresse auf eine Wortgrenze, ist der erste Puffer 4 Bytes lang, bei ungeraden Adressen entsprechend weniger.

Der mit »newSVpv()« erzeugte Perl-Skalar zum Speichern des Dateistrings ist anfangs leer und »sv_catpvn()« hängt hinten jeweils ein neugefundenes Byte an. Stößt die Funktion auf ein Nullbyte, ist der String im Speicher zu Ende und ein »goto« springt aus der Doppelschleife zum Label »FINISH«.

Einschränkungen

Ruft das mit Ptrace überwachte Programm weitere Prozesse auf, entziehen die sich der Überwachung. Der Benutzer kann also nicht einfach »write-tracer make install« aufrufen, da »make« die Installationskommandos nicht im gleichen Prozess aufruft, sondern jeweils eine neue Shell startet.

Überwacher wie Installwatch [3] und Checkinstall [4] arbeiten anders, um diese Beschränkung zu umgehen. Sie setzen die Umgebungsvariable »LD_PRELOAD«, die eine Shared Library mit Systemcall-Wrappern einschleust und »make« auch an die aufgerufenen Subshells vererbt. Die Wrapper-Library definiert neue Einträge für alle gängigen Dateifunktionen der Libc und gaukelt dem zu überwachenden Programm vor, dies seien die richtigen.

Die Wrapper-Funktionen loggen aber nur mit, was abläuft, und verzweigen anschließend sofort zur originalen Libc, die die eigentliche Arbeit erledigt. Aber auch diese Technik lässt sich aushebeln: Wenn zum Beispiel ein Perl-Skript das Kommando »system(“cp a b”)« absetzt, vererbt sich »LD_PRELOAD« nicht, Installwatch oder Checkinstall bekommen vom Kopiervorgang nichts mit.

Ptrace taugt nicht nur für friedliche Lösungen: Wie [5] beschreibt, verwenden Dunkelmänner die Technik, um Prozesse umzulenken und für finstere Machenschaften ihrem Zweck zu entfremden.

Wer sich nicht nur für Ptrace, sondern auch für weiterführende Techniken zur Fehlersuche und zur Prozessüberwachung interessiert, dem sei das an dieser Stelle schon einmal empfohlene Buch [2] ans Herz gelegt, das auch beim Schreiben dieses Artikels eine unentbehrliche Hilfe war.

Der bekannteste Kunde von Ptrace ist zweifellos das Kommandozeilentool »strace« [6], das Prozesse lückenlos überwacht und sogar an laufende Prozesse andockt. (jcb)

Infos

[1] Listings zu diesem Artikel: [ftp://www.linux-magazin.de/pub/listings/magazin/2008/04/Perl]

[2] Mark Wilding, Dan Behman, “Self-Service Linux”: Prentice Hall, 2006

[3] Installwatch: [http://asic-linux.com.mx/~izto/checkinstall/installwatch.html]

[4] Checkinstall: [http://asic-linux.com.mx/~izto/checkinstall/]

[5] “Execution Flow Hijacking” in “Security Power Tools”, O’Reilly 2007

[6] Strace: [http://sourceforge.net/projects/strace/]

Der Autor


Michael Schilli arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat “Goto Perl 5” (deutsch) und “Perl Power” (englisch) für Addison-Wesley geschrieben und ist unter [mschilli@perlmeister.com] zu erreichen.

LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben