Pathfinder

Eh4x CTFby smothy

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:

bash
$ file pathfinder
pathfinder: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, stripped

Stripped binary. No symbols. Pain.

bash
$ ./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:

  1. Each character must be a valid direction (enabled in the table)
  2. Every move stays inside the 10x10 grid
  3. The maze walls allow each move (bitfield check)
  4. You end at position (9,9)
  5. 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:

ChardxdyEnabledDescription
N-10YESMove North (up)
S+10YESMove South (down)
E0+1YESMove East (right)
W0-1YESMove West (left)
n+10NOFake/disabled
s-10NOFake/disabled
e0-1NOFake/disabled
w0+1NOFake/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:

Directioncheck1 (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:

python
def decode_grid():
    for i in range(100):
        key = ((i * 31 + 17) ^ (i * 8) ^ 0xA5) & 0xFF
        grid[i] = encoded_data[i] ^ key

The 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:

python
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 h

This 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:

python
#!/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:

  1. Right 2 β†’ reach column 2
  2. Down 3 β†’ drop to row 3
  3. Left 2 β†’ back to column 0
  4. Down 6 β†’ all the way to row 9
  5. Right 8 β†’ cruise along the bottom to column 8
  6. Up 2 β†’ pop up to row 7
  7. Right 1 β†’ reach column 9
  8. 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)

ConceptWhat It Teaches
XOR-encrypted data in .init_arrayBinaries can hide data that only exists at runtime. Static analysis of .data/.bss sections shows zeros
Bitmask maze encodingEach 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 selectorEven when multiple maze solutions exist, the hash pins down exactly ONE correct answer
Decoy table entriesLowercase n/s/e/w entries are bait β€” always check the "enabled" flag
RLE in flag generationThe 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. πŸ˜”