Ashokavana: The Veiled Sanctuary
By Team Cryptonite · 4 min read
About the CTF: Pinaka CTF
Reverse a custom binary recon format, recover a damaged detector-calibration matrix via weak-perspective photogrammetry, repair a broken road-network edge, and fuse both sources to identify the one authentic Ashoka…
**Pinaka CTF 2026** · Forensics · Team Cryptonite · All 44 Pinaka write-ups
Challenge
> Reverse a custom binary recon format, recover a damaged detector-calibration matrix via weak-perspective photogrammetry, repair a broken road-network edge, and fuse both sources to identify the one authentic Ashoka grove.
> Validator: https://pinaka-s02-cyber-xo-x.vercel.app/
We're given recon.survey, citymodel.json, supplyrecords.json and a README.md.
Six gardens were all built from one royal plan, so from the air they look identical. We have to find the one real Ashoka grove by combining three damaged sources: aerial recon of the marker stones, the city map, and the quartermaster's ration ledger. The validator wants a pile of intermediate facts too (the recovered camera matrix, the look-alike family, the repaired road, the distances), so guessing the garden isn't enough.
Recon format
recon.survey is a custom binary starting with PNKVEIL1. The header:
0x00 magic "PNKVEIL1"
0x08 u16 version=1, u16 n_images=6, u32 n_stones=7
0x10 u16 anchor flag, u32 crc32(bytes 0..0x12)
0x16 64-byte D matrix (4 num/den i64 pairs) - all zeros, the damaged calibration, then a u32 crc
0x5a image/observation records to end of file
The D matrix is zeroed out, which is the "damaged detector calibration" the README warns about. We have to recover it ourselves.
Each observation record is [u16 stoneid][i64 pxnum][i64 pxden][i64 pynum][i64 py_den][u32 crc32], the crc covering the 34 bytes before it. Observations are grouped per image, and I split images on a stone-id reset. Rather than trust the record offsets, I scanned the file for every 34-byte window whose trailing 4 bytes are its crc32 - that recovers the intact records and silently drops the torn ones.
That gives 35 good observations in 6 images:
image 0: stones [0, 1] <- the anchor (square-on)
image 1: stones [0, 1, 2, 3, 4, 5, 6]
image 2: stones [0, 1, 2, 4, 5, 6] <- stone 3 torn
image 3: stones [0, 1, 2, 3, 4, 5, 6]
image 4: stones [0, 1, 2, 3, 4, 6] <- stone 5 torn
image 5: stones [0, 1, 2, 3, 4, 5, 6]
Photogrammetry: recovering D
The model is obs = D (sRT + t), where T is a stone's template coordinate, (s, R, t) is a per-image similarity (scale, rotation, shift), and D is one global detector-defect matrix shared by every image. The anchor (image 0) is square-on: s = 1, R = I, t = 0, so for it obs = D T.
Stones 0 and 1 are the same in every garden's template: stone 0 is (2, 1), stone 1 is (1, 3). The anchor observes them at (5/2, 1) and (3/2, 13/4). That's four linear equations for the four entries of D:
D * (2, 1) = (5/2, 1)
D * (1, 3) = (3/2, 13/4)
Solving over the rationals:
D = [[6/5, 1/10], [-1/20, 11/10]]
The look-alike family
For every other image, apply D^-1 to its observations. The result should be an exact similarity transform of some candidate garden's template. I fit s*R and t from two stones (as [a, -b; b, a] plus a shift) and checked that it reproduces every remaining stone exactly, over the rationals, with no rounding.
The gardens whose template is consistent with all the images are 1, 2 and 4. Gardens 0, 3 and 5 each differ in a single stone, so they're decoys. The three authentic ones share one layout, which is exactly why photogrammetry alone can't pick between them, and why we need the ledger.
The template fingerprint for the shared layout (sorted stones, id:px/py as exact fractions, sha256):
feec6a12bc90a538ec4ca3a72b74f62ae0bc4579c1d3a41cdc7706aba994cec4
The road network
supply_records.json has the roads with one edge overwritten by the sentinel -1. The known edges sum to 10 + 14 + 19 + 7 + 25 = 75, and the published total is 79, so the missing length is 79 - 75 = 4. The broken edge [2, 3, -1] repairs to [2, 3, 4].
Dijkstra from the depot (node 0) over the repaired network:
garden 0 -> 7
garden 1 -> 10
garden 2 -> 14
garden 3 -> 14
garden 4 -> 19
garden 5 -> 25
The ledger says the porters carried (50 - 8) / 3 = 14 leagues to the sanctuary.
Fuse
Only one garden is in the authentic family and sits at road distance 14 from the depot. Garden 3 is also at distance 14 but it's a decoy (fails photogrammetry); gardens 1 and 4 are authentic but at the wrong distance. That leaves garden 2, Shimshapa Bower.
I put the whole thing in one script:
import json, struct, zlib, hashlib, heapq
from fractions import Fraction as F
D = open("recon.survey", "rb").read()
obs = []
for st in range(0x16, len(D) - 37):
if zlib.crc32(D[st:st+34]) == struct.unpack_from("<I", D, st+34)[0]:
sid = struct.unpack_from("<H", D, st)[0]
n = [struct.unpack_from("<q", D, st+2+8*i)[0] for i in range(4)]
obs.append((sid, F(n[0], n[1]), F(n[2], n[3])))
images, cur = [], []
for o in obs:
if cur and o[0] <= cur[-1][0]:
images.append(cur); cur = []
cur.append(o)
images.append(cur)
model = json.load(open("city_model.json"))
T = {g["id"]: {int(k): (F(v[0]), F(v[1])) for k, v in g["template"].items()} for g in model["gardens"]}
# D from the anchor: obs = D * T on stones 0 and 1
(sa, xa, ya), (sb, xb, yb) = images[0][0], images[0][1]
Ta, Tb = T[0][sa], T[0][sb]
den = Ta[0]*Tb[1] - Ta[1]*Tb[0]
d00 = (xa*Tb[1] - xb*Ta[1]) / den; d01 = (Ta[0]*xb - Tb[0]*xa) / den
d10 = (ya*Tb[1] - yb*Ta[1]) / den; d11 = (Ta[0]*yb - Tb[0]*ya) / den
det = d00*d11 - d01*d10
Dinv = ((d11/det, -d01/det), (-d10/det, d00/det))
ap = lambda M, p: (M[0][0]*p[0]+M[0][1]*p[1], M[1][0]*p[0]+M[1][1]*p[1])
def consistent(img, tm):
pts = {s: ap(Dinv, (x, y)) for s, x, y in img}
ids = sorted(pts)
i, j = ids[0], ids[1]
dx, dy = tm[i][0]-tm[j][0], tm[i][1]-tm[j][1]
rx, ry = pts[i][0]-pts[j][0], pts[i][1]-pts[j][1]
dd = dx*dx + dy*dy
a = (dx*rx + dy*ry) / dd; b = (dx*ry - dy*rx) / dd
tx = pts[i][0] - (a*tm[i][0] - b*tm[i][1]); ty = pts[i][1] - (b*tm[i][0] + a*tm[i][1])
return all((a*tm[s][0]-b*tm[s][1]+tx, b*tm[s][0]+a*tm[s][1]+ty) == pts[s] for s in ids)
family = sorted(g for g, tm in T.items() if all(consistent(im, tm) for im in images[1:]))
sup = json.load(open("supply_records.json"))
roads = [e[:] for e in sup["roads"]]
bad = next(e for e in roads if e[2] == sup["corrupt_sentinel"])
bad[2] = sup["royal_total_length"] - sum(e[2] for e in roads if e[2] != sup["corrupt_sentinel"])
adj = {}
for u, v, w in roads:
adj.setdefault(u, []).append((v, w)); adj.setdefault(v, []).append((u, w))
dist = {0: 0}; pq = [(0, 0)]
while pq:
dd, u = heapq.heappop(pq)
for v, w in adj[u]:
if dd + w < dist.get(v, 1 << 60):
dist[v] = dd + w; heapq.heappush(pq, (dd + w, v))
ledger = (sup["rations_dispatched"] - sup["rations_returned"]) // sup["rations_per_league"]
gdist = {int(g): dist[n] for g, n in sup["garden_nodes"].items()}
gid = next(g for g in family if gdist[g] == ledger)
tm = T[gid]
dig = hashlib.sha256(";".join(
f"{s}:{tm[s][0].numerator}/{tm[s][0].denominator},{tm[s][1].numerator}/{tm[s][1].denominator}"
for s in sorted(tm)).encode()).hexdigest()
print(json.dumps({
"garden_id": gid,
"detector": {"d00": [d00.numerator, d00.denominator], "d01": [d01.numerator, d01.denominator],
"d10": [d10.numerator, d10.denominator], "d11": [d11.numerator, d11.denominator]},
"template_digest": dig, "authentic_family": family,
"repaired_edge": bad, "ledger_distance": ledger, "garden_distance": gdist[gid]}))
python3 solve.py > payload.json
cat payload.json
{"garden_id": 2, "detector": {"d00": [6, 5], "d01": [1, 10], "d10": [-1, 20], "d11": [11, 10]}, "template_digest": "feec6a12bc90a538ec4ca3a72b74f62ae0bc4579c1d3a41cdc7706aba994cec4", "authentic_family": [1, 2, 4], "repaired_edge": [2, 3, 4], "ledger_distance": 14, "garden_distance": 14}
Submitting
curl -X POST https://pinaka-s02-cyber-xo-x.vercel.app/sanctuary/verify \
-H 'Content-Type: application/json' -d @payload.json
{"flag":"PINAKA{setu_bandhan_veiled_sanctuary_fused_recon_and_rations}","proclamation":"Hanuman has found Sita in the true Ashoka grove","status":"sanctuary-confirmed"}
Flag
PINAKA{setu_bandhan_veiled_sanctuary_fused_recon_and_rations}
Summary
recon.surveyis a custom binary with a zeroed detector matrix and per-stone observation records, each ending in a crc32. Scan for crc-valid records to recover the intact observations and drop the torn ones.- The anchor image gives
obs = D*T; two shared stones solve the global defect matrixD = [[6/5, 1/10], [-1/20, 11/10]]. - Apply
D^-1to the other images and check which gardens' templates they match as similarities: the authentic look-alike family is 1, 2, 4. - Repair the road edge
[2, 3, 4]from the total length, run Dijkstra from the depot, and read the ledger distance(50 - 8) / 3 = 14. - The only authentic garden at distance 14 is garden 2, Shimshapa Bower.