Pathfinder β EH4X CTF 2026 | Reverse Engineering | 500 pts
"You can go funky ways"
bro this challenge had me lost in a maze... literally. π
First Look β What Even Is This?
Downloaded the binary. Classic first move:
$ file pathfinder
pathfinder: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, strippedStripped binary. No symbols. Pain.
$ ./pathfinder
Are you a pathfinder?
[y/n]: y
Ok, tell me the best path: idk
Better luck next time.So it wants a "path". The challenge name is "Pathfinder". The description says "funky ways". Sounds like a maze. Let's find out what makes the path "best".
me: *types random stuff*
binary: "Better luck next time."
me: understandable have a great day
Reversing the Binary β Into the Rabbit Hole
Threw it into radare2 (or your decompiler of choice β Ghidra, IDA, Binary Ninja, whatever you vibe with).
The Main Flow
main():
1. Print "Are you a pathfinder?"
2. Read 1 byte β check 'y' or 'n'
3. If 'y' β read up to 64 bytes for the path
4. Call validate_path(path) β the important one
5. If valid β generate_flag(path, buf), print flag
6. If invalid β "Better luck next time."
So we need to figure out what validate_path() considers a valid path.
The Validation Function (fcn.00001444)
This is where the magic happens. Here's the logic broken down:
validate_path(path):
x = 0, y = 0 // start position
for each char in path:
entry = direction_table[char] // 12-byte entries
if entry.enabled == 0:
return FAIL
// compute move validation masks
check1 = (char * 0x6B) XOR entry.byte8 XOR 0x3C
check2 = (char * 0x6B) XOR entry.byte9 XOR 0x3C
new_x = x + entry.dx
new_y = y + entry.dy
// bounds check: must stay in 0..9
if new_x > 9 or new_y > 9: // unsigned, catches negatives too
return FAIL
// maze wall check
if (grid[x][y] & check1) | (grid[new_x][new_y] & check2) == 0:
return FAIL
x, y = new_x, new_y
// must end at bottom-right corner
if x != 9 or y != 9:
return FAIL
// path must have specific hash
if custom_hash(path) != 0x86BA520C:
return FAIL
return SUCCESS
So there are 4 conditions for a valid path:
- Each character must be a valid direction (enabled in the table)
- Every move stays inside the 10x10 grid
- The maze walls allow each move (bitfield check)
- You end at position (9,9)
- The entire path string hashes to a specific value
bro really said "not only find the maze path, but it also has to hash correctly" π
Extracting the Data β CSI: Binary Edition
The Direction Table
Found in the .init_array constructor (entry.init2). The table at 0x4120 maps ASCII chars to movement data:
| Char | dx | dy | Enabled | Description |
|---|---|---|---|---|
N | -1 | 0 | YES | Move North (up) |
S | +1 | 0 | YES | Move South (down) |
E | 0 | +1 | YES | Move East (right) |
W | 0 | -1 | YES | Move West (left) |
n | +1 | 0 | NO | Fake/disabled |
s | -1 | 0 | NO | Fake/disabled |
e | 0 | -1 | NO | Fake/disabled |
w | 0 | +1 | NO | Fake/disabled |
The lowercase versions are decoys β they're in the table but disabled. Classic bait. π£
The Move Validation Masks
For each direction, the XOR math produces bitmasks that check specific bits of the grid cells:
| Direction | check1 (old cell) | check2 (new cell) | Meaning |
|---|---|---|---|
| N (up) | 0x04 (bit 2) | 0x01 (bit 0) | Old has N-opening OR new has S-opening |
| S (down) | 0x01 (bit 0) | 0x04 (bit 2) | Old has S-opening OR new has N-opening |
| E (right) | 0x02 (bit 1) | 0x08 (bit 3) | Old has E-opening OR new has W-opening |
| W (left) | 0x08 (bit 3) | 0x02 (bit 1) | Old has W-opening OR new has E-opening |
So each cell in the grid stores a 4-bit bitmask:
Bit 0 (0x01) = South opening
Bit 1 (0x02) = East opening
Bit 2 (0x04) = North opening
Bit 3 (0x08) = West opening
This is literally a maze encoded as wall bitmasks per cell. Classic CS data structure!
If you've done maze generation algorithms before (Prim's, Kruskal's, recursive backtracker), this encoding will feel very familiar. Each cell stores which walls are open.
The Grid β XOR-Encrypted Maze
The grid lives at 0x40a0 but it's all zeros in the binary. Another .init_array function (entry.init1) decodes it at startup:
def decode_grid():
for i in range(100):
key = ((i * 31 + 17) ^ (i * 8) ^ 0xA5) & 0xFF
grid[i] = encoded_data[i] ^ keyThe encoded data sits at 0x2020 in the .rodata section. After decoding:
Col: 0 1 2 3 4 5 6 7 8 9
Row 0: 08 0a 0c 00 00 00 00 00 00 00
Row 1: 00 00 05 00 00 08 0a 0a 0c 00
Row 2: 00 00 03 00 00 05 00 00 05 00
Row 3: 08 0a 0a 0a 0a 01 00 00 05 00
Row 4: 05 00 00 00 00 00 08 0a 01 00
Row 5: 05 00 0c 0a 0c 00 05 00 00 00
Row 6: 05 00 05 00 05 00 05 00 00 00
Row 7: 05 00 01 00 03 0a 09 00 08 0c
Row 8: 05 00 00 00 00 00 00 00 05 05
Row 9: 03 0a 0a 0a 0a 0a 0a 0a 01 03
Visualizing the Maze
Converting the bitmasks to actual walls (0x00 = solid wall, nonzero = passable):
0 1 2 3 4 5 6 7 8 9
+---+---+---+---+---+---+---+---+---+---+
0 | S β β | . | . | . | . | . | . | . |
+---+---+ +---+---+---+---+---+---+---+
1 | . | . | β | . | . | S β β β | . |
+---+---+ +---+---+ +---+---+ +---+
2 | . | . | β | . | . | β | . | . | β | . |
+---+---+ +---+---+ +---+---+ +---+
3 | S β β β β β | . | . | β | . |
+ +---+---+---+---+---+---+---+ +---+
4 | β | . | . | . | . | . | S β β | . |
+ +---+---+---+---+---+ +---+---+---+
5 | β | . | β β β | . | β | . | . | . |
+ +---+ +---+ +---+ +---+---+---+
6 | β | . | β | . | β | . | β | . | . | . |
+ +---+ +---+ +---+ +---+---+---+
7 | β | . | β | . | β β β | . | S β |
+ +---+---+---+---+---+---+---+ + +
8 | β | . | . | . | . | . | . | . | β | β |
+ +---+---+---+---+---+---+---+ + +
9 | β β β β β β β β β β |
+---+---+---+---+---+---+---+---+---+---+
START = (0,0) top-left
END = (9,9) bottom-right
Now THAT'S a maze. The challenge author literally embedded a full maze in the binary with XOR encryption. Respect. π«‘
The Hash Function β One More Lock on the Door
Even if you find a valid path through the maze, the path string itself must hash to 0x86BA520C:
def custom_hash(path):
h = 0xDEADBEEF # lol classic
for c in path:
h ^= ord(c)
h = rotate_left(h, 13) # ROL 13
h = (h * 0x045D9F3B) & 0xFFFFFFFF
h ^= (h >> 16) # avalanche
h = (h * 0x85EBCA6B) & 0xFFFFFFFF
h ^= (h >> 13)
return hThis is a variant of MurmurHash-style mixing. The 0xDEADBEEF seed is a dead giveaway that the author has a sense of humor.
Since the maze is constrained enough, there likely aren't many valid paths from (0,0) to (9,9). We just need to find the one with the matching hash.
Solving It β DFS Go Brrrrr
Time to write a solver:
#!/usr/bin/env python3
# ... (grid decoding and direction table setup) ...
def is_valid_move(char, cur_x, cur_y):
"""Check if moving in direction 'char' from (cur_x, cur_y) is valid"""
dx, dy, b8, b9, enabled = directions[char]
if not enabled:
return False, 0, 0
c = ord(char)
check1 = ((c * 0x6b) ^ b8 ^ 0x3c) & 0xFF
check2 = ((c * 0x6b) ^ b9 ^ 0x3c) & 0xFF
new_x, new_y = cur_x + dx, cur_y + dy
if not (0 <= new_x <= 9 and 0 <= new_y <= 9):
return False, 0, 0
old_cell = grid[cur_x * 10 + cur_y]
new_cell = grid[new_x * 10 + new_y]
if (old_cell & check1) | (new_cell & check2) == 0:
return False, 0, 0
return True, new_x, new_y
def dfs(x, y, path, visited):
"""DFS to find path from (x,y) to (9,9) with correct hash"""
if x == 9 and y == 9:
if path_hash(path) == 0x86BA520C:
print(f"FOUND IT: '{path}'")
return path
return None
if len(path) > 50: # sanity limit
return None
for char in ['N', 'S', 'E', 'W']:
valid, nx, ny = is_valid_move(char, x, y)
if valid and (nx, ny) not in visited:
visited.add((nx, ny))
result = dfs(nx, ny, path + char, visited)
if result:
return result
visited.remove((nx, ny))
return None
# GO!
solution = dfs(0, 0, "", {(0, 0)})$ python3 solve.py
FOUND: 'EESSSWWSSSSSSEEEEEEEENNESS' (hash=0x86ba520c)
One unique path, one unique hash. Chef's kiss. π¨βπ³
The Path Visualized
0 1 2 3 4 5 6 7 8 9
+---+---+---+---+---+---+---+---+---+---+
0 | β
β β . | . | . | . | . | . | . | . |
+---+---+ β +---+---+---+---+---+---+---+
1 | . | . | β | . | . | | | β | . |
+---+---+ β +---+---+ +---+---+ +---+
2 | . | . | β | . | . | | . | . | | . |
+---+---+ β +---+---+ +---+---+ +---+
3 | β β β | | . | . | | . |
+ β +---+---+---+---+---+---+---+ +---+
4 | β | . | . | . | . | . | | | . |
+ β +---+---+---+---+---+ +---+---+---+
5 | β | . | | β | . | | . | . | . |
+ β +---+ +---+ +---+ +---+---+---+
6 | β | . | | . | | . | | . | . | . |
+ β +---+ +---+ +---+ +---+---+---+
7 | β | . | | . | | | . | β β β |
+ β +---+---+---+---+---+---+---+ + β +
8 | β | . | . | . | . | . | . | . | β | β |
+ β +---+---+---+---+---+---+---+ β + β +
9 | β β β β β β β β β β β β β β β β | β |
+---+---+---+---+---+---+---+---+---+---+
Path: E E S S S W W S S S S S S E E E E E E E E N N E S S
ββ βββ ββ ββββββ ββββββββ ββ β ββ
The path goes:
- Right 2 β reach column 2
- Down 3 β drop to row 3
- Left 2 β back to column 0
- Down 6 β all the way to row 9
- Right 8 β cruise along the bottom to column 8
- Up 2 β pop up to row 7
- Right 1 β reach column 9
- Down 2 β arrive at (9,9) β
The Flag Generation β RLE Encoding
The generate_flag() function does Run-Length Encoding (RLE) on the path:
Input: EESSSWWSSSSSSEEEEEEEENNESS
RLE: 2E 3S 2W 6S 8E 2N E 2S
(single chars written as-is, runs as count+char)
Wrapped in EHAX{...}:
EHAX{2E3S2W6S8E2NE2S}
Flag
EHAX{2E3S2W6S8E2NE2S}
What I Learned (TL;DR for fellow CTF grinders)
| Concept | What It Teaches |
|---|---|
XOR-encrypted data in .init_array | Binaries can hide data that only exists at runtime. Static analysis of .data/.bss sections shows zeros |
| Bitmask maze encoding | Each cell uses 4 bits for N/S/E/W walls β compact and elegant maze representation |
Mixed raw read() + getchar() | Mixing buffered (FILE*) and unbuffered (fd) I/O can cause subtle bugs when piping input |
| Custom hash as path selector | Even when multiple maze solutions exist, the hash pins down exactly ONE correct answer |
| Decoy table entries | Lowercase n/s/e/w entries are bait β always check the "enabled" flag |
| RLE in flag generation | The flag itself encodes the solution path β cool design where the answer IS the flag |
Tools Used
- radare2 β disassembly and function analysis
- GDB β runtime debugging (dumping decoded grid, tracing validation)
- Python 3 β solver script (grid decoding + DFS pathfinding)
"Are you a pathfinder?" β yes, yes I am now. πΊοΈ
Writeup by a sleep-deprived CTF player who spent too long typing the wrong number of S's into a pipe. Don't be like me. Copy-paste your payloads. π