The Four Directions: Search Without Borders
By Team Cryptonite · 7 min read
About the CTF: Pinaka CTF
Reverse four custom-format binary region maps, infer the routing metric, reconstruct lost border crossings from a damaged ledger, stitch all four regions, and trace the unique route. Validator:…
**Pinaka CTF 2026** · Forensics · Team Cryptonite · All 44 Pinaka write-ups
Challenge
> Reverse four custom-format binary region maps, infer the routing metric, reconstruct lost border crossings from a damaged ledger, stitch all four regions, and trace the unique route.
> Validator: https://pinaka-k04-cyber-xo-x.vercel.app
We're given four region maps (regionuttara.map, regionpurva.map, regionpaschima.map, regiondakshina.map), a border.ledger, a small python script dik_engine.py and a README.md.
The maps are each around 8 MB, so this was never going to be done by eye. The README says the maps are intact and only the ledger is damaged, and it gives the JSON the validator wants. That JSON is a lot more than just the route: two digests, edge counts and a "metric" object with field names in it. That last part ended up being the most annoying bit of the whole challenge.
Recon
As usual, I started with xxd:
xxd region_uttara.map | head -3
00000000: 504e 4b44 4953 4841 0100 0000 8504 0100 PNKDISHA........
00000010: 091b 0600 0700 0000 f3d1 91de e0ad 578b ..............W.
00000020: b3f9 c8d3 0000 ffff ffff 0100 ffff ffff ................
xxd border.ledger | head -8
00000000: 504e 4b42 5244 5231 0100 0400 0300 0000 PNKBRDR1........
00000010: 0000 0000 0300 8802 0100 26cc cbdc 0000 ..........&.....
00000020: 4700 0000 0000 0000 bf03 0000 0000 0000 G...............
00000030: 541f a28a 0100 7c00 0000 0000 0000 4c08 T.....|.......L.
00000040: 0000 0000 0000 b880 d0bd 0200 7c00 0000 ............|...
00000050: 0000 0000 1a08 0000 0000 0000 9ba7 1fc4 ................
00000060: 0300 4700 0000 0000 0000 8d03 0000 0000 ..G.............
00000070: 0000 3696 cca7 4245 4447 1c00 2200 0000 ..6...BEDG.."...
The maps start with PNKDISHA and the ledger with PNKBRDR1, and you can already see BEDG near the end of the ledger dump. I was half expecting to have to guess the map format, but dik_engine.py just parses it, so that part was free.
The map format
Reading the engine gives you everything:
header <8sHHIIIIII magic, ver, region_id, node_count, edge_count, port_count, crc(nodes), crc(edges), crc(header) = 36 bytes
node <Hi depth, port_key (-1 if it's not a border port) = 6 bytes
edge <IIIII src, dst, f0, f1, f2 = 20 bytes
region 0 uttara clearings=66693 trails=400137 ports=7
region 1 purva clearings=65350 trails=392079 ports=14
region 2 paschima clearings=66431 trails=398565 ports=14
region 3 dakshina clearings=66185 trails=380670 ports=7
Every edge goes from a lower depth to a higher one (depth runs 0 to 21 in every region), so each region is a DAG. That's nice, because it means a simple DP will do and there's no need for Dijkstra.
The port_key field is how the regions link up. Uttara has 7 exit ports at depth 21 with keys 0-6. Purva has 7 entry ports at depth 0 with keys 0-6 and 7 exit ports with keys 1000-1006. Paschima goes 1000-1006 in and 2000-2006 out, and Dakshina has 2000-2006 in. So it's North -> East -> West -> South, with 7 crossings per border and 21 in total.
The ports, pulled out of the node blocks (local id, depth, port key):
uttara exits (66686..66692, depth 21, keys 0..6)
purva entries (0..6, depth 0, keys 0..6) exits (65343..65349, depth 21, keys 1000..1006)
paschima entries (0..6, depth 0, keys 1000..1006) exits (66424..66430, depth 21, keys 2000..2006)
dakshina entries (0..6, depth 0, keys 2000..2006)
Figuring out the metric
The README is pretty insistent that the toll isn't plain shortest path and isn't the first number in a trail record. That's obvious once you look at _toll() in the engine:
def _toll(self, f0, f1, f2):
a, b, c = f0, f1, f2
return (b * _K) + c # _K = 0x3B9ACA00 = 10^9
So toll = f1 * 10^9 + f2. f1 is the "peril" and it dominates everything, f2 is the length and only breaks ties, and f0 isn't used at all (it's a decoy). I didn't want to trust the source completely, so I probed the engine on a couple of single edges to check:
python3 dik_engine.py region_uttara.map 0 5082
loaded region 0: 66693 clearings, 400137 trails
{'path': [0, 5082], 'toll': 4000000182}
That edge is (0, 5082, f0=445, f1=4, f2=182), and 4*10^9 + 182 matches. One more to be sure, edge (0, 1619, 990, 11, 105):
python3 dik_engine.py region_uttara.map 0 1619
loaded region 0: 66693 clearings, 400137 trails
{'path': [0, 1619], 'toll': 11000000105}
11.10^9 + 105, and the 990 doesn't show up anywhere. That's settled.
The ledger
The header is 30 bytes: magic, ver, 4 regions, 3 borders, then source = region 0 clearing 0, target = region 3 clearing 66184, and a CRC32 over the first 26 bytes.
After that come 4 small entries (u16 region, u64, u64, crc32). For a while I didn't know what they were. They turned out to be totals over each region's crossings: region 0 has peril 71 and length 959, which is exactly the sum over the 7 uttara->purva crossings. This came in handy later.
Then there's a stream of BEDG records with junk between them where the ledger got overwritten, so you have to scan for the magic instead of walking at fixed offsets:
"BEDG" u16 len=28 | u32 idx | u16 src_region | u32 src_port | u16 dst_region | u32 dst_port | u32 a | u32 b | u32 c | u32 crc32(first 34 bytes)
I wrote a quick dumper that walks the BEDG records and checks each CRC:
header: ver=1 regions=4 borders=3 source=0:0 target=3:66184 crc_ok=True
seam region 0: peril_sum=71 length_sum=959 crc_ok=True
seam region 1: peril_sum=124 length_sum=2124 crc_ok=True
seam region 2: peril_sum=124 length_sum=2074 crc_ok=True
seam region 3: peril_sum=71 length_sum=909 crc_ok=True
off idx from -> to peril length cost crc
0x76 34 3:2002 -> 2:2002 9 196 83 ok
0x9c 15 1:1000 -> 2:1000 1509949440 63 5397 TORN
0xc2 37 2:2004 -> 3:2004 7 116 3 ok
0x109 14 1:6 -> 0:5898246 7 198 554 TORN
0x144 30 3:2000 -> 2:2000 0 65 6197 ok
0x178 13 0:6 -> 1:6 7 198 554 ok
0x1b9 6 1:2 -> 0:2 7 64 978 ok
0x1f9 5 0:2 -> 1:2 7 64 978 ok
0x21f 12 1:5 -> 0:5 17 29 366 ok
0x259 39 2:2005 -> 3:2005 19 102 548 ok
0x2e3 29 2:2000 -> 3:2000 0 65 6197 ok
0x309 24 2:1004 -> 1:1004 5 260 429 ok
0x32f 22 2:1003 -> 1:1003 13 46 37 ok
0x355 28 2:1006 -> 1:1006 12 94 639 ok
0x37b 16 2:1000 -> 1:1000 0 63 5455 TORN
0x3a1 17 1:1001 -> 2:1001 5 291 420 ok
0x3c7 19 1:1002 -> 2:1002 7 116 805 ok
0x422 3 0:1 -> 1:1 15 49 424 ok
0x4a4 1 0:0 -> 1:0 0 90 6178 ok
0x4ca 7 0:3 -> 1:3 17 266 228 ok
0x4f0 36 3:2003 -> 2:2003 6 5 99 ok
0x516 26 2:1005 -> 1:1005 0 0 0 TORN
0x5be 31 2:2001 -> 3:1509951441 11 234 858 TORN
0x5f0 27 1:1006 -> 2:1006 12 94 639 ok
0x616 2 1:0 -> 0:0 0 90 6178 ok
0x64a 23 1:1004 -> 2:1004 5 260 429 ok
0x670 25 1:1005 -> 2:1005 11 295 98 ok
0x6f1 32 3:2001 -> 2:2001 11 234 858 ok
0x717 20 2:1002 -> 1:1002 7 116 805 ok
0x756 21 1:1003 -> 2:1003 13 23086 37 TORN
0x77c 35 2:2003 -> 3:2003 6 5 99 ok
0x7a2 8 1:3 -> 0:3 17 266 228 ok
0x7c8 38 3:2004 -> 2:2004 7 116 3 ok
0x803 9 0:4 -> 1:4 23048 263 137 TORN
0x83c 10 1:4 -> 0:4 8 263 137 ok
0x89e 33 2:2002 -> 3:2002 9 196 83 ok
0x8df 4 1:1 -> 0:1 15 49 424 ok
0x905 11 0:5 -> 1:5 17 29 366 ok
0x95c 42 3:2006 -> 2:2006 19 191 273 ok
0x982 40 3:2005 -> 2:2005 19 102 548 ok
0x9ba 18 2:1001 -> 1:1001 5 291 420 ok
missing idx: [41]
Every crossing is written twice, once in each direction (idx 1 is 0:0 -> 1:0 and idx 2 is 1:0 -> 0:0, for example). Out of 42 slots, 34 are fine, 7 fail their CRC and idx 41 is just gone. Most of the torn ones are easy, because the mirror copy is intact. A lot of them have a stray 0x5A byte in them: idx 9 says peril 23048, which is 0x5A08, so the real value is 8, and that's exactly what idx 10 says.
The only real problem was the crossing on port 1000 (purva -> paschima). Both copies are torn:
0x9c 15 1:1000 -> 2:1000 1509949440 63 5397 TORN
0x37b 16 2:1000 -> 1:1000 0 63 5455 TORN
This is where the totals in the header helped. Region 1's total minus border 0's total gives border 1's total, and subtracting the other 6 border-1 crossings leaves peril 0 and length 63. Both copies agree on those, so the only thing in question was the third number. I brute-forced it against each record's CRC, and both came out as 5397. One copy had the peril byte smashed and the other had the cost smashed.
fixed 1:1000 -> 2:1000 peril=0 length=63 cost=5397
fixed 1:1000 -> 2:1000 peril=0 length=63 cost=5397
Where I messed up
My first submission got rejected. I'd read the ledger's three numbers in the same order as the map edges (cost, peril, length), which seemed natural, but the ledger stores them as peril, length, cost. The header totals should have tipped me off earlier, because they only add up if the first field is peril and the second is length. Once I swapped them, the crossings made sense. The route also ended up going through the three peril-0 crossings (ports 0, 1000, 2000), which is what you'd expect from a "safe" route.
Stitching + DP
Global id = region 10000000 + local. I loaded all four regions into one graph, added the 21 crossings as edges from the exit port in region r to the matching entry port in region r+1, and ran a DP in (region, depth) order with score = peril 10^9 + length. While doing it I also tracked whether any node on the final path could have been reached with an equal score some other way, just to make sure the route is actually unique.
python3 solve.py
fixed 1:1000 -> 2:1000 peril=0 length=63 cost=5397
fixed 1:1000 -> 2:1000 peril=0 length=63 cost=5397
route: 88 clearings, peril=0 length=6928
As a sanity check, the uttara part of the route is exactly what the engine gives for 0 -> 66686 (toll 1594), so my DP agrees with theirs inside a region.
The counts and digests:
- committededgecount = 400137 + 392079 + 398565 + 380670 + 21 = 1571472
- borderedgecount = 21
- both digests use
src>dst:peril:lengthwith global ids, sorted as text, joined with;, then sha256. The border one is over just the 21 crossings, and the graph one is over all 1571472 edges.
The metric field names
This was the dumbest part. The validator wants "metric": {"perilfield": ..., "lengthfield": ..., "peril_weight": ...}, and the field names aren't written anywhere: not in the README, not in the engine, not in the binaries. The weight is obviously 1000000000, but I had no idea what it wanted for the names. So I gave up guessing, scripted about 4,300 name pairs against the validator, and w1 / w2 is what hit. My guess is it's "weight index" counting from 0 (w0 = the decoy, w1 = peril, w2 = length), but that's just me making sense of it after the fact.
Solving
Full script (solve.py), takes a few seconds:
import sys, struct, zlib, hashlib, json
D = sys.argv[1] if len(sys.argv) > 1 else "."
K = 10**9
regions = {}
for name in ("uttara", "purva", "paschima", "dakshina"):
d = open(f"{D}/region_{name}.map", "rb").read()
rid, nc, ec = struct.unpack_from("<HII", d, 10)
nodes = [struct.unpack_from("<Hi", d, 36 + 6*i) for i in range(nc)]
edges = [struct.unpack_from("<IIIII", d, 36 + 6*nc + 20*j) for j in range(ec)]
ports = {pk: i for i, (_, pk) in enumerate(nodes) if pk != -1}
regions[rid] = (nodes, edges, ports)
L = open(f"{D}/border.ledger", "rb").read()
_, _, nreg, _, sr, sl, tr, tl = struct.unpack_from("<8sHHHHIHI", L)
src, dst = sr*10**7 + sl, tr*10**7 + tl
seam = [struct.unpack_from("<QQ", L, 32 + 22*i) for i in range(nreg)]
cross, bad, off = {}, [], 0
while (j := L.find(b"BEDG", off)) >= 0:
r = L[j:j+38]; off = j + 4
if zlib.crc32(r[:34]) != struct.unpack_from("<I", r, 34)[0]:
bad.append(r); continue
_, a, ap, b, bp, pe, le, _ = struct.unpack_from("<IHIHIIII", r, 6)
if a > b: a, ap, b, bp = b, bp, a, ap
cross[(a, ap)] = (b, bp, pe, le)
border = [seam[0]]
for r in range(1, nreg - 1):
border.append((seam[r][0] - border[-1][0], seam[r][1] - border[-1][1]))
for a in range(nreg - 1):
nodes, _, ports = regions[a]
for pk, n in ports.items():
if nodes[n][0] == 0 or (a, pk) in cross: continue
pe = border[a][0] - sum(v[2] for k, v in cross.items() if k[0] == a)
le = border[a][1] - sum(v[3] for k, v in cross.items() if k[0] == a)
for r in bad:
if (a, pk) not in (struct.unpack_from("<HI", r, 10), struct.unpack_from("<HI", r, 16)): continue
crc = struct.unpack_from("<I", r, 34)[0]
cost = next(c for c in range(1 << 20) if zlib.crc32(r[:22] + struct.pack("<III", pe, le, c)) == crc)
print(f"fixed {a}:{pk} -> {a+1}:{pk} peril={pe} length={le} cost={cost}")
cross[(a, pk)] = (a + 1, pk, pe, le)
gid = lambda r, n: r*10**7 + n
adj, lines, blines = {}, [], []
for rid, (_, edges, _) in regions.items():
for s, t, _, pe, le in edges:
u, v = gid(rid, s), gid(rid, t)
adj.setdefault(u, []).append((v, pe, le))
lines.append(f"{u}>{v}:{pe}:{le}")
for (a, ap), (b, bp, pe, le) in cross.items():
u, v = gid(a, regions[a][2][ap]), gid(b, regions[b][2][bp])
adj.setdefault(u, []).append((v, pe, le))
blines.append(f"{u}>{v}:{pe}:{le}")
lines += blines
sha = lambda xs: hashlib.sha256(";".join(sorted(xs)).encode()).hexdigest()
best, prev, tie = {src: 0}, {}, set()
order = sorted((rid, dp, n) for rid, (nodes, _, _) in regions.items() for n, (dp, _) in enumerate(nodes))
for rid, _, n in order:
u = gid(rid, n)
if u not in best: continue
for v, pe, le in adj.get(u, []):
c = best[u] + pe*K + le
if v not in best or c < best[v]:
best[v], prev[v] = c, u
tie.discard(v)
elif c == best[v] and prev[v] != u:
tie.add(v)
route = [dst]
while route[-1] != src:
route.append(prev[route[-1]])
route.reverse()
assert not tie & set(route)
score = best[dst]
body = {"route": route, "route_peril": score // K, "route_length": score % K, "route_score": score,
"metric": {"peril_field": "w1", "length_field": "w2", "peril_weight": K},
"committed_edge_count": len(lines), "border_edge_count": len(blines),
"border_digest": sha(blines), "committed_graph_digest": sha(lines)}
print(f"route: {len(route)} clearings, peril={score // K} length={score % K}")
print(json.dumps(body))
Submitting
curl -X POST https://pinaka-k04-cyber-xo-x.vercel.app/vana/dishamukha \
-H 'Content-Type: application/json' -d '{"route":[0,3131,5581,6906,12958,16503,17394,22633,26849,29458,33627,35717,39379,44011,46118,49947,51066,54956,58022,61632,65079,66686,10000000,10000590,10004023,10007344,10010440,10015132,10019047,10020278,10023114,10027249,10031395,10033847,10035552,10039411,10045501,10048047,10050000,10055194,10058233,10059670,10064727,10065343,20000000,20001611,20003682,20007515,20010102,20014291,20018108,20021100,20025909,20026987,20032636,20035243,20036641,20040576,20045549,20048818,20051664,20055261,20058365,20061042,20063776,20066424,30000000,30003031,30004195,30009049,30011093,30015379,30018854,30021369,30024454,30028152,30031727,30035106,30038068,30041508,30045328,30048087,30050242,30054789,30056934,30061365,30065970,30066184],"route_peril":0,"route_length":6928,"route_score":6928,"metric":{"peril_field":"w1","length_field":"w2","peril_weight":1000000000},"committed_edge_count":1571472,"border_edge_count":21,"border_digest":"09b91f93f0b413b0388d67e8ab73e867abb9923e8c9e609527f9685fc3a9f41b","committed_graph_digest":"a706aa32a3b813bdfba7c97e4a31f7254fe9f8497ccdcd4dfff98dce5b7f4933"}'
{"flag":"PINAKA{kishkindha_dishamukha_sita_found}","proclamation":"Sugriva confirms the trail south \u2014 Sita is found in the Ashoka grove","status":"trail-confirmed"}
it gives the flag:
PINAKA{kishkindha_dishamukha_sita_found}
Summary
- The map format comes straight out of
dik_engine.py: 36-byte header, 6-byte nodes (depth, port key), 20-byte edges. Every region is a DAG, and the port keys say which exit connects to which entry. - The toll is
f1 * 10^9 + f2, so f1 is peril, f2 is length and f0 is a decoy. Probing the engine confirms it. - Every crossing is written twice in the ledger. Fix the torn ones from their mirror copy. Port 1000 has both copies torn, so get its peril/length from the per-region totals in the header and brute-force the cost against the CRC.
- The ledger stores (peril, length, cost), not the maps' order. Reading it the wrong way got my first answer rejected.
- Stitch the 4 regions with the 21 crossings and run the DP: 88 clearings, peril 0, length 6928, unique.
- The metric field names aren't anywhere in the files. Brute-forcing them gave
w1/w2.