foo/suid/README.md
2026-09-29 09:39:24 +02:00

18 KiB

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:

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:

  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

    $ 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:

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?

  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)

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.
$ make stop
$ make unsetuid