Indrajit: The Invisible War
By Team Cryptonite · 5 min read
About the CTF: Pinaka CTF
Reverse a custom transport protocol from two pcaps, separate genuine orders from forged illusions via authentication weakness, correlate two desynchronized clocks, reconstruct the true command transcript. Validator:…
**Pinaka CTF 2026** · Forensics · Team Cryptonite · All 44 Pinaka write-ups
Challenge
> Reverse a custom transport protocol from two pcaps, separate genuine orders from forged illusions via authentication weakness, correlate two desynchronized clocks, reconstruct the true command transcript.
> Validator: https://pinaka-l03-cyber-xo-x.vercel.app/
We're given capturetrikuta.pcap, capturesuvela.pcap and a README.md.
The README has a codebook (action codes 1-7, sector codes 11-17) and says the validator wants three things: the transcript of the real orders in the right order, the node that sent them, and the length of the secret key the transport uses. That last one was a big hint that the authentication is breakable somehow.
Recon
Both pcaps are tiny (around 25 frames each), so I just opened them in Wireshark first.
tshark -r capture_trikuta.pcap -q -z conv,tcp
10.13.0.3:40001 <-> 10.13.0.5:7447 4 423 bytes 8 880 bytes 12 1,303 bytes 0.000000000 0.2900
10.13.0.1:40004 <-> 10.13.0.2:7447 1 54 bytes 11 1,296 bytes 12 1,350 bytes 0.030000000 0.3200
tshark -r capture_suvela.pcap -q -z conv,tcp
10.13.0.1:40004 <-> 10.13.0.2:7447 1 54 bytes 11 1,296 bytes 12 1,350 bytes 0.000000000 0.3200
10.13.0.3:40002 <-> 10.13.0.4:7447 3 300 bytes 4 370 bytes 7 670 bytes 0.060004000 0.1001
10.13.0.6:40003 <-> 10.13.0.2:7447 1 54 bytes 6 749 bytes 7 803 bytes 0.080006000 0.1600
suvela:

trikuta:

Everything is plain Ethernet / IPv4 / TCP on 10.13.0.0/24, port 7447. Both captures have the .1 -> .2 stream. Trikuta also has .3 -> .5, and Suvela has .3 -> .4 plus a third connection from .6 that shows up out of nowhere. Every TCP payload starts with NPX1.
The NPX1 format
I lined up the payloads in hex and compared them. The .1 -> .2 stream turned out to be beacons with WATCHFIRE in the body, which made the header easy to line up.
tshark -r capture_trikuta.pcap -Y 'frame.number == 10' -T fields -e tcp.payload
4e50583103010201040051510a000000109d1b17240a0600010000010f004f4d010b00bc1b00000000000001007e278786107615dc4c71d0319e150912ff0f6852abce5bb733e36ccbe2caf...

0 4 "NPX1"
4 6 type | ver | ? | flag | dst node | channel
10 2 "QQ"
12 4 transport seq (per connection, LE)
16 4 sender timestamp in us (LE)
20 4 0x00060a24, the same in every frame
24 4 tag, the same for an order and its ACK
28 2 body length (LE)
30 n body
30+n 32 MAC
The timestamp at 16 goes up by exactly 40000 between beacons, which are 40 ms apart, so it's microseconds on the sender's clock.
Types: 06 is a WATCHFIRE beacon, 03 is an order (OM), 04 is an ACK and 05 is a replay (more on that below).
The order body is 15 bytes:
0 2 "OM"
2 1 action code
3 2 sector code (LE)
5 4 tick (LE)
9 4 always 0
13 2 order seq (LE)
So 4f4d010b00bc1b0000000000000100 is action 1, sector 11, tick 7100, seq 1, which with the codebook is ADVANCE northern-rampart tick=7100.
The illusions
There are three kinds of fake orders.
1. A plain replay in Trikuta. Frame 22 is byte-for-byte the STRIKE order from frame 14, with the same MAC. The only changes are type 05 instead of 03 and a transport seq that jumps to 99. Easy to throw out.

2. A replay from a rogue node in Suvela. Frame 23 is the ENCIRCLE order again, with the same MAC as the real one in frame 9. But it comes over that third connection, 10.13.0.6 -> 10.13.0.2, which isn't part of the real relay path (.3 -> .4 and .3 -> .5) at all.
3. Length extension forgeries. Frames 14, 21 and 22 in Suvela also come from .6, but their body is 54 bytes instead of 15:
4f4d010b00bc1b0000000000000100 <- a real order, copied as is
800000000000000000000000000000000000000000000140 <- 24 bytes of glue
4f4d04110029230000000000000000 <- the forged order
That middle part is SHA-256 padding: 0x80, zeros, then the bit length big-endian. 0x140 = 320 bits = 40 bytes. So the MAC is a secret-prefix SHA256(key || message), which you can extend without knowing the key, as long as you have a valid MAC and the length. The attacker took real orders, glued new orders onto them, and the MACs still check out. You can confirm it: if you start SHA-256 from the real order's MAC as the internal state (40 bytes + 24 bytes of padding = 64 bytes processed) and keep hashing the forged order, you get exactly the forged frame's MAC, for all three of them.
The forged orders it appends are:
FEINT lotus-harbour tick=9001 seq=0
REGROUP western-gate tick=9002 seq=0
ENCIRCLE serpent-causeway tick=9003 seq=0
All of them are dropped.

The key length
The glue tells you the key plus the authenticated message is 40 bytes, but not how that splits. I brute-forced every total length from 1 to 200 against the actual padding bytes, and only 40 reproduces them, so that part is solid. The split depends on how many header bytes go into the hash before the 15-byte body, and from the bytes alone 15, 19, 21 and 23 bytes of "message" all looked defensible. In the end I fixed the transcript and origin and swept key_length from 1 to 39 against the validator. 16 is the one it accepted, so the hashed message is 24 bytes (9 bytes of header + the 15-byte body) under a 128-bit key.
Clock correlation
The README says Suvela's clock runs at a different rate and offset, and that the WATCHFIRE beacons are the only bridge. The beacons are identical in both captures (same payload, same MAC), so I matched them up by MAC:
tshark -r capture_trikuta.pcap -Y 'ip.src == 10.13.0.1 && tcp.len > 0' -T fields -e frame.number -e frame.time_epoch
tshark -r capture_suvela.pcap -Y 'ip.src == 10.13.0.1 && tcp.len > 0' -T fields -e frame.number -e frame.time_epoch
trikuta suvela
0.040000000 3000.040003000
0.080000000 3000.080006000
0.120000000 3000.120009000
...
0.360000000 3000.360027000
It's not just a 3000s offset. Suvela gains 3 us on every 40 ms beacon, so:
suvela = 1.000075 * trikuta + 3000
Careful with this one: Wireshark's default time column is relative to the first packet, and Trikuta's first packet is at 0.009997, not 0. Use epoch time (or frame.time_epoch) or the numbers won't line up.
Mapping everything onto Trikuta's clock, the five real orders land at 0.05, 0.10, 0.15, 0.20 and 0.25. That's the same order the tick (7100 to 7700) and order seq (1 to 5) fields give, so all three agree.
Origin node
Every real OM comes from 10.13.0.3. Node ids are the last octet minus one, which you can see from the dst node byte in the header: it's 04 on the leg to .5 and 03 on the leg to .4. So 10.13.0.3 is node 2.
Solving
Trikuta has orders 1, 3 and 5 (leg .3 -> .5), and Suvela has 2 and 4 (leg .3 -> .4). Neither capture has all five, which is why you need both.
import sys, struct, json
D = sys.argv[1] if len(sys.argv) > 1 else "."
ACTIONS = {1: "ADVANCE", 2: "HOLD", 3: "STRIKE", 4: "FEINT", 5: "VANISH", 6: "REGROUP", 7: "ENCIRCLE"}
SECTORS = {11: "northern-rampart", 12: "western-gate", 13: "sky-bridge", 14: "southern-moat",
15: "central-keep", 16: "serpent-causeway", 17: "lotus-harbour"}
def read_pcap(path):
d = open(path, "rb").read()
off, out, n = 24, [], 0
while off < len(d):
n += 1
sec, usec, cap, _ = struct.unpack_from("<IIII", d, off)
f = d[off + 16:off + 16 + cap]
off += 16 + cap
ip = f[14:]
tcp = ip[(ip[0] & 15) * 4:]
data = tcp[(tcp[12] >> 4) * 4:]
if data[:4] == b"NPX1":
out.append((n, sec + usec / 1e6, ip[12:16], ip[16:20], data))
return out
def npx(p):
n = struct.unpack_from("<H", p, 28)[0]
return p[4], p[30:30 + n], p[30 + n:]
tri = read_pcap(f"{D}/capture_trikuta.pcap")
suv = read_pcap(f"{D}/capture_suvela.pcap")
beacons = {npx(p)[2]: t for _, t, _, _, p in tri if npx(p)[0] == 6}
pairs = sorted((beacons[npx(p)[2]], t) for _, t, _, _, p in suv if npx(p)[0] == 6 and npx(p)[2] in beacons)
rate = (pairs[-1][1] - pairs[0][1]) / (pairs[-1][0] - pairs[0][0])
shift = pairs[0][1] - rate * pairs[0][0]
print(f"suvela = {rate:.6f} * trikuta + {shift:.3f} ({len(pairs)} shared beacons)")
orders, seen = [], set()
for name, cap, to_tri in (("trikuta", tri, lambda t: t), ("suvela", suv, lambda t: (t - shift) / rate)):
for n, t, src, dst, p in cap:
typ, body, mac = npx(p)
if typ not in (3, 5) or body[:2] != b"OM":
continue
if len(body) > 15:
print(f"{name} #{n}: forged, .{src[3]} -> .{dst[3]}, glue says {struct.unpack('>Q', body[31:39])[0] // 8} bytes hashed")
continue
if typ == 5 or mac in seen:
print(f"{name} #{n}: replay, .{src[3]} -> .{dst[3]}")
continue
seen.add(mac)
act, sec, tick, _, seq = struct.unpack_from("<BHIIH", body, 2)
orders.append((to_tri(t), seq, ACTIONS[act], SECTORS[sec], tick, src[3] - 1))
orders.sort()
lines = ["NAGAPASHA-TRANSCRIPT v1"] + [f"#{i} {a} sector={s} tick={k}" for i, (_, _, a, s, k, _) in enumerate(orders, 1)]
for t, seq, a, s, k, node in orders:
print(f"t={t:.6f} seq={seq} {a} {s} tick={k} from node {node}")
print("\n".join(lines))
print(json.dumps({"command_transcript": "\n".join(lines), "origin_node": orders[0][5], "key_length": 16}))
python3 solve.py
suvela = 1.000075 * trikuta + 3000.000 (9 shared beacons)
trikuta #22: replay, .3 -> .5
suvela #14: forged, .6 -> .2, glue says 40 bytes hashed
suvela #21: forged, .6 -> .2, glue says 40 bytes hashed
suvela #22: forged, .6 -> .2, glue says 40 bytes hashed
suvela #23: replay, .6 -> .2
t=0.050000 seq=1 ADVANCE northern-rampart tick=7100 from node 2
t=0.100000 seq=2 ENCIRCLE serpent-causeway tick=7250 from node 2
t=0.150000 seq=3 STRIKE central-keep tick=7400 from node 2
t=0.200000 seq=4 HOLD southern-moat tick=7550 from node 2
t=0.250000 seq=5 VANISH sky-bridge tick=7700 from node 2
NAGAPASHA-TRANSCRIPT v1
#1 ADVANCE sector=northern-rampart tick=7100
#2 ENCIRCLE sector=serpent-causeway tick=7250
#3 STRIKE sector=central-keep tick=7400
#4 HOLD sector=southern-moat tick=7550
#5 VANISH sector=sky-bridge tick=7700
{"command_transcript": "NAGAPASHA-TRANSCRIPT v1\n#1 ADVANCE sector=northern-rampart tick=7100\n#2 ENCIRCLE sector=serpent-causeway tick=7250\n#3 STRIKE sector=central-keep tick=7400\n#4 HOLD sector=southern-moat tick=7550\n#5 VANISH sector=sky-bridge tick=7700", "origin_node": 2, "key_length": 16}
Submitting
curl -s -X POST https://pinaka-l03-cyber-xo-x.vercel.app/war/command \
-H 'Content-Type: application/json' \
-d '{"command_transcript": "NAGAPASHA-TRANSCRIPT v1\n#1 ADVANCE sector=northern-rampart tick=7100\n#2 ENCIRCLE sector=serpent-causeway tick=7250\n#3 STRIKE sector=central-keep tick=7400\n#4 HOLD sector=southern-moat tick=7550\n#5 VANISH sector=sky-bridge tick=7700", "origin_node": 2, "key_length": 16}'
{"flag":"PINAKA{indrajit_the_invisible_war}","proclamation":"Lanka-command acknowledges Indrajit's true order","status":"command-confirmed"}
it gives the flag:
PINAKA{indrajit_the_invisible_war}
Summary
- Both pcaps carry a custom TCP protocol,
NPX1: a 30-byte header, a body and a 32-byte MAC. Orders are 15-byteOMbodies that use the README codebook. - The fakes are a type-05 replay in Trikuta, a replay from the rogue node
.6in Suvela, and three SHA-256 length extension forgeries from.6(real order + glue padding + forged order). - The glue padding says 40 bytes get hashed. Sweeping the validator gave a 16-byte key.
- The WATCHFIRE beacons are in both captures. Matching them gives
suvela = 1.000075 * trikuta + 3000, and on one timeline the 5 real orders line up with their ticks and seqs. - All the real orders come from
10.13.0.3, so origin node 2.