foo/suid/README.md

388 lines
18 KiB
Markdown
Raw Normal View History

2026-09-29 09:39:24 +02:00
# 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:
```text
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:**
```console
$ ./foosd ... # see the log line it prints at startup
[foosd 1234] startup: ruid=1000 euid=0 -> ROOT process
```
and from the exploit:
```console
$ ./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
```console
$ 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:
```console
$ ./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:
```console
$ 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:
```console
$ 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
```console
$ 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:
```console
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:
```c
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.**
```c
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
```asm
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)
```text
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`.
```console
$ make stop
$ make unsetuid
```