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
= LeftR
= RightF
= ForwardB
= BackwardU
= UpD
= 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
is3
, it prints a success message and callsget_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}