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.

Ashoka Vatika

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

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:

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}

Read this post on CyberHx →