19 KiB
SUID-root-RCE-lab — foosd (daemon) + foosc (exploit)
Een begeleider van het hoofdlaboratorium (food / fooc, een gewone daemon
waar een bufferoverloop je een gebruiker-shell geeft). Dit voegt de
gevaarlijkste wijziging van één teken in Unix toe: de setuid-bit.
chmod u+sverandert "de aanvaller kan code draaien op deze host" in "de aanvaller kan code draaien als root op deze host".
Die zin is het hele lab. Alles hieronder is het mechanisme eronder, opgeschreven, zodat je wanneer je je eigen software schrijft precies weet welke twee of drie bestandssysteem-attributen en compilerflags bepalen of een geheugenveiligheidsbug in jouw code een ergernis of een root-shell is.
De uiteindelijke demo, wanneer foosd setuid-root is, is een root-shell
die over het netwerk wordt geopend door 32 bytes handgeschreven shellcode uit
te voeren.
1. Wat de setuid-bit daadwerkelijk doet
Elk proces op Linux draagt drie user-ID's, en de setuid-bit rommelt aan de verhouding ertussen:
| ID | Naam | Betekenis |
|---|---|---|
ruid |
reële user-ID | de account die het proces startte |
euid |
effectieve user-ID | wat de kernel controleert wanneer hij toegang handhaaft |
| (saved) | opgeslagen set-user-ID | een "spoor" waarnaar een bevoorrecht proces later kan terugkeren |
Een normaal programma heeft ruid == euid. Wanneer je een binair bestand
uitvoert met de setuid-bit gezet, eigendom van root:
ruid = jij (bijv. 1000, "hanez")
euid = de eigenaar (bijv. 0, "root")
Het proces heeft dus roots autoriteit, ook al is de gebruiker die het
startte volkomen gewoon. Elke controle die de kernel uitvoert — kan dit proces
/etc/shadow lezen? een bestand schrijven? een ander proces doden? — wordt
beantwoord met euid, dus "ja, het is root".
foosd is een netwerkdaemon. Hij bindt een poort en fork()t daarna een kind
per verbinding. Een fork erft de euid, dus elk kind dat een verbinding
afhandelt, is ook root. De overloop in foosds vulnerable_handler() is
daarom een overloop binnenin een root-proces.
Diagnosticeer het zelf wanneer de daemon draait:
$ ./foosd ... # zie de logregel die hij bij de start print
[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process
en vanuit het exploit:
$ ./foosc -t leak
foosc: target euid=0 ruid=1000
2. Het lab in één oogopslag
| Bestand | Rol |
|---|---|
foosd.c |
De bewust kwetsbare daemon (eigenaar van de bugs). Draai als setuid-root-binary voor de root-shell-demo. |
foosc.c |
Het exploit. Gebruikt standaard de 32-byte setreuid + execve-shellcodetechniek. |
shellcode.S |
De referentie-shellcode; make verify diff't het tegen de byte-array in foosc.c. |
tests/pty_suid_test.c |
Test-harness. Drijft foosc door een pseudo-terminal en bewijst zowel "er draaide een shell" als "die was root" (uid=0(). |
Makefile |
Build, setuid/unsetuid-helpers, testmatrix. |
README.md |
Dit bestand. |
Waarom een pty? De laatste actie van het exploit is je terminal doorschakelen naar de shell die op het slachtoffer draait. Een pipe of here-doc komt aan de verkeerde kant van die doorschakeling terecht; een echte terminal is vereist.
3. Snelle start
$ make # bouw alles, als je normale gebruiker
$ make setuid # één keer, vraagt om sudo: chown root + chmod u+s
$ make run # start foosd op 127.0.0.1:2343
$ make test-suid # volledige matrix; shellcode + ret2win-root moeten root geven
Interactieve rooktest:
$ ./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) <-- je bent root, op het slachtoffer
# exit
Wanneer je klaar bent:
$ make stop
$ make unsetuid # hygiëne: laat nooit een root-SUID-binary achter
4. Wanneer moet ik de SUID-bit zetten? — het antwoord dat je vroeg
Precies één keer, na het bouwen, vóór je de daemon start voor de root-shell-demo's — en alleen op een machine die van jou is, geschikt om weg te gooien en losgekoppeld van het netwerk:
$ make # compileer foosd, foosc, tests
$ make setuid # <-- HET MOMENT. sudo chown root:root foosd && sudo chmod u+s foosd
$ make run # start NÁ het zetten van de bit
Twee regels die belangrijker zijn dan het precieze tijdstip:
-
Zet hem alleen als de binary klaar is. Als je herbouwt (
make/make clean) nadat je de bit hebt gezet, krijg je een "Permission denied" bij het schrijven van root-bezeten outputbestanden — en als je de herbouw forceert, herschept de toolchain het bestand zonder desen maak je de opzet stilletjes ongedaan. De canonieke volgorde bij elke herbouw is daarom$ make unsetuid && make && make setuid -
Haal hem weg als je klaar bent.
make unsetuid. Een levende, root-bezeten setuid-binary met een exploiteerbare bug in je boom is geen leermiddel, het is een root-gat met een compilerfout tussen zichzelf en niets. Op een gedeelde of productiemachine: doe niets van dit alles. De daemon weigert bovendien standaard iets anders dan loopback te binden (zie §7).
Als je het exploit zonder ooit de bit te zetten draait, gaat er niets kapot — de payload landt nog steeds, en je krijgt nog steeds een shell. Het verschil zit in één getal, en het exploit zegt het hardop:
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
Het "werkte, maar niet root"-resultaat is zelf onderdeel van het lab. Onthoud dat voor de volgende sectie.
5. Het mechanisme — en de twist die SUID interessant maakt
5.1 De overloop (identiek aan food)
De handler van foosd geeft een read() 512 bytes vertrouwen terwijl hij er
een 64-byte stack-buffer aan reikt:
char buf[64];
n = read(fd, buf, 512); /* <- CWE-120: 448 bytes over de rand */
Op x86-64 groeit de stack naar beneden. Het exploit schrijft 64 bytes rommel
om buf te vullen, 8 om de opgeslagen framepointer te vullen en nog 8 om de
opgeslagen retouradres te vervangen. Wanneer vulnerable_handler de ret
uitvoert, poppt de CPU de waarde van de aanvaller in RIP —
aanvaller-gecontroleerde code-uitvoering. Het exploit vindt de exacte afstand
(88 bytes voor deze build) door objdump-output te parsen in plaats van die te
hardcoden, zodat het getal herbouwen overleeft.
5.2 De twist: de shell weigert root te zijn
Hier is waar "SUID-bug → spawn /bin/sh → root" fout zou gaan, en waarom dit lab precies de vorm heeft die het heeft.
Wanneer een setuid-root-programma draait, is zijn ruid nog steeds de
startende gebruiker en is zijn euid root. Als het programma — of de aanvaller
— nu een shell start:
execve("/bin/sh")verandert de uids niet; het nieuwe proces erft(ruid=1000, euid=0).- bash (en dash) controleert die exacte toestand bij de start. Uit de bash-handleiding: "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."
Dus de shell kijkt naar zichzelf en laat root vallen — een verdediging die de shell-auteurs precies tegen dit aanval bouwden (de historische reden was het setuid-shell-/setuid-scriptprobleem). Het resultaat is de "werkte, maar niet root"-gevallen:
| Techniek | Wat hij uitvoert | Resulterende uid |
|---|---|---|
ret2win |
foosds win() → execl("/bin/sh") |
1000 — shell landde, root gereset door bash |
ret2libc |
system("/bin/sh") → verse sh -c '/bin/sh' |
1000 — dezelfde reset, één niveau lager |
ret2win-root |
foosds win_root() → setreuid(0,0); execl("/bin/sh") |
0 — ruid opgeruimd vanuit C |
shellcode |
32 bytes: setreuid(0,0); execve("/bin/sh") |
0 — ruid opgeruimd vanuit machinecode |
Degene die root bereiken, verschillen van degene die dat niet doen in precies één idee: ze ruimen de reële uid op, niet alleen de effectieve.
setuid(0) /* zet euid op 0, maar ruid blijft 1000:
bash ziet nog steeds euid != ruid en reset NOG STEEDS. */
setreuid(0, 0) /* zet BEIDE: ruid = euid = 0.
bash ziet gelijke uids en houdt root. */
Daarom begint de klassieke /bin/sh-shellcode die je overal op internet vindt
met een uid-opruimend syscall — en daarom is de shellcode hier 32 bytes in
plaats van 23: de eerste vijf instructies zijn
xor edi, edi ; ruid = 0
xor esi, esi ; euid = 0
push 0x71 ; 113 = __NR_setreuid
pop rax
syscall
5.3 Dus wat is het exploit, van begin tot eind?
fooscleest het banner vanfoosdover de socket. Het krijgt:ids=0/1000— euid/ruid (de SUID-zelfdiagnose)stack=…enlibc=…— pointers (de ASLR-leaks)BUF=…— het exacte adres van de buffer die het op het punt staat te laten overlopen
- Uit de doel-binary (via
objdump) leert hetrip_offen de adressen vanwin()/win_root(). - Uit zijn eigen libc (via
/proc/self/maps+dlsym+ een geheugenscan) meet het de offsets vansystem,read,/bin/shen eenpop rdi; ret-gadget — niets is hardcoded. - Het stelt de payload samen. Voor
-t shellcodeis dat:[32-byte-setreuid+execve-code][padding tot RIP][ret-fix][adres van buf]. foosdsread()loopt over; deretlandt op de shellcode; de kernel voertsetreuid(0,0)uit (geen probleem: euid 0 is bevoorrecht) en daarnaexecvevan/bin/sh. bash start metruid == euid == 0en blijft root.fooscschakelt je terminal door naar die root-shell, tot jeexittypt.
Eén gemakdetail dat mensen veel tijd kost als het wordt gemist: het exploit
test elk uid-opruimgedrag zonder eerst de setuid-bit nodig te hebben. Draai
make test vóór make setuid, en je ziet elke techniek een shell landen terwijl
ROOT=MISSING staat; draai make test-suid ná make setuid, en ROOT=SEEN
verschijnt bij de twee technieken die de reële uid opruimen. Die A/B is de hele
les, in tien seconden uitgevoerd.
6. De oude one-liners — en waarom de meeste dood zijn
Heb je over SUID gelezen, dan heb je over PATH-kapingen, LD_PRELOAD en
setuid-shells gelezen. Alle drie zijn klassiek, en alle drie falen op een
modern systeem tegen dit programma. Het is de moeite waard om precies te
weten waarom, want de redenen zijn de verdedigingen die je gratis krijgt:
| Aanvalsklasse | Oude bewering | Waarom hij faalt op een moderne machine |
|---|---|---|
LD_PRELOAD van een kwaadaardige bibliotheek |
"Het setuid-programma laadt mijn .so en draait mijn code als root." |
De kernel markeert een setuid-binary als AT_SECURE; glibc negeert daarna LD_PRELOAD, LD_LIBRARY_PATH, LD_DEBUG en vrienden. De omgeving wordt behandeld als onbetrouwbare input. LD_PRELOAD tegen een setuid-binary is een no-op. |
PATH-kaping (system("ls") met een vergiftigde PATH) |
"Wijs PATH naar een map met mijn neppe ls; het root-programma draait hem." |
Een ander gezicht van hetzelfde verdedigingen: een AT_SECURE-proces krijgt een gesaneerde PATH (een veilige standaard, grofweg /usr/local/bin:/usr/bin:/bin) voor system()/execvp, dus de vergiftigde map wordt nooit geraadpleegd. |
Setuid-system()-commando-injectie |
"Het geïnjecteerde commando draait met euid 0." | system() draait het commando in een verse /bin/sh, en die shell — §5.2 — reset euid = ruid bij de start. Het geïnjecteerde commando wordt uitgevoerd met de reële uid. (Het blijft een bug; het escaleert alleen niet meer via /bin/sh.) |
Setuid-root-shell op de schijf (cp /bin/sh /tmp; chmod u+s) |
"Draai hem, krijg root." | Precies wat hierboven verdedigd wordt, en dat is waarom moderne distro's geen enkele setuid-root-shell leveren. Zelfs als je er één kunt maken, weigert bash euid 0 te houden tenzij hij met -p wordt gestart. |
Wat blijft leven, en dat is dit lab: het programma is al root wanneer het
draait. Je hebt de omgeving of system() niet nodig; je hebt nodig dat het
programma jouw code uitvoert (via een geheugenbeschadigingsbug) terwijl het
bevoorrecht is, en dat jouw code zorgvuldig genoeg is om zelf de uid-mismatch
recht te zetten — setreuid(0,0) — voordat het je een shell overhandigt.
Geheugenbeschadiging + SUID is de combinatie die nog steeds in uid=0 eindigt,
en dat is precies waarom geheugenveilige talen, canaries en no-execute-stacks
geen modebeslissing zijn.
7. De veiligheidsheurlingen die in de daemon zijn ingebouwd
foosd is bewust het slechtste stuk software in dit repository, dus het
draagt ook de meeste leuningen:
- Alleen loopback, afgedwongen.
foosdweigert elke bind-adres buiten loopback, tenzij je-Lgeeft. Een setuid-root-listener op een echte interface is een externe root-dienst; de weigering is de standaard, zodat de gevaarlijke toestand bewust moet worden ingetypt. - Zelfdiagnose. Bij de start logt hij
ruid/euiden of hij als root draait, zodat de console de toestand toont waarvan het exploit afhangt. - De log bereikt de client nooit. De daemon reserveert een privé log-descriptor vóór sockets fd 1 vervangen, zodat crash-reporter-output en interne paden niet door de aanvaller over de draad teruggelezen kunnen worden.
- Crash-reporter. Een SIGSEGV-handler logt
RIP/RSP— de waarde die de aanvaller in het retouradres schreef — zodat een succesvolle overname zichtbaar is infoosd.login plaats van een stille dood. make unsetuid. Het verwijderen van de bit is gescript, omdat hem laten staan de faaltoestand is die mensen daadwerkelijk hebben.
8. Tegenmaatregelen — wat elke stopt en wat hij niet stopt
Toegepast op foosd via make hardened, één voor één of samen:
| Tegenmaatregel | Wat hij stopt | Wat hij niet stopt |
|---|---|---|
-fstack-protector-strong (canary) |
De overloop: ret detecteert een beschadigde canary en aborted vóór het adres van de aanvaller wordt gebruikt. Stopt hier alle vier de technieken — ze delen het ene kwetsbare read(). |
Niets aan het ontwerp: de binary is nog steeds setuid-root; een andere bug (format-string-%n, heap-overflow, use-after-free) heeft geen canary om af te laten gaan. |
-fPIE -pie (ASLR voor de binary) |
Gebruik van voorspelbare win()/win_root()-adressen (de ret2win-technieken). |
De shellcode-techniek, als er nog een stack-adres lekt (BUF=-regel). |
-z noexecstack (NX / W^X) |
De shellcode: de CPU weigert instructies op te halen van een data-only-pagina, dus een sprong naar buf is een SIGSEGV. |
ROP — code draaien die al bestaat (ret2libc). |
| Alle drie samen | Een moeilijk-te-laten-overlopen, gerandomiseerde binary met een niet-uitvoerbare stack. Zo ziet een normale geharde build eruit. | De setuid-bit. Een geharde SUID-binary is nog steeds een SUID-binary. Overleeft er een bereikbare geheugenveiligheidsbug, dan is het nog steeds "bug in een root-proces". |
Het consolebewijs is make test-hardened, dat de geharde build inwisselt en
laat zien hoe alle technieken bij de canary sterven, terwijl
foosd_hardened.log *** stack smashing detected *** opvangt.
Twee tegenmaatregelen op ontwerpniveau die geen enkele compilerflag levert, en
die het hoofdlaboratorium (food) ook gebruikt:
- Minste privilege. Een daemon voor een onbevoordeelde poort (2343 > 1024)
heeft geen legitieme behoefte aan root. Een correcte
foosdzou binden en daarnasetgroups/setgid/setuidnaar een onbevoorrechte account en bevestigen dat het hield (de correcte versie staat in de bron alsdrop_privs(), nooit aangeroepen — het niet-aanroepen is bug nr. 3 van het lab). - Beperk de read.
n = read(fd, buf, sizeof(buf) - 1). Eén correcte regel overtreft elke compilerflag in de tabel.
9. Het wire-protocol (zodat je de daemon met netcat kunt lezen)
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— kon niet alseuid=/ruid=geprint worden, omdat de test-harness een shell bewijst door op het letterlijkeuid=te greppen, en het banner mag dat niet bevatten (een sonde die de handtekening met het antwoord deelt, is een klassieke fout-positief- val; zie de commentaar infoosd.c). De harness vereist bovendien de strikteid-outputvorm —uid=NNN(...)— zodat niets wat de daemon of het exploit print het aan toeval kan laten voldoen:fooscs eigen "target euid=… ruid=…" bevatuid=als deelstring, wat ooit een geharde test een shell liet melden die nooit had gedraaid.stack=,libc=,BUF=— de ASLR-leaks: laten shellcode en ret2libc exacte adressen berekenen.
10. Oefeningen
- Beschouw de niet-root-degradatie. Draai
make testvóórmake setuid, en daarna nog eens achteraf. Verklaar deROOT=SEEN-verandering met het ruid/euid-verhaal in §5.2. - Lees de crash. Draai
./foosc -t demo -nen lees daarnafoosd.log. De regelRIP=0x4141414141414141is de padding van de aanvaller — het bewijs dat de overloop, niet toeval, de uitvoering bestuurt. - Voeg de canary toe.
make hardeneden verander zelf detest-hardened-lus; de logregel*** stack smashing detected ***is de verdediging die werkt. - Schakel het lek uit. Commentaar de
BUF=-regel infoosd.cuit, bouw opnieuw, en zie-t shellcodevan deterministisch naar een raadspel veranderen. Die ene regel is de reden dat echte ASLR-bypasses een heel veld zijn. - Het
-p-experiment. Verander in een kopie vanwin()execl("/bin/sh", "sh", NULL)naarexecl("/bin/sh", "sh", "-p", NULL)en observeer root.-pis de gedocumenteerde nooduitgang uit de wacht van de shell — en de reden dat het advies "spawn gewoon een shell" uit oude write-ups onvolledig is. - Waarom niet
setuid(0)? Herschrijf de shellcode omsetuid(0)te roepen in plaats vansetreuid(0,0)(syscall 105). De shell landt nog steeds — en zakt nog steeds naaruid=1000. Dat is het meest leerzame één-regel-experiment van het hele repository.
11. Veiligheid en opruimen
- Alleen loopback, als standaard en volgens ontwerp;
-Lbindt verder, en alleen een weg-te-gooien-VM zou het überhaupt moeten overwegen. - Dit is een root-shell-lab. Draai het niet op een machine die ertoe doet, en
richt
foosc -hniet op iets dat je niet bezit. - Opruimritueel:
make stopen daarnamake unsetuid, en als je de boom weer vlekkeloos wilt:sudo make clean.
$ make stop
$ make unsetuid