Ashoka Vatika
By Team Cryptonite · 2 min read
About the CTF: Pinaka CTF
Sita waits in the Ashoka grove, hidden somewhere within Lanka's vast gardens. Only fragments of the place survive: a glimpse, a measurement, a shadow. Triangulate the veiled sanctuary and name where she is held.
**Pinaka CTF 2026** · Misc & OSINT · Team Cryptonite · All 44 Pinaka write-ups
Sita waits in the Ashoka grove, hidden somewhere within Lanka's vast gardens. Only fragments of the place survive: a glimpse, a measurement, a shadow. Triangulate the veiled sanctuary and name where she is held.
Target: Ashoka Validator
Category: OSINT / Geospatial Flag format: PINAKA{...}
Solve
The project file survey_project.pinx uses a small custom binary format. We wrote a parser for it. A few things were clearly wrong:
- the focal length had been zeroed out
- image 10, the key photo, had no camera pose at all
- the other poses and 3D points were noisy, giving 75-100 px reprojection errors
lidar_key.bin is a 14-point scan of the garden pavilion. The file says its frame is "camera", but it is really the garden's local template frame.
We seeded image 10 with a PnP solve and then ran a bundle adjustment over all the cameras, points, and intrinsics. We did it in three passes: normal pinhole first, then lens distortion, then a rolling-shutter model for image 10.
Image 10 was the interesting one. Its error kept growing towards the bottom of the image, which is the classic sign of a rolling shutter. Once we added that, its error dropped to about 0.27 px. The final intrinsics were f ~ 2649, k1 ~ -0.085, and k2 ~ 0.026.
Next we needed the city-to-benchmark transform. The city map turned out to be a mirrored, scaled copy of the real metric space. Using the scale bar between landmarks L0 and L1, we got:
M = [[-0.0845159, -0.4131378],
[-0.4131378, 0.0845159]]
t = [15.77263, 295.15861]
z_off = -2.0474
The watchtowers were easier. The calibration flare leaves the city gate at time zero, so each tower's skew is just its recorded time minus the travel time from the gate. With relay speed 3.0:
skews = [0.3700, -0.5200, 0.2100]
Then came the garden. Three clues, and each one had a decoy:
- The flare timing put gardens 2 and 3 on the same hyperbola.
- The road travel time of 165 matched garden 2 exactly, while garden 4 was 165.6.
- The pavilion's height worked out to 17.17 m in city coordinates, matching garden 2's base elevation of 17.15 m.
Garden 2 was the only one that passed all three.
For the final pose, we matched image 10 directly against the 14 LiDAR template points. Letting the rolling-shutter motion float gave a 0.22 px fit and a camera height of 27.82 m. The stated altitude minus garden 2's base was 44.9442 - 17.15 = 27.794, so we locked the height to that and solved again:
C = [29.83458, -47.31869, 27.7942]
quat = [0.50510, 0.81676, 0.23709, -0.14684] # (w, x, y, z)
One annoying detail: our first quaternion was the inverse of what the validator wanted. Since the validator only ever says "not accepted", we had to flip it and try again.
solve.py
import json
import struct
import sys
import time
import numpy as np
from scipy.optimize import least_squares
from scipy.spatial.transform import Rotation
PINX_FILE = "survey_project.pinx"
LIDAR_FILE = "lidar_key.bin"
CITY_FILE = "city_model.json"
CALIB_FILE = "sensor_calibration.json"
LOG_FILE = "watchtower_log.json"
ROUTES_FILE = "supply_routes.json"
VALIDATOR_URL = "https://pinaka-ashoka-validator.onrender.com/submit"
EPS = 1e-12
def rodrigues(v):
return Rotation.from_rotvec(v).as_matrix()
def parse_pinx(path):
blob = open(path, "rb").read()
if blob[:4] != b"PINX":
raise ValueError("not a PINX file")
version = struct.unpack_from("<H", blob, 4)[0]
nchunks = struct.unpack_from(">H", blob, 6)[0]
chunks = {}
for i in range(nchunks):
base = 8 + 12 * i
tag = blob[base:base + 4].decode("ascii")
offset = struct.unpack_from("<I", blob, base + 4)[0]
length = struct.unpack_from(">I", blob, base + 8)[0]
chunks[tag] = (offset, length)
fx, fy, cx, cy = struct.unpack_from("<4d", blob, chunks["INTR"][0])
pos = chunks["IMGS"][0]
nimg = struct.unpack_from(">I", blob, pos)[0]
pos += 4
images = {}
for _ in range(nimg):
image_id = struct.unpack_from("<I", blob, pos)[0]
pos += 4
rvec = np.array(struct.unpack_from("<3d", blob, pos))
pos += 24
centre = np.array(struct.unpack_from("<3d", blob, pos))
pos += 24
nobs = struct.unpack_from(">I", blob, pos)[0]
pos += 4
obs = []
for _ in range(nobs):
pid, u, v = struct.unpack_from("<Iff", blob, pos)
pos += 12
obs.append((pid, u, v))
images[image_id] = {"rvec": rvec, "C": centre, "obs": obs}
pos = chunks["PT3D"][0]
npts = struct.unpack_from(">I", blob, pos)[0]
pos += 4
points = {}
for _ in range(npts):
pid = struct.unpack_from("<I", blob, pos)[0]
pos += 4
points[pid] = np.array(struct.unpack_from("<3d", blob, pos))
pos += 24
return {
"version": version,
"fx": fx, "fy": fy, "cx": cx, "cy": cy,
"images": images,
"points": points,
}
def parse_lidar(path):
blob = open(path, "rb").read()
if blob[:4] != b"LPCD":
raise ValueError("not an LPCD file")
n = struct.unpack_from("<I", blob, 4)[0]
pos = 8
pts = {}
for _ in range(n):
pid = struct.unpack_from("<I", blob, pos)[0]
pos += 4
pts[pid] = np.array(struct.unpack_from("<3d", blob, pos))
pos += 24
return pts
class BundleAdjuster:
def __init__(self, pinx, width, height):
self.width = width
self.height = height
self.cx = pinx["cx"]
self.cy = pinx["cy"]
self.images = pinx["images"]
self.ids = sorted(self.images)
self.point_ids = sorted(pinx["points"])
self.P0 = np.array([pinx["points"][i] for i in self.point_ids])
img_idx, pt_idx, us, vs = [], [], [], []
for iid in self.ids:
for pid, u, v in self.images[iid]["obs"]:
img_idx.append(iid)
pt_idx.append(pid)
us.append(u)
vs.append(v)
self.obs_img = np.array(img_idx)
self.obs_pt = np.array(pt_idx)
self.obs_u = np.array(us, dtype=float)
self.obs_v = np.array(vs, dtype=float)
self.fixed_id = 0
self.free_ids = [i for i in self.ids if i != self.fixed_id]
self.rv0 = {i: self.images[i]["rvec"].copy() for i in self.ids}
self.C0 = {i: self.images[i]["C"].copy() for i in self.ids}
self._seed_key_image()
self.n_cam = 6 * len(self.free_ids)
def _dlt_pose(self, obs, f_guess=2500.0):
X = np.array([self.P0[pid] for pid, _, _ in obs], dtype=float)
uv = np.array([[(u - self.cx) / f_guess,
(v - self.cy) / f_guess] for _, u, v in obs])
c3 = X.mean(axis=0)
d3 = np.sqrt(np.mean(np.sum((X - c3) ** 2, axis=1)))
s3 = np.sqrt(3.0) / d3
Xn = (X - c3) * s3
c2 = uv.mean(axis=0)
d2 = np.sqrt(np.mean(np.sum((uv - c2) ** 2, axis=1)))
s2 = np.sqrt(2.0) / d2
uvn = (uv - c2) * s2
A = []
for (x, y), p in zip(uvn, Xn):
ph = np.append(p, 1.0)
A.append(np.concatenate([ph, np.zeros(4), -x * ph]))
A.append(np.concatenate([np.zeros(4), ph, -y * ph]))
_, _, Vt = np.linalg.svd(np.array(A))
Pn = Vt[-1].reshape(3, 4)
T2 = np.array([[s2, 0.0, -s2 * c2[0]],
[0.0, s2, -s2 * c2[1]],
[0.0, 0.0, 1.0]])
T3 = np.eye(4)
T3[:3, :3] *= s3
T3[:3, 3] = -s3 * c3
P = np.linalg.inv(T2) @ Pn @ T3
scale = np.linalg.norm(P[:, :3], "fro") / np.sqrt(3.0)
M = P[:, :3] / scale
t = P[:, 3] / scale
U, _, Vt2 = np.linalg.svd(M)
R = U @ Vt2
if np.linalg.det(R) < 0:
R = -R
t = -t
centre = -R.T @ t
return Rotation.from_matrix(R).as_rotvec(), centre
def _seed_key_image(self):
key = 10
rvec, centre = self._dlt_pose(self.images[key]["obs"])
self.rv0[key] = rvec
self.C0[key] = centre
def unpack(self, x):
R = {self.fixed_id: rodrigues(self.rv0[self.fixed_id])}
C = {self.fixed_id: self.C0[self.fixed_id].copy()}
for k, iid in enumerate(self.free_ids):
R[iid] = rodrigues(x[6 * k:6 * k + 3])
C[iid] = x[6 * k + 3:6 * k + 6]
base = self.n_cam
points = x[base:base + 3 * len(self.point_ids)].reshape(-1, 3)
q = base + 3 * len(self.point_ids)
f = x[q]
k1 = x[q + 1]
k2 = x[q + 2]
omega = x[q + 3:q + 6]
velocity = x[q + 6:q + 9]
return R, C, points, f, k1, k2, omega, velocity
def residual(self, x, use_distortion=True, use_rolling_shutter=True,
baseline_weight=100.0):
R, C, points, f, k1, k2, omega, velocity = self.unpack(x)
R_obs = np.array([R[i] for i in self.obs_img])
C_obs = np.array([C[i] for i in self.obs_img])
if use_rolling_shutter:
mask = self.obs_img == 10
tt = self.obs_v[mask] / self.height - 0.5
R_key = R[10]
C_key = C[10]
Rt = Rotation.from_rotvec(omega[None, :] * tt[:, None]).as_matrix() @ R_key
R_obs[mask] = Rt
C_obs[mask] = C_key[None, :] + velocity[None, :] * tt[:, None]
Xc = np.einsum("nij,nj->ni", R_obs, points[self.obs_pt] - C_obs)
xp = Xc[:, 0] / Xc[:, 2]
yp = Xc[:, 1] / Xc[:, 2]
if use_distortion:
r2 = xp * xp + yp * yp
factor = 1.0 + k1 * r2 + k2 * r2 * r2
xp *= factor
yp *= factor
du = f * xp + self.cx - self.obs_u
dv = f * yp + self.cy - self.obs_v
baseline = np.linalg.norm(points[1] - points[0])
return np.concatenate([du, dv,
[baseline_weight * (baseline - 105.01623)]])
def initial_vector(self):
x = []
for iid in self.free_ids:
x.extend(self.rv0[iid])
x.extend(self.C0[iid])
x.extend(self.P0.ravel())
x.extend([2500.0, 0.0, 0.0])
x.extend([0.0] * 6)
return np.array(x, dtype=float)
def solve(self):
x = self.initial_vector()
stages = [
("pinhole, free focal length", False, False, 60),
("+ radial distortion", True, False, 60),
("+ rolling shutter on image 10", True, True, 80),
]
for name, use_dist, use_rs, max_nfev in stages:
t0 = time.time()
result = least_squares(
self.residual, x,
args=(use_dist, use_rs),
loss="soft_l1",
f_scale=1.0,
x_scale="jac",
max_nfev=max_nfev,
)
x = result.x
err = self.residual(x, use_dist, use_rs, baseline_weight=1.0)[:-1]
err = np.linalg.norm(err.reshape(-1, 2), axis=1)
print(f" [{name}] median {np.median(err):.2f} px, "
f"{time.time() - t0:.1f}s", flush=True)
R, C, points, f, k1, k2, omega, velocity = self.unpack(x)
return {
"x": x,
"R": R, "C": C, "points": points,
"f": f, "k1": k1, "k2": k2,
"omega": omega, "velocity": velocity,
}
def city_to_benchmark(city):
lm = {d["id"]: np.array(d["xyz"], dtype=float) for d in city["landmarks"]}
l0 = lm[0][:2]
l1 = lm[1][:2]
baseline_city = np.linalg.norm(l1 - l0)
k = city["benchmarks"]["baseline_metric_m"] / baseline_city
u = (l1 - l0) / baseline_city
M = k * np.array([[u[0], u[1]],
[u[1], -u[0]]])
t = -M @ l0
z_off = -lm[0][2]
return M, t, z_off, lm
def clock_skews(city, calib, log):
towers = {d["id"]: np.array(d["xy"], dtype=float) for d in city["watchtowers"]}
gate = np.array(city["city_gate_xy"], dtype=float)
speed = calib["watchtowers"]["signal_relay_speed"]
event = next(e for e in log["events"] if e["event"] == "gate_calibration_flare")
skew = {}
for arrival in event["arrivals"]:
tower = arrival["tower"]
dist = np.linalg.norm(towers[tower] - gate)
skew[tower] = arrival["recorded_time"] - dist / speed
return skew, towers, speed
def travel_times(routes):
import heapq
graph = {}
for e in routes["edges"]:
if e["time"] < 0:
continue
graph.setdefault(e["a"], []).append((e["b"], e["time"]))
graph.setdefault(e["b"], []).append((e["a"], e["time"]))
dist = {0: 0.0}
pq = [(0.0, 0)]
while pq:
d, node = heapq.heappop(pq)
if d > dist.get(node, float("inf")):
continue
for nxt, w in graph.get(node, []):
nd = d + w
if nd < dist.get(nxt, float("inf")):
dist[nxt] = nd
heapq.heappush(pq, (nd, nxt))
return dist
def choose_garden(city, calib, log, routes, bundle):
lm = {d["id"]: np.array(d["xyz"], dtype=float) for d in city["landmarks"]}
skew, towers, speed = clock_skews(city, calib, log)
garden_event = next(e for e in log["events"] if e["event"] == "garden_flare")
corrected = {a["tower"]: a["recorded_time"] - skew[a["tower"]]
for a in garden_event["arrivals"]}
observed_diff = speed * (corrected[0] - corrected[1])
tdoa_ok = set()
for g in city["garden_candidates"]:
p = np.array(g["xy"], dtype=float)
pred = np.linalg.norm(p - towers[0]) - np.linalg.norm(p - towers[1])
if abs(pred - observed_diff) < 0.5:
tdoa_ok.add(g["id"])
routes_dist = travel_times(routes)
measured = routes["measured_travel_time_to_observed_garden"]
node_map = {int(k): v for k, v in routes["garden_node_map"].items()}
travel_ok = {
gid for gid, node in node_map.items()
if abs(routes_dist.get(node, float("inf")) - measured) < 0.5
}
M, t, z_off, _ = city_to_benchmark(city)
F = np.array([np.r_[M @ lm[i][:2] + t, lm[i][2] + z_off] for i in range(40)])
P = bundle["points"][:40]
src_c = P.mean(axis=0)
dst_c = F.mean(axis=0)
Xc = P - src_c
Yc = F - dst_c
U, S, Vt = np.linalg.svd(Xc.T @ Yc)
R = Vt.T @ U.T
if np.linalg.det(R) < 0:
Vt[-1] *= -1
R = Vt.T @ U.T
scale = np.sum(S) / np.sum(Xc ** 2)
centre = dst_c - scale * R @ src_c
garden_points = bundle["points"][40:]
garden_f = (scale * (R @ garden_points.T).T) + centre
garden_z = garden_f.mean(axis=0)[2]
garden_city_z = garden_z - z_off
elev_ok = {
g["id"] for g in city["garden_candidates"]
if abs(g["base_elevation"] - garden_city_z) < 0.5
}
candidates = tdoa_ok & travel_ok & elev_ok
if len(candidates) != 1:
raise RuntimeError(
f"garden not unique: TDOA={sorted(tdoa_ok)}, "
f"travel={sorted(travel_ok)}, elevation={sorted(elev_ok)}"
)
gid = candidates.pop()
garden = next(g for g in city["garden_candidates"] if g["id"] == gid)
print(f" TDOA still possible: {sorted(tdoa_ok)}")
print(f" travel time still possible: {sorted(travel_ok)}")
print(f" elevation still possible: {sorted(elev_ok)}")
print(f" garden structure city elevation: {garden_city_z:.3f} m")
print(f" -> garden {gid}")
return gid, garden, skew, towers
def solve_key_pose(pinx, lidar, bundle, calib, garden):
key = calib["key_observation"]["image_id"]
altitude = calib["key_observation"]["altitude_m_above_datum"]
height = calib["camera"]["image_height"]
f, k1, k2 = bundle["f"], bundle["k1"], bundle["k2"]
cx, cy = pinx["cx"], pinx["cy"]
lidar_ids = sorted(lidar)
X_template = np.array([lidar[i] for i in lidar_ids])
X_recon = np.array([bundle["points"][i] for i in lidar_ids])
a = X_recon.mean(axis=0)
b = X_template.mean(axis=0)
U, S, Vt = np.linalg.svd((X_template - b).T @ (X_recon - a))
R_tr = Vt.T @ U.T
if np.linalg.det(R_tr) < 0:
Vt[-1] *= -1
R_tr = Vt.T @ U.T
s_tr = np.sum(S) / np.sum((X_template - b) ** 2)
R_ba = bundle["R"][key]
C_ba = bundle["C"][key]
C_init = R_tr.T @ (C_ba - a) / s_tr
R_init = R_ba @ R_tr
rv_init = Rotation.from_matrix(R_init).as_rotvec()
obs = [(pid, u, v) for pid, u, v in pinx["images"][key]["obs"] if pid in lidar]
X = np.array([lidar[pid] for pid, _, _ in obs])
uv = np.array([[u, v] for _, u, v in obs])
def project(p, rolling=True):
rv = p[:3]
C = p[3:6]
R0 = Rotation.from_rotvec(rv).as_matrix()
if rolling:
omega = p[6:9]
velocity = p[9:12]
tt = uv[:, 1] / height - 0.5
Rt = Rotation.from_rotvec(omega[None, :] * tt[:, None]).as_matrix() @ R0
Ct = C[None, :] + velocity[None, :] * tt[:, None]
else:
Rt = np.repeat(R0[None], len(X), axis=0)
Ct = np.repeat(C[None], len(X), axis=0)
Xc = np.einsum("nij,nj->ni", Rt, X - Ct)
xp = Xc[:, 0] / Xc[:, 2]
yp = Xc[:, 1] / Xc[:, 2]
r2 = xp * xp + yp * yp
factor = 1.0 + k1 * r2 + k2 * r2 * r2
return np.column_stack([f * xp * factor + cx,
f * yp * factor + cy])
def residual(p, rolling=True):
return (project(p, rolling) - uv).ravel()
p0 = np.r_[rv_init, C_init, np.zeros(6)]
result = least_squares(residual, p0, loss="soft_l1", f_scale=1.0,
max_nfev=500)
p_free = result.x
err_free = np.linalg.norm(project(p_free) - uv, axis=1)
z_lock = altitude - garden["base_elevation"]
def residual_locked(q):
p = np.r_[q[:3], q[3], q[4], z_lock, q[5:8], q[8:11]]
return residual(p)
q0 = np.r_[p_free[:3], p_free[3], p_free[4], p_free[6:9], p_free[9:12]]
result = least_squares(residual_locked, q0, loss="soft_l1", f_scale=1.0,
max_nfev=500)
q = result.x
p = np.r_[q[:3], q[3], q[4], z_lock, q[5:8], q[8:11]]
err = np.linalg.norm(project(p) - uv, axis=1)
print(f" rolling-shutter solve: mean {err_free.mean():.3f} px, "
f"median {np.median(err_free):.3f} px")
print(f" height-locked solve: mean {err.mean():.3f} px, "
f"median {np.median(err):.3f} px")
print(f" locked height: {z_lock:.4f} m")
R0 = Rotation.from_rotvec(p[:3]).as_matrix()
xyzw = Rotation.from_matrix(R0).as_quat()
quat_wxyz = np.r_[xyzw[3], xyzw[:3]]
return p[3:6], quat_wxyz
def main():
print("[*] parsing files")
pinx = parse_pinx(PINX_FILE)
lidar = parse_lidar(LIDAR_FILE)
city = json.load(open(CITY_FILE))
calib = json.load(open(CALIB_FILE))
log = json.load(open(LOG_FILE))
routes = json.load(open(ROUTES_FILE))
width = calib["camera"]["image_width"]
height = calib["camera"]["image_height"]
print("[*] bundle adjustment")
ba = BundleAdjuster(pinx, width, height)
bundle = ba.solve()
print(f" focal length {bundle['f']:.2f} px, "
f"k1 {bundle['k1']:.6f}, k2 {bundle['k2']:.6f}")
print("[*] city -> benchmark transform")
M, t, z_off, _ = city_to_benchmark(city)
print(f" M = {np.round(M, 6).tolist()}")
print(f" t = {np.round(t, 6).tolist()}, z_off = {z_off:.4f}")
print("[*] choosing garden")
gid, garden, skew, _ = choose_garden(city, calib, log, routes, bundle)
skews = [skew[i] for i in sorted(skew)]
print(f" clock skews: {[round(x, 4) for x in skews]}")
print("[*] key camera pose")
C, quat = solve_key_pose(pinx, lidar, bundle, calib, garden)
submission = {
"garden_id": gid,
"transform": {
"M": M.tolist(),
"t": t.tolist(),
"z_off": float(z_off),
},
"clock_skews": [float(x) for x in skews],
"pose": {
"C": [float(x) for x in C],
"quat": [float(x) for x in quat],
},
}
json.dump(submission, open("body.json", "w"))
print("\n[*] submission written to body.json")
print(json.dumps(submission, indent=2))
if __name__ == "__main__":
main()
Final submission
{
"garden_id": 2,
"transform": {
"M": [[-0.08451591379472756, -0.4131377924711144],
[-0.4131377924711144, 0.08451591379472756]],
"t": [15.772630876364246, 295.1586115740326],
"z_off": -2.0474
},
"clock_skews": [0.37, -0.52, 0.21],
"pose": {
"C": [29.834579839892783, -47.31868504318385, 27.7942],
"quat": [0.5051033290152477, 0.8167586979734144, 0.2370926663990623, -0.14684319474279417]
}
}
Then post it:
curl -s -X POST -H 'Content-Type: application/json' --data @body.json \
https://pinaka-ashoka-validator.onrender.com/submit
Flag:
PINAKA{4shoka_v4tika_tr1angulat3d_past_r0lling_shutter_and_cl0ck_sk3w}