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:…

The Four Directions: Search Without Borders

**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:

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

  1. 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.
  2. The toll is f1 * 10^9 + f2, so f1 is peril, f2 is length and f0 is a decoy. Probing the engine confirms it.
  3. 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.
  4. The ledger stores (peril, length, cost), not the maps' order. Reading it the wrong way got my first answer rejected.
  5. Stitch the 4 regions with the 21 crossings and run the DP: 88 clearings, peril 0, length 6928, unique.
  6. The metric field names aren't anywhere in the files. Brute-forcing them gave w1/w2.

Read this post on CyberHx →