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:
- Der Vorgang wird normal fortgesetzt (Absenderadresse wird nicht geändert).
- Der Prozess stürzt ab (die Adresse wurde geändert und zeigt auf nicht ausführbaren Speicher).
- 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 verwaltenIntelligente 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:
- Jeder Wert hat eine Variable namens Eigentümer.
- Es kann immer nur ein Eigentümer gleichzeitig sein.
- 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;
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:
- Absturz : Der Zugriff auf ungültigen Speicher kann zu einer unerwarteten Beendigung der Anwendung führen.
- Informationsverlust : unbeabsichtigte Bereitstellung privater Daten, einschließlich vertraulicher Informationen wie Kennwörter.
- 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:
- Vermeiden Sie nullbare Zeiger.
- 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, SimbaPufferü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};
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