20 KiB
food / fooc — een stack-bufferoverloop, van beide kanten
Een C99-beveiligingslab in twee helften:
food.c— een bewust kwetsbare TCP-daemon. Hij heeft een echte, schoolboekachtige stack-bufferoverloop (CWE-120), plus een paar bugs extra.fooc.c— een exploit daarvoor. Hij berekent de overflow-offset door het doelprogramma tijdens het draaien te disassembleren, leest adres-leaks van de daemon en krijgt een shell op het "slachtoffer" door een opgeslagen retouradres te overschrijven.
Het punt is niet de shell. Het punt is dat je van begin tot eind kunt volgen hoe een geheugenveiligheidsbug uitgroeit tot willekeurige code-uitvoering — en daarna precies ziet welke tegenmaatregelen elke stap in die keten stoppen. Elke regel in beide programma's is gecommentarieerd, want het mechanisme is de les.
jouw terminal
|
./fooc (exploit)
|
TCP 127.0.0.1:2342
|
./food (kwetsbare daemon)
|
fork() -> vulnerable_handler() -> overflow -> ret -> jouw code
⚠️ Lees dit eerst
food is een bewust kapotte netwerkdienst. Hij bindt alleen aan
127.0.0.1, en die standaard is bewust — laat hem daar.
- Draai hem niet op een machine waar je om geeft, of op iets met data.
- Bind hem niet aan
0.0.0.0of een echte netwerkinterface. Hij is bewust extern exploiteerbaar. foocrichten op een host die je niet bezit of waarvoor je geen schriftelijke toestemming hebt om te testen, is in de meeste rechtsgebieden een computercriminaliteitsovertreding — ook onder de UK Computer Misuse Act en de US Computer Fraud and Abuse Act.- Hij bindt aan een onbevoordeelde poort (>1024), dus je hebt geen root nodig. "Verbeter" hem niet door capabilities toe te voegen of hem als systeemdienst te draaien.
- Elke verbinding wordt afgehandeld in een
fork()-kind, enfoodreapt het, dus crashes stapelen zich niet op. Vind je daarna tientallen lossesh-processen, dan ispkill -x shde opruiming.
Bij twijfel: dit lab is voor een virtuele machine of container, op een netwerk dat jij beheert, op een machine zonder iets dat je zou missen.
Snelle start
make # bouwt food, fooc en de test-harnesses
make run # start food op 127.0.0.1:2342, losgekoppeld op de achtergrond
make test # draait alle drie de exploit-technieken
make stop # stopt de daemon
Daarna met de hand:
./fooc -t leak # bekijk de adres-leaks die food prijsgeeft
./fooc -t demo -v # stuur rommel; zie food sterven met SIGSEGV
./fooc -t ret2win -i # spring naar een functie die al bestaat -> shell
Vereisten
| Hulpmiddel | Waarvoor | Opmerkingen |
|---|---|---|
gcc (of clang) |
bouwen | C99. Getest met gcc 16.2 |
objdump |
fooc |
binutils. fooc roept het tijdens het draaien aan |
nasm |
make verify |
alleen om de shellcode te verifiëren; wordt overgeslagen als het ontbreekt |
gdb |
make debug |
optioneel |
| Linux, x86-64 | beide | payload en gadget-jacht zijn architectuurafhankelijk |
fooc heeft ook -ldl nodig voor dlsym(); de Makefile regelt dat.
De bug
Eén regel in food.c is het hele aanvalsoppervlak:
char buf[FOOD_BUFSZ]; /* 64 bytes */
n = read(fd, buf, FOOD_READMAX); /* tot 512 bytes van het netwerk */
64 bytes bestemming, 512 bytes geaccepteerd. De aanvaller overschrijft 448 bytes voorbij het einde van de buffer, en omdat de stack naar beneden groeit, betekent "voorbij het einde" "in het frame erboven" — en daar liggen precies de opgeslagen framepointer en de opgeslagen retouradres.
In een gecompileerde x86-64-functie bij -O0:
hoge adressen
+------------------------+ rbp + 16 : locals van de aanroeper
| ... |
+------------------------+ rbp + 8 : OPGESLAGEN RETOURADRES <-- wordt RIP
| saved rbp (8 bytes) |
+------------------------+ rbp : onze framepointer
| line[128] |
| buf[64] | <- rsp: wat read() vult
+------------------------+
lage adressen
Wanneer de functie terugkeert, poppen leave; ret de 8 bytes in RIP, en de
CPU springt waar de aanvaller het heeft bepaald. Al het andere in dit lab is
rekenkunde over waarheen je moet wijzen.
Voor deze build zijn de getallen: buf is 64 bytes, de opgeslagen rbp is 8,
dus het retouradres ligt op offset 88 vanaf het begin van buf. fooc
hardcoded dat niet — het disassembleert food en vindt lea -0x50(%rbp)
vóór call read@plt, zodat het blijft werken als je FOOD_BUFSZ verandert.
gcc vertelt je dit al.
foodbouwen print:warning: 'read' writing 512 bytes into a region of size 64 overflows the destination [-Wstringop-overflow=]. Onderdruk die waarschuwing nooit in echte code. Het is gratis beveiliging.
De drie technieken
fooc -t <technique>. Ze staan in de volgorde waarin een echte aanvaller er
doorheen zou werken, omdat elke techniek nodig heeft wat de vorige je leerde.
1. ret2win — bestuur de instructiepointer
[ 88 bytes rommel ][ het adres van food's win() ]
^ saved rbp
^ wordt RIP
win() is een functie in het doelprogramma die /bin/sh exec't. Het
overschrijven van het retouradres met haar adres is het hele exploit.
Wat het leert: je hebt volledige controle over de instructiepointer. Het
heeft ook geen leak nodig, want het binaire bestand is gebouwd met -no-pie,
dus win() staat voor altijd op een vast adres.
De tegenhanger in de echte wereld is niet "aanvallen zijn makkelijk",
maar "lever geen ongedocumenteerde backdoors in netwerk-binaries". Zit er een
functie als win() in jouw binaire bestand, dan zal een bufferoverloop haar
vinden. Dat is letterlijk de Juniper ScreenOS-backdoor-CVE-klasse.
Verdediging: -fPIE (of ASLR) randomiseert het laadadres, dus de aanvaller
moet het adres kennen — wat meestal betekent dat ze eerst een lek nodig hebben.
Daarom faalt ret2win tegen food_hardened.
2. ret2libc — roep om het even wat aan, bij naam
[ rommel ][ pop rdi; ret ][ adres van "/bin/sh" ][ adres van system() ]
^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
zet rdi de string om te sturen de functie om aan te roepen
Bij uitvoering: ret poppt pop rdi; ret in RIP; dat poppt de
"/bin/sh"-pointer in RDI; zijn ret poppt system() in RIP, terwijl RDI
de string nog vasthoudt. system("/bin/sh") draait.
Gadgets (pop rdi; ret) zitten niet in food — deze glibc heeft geen
__libc_csu_init — dus fooc vindt ze door live libc-geheugen te scannen op
het bytepaar 5f c3. Het lokaliseert libc via /proc/self/maps, vindt de
offsets van system en "/bin/sh" met dlsym() en berekent de base uit het
lek dat food prijsgeeft. Niets is hardcoded, dus het overleeft een
libc-update.
Wat het leert: zodra je RIP kunt controleren, kun je bestaande instructies aan elkaar rijgen. Dat is return-oriented programming, en zo ziet bijna elk echt exploit eruit, omdat het geen door de aanvaller geleverd uitvoerbaar geheugen nodig heeft.
Verdediging: geen van de compilerflags stopt dit alleen. Het werkt tegen een PIE-binary, met NX, met canary — zolang de aanvaller een lek heeft. De verdedigingen zijn "heb de overloop niet" en "lek geen adressen". Zie de tabel hieronder.
3. shellcode — voer je eigen machinecode uit
23 bytes, geplaatst aan het begin van de buffer, met RIP ernaar wijzend:
xor esi, esi ; envp = NULL
xor edx, edx ; argv = NULL
movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" als 8 ruwe bytes
push rdi ; leg de string op de stack
mov rdi, rsp ; rdi = &"/bin/sh"
push 0x3b ; 59 = __NR_execve
pop rax
syscall ; we zijn nu een shell
Dit is de puurste vorm van de bug: de aanvaller levert de instructies, niet alleen het adres van instructies die al bestaan. Geen libc-offsets nodig, dus het werkt in principe tegen een statisch gelinkt, volledig gerandomiseerd doelwit.
make verify assembleert shellcode.S en diff't het tegen de byte-array die
in fooc.c is ingebed, zodat de twee niet uiteen kunnen drijven.
Verdediging: NX (ook wel W^X, "no execute" genoemd). De stack als
niet-uitvoerbaar markeren zorgt ervoor dat de hardware weigert er instructies
uit te halen, en de ret landt op een pagina die niet kan draaien. Daarom
geeft make food -z execstack: een normale Linux-stack is rw-p, niet
rwx, en de techniek sterft met SIGSEGV bij RIP = het adres van de payload.
De allerbelangrijkste les van het lab is dat elk van deze bytes alleen werkt
omdat de compiler opdracht kreeg de stack uitvoerbaar te laten. Dat vlaggetje
staat aan voor niemands gewin.
Ook inbegrepen
| Modus | Wat hij doet |
|---|---|
-t leak |
verbindt, print leaks, stuurt niets |
-t demo |
stuurt rip_off + 8 bytes 0x41, zodat RIP 0x4141... wordt en de daemon sterft. Bewijst de bug zonder enige adreskennis |
-t sled |
een ret-sled, bewust bewaard als fout voorbeeld. Zonder lek zou je ASLR brute-forcen door de buffer te vullen met het adres van een ret. Dat kan hier niet werken: food accepteert 512 bytes, dus de sled heeft ~53 slots tegenover ~28 bits entropie. Zo geïmplementeerd dat je het kunt zien falen en kunt bevestigen dat het mechanisme echt "de CPU volgt een keten van rets" is |
De tabel met tegenmaatregelen
Dit is het deel om te onthouden. Elke rij is een echte verdediging, en de rechterkolom laat zien wat die daadwerkelijk met de gebeurtenisketen doet.
| Tegenmaatregel | Zo activeer je | Wat hij stopt | Wat hij niet stopt |
|---|---|---|---|
| Beperk read | n = read(fd, buf, sizeof buf - 1); |
Alles. De bug bestaat niet, dus niets stroomafwaarts doet ertoe | Niets — dit is de enige volledige fix |
| Stack-canary | -fstack-protector-strong (gcc-standaard) |
De ret: de canary wordt aan het einde van de functie gecontroleerd, dus de beschadiging wordt gedetecteerd en het proces aborted vóórdat RIP wordt gepopt |
Een bug in een functie zonder array (niets om te beschermen); een overloop die onder de canary blijft; alles wat niet normaal return't |
| NX / W^X | -z noexecstack (de standaard) |
Shellcode. De eigen instructies van de payload kunnen niet worden opgehaald | ret2win en ret2libc volledig. Die zijn de reden dat ROP bestaat |
| PIE + ASLR | -fPIE + ASLR=2 (beide standaard) |
De hardcoded adressen van ret2win. Alles verschuift bij elke run | Alles waar de aanvaller een lek heeft. ASLR verhoogt de prijs van een exploit; het is geen fix. Merk op dat stack, heap en mmap worden gerandomiseerd, maar de inhoud van de hoofd-binary niet — dat is wat ROP-ketens gebruiken |
| Lek niets | geen printf("%p") naar clients; initialiseer vóór je print |
Het informatielek dat ASLR van "duur" naar "gratis" verandert | — |
Gebruik geen printf(user_data) |
printf("%s", buf) in plaats van printf(buf) |
Format-string-bugs: %x-stack-reads, %n-willekeurige schrijfbewerkingen — een andere weg naar RCE |
— |
| Gebruik geen onbetrouwbare paden | valideer en openat() onder een vaste map |
Pad-traversal (CWE-22) | — |
| CET / shadow stack | -fcf-protection=full, kernel- en CPU-ondersteuning |
De ret zelf: de shadow stack onthoudt het echte retouradres en faalt bij een mismatch. Vangt ROP-ketens die hardware-ret gebruiken |
Aanvallen die nooit ret-en (call-oriented, of het doel van een functiepointer overschrijven met een gadget-keten die geen retour nodig heeft) |
| Veilige talen | Rust, Go, C# voor nieuwe code | De hele klasse. Bounds-checks worden tijdens de uitvoering afgedwongen, niet vertrouwd bij review | — |
Zie het zelf
make run # kwetsbare daemon
make test # alle drie de technieken werken
make test-hardened # dezelfde broncode, tegenmaatregelen aan
test-hardened bouwt food_hardened met -fstack-protector-strong -fPIE -pie -z noexecstack, wisselt hem in, draait alle drie opnieuw en legt daarna de
kwetsbare terug. Je ziet:
### 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)
En in de log van de geharde daemon, de canary die afgaat:
*** stack smashing detected ***: terminated
Lees dat goed, want het is de belangrijkste regel van het hele lab: de canary
ving ret2win, niet PIE. Alle drie de technieken sterven bij de canary, omdat
alle drie door dezelfde read() gaan en hetzelfde frame beschadigen. NX stopt
alleen nog de code van de shellcode; PIE breekt alleen nog het hardcoded
adres. Zet ze één voor één aan, en je ontdekt dat de meeste enkele
tegenmaatregelen je ergens kwetsbaar achterlaten.
Bestanden
| Bestand | Doel |
|---|---|
food.c |
de kwetsbare daemon. 6 genummerde bugs, elk met zijn fix in de commentaar |
fooc.c |
het exploit. objdump-gebaseerde offenderkenning, /proc-gebaseerde libc-herkenning, 4 payload-bouwers |
shellcode.S |
de 23 shellcode-bytes als assembly, zodat ze leesbaar en verifieerbaar zijn. fooc draagt ze inline en heeft dit tijdens het draaien niet nodig |
Makefile |
bouwt, test en de harde vergelijking |
tests/pty_test.c |
drijft fooc door een pseudo-terminal en controleert op echte shell-output |
tests/sock_test.c |
onafhankelijke verificateur over een rauwe socket, zodat het resultaat niet van fooc afhangt |
food.log |
de log van de daemon. Jouw bewijs van wat er gebeurde |
Twee bugs in dit lab die de moeite van het begrijpen waard zijn
Dit zijn niet de bugs van het doelprogramma. Het zijn bugs in het exploit en in zijn test-harness, en beide produceerden overtuigende leugens. Ze zijn gedocumenteerd in de bron waar ze wonen; hier staan ze omdat de faalpatronen leerzaam zijn.
Stack-uitlijning: de crash die geen NULL-dereferentie is
Symptoom. De overname landt correct — gdb laat je in win() zien — en
dan sterft het allereerste wat win() doet, een dprintf(). De
SIGSEGV-handler rapporteert RIP diep in glibc's formatter en een foutadres
van (nil), wat er precies uitziet als een corrupte pointer.
Oorzaak. De System V AMD64-ABI vereist 16-byte stack-uitlijning. Een
normale ret herstelt %rsp precies zoals de bijbehorende call het
opsloeg, dus de invariant blijft gratis behouden. Onze kale ret doet dat
niet: daarna geldt %rsp = buf + rip_off. Hier is buf 16-byte uitgelijnd en
is rip_off 88, dus de callee krijgt een stack die 8 mod 16 is. glibc is met
SSE2 gecompileerd, en movaps faalt op een verkeerd uitgelijnde operand.
Op x86 werpt dat #GP op, niet #PF, dus de kernel heeft geen foutadres en
rapporteert si_addr = 0. Die NULL is de hint: een uitlijnfout vermomd als
NULL-dereferentie.
Fix. Eén ret-gadget op offset rip_off, dat het echte doelwit 8 bytes
omhoog schuift, omdat elke ret precies 8 bij %rsp optelt. De volgorde is
kritiek: een eerdere versie plakte de ret achter het doelwit en produceerde
[ padding | target | ret ], waarbij de afsluitende ret nooit wordt bereikt
en de fix stilletjes niets doet. Een verdwaalde ret die op een bug lijkt, is
bijna altijd bewust.
Eén socket, twee lezers: de byte die verdween
Symptoom. Shellcode werd gerapporteerd als werkend. Toen werd de
pty-harness strenger gemaakt (ECHO uitzetten, zodat de terminal ophield de
eigen commandoregel van de harness naar zichzelf terug te echoën), en de
techniek begon te falen. Dieper ging elke techniek precies één byte van het
begin van elke uitvoer-chunk kwijt: uid=1000(hanez) werd geprint als
id=1000(hanez), PWNED-OK als WNED-OK, Linux 7.2.7 als inux 7.2.7.
Oorzaak. fooc deed vroeger de socket dup2()en naar zijn eigen
stdin/stdout en een lokale /bin/sh execv()en, terwijl een geforkt
relay-kind dezelfde socket ook las om output naar de terminal te verplaatsen.
De kernel kan het niets schelen dat die twee samenwerken. Een streamsocket heeft
één lees-cursor, en elke lezer verplaatst hem, dus bytes worden
onvoorspelbaar tussen hen verdeeld. De lokale shell — een interactieve
login-shell — las precies één byte en gooide het weg, elke keer weer.
strace -f liet het onmiddellijk zien:
read(0, "u", 1) <- de lokale shell, eet een byte op
read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- het relay, 1 byte te kort
Fix. Er is hier helemaal geen shell aan deze kant. Er is precies één shell in het hele plaatje, en die zit op het slachtoffer, in het gekaapte proces, met de TCP-verbinding als zijn stdin/stdout. Deze kant verplaatst alleen bytes. Als je ooit twee consumenten van een stream nodig hebt, heeft die stream één enkele lezer nodig die hem bewust demultiplext.
De meta-les. Het eerste "werkende" resultaat was een fout-positief, geproduceerd doordat de pty de eigen commandoregel van de harness terug naar zichzelf echoëde, en de fix voor dat fout-positief is wat de echte bug onthulde. Tests die niet kunnen falen, zijn erger dan geen tests, omdat ze "ik weet het niet" veranderen in "het werkt". Een test-harness verdient hetzelfde wantrouwen als de code die hij test.
Ermee spelen
Dingen die de moeite waard zijn om te proberen, ongeveer in de volgorde waarin je er het meest van leert:
-
Verander
FOOD_BUFSZnaar 128. Draaifoocopnieuw. Het zou nog steeds moeten werken zonder wijzigingen, omdat het de offset uit de disassembly leest. Breek het dan met de hand — hardcode 88 — en zie het crashen. Voeg daarna een tweede array toe tussenbufen de opgeslagen registers, en zie hoe de automatische herkenning het afhandelt. -
Voeg
-Wformat-securitytoe en kijk wat het format-string-pad doet. Stuur%p %p %p %nen ziefoodde stack lekken. -
Gebruik gdb.
make debug, daarna:(gdb) break food.c:393 # de read() die overloopt (gdb) run -p 2342 (gdb) info registers rsp rbp (gdb) x/24gx $rsp # merk op waar het retouradres ligt (gdb) c # in een andere terminal: ./fooc -t ret2winDe SIGSEGV-handler logt
REG_RIPenREG_RSP, dusfood.logvertelt je of de overname geland is, zelfs wanneer het kind sterft vóór je kunt aanhaken. -
Verwijder de uitlijnfix in
fooc.cen zie de#GP-fout met desi_addr = 0-handtekening. Lees dan/proc/sys/kernel/randomize_va_spaceen denk na over wat ASLR randomiseert en wat niet. -
Breek de libc-symbolresolutie en zie hoe
fooczich aanpast. Het hele punt van de/proc/self/maps-benadering is dat geen enkele offset hardcoded is. -
Schrijf een vierde techniek. Een
ret2csu-achtige keten als je__libc_csu_initkunt vinden, of een SROP-keten (sigreturn-frames laten je alle registers tegelijk controleren). Beide zijn puur ROP en hebben geen uitvoerbaar geheugen nodig. -
Fix
food.cgoed, één bug tegelijk, en draai het exploit opnieuw na elke fix. De volgorde in de tabel bovenaanfood.cis ongeveer de juiste om in te denken: beperk eerst de read, want niets anders doet ertoe voordat de bug weg is.
Opruiming
make stop # stopt food
make clean # verwijdert bouwproducten; laat food.log met rust
pkill -x sh # alleen als je losse shells hebt van een test die misging
Merk op: pkill -x food matcht de procesnaam precies. Gebruik geen
pkill -f ./food — dat patroon matcht ook de shell waarin je het typt en doodt
je eigen sessie. Dat is geen hypothese; het gebeurde terwijl dit lab werd
gebouwd.