Eines der interessantesten und am häufigsten verwendeten Konzepte in der x86-Architektur ist der geschützte Modus und seine Unterstützung in 4 Modi (auch bekannt als Ringe):

Es war eine schwierige Idee zu begreifen und ich werde versuchen, sie in diesem Beitrag so klar wie möglich zu erklären. Wir werden die folgenden Konzepte abdecken:

  • GDT, LDT, IDT.
  • Virtuelle Speicherübersetzung.
  • ASLR und Kernel ASLR (KASLR).

Lassen Sie uns mit den Grundlagen beginnen, jeder Computer hat mindestens (hoffentlich) die folgenden Komponenten: CPU, Festplatte und RAM. Jede dieser Komponenten spielt eine Schlüsselrolle im Ablauf des Systems. Die CPU führt die Befehle und Operationen im Arbeitsspeicher (RAM) aus, der RAM speichert die Daten, die wir verwenden, und ermöglicht einen schnellen und zuverlässigen Zugriff darauf, die Festplatte speichert persistente Daten, die auch nach einem Neustart oder dem Herunterfahren noch vorhanden sein müssen. Ich fange damit an, weil es wichtig ist, das im Hinterkopf zu behalten und sich beim Lesen dieses Artikels zu fragen, über welche Komponente wir gerade sprechen.

Das Betriebssystem ist die Software, die alles orchestriert, und auch diejenige, die eine schnelle, bequeme, konsistente und effiziente Schnittstelle für den Zugriff auf alle seine Fähigkeiten ermöglicht – einige davon sind der Zugriff auf die Hardware, andere dienen der Verbesserung des Komforts und der Leistung.

Wie jede gute Software arbeitet das Betriebssystem in Schichten, der Kernel ist die erste Schicht und – meiner Meinung nach – die wichtigste. Um zu verstehen, wie wichtig der Kernel ist, müssen wir erst einmal verstehen, was er tut und mit welchen Herausforderungen er konfrontiert ist. Schauen wir uns also einige seiner Aufgaben an:

  • Systemaufrufe verwalten (dieselbe Schnittstelle, über die wir gesprochen haben).
  • Ressourcen (RAM, CPU und vieles mehr) den jeweiligen Prozessen/Threads zuweisen.
  • Ausgeführte Operationen absichern.
  • Vermitteln zwischen der Hardware und der Software.

Viele dieser Aktionen werden mit der großzügigen Hilfe des Prozessors durchgeführt, im Fall von x86 ist der geschützte Modus der Modus, der es uns ermöglicht, die Leistung (Befehlssatz) des aktuell laufenden Ausführungskontextes zu begrenzen.

Angenommen, wir haben zwei Welten – die Welt des Benutzers und die Welt des Supervisors. Zu einem bestimmten Zeitpunkt kann man sich nur in einer dieser Welten befinden. Wenn Sie sich in der Welt des Benutzers befinden, sehen Sie die Welt so, wie der Betreuer sie sehen möchte. Lassen Sie uns sehen, was ich damit meine:

Sagen wir, Sie sind ein Prozess. Ein Prozess ist ein Container mit einem oder mehreren Threads. Ein Thread ist ein Ausführungskontext, er ist die logische Einheit, in der die Maschinenbefehle ausgeführt werden. Das bedeutet, dass der Thread, wenn er, sagen wir, aus der Speicheradresse 0x80808080 liest, tatsächlich auf die virtuelle Adresse 0x808080 des aktuellen Prozesses verweist. Wie Sie sich denken können, wird der Inhalt der Adresse zwischen zwei Prozessen unterschiedlich sein. Der virtuelle Adressraum befindet sich auf der Prozessebene, was bedeutet, dass alle Threads desselben Prozesses denselben Adressraum haben und auf denselben virtuellen Speicher zugreifen können. Um ein Beispiel für eine Ressource auf Thread-Ebene zu geben, nehmen wir den berühmten Stack.

Ich habe also einen Thread, der den folgenden Code ausführt:

Unser Thread führt die Hauptfunktion aus, die unsere „func“-Funktion aufruft. Nehmen wir an, wir unterbrechen den Thread in Zeile 9. Das Stack-Layout wird wie folgt aussehen:

  1. Variable_a.
  2. Parameter.
  3. Rückgabeadresse – Adresse von Zeile 20.
  4. Variable_b.

Zur Veranschaulichung:

Im gegebenen Code erzeugen wir 3 Threads für unseren Prozess und jeder von ihnen gibt seine ID, sein Stacksegment und seinen Stackpointer aus.

Eine mögliche Ausgabe dieses Programms ist:

Wie man sehen kann, haben alle Threads das gleiche Stacksegment, weil sie den gleichen virtuellen Adressraum haben. Der Stack-Zeiger ist für jeden unterschiedlich, weil jeder seinen eigenen Stack hat, in dem er seine Werte speichert.

Nebenbemerkung zum Stack-Segment – ich werde mehr über Segmentregister im GDT/LDT-Abschnitt erklären – für den Moment nehmen Sie mich beim Wort.

Warum ist das so wichtig? Der Prozessor kann zu jeder Zeit den Thread einfrieren und die Kontrolle an einen anderen Thread übergeben. Als Teil des Kernels ist der Scheduler derjenige, der die CPU den aktuell vorhandenen (und „bereiten“) Threads zuweist. Damit die Threads zuverlässig und effizient laufen können, ist es wichtig, dass jeder seinen eigenen Stack hat, in dem er seine relevanten Werte speichern kann (z.B. lokale Variablen und Rücksprungadressen).

Um seine Threads zu verwalten, hält das Betriebssystem eine spezielle Struktur für jeden Thread bereit, die TCB (Thread Control Block) genannt wird, in der es – unter anderem – den Kontext dieses Threads und seinen Zustand (laufend / bereit / etc…) speichert. Der Kontext enthält – wiederum – unter anderem die Werte der CPU-Register:

  • EBP -> Basisadresse des Stacks, jede Funktion verwendet diese Adresse als Basisadresse, von der aus sie auf lokale Variablen und Parameter zugreift.
  • ESP -> Der aktuelle Zeiger auf den letzten Wert (first to pop) auf dem Stack.
  • Allzweckregister -> EAX, EBX, etc…
  • Flags-Register.
  • C3 -> enthält die Position des Seitenverzeichnisses (wird später besprochen).
  • EIP – Die nächste auszuführende Anweisung.

Neben den Threads muss das Betriebssystem eine Menge anderer Dinge verfolgen, einschließlich der Prozesse. Für Prozesse speichert das Betriebssystem die PCB (Process Control Block) Struktur, wir sagten, dass es für jeden Prozess einen isolierten Adressraum gibt. Nehmen wir an, es gibt eine Tabelle, die jede virtuelle Adresse auf eine physische Adresse abbildet, und diese Tabelle ist im PCB gespeichert; das Betriebssystem ist dafür verantwortlich, diese Tabelle zu aktualisieren und sie auf dem richtigen Stand des physischen Speichers zu halten. Jedes Mal, wenn der Scheduler die Ausführung auf einen bestimmten Thread umschaltet, wird die Tabelle, die für den Prozess, der diesen Thread besitzt, gespeichert wurde, auf die CPU angewandt, so dass sie in der Lage ist, die virtuellen Adressen korrekt zu übersetzen.

Das ist genug für die Konzepte, lassen Sie uns verstehen, wie es tatsächlich gemacht wird. Dazu sehen wir uns die Welt aus der Sicht des Prozessors an:

Global Descriptor Table

Wie wir alle wissen, hat der Prozessor Register, die ihm helfen, Berechnungen durchzuführen, einige Register mehr als andere (;)). Die CPU hat ein spezielles Register namens gdtr (Global Descriptor Table Register), das die Adresse einer sehr wichtigen Tabelle enthält. Diese Tabelle ordnet jede virtuelle Adresse dem entsprechenden Prozessormodus zu und enthält auch die Berechtigungen für diese Adresse (READ | WRITE | EXECUTE). Natürlich kann dieses Register nur im Supervisor-Modus geändert werden. Als Teil der Prozessorausführung prüft er, welche Anweisung als nächstes ausgeführt werden soll (und an welcher Adresse sie sich befindet), er vergleicht diese Adresse mit der GDT und weiß so, ob es sich um eine gültige Anweisung handelt, basierend auf dem gewünschten Modus (Übereinstimmung des aktuellen CPU-Modus mit dem Modus in der GDT) und den Berechtigungen (wenn nicht ausführbar – ungültig). Ein Beispiel ist die Anweisung ‚lgdtr‘, die einen Wert in das gdtr-Register lädt und nur im überwachten Modus ausgeführt werden kann, wie angegeben. Der wichtigste Punkt, den es hier zu betonen gilt, ist, dass jeglicher Schutz der Speicheroperationen (Ausführen von Befehlen / Schreiben an einen ungültigen Speicherplatz / Lesen von einem ungültigen Speicherplatz) durch die GDT und LDT (kommt als nächstes) auf der Prozessorebene unter Verwendung dieser Strukturen erfolgt, die vom Betriebssystem erstellt wurden.

So sieht der Inhalt eines Eintrags in der GDT / LDT aus:

http://wiki.osdev.org/Global_Descriptor_Table

Wie man sehen kann, gibt es den Bereich der Adressen, für den der Eintrag relevant ist, und seine Attribute (Berechtigungen), wie Sie es erwarten würden.

Local Descriptor Table

Alles, was wir über die GDT gesagt haben, gilt auch für die LDT mit einem kleinen (aber großen) Unterschied. Wie der Name schon sagt, wird die GDT global auf das System angewendet, während die LDT lokal ist. Die GDT behält den Überblick über die Berechtigungen für alle Prozesse, für jeden Thread, und sie wird nicht zwischen Kontextwechseln geändert, die LDT hingegen schon. Es macht nur Sinn, dass wenn jeder Prozess seinen eigenen Adressraum hat, es möglich ist, dass für einen Prozess die Adresse 0x10000000 ausführbar ist und für einen anderen nur zum Lesen/Schreiben. Dies ist besonders bei eingeschaltetem ASLR der Fall (wird später besprochen). Die LDT ist dafür verantwortlich, die Berechtigungen zu behalten, die jeden Prozess unterscheiden.

Eine Sache, die man beachten sollte, ist, dass alles, was gesagt wurde, der Zweck der Struktur ist, aber in der Realität könnten einige Betriebssysteme einige der Struktur überhaupt nicht benutzen. zum Beispiel ist es möglich, nur die GDT zu benutzen und sie zwischen den Kontextwechseln zu ändern und die LDT nie zu benutzen. Das ist alles Teil der Entwicklung des Betriebssystems und der Kompromisse. Die Einträge dieser Tabelle sehen ähnlich aus wie die der GDT.

Selektoren

Woher weiß der Prozessor, wo er in der GDT oder LDT nachsehen muss, wenn er einen bestimmten Befehl ausführt? Der Prozessor hat spezielle Register, die Segmentregister genannt werden:

https://en.wikibooks.org/wiki/X86_Assembly/X86_Architecture

- Stack Segment (SS). Pointer to the stack. - Code Segment (CS). Pointer to the code. - Data Segment (DS). Pointer to the data. - Extra Segment (ES). Pointer to extra data ('E' stands for 'Extra'). - F Segment (FS). Pointer to more extra data ('F' comes after 'E'). - G Segment (GS). Pointer to still more extra data ('G' comes after 'F').

Jedes Register ist 16 Bits lang und wie folgt aufgebaut:

http://www.c-jump.com/CIS77/ASM/Memory/M77_0290_segment_registers_protected.htm

So haben wir den Index zur GDT/LDT, wir haben auch das Bit, das sagt, ob es die LDT oder die GDT ist, und welcher Modus es sein muss (RPL 0 ist Supervisor, 4 ist User).

Interrupt Descriptor Table

Neben der GDT und LDT haben wir auch die IDT (Interrupt Descriptor Table), die IDT ist einfach eine Tabelle, die die Adressen von sehr wichtigen Funktionen enthält, einige davon gehören zum Betriebssystem, andere zu Treibern und physischen Geräten, die mit dem PC verbunden sind. Wie die gdtr haben wir idtr, das, wie Sie wahrscheinlich erraten haben, das Register ist, das die Adresse der IDT enthält. Was macht die IDT so besonders? Wenn wir einen Interrupt auslösen, schaltet die CPU automatisch in den überwachten Modus, was bedeutet, dass jede Funktion innerhalb der IDT im überwachten Modus läuft. Jeder Thread aus jedem Modus kann einen Interrupt auslösen, indem er die Anweisung „int“ gefolgt von einer Zahl ausgibt, die der CPU mitteilt, auf welchem Index sich die Zielfunktion befindet. Damit ist nun klar, dass jede Funktion innerhalb der IDT ein potenzielles Gateway in den überwachten Modus ist.

Wir wissen also, dass wir die GDT/LDT haben, die der CPU die Berechtigungen für jede virtuelle Adresse mitteilt, und wir haben die IDT, die die „Gateway“-Funktionen auf unseren geliebten Kernel verweist (der sich natürlich im überwachten Bereich des Speichers befindet). Wie verhalten sich diese Strukturen in einem laufenden System?

Virtueller Speicher

Bevor wir verstehen können, wie das alles zusammenspielt, müssen wir ein weiteres Konzept behandeln – den virtuellen Speicher. Erinnern Sie sich, als ich sagte, dass es eine Tabelle gibt, die jede virtuelle Speicheradresse der entsprechenden physischen Adresse zuordnet? Es ist tatsächlich etwas komplizierter als das. Erstens können wir nicht einfach jede virtuelle Adresse abbilden, da dies mehr Platz beanspruchen würde, als wir tatsächlich haben, und abgesehen von der Notwendigkeit, effizient zu sein, kann das Betriebssystem auch Speicherseiten auf die Festplatte auslagern (aus Effizienz- und Leistungsgründen), es ist möglich, dass die Speicherseite der benötigten virtuellen Adresse im Moment nicht im Speicher ist, also müssen wir neben der Übersetzung der virtuellen Adresse in die physische auch speichern, ob der Speicher im RAM ist und wenn nicht, wo er ist (es könnte mehr als eine Seitendatei geben). Die MMU (Memory Management Unit) ist die Komponente, die für die Übersetzung des virtuellen Speichers in einen physischen Speicher verantwortlich ist.

Eine Sache, die wirklich wichtig zu verstehen ist, ist, dass jede Anweisung in jedem Modus den Prozess der Übersetzung der virtuellen Adresse durchläuft, sogar Code im überwachten Modus. Sobald sich die CPU im geschützten Modus befindet, verwendet jede Anweisung, die sie ausführt, eine virtuelle Adresse – niemals eine physische (es gibt einige Tricks, die dafür sorgen, dass die tatsächliche virtuelle Adresse immer in genau denselben virtuellen Speicher übersetzt wird, aber das liegt außerhalb des Rahmens dieses Beitrags).

Woher weiß die CPU also, wenn sie sich im geschützten Modus befindet, wo sie nachschauen muss, wenn sie eine virtuelle Adresse übersetzen muss? Die Antwort ist das CR3-Register, das die Adresse der Struktur enthält, die die erforderlichen Informationen enthält – das Seitenverzeichnis. Sein Wert ändert sich mit dem aktuell laufenden Prozess (wieder ein anderer virtueller Adressraum).

So, wie sieht dieses Seitenverzeichnis aus? Wenn es um Effizienz geht, müssen wir in der Lage sein, diese „Tabelle“ so schnell wie möglich abzufragen, außerdem muss sie so klein wie möglich sein, da diese Tabelle für jeden Prozess erstellt wird. Die Lösung für dieses Problem ist nichts weniger als brillant. Das beste Bild, das ich zur Veranschaulichung des Übersetzungsprozesses finden konnte, ist dieses (aus wikipedia):

Die MMU hat 2 Eingänge, die zu übersetzende virtuelle Adresse und die CR3 (Adresse zum aktuell relevanten Seitenverzeichnis). Die x86-Spezifikation zerlegt die virtuelle Adresse in 3 Teile:

  • 10-Bit-Zahl – Index zum Seitenverzeichnis.
  • 10-Bit-Zahl – Index zur Seitentabelle.
  • 12-Bit-Zahl – Offset zur eigentlichen physikalischen Adresse.

Der Prozessor nimmt also die erste 10-Bit-Zahl und verwendet sie als Index für das Seitenverzeichnis, für jeden Eintrag im Seitenverzeichnis haben wir eine Seitentabelle, für die der Prozessor dann die nächste 10-Bit-Zahl als Index verwendet. Jeder Eintrag in der Verzeichnistabelle verweist auf eine 4K-Grenzspeicherseite, und der letzte 12-Bit-Offset der virtuellen Adresse wird verwendet, um die genaue Position im physischen Speicher zu bestimmen. Das Geniale an dieser Lösung ist:

  • Die Flexibilität, dass jede virtuelle Adresse auf eine völlig unabhängige physikalische Adresse verweist.
  • Die Raumeffizienz der beteiligten Strukturen ist erstaunlich.
  • Nicht jeder Eintrag jeder Tabelle wird verwendet, nur virtuelle Adressen, die tatsächlich vom Prozess verwendet und zugeordnet werden, sind in den Tabellen vorhanden.

Es tut mir aufrichtig leid, dass ich diesen Prozess nicht näher erkläre, dies ist ein gut dokumentierter Prozess, an dessen Erklärung viele Leute hart gearbeitet haben, besser als ich es jemals könnte – googeln Sie es.

Kernel vs. Benutzer

Hier wird es interessant (und magisch, wenn ich darf).

Wir haben diesen Artikel damit begonnen, dass das Betriebssystem alles orchestriert, es tut dies mit Hilfe des Kernels. Wie bereits erwähnt, läuft der Kernel in einem Speicherbereich, der in der GDT für alle Prozesse als Überwachungsmodus gekennzeichnet ist. Ja, ich weiß, dass jeder Prozess seinen eigenen Adressraum hat, aber der Kernel schneidet diesen Adressraum (in der Regel die obere Hälfte, abhängig vom Betriebssystem) für seinen persönlichen Gebrauch, nicht nur den Adressraum, sondern auch an der gleichen Adresse für alle Prozesse. Dies ist wichtig, da der Code des Kernels fest ist und jeder Verweis auf Variablen und Strukturen für alle Prozesse an derselben Stelle liegen muss. Man kann den Kernel als eine spezielle Bibliothek betrachten, die für jeden Prozess an der gleichen Stelle geladen wird.

Tiefere Einblicke in Interrupts

Wir wissen, dass die IDT Adressen von Funktionen enthält, diese Funktionen werden ISR (Interrupt Service Routine) genannt, einige werden ausgeführt, wenn ein Hardware-Ereignis eintritt (Tastendruck auf der Tastatur) und andere, wenn die Software den Interrupt auslöst, z.B. um in den Kernel-Modus zu wechseln.

Windows hat ein gutes Konzept für Interrupts und deren Priorisierung: Ein besonders wichtiger Interrupt sind die Ticks der Uhr. Bei jedem Ticken der Uhr gibt es einen Interrupt, der von der zugehörigen ISR behandelt wird. Der Scheduler des Betriebssystems verwendet dieses Ereignis, um zu kontrollieren, wie lange jeder Prozess läuft und ob ein anderer Prozess an der Reihe ist oder nicht. Wie Sie verstehen können, ist dieser Interrupt sehr wichtig und muss so schnell wie möglich bedient werden, aber nicht alle ISRs sind gleich wichtig und hier kommen die Prioritäten zwischen den Interrupts ins Spiel. Nehmen wir zum Beispiel den Tastendruck auf der Tastatur und nehmen wir an, dass er die Priorität 1 hat. Ich habe gerade eine Taste auf der Tastatur gedrückt und die ISR wird ausgeführt, während die ISR der Tastatur ausgeführt wird, werden alle Interrupts mit der gleichen Priorität und niedriger ignoriert. Während der Ausführung der ISR wird die ISR der Uhr mit der Priorität 2 ausgelöst (deshalb wurde sie nicht deaktiviert), es erfolgt ein sofortiger Wechsel zur ISR der Uhr, sobald die Uhr fertig ist, wird die Kontrolle an die ISR der Tastatur zurückgegeben, wo sie aufgehört hat. Diese Interrupts werden IRQLs (Interrupt ReQuest Level) genannt, je höher der IRQL des Interrupts, desto höher ist seine Priorität. Die Interrupts mit der höchsten Priorität sind nie Interrupts in der Mitte, sie laufen immer bis zum Ende. IRQLs sind Windows-spezifisch – der IRQL ist eine Zahl zwischen 0-31, für Linux hingegen existiert er nicht, Linux behandelt jeden Interrupt mit der gleichen Priorität und schaltet einfach alle Interrupts ab, wenn es wirklich notwendig ist, dass diese spezielle Routine nicht gestört wird. Wie man sieht, ist das alles eine Frage des Designs und der Vorlieben.

Lassen Sie uns das alles mit unserem geliebten User-Modus verbinden. Die ISR dieses Taktereignisses wird unabhängig davon ausgeführt, welcher Thread gerade läuft, und könnte sogar eine andere ISR für eine nicht verwandte Aufgabe unterbrechen. Dies ist ein perfektes Beispiel dafür, warum der Kernel für alle Prozesse dieselbe Adresse hat. Wir wollen nicht jedes Mal, wenn wir eine Unterbrechung ausführen, die GDT und das Seitenverzeichnis (in C3) ändern, da dies VIELE Male sogar während einer einzigen Funktion eines beliebigen Benutzermodusprozesses geschieht. Eine Menge passiert zwischen den Codezeilen, die Sie schreiben, wenn Sie Ihre User-Mode-Anwendung entwickeln (;)).

Eine andere Art, Interrupts zu betrachten, ist als externe und unabhängige Eingänge zu unserem Betriebssystem, diese Definition ist nicht genau (nicht alle Interrupts sind extern oder unabhängig), aber es ist gut, einen Punkt zu machen, ein großer Teil der Aufgabe des Kernels ist es, Sinn der Ereignisse zu machen, die die ganze Zeit von jedem Ort (Eingabegeräte) und von einer Seite, um diese Ereignisse zu dienen und die andere, um sicherzustellen, dass alles korrekt korreliert ist.

Um das Ganze zu verstehen, beginnen wir mit einer einfachen Anwendung im Benutzermodus, die folgende Anweisung ausführt:

0x0000051d push ebp;

Für jede Anweisung, die die CPU ausführt, prüft sie zunächst die Adresse dieser Anweisung (in diesem Fall ‚0x0000051d‘) anhand der GDT/LDT unter Verwendung des Code-Segment-Registers (‚cs‘, weil es sich um die auszuführende Anweisung handelt), um den Index zu kennen, nach dem sie in der Tabelle suchen muss (denken Sie daran, dass das Segmentregister der CPU genau sagt, wo sie suchen muss). Sobald die CPU weiß, dass sich der Befehl an der ausführbaren Stelle befindet und wir uns im richtigen Ring befinden (Benutzermodus/Kernelmodus), fährt sie mit der Ausführung des Befehls fort. In diesem Fall wirkt sich die Anweisung „push ebp“ nicht nur auf das Register, sondern auch auf den Stack des Programms aus (sie schiebt den ebp-Inhalt auf den Stack), so dass die CPU auch anhand der GDT/LDT die Adresse im esp-Register überprüft (die Adresse der aktuellen Position auf dem Stack, und da es sich um die Stack-Position handelt, weiß die CPU, dass sie dafür das Stack-Segment-Register verwenden muss), um sicherzustellen, dass es in diesem speziellen Ring schreibbar ist. Bitte beachten Sie, dass die CPU, wenn diese Anweisung auch aus dem Speicher lesen würde, die entsprechende Adresse auch für den Lesezugriff überprüfen müsste.

Das ist noch nicht alles, denn nachdem die CPU alle Sicherheitsaspekte überprüft hat, muss sie nun auf den Speicher zugreifen und ihn manipulieren, denn wie Sie sich erinnern, sind die Adressen in ihrem virtuellen Format. Die MMU übersetzt nun jeden virtuellen Speicher, der durch die Anweisung spezifiziert wurde, in eine physische Speicheradresse unter Verwendung des CR3-Registers, das auf das Seitenverzeichnis verweist (das auf die Seitentabelle verweist), was uns ermöglicht, die Adresse schließlich in eine physische Adresse zu übersetzen. In diesem Fall erzeugt das Betriebssystem einen Seitenfehler (eine Ausnahme, die eine Unterbrechung auslöst) und bringt die Daten für uns in den physischen Speicher, um dann die Ausführung fortzusetzen (dies ist für die Anwendung im Benutzermodus transparent).

Vom Benutzermodus zum Kernel

Jeder Austausch zwischen dem Benutzermodus und dem Kernelmodus erfolgt über die IDT. Von der Anwendung im Benutzermodus wird die Anweisung ‚int <num>‘ an die Funktion im IDT mit dem Index num übertragen. Wenn die Ausführung im Kernelmodus erfolgt, ändern sich viele der Regeln, jeder Thread hat unterschiedliche Stacks für den Benutzer- und den Kernelmodus, Speicherzugriffsprüfungen sind viel komplizierter und obligatorisch, im Kernelmodus gibt es sehr wenig, was man nicht tun kann, und sehr viel, was man kaputt machen kann.

ASLR und KASLR

meist ist es „nur“ der Mangel an Wissen, der uns daran hindert, das Unmögliche zu erreichen.

ASLR (Address Space Layout Randomization) ist ein Konzept, das in jedem Betriebssystem anders implementiert ist, das Konzept besteht darin, die virtuellen Adressen der Prozesse und ihrer geladenen Bibliotheken zu randomisieren.

Bevor wir eintauchen, möchte ich anmerken, dass ich mich entschlossen habe, ASLR in diesen Beitrag einzubeziehen, weil es eine gute Möglichkeit ist, zu sehen, wie der geschützte Modus und seine Strukturen diese Art von Fähigkeit ermöglicht haben, auch wenn er nicht derjenige ist, der sie implementiert oder dafür verantwortlich ist.

Warum ASLR?

Das Warum ist einfach, um Angriffe zu verhindern. Wenn jemand in der Lage ist, Code in einen laufenden Prozess einzuschleusen, ist es die Unkenntnis der Adressen einiger nützlicher Funktionen, die den Angriff scheitern lässt.

Wir haben bereits unterschiedliche Adressräume für jeden Prozess, das bedeutet, dass ohne ASLR alle Prozesse die gleichen Basisadressen haben würden, denn wenn jeder Prozess seinen eigenen virtuellen Adressraum hat, müssen wir uns nicht um Kollisionen zwischen den Prozessen kümmern. Wenn wir das Programm linken, wählt der Linker eine feste Basisadresse, gegen die er die ausführbare Datei linkt. Auf dem Papier haben alle ausführbaren Dateien, die von demselben Linker mit den Standardparametern gelinkt werden (die Basisadresse kann bei Bedarf konfiguriert werden), dieselbe Basisadresse. Um ein Beispiel zu geben, habe ich zwei Anwendungen geschrieben, eine mit dem Namen „1.exe“ und die zweite „2.exe“, beides sind verschiedene Projekte in Visual Studio und doch haben beide die gleiche Basisadresse (ich habe exeinfo PE benutzt, um die Basisadresse in der PE-Datei zu überprüfen):

Nicht nur, dass diese beiden ausführbaren Dateien die gleiche Basisadresse haben, sie unterstützen beide kein ASLR (ich habe es deaktiviert):

Sie können es auch im PE-Format unter File Characteristics sehen:

Nun lassen wir beide ausführbaren Dateien gleichzeitig laufen und beide teilen sich die gleiche Basisadresse (ich werde vmmap von Sysinternals verwenden, um das Basisimage zu sehen):

Wir können sehen, dass beide Prozesse kein ASLR verwenden und die gleiche Basisadresse von 0x00400000 haben. Wenn wir Angreifer wären und Zugriff auf diese ausführbare Datei hätten, könnten wir genau wissen, welche Adressen diesem Prozess zur Verfügung stehen, sobald wir einen Weg gefunden haben, uns in seine Ausführung einzuschleusen. Aktivieren wir ASLR in unserer ausführbaren Datei 1.exe und sehen wir, was es bewirkt:

Es hat sich geändert!

KASLR (Kernel ASLR) ist dasselbe wie ASLR, nur dass es auf der Kernel-Ebene arbeitet, was bedeutet, dass ein Angreifer, der sich in den Kernel-Kontext einschleusen konnte, (hoffentlich) nicht in der Lage sein wird, zu wissen, welche Adressen welche Strukturen enthalten (zum Beispiel wo die GDT im Speicher sitzt). Eine Sache, die hier zu erwähnen ist, ist, dass ASLR seine Magie bei jedem Spawnen eines neuen Prozesses (der ASLR natürlich unterstützt) wirkt, während KASLR es bei jedem Neustart tut, da dies der Zeitpunkt ist, an dem der Kernel „gespawnt“ wird.

Wie ASLR?

Wie funktioniert es also und wie ist es mit dem geschützten Modus verbunden? Derjenige, der für die Implementierung von ASLR verantwortlich ist, ist der Lader. Wenn ein Prozess gestartet wird, ist der Lader derjenige, der ihn im Speicher ablegen, die relevanten Strukturen erstellen und seinen Thread starten muss. Der Lader prüft zunächst, ob die ausführbare Datei ASLR unterstützt, und wenn ja, wählt er eine Basisadresse im Bereich der verfügbaren Adressen aus (der Kernelbereich ist zum Beispiel nicht verfügbar). Basierend auf dieser Adresse initialisiert der Lader nun das Seitenverzeichnis für diesen Prozess, um den randomisierten Adressraum mit dem physischen Adressraum zu verbinden. Die Flexibilität von LDT kommt uns auch hier zu Hilfe, da der Lader einfach ein LDT erstellt, das der randomisierten Adresse mit den entsprechenden Rechten entspricht. Das Schöne daran ist, dass der geschützte Modus nicht einmal weiß, dass ASLR verwendet wird, er ist flexibel genug, um sich nicht darum zu kümmern.

Ein interessantes Implementierungsdetail ist, dass in Windows die randomisierte Adresse für eine bestimmte ausführbare Datei aus Effizienzgründen festgelegt ist. Was ich damit meine, ist, dass, wenn wir die Adresse für, sagen wir, calc.exe randomisiert haben, die Basisadresse bei der zweiten Ausführung dieselbe sein wird. Wenn ich also 2 Rechner gleichzeitig öffne, werden sie die gleiche Basisadresse haben. Wenn ich beide Rechner schließe und sie wieder öffne, haben sie wieder dieselbe Adresse, nur dass diese sich von der Adresse der früheren Rechner unterscheidet. Warum ist das nicht effizient, fragen Sie sich? Denken Sie an häufig verwendete DLLs. Viele Prozesse verwenden sie, und wenn ihre Basisadressen bei jedem Prozess unterschiedlich wären, wäre auch ihr Code unterschiedlich (der Code referenziert die Daten über diese Basisadresse), und wenn der Code unterschiedlich ist, muss die DLL für jeden Prozess in den Speicher geladen werden. In Wirklichkeit lädt das Betriebssystem die Bilder nur einmal für alle Prozesse, die dieses Bild verwenden. Das spart Platz – viel Platz!

Abschluss

Jetzt sollten Sie in der Lage sein, sich ein Bild vom Kernel bei der Arbeit zu machen und zu verstehen, wie alle Schlüsselstrukturen der x86-Architektur zu einem größeren Ganzen zusammenspielen und es uns ermöglichen, möglicherweise gefährliche Anwendungen im Benutzermodus ohne (oder mit wenig) Angst auszuführen.

Articles

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.