Dronagiri: The Last Sanjeevani
By Team Cryptonite · 6 min read
About the CTF: Pinaka CTF
Reverse undocumented binary formats for a replicated key-value store, reconcile torn WALs via majority quorum, calibrate a spectrometer, cross-reference luminescence + terrain to identify exactly one authentic…
**Pinaka CTF 2026** · Forensics · Team Cryptonite · All 44 Pinaka write-ups
Challenge
> Reverse undocumented binary formats for a replicated key-value store, reconcile torn WALs via majority quorum, calibrate a spectrometer, cross-reference luminescence + terrain to identify exactly one authentic specimen.
> Validator: https://pinaka-l04-cyber-xo-x.vercel.app/
We're given node0.seg, node1.seg, node2.seg, node0.wal, node1.wal, node2.wal, instrument.cal, dronagiri.dem and a README.md.
The README is long, but the important part is the list of "botanical facts" plus the JSON the validator expects. In short, the real Sanjeevani is a committed record (one that reached at least 2 of the 3 nodes), its spectrum peaks inside 1108-1123 nm once the spectrometer is calibrated, and it grows on a cell with elevation 3000-3400 that is a strict local maximum. The validator wants every intermediate result as well (the decoded spectrum, the calibration, the record count and a digest of the whole archive), so I couldn't just guess the specimen.

Recon
Same as the other challenges in this set: no schema, so I started with a hexdump.
xxd node0.seg | head -4
xxd node0.wal | head -4
xxd instrument.cal
xxd dronagiri.dem | head -4
00000000: 504e 4b44 5345 4731 0100 0000 b004 0000 PNKDSEG1........
00000010: e803 0000 0000 0000 1c24 c259 0100 0000 .........$.Y....
00000020: e903 0000 0000 0000 0198 0000 0001 0000 ................
00000030: 0073 00cf 000e 0020 0048 5041 4b01 2005 .s..... .HPAK. .
00000000: 504e 4b44 5741 4c31 0100 0000 8a15 0000 PNKDWAL1........
00000010: 51a6 95c6 4452 4543 b100 0000 4b04 0000 Q...DREC....K...
00000020: 0000 0000 a743 0f00 0000 0000 0167 0100 .....C.......g..
00000030: 0098 0000 0067 0100 0047 00f5 0027 0020 .....g...G...'.
00000000: 504e 4b43 414c 3031 0100 0200 9001 0000 PNKCAL01........
00000010: 58dd 6116 0500 1504 0000 0000 0000 0100 X.a.............
00000020: 0000 0000 0000 1900 5104 0000 0000 0000 ........Q.......
00000030: 0100 0000 0000 0000 ........
00000000: 504e 4b44 454d 3031 0100 0001 0001 5a0a PNKDEM01......Z.
00000010: 0000 3119 0000 b636 93aa d20b ed0b ff0b ..1....6........
00000020: 180c 2c0c 430c 550c 5f0c 750c 840c 8b0c ..,.C.U._.u.....
00000030: 940c a50c af0c b70c bb0c c90c c80c cd0c ................
Every file starts with an 8-byte magic (PNKDSEG1, PNKDWAL1, PNKCAL01, PNKDEM01), then a u16 version, and somewhere in the header a CRC32. The .seg and .wal records clearly contained the string HPAK, so the specimen values themselves were packed in yet another format.
The file formats
Everything is little-endian unless I say otherwise.
**node.seg* is the snapshot. The header is PNKDSEG1, u16 ver, u16 node, u32 count (1200), u64 checkpoint and a crc32. Then the records:
u32 key | u64 seqno | u8 op | u32 vlen | value | u32 crc32
All 3 segments are the same apart from the node id in the header, and every seqno is just 1000 + key.
**node.wal* has a header of PNKDWAL1, u16 ver, u16 node, u32 nframes and a crc. The frames themselves aren't stored in order:
"DREC" | u32 len | u64 log_index | u64 seqno | u8 op (1 = put, 2 = delete) | u32 key | u32 vlen | value | u32 crc32
The value (the specimen record, the same in both files) looks like this:
u32 key | u16 x | u16 y | u16 ? | u16 nbands (32) | "HPAK" | u8 ver | u8 32 | u8 levels (5) | u8 0 | u32 crc32(coeffs) | 32 x u32 coeffs
Careful with the coefficients: unlike everything else, they're big-endian sign-magnitude.
instrument.cal is tiny: PNKCAL01, u16 ver, u16 nlines (2), a u32, a crc, then two lines of u16 band | i64 num | i64 den. Those give band 5 = 1045/1 and band 25 = 1105/1.
dronagiri.dem is PNKDEM01, u16 ver, u16 W (256), u16 H (256), u32 min, u32 max, a crc32, then WH u16 elevations, row-major (E[yW+x]).
The value also carries the (x, y) cell where the specimen was collected, which is what links the archive to the DEM later.
HPAK
This one took the longest. The 32 coefficients clearly weren't the spectrum itself, because most of them were small and a few were huge and negative. The breakthrough was a record with one very bright band. Its detail coefficients roughly halved at every level: -48067, -24193, -12200, -6006, +3003. That's what a single spike looks like after a Haar transform.
It turned out to be a 5-level integer Haar (S-transform). The layout is [approx, d1(16), d2(8), d3(4), d4(2), d5(1)], with d = odd - even and s = even + floor(d/2), and inverting it is just running the levels backwards. Once I had that right, every single record decoded to non-negative integers with one dominant peak, which was a good sign.
Step 1: Fixing the torn WALs
Each WAL has three problems.
Torn frames. About 600 frames per node fail their CRC32. Rather than throwing them away, I tried the simplest kind of damage first, a single bad byte, and brute-forced it: 185 byte positions x 256 values, checked against the frame's CRC. That fixed every one of them (608 + 589 + 601, no failures), so each torn frame really did have exactly one corrupted byte.
A zeroed tail. Each WAL has a span of zeros (about 1.2 KB, 6-7 frames' worth) where the log was overwritten. The parser just resyncs on the next DREC magic.

node0.wal frames(hdr)=5514 good=4899 torn=608 zeroed_bytes=1323
node1.wal frames(hdr)=5450 good=4854 torn=589 zeroed_bytes=1171
node2.wal frames(hdr)=5438 good=4830 torn=601 zeroed_bytes=1323
Unreliable replication. Lots of writes only made it to one node, which the README is very clear about. That's handled in the next step.
Step 2: Majority quorum
A write counts as committed only if an identical (seqno, op, key, value) frame is on at least 2 of the 3 nodes.
unique frames: 13985
on 1 node(s): 12785
on 2 node(s): 4
on 3 node(s): 1196
committed writes: 1200 puts: 1180 deletes: 20
python3 l04_solve.py
{"specimen_id": 488, "commit_seqno": 1000488, "cell": [52, 210], "elevation": 3332, "peak_band": 27, "peak_wavelength": [1111, 1], "wavelength_solution": [3, 1, 1030, 1], "restored_spectrum": [381, 358, 227, 189, 280, 170, 135, 159, 415, 88, 154, 324, 264, 102, 219, 187, 412, 181, 361, 244, 229, 220, 268, 86, 205, 403, 346, 57817, 384, 253, 141, 240], "committed_record_count": 1180, "committed_archive_digest": "4dbe165e82832e0290e17ea1bcc85bdd1a18a0ae2bd68bdebb1468bfca2c1cf0"}
So exactly 1200 writes are committed, all with seqno 1000000+key. Four of them are on only two nodes, because the third copy was in the zeroed tail. That's exactly why you need a majority and not "all three".
The 12,785 single-node frames are not part of the history, and that includes some of the repaired torn ones. A few of them are nasty decoys:
key=488 seqno=40000488 op=1 nodes=[0]
key=462 seqno=40000462 op=1 nodes=[1]
40000488 is a "newer" version of key 488 that only node 0 has, and 40000462 is a glowing specimen sitting on a different summit cell. If you take the highest seqno per key without checking quorum, you land on one of these.
Then I replayed the committed writes over the stale snapshot (newest seqno wins, deletes remove the key). That leaves 1180 records. The digest, built exactly as the README describes (key:seqno:sha256(value) sorted by key, joined with ;, then SHA-256):
committed records: 1180
digest: 4dbe165e82832e0290e17ea1bcc85bdd1a18a0ae2bd68bdebb1468bfca2c1cf0
Step 3: Calibrating the spectrometer
instrument.cal only has two lamp lines: band 5 is 1045 nm and band 25 is 1105 nm. Assuming a linear axis:
p = (1105 - 1045) / (25 - 5) = 3
q = 1045 - 3*5 = 1030
nm = 3*band + 1030
So the glow window 1108-1123 nm is bands 26-31. Six committed specimens peak in there:
key=123 seqno=1000123 cell=(157,174) elev=4728 band=28 nm=1114 summit=False
key=380 seqno=1000380 cell=(152,196) elev=4173 band=26 nm=1108 summit=False
key=451 seqno=1000451 cell=(161,180) elev=4640 band=28 nm=1114 summit=False
key=488 seqno=1000488 cell=(52,210) elev=3332 band=27 nm=1111 summit=True
key=880 seqno=1000880 cell=(186,16) elev=3784 band=26 nm=1108 summit=False
key=1007 seqno=1001007 cell=(225,63) elev=4411 band=28 nm=1114 summit=False
Here's the decoded spectrum of 488. The peak at band 27 is huge compared to everything else:

Step 4: Terrain
A "sunlit summit" is any cell with elevation 3000-3400 that is strictly higher than all 8 of its neighbours. There are 11 of them in the DEM:
(56,107) (58,107) (52,109) (59,113) (52,114) (52,116)
(54,202) (54,204) (52,210) (50,211) (52,212)
Only one glowing specimen sits on one of them: 488 at (52, 210), elevation 3332. The rest are all way up the mountain at 3700+.

Solving
Here's the full script, with everything above put together:
#!/usr/bin/env python3
# Usage: python3 solve.py <challenge_dir>
import sys, struct, zlib, hashlib, json, collections
from fractions import Fraction as F
D = sys.argv[1]
rd = lambda n: open(f"{D}/{n}", "rb").read()
def seg(name): # hdr(28) | {u32 key,u64 seqno,u8 op,u32 vlen,value,u32 crc}
d = rd(name); off = 28; out = {}
while off < len(d):
key, seqno, op, ln = struct.unpack_from("<IQBI", d, off)
out[key] = (seqno, d[off + 17:off + 17 + ln]); off += 21 + ln
return out
def repair(fr): # torn frame = one flipped byte; recover via CRC32
crc = struct.unpack("<I", fr[-4:])[0]; body = fr[:-4]
for i in range(len(body)):
for v in range(256):
g = body[:i] + bytes([v]) + body[i + 1:]
if zlib.crc32(g) == crc: return g
return None
def wal(name):
d = rd(name); off = 20; out = []
while off < len(d):
if d[off:off + 4] != b"DREC": # zeroed tail span: resync
j = d.find(b"DREC", off + 1); off = j if j >= 0 else len(d); continue
ln = struct.unpack_from("<I", d, off + 4)[0]
fr = d[off:off + 12 + ln]
fr = fr[:-4] if zlib.crc32(fr[:-4]) == struct.unpack("<I", fr[-4:])[0] else repair(fr)
if fr:
_, seqno, op, key, vl = struct.unpack_from("<QQBII", fr, 8)
out.append((seqno, op, key, fr[33:33 + vl]))
off += 12 + ln
return out
votes = collections.defaultdict(set) # majority quorum over identical writes
for n in range(3):
for w in wal(f"node{n}.wal"): votes[w].add(n)
committed = sorted(w for w, ns in votes.items() if len(ns) >= 2)
arch = seg("node0.seg")
for seqno, op, key, val in committed:
cur = arch.get(key)
if cur and seqno <= cur[0]: continue
if op == 1: arch[key] = (seqno, val)
elif op == 2: arch.pop(key, None)
digest = hashlib.sha256(";".join(
f"{k}:{arch[k][0]}:{hashlib.sha256(arch[k][1]).hexdigest()}" for k in sorted(arch)
).encode()).hexdigest()
def decode(v): # hpak: 5-level integer Haar, BE sign-magnitude coeffs
c = []
for j in range(24, len(v), 4):
u = struct.unpack(">I", v[j:j + 4])[0]
c.append(-(u & 0x7FFFFFFF) if u >> 31 else u)
groups, pos = [], 1
for k in range(1, v[18] + 1):
groups.append(c[pos:pos + (32 >> k)]); pos += 32 >> k
a = [c[0]]
for d in reversed(groups):
a = [y for s, dd in zip(a, d) for y in (s - (dd >> 1), s - (dd >> 1) + dd)]
return a
cal = rd("instrument.cal")
(i1, n1, d1), (i2, n2, d2) = [struct.unpack_from("<Hqq", cal, 20 + 18 * k) for k in range(2)]
p = (F(n2, d2) - F(n1, d1)) / (i2 - i1); q = F(n1, d1) - p * i1
dem = rd("dronagiri.dem"); W, H = struct.unpack_from("<HH", dem, 10)
E = struct.unpack_from(f"<{W*H}H", dem, 26)
def summit(x, y):
e = E[y * W + x]
if not (3000 <= e <= 3400) or not (0 < x < W - 1 and 0 < y < H - 1): return False
return all(E[(y + dy) * W + x + dx] < e for dx in (-1, 0, 1) for dy in (-1, 0, 1) if dx or dy)
hits = []
for key, (seqno, v) in arch.items():
sp = decode(v); pk = max(range(32), key=sp.__getitem__); nm = p * pk + q
x, y = struct.unpack_from("<HH", v, 4)
if 1108 <= nm <= 1123 and summit(x, y):
hits.append(dict(specimen_id=key, commit_seqno=seqno, cell=[x, y], elevation=E[y * W + x],
peak_band=pk, peak_wavelength=[nm.numerator, nm.denominator],
wavelength_solution=[p.numerator, p.denominator, q.numerator, q.denominator],
restored_spectrum=sp, committed_record_count=len(arch),
committed_archive_digest=digest))
assert len(hits) == 1
print(json.dumps(hits[0]))
The brute-force repair is the slow part. The whole thing takes about 8 seconds.
Submitting
python3 solve.py L04_player > body.json
cat body.json
{"specimen_id": 488, "commit_seqno": 1000488, "cell": [52, 210], "elevation": 3332, "peak_band": 27,
"peak_wavelength": [1111, 1], "wavelength_solution": [3, 1, 1030, 1],
"restored_spectrum": [381, 358, 227, 189, 280, 170, 135, 159, 415, 88, 154, 324, 264, 102, 219, 187,
412, 181, 361, 244, 229, 220, 268, 86, 205, 403, 346, 57817, 384, 253, 141, 240],
"committed_record_count": 1180,
"committed_archive_digest": "4dbe165e82832e0290e17ea1bcc85bdd1a18a0ae2bd68bdebb1468bfca2c1cf0"}
Then fire it at the validator:
curl -s -X POST -H 'Content-Type: application/json' --data @body.json \
https://pinaka-l04-cyber-xo-x.vercel.app/dronagiri/sanjeevani
{"flag":"PINAKA{dronagiri_the_last_sanjeevani}","proclamation":"The true Sanjeevani is found; the fallen will rise.","status":"sanjeevani-confirmed"}
it gives the flag:
PINAKA{dronagiri_the_last_sanjeevani}
Summary
- All five formats are custom, with CRC32s everywhere. The specimen values are packed with HPAK, a 5-level integer Haar transform with big-endian sign-magnitude coefficients.
- Every torn WAL frame has exactly one bad byte, so I repaired them by brute-forcing against the CRC. I skipped the zeroed tail by resyncing on
DREC. - A write is committed only if an identical frame is on at least 2 nodes. That gives 1200 committed writes, which I replayed over the stale snapshot to get 1180 records and the digest. The single-node "newer" versions are decoys.
- Calibration:
nm = 3*band + 1030. Six committed specimens glow in 1108-1123 nm. - Only one of them, key 488 at (52, 210), is on a 3000-3400 strict local maximum. I submitted it and got the flag.