Seminar: Softwarebasierte Fehlertoleranz Thema: Software-basierte Absicherung von dynamisch genutztem Speicher Verfasser: B.Sc. Gregor Kotainy Veranstalter: Prof. Dr.-Ing. Olaf Spinczyk Betreuer: Dipl.-Inf. Christoph Borchert Technische Universität Dortmund Lehrstuhl für Eingebettete Systeme Zusammenfassung In der Ausarbeitung werden zwei Laufzeitumgebungen vorgestellt, die unsichere Programmiersprachen wie C und C++ durch randomisierte und redundante Speicherverwaltung probabilistisch robuster und fehlertoleranter machen sollen. Dazu wird zunächst anschaulich auf die Problematik hingewiesen, wie es zu Speicherinkonsistenz kommt und an welchen Stellen, zu welchen Kosten, angesetzt werden kann, um diese zu vermeiden. Außerdem wird ein Modell besprochen, welches sowohl in Hardware als auch in Software implementiert werden kann und dem Programmierer die Möglichkeit gibt, kritische Daten – jene, die für den Programmzustand sowie den Kontrollfluss essenziell sind – speziell zu kennzeichnen, die besonders geschützt werden sollen. Um das Thema abzuschließen werden Formeln zu FehlermaskierungsWahrscheinlichkeiten aufgeführt und Laufzeit-Tests zitiert, die auf bekannten Anwendungen durchgeführt wurden. Tags: DieHard, Critical Memory, Samurai, Heap, Dynamischer Speicher, Absicherung, Randomisierung, Redundanz, Speicherfehler, Fehlertoleranz, Fehlerbehebung, Programmzustand, Replikation, Programmwiederherstellung, Unsichere Sprachen, Typsicherheit 1 Einleitung Die Welt der eingebetteten Systeme, der hardwarenahen Programmierung ist angewiesen auf Programmiersprachen wie C und C++. Diese reicht von kleinen Systemen, die mit wenig Hauptspeicher auskommen und in wenigen Kilobytes den gesamten Programmcode unterbringen müssen, sodass diese unter Realzeitbedingungen Hardwareschranken einhalten können bis hin zu großen Softwareprojekten, die vom Geschwindigkeitsvorteil dieser Sprachen Gebrauch machen können. Dieser Geschwindigkeitsvorteil bringt aber auch gewisse Nachteile mit sich. In dem einen oder anderen Fall möchte man zum Beispiel beim Einsatz von Arrays eine Gültigkeitsprüfung haben, die verhindert außerhalb der ArrayGrenzen in einen fremden Speicherbereich zu schreiben und damit Metadaten eines anderen Objektes zu überschreiben. Viele dieser Gefahren können dazu 2 Software-basierte Absicherung von dynamisch genutztem Speicher führen, dass das Programm ein unvorhersagbares Verhalten aufweist oder gar abstürzt. Aus solchen Gründen muss der Programmierer abwägen, ob er die Risiken in Kauf nimmt oder lieber eine typsichere Sprache einsetzt, die statisch vom Compiler auf Typfehler bzw. korrekte Zuweisungen und dynamisch zur Laufzeit überprüft wird. Als kleine Motivation für den Einsatz von C und C++ spricht die Tabelle 1, aus der hervorgeht, dass diese Sprachen weiterhin sehr aktuell sind und einen Großteil ausmachen. Der Index zeigt weiterhin, dass sich Sprachen wie Java sehr beliebt gemacht haben. Ein Grund für diese gesteigerte Popularität Pos Feb 2013 Pos Feb 2012 Zuwachs Programmiersprache Rating 1 2 3 4 5 6 7 8 9 10 11 12 13 .. . 1 2 5 4 3 6 8 7 9 12 10 16 13 .. . +1.34% +0.56% +2.74% +0.91% −1.97% −0.57% +1.80% +0.33% −0.68% +0.19% −1.04% +0.21% +0.04% .. . Java C Objective-C C++ C# PHP Python (Visual) Basic Perl Ruby JavaScript Visual Basic .NET Lisp .. . 18.387% 17.080% 9.803% 8.758% 6.680% 5.074% 4.949% 4.648% 2.252% 1.752% 1.423% 1.007% 0.943% .. . Tabelle 1. Ein Ausschnitt über die populärsten Programmiersprachen im Februar 2013. Laut Datenerhebung [3] können C und C++ die Spitzenplätze 2 und 4 behaupten und sind damit zum Vorjahr konstant. Die Datenerhebung basiert auf anteiligen Ergebnissen diverser Suchmaschinentreffer. Der genaue Suchbegriff und die prozentuale Gewichtung kann aus [3] entnommen werden. ist der Großteil auf dem Markt erhältlicher Smartphones, die Google-Android basiert sind und primär in einer angepassten Java-Version entwickelt wurden. Diese Smartphones sind aber nicht rein in Java geschrieben. Damit Java auf diesen Maschinen lauffähig ist wurde das Grundsystem zunächst Unix-ähnlich in C und C++ geschrieben und die Java VM darauf ausgeführt. Und genau hier gelangt man zur Ausgangsproblematik zurück. Es kann zwar auf einen Großteil von Bibliotheken zurückgegriffen werden, die aber ihrerseits auch Fehler beinhalten können wodurch fremder Code den eigenen allozierten Heap-Speicher irreparabel schädigen kann. Software-basierte Absicherung von dynamisch genutztem Speicher 3 Im Grundlagenteil dieser Ausarbeitung werden die Fehlerquellen genauer durchleuchtet und Werkzeuge genannt, die dem Programmierer dabei helfen sollen, jene zu vermeiden oder zu reduzieren. Weiterhin werden im Hauptteil zwei Laufzeitumgebungen vorgestellt, die zu Kosten weiteren Speichers bzw. CPUZeit Fehler erkennen und tolerieren sollen. Abschließend werden Formeln zu Fehlermaskierungs-Wahrscheinlichkeiten aufgeführt und Laufzeiten von bekannten Programmen diskutiert. 2 Grundlagen Die Programmierung in C und C++ erfordert ein großes Maß an Verständnis und Programmiererfahrung, um den Schwierigkeiten entgegenzuwirken. In diesem Grundlagenteil werden die Fehler aufgezeigt, die durch falsche Verwendung der zur Verfügung stehenden Sprachmittel entstehen und zu Inkonsistenzen im dynamisch allozierten Speicher führen. In [1] werden die möglichen Fehler in sechs Kategorien unterteilt. 2.1 Hängende Zeiger Hängende Zeiger entstehen häufig nach dem Freigeben bereits genutzter allozierter Objekte, die weiterhin auf genau dieselbe Speicherzelle zeigen, an der vorher das Objekt existiert hat. Um Fehler im weiteren Programmfluss zu vermeiden sollte den Zeigern direkt nach deren Verwendung NULL zugewiesen werden. Weil das oft vergessen wird und ein Zeiger in komplexen Algorithmen wiederverwendet werden kann, besteht die Gefahr, dass zwischenzeitlich ein neues Objekt an dieser Stelle im Speicher erstellt wurde. Sollte über den hängenden Zeiger nun versehentlich etwas geschrieben werden, wird damit das neue Objekt fehlerbehaftet. In der Abbildung 1 ist ein Minimalbeispiel gezeigt, bei dem durch Unachtsamkeit des Programmierers ein hängender Zeiger den Wert einer anderen Speicherzelle beeinflusst und ein Fehler unbemerkt bleibt. Zunächst wird der DEBUG-Modus in der ersten Zeile aktiviert. Danach werden in den Code-Zeilen 7 und 8 zwei Zeiger erstellt und dynamisch Speicher auf dem Heap alloziert, sodass ein einelementiger Integer Wert hinein passt. Diese Zellen erhalten dann die Werte 111 und 222. in der 13. Code-Zeile wird der allozierte Speicherplatz für den ersten Zeiger wieder freigegeben. Da der DEBUG-Modus aktiv ist, wird der Zeiger nicht auf NULL gesetzt und die Adresse weiterhin referenziert. Die Code-Zeile 17 soll lediglich einen Fehler illustrieren, bei dem der Zeiger weiter verwendet wird und sich in der zweiten Speicherzelle somit eine 333 als Wert einstellt, was die printf -Ausgabe in Code-Zeile 19 bestätigt. Richtigerweise müsste die Zeiger-Arithmetik mit NULL zum Segmentation fault führen, wodurch der Fehler entdeckt werden würde. Da allerdings die Adresse für den ersten Zeiger bestehen bleibt, kann eine Integer-Breite weitergesprungen werden. 4 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 Software-basierte Absicherung von dynamisch genutztem Speicher #define DEBUG true #include <stdio.h> #include <stdlib.h> int main() { int *p1 = (int *) malloc ( sizeof ( int ) ); int *p2 = (int *) malloc ( sizeof ( int ) ); *p1 = 111; *p2 = 222; free ( p1 ); if ( !DEBUG ) p1 = NULL; *( p1 + sizeof ( int ) ) = 333; printf ( "%d\n", *p2 ); free ( p2 ); p2 = NULL; return 0; } Abbildung 1. Der in Code-Zeile 1 aktivierte DEBUG-Modus wurde vergessen zu deaktivieren. Dadurch wird die Code-Zeile 15 nicht länger ausgeführt und p1 zum hängenden Zeiger. In der Code-Zeile 17 ist ein unentdeckter Fehler, der den Wert einer anderen Speicherzelle überschreibt. Software-basierte Absicherung von dynamisch genutztem Speicher 2.2 5 Pufferüberläufe Durch zu gering gewählte Puffer oder nicht abgeprüfte Grenzen kann es zu Pufferüberläufen kommen. Diese lassen sich ausnutzen, um dahinter liegende Speicherbereiche zu manipulieren. In Abbildung 2 wird dies anhand eines Pufferüberlaufs auf dem Stack gezeigt. In diesem Beispiel ist der Überlauf vielleicht noch nicht tragisch, allerdings lassen sich weitaus komplexere Möglichkeiten ausschöpfen. Ein mögliches Szenario wäre unsichere Funktionen wie strcpy, cin, get 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include <stdio.h> #include <stdlib.h> int main() { int array[3], value; value = 830; for ( int i = 0; i <= 3; i++ ) array[i] = i * i; printf("%d\n", value); return 0; } Abbildung 2. Das Integer-Array ist ein 3-Element-Puffer. In der 10. Code-Zeile wird die Bedingung nicht richtig abgeprüft und auf das 4 Element zugegriffen. Der reservierte Puffer ist zu klein und es kommt zum Überlauf. Dadurch wird die Variable value angesprochen und überschrieben. Aus 830 wird 9 als neuer Wert angenommen. und andere so zu exploiten, dass die Rücksprungadresse auf dem Stack manipuliert wird und beliebiger Code ausgeführt werden kann. Moderne Compiler setzen zwischen der Rücksprungadresse einen Zufallswert ein, der sich mit jedem Programmstart divergent einstellt um solche Angriffe abzuwehren. Wenn dieser Wert manipuliert wurde, dann mit großer Wahrscheinlichkeit auch die Rücksprungadresse. 2.3 Heap-Überläufe (Metadaten-Korruption) Ein Ansatz, den Freispeicher der Heaps zu verwalten ist die Verwendung von verketteten Listen. Benachbarter Freispeicher wird zu einem größeren vereint. Daraus folgt, dass es keine zwei angrenzenden freien Speicherbereiche gibt. Allozierte Speicherblöcke können anhand deren Größe navigiert werden [5]. Die Verwaltung eines malloc-Blocks in Programmiersprache C ist in Abbildung 3 6 Software-basierte Absicherung von dynamisch genutztem Speicher 1 struct malloc_chunk 2 { 3 INTERNAL_SIZE_T prev_size; 4 INTERNAL_SIZE_T size; 5 struct malloc_chunk *fd; 6 struct malloc_chunk *bk; 7 } Abbildung 3. Aufbau eines Malloc-Blocks entnommen aus [5] dargestellt. Die Metadaten enthalten neben der eigenen Größe des freien Speichers auch die Größe des vorangehenden und Zeiger auf den Vorgänger sowie Nachfolger. Die allozierten Blöcke enthalten analog die Größe des vorangehenden allozierten Blocks, die eigene Größe, diverse Flags (z.B. für mutual exclusion und die eigentlichen Benutzerdaten. Sollten Überläufe die Metadaten beschädigen, kann es zu undefinierten Programmverhalten oder Abstürzen kommen. Falls sich ausführbare Daten im Speicher befinden, können auch diese überschrieben werden. 2.4 Nicht initialisierte Lesevorgänge Uninitialisierte Lesevorgänge die nicht frühzeitig entdeckt werden führen oft zu unbestimmten Programmverhalten. Sollten diese Lesevorgänge den Kontrollfluss beeinflussen, kann das fatale Folgen haben. Aus diesem Grund würde man erwarten, dass das Programm bei einem Versuch eine uninitialisierte Variable zu lesen mit einer Fehlermeldung abbricht, was nicht standardmäßig der Fall ist. Es ist nicht so einfach diesen Fall abzuhandeln, da als Gegenmaßnahme zum Beispiel der Speicher mit einem bestimmten Wert vorinitialisiert sein müsste. Bei Lesen würde man erkennen, dass an der Adresse dieser Wert steht, das das fehlenden Initialisieren implizieren würde. Komplizierter wird es, wenn man genau diesen Wert speichern wollte. Eine praktikable Lösung wird im Hauptteil erläutert. 2.5 Ungültige Freigaben von Speicher Ein weiterer Fehler sind ungültige Freigaben von Speicher. Sollte der Speicher belegt sein, werden die Metadaten wie in Untersektion 2.3 beschrieben angelegt. Wird free mit einer Adresse aufgerufen, die nicht auf den Anfang eines belegten Speichers, sondern auf eine beliebige Adresse in den Nutzdaten zeigt, sind daraufhin zum einen die Nutzdaten defekt und zum anderen existiert je nach Implementierung der darauffolgende Bereich zweimal, da sich nun Freispeicherliste mit belegten Daten überschneiden. Wenn diese Daten dann wieder beschrieben werden, gehen Zeiger zu weiteren Freispeicherblöcken verloren. Der Vorgänger Software-basierte Absicherung von dynamisch genutztem Speicher 7 und Nachfolger zeigen dann nicht auf einen Freispeicherblock, sondern auf Nutzdaten. Spätestens bei nächster Allokation und der Suche nach freien Speicher wird das Programm abstürzen. 2.6 Doppelte Freigaben von Speicher Doppelte Freigaben sollten von den meisten Allokatoren erkannt werden, aber es gibt auch hier ausnahmen. In [4] wird beschrieben, dass die Möglichkeit besteht, dass ein Speicherblock, der bereits freigegeben wurde, bei erneuter Freigabe doppelt in die Freispeicherliste eingehängt werden könnte. Das spätere allozieren würde den Speicher zweimal für die Benutzung bereitstellen. Die meisten Speicherverwalter-Bibliotheken (Windows und GNU libc Implementierungen) wurden korrigiert, doch es gibt immer noch Betriebssysteme, in denen dieser Fehler existiert. 2.7 Werkzeugeunterstützung In [1] werden die Werkzeuge Purify und Valgrind genannt, mit deren Hilfe es möglich sei – zu Kosten eines 2-25 mal so großen Speicherlecks – Speicherfehler zur Laufzeit während des Testvorgangs zu erkennen. Das bedeutet aber, dass diese Fehler nur dann erkannt werden, wenn sie auch tatsächlich eintreffen. Außerdem könne ein Programmierer einen sicheren C-Compile verwenden, der diese dynamischen Speicherchecks einfügt. 3 Hauptteil Die im Grundlagenteil aufgezeigten Speicherprobleme würden nicht zutreffen, wenn ein unendlich großer Speicher – wie ein Bandlaufwerk unendlicher Länge – zur Verfügung stünde [1]. Bei einem solchen Speicher wäre es nicht notwendig Objekte zu zerstören und Speicher freizugeben. Beim Freigeben würde man lediglich den Verweis löschen und einen neuen Speicherbereich reservieren. Man könne Objekte unendlich weit voneinander im Speicher ablegen, sodass auch Überläufe kein Problem darstellen würden, denn sie würden den Speicherblock des darauf folgenden Objektes nie erreichen. Eine solche Implementierung ist nicht möglich, weshalb nun zwei Laufzeitumgebungen vorgestellt werden, die eine Fehlererkennung integriert haben und Fehler mit hoher Wahrscheinlichkeit tolerieren können. 3.1 Laufzeitumgebung DieHard Erstmals erschien DieHard [1] im Jahre 2006. Aktuell existieren weitaus aktuellere Paper, die das ursprüngliche Konzept erweitern. Da auch die neuere Laufzeitumgebung DieHarder [6] auf DieHard aufbaut, können die grundlegenden Gedanken vermittelt werden. Die Motivation für die Entwicklung sind 8 Software-basierte Absicherung von dynamisch genutztem Speicher sicher ausgeführte Programm. Diese sind in Bezug auf Speicherzugriffe sicher, wenn sie ... – keinen uninitialisierten Speicher lesen, – keine invaliden oder doppelten free-Aufrufe machen und – nicht auf uninitialisierten Speicher zugreifen. Ein wichtiger Hinweis an dieser Stelle: Ein sicherer C Compiler, der dynamische Checks an den nötigen Stellen hinzufügt bricht das Programm ab. Es geht hier um eine Laufzeitumgebung, die diese Fehler erkennen und tolerieren soll. Wichtig ist das zum Beispiel auch für den Fall externer Bibliotheken, deren Sourcecode nicht vorhanden ist. Diese Module können aber auch Fehler enthalten und so zum Beispiel eigenen Programmspeicher zerstören. DieHard verspricht mit hoher Wahrscheinlichkeit, dass es Speicherfehler erkennen und automatisch tolerieren kann, indem es den Standard-Speicherverwalter ersetzt. Die Umgebung kann als alleinstehende Version oder mit mehrfachen, simultanen Ausführungen des Programms gestartet werden. Hierbei soll die alleinstehende Variante wesentlichen Schutz vor Speicherfehlern bieten und den Allokations-Mechanismus implementieren. Die Mehrfachausführung soll den Schutz erhöhen und ungültige Lesevorgänge auf nicht initialisiertem Speicher erkennen können. Alleinstehend: Bei diesem Laufzeit-Modi spielt die Allokation die wichtigste Rolle. Durch zufällige Platzierung der Objekte im Speicher sinkt die Wahrscheinlichkeit, dass ein Überschreiben durch Pufferüberläufe andere Objekte schädigt. Auch unwahrscheinlich ist, dass ein neues Objekt direkt nach einem free-Aufruf den Speicherblock erhält, der durch diesen Aufruf freigegeben wurde. Damit werden Fehler durch hängende Zeiger minimiert. Heap-Überläufe, ungültige und doppelte Freigaben werden verhindert, indem die Metadaten der Speicherverwaltung getrennt von den Nutzdaten aufgehoben werden und die Speicheradresse vor der Freigabe auf Belegung geprüft wird. Dennoch besteht die Gefahr bei doppelten Freigaben, dass zwischen dem ersten und zweiten Freigeben genau diese Adresse vergeben wird. Die doppelte Freigabe würde also ein anderes Objekt betreffen und dieses löschen. Auch wenn dieses Verhalten ungewollt ist, stellt es in erster Linie keinen maschinellen Fehler oder keine Speicherkorruption dar. Die Erkennung von uninitialisierten Lesevorgängen ist mit der alleinstehenden Version nicht möglich und so nicht vorgesehen. Dafür bedarf es der Mehrfachausführung, die auch die minimierten Problemfelder „stärker“ schützt. Zusammenfassend können nach diesem Ansatz Probleme durch – Heap-Überläufe der Metadaten – ungültige Freigaben von Speicher – doppelte Freigaben von Speicher vermieden und Probleme durch – hängende Zeiger Software-basierte Absicherung von dynamisch genutztem Speicher 9 – Pufferüberläufe minimiert werden. Der Folgende Teil bezieht sich auf die Speichersemantik und Allokation des Systemspeichers. Der allozierte Speicher wird in 12 Sektionen unterteilt, die jeweils Objekte der Größen 2i · 8 Bytes speichern können, wobei i ∈ {0, . . . , 11} ⊂ N. Die kleinstmögliche Allokation beträgt demnach 8 Bytes, die größte 16 Kilobytes. Die Größe des benötigten Speichers für ein Objekt wird hierbei auf die nächst größere Zweierpotenz aufgerundet. Eine solche Verwaltung hat den Vorteil, dass Divisions und Modulo-Operationen durch Bit-Shifting erreicht werden können. Ein Nachteil hingegen ist der interne Verschnitt bei Objekten, die eine Speichersektion nur zur Hälfte +1 in Anspruch nehmen, also genau so groß sind, dass sie gerade zu groß für eine kleinere Sektion sind. Dafür allerdings wird die Fragmentierung lokal auf die Sektionen begrenzt. Für größere Objekt, die mehr als 16 KB Speicher benötigen, wird der Speicher direkt vom System mit mmap alloziert und von DieHard mit Guards geschützt. Der unendlich große Speicher aus Abschnitt 3 soll durch eine Zahl M approximiert werden. Für ein Programm, welches im Normalzustand N Speicherzellen belegt, werden N · M Speicherzellen alloziert. Insbesondere wird pro Sektion M 1 mal so viel Speicher angelegt wie benötigt. Die Partition kann jedoch nur zu M verwendet werden. Ein Beispiel in Abbildung 4 zeigt eine Heap-Partition von 64 Bytes für Objekte der Größe 8 Bytes bei gewähltem M = 2. Es wurden 3 Objekte zufällig angeordnet gespeichert, sodass 3 · 8 Bytes = 24 Bytes belegt sind. Da die Par1 tition nur zu M , also 12 in diesem Beispiel belegt sein darf, können nur noch 8 Bytes angelegt werden. Damit nicht sofort der gesamte Speicher des Systems Abbildung 4. Randomisierte Belegung von 3 Objekten in der 8 Bytes-Sektion. Für die Partition mit 64 Bytes dieser Sektion und M = 2 kann noch ein weiteres Objekt alloziert werden. Dann sind 4 · 8 · 2 = 64. verschwendet wird, erfolgt die Allokation erst bei Benutzung. Mehrfachausführung: Im Unterschied zur alleinstehenden DieHard-Version, das jedes Objekt nur einmal im Speicher verwaltet, wird das zu schützende Programm in dieser Version mehrfach parallel ausgeführt, wobei jeder Prozess einen semantisch äquivalenten Speichers und einen eigens initialisierten Zufallsgenerator besitzt, mit dessen Hilfe die Speicherallokation erfolgt und jedes Objekt in allen Kopien in einen unterschiedlichen Block innerhalb der eigenen 10 Software-basierte Absicherung von dynamisch genutztem Speicher Partition geschrieben wird. Die Kommunikation zwischen DieHard und den Prozessen erfolgt über die Standard-Eingabe per Unix-Pipes für Eingaben und über einen gemeinsamen Speicherbereich für Ausgaben. Weil jeder Block mit einem Zufallswert bei der Allokation beschrieben wird, kann auf diese Weise ein nicht initialisierter Lesevorgang dadurch entdeckt werden, dass die Ausgabe jedes Prozesses einen unterschiedlichen Wert liefert. In diesem Fall sei die einzige Möglichkeit das Programm zu terminieren, da ein zuverlässiger Programmablauf nicht möglich ist und nicht entschieden werden kann, welcher Wert richtig ist. In der Abbildung 5 sind drei Prozesse abgebildet in denen die Objekte A, B und C im Speicher an unterschiedlichen Stellen abgelegt sind. Für den Betrieb dieses Modus sind mindestens 3 Prozesse notwendig, damit bei einer Wertunstimmigkeit eine Abstimmung mit Mehrheit gemacht werden kann. Bei einer Unstimmigkeit, wird der am meisten vorkommende Wert verwen- Abbildung 5. Randomisierte Belegung von 3 Objekten in 3 Replica. det. Prozesse, in denen ein Fehler aufgetreten ist und ein Objekt überschrieben wurde oder ein Wert nicht stimmt können einfach beendet werden. Der Einsatz dieser Version sei laut Paper nicht dafür geeignet Programme zu verwalten, die nichtdeterministische Ausgaben haben und ziele in erster Linie auf Unix-Kommandozeilenprogramme ab. Damit alle Prozesse übereinstimmen, werden Systemcalls – wie Zeit/Datum-Abfragen – abgefangen. 3.2 Speicherschutzmodell Critical Memory Critical Memory [7] ist ein Schutzmodell für sicherheitskritische Daten im Speicher, welches eine konkrete Implementierung in Software oder in Hardware offen lässt. Es schützt kritische Daten vor willkürlichen Schreibzugriffen, also jenen, die nicht vom Programmierer gewollt waren und versehentlich geschehen sind. Dies wird erreicht, indem normale und kritische Speicherzugriffe Software-basierte Absicherung von dynamisch genutztem Speicher 11 voneinander getrennt werden und der Programmierer die zu schützenden Daten explizit im Programmcode identifiziert. Dadurch können lokale, kritische Daten nicht durch Einsatz externer Bibliotheken zerstört werden können, da sie im normalen Modus ausgeführt werden und keinen Zugriff auf kritische Daten haben. Um kritische Daten identifizieren zu können, soll eine kleine Aufzählung möglicher Kandidaten dienen: – Daten, die den Zustand eines Programms wiederherstellen können (Zum Beispiel: Ein Webbrowser speichert in regelmäßigen Abständen seine offenen Tabs, die geladenen URLs, Session-Cookies usw.) – Metadaten eines Speicherverwalters bei malloc- und free-Aufrufen. – Hash-Tabellen, Trees, Listen – Unkritische Daten, die den Kontrollfluss beeinflussen Die technische Realisierung geschieht durch einen erweiterten Befehlssatz zum Schreiben und Lesen von Daten. Der einfache Befehlssatz, um die Variable var1 zu schreiben oder zu lesen sieht folgendermaßen aus: – store 7000, (&var1) – load r1, (&var1) Kritische Daten werden in einer separaten Speicher-Schicht gespeichert. Um auf diese Schicht zuzugreifen wird der Befehlssatz um die folgenden Befehle erweitert: – critical_store 231, (&var1) – critical_load r2, (&var1) Entsprechende Speicheradressen werden mit map_critical alloziert und mit unmap_critical freigegeben. Ein unkritisches Datum kann auch im Nachhinein mit promote zu einem kritischen gemacht werden. Verifizierte, wichtige Daten externer Bibliotheken im normalen Speicher können auf diese Weise in den kritischen Speicher geschrieben werden. Im Ablaufdiagramm in Abbildung 6 ist dargestellt, dass ein kritischer Schreibvorgang sowohl den kritischen, als auch den normalen Speicher beschreibt. Die normalen Befehle zum Schreiben und Lesen bleiben unverändert und betreffen nur den normalen Speicher. Ein kritischer Lesevorgang liefert hingegen den zuletzt geschriebenen Wert aus dem kritischen Speicher. Auf diese Weise können Module entwickelt werden, die miteinander kommunizieren und gegenseitige Schreibzugriffe machen. Die jeweiligen kritischen Daten bleiben geschützt, wodurch auf den Originalwert zugegriffen werden kann. Falls der normale Speicher mit dem kritischen nicht synchron ist, können Traps bei store und critical_load ausgelöst werden. Bei einem Trap kann zum Beispiel eine Exception geworfen oder der Wert toleriert werden. Eine weitere Möglichkeit stellt das vorherige Sichern von Programmzuständen mit anschließender Wiederherstellung dar. Dies wird in Abschnitt 3.3 genauer erklärt. Die Vorteile von Critical Memory sind die Einsatzmöglichkeiten bei teilweise 12 Software-basierte Absicherung von dynamisch genutztem Speicher Abbildung 6. Ablaufdiagramm mit erweitertem Befehlssatz zu Critical Memory. Normale Lese- und Schreibzugriffe beeinflussen den kritischen Speicher nicht. Durch Ausführung beider load -Befehle können Unstimmigkeiten entdeckt werden. Software-basierte Absicherung von dynamisch genutztem Speicher 13 technischen Speicherdefekten an bestimmten Adressen, der Schutz vor kosmischer Strahlung im Speicherkreislauf und der Schutz vor uninitialisierten Lesezugriffen (kritische Daten erhalten bei Allokation den Wert SENTINEL; Wird dieser gelesen, so ist das Ergebnis ein nicht initialisierter Lesevorgang). Ferner lassen sich Pufferüberläufe durch Unstimmigkeiten in den normalen und kritischen Daten erkennen. Sollten Daten im normalen Speicherbereich überschrieben werden, können sie so aus den kritischen rekonstruiert werden. Als Nachteil der Verwendung von Critical Memory gilt die sehr schnelle Unübersichtlichkeit des Quelltextes durch den expliziten Einsatz von critical_store und critical_load, was das Programmieren sehr fehleranfällig macht. Wünschenswert wäre entsprechende Compiler-Unterstützung durch die Verwendung von entsprechenden Schlüsselworten, Typ-Spezifikationen etc.. Im Paper [7] werden drei alternative Schreibweisen verglichen, auf die an dieser Stelle nur verwiesen werden soll. 3.3 Fehlerreparatur durch Zustandssicherung und Wiederherstellung Nachdem eine Unstimmigkeit im normalen und kritischen Speicher von Critical Memory erkannt wurde und ein Trap ausgelöst wurde, müssen die Daten aus einer vorherigen Sicherung wiederhergestellt werden. Das Verfahren, nach dem die Daten gesichert werden nennt sich Checkpointing [7]. Hierbei wird der Programmzustand periodisch oder bei aufgetretenen Ereignissen gesichert. Die Fehlererkennung kann bei critical_load (Eager detection) erfolgen, sodass die Reparatur anschließend beginnt oder erst beim Schreiben einer neuen Sicherung (Lazy detection). Es ist nicht auszuschließen, dass zum Beispiel ein Timer periodisch eine Methode aufruft, die den Fehler verursacht. Beim einfachen Wiederherstellen besteht die Gefahr, dass das Programm ständig mit der Wiederherstellung beschäftigt sein würde. Die Isolation eines größeren Fehlers ist schwierig, da eine Sicherheitslücke geöffnet werden könnte, wenn die fehlerhafte Methode nicht ausgeführt wird. Denkbar wäre eine fehlerhaft implementierte Verschlüsselungsroutine wobei bei Isolation Daten im Klartext gesendet werden würden. Zur Fehlerbehebung existieren drei Strategien: – Der Wert im normalen Speicher wird mit dem des kritischen überschrieben (forward recovery) – Bei einem Fehler wird der gesicherte Checkpoint geladen (backword recovery) – Es wird ein Trap ausgelöst, der den Fehler toleriert (ignoriert) (trap, no recovery) 14 Software-basierte Absicherung von dynamisch genutztem Speicher 3.4 Laufzeitumgebung Samurai Samurai [7] ist eine Laufzeitumgebung, die das Modell Critical Memory (Abschnitt 3.2) objektorientiert in Software implementiert. Für kritische Daten auf dem Heap, stellt es die Methoden critical_malloc und critical_free zum allozieren und freigegeben zur Verfügung. Dadurch lassen sich ohne große Änderungen bei existierenden Programmen die Standard-Allokatoren ersetzen und das Programm statistisch gesehen sicherer machen. Der entstehende Overhead bei der Verwendung dieser Umgebung ist proportional zu den zu sichernden kritischen Daten. Da Critical Memory vorsieht, dass die kritischen Daten als separate Ebene behandelt werden (Siehe auch Abbildung 6), verwaltet Samurai zwei Kopien, die im folgenden Schatten bezeichnet werden sollen. Die originalen Daten werden dementsprechend als Spiegel bezeichnet. Mit den Befehlen load und store lässt sich der Spiegel beschreiben, der erst bei einem critical_load mit den Schatten verglichen wird. Sollte hierbei eine Unstimmigkeit zwischen dem Spiegel und den Schatten festgestellt werden, wird eine Abstimmung durchgeführt. Resultierend aus dem Ergebnis wird jener Wert übernommen, der zweimal wiedergegeben wurde, und der Fehler korrigiert. Diese Konfiguration entspricht eager detection mit forward recovery (Abschnitt 3.3). Mit dem Befehl critical_store werden sowohl Spiegel, als auch Schatten auf den entsprechenden Wert gesetzt. Zusätzlich werden die folgenden Optimierungen durchgeführt: 1. Um den entstehenden Overhead bei jedem critical_load zu minimieren, wird der Spiegel nur mit einem Schatten verglichen. Der weitere Schatten kommt dann bei Unstimmigkeit dazu. 2. Nach jeweils n vergleichen mit einem Schatten findet jeder weitere Vergleich mit dem anderen Schatten statt. Dadurch soll die Wahrscheinlichkeit einer Korruption beider Schatten minimiert werden. Würde der zweite Schatten nie verglichen werden könnte sich ein unentdeckter Fehler einschleichen, sodass bei einer Abstimmung plötzlich drei unterschiedliche Werte gelesen würden. 3. Die Metadaten des zuletzt gelesenen Objektes werden zwischengespeichert, sodass die Performance bei mehrfachem Zugriff darauf gesteigert werden kann. Zur Veranschaulichung eines möglichen Ablaufs dient Tabelle 2 mit folgender Beschreibung zu jedem Zeitpunkt ∆t. ∆t = 0: Sowohl Spiegel, als auch Schatten werden auf den Wert 700 gebracht. ∆t = 1: Unkritisch wird der Wert 200 in den Spiegel geschrieben. ∆t = 2: Der soeben gesetzte Wert wird wieder aus dem Spiegel ausgelesen. Software-basierte Absicherung von dynamisch genutztem Speicher ∆t Operation 0 1 2 3 4 5 6 .. . 98 99 100 101 .. . 199 200 201 critical_store, 700 store, 200 load store, 700 critical_load store, 100 critical_load .. . store, 200 critical_load critical_store, 400 critical_load .. . critical_load store, 300 critical_load 15 Spiegel Schatten 1 Schatten 2 aktiv 700 200 200 700 700 100 700 .. . 200 200 400 400 .. . 100 300 300 700 700 700 700 700 700 700 .. . 101 200 400 400 .. . 553 553 553 700 700 700 700 700 700 700 .. . 200 200 400 400 .. . 100 100 100 1 1 1 1 1 1 1 .. . 1 1 2 2 .. . 2 1 1 Tabelle 2. Die Tabelle beschreibt eine mögliche Ablauffolge von aufeinander folgenden Speicheroperationen und die Interaktion der Schatten zur Fehlerkorrektur für ein kritisches Datum. Die Optimierung für den Vergleich mit nur einem Schatten wurde berücksichtigt, nicht aber die Zwischenspeicherung, da sich die Tabelle auf eine Speicherzelle bezieht und möglichst überschaubar sein soll. Die letzte Spalte gibt Aufschluss darüber, mit welchem Schatten bei einem critical_load verglichen wird. Eingestellter Wechsel bei n = 100 Operationen. Fehler, die durch Abstimmung behoben werden konnten, wurden in rot markiert. Eingeschlichene oder injizierte Fehler wurden in gelb und Schatten-Wechsel in grün markiert. Die letzte Zeile wurde komplett in rot markiert, da hier auch keine Abstimmung zur Fehlerkorrektur hilft. 16 Software-basierte Absicherung von dynamisch genutztem Speicher ∆t = 3: Der ursprüngliche Wert 700 wird zurück in den Spiegel geschrieben. Die letzten drei Vorgänge könnten einem Modul entsprechen, dass den Originalwert zwischengespeichert, eine Berechnung durchgeführt und den Wert wieder zurückgeschrieben hat. ∆t = 4: critical_load Vergleicht nun den gelesenen Wert mit dem aktiven Schatten. Dieser stimmt überein, also wird er zurückgeliefert. ∆t = 5: Unkritisch wird der Wert 100 in den Spiegel geschrieben. ∆t = 6: critical_load bemerkt die Unstimmigkeit zwischen dem Spiegel und dem Schatten und repariert den Spiegel durch Mehrheitsabstimmung. Der Wert 100 wird zu 700 korrigiert und zurückgeliefert. ∆t = 98: Es wird ein normaler Speicherbefehl ausgeführt. In Schatten 1 schleicht sich unbemerkt ein Fehler ein. ∆t = 99: Der Fehler wird von critical_load entdeckt, da der aktive Schatten nicht mit dem Spiegel übereinstimmt. Er kann mit Hilfe des zweiten Schattens korrigiert werden. ∆t = 100: Es wurden 100 Operationen durchgeführt, der aktive Vergleichsschatten wird gewechselt. Schatten 2 ist nun aktiv. ∆t = 101: Die Operation critical_load stellt keine Fehler fest. ∆t = 199: In Schatten 1 hat sich ein Fehler eingeschlichen, der von critical_load nicht entdeckt wird, da Spiegel und aktiver Schatten 2 gleich sind. ∆t = 200: Der aktive Schatten wird gewechselt und es wird 300 in den Spiegel geschrieben. ∆t = 201: Der critical_load Aufruf stellt einen Fehler fest. Dieser kann jedoch nicht per Mehrheitsabstimmung repariert werden, da alle Werte verschieden sind. Desweiteren verwendet Samurai den Heap-Allokator DieHard (Abschnitt 3.1), welches die Speicheradresse für neue Objekte randomisiert und damit die Wahrscheinlichkeit verringert, dass der Spiegel und die Schatten bei einem Fehler gleichzeitig beschädigt werden. Samurai toleriert mit Hilfe des DieHardAllokators sowohl Fehler in kritischen (durch Abstimmung), als auch unkritischen (durch Randomisierung) Speicherbereichen. Aufgrund der Tatsache, dass sich die Schatten im selben Adressraum befinden ist nicht auszuschließen, dass sich der selbe Fehler – trotz der geringen Wahrscheinlichkeit – in allen Schatten wiederfindet und somit keine Garantie auf Korrektheit gegeben ist. Es können externe Bibliotheken von Drittanbietern relativ unproblematisch ausgeführt werden, da eventuelle Zeigerfehler in jenen nicht die kritischen Daten des Programms manipulieren können. Wie aus Tabelle 3 hervorgeht, sind die Metadaten des Samurai-Heaps einfach gestrickt. Für die Allokation der drei Speicher-Heaps wird der DieHard-Allokator drei mal aufgerufen. Die Adressen zu den beiden Schatten werden direkt in den Metadaten gespeichert. Zusätzlich wird ein Muster in den Valid-Tag geschrieben, welches anzeigt, dass es sich um valide Daten handelt, die nicht durch einen Überlauf überschrieben wurden. Zuletzt wird die exakte Metadaten-Größe und eine Checksumme der beiden Zeiger und der Größe gespeichert. Das Metadaten-Objekt wird dann an den Anfang des Spiegels geschrieben und ein Zeiger auf den dahinter liegenden freien Software-basierte Absicherung von dynamisch genutztem Speicher 17 Attribut Größe Zweck Valid-Tag Zeiger Schatten 1 Zeiger Schatten 2 Objektgröße Checksumme 2 4 4 4 2 Spezielles Flag zur Anzeige valider Heap-Daten Adresse des ersten Schatten Adresse des zweiten Schatten Die exakte Objektgröße Checksumme der Zeiger auf Schatten und die Objektgröße Bytes Bytes Bytes Bytes Bytes Tabelle 3. Attribute des Metadaten-Objektes von Samurai entnommen aus [7] Speicherbereich zurückgeliefert, wobei der allozierte Speicherplatz für den Spiegel um die Größe des Objektes vergrößert wurde. Bei dem Befehl critical_load wird unter anderem der Valid-Tag gelesen, der bei Inkorrektheit zum Abbruch führt. Bei critical_store Befehlen werden die Grenzen des Speicherbereichs geprüft, sodass kritische Daten keinen Überlauf verursachen. Dieser Speicherschutz ist nicht dazu geeignet, gezielte Speicher-Attacken abzuwehren, da ein cleverer Angreifer die Adressen zu den Schatten herausfinden kann. 4 4.1 Der Vergleich und Laufzeitcheck Grundlegende Unterscheidung zwischen DieHard und Samurai Durch eine Gegenüberstellung der beiden Laufzeitumgebungen lassen sich die folgenden Unterschiede festhalten: DieHard 1. Replikation des gesamten Prozesses 2. Kein expliziter Eingriff des Programmierers notwendig Samurai 1. Replikation kritischer Daten 2. Explizite Identifizierung kritischer Daten durch den Programmierer ist notwendig 4.2 Wahrscheinlichkeiten zu denen DieHard einen Fehler maskiert und einen uninitialisierten Lesevorgang entdeckt In dem Paper [1] werden drei Wahrscheinlichkeits-Formeln aufgestellt, zu denen DieHard einen Fehler erfolgreich maskiert und einen uninitialisierten Lesevorgang entdeckt. Für die Formeln gilt die Eigenschaft, dass sie nur auf Objekte 18 Software-basierte Absicherung von dynamisch genutztem Speicher der selben Größe beschränkt sind und dass sie die Wahrscheinlichkeit angeben, dass exakt ein Fehler des jeweiligen Typs auftritt. Maskierung von Pufferüberläufen: Die folgende Funktion liefert die Wahrscheinlichkeit, dass kein instanziiertes Objekt durch einen Pufferüberlauf überschrieben wird: " O #k F PP = 1 − 1 − (1) H F steht für den freien Speicher der Sektion, H für die Heap-Größe, O für die Anzahl instanziierter Objekte und k für die Anzahl der verwendeten Prozesse in Mehrfachausführung. F O die Wahrscheinlichkeit liefert, dass O instanziierte ObBeweis. Während H jekte nicht überschrieben werden, liefert die Negation die Wahrscheinlichkeit, dass mindestens ein Objekt überschrieben wird. Potenziert man diesen Wert mit der Anzahl der Prozesse, so erhält man die Wahrscheinlichkeit, dass in jedem Prozess mindestens ein Überlauf stattgefunden hat. Die Negation davon liefert die Wahrscheinlichkeit, dass mindestens ein Prozess keinen Fehler enthält. In Abbildung 7 sind die Wahrscheinlichkeiten zu variierter Anzahl Prozesse und variierten Speicherfüllstand nebeneinander gestellt. Man kann ablesen, dass sich ein Fehler beim alleinstehenden Betriebsmodus von DieHard und einem 1/8 gefüllten Heap bereits zu 87,5% maskieren lässt. Maskierung von hängenden Zeigern: Die nachfolgende Funktion stellt die Wahrscheinlichkeit dar, dass nach einem free-Aufruf kein neu instanziiertes Objekt nach A Allokationen durch den hängenden Zeiger überschrieben wird: Ph ≥ 1 − A F/S k , A ≤ F/S (2) F steht für den freien Speicher der Sektion, S für die Größe des freigegebenen Objektes und k für die Anzahl der Prozesse. Beweis. Die Anzahl gleich großer Objekte, die in den Freispeichers passen beträgt Q = F/S. Daraus ergibt sich die Wahrscheinlichkeit (Q − 1)/Q, dass genau dieses Objekt bei der nächsten Allokation (ebenfalls gleicher Größe) nicht überschrieben wird. Der Worst-Case ist der, dass jeder nachfolgend allozierte Speicher nicht wieder freigegeben und somit knapper wird, sodass es irgendwann zum Überschreiben kommt. Für die zweite Allokation gilt dann eine Wahrscheinlichkeit von (Q − 1)/Q · (Q − 2)/(Q − 1) = (Q − 2)/Q, dass das Objekt nicht überschrieben wird. Für die Wahrscheinlichkeit, dass es nach A Allokationen nicht zum Überschreiben kommt gilt dann (Q − A)/Q. Wie im vorherigen Beweis kann daraus die Negation für die Wahrscheinlichkeit gebildet werden, dass es zum Überschreiben kommt. Potenziert man diesen Wert mit der Anzahl der Software-basierte Absicherung von dynamisch genutztem Speicher 19 Probabilit y of Avoiding Buf f er Overf low Probabilit y 1/8 f ull 1/4 f ull 1/2 f ull 100% 90% 80% 70% 60% 50% 40% 30% 20% 10% 0% 1 2 3 4 5 6 Replicas Abbildung 7. Wahrscheinlichkeit, dass kein instanziiertes Objekt durch einen Pufferüberlauf überschrieben wird. Variiert werden die Anzahl der Prozesse (hier Replicas genannt) und der Füllstand des Heaps. Entnommen aus [1] 20 Software-basierte Absicherung von dynamisch genutztem Speicher Prozesse, ergibt es die Wahrscheinlichkeit, dass in jedem Prozess ein Überlauf stattfindet. Die Negation liefert entsprechend die Wahrscheinlichkeit, dass kein k Prozess ein freigegebenes Objekt überschreibt 1 − (1 − (Q − A)/Q) , was der Ausgangsformel entspricht. In der Abbildung 8 kann man erkennen, dass DieHard bei kleinen 8-Byte Objekten mit 99,5% eine sehr hohe Fehlertoleranz hat. Probabilit y of Avoiding Dangling Point er Error Probabilit y 100 allocs 1000 allocs 10,000 allocs 100% 90% 80% 70% 60% 50% 40% 30% 20% 10% 0% 8 16 32 64 128 256 Object size (byt es) Abbildung 8. Wahrscheinlichkeit zu der ein hängender Zeiger maskiert ist und es nicht zum Überschreiben kommt. Variiert werden die Objektgrößen und die Anzahl der Allokationen. Entnommen aus [1] Erkennung uninitialisierter Lesevorgänge: Für die Erkennung uninitialisierter Lesevorgänge müssen die Werte aller k Prozesse disjunkt sein. Insbesondere müssen sich die gelesenen Werte der Länge B Bits unterscheiden. Die Wahrscheinlichkeit für die Erkennung eines Lesefehlers seitens DieHard liegt demzufolge bei: 2B ! Pu = B , 2B > k (3) (2 − k)! · 2Bk Beweis. Damit DieHard einen Fehler erkennt, müssen sich die Bitmuster im uninitialisierten Speicher aller Prozesse unterscheiden. Bei B Bits gibt es 2B Bk unterschiedliche Werte und beik Prozessen 2 mögliche Kombinationen. Aus diesem Grund lassen sich 2B !/ 2B − k ! unterschiedliche Bitmuster – quer Software-basierte Absicherung von dynamisch genutztem Speicher 21 durch die Prozesse – ziehen, woraus sich die Wahrscheinlichkeit für obige Formel ergibt. Anders als bei den beiden vorherigen Funktionen führt eine Erhöhung der Prozessanzahl zu einer verschlechterten Erkennungsrate. Das liegt daran, dass nur begrenzt viele Bitmuster der Länge B generiert werden können. Bei steigender Prozessanzahl wächst die Wahrscheinlichkeit einer Doppelbelegung, das nicht mehr zum erwünschten Ergebnis führen würde. Eine einfache Berechnung bestätigt: Für zwei Prozesse und ein 2-Byte Bitmuster ergibt sich eine ErkennungsWahrscheinlichkeit von 87,5%. Die Hinzunahme eines weiteren Prozesses reduziert diese auf 65,6%. Trotz dieser Umstände ist die Erkennung von Fehlern beim Lesen von Werten mit jeweils 32-Bit Länge akzeptabel. 4.3 Benchmarks zu DieHard: Es wurden verschiedene Benchmarks mit der SPECint2000 Suite und Programmen mit häufiger Speicherallokation auf drei unterschiedlichen Plattformen durchgeführt. DieHard wurde in den Benchmarks als alleinstehende Variante mit 384MB Heap-Speicher verwendet. In der Abbildung 9 kann man die Ergebnisse der Benchmarks für Linux und die Unterschiede zu GNU libc malloc und dem Boehm-Demers-Weiser collector betrachten. Während der Overhead bei Programmen mit viel Allokationen mit ca. 40% vergleichbar mit dem des Boehm-Demers-Weiser collector ist, hat DieHard bei den kleineren Programmen einen Overhead von etwa 12%. Aus der Abbildung 10 kann entnommen werden, dass die Benchmarks unter Windows XP und der Verwendung von DieHard sogar zu einem PerformanceGewinn geführt haben. Weitere Tests wurden mit DieHard in Mehrfachausführung auf einem Solaris UltraSparc v9 mit 16 Cores durchgeführt. Hier wurde eine um etwa 50% erhöhte Laufzeit gemessen, als bei der Ausführung der alleinstehenden Version. Das hängt unter anderem damit zusammen, dass die Prozesse erzeugt werden und untereinander kommunizieren müssen. Des weiteren wurde ein eigens implementiertes Programm zur Fehlerinjektion verwendet, um die Praxisrelevanz der Fehlertoleranz von DieHard mit Programmen aus der SPECint2000 Suite zu testen. Tests ergaben, dass das Programm espresso mit Standard-Allokator in 9 von 10 Fällen abstürzte und einmal in einer Endlosschleife hing. Mit dem DieHard-Allokator liefen alle 10 Versuche einwandfrei durch. 4.4 Benchmarks zu Samurai Auch in diesem Fall wurden Benchmarks mit vier modifizierten Programmen aus der SPECint2000 Suite und einer Ray-Shading Simmulation durchgeführt. Zu den getesteten Programmen gehören die Folgenden: 22 Software-basierte Absicherung von dynamisch genutztem Speicher Runt ime on Linux malloc 2.5 GC alloc-int ensive DieHard general-purpose Normalized runtime 2 1.5 1 300.t w olf Geo. M ean 256.bzip2 254.gap 255.vort ex 253.perlbmk 252.eon 197.parser 186.craf t y 176.gcc 181.mcf 175.vpr 164.gzip Geo. M ean p2c roboop lindsay cf rac 0 espresso 0.5 Abbildung 9. Durchgeführte Benchmarks der alleinstehenden DieHard Laufzeitumgebung auf der Linux Plattform. Gegenübergestellt sind GNU libc malloc und der Boehm-Demers-Weiser collector. Entnommen aus [1] Software-basierte Absicherung von dynamisch genutztem Speicher 23 Runt ime on Windows malloc DieHard Normalized runtime 2.5 2 1.5 1 0.5 0 cfrac espresso lindsay pzc roboop Geo. Mean Abbildung 10. Durchgeführte Benchmarks der alleinstehenden DieHard Laufzeitumgebung auf der Windows XP Plattform. Gegenübergestellt ist GNU libc malloc. Entnommen aus [1] 24 – – – – – Software-basierte Absicherung von dynamisch genutztem Speicher vpr crafty parser rayshade gzip Die Modifikationen beziehen sich auf die vorherige Auswahl kritischer Daten. Abhängig von den gewählten kritischen Daten machen die critical_load -Aufrufe etwa 0,01% - 12,8% Prozent der Operationen aus. Wie aus Abbildung 11 hervorgeht liegt der Overhead bei den meisten getesteten Anwendungen, mit Ausnahme von gzip, unter 10%. Da gzip seinerseits verhältnismäßig viele critical_load - Abbildung 11. Performance-Overhead bei Samurai. Entnommen aus [7] Aufrufe tätigt, liegt auch der Overhead entsprechend höher. 4.5 Fehlerinjektions-Experimente bei Samurai Es werden Experimente durch injizieren von Fehlern sowohl in kritischen als auch unkritischen Daten gemacht. In den kritischen Daten werden zufällige kritische Objekte ausgewählt und ein Teil von diesen mit zufälligen Daten überschrieben. Die Rate wird durch die Anzahl der in der jeweiligen Partition allozierten Objekte bestimmt. Für die Injektion von Fehlern in unkritischen Daten wird zunächst ein Trace anhand eines normalen, fehlerfreien Durchlaufs erzeugt (Golden-Run) und die Ausgabe mit dem Experiment verglichen. Dabei kann es passieren, dass ein critical_store Befehl aufgrund falscher Berechnung einen fehlerhaften Wert Software-basierte Absicherung von dynamisch genutztem Speicher 25 speichert, das Programm einen falschen Pfad nimmt oder ein critical_store Befehl in eine ungültige, aber valide kritische Adresse schreibt. Die Abbildungen 12 und 13 reflektieren das Ergebnis eines FehlerinjektionsExperimentes in kritischen Daten, bei dem der Benchmark vpr einmal ohne Samurai (Abbildung 12) und einmal mit Samurai (Abbildung 13) getestet wurde. Bei diesem Experiment wurde die Fehlertoleranz gemessen, indem alle 100.000 Abbildung 12. Fehlertoleranz von vpr ohne Samurai. Entnommen aus [7] Speicheroperationen ein Fehler injiziert wurde. Der Versuch wurde dabei 10 mal wiederholt. Hierbei wurde gemessen, wie oft das Programm erfolgreich durchlief, wie oft es einen Fehlalarm gab und wie oft es zu Fehlern in der Berechnung kam. Für die gesamte Grafik wurde das Experiment mit angepasster Injektionsrate – die im Intervall von 100.000 bis 1.000.000 liegt – wiederholt. Erkennbar ist, dass durch den Einsatz von Samurai alle Fehler toleriert werden konnten und es nur einen Fehlalarm gab, bei dem das Ergebnis korrekt war. 4.6 Implementierter Speicherverwalter unter Verwendung von Samurai In dem Paper [7] wird ein kompletter Speicherverwalter unter Verwendung von Samurai entwickelt. Dadurch wird ermöglicht, dass Programme nicht neu kompiliert werden müssen und mit DieHard verglichen werden können, wie aus Ab- 26 Software-basierte Absicherung von dynamisch genutztem Speicher Abbildung 13. Fehlertoleranz von vpr mit Samurai. Entnommen aus [7] Software-basierte Absicherung von dynamisch genutztem Speicher 27 bildung 14 hervorgeht. Insgesamt liegt der Overhead beim Samurai basierten Speicherverwalter bei 10% und bei DieHard bei 6%. Abbildung 14. Implementierter Speicherverwalter mit Samurai. Ein Vergleich mit DieHard. Entnommen aus [7] 5 Verwandte Themen Die Laufzeitumgebung DieHard ist verwandt mit dem Thema „N-Verions Programming“. Dabei zielt letzteres darauf ab, Fehler zu reduzieren, indem mehrere unterschiedliche Software-Versionen (Variantenvielfalt) implementiert werden, welche die selbe Aufgabe erfüllen. Diese Vielfalt wird zum Beispiel erreicht, indem mehrere verschiedene Programmierer die Aufgabe unterschiedlich lösen. Es basiert auf der Vermutung, dass Fehler reduziert werden können, indem Versionen, die sich fälschlich verhalten entfernt werden und jene übrig bleiben, die fehlerfrei sind. DieHard hingegen stellt hard-analytische Garantien bereit. Des weiteren ist das Thema „pool-allocation“ mit DieHard verwandt, da hierbei Objekte des selben Typs in Pools verwaltet werden, während DieHard Objekte gleicher Größe in Sektionen verwaltet. Nicht zuletzt erwähnt bleibt die randomisierte Verteilung des Speichers von DieHard. Einen ähnlichen Ansatz verfolgt auch Microsoft seit Windows Vista Beta2 mit „Address Space Layout Randomization (ASLR)“. Damit werden die Einstiegspunkte zu den Bibliotheken 28 Software-basierte Absicherung von dynamisch genutztem Speicher wie kernel32.dll oder wsock32.dll an 256 unterschiedlichen Speicherregionen abgelegt und die Wahrscheinlichkeit potenzieller Angreifer beim Versuch über einen Pufferüberlauf zum Beispiel ein Socket zu öffnen minimiert [2]. Die Umsetzung von Critical Memory zum Schutz kritischer Datenstrukturen ist sehr nah an das Thema „Fehlertoleranten Datenstrukturen“ angelehnt. Die Verwendung der critical_store und critical_load Befehle bei Critical Memory erfolgt in der Regel explizit und ist sehr fehlerträchtig. Der Einsatz von Critical-Typspezifikationen oder der Isolation des Adressraumes jedes Moduls erfordert Unterstützung durch den Compiler, weshalb auch hier Parallelen existieren. Eine andere Alternative ist der Einsatz aspektorientierter Programmierung. Damit lassen sich zum Beispiel dynamische Speicherchecks in die entsprechenden Codefragmente einfügen. Den wichtigsten Punkt stellt jedoch die Fehlertoleranz mit Programmfortführung der beiden Laufzeitumgebungen DieHard und Samurai dar. Einige der oben genannten Methoden bieten nicht ausreichend Schutz vor Speicherfehlern und müssen das Programm bei Auftritt beenden. Literatur 1. Emery D. Berger and Benjamin G. Zorn. DieHard: probabilistic memory safety for unsafe languages. SIGPLAN Not., 41(6):158–168, June 2006. 2. MSDN Blogs. Address Space Layout Randomization in Windows Vista. http://blogs.msdn.com/b/michael_howard/archive/2006/05/26/608315. aspx, may 2006. 3. TIOBE Software BV. TIOBE Programming Community Index for February 2013. http://www.tiobe.com/index.php/content/paperinfo/tpci/index.html, feb 2013. 4. Mark Dowd, John McDonald, and Justin Schuh. The Art of Software Security Assessment: Identifying and Preventing Software Vulnerabilities. Addison-Wesley Professional, 2006. 5. Justin N. Ferguson. Understanding the heap by breaking it. BlackHat, 2007. https://www.blackhat.com/presentations/bh-usa-07/Ferguson/ Whitepaper/bh-usa-07-ferguson-WP.pdf. 6. Gene Novark and Emery D. Berger. DieHarder: Securing the heap. In Proceedings of the 5th USENIX conference on Offensive technologies, WOOT’11, pages 12–12, Berkeley, CA, USA, 2011. USENIX Association. 7. Karthik Pattabiraman, Vinod Grover, and Benjamin G. Zorn. Samurai: protecting critical data in unsafe languages. SIGOPS Oper. Syst. Rev., 42(4):219–232, April 2008.