HTB TunnelMadness writeup - Pez1181/CTF GitHub Wiki

🏷️ Challenge: Tunnel
📦 Category: Reversing / Automation
🧠 Skill Level: Beginner-Friendly
🛡️ Protections: Partial RELRO, NX, PIE, No Canary


📦 Intro

You're dropped into a mysterious binary that prints a prompt like this:

Direction (L/R/F/B/U/D/Q)?

You're expected to navigate through some kind of 3D maze, one step at a time, by entering movement commands:

  • L = Left
  • R = Right
  • F = Forward
  • B = Backward
  • U = Up
  • D = Down

You can solve this manually, trial-and-error style... but where’s the fun in that? 😎
Let’s reverse the maze system, write a pathfinding algorithm, and automate our way to victory.


🔍 Recon

$ file tunnel
ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped

$ checksec tunnel
RELRO           Partial RELRO
Stack Canary    No canary found
NX              NX enabled
PIE             PIE enabled

Good news: the binary is not stripped, so we can see function names when reversing.
Strings also give us clues:

Direction (L/R/F/B/U/D/Q)?
get_cell
prompt_and_update_pos
get_flag
maze

🔎 Reverse Engineering

We open the binary in Ghidra and start with main():

  • It initializes position at (0, 0, 0)
  • On each loop, it calls get_cell() to check the current position
  • If the cell’s value at offset +0xC is 3, it prints a success message and calls get_flag()
  • Otherwise, it calls prompt_and_update_pos() to move the player

So the goal is to reach a cell where cell[0xC] == 3.

What's the Maze?

Looking at the symbol maze, we find it’s a large static array.
By calculating the dimensions in Ghidra, we see it's structured as a 20 x 20 x 20 cube, with each cell taking 16 bytes.

The structure of each cell seems to be:

  • Offset +0xC (last 4 bytes) = cell type:
    • 2 = Wall (cannot walk here)
    • 3 = Goal (win condition)
    • Anything else = Walkable

So we:

  • Read the maze data directly from the binary
  • Parse it into a 3D array
  • Perform BFS (Breadth-First Search) to find the shortest path
  • Send movement commands to the binary to walk that path

🧠 Movement Logic

We reverse prompt_and_update_pos() and determine how each input affects position:

Input Affects Axis Delta
L x -1
F y +1
D z -1
B y -1
U z +1
R x +1

This may feel counterintuitive, but makes sense once you look at the raw offset math.
These are the real movement directions used in our script.


🧪 Exploit Script

This script:

  • Parses the maze from the binary
  • Finds the goal using BFS
  • Prints the movement path
  • Sends each movement to the binary to reach the goal automatically
from pwn import *
import numpy as np
import struct
from collections import deque

# Set up pwntools with target binary
context.binary = './tunnel'
elf = ELF(context.binary.path)

# === Remote Toggle ===
HOST = "94.237.62.203"
PORT = 48375

# Toggle these:
# For local testing:
# p = process('./tunnel', stdin=PTY)

# For remote challenge server:
p = remote(HOST, PORT)

# === Maze Setup ===
# The maze is a 20x20x20 cube where each cell is 16 bytes
maze_addr = elf.symbols['maze']
maze_size = 20 * 20 * 20 * 16  # Total bytes in the maze

# Read the binary's data section to extract the maze
with open(context.binary.path, "rb") as f:
    f.seek(maze_addr)
    raw = f.read(maze_size)

# Parse the last 4 bytes of each 16-byte cell (offset 0xC) as the cell type
maze = np.zeros((20, 20, 20), dtype=np.uint8)
for x in range(20):
    for y in range(20):
        for z in range(20):
            idx = ((x * 400) + (y * 20) + z) * 16
            maze[x, y, z] = struct.unpack_from("<I", raw, idx + 0xC)[0]

# Start at position (0,0,0), find the goal (where cell == 3)
start = (0, 0, 0)
goal = tuple(np.argwhere(maze == 3)[0])  # Find first matching cell

# === Movement Directions ===
# These are the directions understood by the binary, and how they change position
directions = [
    ("L", -1, 0, 0),  # Move along -X axis
    ("F", 0, 1, 0),   # Move along +Y axis
    ("D", 0, 0, -1),  # Move along -Z axis
    ("B", 0, -1, 0),  # Move along -Y axis
    ("U", 0, 0, 1),   # Move along +Z axis
    ("R", 1, 0, 0),   # Move along +X axis
]

# === BFS Pathfinding ===
# Standard BFS to find the shortest path from start to goal
visited = set()
queue = deque([(start, [])])
path = None

while queue:
    (x, y, z), trail = queue.popleft()
    if (x, y, z) == goal:
        path = trail
        break
    for label, dx, dy, dz in directions:
        nx, ny, nz = x + dx, y + dy, z + dz
        if 0 <= nx < 20 and 0 <= ny < 20 and 0 <= nz < 20:
            if maze[nx, ny, nz] != 2 and (nx, ny, nz) not in visited:
                visited.add((nx, ny, nz))
                queue.append(((nx, ny, nz), trail + [label]))

# === Output and Send Path ===
if not path:
    print("[!] No path found.")
else:
    direction_sequence = ''.join(path)
    print("[+] Correct input sequence:")
    print(direction_sequence)

    # Send each movement to the binary
    for c in direction_sequence:
        p.sendlineafter(b"Direction (L/R/F/B/U/D/Q)? ", c.encode())

    # Drop into interactive mode to view final output and flag
    p.interactive()

🏁 Final Output

When successful, the binary prints:

You break into the vault and read the secrets within...
HTB{tunn3l_sn4k3_0f_d00m}

✅ Mission complete. We didn’t just walk through a maze — we reverse engineered the matrix and out-sprinted the recursion.