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.
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, ®isters);
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 }
|
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.
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.
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 |
|---|
|
|








