Furchtloser Schutz. Speichersicherheit in Rust

Mozilla hat im vergangenen Jahr Quantum CSS für Firefox veröffentlicht und damit acht Jahre lang Rust entwickelt, eine speicherfreundliche Systemprogrammiersprache. Es dauerte mehr als ein Jahr, um die Hauptbrowserkomponente in Rust neu zu schreiben.

Bisher sind alle wichtigen Browser-Engines hauptsächlich aus Effizienzgründen in C ++ geschrieben. Mit einer hohen Leistung geht jedoch eine große Verantwortung einher: C ++ - Programmierer müssen den Speicher manuell verwalten, wodurch die Schwachstellenbox der Pandora geöffnet wird. Rust behebt nicht nur solche Fehler, sondern verhindert mit seinen Methoden auch Datenrennen , sodass Programmierer parallelen Code effizienter implementieren können.


Was ist Speichersicherheit?


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

  • Speichern des Zeigers nach dem Freigeben des Speichers (Use-After-Free);
  • Dereferenzieren eines Nullzeigers;
  • Verwendung von nicht initialisiertem Speicher;
  • Programmversuch, dieselbe Zelle zweimal freizugeben (doppelt frei);
  • Pufferüberlauf.

Eine formellere Definition finden Sie in Michael Hicks 'What is Memory Security' sowie in einem wissenschaftlichen Artikel zu diesem Thema.

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

Speicherverwaltung


Die Speicherverwaltung ist für die Anwendungsleistung und -sicherheit von entscheidender Bedeutung. In diesem Abschnitt betrachten wir das grundlegende Speichermodell. Eines der Schlüsselkonzepte sind Zeiger . Dies sind Variablen, in denen Speicheradressen gespeichert sind. Wenn wir zu dieser Adresse gehen, werden wir dort einige Daten sehen. Daher sagen wir, dass der Zeiger eine Referenz auf diese Daten ist (oder auf diese zeigt). So wie die Privatadresse den Leuten sagt, wo Sie zu finden sind, zeigt die Speicheradresse dem Programm, 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 Sicherheitslücken führen, einschließlich Informationsverlust und willkürlicher Codeausführung.

Zuordnung / Freigabe


Wenn wir eine Variable erstellen, sollte das Programm genügend Speicherplatz im Speicher zuweisen, um die Daten dieser Variablen zu speichern. Da jeder Prozess nur über eine begrenzte Menge an Speicher verfügt, benötigen Sie natürlich eine Möglichkeit, Ressourcen freizugeben. Wenn der Speicher freigegeben wird, wird er zum Speichern neuer Daten verfügbar, aber die alten Daten bleiben dort, bis die Zelle überschrieben wird.

Puffer


Ein Puffer ist ein zusammenhängender Speicherbereich, in dem mehrere Instanzen desselben Datentyps gespeichert sind. Beispielsweise wird der Ausdruck "Meine Katze ist Batman" in einem 16-Byte-Puffer gespeichert. Puffer werden durch die Startadresse und Länge bestimmt. Um die Daten im benachbarten Speicher nicht zu beschädigen, ist darauf zu achten, dass wir nicht außerhalb des Puffers lesen oder schreiben.

Kontrollfluss


Programme bestehen aus Routinen, die in einer bestimmten Reihenfolge ausgeführt werden. Am Ende des Unterprogramms geht der Computer zum gespeicherten Zeiger auf den nächsten Teil des Codes (als Rücksprungadresse bezeichnet ). Wenn Sie zur Absenderadresse gehen, geschieht eines von drei Dingen:

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

Wie Sprachen Speichersicherheit bieten


Alle Programmiersprachen gehören zu verschiedenen Teilen des Spektrums . Auf der einen Seite des Spektrums stehen Sprachen wie C / C ++. Sie sind effektiv, erfordern jedoch eine manuelle Speicherverwaltung. Interpretierte Sprachen hingegen mit automatischer Speicherverwaltung (z. B. Referenzzählung und Garbage Collection (GC)) zahlen sich jedoch mit der Leistung aus. Selbst Sprachen mit einer gut optimierten Speicherbereinigung können nicht mit Sprachen ohne GC verglichen werden.

Manuelle Speicherverwaltung


In einigen Sprachen (z. B. C) müssen Programmierer den Speicher manuell verwalten: Wann und wie viel Speicher muss zugewiesen werden, wann muss er freigegeben werden? Dies gibt dem Programmierer die vollständige Kontrolle darüber, wie das Programm Ressourcen verwendet, und bietet schnellen und effizienten Code. Dieser Ansatz ist jedoch fehleranfällig, insbesondere bei komplexen Codebasen.

Fehler, die leicht zu machen sind:

  • Vergessen Sie, dass die Ressourcen kostenlos sind, und versuchen Sie, sie zu verwenden.
  • Weisen Sie nicht genügend Speicherplatz für die Datenspeicherung zu.
  • Speicher außerhalb des Puffers lesen.


Geeignete Sicherheitshinweise für diejenigen, die den Speicher manuell verwalten

Intelligente Zeiger


Intelligente Zeiger bieten zusätzliche Informationen, um eine fehlerhafte Speicherverwaltung zu verhindern. Sie werden zur automatischen Speicherverwaltung und Grenzüberprüfung verwendet. Im Gegensatz zu einem normalen Zeiger kann sich ein intelligenter Zeiger selbst zerstören und wartet nicht darauf, dass der Programmierer ihn manuell löscht.

Für eine solche Konstruktion gibt es verschiedene Optionen, bei denen der ursprüngliche Zeiger in mehrere nützliche Abstraktionen eingeschlossen wird. Einige intelligente Zeiger zählen Verweise auf jedes Objekt, während andere eine Gültigkeitsbereichsrichtlinie implementieren, um die Lebensdauer des Zeigers auf bestimmte Bedingungen zu beschränken.

Beim Zählen von Links werden Ressourcen freigegeben, wenn der letzte Verweis auf das Objekt gelöscht wird. Grundlegende Implementierungen für die Referenzzählung leiden unter schlechter Leistung, erhöhtem Speicherverbrauch und sind in Multithread-Umgebungen schwierig zu verwenden. Wenn sich die Objekte aufeinander beziehen (kreisförmige Links), erreicht der Referenzzähler für jedes Objekt niemals Null, sodass komplexere Methoden erforderlich sind.

Müllabfuhr


Einige Sprachen (z. B. Java, Go, Python) implementieren die Speicherbereinigung . Ein Teil der Laufzeitumgebung, der als Garbage Collector (GC) bezeichnet wird, verfolgt Variablen und identifiziert unzugängliche Ressourcen im Verknüpfungsdiagramm zwischen Objekten. Sobald das Objekt nicht mehr verfügbar ist, gibt der GC den Basisspeicher für die zukünftige Wiederverwendung frei. Die Zuweisung und Freigabe von Speicher erfolgt ohne expliziten Programmierbefehl.

Obwohl der GC sicherstellt, dass der Speicher immer korrekt verwendet wird, gibt er den Speicher nicht auf die effizienteste Weise frei - manchmal erfolgt die letzte Verwendung eines Objekts viel früher, als der Garbage Collector Speicher freigibt. Leistungskosten sind für geschäftskritische Anwendungen unerschwinglich: Manchmal müssen Sie fünfmal mehr Speicher verwenden, um Leistungseinbußen zu vermeiden.

Besitz


Rust nutzt das Eigentum, um hohe Leistung und Speichersicherheit zu gewährleisten. Formal ist dies ein Beispiel für die Affinitätstypisierung . Der gesamte Rust-Code folgt bestimmten Regeln, nach denen der Compiler den Speicher verwalten kann, ohne die Ausführungszeit zu verlieren:

  1. Jeder Wert hat eine Variable namens Eigentümer.
  2. Es kann immer nur ein Eigentümer gleichzeitig sein.
  3. Wenn der Eigentümer den Gültigkeitsbereich verlässt, wird der Wert gelöscht.

Werte können von einer Variablen in eine andere übertragen oder ausgeliehen werden . Diese Regeln gelten für einen Teil des Compilers namens Borrow Checker.

Wenn eine Variable den Gültigkeitsbereich verlässt, gibt Rust diesen Speicher frei. Im folgenden Beispiel gehen die Variablen s1 und s2 über den Gültigkeitsbereich hinaus. Beide versuchen, denselben Speicher freizugeben, was zu einem doppelten Fehler führt. Um dies zu verhindern, wird der Vorbesitzer beim Übertragen eines Werts von einer Variablen ungültig. Wenn der Programmierer dann versucht, eine ungültige Variable zu verwenden, lehnt der Compiler den Code ab. Dies kann vermieden werden, indem eine tiefe Kopie der Daten erstellt oder Links verwendet werden.

Beispiel 1 : Eigentumsübertragung

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

Ein weiterer Satz von Regeln für die Kreditprüfung bezieht sich auf die Lebensdauer von Variablen. Rust verbietet die Verwendung nicht initialisierter Variablen und baumelnder Zeiger auf nicht vorhandene Objekte. Wenn Sie den Code aus dem folgenden Beispiel kompilieren, bezieht sich r auf einen Speicher, der freigegeben wird, wenn x Gültigkeitsbereich verlässt: Ein baumelnder Zeiger tritt auf. Der Compiler überwacht alle Bereiche und überprüft die Gültigkeit aller Übertragungen. Manchmal muss der Programmierer die Lebensdauer der Variablen explizit angeben.

Beispiel 2 : Hängender Zeiger

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

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

Speicherschwachstellen


Die Hauptfolgen des anfälligen Gedächtnisses:

  1. Absturz : Der Zugriff auf ungültigen Speicher kann zu einer unerwarteten Beendigung der Anwendung führen.
  2. Informationsverlust : unbeabsichtigte Bereitstellung privater Daten, einschließlich vertraulicher Informationen wie Kennwörter.
  3. Arbitrary 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 Beendigung des Programms nicht freigegeben wird. Sie können also den gesamten verfügbaren Speicher belegen: Anschließend werden Ressourcenanforderungen blockiert, was zu einem Denial-of-Service führt. Dies ist ein Speicherproblem, das auf PL-Ebene nicht gelöst werden kann.

Im besten Fall stürzt die Anwendung bei einem Speicherfehler ab. Im schlimmsten Fall erlangt ein Angreifer durch eine Sicherheitsanfälligkeit die Kontrolle über ein Programm (was zu weiteren Angriffen führen kann).

Missbrauch von freigegebenem Speicher (Use-After-Free, Double Free)


Diese Unterklasse von Sicherheitsanfälligkeiten tritt auf, wenn eine Ressource freigegeben wird, ein Link zu ihrer Adresse jedoch weiterhin erhalten bleibt. Dies ist eine leistungsstarke Hacker-Methode , die zu Zugriff außerhalb des Bereichs, Informationsverlust, Codeausführung und vielem mehr führen kann.

Sprachen mit Garbage Collection und Referenzzählung verhindern die Verwendung ungültiger Zeiger, wodurch nur unzugängliche Objekte zerstört werden (was zu Leistungseinbußen führen kann), und manuell gesteuerte Sprachen sind von dieser Sicherheitsanfälligkeit betroffen (insbesondere in komplexen Codebasen). Mit dem Tool zum Ausleihen von Prüfern in Rust können Objekte nicht zerstört werden, während auf sie verwiesen wird. Daher werden diese Fehler beim Kompilieren entfernt.

Nicht initialisierte Variablen


Wenn die Variable vor der Initialisierung verwendet wird, können diese Daten beliebige Daten enthalten, einschließlich zufälligen Mülls oder zuvor verworfener Daten, was zu Informationslecks führt (sie werden manchmal als ungültige Zeiger bezeichnet ). Um diese Probleme zu vermeiden, verwenden Speicherverwaltungssprachen häufig das automatische Initialisierungsverfahren nach dem Zuweisen von Speicher.

Wie in C werden die meisten Variablen in Rust zunächst 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 sie normalerweise nur auf den Müll 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 ). Rust hat zwei Arten von Zeigern: Links und Rohzeiger. Links sind sicher, aber rohe Zeiger können ein Problem sein.

Rust verhindert die Dereferenzierung eines Nullzeigers auf zwei Arten:

  1. Vermeiden Sie nullbare Zeiger.
  2. Vermeiden Sie die Dereferenzierung von Rohzeigern.

Rust vermeidet Nullzeiger, indem es sie durch den speziellen Option . Um den möglichen Nullwert im Option zu ändern, muss der Programmierer den Fall explizit mit einem Nullwert behandeln, da das Programm sonst nicht kompiliert wird.

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 Rohzeigern muss in einem isolierten unsicheren Block erfolgen. Es löst die Rust-Regeln und löst einige Vorgänge auf, die undefiniertes Verhalten verursachen können (z. B. Dereferenzieren eines Rohzeigers).


"Alles über den Leihscheck ... was ist mit diesem dunklen Ort?"
- Dies ist ein unsicherer Block. Geh niemals dorthin, Simba

Pufferüberlauf


Wir haben Schwachstellen besprochen, die vermieden werden können, indem der Zugriff auf undefinierten Speicher eingeschränkt wird. Das Problem ist jedoch, dass der Pufferüberlauf nicht korrekt auf undefinierten, sondern legal zugewiesenen Speicher zugreift. Wie der Use-After-Free-Fehler kann ein solcher Zugriff ein Problem sein, da er auf den freigegebenen Speicher zugreift, der noch vertrauliche Informationen enthält, die nicht mehr vorhanden sein sollten.

Pufferüberläufe bedeuten einfach Zugriff außerhalb der Grenzen. Aufgrund der Art und Weise, wie Puffer im Speicher gespeichert werden, verlieren sie häufig Informationen, die vertrauliche Daten enthalten können, einschließlich Kennwörter. In schwerwiegenderen Fällen sind ACE / RCE-Schwachstellen durch Überschreiben des Anweisungszeigers möglich.

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 Schutz gegen Pufferüberläufe besteht darin, beim Zugriff auf Elemente immer Grenzprüfungen zu erfordern. Dies führt jedoch zu einer schlechten Leistung .

Was macht Rost? Die in der Standardbibliothek integrierten Puffertypen erfordern Grenzprüfungen für jeden wahlfreien Zugriff, bieten jedoch auch Iterator-APIs, um sequentielle Aufrufe zu beschleunigen. Dies stellt sicher, dass das Lesen und Schreiben außerhalb der Grenzen für diese Typen nicht möglich ist. Rust fördert Muster, die Randprüfungen nur an Stellen erfordern, an denen Sie sie mit ziemlicher Sicherheit manuell in C / C ++ platzieren müssen.

Speichersicherheit ist nur die halbe Miete


Sicherheitsverletzungen führen zu Sicherheitslücken wie Datenverlust und Remotecodeausführung. Es gibt verschiedene Möglichkeiten, den Speicher zu schützen, einschließlich intelligenter Zeiger und Speicherbereinigung. Sie können die Speichersicherheit sogar formal nachweisen . Während einige Sprachen aus Gründen der Speichersicherheit mit Leistungseinbußen fertig geworden sind, bietet das Besitzkonzept von 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 uns mit Thread-Sicherheit und Angriffen auf parallelen Code befassen.

Ausnutzen von Sicherheitslücken im Speicher: Zusätzliche Ressourcen


Source: https://habr.com/ru/post/de438288/


All Articles