| .. | ||
| tests | ||
| .gitignore | ||
| foosc.c | ||
| foosd.c | ||
| Makefile | ||
| README.DA.md | ||
| README.DE.md | ||
| README.ES.md | ||
| README.FR.md | ||
| README.md | ||
| README.NL.md | ||
| README.NO.md | ||
| shellcode.S | ||
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+sturns "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:
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:
$ ./foosd ... # see the log line it prints at startup
[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process
and from the exploit:
$ ./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
$ 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:
$ ./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:
$ 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:
$ 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:
-
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 thes, silently undoing the setup. The canonical sequence whenever you rebuild is therefore$ make unsetuid && make && make setuid -
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:
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:
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.
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
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?
fooscreadsfoosd's banner over the socket. It gets:ids=0/1000— euid/ruid (the SUID self-diagnosis)stack=…andlibc=…— pointers (the ASLR leaks)BUF=…— the exact address of the buffer it is about to overflow
- From the target binary (via
objdump) it learnsrip_offand the addresses ofwin()/win_root(). - From its own libc (via
/proc/self/maps+dlsym+ a memory scan) it measures the offsets ofsystem,read,/bin/shand apop rdi; retgadget — nothing is hardcoded. - It assembles the payload. For
-t shellcodethat is:[32-byte setreuid+execve code][padding to RIP][ret fix][address of buf]. foosd'sread()overflows;retlands on the shellcode; the kernel executessetreuid(0,0)(fine: euid 0 is privileged) and thenexecveof/bin/sh. bash starts withruid == euid == 0and stays root.fooscrelays your terminal to that root shell until you typeexit.
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:
- Loopback only, enforced.
foosdrefuses 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. - Self-diagnosis. At startup it logs
ruid/euidand whether it is running as root, so the console shows the state the exploit depends on. - 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.
- Crash reporter. A SIGSEGV handler logs
RIP/RSP— the value the attacker wrote into the return address — so a successful hijack is visible infoosd.loginstead of being a silent death. 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
foosdwould bind, thensetgroups/setgid/setuidto an unprivileged account and verify it stuck (the correct version is in the source asdrop_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)
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 aseuid=/ruid=because the test harness proves a shell by grepping for the literaluid=and the banner must not contain it (a probe sharing a signature with the answer is a classic false-positive trap; see the comment infoosd.c). The harness additionally requires the strictid-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 containsuid=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
- Watch the non-root demotion. Run
make testbeforemake setuid, then again after. Explain theROOT=SEENchange using the ruid/euid story in §5.2. - Read the crash. Run
./foosc -t demo -nand then readfoosd.log. TheRIP=0x4141414141414141line is the attacker's padding — the proof that the overflow, not bad luck, controls execution. - Add the canary.
make hardenedand change thetest-hardenedloop yourself; the log line*** stack smashing detected ***is the defence working. - Disable the leak. Comment out the
BUF=line infoosd.c, rebuild, and watch-t shellcodego from deterministic to a guessing game. That single line is why real ASLR bypasses are a whole field. - The
-pexperiment. In a copy ofwin(), changeexecl("/bin/sh", "sh", NULL)toexecl("/bin/sh", "sh", "-p", NULL)and observe root.-pis the documented escape hatch from the shell's guard — and the reason "just spawn a shell" advice from old write-ups is incomplete. - Why not
setuid(0)? Rewrite the shellcode to callsetuid(0)instead ofsetreuid(0,0)(syscall 105). The shell still lands — and still drops touid=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;
-Lbinds 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 -hat anything you do not own. - Cleanup ritual:
make stopthenmake unsetuid, and if you want the tree pristine againsudo make clean.
$ make stop
$ make unsetuid