Furchtlose Verteidigung. Gedächtnissicherheit in Rust

Published on January 31, 2019

Furchtlose Verteidigung. Gedächtnissicherheit in Rust

Ursprünglicher Autor: Diane Hosfelt
  • Übersetzung
Letztes Jahr veröffentlichte Mozilla Quantum CSS für Firefox, das in acht Jahren der Entwicklung von Rust, einer speichersicheren Systemprogrammiersprache, gipfelte. Es dauerte mehr als ein Jahr, um die Hauptkomponente des Browsers in Rust umzuschreiben.

Bisher wurden alle wichtigen Browser-Engines hauptsächlich aus Effizienzgründen in C ++ geschrieben. Mit großer Leistung ist jedoch eine große Verantwortung verbunden: C ++ - Programmierer müssen den Speicher manuell verwalten, wodurch die Sicherheitslücke der Pandora geöffnet wird. Rust beseitigt nicht nur solche Fehler, sondern auch die Vermeidung von Daten , wodurch Programmierer parallelen Code effektiver implementieren können.


Was ist Gedächtnissicherheit?


Wenn wir über das Erstellen sicherer Anwendungen sprechen, wird häufig die Speichersicherheit erwähnt. Inoffiziell meinen wir, dass ein Programm in keinem Zustand auf ungültigen Speicher zugreifen kann. Ursachen für Sicherheitsverletzungen:

  • Speichern des Zeigers nach dem Freigeben von Speicher (Use-After-Free);
  • Nullzeiger-Dereferenzierung;
  • Verwendung von nicht initialisiertem Speicher;
  • Programmierversuch, um dieselbe Zelle zweimal freizugeben (doppelt frei);
  • Pufferüberlauf.

Eine formellere Definition finden Sie in dem Artikel „Was ist Sicherheit im Gedächtnis“ von Michael Hicks sowie in einem wissenschaftlichen Artikel zu diesem Thema.

Solche Verstöße können zu einem unerwarteten Fehlschlag oder einer Änderung des beabsichtigten Verhaltens des Programms führen. Mögliche Folgen: Informationsverlust, Ausführung von willkürlichem Code und Ausführung von Remote-Code.

Speicherverwaltung


Die Speicherverwaltung ist entscheidend für die Anwendungsleistung und -sicherheit. In diesem Abschnitt betrachten wir das grundlegende Speichermodell. Eines der Schlüsselkonzepte sind Zeiger . Dies sind Variablen, die Speicheradressen speichern. Wenn wir zu dieser Adresse gehen, werden wir dort einige Daten sehen. Daher sagen wir, dass der Zeiger eine Verknüpfung zu diesen Daten ist (oder darauf verweist). So wie die Heimatadresse den Leuten sagt, wo Sie Sie finden, zeigt die Speicheradresse dem Programm an, wo die Daten zu finden sind.

Alles im Programm befindet sich an bestimmten Speicheradressen, einschließlich Code-Anweisungen. Die falsche Verwendung von Zeigern kann zu schwerwiegenden Sicherheitsanfälligkeiten führen, einschließlich Informationsverlusten und der Ausführung von beliebigem Code.

Zuteilung / Freigabe


Wenn Sie eine Variable erstellen, muss das Programm genügend Speicherplatz zum Speichern der Daten dieser Variablen reservieren. Da jeder eine begrenzte Menge an Speicher, natürlich verarbeiten, müssen wir einen Weg zu lösen Ressourcen. Wenn der Speicher freigegeben wird, steht er zum Speichern neuer Daten zur Verfügung. Die alten Daten bleiben jedoch dort, bis die Zelle überschrieben wird.

Puffer


Ein Puffer ist ein zusammenhängender Speicherbereich, in dem mehrere Instanzen desselben Datentyps gespeichert sind. Zum Beispiel wird der Satz "Meine Katze ist Batman" in einem 16-Byte-Puffer gespeichert. Die Puffer werden nach Startadresse und Länge bestimmt. Um die Daten im Nachbarspeicher nicht zu beschädigen, ist es wichtig sicherzustellen, dass wir nicht außerhalb des Puffers lesen oder schreiben.

Kontrollfluss


Programme bestehen aus Unterprogrammen, die in einer bestimmten Reihenfolge ausgeführt werden. Am Ende der Subroutine wechselt der Computer zum gespeicherten Zeiger zum nächsten Teil des Codes (der als Rücksprungadresse bezeichnet wird ). Wenn Sie zur Rücksendeadresse gehen, passiert eines von drei Dingen:

  1. Der Prozess wird normal fortgesetzt (die Absenderadresse wird nicht geändert).
  2. Der Prozess stürzt ab (die Adresse wird geändert und zeigt nicht ausführbaren Speicher an).
  3. Der Prozess wird fortgesetzt, jedoch nicht wie erwartet (die Rücksprungadresse wurde geändert und der Steuerungsfluss wurde geändert).

Wie Sprachen Speichersicherheit bieten


Alle Programmiersprachen gehören zu verschiedenen Teilen des Spektrums . Zum einen Spektrum - Sprachen wie C / C ++. Sie sind effektiv, erfordern jedoch eine manuelle Speicherverwaltung. Andererseits werden interpretierte Sprachen mit automatischer Speicherverwaltung (z. B. Referenzzählung und Speicherbereinigung (Garbage Collection, GC)) verwendet, die sich jedoch mit Leistung bezahlt machen. Auch Sprachen mit gut optimierte Garbage Collection können nicht die passen Leistung von einer Sprache ohne GC.

Manuelle Speicherverwaltung


In einigen Sprachen (z. B. C) müssen Programmierer den Speicher manuell verwalten: Wann und wie viel Speicherplatz zugewiesen wird, wann er freigegeben wird. Dies gibt dem Programmierer die vollständige Kontrolle über die Ressourcennutzung des Programms und liefert schnellen und effizienten Code. Dieser Ansatz ist jedoch insbesondere in komplexen Codebasen fehleranfällig.

Fehler, die leicht zu machen sind:

  • Vergessen Sie, dass Ressourcen freigegeben werden, und versuchen Sie, sie zu verwenden.
  • Weisen Sie nicht genügend Speicherplatz für die Datenspeicherung zu.
  • Speicher aus dem Puffer lesen.


Geeignete Sicherheitshinweise für manuelle Speichermanager.

Intelligente Zeiger


Intelligente Zeiger werden mit zusätzlichen Informationen versehen, um Speichermissbrauch zu verhindern. Sie werden zur automatischen Speicherverwaltung und zur Überprüfung der Grenzen verwendet. Im Gegensatz zu einem normalen Zeiger kann ein intelligenter Zeiger sich selbst zerstören und wartet nicht, bis der Programmierer ihn manuell löscht.

Es gibt verschiedene Varianten einer solchen Konstruktion, die den Quellzeiger in mehrere nützliche Abstraktionen einschließt. Einige intelligente Zeiger zählen Verweise auf jedes Objekt, während andere eine Bereichsrichtlinie implementieren, um die Lebensdauer des Zeigers auf bestimmte Bedingungen zu beschränken.

Bei der Linkzählung werden Ressourcen freigegeben, wenn der letzte Objektlink gelöscht wird. Grundlegende Referenzzählungsimplementierungen leiden unter schlechter Leistung, erhöhtem Speicherverbrauch und sind in Umgebungen mit mehreren Threads nur schwer zu verwenden. Wenn sich Objekte auf einander beziehen (Zirkelverweise), wird die Referenzzählung für jedes Objekt niemals Null erreichen, sodass komplexere Methoden erforderlich sind.

Müllsammlung


In einigen Sprachen (z. B. Java, Go, Python) ist die Garbage Collection implementiert . Der Teil der Laufzeitumgebung, der als Garbage Collector (GC) bezeichnet wird, verfolgt Variablen und identifiziert nicht verfügbare Ressourcen im Verknüpfungsdiagramm zwischen Objekten. Sobald das Objekt nicht mehr verfügbar ist, gibt der GC den Basisspeicher für die spätere Wiederverwendung frei. Jegliche Zuweisung und Freigabe von Speicher erfolgt ohne expliziten Programmierbefehl.

Obwohl GC sicherstellt, dass Speicher immer korrekt verwendet wird, gibt er Speicher nicht auf die effizienteste Weise frei - manchmal erfolgt die letzte Verwendung eines Objekts viel früher, als der Garbage Collector Speicher freigibt. Die Leistungskosten sind für kritische Anwendungen unerschwinglich: Um Leistungsbeeinträchtigungen zu vermeiden, müssen Sie manchmal fünfmal mehr Arbeitsspeicher verwenden.

Besitz


In Rust wird Eigentum verwendet, um hohe Leistung und Speichersicherheit zu gewährleisten. Formal ist dies ein Beispiel für affines Tippen . Der gesamte Rust-Code folgt bestimmten Regeln, die es dem Compiler ermöglichen, Speicher zu verwalten, ohne die Laufzeit zu verlieren:

  1. Jeder Wert hat eine Variable, die Besitzer genannt wird.
  2. Nur ein Besitzer zur Zeit.
  3. Wenn der Besitzer den Gültigkeitsbereich verlässt, wird der Wert entfernt.

Werte können von einer Variablen zu einer anderen übertragen oder ausgeliehen werden . Diese Regeln wenden einen Teil des Compilers an, der als Entlehnungsprüfer bezeichnet wird.

Wenn eine Variable den Gültigkeitsbereich verlässt, gibt Rust diesen Speicher frei. Im folgenden Beispiel werden die Variablen s1und s2über den Bereich, die beide versuchen , den gleichen Speicher freizugeben, die zu einem Doppelfreien Fehler führt. Um dies zu verhindern, wird der vorherige Besitzer ungültig, wenn Sie einen Wert aus einer Variablen übertragen. Wenn der Programmierer dann versucht, eine ungültige Variable zu verwenden, lehnt der Compiler den Code ab. Dies kann durch Erstellen einer tiefen Kopie der Daten oder durch Verwendung von Links vermieden werden.

Beispiel 1 : Eigentumsübergang

let s1 = String::from("hello");
let s2 = s1;
//won't compile because s1 is now invalid
println!("{}, world!", s1);

Ein weiterer Satz von Regeln zum Ausleihen von Checkern bezieht sich auf die Lebensdauer von Variablen. Rust verbietet die Verwendung von nicht initialisierten Variablen und hängenden Zeigern auf nicht vorhandene Objekte. Wenn Sie den Code aus dem folgenden Beispiel kompilieren, rverweist er auf den Speicher, der freigegeben wird, wenn er den xGültigkeitsbereich verlässt: Ein baumelnder Zeiger wird angezeigt. Der Compiler verfolgt alle Bereiche und prüft die Zulässigkeit aller Trennungen, sodass der Programmierer manchmal die Lebensdauer einer Variablen explizit angeben muss.

Beispiel 2 : Hängender Index

let r;
{
  let x = 5;
  r = &x;
}
println!("r: {}", r);

Das Eigentumsmodell bietet eine solide Grundlage für den korrekten Speicherzugriff und verhindert undefiniertes Verhalten.

Schwachstellen im Speicher


Die Hauptfolgen von anfälligem Gedächtnis:

  1. Fehler : Der Zugriff auf ungültigen Speicher kann zu einer unerwarteten Programmbeendigung führen.
  2. Informationsverlust : unbeabsichtigte Bereitstellung privater Daten, einschließlich vertraulicher Informationen wie Kennwörter.
  3. Random Code Execution (ACE) : Ermöglicht einem Angreifer, beliebige Befehle auf dem Zielcomputer auszuführen. Wenn dies über das Netzwerk geschieht, nennen wir es Remote Code Execution (RCE).

Ein weiteres Problem ist ein Speicherverlust , wenn der zugewiesene Speicher nach Programmende nicht freigegeben wird. So können Sie den gesamten verfügbaren Speicher verbrauchen: Dann werden Anforderungen für Ressourcen blockiert, was zu einem Denial-of-Service führt. Dies ist ein Speicherproblem, das auf Ebene von PL nicht gelöst werden kann.

Wenn ein Speicherfehler auftritt, stürzt die Anwendung am besten ab. Im schlimmsten Fall erhält der Angreifer die Kontrolle über das Programm durch Verwundbarkeit (was zu weiteren Angriffen führen kann).

Missbrauch des freigegebenen Speichers (Use-After-Free, Double Free)


Diese Unterklasse von Sicherheitsanfälligkeiten entsteht, wenn eine Ressource freigegeben wird, der Link zu seiner Adresse jedoch erhalten bleibt. Dies ist eine leistungsfähige Hacker-Methode , die zu einem Zugriff außerhalb des Bereichs, zu Informationsverlust, zur Codeausführung und vielem mehr führen kann.

Sprachen mit Garbage Collection und Link Counting verhindern die Verwendung ungültiger Zeiger und zerstören nur Objekte, auf die nicht zugegriffen werden kann (was zu einer schlechten Leistung führen kann). Manuelle Sprachen unterliegen dieser Sicherheitsanfälligkeit (insbesondere in komplexen Codebasen). Das Werkzeug zum Ausleihe-Checker in Rust erlaubt es nicht, Objekte zu vernichten, solange Links vorhanden sind. Diese Fehler werden daher beim Kompilieren behoben.

Nicht initialisierte Variablen


Wenn eine Variable vor der Initialisierung verwendet wird, können sich in diesem Speicher beliebige Daten befinden, einschließlich zufälliger Speicherbereinigung oder zuvor verworfener Daten, die zu Informationsverlust führen (diese werden manchmal als ungültige Zeiger bezeichnet ). Um diese Probleme zu vermeiden, wird in Sprachen mit Speicherverwaltung häufig nach der Speicherzuweisung ein automatisches Initialisierungsverfahren verwendet.

Wie in C werden die meisten Variablen in Rust nicht initialisiert. Im Gegensatz zu C können Sie sie jedoch nicht vor der Initialisierung lesen. Der folgende Code wird nicht kompiliert:

Beispiel 3 : Verwenden einer nicht initialisierten Variablen

fn main() {
    let x: i32;
    println!("{}", x);
}

Nullzeiger


Wenn eine Anwendung einen Zeiger dereferenziert, der sich als Null herausstellt, greift er normalerweise einfach auf den Papierkorb zu und verursacht einen Absturz. In einigen Fällen können diese Sicherheitsanfälligkeiten zur Ausführung von beliebigem Code führen ( 1 , 2 , 3 ). In Rust gibt es zwei Arten von Indizes: Links und rohe Zeiger (raw Zeiger). Links sind sicher, aber unbehandelte Hinweise können ein Problem sein.

Rust verhindert auf zwei Arten die Dereferenzierung von Nullzeigern:

  1. Vermeidet Zeiger, die Nullwerte zulassen.
  2. Verhindert das De-Referencing von unverarbeiteten Zeigern.

Rust vermeidet Nullzeiger, indem er durch einen speziellen Zeiger ersetzt wird типом Option. Um den Wert von möglicherweise-null in einem Typ zu ändern Option, muss der Programmierer den Fall explizit mit einem Nullwert behandeln, andernfalls kann das Programm nicht kompilieren.

Was tun, wenn Zeiger, die einen Nullwert zulassen, nicht vermieden werden können (z. B. bei der Interaktion mit Code in einer anderen Sprache)? Versuchen Sie, den Schaden zu isolieren. Die Dereferenzierung von unverarbeiteten Zeigern sollte in einem isolierten, unsicheren Block erfolgen. Es lockert die Regeln von Rust und ermöglicht einige Operationen, die zu undefiniertem Verhalten führen können (z. B. Dereferenzieren eines unformatierten Zeigers).


"Alles, was einen Entleiher betrifft ... aber was ist mit diesem dunklen Ort?"
- Dies ist ein unsicherer Block. Geh nie dorthin, Simba


Pufferüberlauf


Wir haben Sicherheitslücken besprochen, die durch Beschränkung des Zugriffs auf nicht definierten Speicher vermieden werden können. Das Problem ist jedoch, dass der Pufferüberlauf falsch auf nicht definierte, sondern auf den gesetzlich zugewiesenen Speicher verweist. Wie der Use-After-Free-Fehler kann ein solcher Zugriff problematisch sein, da er auf den freigegebenen Speicher zugreift, der noch vertrauliche Informationen enthält, die noch nicht vorhanden sein sollten.

Pufferüberlauf bedeutet nur außerhalb der Grenzen. Aufgrund der Art und Weise, wie die Puffer im Speicher abgelegt werden, führen sie häufig zu Informationslecks, die möglicherweise vertrauliche Daten enthalten, einschließlich Passwörter. In schwerwiegenderen Fällen sind ACE / RCE-Sicherheitsanfälligkeiten möglich, indem der Befehlszeiger neu geschrieben wird.

Beispiel 4: Pufferüberlauf (C-Code)

int main() {
  int buf[] = {0, 1, 2, 3, 4};
  // print out of bounds
  printf("Out of bounds: %d\n", buf[10]);
  // write out of bounds
  buf[10] = 10;
  printf("Out of bounds: %d\n", buf[10]);
  return 0;
}

Der einfachste Pufferüberlaufschutz besteht darin, beim Zugriff auf Elemente immer eine Begrenzungsprüfung zu erfordern. Dies führt jedoch zu einer Beeinträchtigung der Leistung .

Was macht Rust? Die integrierten Puffertypen in der Standardbibliothek erfordern eine Randüberprüfung für beliebigen Zugriff, bieten jedoch auch Iterator-APIs, um sequenzielle Aufrufe zu beschleunigen. Dies stellt sicher, dass Lesen und Schreiben über die Grenzen dieser Typen hinaus unmöglich ist. Rust fördert Vorlagen, für die die Überprüfung der Grenzen nur an Stellen erforderlich ist, an denen Sie sie höchstwahrscheinlich manuell in C / C ++ platzieren müssen.

Gedächtnissicherheit ist nur die halbe Miete


Sicherheitsverletzungen führen zu Schwachstellen wie Datenverlust und Remotecodeausführung. Es gibt verschiedene Möglichkeiten, den Speicher zu schützen, einschließlich intelligenter Zeiger und Speicherbereinigung. Sie können sogar die Speichersicherheit formal beweisen . Obwohl sich einige Sprachen mit dem Leistungsabfall aus Gründen der Speichersicherheit auseinandersetzen, bietet das Konzept des Eigentums in Rust Sicherheit und minimiert den Overhead.

Leider sind Speicherfehler nur ein Teil der Geschichte, wenn wir über das Schreiben von sicherem Code sprechen. Im nächsten Artikel werden wir Thread-Sicherheit und Angriffe auf parallelen Code untersuchen.

Ausnutzen von Speicheranfälligkeiten: Weitere Ressourcen