Ganz nah am Kernel (Linux-Magazin, April 2008)

Mit Ptrace verfügt Linux über ein eingebautes Tool zum Tracen von Prozessen, den Debugger und Prozess-Kidnapper gleichermaßen nutzen. 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 untersuchen und fand heraus, dass es auf dem CPAN sogar ein Modul für Ptrace gibt! Ptrace ist eine im Linux-Kernel verankerte Technik, mit der man Prozesse schrittweise ablaufen lassen und Informationen über gerade aktuelle Prozessdaten einholen kann. Debugger wie gdb nutzen diese Technik und bauen ein komfortables User-Interface darum herum.

Um herauszufinden, welche Dateien ein Prozess zum Schreiben öffnet, reicht es hingegen, Ptrace mit PTRACE_SYSCALL dazu zu überreden, jedesmal anzuhalten, wenn der Prozess einen open()-Systemaufruf absetzt und die in man open beschriebene Funktion der Standard-C-Bibliothek libc im Schreibmodus aufruft. Die mit objdump -d /lib/libc.so.6 erzeugte Ausgabe in Abbildung 1 zeigt, was die libc intern so treibt, damit der Kernel eine angegebene Datei öffnet und einen File-Deskriptor zurück gibt.

Abbildung 1: Der Code der libc, der den Kernel bittet, den Systemcall open() auszuführen.

Eingang zur Unterwelt

Was unter der sauberen C-API eines Linux-Systems abläuft, ist naturgemäß nicht immer schön anzusehen oder einfach zu verstehen. Die Ausgabe in Abbildung 1 zeigt den x86-Assemblercode, der die Funktions-Parameter für open() vom Stack (%esp) holt und mit der Anweisung mov (für 'move') in die Prozessorregister EBX, ECX und EDX (im Assemblercode %ebx, %ecx und %edx) schreibt. Wie man aus der Include-Datei adm/unistd.h (Abbildung 2) entnehmen kann, führt der Kernel den Systemcall open() intern unter der Nummer 5, und die libc schreibt diesen Wert mit mov $0x5,%eax ins Register EAX des Prozessors. Den Sprung in den Kernel führt anschließend die Anweisung int $0x80 aus. Sie löst einen Interrupt aus, der Prozessor springt in den ``Priviledged Mode'' und bearbeitet den Systemcall auf der anderen Seiter der Mauer, im Kernel-Land. Die Parameter holt er sich aus den Prozessorregistern, in denen die libc sie vorher abgelegt hat.

Abbildung 2: Die Kernel-Include-Datei asm/unistd.h weist jedem Systemcall eine eindeutige Nummer zu.

Die Funktion open() nimmt ja bekanntlich 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 eine Speicheradresse, an der der String hinterlegt ist. Um nun zu untersuchen, ob ein zufällig aufgeschnappter Systemcall ein schreibendes open() ist, muss der Überwachungscode später prüfen, ob EAX den Wert 5 aufweist und ob das Register ECX mit der in sys/fcntl.h definierten Konstante O_WRONLY ge-odert wurde. Theoretisch könnte man eine Datei zum Schreiben auch mit O_RDWR (Schreib/Lesezugriff) oder O_APPEND (ans Dateiende anfügen) öffnen, aber das soll der Einfachheit halber unter den Tisch fallen.

Für diese Überlegungen ist es übrigens egal, in welcher Sprache der zu überwachende Code geschrieben wurde -- unter der Haube verwenden, C, Perl, Java, Ruby, und wie sie alle heißen alle den Systemcall open() aus der libc.

Der Überwacher dockt an

Listing WriteTracer.pm zeigt den Perl-Code, mit dem ein Skript alle Systemcalls eines Prozesses abfängt und auf open()-Requests mit Schreibmodus hin untersucht. Abbildung 3 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. Der Elternprozess wartet mit waitpid() darauf, dass der Kernel das Kind mit einem Stop-Signal anhält. Der Elternprozess schickt das Kind anschließend mit PTRACE_SYSCALL wieder ins Rennen, weist den Kernel aber damit an, das Kind beim nächsten Aufruf eines Systemcalls sofort wieder anzuhalten. Das nächste waitpid() erwischt das Kind dann beim Tuscheln mit dem Kernel und der Elternprozess kann das angehaltene Kind in aller Ruhe mit weiteren ptrace-Kommandos untersuchen. Er erfährt, welcher Systemcall aufgerufen wird und sogar dessen Parameter liegen offen.

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

Listing 1: WriteTracer.pm

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

Umwege der Überwachung

Im Normalfall ruft der Kernel nach einem empfangenen Request für einen Systemcall sofort den zugehörigen Systemcall-Handler auf. Stellt er allerdings fest, dass der Prozess mit ptrace überwacht wird, springt er statt dessen die Kernelfunktion tracesys an, die

* 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 diese 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 vorher 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 nun kurz vor einem Systemcall steht, dessen Nummer der Kernel vorsorglich in ORIG_EAX gesichert hat.

Zeile 32 in WriteTracer.pm prüft mit dem Macro WIFSTOPPED() und der Perl-Variablen $? (der Status des letzten waitpid()), ob der Kindprozess tatsächlich angehalten wurde oder ob waitpid() etwa deshalb angeschlagen hat, weil das Kind sich verabschiedet hat. Zeile 36 verifiziert, dass das vorher mittels 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() an der im Register EBX hinterlegten Speicheradresse String aus und speichert den zurückkommenden Perl-Skalar im Array @files. Ein Hash %files stellt sicher, dass dies pro Dateiname genau einmal passiert.

Anschließend schickt WriteTracer.pm das PTRACE_SYSCALL-Kommando ab, worauf das Kind sich wieder in Bewegung setzt. Die redo-Anweisung in Zeile 48 des Elternprozesses springt wieder hoch zu waitpid(), welches auf den nächsten Zustandswechsel des Kindes wartet. Listing write-tracer zeigt eine Anwendung des Tracers. Es nimmt ein Kommando mit Parametern auf der Kommandozeile entgegen und reicht es an WriteTracer.pm weiter. Abbildung 4 zeigt ein Perlprogramm, das zwei Files öffnet und die korrekte Ausgabe des überwachenden Tracers. Abbildung 5 führt das gleiche mit einem in C geschriebenen und mit gcc übersetzten Programm vor.

Listing 2: 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 4: Der Tracer findet heraus, welche Dateien ein Perlskript zum Schreiben öffnet.

Abbildung 5: Der Tracer arbeitet natürlich auch mit compilierten C-Programmen.

Erweiterung in C

Das für die Ptrace-Kommandos verwendete Perl-Modul Sys::Ptrace vom CPAN ist leider nicht ganz vollständig, und so 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 und werden von Inline::C beim ersten Aufruf von WriteTracer.pm on-the-fly compiliert.

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 Kernel-Header 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.

Die ab Zeile 115 definierte Funktion ptrace_string_read() liest mittels des Ptrace-Kommandos TRACE_PEEKDATA einen C-String ab einer vorgegebenen Speicheradresse, muss sich aber mit Alignment-Problemen im Linux-Speicher herumschlagen. Denn wie Abbildung 6 zeigt, können Strings auf beliebigen Speicheradressen beginnen, doch abfragen kann man diese immer nur an 4-Byte-Wortgrenzen. Dies führt die ab Zeile 89 definierte C-Funktion ptrace_aligned_word_read_c aus, die eine pid und eine Speicheradresse entgegennimmt und einen Puffer samt 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 jeweils ein neugefundenes Byte hintenan. Stößt die Funktion auf ein Nullbyte, ist der String im Speicher zu Ende und ein goto springt aus der Doppelschleife zum Label FINISH.

Abbildung 6: Obwohl der String bei 0x804848d beginnt, muss der Zugriff über die Wortgrenze (0x804848c) erfolgen.

Einschränkungen

Ruft das mit ptrace überwachte Programm weitere Prozesse auf, entziehen sich diese der Überwachung. Man kann also nicht einfach

    write-tracer make install

aufrufen, da make die einprogrammierten 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 die make auch an die aufgerufenen Sub-Shells 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 an die Originale der libc, die die eigentliche Arbeit erledigen. Aber auch diese Technik lässt sich aushebeln: Wenn zum Beispiel ein Perlskript das Kommando system("cp a b") absetzt, wird LD_PRELOAD nicht vererbt und installwatch bzw. checkinstall bekommen vom Kopiervorgang nichts mit.

Und ptrace taugt nicht nur für friedfertige Lösungen: Wie [5] beschreibt, verwenden Dunkelmänner die Technik auch gerne dazu, laufende Prozesse umzulenken und für finstere Machenschaften zu zweckentfremden.

Wer sich nicht nur für Ptrace, sondern auch für weiterführende Techniken zur Fehlersuche und der Prozessüberwachung interessiert, dem sei das an dieser Stelle schon einmal empfohlene Buch ``Self-Service Linux'' ans Herz gelegt, das auch bei der Herstellung dieses Artikels eine unentbehrliche Hilfe war.

Und der bekannteste Kunde von Ptrace ist zweifellos das Kommandozeilentool strace [6], das Prozesse lückenlos überwacht und sogar an laufende Prozesse andockt.

Infos

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

[2]
``Self-Service Linux'', Mark Wilding and Dan Behman, Prentice Hall, 2006

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

[4]
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/

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. Seine Homepage: http://perlmeister.com.