Challenge Name: rev_roadmap
Category: Reversing
Difficulty: Medium
Author: Quack
Challenge Description
In Brunnerne Inc.™, every request now flows through our new Edge Roadmap Router. Only stakeholders who follow the approved roadmap reach the internal dashboard.
We’ve attached the router configuration for full transparency (we’re very transparent now). Are you corpo enough to gain clearance?
The “router” is an nginx instance. We are given default.conf and an included roadmap-badges.conf. The whole access check is implemented purely with nginx map directives — no backend, no code. Reaching the location / “Cleared” page (HTTP 200) requires walking a path ($uri) that satisfies the chained maps. The winning path is the flag.
Approach
Step 1 — Understand the pieces
roadmap-badges.conf is a character → hex-byte substitution table (a “badge” per character):
"a" "7f"; "b" "59"; ... "r" "0a"; ... "{" "34"; "}" "3d"; (39 entries)
default.conf ties everything to the request path:
map $uri $route { default ""; "~^/(?<s>.*)$" $s; } # route = path after '/'
map $route $route_len_ok { default 0; "~^.{41}$" 1; } # route must be exactly 41 chars
Then 41 maps extract a single character at each position ($wp_XXXX = route[N]), and 41 more turn each extracted char into its badge byte via the substitution table ($badge_XXXX):
map $route $wp_95f3 { default ""; "~^.{0}(?<c>.)" $c; } # position 0
map $route $wp_6a2e { default ""; "~^.{1}(?<c>.)" $c; } # position 1 ... up to 40
map $wp_95f3 $badge_0f51 { include roadmap-badges.conf; } # badge byte of route[0]
Step 2 — Recognise the state machine
Lines 91–131 are a chain of transition maps of the form:
map "${cp_IN}:${badge_B}" $cp_OUT { default "DETOUR"; "chk_STATE:HH" "chk_NEXT"; }
Read as a finite-state machine: “if the current state is chk_STATE and the badge byte at some position equals HH, move to state chk_NEXT.” Any mismatch yields "DETOUR", which poisons the rest of the chain. The variables thread the state through: $cp_OUT of one map is the ${cp_IN} of exactly one other.
Two special anchors:
- Start (no
cp_IN):map $badge_a8e4 $cp_8a32 { default "DETOUR"; "0a" "chk_f6ca"; }— the machine begins by requiringbadge_a8e4 == "0a"(route[18] == 'r') and entering statechk_f6ca. - Accept:
... "chk_3a12:0a" "CLEARED"produces$cp_199f, and finally:
map $cp_199f $reached_cleared { default 0; "CLEARED" 1; }
map "$reached_cleared$route_len_ok" $access { default 0; "11" 1; }
So $access == 1 requires both reaching CLEARED and a 41-char route.
Step 3 — Walk the chain and recover the path
The transitions form one linear thread of 41 steps (start + 40). Following it — each step fixes one (position, required byte) pair, and the byte is reversed through the badge table to a character — reconstructs the whole 41-character route:
import re
char2hex = {} # badge table
for l in open("roadmap-badges.conf"):
m = re.match(r'\s*"(.+?)"\s+"([0-9a-f]{2})"', l)
if m: char2hex[m.group(1)] = m.group(2)
hex2char = {v: k for k, v in char2hex.items()}
conf = open("default.conf").read()
wp_pos = {m[0]: int(m[1]) for m in re.findall(r'\$(wp_\w+) \{ default ""; "~\^\.\{(\d+)\}', conf)}
badge_wp = dict(re.findall(r'map \$(wp_\w+) \$(badge_\w+) \{ include', conf))
badge_pos = {b: wp_pos[w] for w, b in badge_wp.items()}
start, by_cin = None, {}
for line in conf.splitlines():
m = re.match(r'.*map "\$\{(cp_\w+)\}:\$\{(badge_\w+)\}" \$(cp_\w+).*"(chk_\w+):([0-9a-f]{2})" "(chk_\w+|CLEARED)"', line)
if m: by_cin[m[1]] = m.groups() # cin, badge, cout, state, byte, next
m = re.match(r'.*map \$(badge_\w+) \$(cp_\w+).*"([0-9a-f]{2})" "(chk_\w+)"', line)
if m: start = m.groups() # badge, cout, byte, next
sol = {}
sol[badge_pos[start[0]]] = hex2char[start[2]]
cur_out, cur_state = start[1], start[3]
while True:
cin, badge, cout, state, byte, nxt = by_cin[cur_out]
sol[badge_pos[badge]] = hex2char[byte]
if nxt == "CLEARED": break
cur_out, cur_state = cout, nxt
print("".join(sol[i] for i in range(41)))
The walk fills all 41 positions with no conflicts and terminates at CLEARED.
Step 4 — Verify against the real router
Build the container and request the recovered path. Gotcha: curl treats { and } as URL-globbing metacharacters, so -g (globbing off) is required or the braces are mangled into a 403.
docker build -t roadmap:local .
docker run -d --name rm -p 3111:80 roadmap:local
curl -g "http://127.0.0.1:3111/brunner{c0rp0r4t3_r04dm4p_t0_ng1nx_h34rt}"
# -> HTTP 200: "Access cleared, stakeholder."
A one-character mutation of the path returns HTTP 403 (“Detour”), confirming the check is exact.
Flag
brunner{c0rp0r4t3_r04dm4p_t0_ng1nx_h34rt}
Reflections and Learnings
- Config is code: the entire access check was nginx
mapdirectives with no backend at all. Treating declarative config as a program — here, a finite-state machine threaded through variables — is the whole unlock. - Find the anchors, then walk the thread: the start map (no
cp_IN) and theCLEAREDaccept map bracket a single linear chain of 41 transitions. Each step fixes one(position, byte)pair, reversed through the badge table to a character — no guessing once you have both ends. - Two conditions, not one: acceptance required reaching
CLEAREDand a 41-char route; miss the length guard in the final$accessmap and you’d chase a phantom path. Read the accept condition fully. - Mind the client as well as the server:
curlglob-expands{and}into a 403, so-gwas mandatory — a gotcha in the tooling, not the target. The one-character mutation returning 403 then proved the check was exact.