20 KiB
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+sverwandelt „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:
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 foosds
vulnerable_handler() ist daher ein Overflow in einem Root-Prozess.
Diagnostiziere es selbst, sobald der Daemon läuft:
$ ./foosd ... # siehe die Log-Zeile beim Start
[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process
und vom Exploit aus:
$ ./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
$ 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:
$ ./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:
$ 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:
$ 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:
-
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 dassneu, womit die Einrichtung still rückgängig gemacht wird. Die kanonische Reihenfolge bei jedem Rebuild ist daher$ make unsetuid && make && make setuid -
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:
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)
foosds Handler gibt einem read() 512 Bytes Vertrauen, während er ihm einen
64-Byte-Stack-Puffer reicht:
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 |
foosds 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 |
foosds 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.
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
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?
fooscliestfoosds Banner über den Socket. Es bekommt:ids=0/1000— euid/ruid (die SUID-Selbstdiagnose)stack=…undlibc=…— Pointer (die ASLR-Leaks)BUF=…— die exakte Adresse des Puffers, den es gleich überlaufen lässt
- Aus dem Ziel-Binärprogramm (via
objdump) lernt esrip_offund die Adressen vonwin()/win_root(). - Aus seiner eigenen libc (via
/proc/self/maps+dlsym+ einen Speicherscan) misst es die Offsets vonsystem,read,/bin/shund einespop rdi; ret-Gadgets — nichts ist hart verdrahtet. - Es setzt den Payload zusammen. Für
-t shellcodeist das:[32-Byte-setreuid+execve-Code][Padding bis RIP][ret-Fix][Adresse von buf]. foosdsread()läuft über;retlandet auf dem Shellcode; der Kernel führtsetreuid(0,0)aus (ok: euid 0 ist privilegiert) und danachexecvevon/bin/sh. bash startet mitruid == euid == 0und bleibt root.fooscleitet dein Terminal an diese Root-Shell weiter, bis duexittippst.
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:
- Nur Loopback, erzwungen.
foosdweigert 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. - Selbstdiagnose. Beim Start loggt es
ruid/euidund ob es als root läuft, sodass die Konsole den Zustand zeigt, von dem der Exploit abhängt. - 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.
- Crash-Reporter. Ein SIGSEGV-Handler loggt
RIP/RSP— den Wert, den der Angreifer in die Rücksprungadresse geschrieben hat — sodass eine erfolgreiche Übernahme infoosd.logsichtbar ist, statt ein stiller Tod zu sein. 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
foosdwürde binden und dannsetgroups/setgid/setuidauf ein unprivilegiertes Konto ausführen und verifizieren, dass es hielt (die korrekte Version steht im Quellcode alsdrop_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)
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 alseuid=/ruid=gedruckt werden, weil die Test-Harness eine Shell anhand des wörtlichenuid=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 infoosd.c). Die Harness verlangt außerdem die strengeid-Ausgabeform —uid=NNN(...)— sodass nichts, was der Daemon oder der Exploit druckt, die Prüfung zufällig erfüllen kann:fooscs eigenes „target euid=… ruid=…" enthältuid=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
- Beobachte die Nicht-Root-Abstufung. Führe
make testvormake setuidaus, dann danach erneut. Erkläre dieROOT=SEEN-Änderung mit der ruid/euid-Geschichte aus §5.2. - Lies den Absturz. Führe
./foosc -t demo -naus und lies dannfoosd.log. Die ZeileRIP=0x4141414141414141ist das Padding des Angreifers — der Beweis, dass der Overflow, nicht Pech, die Ausführung kontrolliert. - Füge den Canary hinzu.
make hardenedund ändere dietest-hardened-Schleife selbst; die Log-Zeile*** stack smashing detected ***ist die arbeitende Verteidigung. - Deaktiviere das Leak. Kommentiere die
BUF=-Zeile infoosd.caus, baue neu und beobachte, wie-t shellcodevon deterministisch zu einem Ratespiel wird. Diese eine Zeile ist der Grund, warum echte ASLR-Bypasses ein ganzes Feld sind. - Das
-p-Experiment. Ändere in einer Kopie vonwin()execl("/bin/sh", "sh", NULL)zuexecl("/bin/sh", "sh", "-p", NULL)und beobachte root.-pist 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. - Warum nicht
setuid(0)? Schreibe den Shellcode so um, dass ersetuid(0)stattsetreuid(0,0)aufruft (Syscall 105). Die Shell landet trotzdem — und fällt trotzdem aufuid=1000. Das ist das lehrreichste Ein-Zeilen-Experiment im gesamten Repository.
11. Sicherheit und Aufräumen
- Nur Loopback, standardmäßig und per Design;
-Lbindet 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 -hnicht auf etwas, das dir nicht gehört. - Aufräumritual:
make stopund dannmake unsetuid, und wenn du den Baum wieder makellos willst:sudo make clean.
$ make stop
$ make unsetuid