Compiler-Sicherheitsprüfungen im Detail

Veröffentlicht: 24. Mrz 2002 | Aktualisiert: 08. Nov 2004

Von Brandon Bray

Dieses Papier behandelt Pufferüberläufe und beschreibt ausführlich die Funktion für Sicherheitsprüfungen von Microsoft® Visual C++® .NET, die durch das Compiler Flag /GS bereitgestellt wird.

Auf dieser Seite

Einführung Einführung
Pufferüberlauf - Definition Pufferüberlauf - Definition
Anatomie des x86-Stacks Anatomie des x86-Stacks
Laufzeitprüfungen Laufzeitprüfungen
Funktionsweise von /GS Funktionsweise von /GS
Die Fehlerbehandlungsroutine Die Fehlerbehandlungsroutine
Der Cookie-Wert Der Cookie-Wert
Auswirkungen auf die Leistung Auswirkungen auf die Leistung
Beispiele Beispiele
Fazit Fazit

Einführung

Softwaresicherheit ist ein wichtiges Thema für die Hightechindustrie. Der am meisten gefürchtete und missverstandene Softwareschwachpunkt ist der Pufferüberlauf. Heutzutage genügt schon die Erwähnung eines Pufferüberlaufs, damit die Leute innehalten und zuhören. Nur zu oft gehen die technischen Details in den Aufzeichnungen verloren, und die Öffentlichkeit erhält ein ziemlich beunruhigendes Bild eines ziemlich grundlegenden Problems. Um sich dieses Problems anzunehmen, führt Visual C++ .NET Sicherheitsprüfungen ein, die Entwicklern dabei helfen, Pufferüberläufe zu identifizieren.

 

Pufferüberlauf - Definition

Puffer sind Speicherblöcke, normalerweise in Form eines Feldes. Wenn die Größe eines Feldes nicht geprüft wird, ist es möglich, außerhalb des zugeordneten Puffers zu schreiben. Wenn eine solche Aktion an höheren Speicheradressen als der des Puffers stattfindet, wird dies als Pufferüberlauf bezeichnet. Ein ähnliches Problem liegt vor, wenn in einen Puffer an Speicheradressen unterhalb des zugeordneten Puffers geschrieben wird. Dies wird als Pufferunterlauf bezeichnet. Unterläufe sind erheblich seltener als Überläufe, kommen jedoch vor (wie weiter unten in diesem Artikel beschrieben). Ein Pufferüberlauf, der Code in einen aktiven Prozess einschleust, wird als exploitable buffer overrun (ausnutzbarer Pufferüberlauf) bezeichnet.
Eine bestimmte Klasse gut dokumentierter Funktionen, einschließlich strcpy, gets, scanf, sprintf, strcat usw., ist per se anfällig für Pufferüberläufe, von ihrer Verwendung ist strikt abzuraten. Ein einfaches Beispiel zeigt die Gefährlichkeit solcher Funktionen:

int vulnerable1(char * pStr) { 
    int nCount = 0; 
    char pBuff[_MAX_PATH]; 
    strcpy(pBuff, pStr); 
    for(; pBuff; pBuff++) 
       if (*pBuff == '\\') nCount++; 
    return nCount; 
}

Dieser Code hat eine offensichtliche Schwäche - beim Parameter pBuff könnte es zu einem Überlauf kommen, wenn der Puffer, auf den pStr verweist, länger ist als _MAX_PATH. Für einen Debug-Build lässt sich das Problem mit assert(strlen(pStr) < _MAX_PATH) abfangen. Dies gilt aber nicht für einen Release-Build. Die Verwendung dieser anfälligen Funktionen gilt als unsaubere Programmierpraxis. Es gibt ähnliche Funktionen, die technisch weniger anfällig sind. Dazu zählen , beispielsweise strncpy, strncat und memcpy.
Das Problem mit diesen Funktionen ist, dass es der Entwickler ist, der die Größe des Puffers festlegt, nicht der Compiler. Ein häufiger Fehler wird in der folgenden Funktion veranschaulicht:

#define BUFLEN 16 
void vulnerable2(void) { 
    wchar_t buf[BUFLEN]; 
    int ret; 
    ret = MultiByteToWideChar(CP_ACP, 0, "1234567890123456789", -1, 
                              buf, sizeof(buf)); 
    printf("%d\n", ret); 
}

In diesem Fall wurde die Anzahl der Byte statt der Anzahl der Zeichen verwendet, um die Größe des Puffers zu deklarieren, und es kommt zu einem Überlauf. Um diese Schwäche zu beheben, sollte das letzte Argument von MultiByteToWideChar sizeof(buf)/sizeof(buf[0])sein.

Sowohl vulnerable1 als auch vulnerable2 sind häufige Fehler, die einfach verhindert werden können. Wenn sie jedoch bei einer Codeprüfung übersehen werden, kann eine Anwendung potenziell gefährliche Sicherheitsmängel aufweisen. Das ist der Grund, warum Visual C++ .NET Sicherheitsprüfungen einführt, die Pufferüberläufe sowohl in vulnerable1 als auch in vulnerable2 daran hindern würden, schädlichen Code in die anfällige Anwendung einzuschleusen.

 

Anatomie des x86-Stacks

Um vollständig zu verstehen, wie die Umgebung, in der ein Pufferüberlauf ausgenutzt werden kann, und Sicherheitsprüfungen funktionieren, muss das Layout des Stacks vollständig verstanden werden. In der x86-Architektur wachsen Stacks abwärts, d. h. dass neue Daten an Adressen gespeichert werden, die niedriger sind als die der Elemente, die zuvor mit push auf den Stack geschoben wurden. Jeder Funktionsaufruf erzeugt einen neuen Stack-Rahmen mit dem folgenden Layout. Beachten Sie bitte, dass sich hohe Adressen oben in der Liste befinden:

  • Funktionsparameter

  • Rücksprungadresse der Funktion

  • Rahmenzeiger

  • Ausnahmebehandlungsrahmen

  • Lokal deklarierte Variablen und Puffer

  • Register zum Speichern des Aufgerufenen

Aus dem Layout wird deutlich, dass ein Pufferüberlauf die Möglichkeit hat, andere, vor dem Puffer zugeordnete Variablen, den Ausnahmerahmen, den Rahmenzeiger, die Rücksprungadresse und die Funktionsparameter zu überschreiben. Um die Ausführung des Programms zu übernehmen, muss ein Wert in die Daten geschrieben werden, die später in das Register EIP geladen werden. Die Rücksprungadresse der Funktion ist einer dieser Werte. Bei einer klassischen Pufferüberlsausnutzung wird die Rücksprungadresse "überlaufen" und dann die Rücksprunganweisung der Funktion veranlasst, die Rücksprungadresse in EIP zu laden.

Die Datenelemente werden auf folgende Weise im Stack gespeichert: Die Funktionsparameter werden mit push im Stack abgelegt, bevor die Funktion aufgerufen wird. Die Parameter werden von links nach rechts mit push abgelegt. Die Rücksprungadresse der Funktion wird mit der x86-Anweisung CALL, die den aktuellen Wert des Registers EIP speichert, im Stack abgelegt. Der Rahmenzeiger ist der vorherige Wert des Registers EBP. Er wird im Stack abgelegt, wenn die FPO (Frame Pointer Omission)-Optimierung nicht stattfindet. Demzufolge wird der Rahmenzeiger nicht immer in einem Stack-Rahmen abgelegt.

Wenn eine Funktion try/catch oder ein anderes Ausnahmebehandlungskonstrukt enthält, fügt der Compiler Ausnahmebehandlungsinformationen in den Stack ein. Im Anschluss daran werden die lokal deklarierten Variablen und Puffer zugeordnet. Die Reihenfolge dieser Zuordnungen kann sich ändern, je nachdem, welche Optimierungen stattfinden. Schließlich werden die Register zum Speichern des Aufgerufenen wie ESI, EDI und EBX gespeichert, falls sie in der Funktionsausführung verwendet werden.

 

Laufzeitprüfungen

Pufferüberläufe sind häufige Fehler, die von C- bzw. C++-Programmierern gemacht werden. Sie zählen potenziell zu den gefährlichsten Fehlern. Visual C++ .NET bietet Tools, die es Programmierern erleichtern können, diese Fehler im Entwicklungszyklus zu finden, sodass sie behoben werden können. Der Schalter /GZ aus Visual C++ 6.0 wurde in den Schalter /RTC1 von Visual C++ .NET übernommen. Der Schalter /RTC1 ist ein Alias für /RTCsu, wobei s für Stack-Prüfungen (dem Kernpunkt dieser Ausführungen) und u für Prüfungen nicht initialisierter Variablen steht. Alle im Stack zugeordneten Puffer sind an den Grenzen markiert, wodurch Überläufe und Unterläufe abgefangen werden können. Kleine Überläufe ändern zwar möglicherweise nicht die Ausführung des Programms, können jedoch Daten nahe dem Puffer beschädigen, was unbemerkt bleiben kann.

Die Laufzeitprüfungen sind nützlich für Entwickler, die nicht nur sicheren Code schreiben möchten, sondern denen auch das grundlegende Problem, korrekten Code zu schreiben, am Herzen liegt. Die Laufzeitprüfungen funktionieren jedoch nur bei Debug-Builds. Diese Funktionalität war nie dazu gedacht, im Produktionscode eingesetzt zu werden. Allerdings bietet die Durchführung von Prüfungen auf Pufferüberläufe im Produktionscode einen offensichtlichen Nutzen. Die Durchführung der Prüfungen würde ein Design erfordern, das viel geringere Auswirkungen auf die Leistung hat als die Laufzeitprüfungsimplementierung. Dieser Forderung genügend führt der Visual C++ .NET-Compiler den Schalter /GS ein.

 

Funktionsweise von /GS

Der Schalter /GS stellt einen "Bremsklotz" bzw. ein Cookie zwischen dem Puffer und der Rücksprungadresse bereit. Wenn ein Überlauf die Rücksprungadresse überschreibt, muss er das Cookie überschreiben, das sich zwischen dieser und dem Puffer befindet, was zu einem neuen Stack-Layout führt:

  • Funktionsparameter

  • Rücksprungadresse der Funktion

  • Rahmenzeiger

  • Cookie

  • Ausnahmebehandlungsrahmen

  • Lokal deklarierte Variablen und Puffer

  • Register zum Speichern des Aufgerufenen

Das Cookie wird später noch ausführlicher behandelt. Die Ausführung der Funktion ändert sich mit diesen Sicherheitsprüfungen. Wenn eine Funktion aufgerufen wird, befinden sich die ersten auszuführenden Anweisungen im Prolog der Funktion. Ein Prolog ordnet mindestens Platz für die lokalen Variablen im Stack zu, so wie in der folgenden Anweisung:

sub   esp,20h

Diese Anweisung reserviert 32 Byte zur Verwendung durch lokale Variablen in der Funktion. Wenn die Funktion mit /GS kompiliert wird, reserviert der Prolog der Funktion zusätzliche vier Byte und fügt drei weitere Anweisungen hinzu:

sub   esp,24h 
mov   eax,dword ptr [___security_cookie (408040h)] 
xor   eax,dword ptr [esp+24h] 
mov   dword ptr [esp+20h],eax

Der Prolog enthält eine Anweisung, die eine Kopie des Cookies abruft, gefolgt von einer Anweisung, die ein logisches xor des Cookies und der Rücksprungadresse durchführt. Darauf folgt schließlich eine Anweisung, die das Cookie direkt unterhalb der Rücksprungadresse im Stack speichert. Ab diesem Punkt wird die Funktion wie gewöhnlich ausgeführt. Wenn eine Funktion zurückspringt, muss als Letztes der Epilog der Funktion ausgeführt werden, bei dem es sich um das Gegenteil des Prologs handelt. Ohne Sicherheitsprüfungen gibt der Epilog den Stack-Platz frei und springt zurück, wie in den folgenden Anweisungen:

add   esp,20h 
ret

Wenn mit /GS kompiliert wird, werden auch die Sicherheitsprüfungen in den Epilog eingefügt:

mov   dword ptr [esp+20h],eax 
xor   eax,dword ptr [esp+24h] 
add   esp,24h 
jmp   __security_check_cookie (4010B2h)

Die Kopie des Cookies des Stacks wird abgerufen und folgt dann die XOR-Anweisung mit der Rücksprungadresse. Das Register ECX sollte einen Wert enthalten, der mit dem Original-Cookie übereinstimmt, das in der Variablen __security_cookie gespeichert ist. Der Stack-Platz wird dann freigegeben, anschließend wird, statt die Anweisung RET auszuführen, die Anweisung JMP mit der Routine __security_check_cookie als Ziel ausgeführt.
Die Routine __security_check_cookie ist simpel: Wenn das Cookie unverändert war, führt sie die Anweisung RET aus und beendet den Funktionsaufruf. Wenn das Cookie nicht übereinstimmt, ruft die Routine report_failure auf. Die Funktion report_failure ruft dann __security_error_handler(_SECERR_BUFFER_OVERRUN, NULL). auf. Beide Funktionen sind in der Datei seccook.c der CRT (C Run-Time)-Quellcodedateien definiert.

 

Die Fehlerbehandlungsroutine

Es wird CRT-Unterstützung benötigt, damit diese Sicherheitsprüfungen funktionieren. Wenn ein Sicherheitsprüfungsfehler auftritt, wird die Programmsteuerung __security_error_handler übergeben (siehe die folgende Zusammenfassung):

void __cdecl __security_error_handler(int code, void *data) 
{ 
    if (user_handler != NULL) { 
      __try { 
        user_handler(code, data); 
      } __except (EXCEPTION_EXECUTE_HANDLER) {} 
    } else { 
      //...outmsg vorbereiten... 
      __crtMessageBoxA( 
          outmsg, 
          "Microsoft Visual C++ Runtime Library", 
          MB_OK|MB_ICONHAND|MB_SETFOREGROUND|MB_TASKMODAL); 
    } 
    _exit(3); 
}

Standardmäßig zeigt eine Anwendung, die eine Sicherheitsprüfung nicht besteht, ein Dialogfeld an, das besagt "Buffer overrun detected!" (Pufferüberlauf erkannt!). Wenn das Dialogfeld geschlossen wird, wird die Anwendung beendet. Die CRT-Bibliothek bietet dem Entwickler die Möglichkeit, eine andere Behandlungsroutine zu verwenden, die auf den Pufferüberlauf in einer Weise reagiert, die der Anwendung angemessener ist. Die Funktion __set_security_error_handler wird verwendet, um die Behandlungsroutine des Benutzers zu installieren, indem diese in der Variablen user_handler gespeichert wird (siehe dazu das folgende Beispiel):

void __cdecl report_failure(int code, void * unused) 
{ 
    if (code == _SECERR_BUFFER_OVERRUN) 
      printf("Buffer overrun detected!\n"); 
} 
void main() 
{ 
    _set_security_error_handler(report_failure); 
    // Weiterer Code folgt 
}

Ein erkannter Pufferüberlauf in dieser Anwendung gibt eine Meldung im Konsolenfenster aus, statt ein Dialogfeld anzuzeigen. Obwohl die Behandlungsroutine des Benutzers das Programm nicht explizit beendet, wird das Programm, wenn die Behandlungsroutine des Benutzers zurückspringt, durch __security_error_handler mit einem Aufruf von _exit(3) beendet. Sowohl die Funktion __security_error_handler als auch die Funktion _set_security_error_handler befindet sich in der Datei secfail.c der CRT-Quellcodedateien.

Es ist hilfreich, zu erörtern, was in der Behandlungsroutine des Benutzers erfolgen sollte. Eine gängige Reaktion wäre, eine Ausnahme per throw zu übergeben. Weil Ausnahmeinformationen im Stack gespeichert sind, kann jedoch die Übergabe einer Ausnahme per throw die Steuerung an einen beschädigten Ausnahmerahmen übergeben. Um dies zu verhindern, bettet die Funktion __security_error_handler den Aufruf der Benutzerfunktion in einen __try/__except-Block ein, der alle Ausnahmen erfasst, gefolgt von der Beendigung des Programms. Der Entwickler sollte DebugBreak nicht aufrufen, da es eine Ausnahme auslöst, oder longjmp verwenden. Die Behandlungsroutine des Benutzers sollte lediglich den Fehler melden und möglicherweise ein Protokoll erstellen, sodass der Pufferüberlauf behoben werden kann.

Manchmal will ein Entwickler __security_error_handler neu schreiben, statt _set_security_error_handler zu verwenden, um das gleiche Ziel zu erreichen. Das Neuschreiben ist fehleranfällig, und die Hauptbehandlungsroutine ist so wichtig, dass eine nicht korrekte Implementierung zu gefährlichen Ergebnissen führen kann.

 

Das Cookie ist ein Zufallswert mit der gleichen Größe wie ein Zeiger, d. h. ein Cookie ist bei der x86-Architektur vier Byte lang. Der Wert wird in der Variablen __security_cookie zusammen mit anderen globalen CRT-Daten gespeichert. Der Wert wird durch einen Aufruf von __security_init_cookie aus der Datei seccinit.c der CRT-Quellcodedateien mit einem Zufallswert initialisiert. Die Zufälligkeit des Cookies ergibt sich aus den Prozessorzählern. Jedes Image (d. h. jede DLL- oder EXE-Datei, die mit /GS kompiliert wurde) hat beim Laden einen anderen Cookie-Wert.

Es können zwei Probleme auftreten, wenn begonnen wird, Anwendungen mit dem Compiler-Schalter /GS zu erstellen. Erstes Problem: Bei Anwendungen, die keine CRT-Unterstützung beinhalten, fehlt ein Zufalls-Cookie, weil der Aufruf von __security_init_cookie während der CRT-Initialisierung erfolgt. Wenn das Cookie beim Laden nicht mit einem Zufallswert definiert wird, bleibt die Anwendung anfällig für Angriffe, wenn ein Pufferüberlauf erkannt wird. Um dieses Problem zu beheben, muss die Anwendung __security_init_cookie beim Start explizit aufrufen. Zweites Problem: Bei alten Anwendungen, die die dokumentierte Funktion _CRT_INIT aufrufen, um die Initialisierung durchzuführen, können unerwartete Sicherheitsprüfungsfehler auftreten (siehe dazu das folgende Beispiel):

DllEntryPoint(...) { 
    char buf[_MAX_PATH];   // Ein Puffer, der Sicherheitsprüfungen auslöst 
    ... 
    _CRT_INIT(); 
    ... 
}

Das Problem ist, dass der Aufruf von _CRT_INIT den Wert des Cookies ändert, während eine bereits für eine Sicherheitsprüfung eingerichtete Funktion aktiv ist. Weil der Wert des Cookies beim Beenden der Funktion anders ist, kommt die Sicherheitsprüfung zu dem Schluss, dass ein Pufferüberlauf aufgetreten ist. Die Lösung besteht darin, die Deklaration von Puffern in aktiven Funktionen vor dem Aufruf von _CRT_INIT zu vermeiden. Derzeit gibt es eine provisorische Lösung, indem die Funktion _alloca verwendet wird, um den Puffer im Stack zuzuordnen, weil der Compiler keine Sicherheitsprüfung generiert, wenn die Zuordnung mit _alloca erfolgt. Es kann nicht garantiert werden, dass diese provisorische Lösung auch in zukünftigen Versionen von Visual C++ funktionieren wird.

 

Auswirkungen auf die Leistung

Für die Verwendung von Sicherheitsprüfungen in einer Anwendung müssen Leistungsabwägungen getroffen werden. Das Visual- C++-Compilerteam hat sich darauf konzentriert, die Leistungseinbußen gering zu halten. In den meisten Fällen sollte die Leistung um nicht mehr als 2 Prozent abnehmen. In der Praxis hat sich gezeigt, dass bei den meisten Anwendungen, einschließlich Hochleistungsserveranwendungen, keinerlei Leistungseinbußen feststellbar waren.

Der wichtigste Faktor bei der Vermeidung problematischer Leistungseinbußen ist, sich ausschließlich auf Funktionen zu konzentrieren, die für Angriffe anfällig sind. Derzeit gilt eine Funktion als anfällig, wenn sie einen Typ von Zeichenfolgenpuffer im Stack zuordnet. Bei einem Zeichenfolgenpuffer, der als anfällig angesehen wird, werden mehr als vier Byte Speicherplatz zugeordnet, und jedes Element des Puffers umfasst entweder ein oder zwei Byte. Kleine Puffer sind ein unwahrscheinliches Angriffsziel, und die Beschränkung der Anzahl von Funktionen mit Sicherheitsprüfungen beschränkt das Anwachsen des Codeumfangs. Bei den meisten Programmdateien kommt es zu keiner Vergrößerung, wenn sie mit /GS kompiliert werden.

 

Beispiele

Der Schalter /GS verhindert keine Pufferüberläufe, kann jedoch verhindern, dass Pufferüberläufe in bestimmten Situationen ausgenutzt werden. vulnerable1 und vulnerable2 sind gegen eine Ausnutzung immun, wenn sie mit dem Schalter /GS kompiliert werden. Jede Funktion, bei der ein Pufferüberlauf die letzte Aktion ist, die vor dem Rücksprung erfolgt, ist immun gegen eine Ausnutzung. Weil ein Pufferüberlauf bei der Ausführung einer Funktion frühzeitig auftreten kann, gibt es Umstände, unter denen eine Sicherheitsprüfung entweder nicht die Gelegenheit hat, den Pufferüberlauf zu erkennen, oder die Sicherheitsprüfung selbst vom Überlauf betroffen war (siehe dazu die folgenden Beispiele).

Beispiel 1

class Vulnerable3 { 
public: 
    int value; 
    Vulnerable3() { value = 0; } 
    virtual ~Vulnerable3() { value = -1; } 
}; 
void vulnerable3(char * pStr) { 
    Vulnerable3 * vuln = new Vulnerable3; 
    char buf[20]; 
    strcpy(buf, pStr); 
    delete vuln; 
}

In diesem Fall wird ein Zeiger auf ein Objekt mit virtuellen Funktionen im Stack zugeordnet. Weil das Objekt virtuelle Funktionen hat, enthält es einen vtable-Zeiger. Ein Angreifer könnte diese Gelegenheit nutzen und einen bösartigen pStr-Wert liefern, um einen Überlauf von buf zu verursachen. Bevor die Funktion zurückspringt, ruft der Operator delete den virtuellen Destruktor für vuln auf. Dazu muss die Destruktorfunktion in der vtable gesucht werden, die nun übernommen wurde. Die Ausführung des Programms wird übernommen, bevor die Funktion zurückspringt, sodass die Sicherheitsprüfungen nie die Möglichkeit hatten, den Pufferüberlauf zu erkennen.

Beispiel 2

void vulnerable4(char *bBuff, in cbBuff) { 
    char bName[128]; 
    void (*func)() = MyFunction; 
    memcpy(bName, bBuff, cbBuff); 
    (func)(); 
}

In diesem Fall ist die Funktion anfällig für einen Zeigertrickangriff. Wenn der Compiler Platz für die zwei lokalen Adressen zuordnet, stellt er die Variable func vor pName. Der Grund hierfür ist, dass der Optimierer die Effizienz des Codes bei diesem Layout verbessern kann. Unglücklicherweise ermöglicht dies einem Angreifer, einen bösartigen Wert für bBuff zu liefern. Ein Angreifer kann auch den Wert von cbBuff liefern, das die Größe von bBuff angibt. Die Funktion lässt fälschlicherweise die Prüfung, ob cbBuff kleiner oder gleich 128 ist, weg. Der Aufruf von memcpy kann daher zu einem Überlauf des Puffers führen und den Wert von func zerstören. Weil der func-Zeigertrick verwendet wird, um die Funktion aufzurufen, auf die der Zeiger verweist, bevor die Funktion vulnerable4 zurückspringt, findet der Angriff statt, bevor die Sicherheitsprüfung erfolgen konnte.

Beispiel 3

int vulnerable5(char * pStr) { 
    char buf[32]; 
    char * volatile pch = pStr; 
    strcpy(buf, pStr); 
    return *pch == '\0'; 
} 
int main(int argc, char* argv[]) { 
    __try { vulnerable5(argv[1]); } 
    __except(2) { return 1; } 
    return 0; 
}

Dieses Programm zeigt ein besonders schwieriges Problem, weil darin eine strukturierte Ausnahmebehandlung verwendet wird. Wie bereits gesagt, legen Funktionen, die eine Ausnahmebehandlung verwenden, Informationen - etwa Informationen zu den jeweiligen Ausnahmebehandlungsfunktionen - im Stack ab. In diesem Fall kann der Ausnahmebehandlungsrahmen in der Funktion main angegriffen werden, obwohl vulnerable5 die Schwachstelle enthält. Ein Angreifer wird die Gelegenheit nutzen, einen Überlauf von buf zu erzeugen und sowohl pch als auch den Ausnahmebehandlungsrahmen von main zu zerstören. Weil die Funktion vulnerable5 später pch dereferenziert, könnte der Angreifer, wenn er einen Wert wie Null liefert, eine Zugriffsverletzung verursachen, die wiederum eine Ausnahmebedingung hervorruft. Beim Entladen des Stacks sucht das Betriebssystem in den Ausnahmerahmen nach Ausnahmebehandlungsroutinen, denen es die Steuerung übergeben soll. Weil der Ausnahmebehandlungsrahmen beschädigt wurde, übergibt das Betriebssystem die Steuerung des Programms beliebigem Code, den der Angreifer liefert. Die Sicherheitsprüfungen konnten diesen Pufferüberlauf nicht erkennen, weil die Funktion nicht korrekt zurücksprang.

Bei einigen der bekanntesten Ausnutzungen sind jüngst Ausnahmebehandlungsausnutzungen verwendet worden. Eine der bemerkenswertesten war der Virus Code Red, der im Sommer 2001 auftrat. Windows XP schafft bereits eine Umgebung, in der Ausnahmebehandlungsangriffe erschwert sind, weil die Adresse der Ausnahmebehandlungsroutine nicht im Stack sein darf und alle Register auf null gesetzt werden, bevor die Ausnahmebehandlungsroutine aufgerufen wird.

Beispiel 4

void vulnerable6(char * pStr) { 
    char buf[_MAX_PATH]; 
    int * pNum; 
    strcpy(buf, pStr); 
    sscanf(buf, "%d", pNum); 
}

Diese Funktion kann bei Kompilierung mit /GS, anders als bei den vorherigen drei Beispielen, die Ausführung nicht übernehmen, indem einfach der Puffer zum Überlauf gebracht wird. Diese Funktion erfordert einen Angriff in zwei Etappen, um die Ausführung des Programms zu übernehmen. Die Tatsache, dass pNum vor buf zugeordnet wird, macht sie dafür anfällig, durch einen beliebigen Wert überschrieben zu werden, der von der Zeichenfolge pStr geliefert wird. Ein Angreifer müssten zum Überschreiben vier Byte des Speichers wählen. Überschriebe der Puffer das Cookie, besteht eine Gelegenheit darin, den Zeiger der Benutzerbehandlungsfunktion zu übernehmen, der in der Variablen user_handler gespeichert ist, oder aber den Wert, der in der Variablen __security_cookie gespeichert ist. Wenn das Cookie nicht überschrieben wird, müsste der Angreifer die Adresse der Rücksprungadresse für eine Funktion wählen, die keine Sicherheitsprüfungen enthält. In diesem Fall würde das Programm normal ausgeführt und ohne Kenntnis des Pufferüberlaufs von Funktionen zurückspringen. Kurze Zeit später wird das Programm unbemerkt übernommen.

Anfälliger Code könnte auch weiteren Angriffen ausgesetzt sein, die von /GS nicht berücksichtigt werden, etwa einem Pufferüberlauf im Heap. Angriffe nach dem Muster "Index außerhalb des Bereichs", die auf bestimmte Indizes für ein Feld abzielen, statt sequenziell in das Feld zu schreiben, werden von /GS ebenfalls nicht berücksichtigt. Ein ungeprüfter Index außerhalb des Bereichs kann im Wesentlichen auf jeden Teil des Speichers abzielen und das Überschreiben des Cookies vermeiden. Eine weitere Form von ungeprüftem Index ist ein Konflikt um Ganzzahlen mit/ohne Vorzeichen, bei dem einem Feldindex eine negative Zahl geliefert wurde. Einfach nur zu prüfen, ob der Index kleiner ist als die Größe des Feldes, genügt nicht, wenn es sich beim Index um eine Ganzzahl mit Vorzeichen handelt. Schließlich berücksichtigen die /GS-Sicherheitsprüfungen im Allgemeinen keine Pufferunterläufe.

 

Fazit

Pufferüberläufe können zweifelsfrei eine kritische Schwäche in einer Anwendung darstellen. Zunächst einmal kann das Schreiben von straffem, sicherem Code durch nichts ersetzt werden. Entgegen der landläufigen Meinung sind nur die wenigsten Pufferüberläufe schwer zu entdecken. Der Schalter /GS ist ein nützliches Tool für Entwickler, die daran interessiert sind, sicheren Code zu schreiben. Damit lässt sich jedoch nicht das Problem beheben, dass Pufferüberläufe im Code vorhanden sind. Auch mit den Sicherheitsprüfungen, die verhindern, dass der Pufferüberlauf in manchen Fällen ausgenutzt wird, wird das Programm weiterhin beendet, wodurch es sich um einen "Denial of Service"-Angriff handelt, speziell gegen Servercode. Die Programmerstellung mit /GS ist für Entwickler eine sichere Methode, die Risiken von anfälligen Puffern, deren sie sich nicht bewusst waren, zu mindern.
Es gibt zwar Tools, um mögliche Schwächen wie die in diesem Artikel behandelten zu markieren, diese arbeiten jedoch nachweislich lückenhaft. Es gibt nichts Besseres als eine gute Codeprüfung durch Entwickler, die wissen, worauf sie achten müssen. Das Buch Writing Secure Code von Michael Howard und David LeBlanc bietet eine exzellente Abhandlung zu vielen weiteren Möglichkeiten, das Risiko beim Schreiben von hochsicheren Anwendungen zu mindern.

Siehe auch:
Visual C++ .NET-Artikel