Wie funktionieren Adressraum-Layout-Randomisierung (ASLR) und Data Execution Prevention (DEP), um zu verhindern, dass Schwachstellen ausgenutzt werden? Können sie umgangen werden?
Wie funktionieren Adressraum-Layout-Randomisierung (ASLR) und Data Execution Prevention (DEP), um zu verhindern, dass Schwachstellen ausgenutzt werden? Können sie umgangen werden?
Adressraum-Layout-Randomisierung (ASLR) ist eine Technologie, mit der verhindert wird, dass Shellcode erfolgreich ist. Dies geschieht durch zufälliges Versetzen der Position von Modulen und bestimmten speicherinternen Strukturen. Data Execution Prevention (DEP) verhindert bestimmte Speichersektoren, z. der Stapel, von der Ausführung. In Kombination wird es äußerst schwierig, Schwachstellen in Anwendungen mithilfe von Shellcode- oder ROP-Techniken (Return-Oriented Programming) auszunutzen.
Schauen wir uns zunächst an, wie eine normale Schwachstelle ausgenutzt werden kann. Wir werden alle Details überspringen, aber sagen wir einfach, wir verwenden eine Sicherheitsanfälligkeit bezüglich eines Stapelpufferüberlaufs. Wir haben einen großen Blob mit 0x41414141
-Werten in unsere Nutzdaten geladen, und eip
wurde auf 0x41414141
gesetzt, sodass wir wissen, dass er ausnutzbar ist. Wir haben dann ein geeignetes Tool verwendet (z. B. Metasploits pattern_create.rb
), um den Offset des Werts zu ermitteln, der in eip
geladen wird. Dies ist der Startoffset unseres Exploit-Codes. Zur Überprüfung laden wir 0x41
vor diesem Offset, 0x42424242
am Offset und 0x43
nach dem Offset.
In Bei einem Nicht-ASLR- und Nicht-DEP-Prozess ist die Stapeladresse bei jedem Ausführen des Prozesses dieselbe. Wir wissen genau, wo es in Erinnerung ist. Schauen wir uns also an, wie der Stapel mit den oben beschriebenen Testdaten aussieht:
stack addr | Wert ----------- + ---------- 000ff6a0 | 41414141 000ff6a4 | 41414141 000ff6a8 | 41414141 000ff6aa | 41414141>000ff6b0 | 42424242 > esp Punkte hier 000ff6b4 | 43434343 000ff6b8 | 43434343
Wie wir sehen können, zeigt esp
auf 000ff6b0
, das auf 0x42424242
gesetzt wurde. Die Werte davor sind 0x41
und die Werte danach sind 0x43
, wie wir sagten, dass sie sein sollten. Wir wissen jetzt, dass die unter 000ff6b0
gespeicherte Adresse gesprungen wird. Also setzen wir es auf die Adresse eines Speichers, den wir steuern können:
stack addr | Wert ----------- + ---------- 000ff6a0 | 41414141 000ff6a4 | 41414141 000ff6a8 | 41414141 000ff6aa | 41414141>000ff6b0 | 000ff6b4 000ff6b4 | cccccccc 000ff6b8 | 43434343
Wir haben den Wert auf 000ff6b0
festgelegt, sodass eip
auf 000ff6b4
gesetzt wird. der nächste Versatz im Stapel. Dies führt dazu, dass 0xcc
ausgeführt wird, was eine int3
-Anweisung ist. Da int3
ein Software-Interrupt-Haltepunkt ist, wird eine Ausnahme ausgelöst und der Debugger wird angehalten. Auf diese Weise können wir überprüfen, ob der Exploit erfolgreich war.
> Ausnahme der Unterbrechungsanweisung - Code 80000003 (erste Chance) [snip] eip = 000ff6b4
Jetzt Wir können den Speicher bei 000ff6b4
durch Shellcode ersetzen, indem wir unsere Nutzdaten ändern. Damit ist unser Exploit abgeschlossen.
Um zu verhindern, dass diese Exploits erfolgreich sind, wurde Data Execution Prevention entwickelt. DEP erzwingt, dass bestimmte Strukturen, einschließlich des Stapels, als nicht ausführbar markiert werden. Dies wird durch die CPU-Unterstützung mit dem No-Execute (NX) -Bit, auch als XD-Bit, EVP-Bit oder XN-Bit bezeichnet, verstärkt, mit dem die CPU Ausführungsrechte auf Hardwareebene erzwingen kann. DEP wurde 2004 unter Linux (Kernel 2.6.8) und Microsoft 2004 als Teil von WinXP SP2 eingeführt. Apple hat DEP-Unterstützung hinzugefügt, als sie 2006 auf die x86-Architektur umgestiegen sind. Wenn DEP aktiviert ist, funktioniert unser vorheriger Exploit nicht:
> Zugriffsverletzung - Code c0000005 (!!! zweite Chance !! !) [snip] eip = 000ff6b4
Dies schlägt fehl, da der Stapel als nicht ausführbar markiert ist und wir versucht haben, ihn auszuführen. Um dies zu umgehen, wurde eine Technik namens Return-Oriented Programming (ROP) entwickelt. Dies beinhaltet die Suche nach kleinen Codeausschnitten, sogenannten ROP-Gadgets, in legitimen Modulen innerhalb des Prozesses. Diese Gadgets bestehen aus einer oder mehreren Anweisungen, gefolgt von einer Rückgabe. Wenn Sie diese mit den entsprechenden Werten im Stapel verketten, kann Code ausgeführt werden.
Schauen wir uns zunächst an, wie unser Stapel jetzt aussieht:
stack addr | Wert ----------- + ---------- 000ff6a0 | 41414141 000ff6a4 | 41414141 000ff6a8 | 41414141 000ff6aa | 41414141>000ff6b0 | 000ff6b4 000ff6b4 | cccccccc 000ff6b8 | 43434343
Wir wissen, dass wir den Code bei 000ff6b4
nicht ausführen können, daher müssen wir einen legitimen Code finden, den wir stattdessen verwenden können. Stellen Sie sich vor, unsere erste Aufgabe besteht darin, einen Wert in das Register eax
zu übertragen. Wir suchen nach einem pop eax; ret
Kombination irgendwo in einem beliebigen Modul innerhalb des Prozesses. Sobald wir eine gefunden haben, sagen wir bei 00401f60
, legen wir ihre Adresse in den Stapel:
stack addr | Wert ----------- + ---------- 000ff6a0 | 41414141 000ff6a4 | 41414141 000ff6a8 | 41414141 000ff6aa | 41414141>000ff6b0 | 00401f60 000ff6b4 | cccccccc 000ff6b8 | 43434343
Wenn dieser Shellcode ausgeführt wird, wird erneut eine Zugriffsverletzung angezeigt:
> Zugriffsverletzung - Code c0000005 (!!! zweite Chance! !!) eax = cccccccc ebx = 01020304 ecx = 7abcdef0 edx = 00000000 esi = 7777f000 edi = 0000f0f1eip = 43434343 esp = 000ff6ba ebp = 000ff6ff
Die CPU hat jetzt Folgendes ausgeführt:
pop eax
unter 00401f60
gesprungen. cccccccc
vom Stapel gestrichen , in ax
. ret
ausgeführt und 43434343
in eip
eingefügt. 43434343
keine gültige Speicheradresse ist. Stellen Sie sich nun vor, dass anstelle von 43434343
wurde der Wert bei 000ff6b8
auf die Adresse eines anderen ROP-Gadgets gesetzt. Dies würde bedeuten, dass pop eax
ausgeführt wird, dann unser nächstes Gadget. So können wir Gadgets verketten. Unser oberstes Ziel besteht normalerweise darin, die Adresse einer Speicherschutz-API wie VirtualProtect
zu ermitteln und den Stapel als ausführbar zu markieren. Wir würden dann ein endgültiges ROP-Gadget einfügen, um eine jmp esp
-äquivalente Anweisung auszuführen und Shellcode auszuführen. Wir haben DEP erfolgreich umgangen!
Um diese Tricks zu bekämpfen, wurde ASLR entwickelt. ASLR beinhaltet das zufällige Versetzen von Speicherstrukturen und Modulbasisadressen, um das Erraten des Speicherorts von ROP-Gadgets und APIs sehr schwierig zu machen.
Unter Windows Vista und 7 randomisiert ASLR den Speicherort von ausführbaren Dateien und DLLs im Speicher sowie der Stapel und Haufen. Wenn eine ausführbare Datei in den Speicher geladen wird, erhält Windows den Zeitstempelzähler (TSC) des Prozessors, verschiebt ihn um vier Stellen, führt den Divisionsmod 254 durch und addiert dann 1. Diese Zahl wird dann mit 64 KB multipliziert, und das ausführbare Image wird mit diesem Versatz geladen . Dies bedeutet, dass es 256 mögliche Speicherorte für die ausführbare Datei gibt. Da DLLs prozessübergreifend im Speicher gemeinsam genutzt werden, werden ihre Offsets durch einen systemweiten Bias-Wert bestimmt, der beim Booten berechnet wird. Der Wert wird als TSC der CPU berechnet, wenn die Funktion MiInitializeRelocations
zum ersten Mal aufgerufen, verschoben und in einen 8-Bit-Wert maskiert wird. Dieser Wert wird nur einmal pro Start berechnet.
Wenn DLLs geladen werden, werden sie in einen gemeinsam genutzten Speicherbereich zwischen 0x50000000
und 0x78000000
verschoben. Die erste zu ladende DLL ist immer ntdll.dll, die unter 0x78000000 - Bias * 0x100000
geladen wird, wobei Bias
der systemweite Bias-Wert ist, der beim Booten berechnet wird. Da es trivial wäre, den Offset eines Moduls zu berechnen, wenn Sie die Basisadresse von ntdll.dll kennen, wird auch die Reihenfolge, in der Module geladen werden, zufällig ausgewählt.
Wenn Threads erstellt werden, wird ihre Stapelbasisposition zufällig ausgewählt . Dies erfolgt durch Auffinden von 32 geeigneten Stellen im Speicher und anschließende Auswahl einer Stelle basierend auf der aktuellen TSC, die maskiert in einen 5-Bit-Wert verschoben wurde. Sobald die Basisadresse berechnet wurde, wird ein weiterer 9-Bit-Wert von der TSC abgeleitet, um die endgültige Stapelbasisadresse zu berechnen. Dies bietet einen hohen theoretischen Grad an Zufälligkeit.
Schließlich werden die Position der Heaps und die Heap-Zuordnungen randomisiert. Dies wird als 5-Bit-TSC-abgeleiteter Wert multipliziert mit 64 KB berechnet, was einen möglichen Heap-Bereich von 00000000
bis 001f0000
ergibt.
Wenn alle Diese Mechanismen werden mit DEP kombiniert. Wir können keinen Shellcode ausführen. Dies liegt daran, dass wir den Stapel nicht ausführen können, aber wir wissen auch nicht, wo sich eine unserer ROP-Anweisungen im Speicher befinden wird. Bestimmte Tricks können mit nop
-Schlitten ausgeführt werden, um einen probabilistischen Exploit zu erstellen. Sie sind jedoch nicht vollständig erfolgreich und können nicht immer erstellt werden.
Die einzige Möglichkeit, DEP zuverlässig zu umgehen und ASLR ist durch ein Zeigerleck. Dies ist eine Situation, in der ein Wert auf dem Stapel an einem zuverlässigen Ort verwendet werden kann, um einen verwendbaren Funktionszeiger oder ein ROP-Gadget zu lokalisieren. Sobald dies erledigt ist, ist es manchmal möglich, eine Nutzlast zu erstellen, die beide Schutzmechanismen zuverlässig umgeht.
Quellen:
Weiterführende Literatur:
Zur Ergänzung der Selbstantwort von @ Polynomial: DEP kann tatsächlich auf älteren x86-Computern (die vor dem NX-Bit liegen) erzwungen werden, jedoch zu einem Preis.
Die einfache, aber eingeschränkte Möglichkeit DEP auf alter x86-Hardware verwendet Segmentregister. Bei aktuellen Betriebssystemen auf solchen Systemen sind Adressen 32-Bit-Werte in einem flachen 4-GB-Adressraum, aber intern verwendet jeder Speicherzugriff implizit eine 32-Bit-Adresse und ein spezielles 16-Bit-Register, das aufgerufen wird ein "Segmentregister".
Im sogenannten geschützten Modus zeigen Segmentregister auf eine interne Tabelle (die "Deskriptortabelle" - tatsächlich gibt es zwei solche Tabellen, aber das ist eine technische Tatsache) und jeden Eintrag in der Tabelle gibt die Eigenschaften des Segments an. Insbesondere die Arten der zulässigen Zugriffe und die Größe des Segments. Darüber hinaus verwendet die Codeausführung implizit das CS-Segmentregister, während der Datenzugriff hauptsächlich DS verwendet (und der Stapelzugriff, z. B. mit den Opcodes push
und pop
, SS verwendet). Dadurch kann das Betriebssystem den Adressraum in zwei Teile aufteilen. Die unteren Adressen liegen sowohl für CS als auch für DS im Bereich, während die oberen Adressen für CS außerhalb des Bereichs liegen. Beispielsweise hat das von CS beschriebene Segment eine Größe von 512 MB. Dies bedeutet, dass auf jede Adresse jenseits von 0x20000000 als Daten zugegriffen werden kann (gelesen oder geschrieben, um DS als Basisregister zu verwenden), Ausführungsversuche jedoch CS verwenden. Zu diesem Zeitpunkt löst die CPU eine Ausnahme aus (die der Kernel in ein geeignetes Signal wie konvertiert) SIGILL oder SIGSEGV, was normalerweise den Tod des fehlerhaften Prozesses impliziert.
(Beachten Sie, dass Segmente auf den Adressraum angewendet werden; die MMU ist also auf einer unteren Ebene noch aktiv Der oben erläuterte Trick ist pro Prozess.)
Dies ist billig: Die x86-Hardware erzwingt systematisch Segmente (und der erste 80386 hat dies bereits getan; tatsächlich hatte der 80286 bereits solche Segmente mit Grenzen, aber nur 16-Bit Offsets). Wir können sie normalerweise vergessen, weil vernünftige Betriebssysteme die Segmente so einstellen, dass sie bei Offset Null beginnen und 4 GB lang sind. Wenn Sie sie jedoch anderweitig einstellen, bedeutet dies keinen Overhead, den wir noch nicht hatten. Als DEP-Mechanismus ist es jedoch unflexibel: Wenn ein Datenblock vom Kernel angefordert wird, muss der Kernel entscheiden, ob dies für Code gilt oder nicht, da die Grenze fest ist. Wir können uns nicht entscheiden, eine bestimmte Seite dynamisch zwischen Codemodus und Datenmodus zu konvertieren.
Die unterhaltsame, aber etwas teurere Methode, DEP durchzuführen, verwendet etwas namens PaX. Um zu verstehen, was es tut, muss man auf einige Details eingehen.
Die MMU auf x86-Hardware verwendet In-Memory-Tabellen, die den Status jeder 4-kB-Seite in der Adresse beschreiben Raum. Der Adressraum beträgt 4 GB, es gibt also 1048576 Seiten. Jede Seite wird durch einen 32-Bit-Eintrag in einer Untertabelle beschrieben. Es gibt 1024 Untertabellen mit jeweils 1024 Einträgen und eine Haupttabelle mit 1024 Einträgen, die auf die 1024 Untertabellen verweisen. Jeder Eintrag gibt an, wo sich das Objekt, auf das verwiesen wird (eine Untertabelle oder eine Seite), im RAM befindet oder ob es überhaupt vorhanden ist und welche Zugriffsrechte es hat. Die Ursache des Problems liegt darin, dass es bei Zugriffsrechten um Berechtigungsstufen (Kernelcode vs. Benutzerland) und nur um ein Bit für den Zugriffstyp geht, sodass "Lese-Schreib" oder "Nur-Lese" möglich ist. "Ausführung" wird als eine Art Lesezugriff angesehen. Daher hat die MMU keine Vorstellung davon, dass sich "Ausführung" vom Datenzugriff unterscheidet. Was lesbar ist, ist ausführbar.
(Seit dem Pentium Pro im vergangenen Jahrhundert kennen x86-Prozessoren ein anderes Format für die Tabellen, PAE. Es verdoppelt die Größe der Einträge, wodurch Platz für die Adressierung von mehr physischem RAM bleibt und auch ein NX-Bit hinzufügen - aber dieses spezifische Bit wurde erst um 2004 von der Hardware implementiert.)
Es gibt jedoch einen Trick. RAM ist langsam. Um einen Speicherzugriff durchzuführen, muss der Prozessor zuerst die Haupttabelle lesen, um die zu durchsuchende Untertabelle zu finden, dann diese Untertabelle erneut lesen, und erst an diesem Punkt weiß der Prozessor, ob der Speicherzugriff erfolgen soll erlaubt oder nicht, und wo im physischen RAM die Daten, auf die zugegriffen wird, wirklich sind. Dies sind Lesezugriffe mit vollständiger Abhängigkeit (jeder Zugriff hängt von dem vom vorherigen gelesenen Wert ab), sodass sich die volle Latenz auszahlt, die auf einer modernen CPU Hunderte von Taktzyklen darstellen kann. Daher enthält die CPU einen bestimmten Cache, der die zuletzt aufgerufenen MMU-Tabelleneinträge enthält. Dieser Cache ist der Translation Lookaside Buffer.
Ab 80486 verfügt die x86-CPU nicht über einen TLB, sondern über zwei . Das Caching arbeitet mit Heuristiken, und Heuristiken hängen von Zugriffsmustern ab, und Zugriffsmuster für Code unterscheiden sich tendenziell von Zugriffsmustern für Daten. Die intelligenten Mitarbeiter von Intel / AMD / other fanden es daher sinnvoll, einen TLB für den Codezugriff (Ausführung) und einen TLB für den Datenzugriff zu haben. Darüber hinaus verfügt der 80486 über einen Opcode ( invlpg
), mit dem ein bestimmter Eintrag aus dem TLB entfernt werden kann.
Die Idee lautet also wie folgt: Stellen Sie sicher, dass die beiden TLBs unterschiedliche Ansichten desselben Eintrags haben. Alle Seiten sind in den Tabellen (im RAM) als "nicht vorhanden" markiert, wodurch beim Zugriff eine Ausnahme ausgelöst wird. Der Kernel fängt die Ausnahme ab, und die Ausnahme enthält einige Daten zur Art des Zugriffs, insbesondere, ob es sich um eine Codeausführung handelte oder nicht. Der Kernel macht dann den neu gelesenen TLB-Eintrag ungültig (derjenige, der "abwesend" sagt), füllt dann den Eintrag im RAM mit einigen Rechten, die den Zugriff ermöglichen, und erzwingt dann einen Zugriff des erforderlichen Typs (entweder Datenlesen oder Codeausführung), der füttert den Eintrag in den entsprechenden TLB und nur diesen. Der Kernel setzt dann den Eintrag im RAM sofort wieder auf abwesend und kehrt schließlich zum Prozess zurück (zurück zum erneuten Versuch des Opcodes, der die Ausnahme ausgelöst hat).
Der Nettoeffekt ist der, wenn die Ausführung zurückkommt Für den Prozesscode enthält der TLB für Code oder der TLB für Daten den entsprechenden Eintrag, der andere TLB jedoch nicht und nicht , da die Tabellen im RAM immer noch angeben "abwesend". Zu diesem Zeitpunkt kann der Kernel entscheiden, ob die Ausführung zulässig ist oder nicht, unabhängig davon, ob er den Datenzugriff zulässt oder nicht. Es kann somit eine NX-ähnliche Semantik erzwingen.
Der Teufel versteckt sich in den Details; In diesem Fall ist Platz für eine ganze Legion Dämonen. Ein solcher Tanz mit der Hardware ist nicht einfach richtig umzusetzen. Insbesondere auf Mehrkernsystemen.
Der Overhead ist folgender: Wenn ein Zugriff ausgeführt wird und der TLB nicht den relevanten Eintrag enthält, muss auf die Tabellen im RAM zugegriffen werden, und dies allein bedeutet, dass einige verloren gehen hundert Zyklen. Zu diesen Kosten addiert PaX den Overhead der Ausnahme und den Verwaltungscode, der den richtigen TLB ausfüllt, wodurch die "einige hundert Zyklen" in "einige tausend Zyklen" umgewandelt werden. Glücklicherweise sind TLB-Fehler richtig. Die PaX-Leute behaupten, eine Verlangsamung von nur 2,7% bei einem großen Kompilierungsjob gemessen zu haben (dies hängt jedoch vom CPU-Typ ab).
Das NX-Bit macht all dies überflüssig. Beachten Sie, dass das PaX-Patchset auch einige andere sicherheitsrelevante Funktionen enthält, z. B. ASLR, das mit einigen Funktionen neuerer offizieller Kernel überflüssig ist.