Inferno Sprint - EH4X CTF 2026
Category: Miscellaneous | Points: 500 | Solves: 0 (First Blood baby š©ø)
Challenge Description
The labyrinth burns. You have 90 seconds. Survive 5 rounds of increasingly brutal fire mazes. Dodge multi-speed fires, use portals, and escape to any edge ā before the flames consume you.
nc 20.244.7.184 31337
First Look ā "How bad can it be?"
So I connect to the server and...
nc 20.244.7.184 31337
============================================================
INFERNO SPRINT | EHAX CTF
============================================================
Survive 5 rounds of burning mazes to claim the flag.
5 rounds. 90 seconds. Fire mazes. Portals.
Me reading the rules:

No way I'm solving this by hand.
Yeah this is NOT a "solve by hand" challenge. 5 rounds, increasing grid sizes (15x15 up to 35x35), fire spreading everywhere, 90 second total timer. This screams scripted solver.
Understanding the Protocol
When you connect, the server dumps the rules then gives you rounds one by one. Each round looks like:
ROUND 1/5
SIZE 15 15
START 6 5
LIMIT 8
2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e
2e2e312e2e2e2e2e2e232e232e2e2e
...
PATH>
Key things I noticed:
| Info | Meaning |
|---|---|
SIZE R C | Grid dimensions (rows x cols) |
START R C | Player position (0-indexed) |
LIMIT N | Max moves allowed (near-optimal) |
| Hex rows | Each row is hex-encoded ASCII |
PATH> | Where you send your answer |
The Grid Legend
After hex-decoding each row:
| Symbol | Meaning |
|---|---|
M | You are here |
. | Empty cell (safe... for now) |
# | Wall (can't pass) |
1 | Fire ā spreads 1 cell EVERY turn (fast and angry) |
2 | Fire ā spreads 1 cell every 2 turns (medium) |
3 | Fire ā spreads 1 cell every 3 turns (slow but still scary) |
a-e | Portal pairs (step on one + send P = teleport to its twin) |
Movement
Send one line of moves: W (up), S (down), A (left), D (right), P (portal).
Goal: reach ANY edge cell before fire catches you.
Hex Decoding ā "Wait, 2e means what?"
Every grid row comes hex-encoded. Two hex chars = one ASCII character.
2e = '.' (0x2e = 46 = ASCII period)
23 = '#' (0x23 = 35 = ASCII hash)
4d = 'M' (0x4d = 77 = ASCII 'M')
31 = '1' (0x31 = 49 = ASCII '1')
32 = '2'
33 = '3'
61 = 'a' (portal)
62 = 'b' (portal)
63 = 'c' (portal)
So 2e2e312e2e decodes to ..1.. ā two empty cells, a speed-1 fire, two more empty cells.
Quick python one-liner:
row = bytes.fromhex("2e2e312e2e").decode('ascii')
# Result: '..1..'The Algorithm ā BFS go brrrr
This is a classic time-expanded BFS (Breadth-First Search) problem. Here's the game plan:
Step 1: Fire Arrival Map
Before solving the player's path, we need to know when each cell catches fire.
Think of it like this ā every fire source is a BFS root. Fire with speed K takes K turns to spread 1 cell. Multiple fires race each other, and the earliest arrival wins.
Fire speed 1: turn 0 ā turn 1 ā turn 2 (spreads every turn)
Fire speed 2: turn 0 ā turn 2 ā turn 4 (spreads every 2 turns)
Fire speed 3: turn 0 ā turn 3 ā turn 6 (spreads every 3 turns)
We run a multi-source BFS from ALL fire cells simultaneously:
def compute_fire_arrival(grid, rows, cols):
fire_arrival = [[inf] * cols for _ in range(rows)]
queue = deque()
# Seed all fire sources
for r in range(rows):
for c in range(cols):
if grid[r][c] in '123':
speed = int(grid[r][c])
fire_arrival[r][c] = 0
queue.append((r, c, 0, speed))
while queue:
r, c, turn, speed = queue.popleft()
next_turn = turn + speed # KEY: fire spreads 1 cell every 'speed' turns
for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
nr, nc = r + dr, c + dc
if in_bounds(nr, nc) and grid[nr][nc] != '#':
if next_turn < fire_arrival[nr][nc]:
fire_arrival[nr][nc] = next_turn
queue.append((nr, nc, next_turn, speed))
return fire_arrivalThis gives us a 2D array where fire_arrival[r][c] = the earliest turn that cell is burning.
Visual example (Round 1 decoded grid):
............... Fire arrival times (partial):
...............
......##1...... The '1' at (2,8) spreads every turn:
........#...#.. turn 1: neighbors burn
.........#.1... turn 2: next ring burns
............... etc.
.....M......#..
............... Player at (6,5) just needs to go
............... left 5 times: AAAAA ā reaches col 0 (edge!)
........#.#.1..
.......#.1.....
..#...1..11.1..
....1..........
...............
...............
Step 2: Player BFS (Time-Aware)
Now we BFS from the player. The state is (row, col, turn).
A move is valid if:
- The destination cell is not a wall
- The destination cell is NOT on fire at
turn + 1(when we arrive) - We haven't exceeded the move limit
def solve_maze(grid, rows, cols, start_r, start_c, limit, fire_arrival, portals):
queue = deque([(start_r, start_c, 0, "")])
visited = {(start_r, start_c, 0)}
while queue:
r, c, turn, path = queue.popleft()
if turn >= limit:
continue
for move, (dr, dc) in {'W':(-1,0),'S':(1,0),'A':(0,-1),'D':(0,1)}.items():
nr, nc = r + dr, c + dc
if valid(nr, nc) and grid[nr][nc] != '#' and fire_arrival[nr][nc] > turn + 1:
if (nr, nc, turn+1) not in visited:
visited.add((nr, nc, turn+1))
new_path = path + move
if is_edge(nr, nc):
return new_path # ESCAPED!
queue.append((nr, nc, turn+1, new_path))
# Portal check
if grid[r][c] in portals:
# teleport to paired cell, costs 1 turn
...
return None # ripStep 3: Portals
Portals are letter pairs (a-e). Standing on one and sending P teleports you to the other one (same letter). Costs 1 turn. This is just another "neighbor" in our BFS ā instead of moving to an adjacent cell, you jump to the paired portal cell.
Portal 'a' at (9,14) <āāāāāā> Portal 'a' at (9,24)
send 'P' to teleport between them
Watching It Solve ā the satisfying part
--- Round 1 --- Size: 15x15 Path: AAAAA (5 moves) OK!
--- Round 2 --- Size: 18x18 Path: WWWDWWW (7 moves) OK!
--- Round 3 --- Size: 22x22 Path: WDDDDDDDDD (10 moves) OK!
--- Round 4 --- Size: 28x28 Path: WWWWWWWWW (9 moves) OK!
--- Round 5 --- Size: 35x35 Path: AWAAWAAAAAAAA (13 moves) OK!
============================================================
ALL ROUNDS CLEARED! FLAG: EH4X{1nf3rn0_spr1n7_bl4z3_runn3r_m4573r}
============================================================
First blood on a 500pt misc. Feels good.

Key Takeaways (Educational Bits)
1. BFS is your best friend for shortest path
BFS guarantees the shortest path in an unweighted graph. Since every move costs 1 turn, BFS finds the optimal escape route. If the graph had weighted edges, you'd use Dijkstra instead.
When to use what:
BFS ā unweighted shortest path (this challenge)
Dijkstra ā weighted shortest path
A* ā weighted + heuristic (faster in practice)
DFS ā when you just need ANY path, not shortest
2. Time-expanded graphs
The trick here is that the graph changes over time (fire spreads). So instead of just tracking (row, col) as visited, we track (row, col, turn). This is called a time-expanded graph ā basically treating each timestep as a separate "layer" of the graph.
Turn 0: [safe] [safe] [safe] Turn 1: [safe] [FIRE] [safe]
Turn 2: [FIRE] [FIRE] [FIRE] ... fire keeps spreading
The player exists in this 3D space of (x, y, time).
3. Multi-source BFS for fire
Instead of running BFS from each fire source separately, we seed ALL fire sources into the queue at once with turn=0. This gives us the earliest arrival time at every cell in a single pass. Very efficient ā O(rows * cols) total.
4. Precompute, then solve
The pattern here is super common in competitive programming / CTF:
- Precompute the "danger map" (fire arrival times)
- Query against it during path search
This separates concerns and keeps the solver clean.
5. Don't forget edge cases (literally)
The escape condition is reaching ANY edge cell ā row 0, last row, col 0, or last col. Easy to forget one of these when coding in a hurry.
Full Solve Script
The complete solver is in solve.py ā it handles all 5 rounds automatically over a socket connection. The core logic is ~100 lines of BFS.
Flag
EH4X{1nf3rn0_spr1n7_bl4z3_runn3r_m4573r}
translates to: inferno sprint blaze runner master
GG. The inferno didn't wait, but we were faster.
writeup by smothy | EH4X CTF 2026