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
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.0or a real interface. It is remotely exploitable by design. - Pointing
foocat 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, andfoodreaps it, so crashes do not accumulate. If you find yourself with dozens of strayshprocesses afterwards,pkill -x shis 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.
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 daemonThen, by hand:
./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| 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.
One line in food.c is the whole exploit surface:
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
foodprints: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.
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.
[ 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.
[ 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.
23 bytes, placed at the start of the buffer, with RIP pointed at them:
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 shellThis 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.
| 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" |
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 | — |
make run # vulnerable daemon
make test # all three techniques work
make test-hardened # same source, mitigations ontest-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.
| 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 |
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.
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.
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.
Things worth trying, roughly in order of how much you will learn:
-
Change
FOOD_BUFSZto 128. Runfoocagain. 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 betweenbufand the saved registers and watch the automatic detection handle it. -
Add
-Wformat-securityand look at what the format-string path does. Send%p %p %p %nand watchfoodleak the stack. -
Use gdb.
make debug, then:(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
SIGSEGVhandler logsREG_RIPandREG_RSP, sofood.logtells you whether the hijack landed even when the child dies before you can attach. -
Delete the alignment fix in
fooc.cand watch the#GPfault with thesi_addr = 0signature. Then read/proc/sys/kernel/randomize_va_spaceand think about what ASLR does and does not randomise. -
Break libc symbol resolution and watch
foocadapt. The whole point of the/proc/self/mapsapproach is that no offset is hardcoded. -
Write a fourth technique. A
ret2csu-style chain if you can find__libc_csu_init, or a SROP chain (sigreturnframes let you control every register at once). Both are pure ROP and need no executable memory. -
Fix
food.cproperly, one bug at a time, and re-run the exploit after each fix. The order in the table at the top offood.cis roughly the right order to think about them: bound the read first, because nothing else matters until the bug is gone.
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 sidewaysNote 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.