foo/README.DE.md
2026-09-29 10:04:38 +02:00

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.0 oder eine echte Netzwerkschnittstelle. Er ist bewusst remote ausnutzbar.
  • Ein fooc gegen 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, und food reaped ihn, sodass sich keine Abstürze ansammeln. Falls du danach dutzende streunende sh-Prozesse vorfindest, ist pkill -x sh die 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 food druckt: 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:

  1. Ändere FOOD_BUFSZ auf 128. Führe fooc erneut 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 zwischen buf und den gespeicherten Registern ein zweites Array ein und beobachte, wie die automatische Erkennung es verkraftet.

  2. Füge -Wformat-security hinzu und schau, was der Format-String-Pfad tut. Sende %p %p %p %n und beobachte, wie food den Stack leakt.

  3. 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 ret2win
    

    Der SIGSEGV-Handler loggt REG_RIP und REG_RSP, sodass dir food.log sagt, ob die Übernahme gelandet ist, selbst wenn das Kind stirbt, bevor du dich anhängen kannst.

  4. Lösche den Ausrichtungs-Fix in fooc.c und beobachte den #GP-Fault mit der si_addr = 0-Signatur. Lies dann /proc/sys/kernel/randomize_va_space und denke darüber nach, was ASLR randomisiert und was nicht.

  5. Brich die libc-Symbolauflösung und beobachte, wie fooc sich anpasst. Der ganze Sinn des /proc/self/maps-Ansatzes ist, dass kein Offset hart verdrahtet ist.

  6. Schreibe eine vierte Technik. Eine ret2csu-artige Kette, wenn du __libc_csu_init findest, oder eine SROP-Kette (sigreturn-Frames lassen dich alle Register gleichzeitig kontrollieren). Beides ist reines ROP und braucht keinen ausführbaren Speicher.

  7. Fixe food.c richtig, Bug für Bug, und führe den Exploit nach jedem Fix erneut aus. Die Reihenfolge in der Tabelle am Anfang von food.c ist 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.