388 lines
No EOL
18 KiB
Markdown
388 lines
No EOL
18 KiB
Markdown
# 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
|
|
``` |