409 lines
19 KiB
Markdown
409 lines
19 KiB
Markdown
# 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 <technique>`. 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.
|