Angriffe auf unsicher programmierte Signal-Handler
Aus HackerWiki
Contents |
Signal-Handler-Angriffe
Vorwort
Sichere Programmierung wird in unserer Zeit immer wichtiger. Täglich tauchen diverse Exploits im Internet auf, die es ermöglichen Sicherheitslücken in Programmen auszunutzen. Mittlerweile werden daher fast alle Programme schon so geschrieben, dass sie gegen Symlink-Angriffe, stack-basierte Overflows und Heap-Overflows sicher sind. Allerdings werden selbst bei sogenannten "sicheren" Programmen bestimmte Angriffstechniken einfach nicht getestet, so dass diese Programme oft immernoch Angriffspunkte bieten. Auf eine dieser Schwachstellen möchte ich im folgenden Paper etwas genauer eingehen, denn allzu oft achten Programmierer nicht auf die sichere Implementierung von Signal-Handlern.
Beim folgenden Text handelt es sich nicht um ein Howto, sondern primär um rein theoretische Überlegungen zur Programmierung von Signal-Handlern, die an entsprechenden Stellen mit Beispielen belegt sind. Es ist daher eher als Abhandlung zu diesem Thema zu verstehen. Kenntnisse in der Programmierung mit C und über den Umgang mit Unix-ähnlichen Betriebssystemen sind zwingende Voraussetzung zum Verständnis des folgenden Textes. Ich beziehe mich hier speziell auf Linux, aber viele der hier angesprochenen Techniken funktionieren mit ziemlicher Sicherheit in jedem Unix-System.
Auf die Idee dieses Dokument zu schreiben kam ich durch das Paper "Injecting signals for Fun and Profit" von shaun2k2 und beim Schreiben meines Shellcode-Howtos. Scheinbar scheint es nur wenige Dokumente zu diesem Thema zu geben und so möchte ich hier meine Erfahrungen mit Signal-Handler-Angriffen vermitteln. Ich habe die Strukturierung seines Papers versucht zu übernehmen (auch wenn das leider nur teilweise gelungen ist), da sein Dokument-Aufbau gut durchdacht und dem leichteren Verständnis (in meinen Augen) sehr zuträglich ist. Allerdings handelt es sich bei dem vorliegenden Text nicht um eine Übersetzung von "Injecting signals for Fun and Profit". Ich habe aufgrund eigener Erfahrungen einige Teile hinzugefügt und an vielen Stellen versucht die Hintergründe etwas genauer zu beleuchten.
Eine kurze Einführung in das Signal-Handling
Signale
Um zu verstehen was Signal-Handler sind, müssen wir uns erstmal anschauen, was genau ein Signal ist. Im Allgemeinen versteht man in einem Unix-ähnlichen Betriebssystem unter einem Signal eine Mitteilung an einen Prozess, die diesen über ein Ereignis informiert, das ihn betrifft. Zum Beispiel kann ein Benutzer, der gerade ein Konsolen-Programm benutzt, diesem mitteilen, dass es beendet werden soll, indem er Strg+C drückt. Dadurch wird das Signal SIGINT an den angegebenen (in diesem Fall den aktuellen) Prozess geschickt.
Es existieren in jedem Unix-ähnlichen Betriebssystem verschiedenste Signale für verschiedenste Ereignisse. Die wichtigsten davon sind: SIGINT, SIGHUP, SIGKILL, SIGABRT, SIGTERM und SIGPIPE. Es gibt noch viele weitere, die man unter Linux der Datei /usr/include/asm/signal.h entnehmen kann. Im folgenden eine Aufstellung aller Signale, die in Linux genutzt werden (können):
Signale von Linux
|
POSIX.1 definiert dabei folgende Signal
|
Wenn ein Prozess eines der oben aufgeführten Signale empfängt, wird die Default-Aktion dieses Signals (Spalte "Aktion") ausgeführt. Trotzdem ist das nicht immer die Aktion, mit der der Prozess reagiert. Wie ein Programm auf die verschiedenen Signale reagieren soll, kann der Programmierer des Programms selbst festlegen. Dazu muss er lediglich einen entsprechenden Signal-Handler in seinem Programm implementieren. Allerdings ist es nicht möglich Signal-Handler für die Signale SIGKILL und SIGSTOP zu implementieren, damit ein Benutzer und/oder das System immer die Möglichkeit hat einen Prozess zu beenden.
Signal-Handler
Nun mag sich der/die ein/e oder andere, der/die noch nie mit Signal-Handlern zu tun hatte, fragen, was denn Signal-Handler überhaupt sind. Um es in einem Satz auszudrücken: Signal-Handler sind (mehr oder weniger) kleine Routinen, die von einem Programm ausgeführt werden, wenn dieses ein bestimmtes Signal vom System empfängt. In C implementiert man diese, indem man die auszuführenden Funktionalitäten, die das Programm beim Empfang eines bestimmten Signals ausführen soll, als normale Funktionen implementiert und diese dann als Handler-Funktionen für ein Signal implementiert. Schauen wir uns ein einfaches Beispiel mit einem Signal-Handler für SIGINT an:
#include <stdio.h>
#include <signal.h>
void signalhandler()
{
printf("Ctrl-C wurde gedrueckt!\n");
exit(0);
}
int main()
{
signal(SIGINT, signalhandler);
while(1)
sleep(1);
/* should never reach here */
return(0);
}
Um Signal-Handler in C implementieren zu können, müssen wir die Datei signal.h includen. Diese stellt uns die Funktion signal(2) zur Verfügung. Als Parameter bekommt sie die Signal-Nummer, wie sie in /usr/include/asm/signal.h definiert ist, und den Funktionsnamen des Signalhandlers (Typendefinition 'typedef void (*sighandler_t)(int)') übergeben. In unserem Beispiel ist das die Funktion signalhandler(). Wenn wir dieses Programm nun in der Datei signalhandler.c speichern und mit
gcc -o signalhandler signalhandler.c
kompilieren, haben wir ein einfaches kleines Programm mit einem Signal-Handler. Wenn wir dieses ausführen und mit Strg+C beenden, bekommen wir noch eine Meldung ausgegeben, bevor das Programm beendet wird. Normalerweise würde man einen Signal-Handler für SIGINT sicherlich eher nutzen um Aufrämarbeiten, Datensicherungen und Logging durchzuführen, aber um einen Einblick in die Implementierung eines Signal-Handlers zu geben, sollte dieses kleine Beispiel eigentlich ausreichen.
Als Programmierer sollte man wissen, wie man einen Signal-Handler testen kann. Dazu können wir uns das Programm 'kill' zunutze machen. 'kill' ermöglicht es, einem Programm jedes beliebige Signal zu schicken. Dazu benutzen wir einfach folgende Syntax:
kill -<signalnummer> <PID>Schauen wir uns auch das an einem kleinen Beispiel an. Wir schreiben unsere signalhandler.c einfach so um, dass signal() als ersten Parameter nicht mehr SIGINT bekommt, sondern SIGUSR1. SIGUSR1 ist mit der Signal-Nummer 10 in /usr/include/asm/signal.h definiert. Wir kompilieren unser Programm wieder und starten es. Danach öffnen wir ein zweites Terminal und schauen mit
ps axnach welche PID unser Prozess hat. Dann schicken wir ihm mit
kill -10 <PID-von-signalhandler>das SIGUSR1-Signal. Wie wir sehen, bekommen wir jetzt unsere Meldung ausgegeben und das Programm wird beendet (durch den Aufruf von exit()). Auf diese Art können wir jedes beliebige Signal an ein Programm schicken und damit die entsprechenden Signal-Handler auslösen, sofern für das entsprechende Signal ein Handler implementiert ist.
Egal wie einfach oder komplex ein Signal-Handler ist, so birgt deren Programmierung doch einige Sicherheitsrisiken, die der Programmierer beachten sollte. Es gibt vor allem zwei Situationen in einem Signal-Handler, die grundsätzlich vermieden werden sollten, da sie potentielle Risiken für die Systemsicherheit beinhalten. Diese beiden Situationen sind:
- nicht-atomare Prozess-Modifikationen
- Code, der im Programm-Ablauf nur einmal genutzt werden kann, aber mehrfach ansprechbar ist (wiederbetretbarer Code)
Nicht-atomare Modifikationen
Da Signale zu jedem beliebigen Zeitpunkt im Programm-Ablauf auftreten können und manchmal dabei aus verschiedensten Gründen (z.B. den Zugriff auf Raw-Sockets, Hardware-Ressourcen u.ä.) die Rechte beachtet werden müssen (wie z.B. root-Rechte in SUID-root-Programmen), sollten Signal-Handler mit besonderer Vorsicht benutzt und programmiert werden. Wenn man das nicht tut, kann schnell eine Sicherheitslücke entstehen, wenn ein Signal zu einem Zeitpunkt auftritt, an dem das Programm gerade besondere Rechte hat. Was hier mit "nicht-atomar" gemeint ist, sind Prozesse, die nur bei bestimmten Situationen bestimmten Modifikationen (wie z.B. dem Ändern der Rechte) unterliegen. Diese Änderungen sind nicht permanent und werden normalerweise im Programm-Ablauf wieder rückgängig gemacht. Am einfachsten ist das aber auch wieder an einem Beispiel zu erklären. Schauen wir uns dazu ein kleines (unsicheres) Programm an:
#include <stdio.h>
#include <signal.h>
void sighndlr() {
printf("Strg+C wurde gedrueckt!\n");
printf("UID beim SIGINT: %d\n", getuid());
}
int showuid() {
printf("UID: %d\n", getuid());
return(0);
}
int main() {
int origuid = getuid();
signal(SIGINT, sighndlr);
setuid(0);
sleep(5);
setuid(origuid);
showuid();
return(0);
}
Jeder erfahrene Programmierer sollte in diesem Programm eigentlich sofort die Sicherheitsrisiken entdecken, aber leider sind nur die wenigsten mit Signal-Handlern vertraut, so dass solche Sicherheitsprobleme in Programmen oft übersehen werden. Wie wir hier sehen können, ist ein Signal-Handler für SIGINT deklariert. Das Programm gibt sich selbst root-Rechte (sofern möglich) und nach einer Wartezeit von 5 Sekunden werden die Rechte wieder zurückgesetzt und das Programm wird beendet. Wird jedoch die Programm-Ausführung durch ein SIGINT unterbrochen, wird der Signal-Handler sighndlr() aufgerufen. Und hier liegt das Problem dieses Programms. Schauen wir uns das ganze einfach mal an. Wir speichern diesen Code in der Datei nonatomicvuln.c und machen uns mit gcc -o nonatomicvuln nonatomicvuln.cein kleines Programm daraus. Schauen wir uns nun ein wenig Beispiel-Output des Programms an. Zuerst einmal lassen wir das Programm ohne Einflussnahme durchlaufen:
theton@BigTAPS:~/src/hacking> ./nonatomicvuln UID: 1000 theton@BigTAPS:~/src/hacking>
Wir sehen hier, dass das Programm beim Beenden die Rechte des Benutzers mit der UID 1000 hatte. Nun unterbrechen wir das Programm aber mit Strg+C in seinem Ablauf und lösen damit den Signal-Handler aus:
theton@BigTAPS:~/src/hacking> ./nonatomicvuln Strg+C wurde gedrueckt! UID beim SIGINT: 1000 UID: 1000 theton@BigTAPS:~/src/hacking>
Auch das stellt noch weiter kein Problem dar. Das Problem beginnt erst, wenn wir dem Programm ein SUID-Flag verpassen und den Eigentümer auf root setzen, kurz gesagt: wir machen ein SUID-root-Programm daraus. Nun starten wir das Programm nochmal und unterbrechen es wiederum mit Strg+C:
theton@BigTAPS:~/src/hacking> ./nonatomicvuln Strg+C wurde gedrueckt! UID beim SIGINT: 0 UID: 1000 theton@BigTAPS:~/src/hacking>
Wem bisher nicht klar war, wo hier ein Sicherheitsproblem vorliegen sollte, dem sollte spätestens dieser Output zu denken geben. Da die Signal-Handler-Routine aufgerufen wurde, als das Programm root-Rechte hatte, wurde auch diese mit root-Rechten ausgeführt. Würden wir hier nun mit unsicheren Funktionen arbeiten (z.B. String-Handling-Funktionen aus der glibc) und diese nicht ausreichend absichern, wäre es möglich darin einen Overflow auszulösen und mit einem Shellcode an root-Rechte zu kommen.
Ich denke, dass hiermit die Gefahr von nicht-atomarem Prozess-Modifikationen in Verbindung mit Signal-Handlern klar geworden sein sollte. Wenden wir uns nun also der zweiten grossen Gefahr in Verbindung mit Signal-Handlern zu.
Wiederbetretbarer Code
Einige Funktionen der glibc wurden nicht so implementiert, dass man sie nach dem Auftreten eines Signal wiederbenutzen könnte.
free()
Ein typisches Beispiel dafür ist free(). Aus der Manpage von free():
free() frees the memory space pointed to by ptr, which must have been
returned by a previous call to malloc(), calloc() or realloc(). Other
wise, or if free(ptr) has already been called before, undefined
behaviour occurs. If ptr is NULL, no operation is performed.
Wie uns die Manpage ja sagt, kann free() nur auf auf Speicher angewendet werden, der vorher mit malloc() reserviert wurde, da sonst ein "nicht vorhersehbares Verhalten" auftritt. Durch diesen Design-Fehler sind Signal-Handler-Routinen, die free() benutzen angreifbar. Schauen wir uns das am Besten wieder am Beispiel eines kleinen Programms an.
#include <stdio.h>
#include <signal.h>
#include <syslog.h>
#include <string.h>
#include <stdlib.h>
void *data1, *data2;
char *logdata;
void sighdlr()
{
printf("sighdlr() aufgerufen...\n");
syslog(LOG_NOTICE,"%s\n", logdata);
free(data2);
free(data1);
sleep(10);
exit(0);
}
int main(int argc, char *argv[])
{
logdata = argv[1];
data1 = strdup(argv[2]);
data2 = malloc(340);
signal(SIGHUP, sighdlr);
signal(SIGTERM, sighdlr);
sleep(10);
return(0);
}
Dieses Programm definiert einen Signal-Handler für SIGHUP und SIGTERM, der allozierten Speicher wieder freigibt, bevor das Programm beendet wird. Während der Signal-Handler läuft, werden aber andere Signale nicht blockiert, so dass wir diesem Prozess auch noch das zweite Signal schicken können, wodurch der Signal-Handler nochmal aufgerufen wird. In dieser Situation werden also die beiden free()-Funktionen doppelt ausgeführt und wie wir aus der Manpage wissen, kommt es dabei zu einem nicht-vorhersehbaren Verhalten. Wir würden also damit das Datensegment des Programms zerstören und das Programm zum Absturz bringen. Wie wir sehen können, wird zusätzlich Input entgegen genommen und auch syslog() wird durch den Signal-Handler aufgerufen. Aber was macht syslog() eigentlich?
syslog()
Uns muss erstmal nur das Speicherverhalten von syslog() interessieren. Wenn wir uns dieses genauer ansehen, sehen wir, dass syslog() einen Buffer im Speicher anlegt, wobei zwei malloc()-Aufrufe benutzt werden. Das erste malloc() alloziert Speicher für eine "Stream-Beschreibungs-Struktur", während das zweite malloc() Speicher für die syslog-Nachricht reserviert. Das wird benutzt, damit syslog() auf einer temporären Kopie der Nachricht arbeiten kann.
Das Problem, das hier mit wiederbetretbarem Code in Verbindung mit syslog() liegt, mag nicht sofort offensichtlich sein, aber ich werde versuchen es zu erlätern. Versuchen wir doch einfach in der Theorie ein Angriffs-Szenario für unser Beispielprogramm zu erstellen. Wir speichern es also in der Datei reentryvuln.c und kompilieren es mitgcc -o reentryvuln reentryvuln.c
Nun wollen wir mal ein wenig mit den Parametern experimentieren.
theton@BigTAPS:~/src/hacking> ./reentryvuln `perl -e 'print "a"x100'` `perl -e 'print "b"x410'` & sleep 1 ; \ killall -HUP reentryvuln ; sleep 1 ; killall -TERM reentryvuln [4] 19693 [3] Aborted ./reentryvuln `perl -e 'print "a"x100'` `perl -e 'print "b"x410'` sighdlr() aufgerufen... sighdlr() aufgerufen... *** glibc detected *** double free or corruption (out): 0x0804a1a8 *** theton@BigTAPS:~/src/hacking>
Das wäre der Output mit einem Linux, dessen glibc gegen diese Form von doppeltem Speicherzugriff abgesichert ist. In diesem Fall ein SuSE 9.3. Anders sieht das schon auf einem System aus, das noch nicht dagegen abgesichert wurde. Schauen wir uns das Ganze also auch mal auf einem Debian an:
bitmuncher@TAPS:/schrank/hacking$ ./reentryvuln `perl -e 'print "a"x100'` `perl -e 'print "b"x410'` & sleep 1 ; \ killall -HUP reentryvuln ; sleep 1 ; killall -TERM reentryvuln [1] 13966 sighdlr() aufgerufen... sighdlr() aufgerufen... [1]+ Speicherzugriffsfehler (core dumped) ./reentryvuln `perl -e 'print "a"x100'` `perl -e 'print "b"x410'` bitmuncher@TAPS:/schrank/hacking$
Hier kommt es zu einem Speicherzugriffsfehler, den wir uns mit 'gdb' mal etwas genauer anschauen wollen.
bitmuncher@TAPS:/schrank/hacking$ gdb -c core GNU gdb 6.3-debian Copyright 2004 Free Software Foundation, Inc. GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type "show copying" to see the conditions. There is absolutely no warranty for GDB. Type "show warranty" for details. This GDB was configured as "i386-linux". Using host libthread_db library "/lib/tls/libthread_db.so.1". (no debugging symbols found) Core was generated by `./reentryvuln aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'. Program terminated with signal 11, Segmentation fault. #0 0x40093e84 in ?? () (gdb) info reg eax 0x69660300 1768293120 ecx 0x804a1a0 134521248 edx 0x61616161 1633771873 ebx 0x40152f40 1075130176 esp 0xbfffeec8 0xbfffeec8 ebp 0xbfffeef8 0xbfffeef8 esi 0x61616160 1633771872 edi 0x804a1a8 134521256 eip 0x40093e84 0x40093e84 eflags 0x10246 66118 cs 0x73 115 ss 0x7b 123 ds 0x7b 123 es 0x7b 123 fs 0x0 0 gs 0x33 51 (gdb)
Wie wir sehen können, hat unser langer String mit den vielen 'a' seinen Weg in den EDX- und den ESI-Register gefunden. Das muss also der nicht-vorhersehbare Zustand sein, von dem in der Manpage von free() die Rede war. Ich will versuchen zu erklären, was hier passiert ist, wozu wir uns einfach den Programm-Ablauf mal etwas genauer anschauen.
Beim Eintreffen des ersten Signals (SIGHUP) wird der Signal-Handler aufgerufen, was noch keine weiteren Probleme verursacht. syslog() nutzt nun diesen Speicher (*data2) um temporär seine syslog-Nachricht darin abzulegen. Nun trifft aber das zweite Signal ein und ruft wieder ein free() auf dem gleichen Speicherbereich auf, der zuerst von *data2 belegt war, nun aber die temporär gespeicherte Nachricht von syslog() enthält, wodurch es also direkt auf den Speicher von syslog() zugreift.
Wie wir am Output von GDB sehen können, haben wir also eine gewisse Kontrolle über die Register des Programms, wenn dieser "nicht-definierbare Zustand" auftritt. Daher ist es in solchen Situationen meist möglich das Programm für einen Angriff auszunutzen. Ein geschickt plaziertes Argument könnte hier Werte in den Registern platzieren, die dafür sorgen, dass die Programm-Ausführung beeinflusst wird u.ä..
Da ein Exploit für sylog() in Verbindung mit free() aber zu Distributions- und Hardware-spezifisch wäre, werde ich an dieser Stelle darauf nicht weiter eingehen. Ich denke, dass die Gefahr von wiederbetretbarem Code in Verbindung mit Funktionen, die nur einmal im Programmablauf aufgerufen werden dürfen, bis hierhin deutlich geworden ist.Die Wirkungsweise eines solchen Exploits könnte in der Theorie z.B. wie folgt aussehen:
- verursache (wie oben gezeigt) einen Speicherzugriffsfehler und benutze dabei ein Argument, das die Register mit Werten belegt, die die Programm-Ausführung in einem anderen Speicherbereich fortsetzt
- platziere an dieser Stelle einen Shellcode, der eine root-Shell öffnet
- ist das Programm ein SUID-root-Programm, bekommen wir nach mehr oder weniger vielen Versuchen eine root-Shell
Oft sind noch weitere Schritte notwendig um solch' ein Exploit zum Erfolg zu führen, da die Belegung der verschiedenen Register manchmal in mehreren Schritten ablaufen muss. Weiterhin besteht immer das Problem, das oft Register belegt werden, die erstmal nicht brauchbar scheinen um den Programm-Ablauf zu beeinflussen, aber bei genauerem Hinsehen zeigt sich oft, dass sie die Übergabe von Parametern an System-Funktionen erlauben, mit denen eine indirekte Beeinflussung des Programms dennoch möglich wird.
Sichere Signal-Handler programmieren
Generell kann man sagen, dass es drei Grundregeln gibt um sichere Signal-Handler zu programmieren:
- Benutze nur Funktionen aus der glibc, die man problemlos wiederverwenden kann. Da dies aber nicht immer möglich ist, z.B. wenn man reservierten Speicher wieder freigeben will/muss, wenn ein bestimmtes Signal auftritt, sollte man dann zur zweiten Grundregel greifen.
- Blocke andere Signale, während ein Signal-Handler gerade aktiv ist. Das ist ziemlich einfach zu erreichen, indem man einfach entsprechende SIG_IGN in den Signal-Handler einbaut, wie es im folgenden Beispiel gemacht wurde:
void sighdlr() {
/* andere Signale blockieren */
signal(SIGINT, SIG_IGN);
signal(SIGABRT, SIG_IGN);
signal(SIGHUP, SIG_IGN);
/* hier kommt der eigentliche Signal-Handler rein */
exit(0);
}
Wie wir hier sehen können, werden Signale geblockt, bevor irgendetwas anderes im Signal-Handler gemacht wird. Das verhindert, dass der Signal-Handler mehrfach ausgefüht wird.
- Ignoriere Signale solange nicht-atomare Prozess-Modifikationen aktiv sind. Die Vorgehensweise ist hierbei ähnlich wie beim Blocken von Signalen während ein Signal-Handler aktiv ist. Ein Beispiel dazu könnte wie folgt aussehen:
/* hier beginnt der Code, der nicht-atomare Prozessmodifkationen macht */ /* blocke alle unerwuenschten Signale */ signal(SIGINT, SIG_IGN); signal(SIGABRT, SIG_IGN); signal(SIGHUP, SIG_IGN); setuid(0); /* der kritische Code kommt hier hin */ setuid(getuid()); // und hier endet der kritische Code /* setze die Signale wieder zurueck auf ihre Default-Aktion */ signal(SIGINT, SIG_DFL); signal(SIGABRT, SIG_DFL); signal(SIGHUP, SIG_DFL); /* ...weiterer Code... */
Bevor hier privilegierter Code ausgeführt wird, werden Signale geblockt. Nachdem das Programm wieder seine ursprünglichen Rechte zurück hat, werden die Signale einfach wieder "freigegeben". Damit vermeiden wir, dass ein Programm einen Signal-Handler aufruft, während es gerade root-Rechte hat.
Sicherlich gibt es weitere Wege um sichere Signal-Handler zu programmieren, aber diese 3 Wege sollten ausreichend sein, um die typischen Angriffstechniken auf Signal-Handler zu unterbinden. Ich hoffe, dass ich mit diesem Paper die Gefahr, die in unsicherer Programmierung von Signal-Handlern liegt, etwas deutlicher machen konnte. Bei Fragen, die dieses Dokument und/oder dieses Thema betreffen, kann man mich gern per Email an bitmuncher(at)bitmuncher(dot)de kontaktieren.

