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

Indrajit: The Invisible War

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

Indrajit: The Invisible War: step screenshot

trikuta:

Indrajit: The Invisible War: step screenshot

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...

OM


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.

Indrajit: The Invisible War: step screenshot

Indrajit: The Invisible War: step screenshot

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.

Indrajit: The Invisible War: step screenshot

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

  1. Both pcaps carry a custom TCP protocol, NPX1: a 30-byte header, a body and a 32-byte MAC. Orders are 15-byte OM bodies that use the README codebook.
  2. The fakes are a type-05 replay in Trikuta, a replay from the rogue node .6 in Suvela, and three SHA-256 length extension forgeries from .6 (real order + glue padding + forged order).
  3. The glue padding says 40 bytes get hashed. Sweeping the validator gave a 16-byte key.
  4. 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.
  5. All the real orders come from 10.13.0.3, so origin node 2.

Read this post on CyberHx →