21 KiB
food / fooc — ein Stack-Pufferüberlauf, von beiden Seiten
Ein C99-Sicherheitslabor in zwei Hälften:
food.c— ein absichtlich angreifbarer TCP-Daemon. Er enthält einen echten, lehrbuchreifen Stack-Pufferüberlauf (CWE-120) und nebenbei noch ein paar weitere Bugs.fooc.c— ein Exploit dafür. Er berechnet das Overflow-Offset, indem er das Zielprogramm zur Laufzeit disassembliert, liest Adress-Leaks vom Daemon und erhält eine Shell auf dem „Opfer", indem er eine gespeicherte Rücksprungadresse überschreibt.
Es geht nicht um die Shell. Es geht darum, dass man Ende-zu-Ende mitverfolgen kann, wie aus einem Speichersicherheitsfehler eine beliebige Codeausführung wird — und dann genau sieht, welche Gegenmaßnahmen welchen Schritt dieser Kette stoppen. Jede Zeile beider Programme ist kommentiert, denn der Mechanismus ist die Lektion.
dein Terminal
|
./fooc (Exploit)
|
TCP 127.0.0.1:2342
|
./food (angreifbarer Daemon)
|
fork() -> vulnerable_handler() -> Overflow -> ret -> dein Code
⚠️ Bitte zuerst lesen
food ist ein absichtlich kaputter Netzwerkdienst. Er bindet ausschließlich
an 127.0.0.1, und dieser Standard ist Absicht — bitte lass ihn so.
- Führe ihn nicht auf einer Maschine aus, die dir wichtig ist, oder auf irgendetwas mit Daten darauf.
- Binde ihn nicht an
0.0.0.0oder eine echte Netzwerkschnittstelle. Er ist bewusst remote ausnutzbar. - Ein
foocgegen einen Host zu richten, der dir nicht gehört bzw. für den du keine schriftliche Testgenehmigung hast, ist in den meisten Rechtsordnungen ein Computersabotage-Straftatbestand — auch nach dem UK Computer Misuse Act und dem US Computer Fraud and Abuse Act. - Er bindet einen unprivilegierten Port (>1024), du brauchst also kein root. „Verbessere" ihn nicht, indem du Capabilities hinzufügst oder ihn als Systemdienst laufen lässt.
- Jede Verbindung wird in einem per
fork()erzeugten Kindprozess behandelt, undfoodreaped ihn, sodass sich keine Abstürze ansammeln. Falls du danach dutzende streunendesh-Prozesse vorfindest, istpkill -x shdie Aufräumlösung.
Im Zweifel: Dieses Labor ist für eine virtuelle Maschine oder einen Container gedacht, in einem Netzwerk, das du kontrollierst, auf einer Maschine, auf der dir nichts fehlen würde.
Schnellstart
make # baut food, fooc und die Test-Harnesses
make run # startet food auf 127.0.0.1:2342, abgelöst im Hintergrund
make test # führt alle drei Exploit-Techniken aus
make stop # stoppt den Daemon
Danach von Hand:
./fooc -t leak # sieh dir die Adress-Leaks an, die food ausgibt
./fooc -t demo -v # sende Datenmüll; beobachte, wie food mit SIGSEGV stirbt
./fooc -t ret2win -i # springe zu einer Funktion, die bereits existiert -> Shell
Voraussetzungen
| Werkzeug | Wofür | Hinweise |
|---|---|---|
gcc (oder clang) |
Bauen | C99. Getestet mit gcc 16.2 |
objdump |
fooc |
binutils. fooc ruft es zur Laufzeit auf |
nasm |
make verify |
nur zum Gegenprüfen des Shellcodes; wird übersprungen, wenn nicht vorhanden |
gdb |
make debug |
optional |
| Linux, x86-64 | beides | Payload und Gadget-Suche sind architekturspezifisch |
fooc benötigt außerdem -ldl für dlsym(); das erledigt das Makefile.
Der Bug
Eine Zeile in food.c ist die gesamte Angriffsfläche:
char buf[FOOD_BUFSZ]; /* 64 Bytes */
n = read(fd, buf, FOOD_READMAX); /* bis zu 512 Bytes aus dem Netzwerk */
64 Bytes Ziel, 512 Bytes akzeptiert. Der Angreifer überschreibt 448 Bytes über das Ende des Puffers hinaus, und weil der Stack nach unten wächst, bedeutet „über das Ende hinaus" „in den darüberliegenden Frame hinein" — und genau dort liegen der gespeicherte Frame-Pointer und die gespeicherte Rücksprungadresse.
In einer kompilierten x86-64-Funktion bei -O0:
hohe Adressen
+------------------------+ rbp + 16 : Locals des Aufrufers
| ... |
+------------------------+ rbp + 8 : GESPEICHERTE RÜCKSPRUNGSADRESSE <-- wird zu RIP
| saved rbp (8 Bytes) |
+------------------------+ rbp : unser Frame-Pointer
| line[128] |
| buf[64] | <- rsp: das, was read() füllt
+------------------------+
niedrige Adressen
Wenn die Funktion zurückkehrt, poppt leave; ret diese 8 Bytes in RIP, und
die CPU springt dorthin, wo der Angreifer es bestimmt hat. Alles andere in
diesem Labor ist Arithmetik darüber, wohin gedeutet werden soll.
Für diesen Build sind die Zahlen: buf ist 64 Bytes, das gespeicherte rbp
ist 8, die Rücksprungadresse liegt also bei Offset 88 vom Anfang von buf.
fooc härtet das nicht ein — es disassembliert food und findet das
lea -0x50(%rbp) vor dem call read@plt, sodass es weiter funktioniert, wenn
du FOOD_BUFSZ änderst.
gcc weist bereits darauf hin. Das Bauen von
fooddruckt:warning: 'read' writing 512 bytes into a region of size 64 overflows the destination [-Wstringop-overflow=]. Unterdrücke diese Warnung in echtem Code nie. Sie ist geschenkte Sicherheit.
Die drei Techniken
fooc -t <technique>. Sie stehen in der Reihenfolge, in der ein echter
Angreifer sie durcharbeiten würde, denn jede braucht, was die vorherige dich
gelehrt hat.
1. ret2win — den Befehlszeiger kontrollieren
[ 88 Bytes Müll ][ Adresse von food's win() ]
^ saved rbp
^ wird zu RIP
win() ist eine Funktion im Zielprogramm, die /bin/sh ausführt. Das
Überschreiben der Rücksprungadresse mit ihrer Adresse ist der gesamte Exploit.
Was es lehrt: Du hast beliebige Kontrolle über den Befehlszeiger. Es
braucht außerdem kein Leak, weil das Binärprogramm -no-pie gebaut ist, sodass
win() für immer an einer festen Adresse sitzt.
Das reale Äquivalent ist nicht „Angriffe sind einfach", sondern „verschiffe
keine undokumentierten Hintertüren in Netzwerk-Binärprogrammen". Wenn eine
Funktion wie win() in deinem Binärprogramm existiert, wird ein
Pufferüberlauf sie finden. Das ist wörtlich die Hintertür-Klasse von Juniper
ScreenOS (CVE).
Verteidigung: -fPIE (oder ASLR) randomisiert die Ladeadresse, sodass der
Angreifer die Adresse kennen muss — was meistens bedeutet, dass er zuerst ein
Leak braucht. Deshalb schlägt ret2win gegen food_hardened fehl.
2. ret2libc — Beliebiges aufrufen, beim Namen
[ Müll ][ pop rdi; ret ][ Adresse von "/bin/sh" ][ Adresse von system() ]
^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
setzt rdi der zu übergebende String die aufzurufende Funktion
Zur Ausführungszeit: ret poppt pop rdi; ret in RIP; das poppt den
"/bin/sh"-Pointer in RDI; dessen ret poppt system() in RIP, wobei
RDI weiterhin den String hält. system("/bin/sh") läuft.
Die Gadgets (pop rdi; ret) stecken nicht in food — diese glibc hat kein
__libc_csu_init — daher findet sie fooc, indem es den Live-libc-Speicher
nach dem Bytepaar 5f c3 durchsucht. Es lokalisiert libc über
/proc/self/maps, findet die Offsets von system und "/bin/sh" mit
dlsym() und berechnet die Basis aus dem Leak, das food veröffentlicht.
Nichts ist fest verdrahtet, sodass der Exploit ein libc-Update überlebt.
Was es lehrt: Wenn du einmal RIP kontrollierst, kannst du vorhandene
Befehle aneinanderreihen. Das ist Return-Oriented Programming, und es ist, wie
fast alle echten Exploits aussehen, weil es keinen vom Angreifer gelieferten
ausführbaren Speicher braucht.
Verteidigung: Keine der Compiler-Flags stoppt das allein. Es funktioniert gegen ein PIE-Binärprogramm, mit NX, mit Canary — solange der Angreifer ein Leak hat. Die Verteidigungen sind „habe den Overflow nicht" und „leake keine Adressen". Siehe Tabelle unten.
3. shellcode — eigenen Maschinencode ausführen
23 Bytes, platziert am Anfang des Puffers, mit RIP, das auf sie zeigt:
xor esi, esi ; envp = NULL
xor edx, edx ; argv = NULL
movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" als 8 rohe Bytes
push rdi ; lege den String auf den Stack
mov rdi, rsp ; rdi = &"/bin/sh"
push 0x3b ; 59 = __NR_execve
pop rax
syscall ; wir sind jetzt eine Shell
Das ist die reinste Form des Bugs: Der Angreifer liefert die Befehle, nicht nur die Adresse von Befehlen, die bereits existieren. Keine libc-Offsets nötig, also funktioniert es im Prinzip gegen ein statisch gelinktes, vollständig randomisiertes Ziel.
make verify assembliert shellcode.S und vergleicht es mit dem Byte-Array,
das in fooc.c eingebettet ist, sodass die beiden nicht auseinanderlaufen
können.
Verteidigung: NX (auch W^X, „no execute"). Wenn der Stack als
nicht-ausführbar markiert ist, weigert sich die Hardware, Befehle von ihm zu
holen, und das ret landet auf einer Seite, die nicht ausführbar ist. Deshalb
übergibt make food die Flag -z execstack: Ein Standard-Linux-Stack ist
rw-p, nicht rwx, und die Technik stirbt mit SIGSEGV bei RIP = die Adresse des Payloads. Die mit Abstand wichtigste Lektion des Labors ist, dass jedes
dieser Bytes nur funktioniert, weil dem Compiler gesagt wurde, den Stack
ausführbar zu lassen. Diese Flag ist für niemandes Wohl eingeschaltet.
Außerdem enthalten
| Modus | Was es tut |
|---|---|
-t leak |
verbindet, druckt die Leaks, sendet nichts |
-t demo |
sendet rip_off + 8 Bytes 0x41, sodass RIP zu 0x4141... wird und der Daemon stirbt. Beweist den Bug ganz ohne Adresswissen |
-t sled |
ein Ret-Sled, bewusst als fehlschlagendes Beispiel behalten. Ohne ein Leak würdest du ASLR brute-forcen, indem du den Puffer mit der Adresse eines ret füllst. Hier kann es nicht funktionieren: food akzeptiert 512 Bytes, der Sled hat also ~53 Slots gegen ~28 Bit Entropie. Implementiert, damit du zusehen kannst, wie es scheitert, und bestätigst, dass der Mechanismus wirklich „die CPU folgt einer Kette von rets" ist |
Die Tabelle der Gegenmaßnahmen
Das ist der Teil, den man sich merken sollte. Jede Zeile ist eine echte Verteidigung, und die rechte Spalte zeigt, was sie tatsächlich mit der Ereigniskette macht.
| Gegenmaßnahme | So aktivierst du sie | Was sie stoppt | Was sie nicht stoppt |
|---|---|---|---|
| Read begrenzen | n = read(fd, buf, sizeof buf - 1); |
Alles. Der Bug existiert nicht, also ist nichts nachgelagert relevant | Nichts — das ist der einzige vollständige Fix |
| Stack-Canary | -fstack-protector-strong (gcc-Standard) |
Das ret: Der Canary wird beim Funktionsende geprüft, der Einschlag wird also erkannt und der Prozess bricht ab, bevor RIP gepoppt wird |
Ein Bug in einer Funktion ohne Array (nichts zu schützen); ein Overflow, der unter dem Canary bleibt; alles, was nicht normal zurückkehrt |
| NX / W^X | -z noexecstack (der Standard) |
Shellcode. Die eigenen Befehle des Payloads können nicht geholt werden | ret2win und ret2libc vollständig. Sie sind der Grund, warum ROP existiert |
| PIE + ASLR | -fPIE + ASLR=2 (beides Standard) |
ret2wins fest verdrahtete Adressen. Alles bewegt sich bei jedem Lauf | Alles, wo der Angreifer ein Leak hat. ASLR erhöht die Kosten eines Exploits; es ist kein Fix. Beachte, dass Stack, Heap und mmap randomisiert sind, der Inhalt des Haupt-Binärprogramms jedoch nicht — das ist es, was ROP-Ketten verwenden |
| Nicht leaken | kein printf("%p") an Clients; vor dem Drucken initialisieren |
Der Informations-Leak, der ASLR von „teuer" zu „gratis" macht | — |
Kein printf(user_data) |
printf("%s", buf) statt printf(buf) |
Format-String-Bugs: %x-Stack-Reads, %n-beliebige Schreibzugriffe — ein zweiter Weg zu RCE |
— |
| Keine unvertrauenswürdigen Pfade | validieren und openat() unter einem festen Verzeichnis |
Pfad-Traversal (CWE-22) | — |
| CET / Shadow Stack | -fcf-protection=full, Kernel- und CPU-Unterstützung |
Das ret selbst: Der Shadow Stack merkt sich die echte Rücksprungadresse und fault bei einem Mismatch. Fängt ROP-Ketten ab, die Hardware-ret verwenden |
Angriffe, die nie ret ausführen (call-oriented, oder das Ziel eines Funktionspointers mit einer Gadget-Kette überschreiben, die keine Rückkehr braucht) |
| Sichere Sprachen | Rust, Go, C# für neuen Code | Die ganze Klasse. Bounds-Checks werden zur Laufzeit geprüft, nicht beim Review erhofft | — |
Selbst ausprobieren
make run # angreifbarer Daemon
make test # alle drei Techniken funktionieren
make test-hardened # gleicher Quellcode, Gegenmaßnahmen an
test-hardened baut food_hardened mit -fstack-protector-strong -fPIE -pie -z noexecstack, tauscht es ein, führt alle drei erneut aus und legt danach
das angreifbare wieder zurück. Du wirst sehen:
### stack segment: 'rw-p' (NOT executable) is what you want to see
--- ret2win was stopped by the mitigations (as expected)
--- ret2libc was stopped by the mitigations (as expected)
--- shellcode was stopped by the mitigations (as expected)
Und im Log des gehärteten Daemons das Auslösen des Canarys:
*** stack smashing detected ***: terminated
Lies das genau, denn es ist die wichtigste Zeile des ganzen Labors: der
Canary hat ret2win erwischt, nicht PIE. Alle drei Techniken sterben am
Canary, weil alle drei durch dasselbe read() gehen und denselben Frame
zerstören. NX stoppt nur zusätzlich den Code des Shellcodes; PIE bricht nur
zusätzlich die hart verdrahtete Adresse. Schalte sie einzeln an, und du wirst
feststellen, dass dich die meisten einzelnen Gegenmaßnahmen irgendetwas
ausgesetzt lassen.
Dateien
| Datei | Zweck |
|---|---|
food.c |
der angreifbare Daemon. 6 nummerierte Bugs, jeder mit seinem Fix im Kommentar |
fooc.c |
der Exploit. objdump-basierte Offset-Erkennung, /proc-basierte libc-Erkennung, 4 Payload-Builder |
shellcode.S |
die 23 Shellcode-Bytes als Assembly, damit man sie lesen und verifizieren kann. fooc trägt sie inline und braucht das zur Laufzeit nicht |
Makefile |
baut, testet und liefert den gehärteten Vergleich |
tests/pty_test.c |
treibt fooc über ein Pseudo-Terminal und prüft auf echte Shell-Ausgabe |
tests/sock_test.c |
unabhängiger Verifizierer über einen rohen Socket, damit das Ergebnis nicht von fooc abhängt |
food.log |
das Log des Daemons. Dein Beweis, was passiert ist |
Zwei Bugs in diesem Labor, die es wert sind, verstanden zu werden
Das sind nicht die Bugs des Zielprogramms. Es sind Bugs im Exploit und in seiner Test-Harness, und beide haben überzeugende Lügen produziert. Sie sind im Quellcode an ihrem Ort dokumentiert; hier stehen sie, weil die Ausfallmuster lehrreich sind.
Stack-Ausrichtung: der Absturz, der kein NULL-Deref ist
Symptom. Die Übernahme landet korrekt — gdb zeigt dich in win() — und
dann stirbt das allererste, was win() tut, ein dprintf(). Der
SIGSEGV-Handler meldet RIP tief im glibc-Formatter und eine Fehleradresse
von (nil), was exakt wie ein korrupter Pointer aussieht.
Ursache. Die System-V-AMD64-ABI verlangt 16-Byte-Stack-Ausrichtung. Ein
normales ret stellt %rsp exakt auf das wieder her, was das zugehörige
call gespeichert hat, sodass die Invariante gratis erhalten bleibt. Unser
nacktes ret tut das nicht: Danach gilt %rsp = buf + rip_off. Hier ist buf
16-Byte-ausgerichtet und rip_off ist 88, der Callee bekommt also einen Stack,
der 8 mod 16 ist. glibc ist mit SSE2 kompiliert, und movaps fault bei
einem nicht ausgerichteten Operanden. Auf x86 löst das #GP aus, nicht #PF,
der Kernel hat also keine Fehleradresse und meldet si_addr = 0. Dieses NULL
ist der Hinweis: ein Ausrichtungsfehler, verkleidet als NULL-Deref.
Fix. Ein ret-Gadget bei Offset rip_off, das das echte Ziel um 8 Bytes
nach oben verschiebt, denn jedes ret addiert exakt 8 auf %rsp. Die
Reihenfolge ist entscheidend: Eine frühere Version hängte das ret hinter
das Ziel an und erzeugte [ padding | target | ret ], wo das abschließende
ret nie erreicht wird und der Fix still nichts tut. Ein versprengtes ret,
das wie ein Fehler aussieht, ist fast immer Absicht.
Ein Socket, zwei Leser: das verschwundene Byte
Symptom. Shellcode wurde als funktionierend gemeldet. Dann wurde die
pty-Harness strenger gemacht (Abschalten von ECHO, sodass das Terminal seine
eigene Befehlszeile nicht mehr zurückspiegelte) und die Technik begann zu
scheitern. Im Kern ließ jede Technik exakt ein Byte vom Anfang jedes
Ausgabeblocks fallen: uid=1000(hanez) wurde zu id=1000(hanez) gedruckt,
PWNED-OK zu WNED-OK, Linux 7.2.7 zu inux 7.2.7.
Ursache. fooc pflegte den Socket per dup2() auf sein eigenes
stdin/stdout zu legen und eine lokale /bin/sh per execv() zu starten,
während ein geforktes Relay-Kind denselben Socket ebenfalls las, um die Ausgabe
zum Terminal zu befördern. Dem Kernel ist egal, dass die beiden kooperieren. Ein
Stream-Socket hat einen Read-Cursor, und jeder Leser bewegt ihn, sodass
Bytes unvorhersehbar zwischen ihnen aufgeteilt werden. Die lokale Shell las als
interaktive Login-Shell exakt ein Byte und verwarf es — bei jedem einzelnen
Mal. strace -f zeigte es sofort:
read(0, "u", 1) <- die lokale Shell, frisst ein Byte
read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- das Relay, 1 Byte zu wenig
Fix. Auf dieser Seite gibt es überhaupt keine Shell. Es gibt genau eine Shell im gesamten Bild, und sie ist auf dem Opfer, im übernommenen Prozess, mit der TCP-Verbindung als stdin/stdout. Diese Seite bewegt nur Bytes. Wenn du je zwei Konsumenten eines Streams brauchst, braucht dieser Stream einen einzigen Leser, der ihn bewusst demultiplexiert.
Die Meta-Lektion. Das erste „funktionierende" Ergebnis war ein Fehlpositiv, das dadurch entstand, dass die pty die eigene Befehlszeile der Harness zurückwarf, und der Fix für dieses Fehlpositiv ist es, der den echten Bug bloßlegte. Tests, die nicht scheitern können, sind schlimmer als keine Tests, weil sie „ich weiß es nicht" in „es funktioniert" verwandeln. Eine Test-Harness verdient denselben Argwohn wie der Code, den sie testet.
Daran herumexperimentieren
Dinge, die einen Versuch wert sind, ungefähr in der Reihenfolge, in der man mehr lernt:
-
Ändere
FOOD_BUFSZauf 128. Führefoocerneut aus. Es sollte ohne jede Änderung weiter funktionieren, weil es das Offset aus der Disassembly liest. Brich es dann von Hand — härt 88 ein — und sieh zu, wie es abstürzt. Füge dann zwischenbufund den gespeicherten Registern ein zweites Array ein und beobachte, wie die automatische Erkennung es verkraftet. -
Füge
-Wformat-securityhinzu und schau, was der Format-String-Pfad tut. Sende%p %p %p %nund beobachte, wiefoodden Stack leakt. -
Nutze gdb.
make debug, dann:(gdb) break food.c:393 # das read(), das überläuft (gdb) run -p 2342 (gdb) info registers rsp rbp (gdb) x/24gx $rsp # beachte, wo die Rücksprungadresse liegt (gdb) c # in einem anderen Terminal: ./fooc -t ret2winDer
SIGSEGV-Handler loggtREG_RIPundREG_RSP, sodass dirfood.logsagt, ob die Übernahme gelandet ist, selbst wenn das Kind stirbt, bevor du dich anhängen kannst. -
Lösche den Ausrichtungs-Fix in
fooc.cund beobachte den#GP-Fault mit dersi_addr = 0-Signatur. Lies dann/proc/sys/kernel/randomize_va_spaceund denke darüber nach, was ASLR randomisiert und was nicht. -
Brich die libc-Symbolauflösung und beobachte, wie
foocsich anpasst. Der ganze Sinn des/proc/self/maps-Ansatzes ist, dass kein Offset hart verdrahtet ist. -
Schreibe eine vierte Technik. Eine
ret2csu-artige Kette, wenn du__libc_csu_initfindest, oder eine SROP-Kette (sigreturn-Frames lassen dich alle Register gleichzeitig kontrollieren). Beides ist reines ROP und braucht keinen ausführbaren Speicher. -
Fixe
food.crichtig, Bug für Bug, und führe den Exploit nach jedem Fix erneut aus. Die Reihenfolge in der Tabelle am Anfang vonfood.cist ungefähr die richtige Reihenfolge zum Nachdenken: begrenze zuerst das read, denn nichts anderes zählt, bis der Bug weg ist.
Aufräumen
make stop # stoppt food
make clean # entfernt Build-Produkte; lässt food.log in Ruhe
pkill -x sh # nur, wenn du streunende Shells aus einem schiefgelaufenen Test hast
Beachte: pkill -x food matcht den Prozessnamen exakt. Verwende nicht
pkill -f ./food — dieses Muster matcht auch die Shell, in die du es getippt
hast, und tötet deine eigene Session. Das ist keine Hypothese; es ist beim Bau
dieses Labors passiert.