Seminar: Softwarebasierte Fehlertoleranz Thema: Software

Werbung
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.
Herunterladen