foo/suid/README.DE.md
2026-09-29 09:39:24 +02:00

406 lines
No EOL
20 KiB
Markdown

# SUID-Root-RCE-Labor — `foosd` (Daemon) + `foosc` (Exploit)
Ein Begleiter zum übergeordneten Labor (`food` / `fooc`, ein gewöhnlicher
Daemon, bei dem ein Pufferüberlauf eine *Benutzer*-Shell liefert). Dieses fügt
die gefährlichste Ein-Zeichen-Änderung in Unix hinzu: das **Setuid-Bit**.
> `chmod u+s` verwandelt „der Angreifer kann Code auf diesem Host ausführen" in
> „der Angreifer kann auf diesem Host Code als **root** ausführen".
Dieser Satz ist das gesamte Labor. Alles darunter ist der Mechanismus darunter,
aufgeschrieben, damit du beim Schreiben eigener Software genau weißt, welche
zwei oder drei Dateisystem-Attribute und Compiler-Flags entscheiden, ob ein
Speichersicherheitsbug in deinem Code eine Belästigung oder eine Root-Shell ist.
Die finale Demo, wenn `foosd` setuid-root ist, ist eine **Root-Shell**, die
über das Netzwerk geöffnet wird, indem 32 Bytes handgeschriebener Shellcode
ausgeführt werden.
---
## 1. Was das Setuid-Bit tatsächlich tut
Jeder Prozess unter Linux trägt drei User-IDs, und das Setuid-Bit bastelt an
der Beziehung zwischen ihnen:
| ID | Name | Bedeutung |
|----|------|---------|
| `ruid` | reale User-ID | das Konto, das den Prozess *gestartet* hat |
| `euid` | effektive User-ID | was der Kernel bei der Durchsetzung von Zugriffsrechten prüft |
| (saved) | gespeicherte Set-User-ID | ein „Slot", in den ein privilegierter Prozess später zurückkehren darf |
Ein normales Programm hat `ruid == euid`. Wenn du ein Binärprogramm mit
gesetztem Setuid-Bit ausführst, das root gehört:
```text
ruid = du (z. B. 1000, "hanez")
euid = der Besitzer (z. B. 0, "root")
```
Der Prozess hat also **roots Autorität**, obwohl der Benutzer, der ihn
gestartet hat, völlig gewöhnlich ist. Jede Prüfung, die der Kernel durchführt —
kann dieser Prozess `/etc/shadow` lesen? eine Datei schreiben? einen anderen
Prozess töten? — wird mit `euid` beantwortet, d. h. „ja, es ist root".
`foosd` ist ein Netzwerk-Daemon. Er bindet einen Port und `fork()`t dann pro
Verbindung ein Kind. Ein Fork *erbt* die euid, also ist auch jedes Kind, das
eine Verbindung behandelt, root. Der Overflow in `foosd`s
`vulnerable_handler()` ist daher ein Overflow *in einem Root-Prozess*.
**Diagnostiziere es selbst, sobald der Daemon läuft:**
```console
$ ./foosd ... # siehe die Log-Zeile beim Start
[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process
```
und vom Exploit aus:
```console
$ ./foosc -t leak
foosc: target euid=0 ruid=1000
```
---
## 2. Das Labor auf einen Blick
| Datei | Rolle |
|------|------|
| `foosd.c` | Der absichtlich angreifbare Daemon (besitzt die Bugs). Für die Root-Shell-Demo als *setuid-root*-Binärprogramm ausführen. |
| `foosc.c` | Der Exploit. Standard: die 32-Byte-`setreuid + execve`-Shellcode-Technik. |
| `shellcode.S` | Der Referenz-Shellcode; `make verify` vergleicht ihn mit dem Byte-Array in `foosc.c`. |
| `tests/pty_suid_test.c` | Test-Harness. Treibt `foosc` durch ein Pseudo-Terminal und beweist sowohl „eine Shell lief" als auch „sie war root" (`uid=0(`). |
| `Makefile` | Build, `setuid`/`unsetuid`-Helfer, Test-Matrix. |
| `README.md` | Diese Datei. |
> **Warum eine pty?** Die letzte Aktion des Exploits ist es, dein Terminal an
> die Shell weiterzuleiten, die auf dem Opfer läuft. Eine Pipe oder ein
> Here-Doc landet am falschen Ende dieser Weiterleitung; ein echtes Terminal
> ist erforderlich.
---
## 3. Schnellstart
```console
$ make # alles bauen, als dein normaler Benutzer
$ make setuid # einmalig, fragt nach sudo: chown root + chmod u+s
$ make run # startet foosd auf 127.0.0.1:2343
$ make test-suid # volle Matrix; shellcode + ret2win-root müssen root ergeben
```
Interaktiver Smoke-Test:
```console
$ ./foosc -t shellcode
...
foosc: target euid=0 ruid=1000
foosc: shell is on the victim (root if foosd is SUID); relaying
# id
uid=0(root) gid=0(root) groups=0(root) <-- du bist root, auf dem Opfer
# exit
```
Wenn du fertig bist:
```console
$ make stop
$ make unsetuid # Hygiene: nie ein Root-SUID-Binärprogramm liegen lassen
```
---
## 4. *Wann sollte ich das SUID-Bit setzen?* — die Antwort, die du wolltest
Genau **einmal, nach dem Bauen, vor dem Start des Daemons für die
Root-Shell-Demos** — und nur auf einer Maschine, die dir gehört, wegwerfbar und
vom Netzwerk getrennt ist:
```console
$ make # kompiliere foosd, foosc, tests
$ make setuid # <-- DER Moment. sudo chown root:root foosd && sudo chmod u+s foosd
$ make run # starte NACH dem Setzen des Bits
```
Zwei Regeln, die wichtiger sind als der exakte Zeitpunkt:
1. **Setze es erst, wenn das Binärprogramm final ist.** Wenn du das Bit setzt
und danach neu baust (`make` / `make clean`), bekommst du beim Schreiben der
root-gehörigen Ausgabedatei ein „Permission denied" — und wenn du den
Rebuild erzwingst, erstellt die Toolchain die Datei **ohne** das `s` neu,
womit die Einrichtung still rückgängig gemacht wird. Die kanonische
Reihenfolge bei jedem Rebuild ist daher
```console
$ make unsetuid && make && make setuid
```
2. **Entferne es, wenn du fertig bist.** `make unsetuid`. Ein lebendes,
root-gehöriges Setuid-Binärprogramm mit einem ausnutzbaren Bug in deinem
Baum ist kein Lernmittel, sondern ein Root-Loch mit einem Compilefehler
zwischen ihm und nirgendwo. Auf einer geteilten oder Produktionsmaschine:
**mach davon nichts.** Der Daemon weigert sich außerdem standardmäßig,
etwas anderes als Loopback zu binden (siehe §7).
Wenn du den Exploit *ohne* je gesetztes Bit ausführst, bricht nichts — der
Payload landet trotzdem und du bekommst trotzdem eine Shell. Der Unterschied
steckt in einer Zahl, und der Exploit sagt sie laut:
```console
foosc: WARNING: the daemon is NOT running with euid 0.
The payload will still land, but the shell will be
a plain user shell, not root.
Fix: sudo make setuid
```
Dieses „funktioniert, aber nicht root" ist selbst Teil des Labors. Behalte es
für den nächsten Abschnitt im Kopf.
---
## 5. Der Mechanismus — und die Wendung, die SUID interessant macht
### 5.1 Der Overflow (identisch zu `food`)
`foosd`s Handler gibt einem `read()` 512 Bytes Vertrauen, während er ihm einen
64-Byte-Stack-Puffer reicht:
```c
char buf[64];
n = read(fd, buf, 512); /* <- CWE-120: 448 Bytes über die Kante */
```
Auf x86-64 wächst der Stack nach unten. Der Exploit schreibt 64 Bytes Müll, um
`buf` zu füllen, 8, um den gespeicherten Frame-Pointer zu füllen, und 8 mehr,
um die **gespeicherte Rücksprungadresse** zu ersetzen. Wenn
`vulnerable_handler` das `ret` ausführt, poppt die CPU den Wert des Angreifers
in `RIP` — vom Angreifer kontrollierte Codeausführung. Der Exploit ermittelt
den exakten Abstand (88 Bytes für diesen Build), indem er die `objdump`-Ausgabe
parst, statt ihn hart zu verdrahten, sodass die Zahl Rebuilds überlebt.
### 5.2 Die Wendung: Die Shell weigert sich, root zu sein
Hier geht „SUID-Bug → /bin/sh spawne → root" fehl, und das ist der Grund,
warum dieses Labor genau diese Form hat.
Wenn ein setuid-root-Programm läuft, ist sein `ruid` immer noch der
startende Benutzer und sein `euid` ist root. Wenn das Programm — oder der
Angreifer — jetzt eine Shell startet:
* `execve("/bin/sh")` ändert die uids **nicht**; der neue Prozess erbt
`(ruid=1000, euid=0)`.
* bash (und dash) **prüfen genau diese Bedingung beim Start**. Aus dem
bash-Handbuch: *„If the shell is started with the effective user (group) id
not equal to the real user (group) id, and the -p option is not supplied, …
the effective user id is set to the real user id."*
Die Shell wirft also einen Blick auf sich selbst und *lässt root fallen* — eine
Verteidigung, die die Shell-Autoren genau gegen diesen Angriff gebaut haben
(die historische Rechtfertigung war das Setuid-Shell-/Setuid-Skript-Problem).
Das Ergebnis sind die „funktioniert, aber nicht root"-Fälle:
| Technik | Was sie ausführt | Resultierende uid |
|-----------|------------------|---------------|
| `ret2win` | `foosd`s `win()` → `execl("/bin/sh")` | **1000** — Shell gelandet, root von bash zurückgesetzt |
| `ret2libc` | `system("/bin/sh")` → frisches `sh -c '/bin/sh'` | **1000** — gleiches Zurücksetzen, eine Ebene tiefer |
| `ret2win-root` | `foosd`s `win_root()` → `setreuid(0,0); execl("/bin/sh")` | **0** — ruid aus C heraus geleert |
| `shellcode` | 32 Bytes: `setreuid(0,0); execve("/bin/sh")` | **0** — ruid aus Maschinencode geleert |
Die beiden, die root erreichen, unterscheiden sich von den beiden, die es
nicht tun, um genau eine Idee: **sie leeren die *reale* uid, nicht nur die
effektive.**
```c
setuid(0) /* setzt euid auf 0, aber ruid bleibt 1000:
bash sieht weiterhin euid != ruid und setzt IMMER NOCH
zurück. */
setreuid(0, 0) /* setzt BEIDE: ruid = euid = 0.
bash sieht gleiche uids und behält root. */
```
Deshalb beginnt der klassische `/bin/sh`-Shellcode, den du überall im Internet
findest, mit einem uid-leerenden Syscall — und deshalb ist der Shellcode hier
32 Bytes statt 23: Die ersten fünf Anweisungen sind
```asm
xor edi, edi ; ruid = 0
xor esi, esi ; euid = 0
push 0x71 ; 113 = __NR_setreuid
pop rax
syscall
```
### 5.3 Also, was ist der Exploit Ende-zu-Ende?
1. `foosc` liest `foosd`s Banner über den Socket. Es bekommt:
- `ids=0/1000` — euid/ruid (die SUID-Selbstdiagnose)
- `stack=…` und `libc=…` — Pointer (die ASLR-Leaks)
- `BUF=…` — die exakte Adresse des Puffers, den es gleich überlaufen lässt
2. Aus dem Ziel-Binärprogramm (via `objdump`) lernt es `rip_off` und die
Adressen von `win()` / `win_root()`.
3. Aus *seiner eigenen* libc (via `/proc/self/maps` + `dlsym` + einen
Speicherscan) misst es die Offsets von `system`, `read`, `/bin/sh` und
eines `pop rdi; ret`-Gadgets — nichts ist hart verdrahtet.
4. Es setzt den Payload zusammen. Für `-t shellcode` ist das:
`[32-Byte-setreuid+execve-Code][Padding bis RIP][ret-Fix][Adresse von buf]`.
5. `foosd`s `read()` läuft über; `ret` landet auf dem Shellcode; der Kernel
führt `setreuid(0,0)` aus (ok: euid 0 ist privilegiert) und danach `execve`
von `/bin/sh`. bash startet mit `ruid == euid == 0` und bleibt root.
6. `foosc` leitet dein Terminal an diese Root-Shell weiter, bis du `exit`
tippst.
Ein Details zur Absicherung, das Leute viel Zeit kostet, wenn es übersehen
wird: Der Exploit testet jedes uid-leerende Verhalten **ohne** das benötigte
Setuid-Bit zuerst. Führe `make test` vor `make setuid` aus, und du siehst jede
Technik eine Shell landen, während `ROOT=MISSING` dasteht; führe `make
test-suid` nach `make setuid` aus, und `ROOT=SEEN` erscheint bei den zwei
Techniken, die die reale uid leeren. Dieses A/B ist die ganze Lektion,
ausführbar in zehn Sekunden.
---
## 6. Die alten Einzeiler — und warum die meisten von ihnen tot sind
Wenn du über SUID gelesen hast, hast du über `PATH`-Hijacking, `LD_PRELOAD`
und Setuid-Shells gelesen. Alle drei sind klassisch, und alle drei scheitern
auf einem modernen System gegen *dieses Programm*. Es lohnt sich, genau zu
wissen, warum, denn die Gründe sind die Verteidigungen, die du gratis
bekommst:
| Angriffsklasse | Alte Behauptung | Warum sie auf einem modernen Rechner scheitert |
|--------------|-----------|------------------------------|
| `LD_PRELOAD` einer bösartigen Bibliothek | „Das Setuid-Programm lädt meine `.so` und führt meinen Code als root aus." | Der Kernel markiert ein Setuid-Binärprogramm als **AT_SECURE**; glibc ignoriert daraufhin `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_DEBUG` und Verwandtes. Die Umgebung wird als *unvertrauenswürdige Eingabe* behandelt. `LD_PRELOAD` gegen ein Setuid-Binärprogramm ist eine No-Operation. |
| `PATH`-Hijack (`system("ls")` mit vergiftetem PATH) | „Zeige PATH auf ein Verzeichnis mit meinem falschen `ls`; das Root-Programm führt es aus." | Ein zweites Gesicht derselben Verteidigung: Ein AT_SECURE-Prozess bekommt einen **bereinigten `PATH`** (einen sicheren Standard, in etwa `/usr/local/bin:/usr/bin:/bin`) für `system()`/`execvp`, sodass das vergiftete Verzeichnis nie konsultiert wird. |
| Setuid-`system()`-Befehlsinjektion | „Der injizierte Befehl läuft mit euid 0." | `system()` führt den Befehl in einer frischen `/bin/sh` aus, und diese Shell — §5.2 — setzt `euid = ruid` beim Start zurück. Der injizierte Befehl läuft mit der *realen* uid. (Es ist immer noch ein Bug; er eskaliert nur nicht mehr über `/bin/sh`.) |
| Setuid-Root-Shell auf der Platte (`cp /bin/sh /tmp; chmod u+s`) | „Führe sie aus, bekomme root." | Genau die Verteidigung oben, und das ist der Grund, warum moderne Distributionen keine Setuid-Root-Shell ausliefern. Selbst wenn dir eine gelingt, weigert sich bash, euid 0 zu behalten, sofern es nicht mit `-p` gestartet wird. |
Was lebendig bleibt, und das ist dieses Labor: **Das Programm ist beim Laufen
*bereits* root.** Du brauchst weder die Umgebung noch `system()`; du brauchst,
dass das Programm *deinen* Code (über einen Memory-Corruption-Bug) ausführt,
solange es privilegiert ist, und dein Code muss vorsichtig genug sein, den
uid-Mismatch selbst zu beheben — `setreuid(0,0)` — bevor er dir eine Shell
übergibt. Memory Corruption + SUID ist die Kombination, die immer noch in
`uid=0` endet, und genau deshalb sind speichersichere Sprachen, Canaries und
No-Execute-Stacks keine Modeentscheidung.
---
## 7. Die in den Daemon eingebauten Sicherheitsleitplanken
`foosd` ist absichtlich das *schlechteste* Stück Software in diesem
Repository, also trägt es auch die meisten Leitplanken:
1. **Nur Loopback, erzwungen.** `foosd` weigert sich, eine andere Adresse als
Loopback zu binden, sofern du nicht `-L` übergibst. Ein Setuid-Root-Listener
auf einer echten Schnittstelle ist ein entfernter Root-Dienst; die Weigerung
ist der Standard, damit der gefährliche Zustand bewusst eingetippt werden
muss.
2. **Selbstdiagnose.** Beim Start loggt es `ruid`/`euid` und ob es als root
läuft, sodass die Konsole den Zustand zeigt, von dem der Exploit abhängt.
3. **Das Log erreicht den Client nie.** Der Daemon reserviert einen privaten
Log-Deskriptor, bevor Sockets fd 1 ersetzen, sodass Crash-Reporter-Ausgabe
und interne Pfade vom Angreifer nicht über die Leitung zurückgelesen werden
können.
4. **Crash-Reporter.** Ein SIGSEGV-Handler loggt `RIP`/`RSP` — den Wert, den
der Angreifer in die Rücksprungadresse geschrieben hat — sodass eine
erfolgreiche Übernahme in `foosd.log` sichtbar ist, statt ein stiller Tod zu
sein.
5. **`make unsetuid`.** Das Entfernen des Bits ist skriptiert, denn es gesetzt
zu lassen ist der Ausfallmodus, den Leute tatsächlich haben.
---
## 8. Gegenmaßnahmen — was jede stoppt und was nicht
Angewendet auf `foosd` via `make hardened`, einzeln oder zusammen:
| Gegenmaßnahme | Was sie stoppt | Was sie *nicht* stoppt |
|------------|---------------|-------------------------|
| `-fstack-protector-strong` (Canary) | Den Overflow: `ret` erkennt einen zerstörten Canary und bricht ab, bevor die Adresse des Angreifers verwendet wird. Stoppt hier **alle vier** Techniken — sie teilen sich das eine angreifbare `read()`. | Nichts am *Design*: Das Binärprogramm ist immer noch setuid-root; ein anderer Bug (Format-String-`%n`, Heap-Overflow, Use-after-Free) hat keinen Canary zum Auslösen. |
| `-fPIE -pie` (ASLR für das Binärprogramm) | Nutzung vorhersagbarer `win()`/`win_root()`-Adressen (die ret2win-Techniken). | Die Shellcode-Technik, wenn weiterhin eine Stack-Adresse leakt (die `BUF=`-Zeile). |
| `-z noexecstack` (NX / W^X) | Den Shellcode: Die CPU weigert sich, Befehle von einer daten-only-Seite zu holen, sodass ein Sprung auf `buf` ein SIGSEGV ist. | ROP — das Ausführen vorhandenen Codes (`ret2libc`). |
| Alle drei zusammen | Ein schwer zu überlaufendes, randomisiertes Binärprogramm mit nicht-ausführbarem Stack. So sieht ein normaler gehärteter Build aus. | Das Setuid-Bit. **Ein gehärtetes SUID-Binärprogramm ist immer noch ein SUID-Binärprogramm.** Wenn irgendein erreichbarer Speichersicherheitsbug überlebt, ist es immer noch „Bug in einem Root-Prozess". |
Der Konsolenbeweis ist `make test-hardened`, das den gehärteten Build
eintauscht und zeigt, wie alle Techniken am Canary sterben, während
`foosd_hardened.log` `*** stack smashing detected ***` aufzeichnet.
Zwei Designebenen-Gegenmaßnahmen, die keine Compiler-Flag liefert und die auch
das übergeordnete Labor (`food`) nutzt:
- **Least Privilege.** Ein Daemon für einen unprivilegierten Port (2343 > 1024)
hat keinen legitimen Bedarf an root. Ein korrektes `foosd` würde binden und
dann `setgroups`/`setgid`/`setuid` auf ein unprivilegiertes Konto ausführen
und *verifizieren, dass es hielt* (die korrekte Version steht im Quellcode
als `drop_privs()`, nie aufgerufen — die Nicht-Aufrufung ist Bug #3 des
Labors).
- **Das read begrenzen.** `n = read(fd, buf, sizeof(buf) - 1)`. Eine korrekte
Zeile schlägt jede Compiler-Flag in der Tabelle.
---
## 9. Das Wire-Protokoll (damit du den Daemon mit netcat lesen kannst)
```text
FOOSD 1.0 - deliberately vulnerable SUID service
Type 'quit' to disconnect. Buffer = 64 bytes, read accepts 512.
FOOSD 1.0 ids=0/1000 leak stack=0x7ffd... libc=0x7f...
BUF=0x7ffd...
```
* `ids=euid/ruid` — durfte nicht als `euid=`/`ruid=` gedruckt werden, weil die
Test-Harness eine Shell anhand des wörtlichen `uid=` beweist und das Banner
es nicht enthalten darf (eine Sonde, die die Signatur mit der Antwort teilt,
ist eine klassische Fehlpositiv-Falle; siehe den Kommentar in `foosd.c`).
Die Harness verlangt außerdem die strenge `id`-Ausgabeform — `uid=NNN(...)` —
sodass nichts, was der Daemon oder der Exploit druckt, die Prüfung zufällig
erfüllen kann: `foosc`s eigenes „target euid=… ruid=…" enthält `uid=` als
Teilstring, was einmal einen gehärteten Test eine nie gelaufene Shell melden
ließ.
* `stack=`, `libc=`, `BUF=` — die ASLR-Leaks: erlauben Shellcode und ret2libc,
exakte Adressen zu berechnen.
---
## 10. Übungen
1. **Beobachte die Nicht-Root-Abstufung.** Führe `make test` *vor* `make
setuid` aus, dann danach erneut. Erkläre die `ROOT=SEEN`-Änderung mit der
ruid/euid-Geschichte aus §5.2.
2. **Lies den Absturz.** Führe `./foosc -t demo -n` aus und lies dann
`foosd.log`. Die Zeile `RIP=0x4141414141414141` ist das Padding des
Angreifers — der Beweis, dass der Overflow, nicht Pech, die Ausführung
kontrolliert.
3. **Füge den Canary hinzu.** `make hardened` und ändere die
`test-hardened`-Schleife selbst; die Log-Zeile
`*** stack smashing detected ***` ist die arbeitende Verteidigung.
4. **Deaktiviere das Leak.** Kommentiere die `BUF=`-Zeile in `foosd.c` aus,
baue neu und beobachte, wie `-t shellcode` von deterministisch zu einem
Ratespiel wird. Diese eine Zeile ist der Grund, warum echte
ASLR-Bypasses ein ganzes Feld sind.
5. **Das `-p`-Experiment.** Ändere in einer Kopie von `win()` `execl("/bin/sh",
"sh", NULL)` zu `execl("/bin/sh", "sh", "-p", NULL)` und beobachte root.
`-p` ist die dokumentierte Notluke aus dem Wächter der Shell — und der
Grund, warum der Rat „spawne einfach eine Shell" aus alten Write-ups
unvollständig ist.
6. **Warum nicht `setuid(0)`?** Schreibe den Shellcode so um, dass er
`setuid(0)` statt `setreuid(0,0)` aufruft (Syscall 105). Die Shell landet
trotzdem — und fällt trotzdem auf `uid=1000`. Das ist das lehrreichste
Ein-Zeilen-Experiment im gesamten Repository.
---
## 11. Sicherheit und Aufräumen
- Nur Loopback, standardmäßig und per Design; `-L` bindet weiter, und nur eine
Wegwerf-VM sollte es überhaupt in Betracht ziehen.
- Dies ist ein Root-Shell-Labor. Führe es nicht auf einer Maschine aus, die
wichtig ist, und richte `foosc -h` nicht auf etwas, das dir nicht gehört.
- Aufräumritual: `make stop` und dann `make unsetuid`, und wenn du den Baum
wieder makellos willst: `sudo make clean`.
```console
$ make stop
$ make unsetuid
```