Chitrakuta: The Vanishing Footprints
By Team Cryptonite · 5 min read
About the CTF: Pinaka CTF
Three undocumented binary artifacts encode a bird's-eye survey, uncalibrated watch-tower sightings, and a partial trail record. Invert the non-standard map projection, self-calibrate instruments, and reconstruct the…
**Pinaka CTF 2026** · Forensics · Team Cryptonite · All 44 Pinaka write-ups
Challenge
> Three undocumented binary artifacts encode a bird's-eye survey, uncalibrated watch-tower sightings, and a partial trail record. Invert the non-standard map projection, self-calibrate instruments, and reconstruct the single true route.
> Validator: https://pinaka-v02-cyber-xo-x.vercel.app/
We're given survey.gcp, towers.obs, route.frags and a README.md.
The README has the story and, more usefully, the exact JSON the validator expects. It wants the route, the true-ground position of every footprint, a SHA-256 of the plate projection H, and the calibration of all 18 towers, all as exact fractions. Floats were never going to work here, so I used Python's Fraction the whole way through.

Recon
None of the three files has a published format, so I started with a hexdump:
xxd survey.gcp | head
xxd towers.obs | head
xxd route.frags | head
00000000: 504e 4b47 4350 3031 0100 2e00 0000 0000 PNKGCP01........
00000010: 0000 0000 9001 0000 9001 0000 f704 6c30 ..............l0
00000020: 0100 3e01 0000 8300 0000 8508 0000 0000 ..>.............
00000030: 0000 fd06 0000 0000 0000 6604 0000 0000 ..........f.....
00000040: 0000 fd06 0000 0000 0000 cf95 2826 0200 ............(&..
00000050: 1601 0000 5b00 0000 4507 0000 0000 0000 ....[...E.......
00000060: 5d06 0000 0000 0000 5e00 0000 0000 0000 ].......^.......
00000070: b500 0000 0000 0000 748b 8071 0300 a400 ........t..q....
00000080: 0000 5b00 0000 9904 0000 0000 0000 7905 ..[...........y.
00000090: 0000 0000 0000 f400 0000 0000 0000 d301 ................
00000000: 504e 4b4f 4253 3031 0100 1200 a005 0000 PNKOBS01........
00000010: b8d4 bf32 0000 0001 0080 db1a 0000 0000 ...2............
00000020: 00c9 6b03 0000 0000 00b3 fc08 3200 0000 ..k.........2...
00000030: 0200 6080 0700 0000 0000 8305 0100 0000 ..`.............
00000040: 0000 cf07 0b12 0000 0003 0072 6d00 0000 ...........rm...
00000050: 0000 00bf 0c00 0000 0000 004b 65e8 b900 ...........Ke...
00000060: 0000 0400 4061 0f00 0000 0000 e50c 0100 ....@a..........
00000070: 0000 0000 5e5c 4f0e 0000 0005 0020 7b02 ....^\O...... {.
00000080: 0000 0000 0027 3200 0000 0000 00da b848 .....'2........H
00000090: 4700 0000 0600 b23e 1700 0000 0000 ed4f G......>.......O
00000000: 504e 4b52 5445 3031 0100 1300 f903 21e5 PNKRTE01......!.
00000010: 5caa f803 0904 5d20 7fb6 0904 0404 5739 \.....] ......W9
00000020: 4438 0304 fb03 e2b1 b95a 0504 0604 6de4 D8.......Z....m.
00000030: a440 f303 ed03 1704 08c5 f403 f203 3032 [email protected]
00000040: 8595 ef03 0504 7700 04d7 ed03 ff03 6854 ......w.......hT
00000050: 3a0d fd03 0a04 ac83 8caa f703 f503 190b :...............
00000060: 71c8 0604 f803 1312 4a46 fb03 0104 bb05 q.......JF......
00000070: 136c f503 ef03 4939 55d2 f903 f303 acdb .l....I9U.......
00000080: f47e 0104 f403 a665 2877 0404 0304 4d77 .~.....e(w....Mw
00000090: 6f85 f203 fd03 2371 7637 ee03 f703 8e71 o.....#qv7.....q
Each one starts with an 8-byte magic (PNKGCP01, PNKOBS01, PNKRTE01), then a version u16 and a count. The README mentions "torn" records whose checksum no longer matches, so I tried CRC32 over different lengths until one matched the 4 bytes after it. Every header and every record ends in a CRC32 of the bytes before it, little-endian.
The file formats
survey.gcp has a 32-byte header: magic, ver, count = 46, four u32s (0, 0, 400, 400) that look like the map bounds, and a CRC. After that come 46 records of 46 bytes each:
u16 id | u32 X | u32 Y | i64 px_num | i64 px_den | i64 py_num | i64 py_den | u32 crc
So every landmark has an integer true-ground position and an exact fractional position on the old plate. Landmark 1, for example, is (318, 131) on the ground and (2181/1789, 1126/1789) on the plate. All 46 CRCs are fine.
towers.obs has a 20-byte header: magic, ver, 18 towers, 1440 readings, and a CRC. Each record is 25 bytes:
u16 tower | u8 flag | u16 target | i64 u_num | i64 u_den | u32 crc
flag = 0 means the tower was sighting a landmark, and flag = 1 means it was sighting the party, with target being a sighting id from 1001 up. 259 records fail their CRC (those are the torn ones), so I dropped them, which leaves 1181.
route.frags has an 18-byte header: magic, ver, 19 links, trail-head = 1017, and a CRC. Each record is 8 bytes, u16 from | u16 to | u32 crc. All 19 are fine.

Step 1: Inverting the plate
The plate is a perspective drawing of the ground, so it's a homography:
(px, py, 1) ~ H * (X, Y, 1)
Clearing the denominators gives two linear equations per landmark in the 9 entries of H:
h0.X + h1*Y + h2 - px*(h6*X + h7*Y + h8) = 0
h3*X + h4*Y + h5 - py*(h6*X + h7*Y + h8) = 0
46 landmarks give 92 equations, and the exact nullspace is one-dimensional. Scaling so that h8 = 1:
H = [[2/297, 2/891, 1/81],
[1/891, 2/297, 2/81],
[2/891, 2/891, 1 ]]
The digest string from the README:
0:2/297;1:2/891;2:1/81;3:1/891;4:2/297;5:2/81;6:2/891;7:2/891;8:1/1
sha256 = 646b1ffe482475119472528f114bb8cc5c9f6c3346b505206354a2ad64146eb7
Step 2: Calibrating the towers
The README gives the tower model, which works in plate coordinates:
u = f * (-s*dx + c*dy) / (c*dx + s*dy) + cc, dx = X - ox, dy = Y - oy
This is a 1D pinhole camera. Multiplying out, it's u (P2g) = P1*g with g = (x, y, 1), so each landmark reading gives one linear equation in 6 unknowns (a0, a1, a2, b0, b1, b2):
a0*x + a1*y + a2 - u*(b0*x + b1*y + b2) = 0
Every tower has 35-41 good landmark readings, which is far more than enough, and the nullspace again comes out one-dimensional. To get the actual parameters back from P1 = (a0, a1, a2) and P2 = (b0, b1, b2):
lambda = +/-sqrt(b0^2 + b1^2). This works out to an exact rational because the cos/sin values are Pythagorean (like 8/17 and 15/17). I tookmath.isqrtof the numerator and denominator separately.- Choose the sign of lambda so that
(P2*g)/lambda > 0for the landmarks. That's the "landmark is in front of the tower" condition. With the wrong sign, the same readings come out with cos and sin negated, and the validator rejects that. cos = b0/lambda,sin = b1/lambdacc = (a0cos + a1sin)/lambda,f = (-a0sin + a1cos)/lambdad = -b2/lambda,e = -a2/lambda,w = (e - cc*d)/fox = cosd - sinw,oy = sind + cosw
As a sanity check, I plugged every tower's recovered parameters back into the model, and it reproduced every one of its landmark readings exactly.

Tower 0, for example, comes out as f = 60, c = 8, cos = 15/17, sin = 8/17, (ox, oy) = (-90/17, -48/17).
Step 3: Triangulating the sightings
Once a tower is calibrated, a reading u of the party fixes a line in the plate: (P1 - uP2)g = 0. Each sighting is seen by 13-16 towers, so I stacked their lines and took the nullspace to get the exact plate point. Then I mapped it back to the ground by solving H*(X, Y, 1) ~ (px, py, 1).
Every footprint lands on whole-number ground coordinates, which is a nice confirmation that everything up to this point is right. For example, 1017 is at plate (367/1091, 427/1091) and ground (39, 61).
Step 4: Building the route
I started from trail-head 1017 and followed the 19 succession links:
1017 1011 1005 1023 1006 1015 1013 1007 1029 1030
1016 1033 1028 1027 1019 1025 1012 1010 1021 1034
That's 20 footprints. There are 34 sighting ids in total, and the other 14 (1001-1004, 1008, 1009, 1014, 1018, 1020, 1022, 1024, 1026, 1031, 1032) never appear in any link, so they're the decoys ("startled deer") and they're left out.
final json:
{"route":[1017,1011,1005,1023,1006,1015,1013,1007,1029,1030,1016,1033,1028,1027,1019,1025,1012,1010,1021,1034],
"footprints":{"1017":[39,1,61,1],"1011":[378,1,296,1],"1005":[369,1,220,1],"1023":[288,1,161,1],"1006":[287,1,140,1],"1015":[130,1,367,1],"1013":[321,1,234,1],"1007":[316,1,160,1],"1029":[250,1,272,1],"1030":[358,1,348,1],"1016":[378,1,202,1],"1033":[62,1,186,1],"1028":[333,1,79,1],"1027":[269,1,320,1],"1019":[342,1,191,1],"1025":[117,1,144,1],"1012":[28,1,158,1],"1010":[79,1,132,1],"1021":[210,1,107,1],"1034":[190,1,238,1]},
"projection_digest":"646b1ffe482475119472528f114bb8cc5c9f6c3346b505206354a2ad64146eb7",
"calibration":{
"0":[60,1,8,1,15,17,8,17,-90,17,-48,17],
"1":[90,1,14,1,7,25,24,25,-42,25,-144,25],
"2":[108,1,-20,1,4,5,-3,5,-24,5,18,5],
"3":[90,1,-16,1,8,17,-15,17,-48,17,90,17],
"4":[132,1,-6,1,9,41,40,41,-54,41,-240,41],
"5":[120,1,-28,1,-28,53,45,53,168,53,-270,53],
"6":[120,1,25,1,9,41,-40,41,-54,41,240,41],
"7":[120,1,19,1,24,25,7,25,-144,25,-42,25],
"8":[60,1,-20,1,-24,25,7,25,144,25,-42,25],
"9":[132,1,-26,1,12,13,5,13,-72,13,-30,13],
"10":[96,1,14,1,-5,13,12,13,30,13,-72,13],
"11":[80,1,-2,1,-4,5,3,5,24,5,-18,5],
"12":[144,1,26,1,-9,41,40,41,54,41,-240,41],
"13":[132,1,28,1,-9,41,-40,41,54,41,240,41],
"14":[144,1,8,1,28,53,45,53,-168,53,-270,53],
"15":[60,1,26,1,-7,25,-24,25,42,25,144,25],
"16":[60,1,1,1,-7,25,24,25,42,25,-144,25],
"17":[108,1,-11,1,-3,5,4,5,18,5,-24,5]}}
Submitting
curl -s -X POST -H 'Content-Type: application/json' \
https://pinaka-v02-cyber-xo-x.vercel.app/chitrakuta/footprints \
-d '{"route":[1017,1011,1005,1023,1006,1015,1013,1007,1029,1030,1016,1033,1028,1027,1019,1025,1012,1010,1021,1034],
"footprints":{"1017":[39,1,61,1],"1011":[378,1,296,1],"1005":[369,1,220,1],"1023":[288,1,161,1],"1006":[287,1,140,1],"1015":[130,1,367,1],"1013":[321,1,234,1],"1007":[316,1,160,1],"1029":[250,1,272,1],"1030":[358,1,348,1],"1016":[378,1,202,1],"1033":[62,1,186,1],"1028":[333,1,79,1],"1027":[269,1,320,1],"1019":[342,1,191,1],"1025":[117,1,144,1],"1012":[28,1,158,1],"1010":[79,1,132,1],"1021":[210,1,107,1],"1034":[190,1,238,1]},
"projection_digest":"646b1ffe482475119472528f114bb8cc5c9f6c3346b505206354a2ad64146eb7",
"calibration":{
"0":[60,1,8,1,15,17,8,17,-90,17,-48,17],
"1":[90,1,14,1,7,25,24,25,-42,25,-144,25],
"2":[108,1,-20,1,4,5,-3,5,-24,5,18,5],
"3":[90,1,-16,1,8,17,-15,17,-48,17,90,17],
"4":[132,1,-6,1,9,41,40,41,-54,41,-240,41],
"5":[120,1,-28,1,-28,53,45,53,168,53,-270,53],
"6":[120,1,25,1,9,41,-40,41,-54,41,240,41],
"7":[120,1,19,1,24,25,7,25,-144,25,-42,25],
"8":[60,1,-20,1,-24,25,7,25,144,25,-42,25],
"9":[132,1,-26,1,12,13,5,13,-72,13,-30,13],
"10":[96,1,14,1,-5,13,12,13,30,13,-72,13],
"11":[80,1,-2,1,-4,5,3,5,24,5,-18,5],
"12":[144,1,26,1,-9,41,40,41,54,41,-240,41],
"13":[132,1,28,1,-9,41,-40,41,54,41,240,41],
"14":[144,1,8,1,28,53,45,53,-168,53,-270,53],
"15":[60,1,26,1,-7,25,-24,25,42,25,144,25],
"16":[60,1,1,1,-7,25,24,25,42,25,-144,25],
"17":[108,1,-11,1,-3,5,4,5,18,5,-24,5]}}'
it gives the flag:
PINAKA{chitrakuta_vanishing_footprints_exact_projective_geometry}
Summary
- All three files use custom formats where every header and record ends in a CRC32. I dropped the 259 tower readings with bad CRCs.
- I recovered the plate homography
Hexactly from the 46 landmarks (a DLT withh8 = 1), then hashed it for the digest. - I self-calibrated each tower as a 1D camera from its landmark readings, choosing the sign of lambda so the landmarks are in front of the tower.
- I triangulated each party sighting from the tower bearings and mapped it through
H^-1back to integer ground coordinates. - I followed the succession links from
1017to get the 20-footprint route, dropping the 14 decoys, and submitted.