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