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

305 lines
No EOL
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# The `wosuid` lab — root RCE with **no** setuid bit
```
foowosd an intentionally vulnerable daemon that is root because it was
*STARTED* as root (port 2344, loopback-only by default)
foowosc the exploit: turns a stack overflow into a **root** shell by
executing shellcode — same 23 bytes that pwned the user-level
`food` daemon in the parent lab
```
This is the third lab in the series. Same exploit toolchain, same style, one
fundamental difference:
| lab | how the target process becomes root | `uid=0(root)` shell? |
|----------|------------------------------------------------|----------------------|
| food/fooc| never — it is a plain user daemon | no |
| foosd/foosc | the SUID bit (`chmod u+s`) — euid 0, ruid 1000 | yes (needs `setreuid` in the shellcode, because bash resets euid→ruid) |
| **foowosd/foowosc** | **none — root *starts* the daemon** (sudo / systemd `User=root`) | **yes (plain `execve` shellcode)** |
The setuid bit is a *transfer vehicle* for privilege — not the privilege
itself. A daemon launched by root has real, effective and saved uid all equal
to 0. To the kernel that is root, period; it cannot and does not care whether
the process got there via `+s` on a file or via `sudo ./foowosd`. So the
overflow in a root-started daemon is a root exploit — *"I don't have SUID
binaries" is not the same as "I am not exploitable".*
That is the whole lesson of this lab. Everything below is the machinery.
---
## Quick start (what the user requested)
```
cd wosuid
make # build the daemon, the exploit, and the test harness
```
### The real thing — run the daemon as **root**
```
sudo make run-root # starts foowosd as uid 0 (process, not file mode)
make test-root # every technique must now yield uid=0(root)
```
### No sudo? The identical kernel path via a user namespace
```
make run-root-ns # uid 0 inside a user namespace — no password needed
make test-root # same verdicts; used by CI and anyone without sudo
```
### Baseline — daemon as your normal user (no root anywhere)
```
make run # foowosd runs with your uids
make test # exploits land shells, but `root` is expected MISSING
```
### Cleanup ritual (always: this is a root-shell lab)
```
make stop
```
`foowosd` is *not* setuid and nothing in this directory ever chmods `+s` —
that is the point. The dangerous state is the **process**, not the file.
---
## When you are told to set the SUID bit
You are **not** going to. This lab deliberately has no SUID bit:
- `foowosd` is built, owned and mode-regular like any other program.
- It becomes root the way real daemons do — by being *started* by root.
- `make run-root` uses `sudo` for exactly that, and `make run-root-ns` gets
a genuinely uid-0 process without any of it.
The suid bit belongs to the *sibling* lab (`foosd`). The contrast between the
two is the syllabus:
1. SUID lab: the bit gives **euid 0 but ruid 1000** → `execve("/bin/sh")` is
demoted by bash's guard (`euid != ruid` → reset) → shellcode must call
`setreuid(0,0)` first (32-byte payload).
2. This lab: root **starts** the process → **ruid == euid == 0** → the guard
has nothing to reset → the plain 23-byte `execve` shellcode keeps root.
Same overflow. Same technique. Different *origin* of privilege, different
payload shape. That is the lesson in miniature.
---
## The protocol
Whatever kind of client connects, foowosd greets it with:
```
FOOWOSD 1.0 ids=0/0 leak stack=0x7ffd… libc=0x7f… (the banner + leaks)
BUF=0x7ffd… (the buffer address)
```
`ids=euid/ruid` is the *"am I root?"* side-channel. foowosc prints a loud
warning when euid is not 0 (i.e. you started the daemon as a plain user): the
payload will still land, but the shell will be a user shell, and calling the
exploit "broken" would be wrong — it is merely not escalating.
> Spelling note: `ids=`, not `euid=`/`ruid=`. The test harness proves a live
> `id` ran by matching the literal `uid=NNN(` shape, so the banner must never
> contain a substring that itself satisfies the check. (In the SUID lab this
> exact trap produced a spectacular false positive.)
---
## The exploit techniques (`foowosc -t …`)
All four exploit paths below work against foowosd. When the daemon is root,
**all four that spawn anything yield root** — unlike the SUID lab, where
ret2win/ret2libc were quietly demoted to uid 1000 by bash's guard. Here there
is no mismatch to guard against.
| `-t` | what happens | when daemon is root |
|---------------|---------------------------------------------------------------------|---------------------|
| `shellcode` | 23-byte `execve("/bin/sh", NULL, NULL)` runs on the stack. | **root shell** (default) |
| `ret2win` | jump to `win()` → `execl("/bin/sh")` | **root shell** |
| `ret2libc` | ROP: `pop rdi; ret` → `"/bin/sh"` → `system()` | **root shell** |
| `demo` | junk overflow only — expect a SIGSEGV in the daemon log | crash, by design |
| `leak` | just print the leaks, send no payload | n/a |
```
./foowosc -t shellcode # interactive; default target 127.0.0.1:2344
./foowosc -t shellcode -n # send and report, no interactive session
```
A successful interactive session relays your terminal to the shell *on the
victim* — there is exactly one shell in the picture, and it is `/bin/sh`
running as root inside foowosd. Type `id` to see `uid=0(root)`.
### Why there is no `ret2win-root` technique here
foosc had one — it jumped to a `win_root()` that called `setreuid(0,0)`
before exec, because a setuid process ran with a real uid that still said
1000. A root-*started* process has real uid 0 already; there is nothing to
clear, so the extra function and technique would be teaching nothing.
Removed.
---
## What foowosc does, step by step
1. **Static analysis** — `objdump -d` of `./foowosd`. Finds
`vulnerable_handler`, `win()`, the `lea -0x50(%rbp)` that addresses `buf`,
and the first bare `ret`. From the displacement it computes
`rip_off = 80 + 8 = 88`. Nothing is hardcoded; this survives a rebuild.
2. **Self-introspection** — reads its own `/proc/self/maps` and `dlsym()`s
`system`/`read` to learn libc *offsets*. The target's libc base is
`leaked_read − off_read`, then `system = base + off_system`, and so on.
This delta-arithmetic is why exploits stay alive across libc versions.
3. **Connect** — reads the banner/leaks (`ids=`, `stack=`, `libc=`, `BUF=`).
4. **Build the payload** — for `shellcode`: 23 bytes of machine code, padding
to `rip_off`, then the saved RIP = `buf` (so `ret` jumps onto the code).
For `ret2win`/`ret2libc`: addresses computed from analysis — no execution
of the stack needed.
5. **The alignment fix** — a hijacked bare `ret` hands the callee `rsp ≡ 8
(mod 16)`, and glibc's SSE2 code `movaps`-faults on a misaligned stack (the
crash reporter logs `si_addr=(nil)` — the tell). foowosc inserts one extra
`ret` gadget before the real target, restoring the invariant. Kid-gloves
engineering in a shellcode lab, but it is the difference between a payload
that "sometimes works" and one that always works.
6. **Send, then relay** — the victim process *is* the shell; this process only
splices bytes. No local shell, no second reader — the single-read-cursor
failure (one byte eaten off every chunk) is documented in `become_shell()`.
---
## The daemon's deliberate bugs (all in `foowosd.c`, all real CWE classes)
| # | bug | CWE | note |
|---|-----|-----|------|
| 1 | `read(fd, buf, 512)` into a 64-byte stack buffer | CWE-120 | the overflow: 448 bytes past `buf`, saved RIP at +88 |
| 2 | `snprintf(line, …, "%.*s", …)` only; but attacker `%` in the echo path | CWE-134 | the leak is the real payload here; a `%n` in a *root* process would be write-what-where as root |
| 3 | children keep root while handling untrusted input | CWE-271 | the correct `drop_privs()` (setgroups→setgid→setuid, in that order, with a verify) sits in the file, commented, *deliberately uncalled* |
| 4 | `ids=`, `stack=`, `libc=`, `BUF=` disclosed to every client | CWE-200 | without these leaks the shellcode and ret2libc techniques could not compute addresses (ASLR would defeat them) |
The handler is exactly the same `buf[64]`/`read(512)` shape as the other two
labs, so the shared objdump-based discovery pipeline works unchanged.
---
## How to inspect the daemon (learning path)
```
make status # is it running? as which uid? file mode shown
make run-root # or run / run-root-ns
./foowosc -t leak # see the banner and the leaks, send nothing
./foowosc -t demo # junk overflow -> SIGSEGV, logged with RIP/rsp
./foowosc -t shellcode # the interactive root shell
make test-root # full matrix, all techniques, --must-root
```
Crash reporter: on SIGSEGV the daemon logs the faulting address, RIP and RSP.
A `ret` into non-canonical `0x4141…` faults at the `ret` itself (RIP like
`0x4028xx`, `si_addr=(nil)`) — worth knowing before you misread a log line as
a NULL dereference.
---
## Why the shellcode is 23 bytes, not 32
```
31 f6 xor esi, esi ; argv = NULL
31 d2 xor edx, edx ; envp = NULL
48 bf 2f62696e2f736800 movabs rdi, "/bin/sh\0"
57 push rdi
48 89 e7 mov rdi, rsp
6a 3b push 0x3b ; 59 = execve
58 pop rax
0f 05 syscall
```
The SUID lab needs `setreuid(0,0)` before this. This lab does not, for the
reason repeated throughout: **ruid is already 0** because root started the
process. `make verify` proves the bytes in `foowosc.c` are byte-for-byte what
`shellcode.S` assembles to.
---
## Mitigations — what `make hardened` changes
Hardened build (`-fstack-protector-strong -fPIE -pie -z noexecstack`):
| technique | vulnerable `foowosd` | hardened `foowosd_hardened` |
|-----------|----------------------|------------------------------|
| `shellcode` | root shell (executable stack) | SIGSEGV at the canary check / NX |
| `ret2win` / `ret2libc` | root shell | canary aborts `ret` — but note: a *PIE* build also makes those addresses random |
| `demo` | SIGSEGV, logged | SIGSEGV, logged |
`make test-hardened` demonstrates this live. The important observation is not
just that the mitigations killed the techniques — it is that they did **not**
make the daemon "not root". A hardened build that is still *started* as root
is still a root daemon; the mitigation only raises the bar for the attacker.
Least privilege (`drop_privs()`) and memory safety are two different bugs,
and a daemon that does not need root should not have it.
---
## Safety rails (same policy as the SUID lab)
- **Loopback only.** foowosd refuses any bind other than `127.0.0.1` /
`localhost` / `::1` unless you pass `-L`. A root daemon on a real interface
is a *remote* root service. `-L` exists solely to show the guard; do not
use it on anything that matters.
- **State is logged loudly.** Startup prints `ruid/euid` and whether this is
a root process, so you always know which exploit outcome to expect.
- **Verdicts come from exit statuses** in `make test*` (the pty harness's
return code), never from grepping its stdout — greppable output lies.
- **pty must run cooked + ECHO off** or the harness echoes its own command
line and fakes the marker. The harness turns ECHO off and keeps ECHONL on.
- **Cleanup ritual:** `make stop` after every session. If the daemon is
root-owned, `stop` tells you to run `sudo pkill -x foowosd`.
- Never run this on any host you care about. It exists to hand out `uid=0`
shells over the loopback interface.
---
## Exercises
1. Run `make run` (user daemon), then `./foowosc -t shellcode`. Why is the
shell not root? (Check `ids=` in the banner — foowosc tells you before you
even connect.)
2. `make stop && sudo make run-root && make test-root`. Explain, from the
banner line, why all four techniques now yield `uid=0(root)`.
3. In `foowosd.c`, find `drop_privs()` and read *why the order* of
`setgroups → setgid → setuid` matters. Decide where in `main()` it would
belong, and what the lab's exploit surface becomes once it is actually
called.
4. Compute `rip_off` by hand from `objdump -d foowosd`: find `buf`'s
`lea -0xNN(%rbp)` inside `vulnerable_handler`, then `NN + 8`. foowosc does
exactly this; check its math against your own.
5. `make hardened && make test-hardened`. Which technique falls to the canary
and which to NX? Why does hardening not change what `make status` reports
about the *process*?
6. Compare the two labs' shellcodes: 23 bytes here, 32 for foosd. What does
the extra 9 bytes do, and why is it only needed in the SUID case?
7. Read `become_shell()`'s comment about the single read cursor. Recreate the
failure mode mentally: two readers on one socket means the login shell eats
one byte per chunk — "uid=1000…" arrives as "id=1000…". Why can a relay
process never have this bug?
---
## Files
```
foowosd.c the vulnerable root daemon (every line commented)
foowosc.c the exploit (every line commented)
shellcode.S reference assembly for the 23-byte payload
tests/pty_wosuid_test.c the pty harness (marker + strict id-shape checks)
Makefile build / run / run-root / run-root-ns / test /
test-root / verify / hardened / clean …
```
Sibling labs: `../food.c`/`../fooc.c` (user-level baseline, port 2342) and
`../suid/` (SUID-root daemon `foosd`/`foosc`, port 2343). Ports are distinct
on purpose — you can run all three at once and cross-check their banners'
`ids=` lines.