2026-09-29 09:39:24 +02:00
# 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 |
|---|-----|-----|------|
2026-09-29 10:04:38 +02:00
| 1 | `read(fd, buf, 512)` into a 64-byte stack buffer | [CWE-120 ](https://cwe.mitre.org/data/definitions/120.html ) | the overflow: 448 bytes past `buf` , saved RIP at +88 |
| 2 | `snprintf(line, …, "%.*s", …)` only; but attacker `%` in the echo path | [CWE-134 ](https://cwe.mitre.org/data/definitions/134.html ) | 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 ](https://cwe.mitre.org/data/definitions/271.html ) | 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 ](https://cwe.mitre.org/data/definitions/200.html ) | without these leaks the shellcode and ret2libc techniques could not compute addresses (ASLR would defeat them) |
2026-09-29 09:39:24 +02:00
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'
2026-09-29 10:04:38 +02:00
`ids=` lines.