# 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.