19 KiB
food / fooc — et stack-bufferoverløb, fra begge sider
Et C99-sikkerhedslaboratorium i to halvdele:
food.c— en bevidst sårbar TCP-daemon. Den har et ægte, lærebogsagtigt stack-bufferoverløb (CWE-120) og et par fejl oveni.fooc.c— et exploit til den. Det beregner overflow-offsettet ved at disassemblere target-programmet ved kørsel, læser adresse-leaks fra daemonen og får en shell på "offeret" ved at overskrive en gemt returadresse.
Pointen er ikke shellen. Pointen er, at du kan følge med hele vejen, hvordan en hukommelsessikkerhedsfejl bliver til vilkårlig kodeudførelse — og derefter se præcist, hvilke modforanstaltninger der stopper hvert trin i den kæde. Hver linje i begge programmer er kommenteret, fordi mekanismen er lektionen.
din terminal
|
./fooc (exploit)
|
TCP 127.0.0.1:2342
|
./food (sårbar daemon)
|
fork() -> vulnerable_handler() -> overflow -> ret -> din kode
⚠️ Læs dette først
food er en bevidst ødelagt netværkstjeneste. Den binder kun til
127.0.0.1, og den standard er bevidst — lad den være der.
- Kør den ikke på en maskine, du holder af, eller på noget med data på.
- Bind den ikke til
0.0.0.0eller en rigtig netværksgrænseflade. Den er bevidst eksternt udnyttelig. - At rette
foocmod en host, du ikke ejer eller ikke har skriftlig tilladelse til at teste, er en computerindbrudsforseelse i de fleste jurisdiktioner — også efter UK Computer Misuse Act og US Computer Fraud and Abuse Act. - Den binder til en uprivilegeret port (>1024), så du behøver ikke root. Forbedr den ikke ved at tilføje capabilities eller køre den som systemtjeneste.
- Hver forbindelse håndteres i et
fork()et barn, ogfoodreaper det, så nedbrud hober sig ikke op. Hvis du bagefter finder dusinvis af strejfendesh-processer, erpkill -x shoprydningen.
I tvivlstilfælde: Dette laboratorium er til en virtuel maskine eller container, på et netværk du kontrollerer, på en maskine uden noget, du ville savne.
Hurtig start
make # bygger food, fooc og test-harnessene
make run # starter food på 127.0.0.1:2342, frakoblet i baggrunden
make test # kører alle tre exploit-teknikker
make stop # stopper daemonen
Derefter i hånden:
./fooc -t leak # se de adresse-leaks, food udleverer
./fooc -t demo -v # send junk; se food dø med SIGSEGV
./fooc -t ret2win -i # hop til en funktion, der allerede findes -> shell
Krav
| Værktøj | Hvortil | Bemærkninger |
|---|---|---|
gcc (eller clang) |
bygning | C99. Testet med gcc 16.2 |
objdump |
fooc |
binutils. fooc kalder det ved kørsel |
nasm |
make verify |
kun til at krydstjekke shellcoden; springes over, hvis ikke til stede |
gdb |
make debug |
valgfrit |
| Linux, x86-64 | begge | payload og gadget-jagt er arkitekturafhængige |
fooc har også brug for -ldl til dlsym(); Makefile'et klarer det.
Fejlen
Én linje i food.c er hele angrebsfladen:
char buf[FOOD_BUFSZ]; /* 64 bytes */
n = read(fd, buf, FOOD_READMAX); /* op til 512 bytes fra netværket */
64 bytes destination, 512 bytes accepteret. Angriberen overskriver 448 bytes forbi enden af bufferen, og fordi stacken vokser nedad, betyder "forbi enden" "ind i det ovenstående frame" — og det er præcis der, den gemte framepointer og den gemte returadresse ligger.
I en kompileret x86-64-funktion ved -O0:
høje adresser
+------------------------+ rbp + 16 : callerens lokale
| ... |
+------------------------+ rbp + 8 : GEMT RETURADRESSE <-- bliver til RIP
| saved rbp (8 bytes) |
+------------------------+ rbp : vores framepointer
| line[128] |
| buf[64] | <- rsp: det, read() fylder
+------------------------+
lave adresser
Når funktionen returnerer, popper leave; ret de 8 bytes ind i RIP, og CPU'en
hopper, hvor angriberen har bestemt. Alt andet i dette laboratorium er
aritmetik om, hvorhen der skal peges.
For denne build er tallene: buf er 64 bytes, det gemte rbp er 8, så
returadressen ligger på offset 88 fra starten af buf. fooc hardkoder
ikke det — det disassemblerer food og finder lea -0x50(%rbp) foran
call read@plt, så det fortsat virker, hvis du ændrer FOOD_BUFSZ.
gcc fortæller dig allerede om det. At bygge
foodprinter:warning: 'read' writing 512 bytes into a region of size 64 overflows the destination [-Wstringop-overflow=]. Undertryk aldrig den advarsel i ægte kode. Den er gratis sikkerhed.
De tre teknikker
fooc -t <technique>. De står i den rækkefølge, en ægte angriber ville
arbejde sig igennem dem, fordi hver enkelt har brug for det, den forrige lærte
dig.
1. ret2win — kontrollér instruktionsmarkøren
[ 88 bytes junk ][ adressen på food's win() ]
^ saved rbp
^ bliver til RIP
win() er en funktion i target-programmet, der exec'er /bin/sh. At
overskrive returadressen med dens adresse er hele exploitet.
Hvad det lærer: du har vilkårlig kontrol over instruktionsmarkøren. Det
kræver heller ikke noget leak, fordi binærfilen er bygget -no-pie, så win()
sidder på en fast adresse for evigt.
Den virkelige verdens ækvivalent er ikke "angreb er nemme", men "skib ikke
udokumenterede bagdøre i netværks-binærfiler". Hvis en funktion som win()
findes i din binærfil, vil et bufferoverløb finde den. Det er bogstaveligt talt
Juniper ScreenOS-bagdør-CVE-klassen.
Forsvar: -fPIE (eller ASLR) randomiserer load-adressen, så angriberen må
kende adressen — hvilket som regel betyder, at de først har brug for et leak.
Derfor fejler ret2win mod food_hardened.
2. ret2libc — kald hvad som helst, ved navn
[ junk ][ pop rdi; ret ][ adressen på "/bin/sh" ][ adressen på system() ]
^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
sætter rdi strengen at sende funktionen at kalde
Ved udførelse: ret popper pop rdi; ret ind i RIP; det popper
"/bin/sh"-pointeren ind i RDI; dets ret popper system() ind i RIP, mens
RDI stadig holder strengen. system("/bin/sh") kører.
Gadgets (pop rdi; ret) er ikke i food — denne glibc har intet
__libc_csu_init — så fooc finder dem ved at scanne live libc-hukommelse
efter byteparret 5f c3. Det lokaliserer libc via /proc/self/maps, finder
offsets for system og "/bin/sh" med dlsym() og beregner basen ud fra det
leak, food offentliggør. Intet er hardkodet, så det overlever en
libc-opdatering.
Hvad det lærer: når du først kan kontrollere RIP, kan du kæde
eksisterende instruktioner sammen. Det er return-oriented programming, og det
er sådan næsten alle virkelige exploits ser ud, fordi det ikke kræver
angriberleveret eksekverbar hukommelse.
Forsvar: ingen af compiler-flagene stopper det alene. Det virker mod en PIE-binærfil, med NX, med canary — så længe angriberen har et leak. Forsvarene er "hav ikke overløbet" og "læk ikke adresser". Se tabellen nedenfor.
3. shellcode — kør din egen maskinkode
23 bytes, placeret i starten af bufferen, med RIP pegende på dem:
xor esi, esi ; envp = NULL
xor edx, edx ; argv = NULL
movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" som 8 rå bytes
push rdi ; læg strengen på stacken
mov rdi, rsp ; rdi = &"/bin/sh"
push 0x3b ; 59 = __NR_execve
pop rax
syscall ; vi er nu en shell
Det er den reneste form for fejlen: angriberen leverer instruktionerne, ikke bare adressen på instruktioner, der allerede findes. Ingen libc-offsets nødvendige, så det virker i princippet mod et statisk linket, fuldt randomiseret target.
make verify assemblerer shellcode.S og diff'er det mod byte-arrayet, der er
indlejret i fooc.c, så de to ikke kan drive fra hinanden.
Forsvar: NX (også kaldet W^X, "no execute"). At markere stacken som
ikke-eksekverbar får hardwaren til at nægte at hente instruktioner fra den, og
ret-et lander på en side, der ikke kan køre. Det er derfor make food
giver -z execstack: en normal Linux-stack er rw-p, ikke rwx, og teknikken
dør med SIGSEGV ved RIP = payloadens adresse. Den absolut vigtigste lektion i
laboratoriet er, at hver eneste af disse bytes kun virker, fordi compileren fik
besked på at lade stacken være eksekverbar. Det flag er tændt til gavn for
ingen.
Også inkluderet
| Tilstand | Hvad den gør |
|---|---|
-t leak |
forbinder, printer leaks, sender intet |
-t demo |
sender rip_off + 8 bytes 0x41, så RIP bliver 0x4141... og daemonen dør. Beviser fejlen helt uden adresseviden |
-t sled |
et ret-sled, bevidst beholdt som et fejlende eksempel. Uden et leak ville du brute-force ASLR ved at fylde bufferen med adressen på et ret. Det kan ikke virke her: food accepterer 512 bytes, så sleden har ~53 slots mod ~28 bit entropi. Implementeret, så du kan se det fejle og bekræfte, at mekanismen virkelig er "CPU'en følger en kæde af rets" |
Tabellen over modforanstaltninger
Det er den del, man skal huske. Hver række er et ægte forsvar, og højre kolonne viser, hvad den rent faktisk gør ved begivenhedskæden.
| Modforanstaltning | Sådan aktiveres | Hvad den stopper | Hvad den ikke stopper |
|---|---|---|---|
| Begræns read | n = read(fd, buf, sizeof buf - 1); |
Alt. Fejlen findes ikke, så intet nedstrøms betyder noget | Intet — det er den eneste fuldstændige fix |
| Stack-canary | -fstack-protector-strong (gccs standard) |
ret-et: canaryen tjekkes ved funktionens afslutning, så smadringen opdages, og processen abort'er, før RIP poppes |
En fejl i en funktion uden array (intet at beskytte); et overflow, der holder sig under canaryen; alt, der ikke returnerer normalt |
| NX / W^X | -z noexecstack (standarden) |
Shellcode. Payloadens egne instruktioner kan ikke hentes | ret2win og ret2libc fuldstændigt. De er grunden til, at ROP findes |
| PIE + ASLR | -fPIE + ASLR=2 (begge standard) |
ret2wins hardkodede adresser. Alt flytter sig ved hver kørsel | Alt, hvor angriberen har et leak. ASLR hæver prisen på et exploit; det er ikke en fix. Bemærk, at stack, heap og mmap randomiseres, men hoved-binærfilens indhold gør ikke — det er det, ROP-kæder bruger |
| Læk ikke | ingen printf("%p") til klienter; initialisér før du printer |
Det informationsleak, der gør ASLR til "gratis" i stedet for "dyrt" | — |
Brug ikke printf(user_data) |
printf("%s", buf) i stedet for printf(buf) |
Format-string-fejl: %x-stack-reads, %n-vilkårlige skrivninger, hvilket er en anden vej til RCE |
— |
| Brug ikke utroverdige stier | validér og openat() under en fast mappe |
Sti-traversal (CWE-22) | — |
| CET / shadow stack | -fcf-protection=full, kernel- og CPU-understøttelse |
ret-et selv: shadow stacken husker den rigtige returadresse og fault'er ved mismatch. Fanger ROP-kæder, der bruger hardware-ret |
Angreb, der aldrig ret (call-oriented, eller at overskrive en funktionspegers mål med en gadget-kæde, der ikke behøver en retur) |
| Sikre sprog | Rust, Go, C# til ny kode | Hele klassen. Bounds-tjek udføres ved kørsel, ikke håbet på ved review | — |
Se det selv
make run # sårbar daemon
make test # alle tre teknikker virker
make test-hardened # samme kildekode, modforanstaltninger på
test-hardened bygger food_hardened med -fstack-protector-strong -fPIE -pie -z noexecstack, bytter den ind, kører alle tre igen og lægger derefter den
sårbare tilbage. Du vil se:
### 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)
Og i den hærdede daemons log, canaryen der udløses:
*** stack smashing detected ***: terminated
Læs det grundigt, for det er den vigtigste linje i hele laboratoriet: canaryen
fangede ret2win, ikke PIE. Alle tre teknikker dør ved canaryen, fordi alle tre
går gennem det samme read() og smadrer den samme frame. NX stopper kun
yderligere shellcodens kode; PIE bryder kun yderligere den hardkodede adresse.
Tænd dem enkeltvis, og du vil opdage, at de fleste enkelte modforanstaltninger
efterlader dig udsat over for noget.
Filer
| Fil | Formål |
|---|---|
food.c |
den sårbare daemon. 6 nummererede fejl, hver med sin fix i kommentaren |
fooc.c |
exploitet. objdump-baseret offseterkendelse, /proc-baseret libc-erkendelse, 4 payload-buildere |
shellcode.S |
de 23 shellcode-bytes som assembly, så de kan læses og verificeres. fooc bærer dem inline og behøver ikke dette ved kørsel |
Makefile |
bygger, tester og den hærdede sammenligning |
tests/pty_test.c |
driver fooc gennem et pseudo-terminal og tjekker for ægte shell-output |
tests/sock_test.c |
uafhængig verifikator over en rå socket, så resultatet ikke afhænger af fooc |
food.log |
daemonens log. Dit bevis på, hvad der skete |
To fejl i dette laboratorium, der er værd at forstå
Det er ikke target-programmets fejl. Det er fejl i exploitet og i dets test-harness, og begge producerede overbevisende løgne. De er dokumenteret i kilden, hvor de bor; her står de, fordi fiaskomønstrene er lærerige.
Stack-justering: nedbruddet, der ikke er en NULL-dereference
Symptom. Kapringen lander korrekt — gdb viser dig i win() — og så dør
det allerførste, win() gør, et dprintf(). SIGSEGV-handleren rapporterer
RIP dybt inde i glibcs formatter og en fejladresse på (nil), hvilket ser
præcis ud som en korrupt pointer.
Årsag. System V AMD64-ABI'en kræver 16-byte stack-justering. Et normalt
ret genskaber %rsp præcis som det tilsvarende call gemte det, så
invarianten bevares gratis. Vores nøgne ret gør ikke: efter det gælder
%rsp = buf + rip_off. Her er buf 16-byte justeret og rip_off er 88, så
callee'en får en stack, der er 8 mod 16. glibc er kompileret med SSE2, og
movaps fault'er ved et fejljusteret operand. På x86 rejser det #GP, ikke
#PF, så kernen har ingen fejladresse og rapporterer si_addr = 0. Det NULL
er fingerpeg: en justeringsfejl forklædt som en NULL-dereference.
Fix. Et ret-gadget ved offset rip_off, der flytter det rigtige target
8 bytes op, fordi hvert ret lægger præcis 8 til %rsp. Rækkefølgen er
kritisk: en tidligere version hæftede ret-et efter target og producerede
[ padding | target | ret ], hvor det afsluttende ret aldrig nås, og fixen
stille og roligt intet gør. Et vildfarent ret, der ligner en fejl, er næsten
altid bevidst.
Én socket, to læsere: det byte, der forsvandt
Symptom. Shellcode blev rapporteret som fungerende. Derefter blev
pty-harnessen gjort strengere (slukning af ECHO, så terminalen holdt op med at
ekko harnessens egen kommandolinje tilbage til sig selv), og teknikken begyndte
at fejle. Dybere set tabte hver teknik præcis ét byte fra starten af hver
udgangschunk: uid=1000(hanez) blev printet som id=1000(hanez), PWNED-OK
som WNED-OK, Linux 7.2.7 som inux 7.2.7.
Årsag. fooc plejede at dup2()e socket'en på sit eget stdin/stdout og
execv()e en lokal /bin/sh, mens et forket relay-barn også læste den samme
socket for at flytte output til terminalen. Kernen er ligeglad med, at de to
samarbejder. En streamsocket har én læse-cursor, og hver læser flytter den,
så bytes deles uforudsigeligt mellem dem. Den lokale shell, der er en
interaktiv login-shell, læste præcis ét byte og smed det væk — hver eneste
gang. strace -f viste det øjeblikkeligt:
read(0, "u", 1) <- den lokale shell, æder et byte
read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- relay'et, 1 byte for kort
Fix. Der er slet ingen shell på denne side. Der er præcis én shell i hele billedet, og den er på offeret, inde i den kaprede proces, med TCP-forbindelsen som dens stdin/stdout. Denne side flytter kun bytes. Hvis du nogensinde har brug for to forbrugere af en stream, skal den stream have én eneste læser, der bevidst demultiplekser den.
Meta-lektionen. Det første "fungerende" resultat var et falsk positivt, produceret af at pty'en ekkoede harnessens egen kommandolinje tilbage til den, og fixen for det falske positive er det, der afslørede den rigtige fejl. Tests, der ikke kan fejle, er værre end ingen tests, fordi de forvandler "jeg ved ikke" til "det virker". En test-harness fortjener samme mistænksomhed som den kode, den tester.
At pille ved det
Ting, der er værd at prøve, nogenlunde i den rækkefølge, du lærer mest af dem:
-
Ændr
FOOD_BUFSZtil 128. Kørfoocigen. Det burde stadig virke uden ændringer, fordi det læser offset ud af disassembly'en. Bræk det så i hånden — hardkod 88 — og se det crashe. Tilføj derefter et andet array mellembufog de gemte registre, og se den automatiske erkendelse klare det. -
Tilføj
-Wformat-securityog se, hvad format-string-stien gør. Send%p %p %p %nog sefoodlække stacken. -
Brug gdb.
make debug, derefter:(gdb) break food.c:393 # det read(), der løber over (gdb) run -p 2342 (gdb) info registers rsp rbp (gdb) x/24gx $rsp # bemærk, hvor returadressen ligger (gdb) c # i et andet terminal: ./fooc -t ret2winSIGSEGV-handleren loggerREG_RIPogREG_RSP, såfood.logfortæller dig, om kapringen landede, selv når barnet dør, før du kan koble på. -
Slet justeringsfixen i
fooc.cog se#GP-fejlen medsi_addr = 0-signaturen. Læs derefter/proc/sys/kernel/randomize_va_spaceog tænk over, hvad ASLR randomiserer, og hvad det ikke gør. -
Bræk libc-symbolopløsningen og se
fooctilpasse sig. Hele pointen med/proc/self/maps-tilgangen er, at intet offset er hardkodet. -
Skriv en fjerde teknik. En
ret2csu-lignende kæde, hvis du kan finde__libc_csu_init, eller en SROP-kæde (sigreturn-frames lader dig kontrollere alle registre på én gang). Begge er rent ROP og behøver ingen eksekverbar hukommelse. -
Fix
food.cordentligt, én fejl ad gangen, og kør exploitet igen efter hver fix. Rækkefølgen i tabellen øverst ifood.cer nogenlunde den rigtige rækkefølge at tænke i: begræns først read'et, for intet andet betyder noget, før fejlen er væk.
Oprydning
make stop # stopper food
make clean # fjerner build-produkter; lader food.log være i fred
pkill -x sh # kun hvis du har strejfende shells fra en test, der gik skævt
Bemærk: pkill -x food matcher procesnavnet præcist. Brug ikke
pkill -f ./food — det mønster matcher også den shell, du har skrevet det i,
og dræber din egen session. Det er ikke en hypotese; det skete, mens dette
laboratorium blev bygget.