Jatayu: Echoes of the Fallen

By Team Cryptonite · 7 min read

About the CTF: Pinaka CTF

Three salvaged artifacts (firmware dump, I/Q radio capture, NAND flash image) encode the same telemetry from independent timebases. Reverse the firmware, demodulate + RS-FEC the radio, rebuild the flash FTL, align…

Jatayu: Echoes of the Fallen

**Pinaka CTF 2026** · Forensics · Team Cryptonite · All 44 Pinaka write-ups


Challenge

> Three salvaged artifacts (firmware dump, I/Q radio capture, NAND flash image) encode the same telemetry from independent timebases. Reverse the firmware, demodulate + RS-FEC the radio, rebuild the flash FTL, align all three, and recover the final transmission.

> Validator: https://pinaka-v06-cyber-xo-x.vercel.app/

We're given recorder.fw (74 bytes), radio.iq (about 700 KB of int8 I/Q), flash.img (8 KB) and a README.md.

The README makes the plan pretty clear. The radio lost some chunks on the air, the flash has them but not in transmit order, and the firmware has the parameters for reading both of them and for relating their clocks. The validator wants a digest of the full 56-chunk transmission, a digest of the flash state, the clock calibration, the transmit cadence and the list of slots that got lost on the radio.

Recon

The firmware is tiny, so I started there:


xxd recorder.fw
00000000: 4a54 5955 01d3 80ba eb3f 0060 1ca7 cfa2  JTYU.....?.`....
00000010: 0674 b39f cadd 98a8 fdc4 2155 8761 976e  .t........!U.a.n
00000020: 6729 1157 3b13 0783 71e4 9215 b9e6 7aca  g).W;...q.....z.
00000030: d80b b482 038c 59ae 5db5 1497 1387 ac4e  ......Y.]......N
00000040: b3ee 708d 6f63 764c a087                 ..p.ocvL..

JTYU, version 01, then what looks like a u32 at offset 5 (0xebba80d3) and a u16 length of 63. The other 63 bytes are high entropy, so they're encrypted with something.

The flash looks a lot friendlier:


xxd flash.img | sed -n '9,11p'
00000080: 594a 016c 0002 0000 00be 8e11 000a 2d7f  YJ.l..........-.
00000090: bc60 99e5 120b 635e 3ff5 6b1f 0bd9 0000  .`....c^?.k.....
000000a0: 0000 0000 0000 0000 0000 0000 0000 0000  ................

There are YJ records on 128-byte pages, some pages full of ff and a few that are pure noise.

1. Firmware

The u32 in the header is a seed. The body is XORed with an xorshift32 stream (shifts 13, 17, 5) started from it, and every output word gives 4 keystream bytes, little-endian. Decrypted:


03 01 10 00 0c 00 02 1c 00 14 00 03 30 00 0f 00 71 01 03 06 79 df 43 e4 14 16 7a e8 c0 5d 00 00
40 00 02 80 38 01 00 60 ea 00 00 10 36 c9 78 e1 40 42 0f 00 00 00 00 00 20 4e 00 00 0a 38 00

The first byte is a section count (3), followed by (id u8, off u16, len u16) entries:


section 1  off 16  len 12   71 01 | 03 | 06 | 79 df 43 e4 14 16 7a e8
section 2  off 28  len 20   24000 | 64 | 2 | 80000 | 60000 | 16 | 36 c9 78 e1
section 3  off 48  len 15   1000000 (u64) | 20000 (u32) | 10 | 56

Section 1 is the FEC setup: 0x171 is the GF polynomial, 03 turned out to be the first consecutive root (fcr), 06 is the number of parity bytes, and the last 8 bytes are a key.

Section 2 is the radio. 24000 is the sample rate, then 64 samples per symbol and 2 bits per symbol, so it's 4-FSK. The 16 near the end is the sync length in symbols, and 36c978e1 is the sync word itself. I never needed the 80000 and 60000 for anything.

Section 3 is the clock: w0 = 1000000, dw = 20000, 10-byte payloads and 56 slots.

The 8-byte key is actually two little-endian words, kA = 0xe443df79 and kB = 0xe87a1614. kA ended up being the whitening seed and kB the interleaver seed. The third section gives the cadence directly: [1000000, 20000].

2. Radio: demodulation

The capture has 366864 complex samples. Looking at the power envelope, the first burst starts at sample 400, and after that a burst comes every 6544 samples, 56 bursts in total. Each burst is 96 symbols of 64 samples.

An FFT of the first few symbols shows the tones sit 4 bins apart (1500 Hz at 24 kHz), but the base bin drifts a bit from burst to burst (anywhere from about 4.6 to 5.2). So for each burst I searched for the carrier offset that best lines up with the 16 sync symbols (0312302113203201, which is just 36c978e1 read as 2-bit symbols). Then I picked the strongest tone for each symbol. 80 data symbols at 2 bits each gives 20 bytes, packed MSB-first.


burst  offset  sync  confidence  bytes
0      5.0     ok    1.00        b3aa922a0252eab2e5cce42d3f1c0bf6c0459a4f
1      5.0     ok    1.00        9009dd8a91151c18a5ec34dfbaaa3e181c60bb4e
...
7      4.9     ok    0.59        362720a36a88148e9d2c2858da9b8f0af43a7b8b
...
16     5.1     ok    0.59        ea67ba0d4ba702a9eb312de4c4795aac3a1f1a88
...
37     4.6     ok    0.56        f7abec724bf99e68c9d23d063a3f53608637514a
40     4.8     ok    0.59        ae18c90ca1acc1be04f3bec6d71b624bf9f5e82f
...
51     4.7     ok    0.57        94a77a6d48b6886c9310c2783951a034a926b316

Confidence here is the best tone's share of the energy across the 4 tones. Bursts 7, 16, 37, 40 and 51 sit around 0.57 the whole way through, which means they're just noise. Those are the lost slots. Everything else is at 0.99-1.00.

The trap

Byte 8 of almost every burst is a5, but some of them have e5. I first thought that was some kind of flag bit, but it's actually deliberate corruption. Every good burst has exactly 2 corrupted bytes, and only the first symbol of a byte ever gets hit, so the error is always XOR 0x40 or 0xc0. That's also why none of the frames is ever a clean codeword, and any test that expects exact zero syndromes will fail on every single frame.

3. Radio: interleaver, RS, whitening

Interleaver

Five slots show up in both the radio and the flash (1, 10, 31, 36, 54), so I used them as known plaintext. Ten radio byte positions match flash bytes 20-29 under a constant XOR. In payload order, they are radio positions [1,11,15,18,0,7,10,3,13,6]. That looked like a seeded shuffle, and after trying a few PRNG shuffles, Fisher-Yates driven by xorshift32 seeded with kB matched:


l = list(range(20)); s = kB
for i in range(19, 0, -1):
    s = xorshift32(s); j = s % (i + 1); l[i], l[j] = l[j], l[i]
# radio[k] = cw[l[k]]

Full permutation: [6, 2, 12, 9, 19, 15, 11, 7, 1, 18, 8, 3, 13, 10, 17, 4, 16, 14, 5, 0]. Byte 8 of the radio frame is codeword byte 1, which is the constant 0x00 field XORed with the whitening byte a5. That's why it's nearly always a5.

RS code

Once de-interleaved, every frame is an RS(20,14) codeword over GF(2^8) with primitive poly 0x171, generator alpha = 2, first consecutive root alpha^3 (fcr = 3) and 6 parity bytes, so it can fix up to 3 errors. Every value here comes from the firmware's FEC section. Finding the code was annoying because of the 2 injected errors per frame: exact syndrome checks fail every time. What worked was bounded-distance decoding on the XOR of two frames, since that cancels the constant whitening. After that, a normal Berlekamp-Massey decoder handles it, and all 51 good frames decode with exactly two corrections each:


0 fixed [(8, '0x40'), (7, '0xc0')] slot 0 pad 0 payload 904730804b9e3225a9f1 crc True
1 fixed [(2, '0x40'), (12, '0xc0')] slot 1 pad 0 payload 33b5dea168f4e2851f07 crc True
2 fixed [(19, '0xc0'), (8, '0x40')] slot 2 pad 0 payload 2fcc00fcaa7ca6206171 crc True
...
7 RS FAIL
...
54 fixed [(0, '0xc0'), (12, '0xc0')] slot 54 pad 0 payload 2b7fe3bd1dc0d104d5a4 crc True
55 fixed [(11, '0x40'), (7, '0xc0')] slot 55 pad 0 payload e168be96f12e5e378d39 crc True
51 good; missing [7, 16, 37, 40, 51]

Whitening

The 14 data bytes are XORed with the xorshift32 stream seeded with kA (4f a5 3a 6a c6 1a f8 ec d6 0f b5 1b d1 cc). So the transmit side does whiten, then RS-encode, then interleave, and you undo it in reverse order. The plaintext frame is:


[slot] [0x00] [payload x10] [CRC-16/CCITT-FALSE over the first 12 bytes, big-endian]

The CRC checks out on all 51 frames, and for the 5 slots that overlap with the flash, the payloads match the flash byte for byte.

4. Flash FTL

128-byte pages, 64 of them: 15 hold records, 4 are garbage (pages 0, 4, 15, 25) and the other 45 are erased. Record layout:


0  "YJ"   2  0x01   3  lpn   4  0x00   5  version u32 LE   9  tick u32 LE
13 len (10)   14 tag[6]   20 payload[10]   30.. zero padding

page  1 lpn 108 v2 tick 1150654 payload 120b635e3ff56b1f0bd9
page  5 lpn 103 v2 tick 1092034 payload b62aa63bf9ab617c088a
page  6 lpn 107 v2 tick 974794  payload 33b5dea168f4e2851f07
page  8 lpn 101 v2 tick 1267894 payload c6d4290062ab517a72e5
page  9 lpn 109 v2 tick 1951794 payload 5e29349d50c04b4072a1
page 11 lpn 106 v1 tick 1736850 payload db193ec48b3cb7759071
page 14 lpn 105 v1 tick 2010405 payload 6d784377c4b443ff370d
page 16 lpn 100 v2 tick 1678234 payload da6994d5f6c6770a005d
page 17 lpn 105 v2 tick 2010414 payload 2b7fe3bd1dc0d104d5a4
page 19 lpn 100 v3 tick 1678235 payload 8b76089932fdd4da6f91
page 20 lpn 106 v2 tick 1736854 payload 48fb294db4d212eec5ea
page 21 lpn 109 v3 tick 1951802 payload a7bb0ce7caea5a824569
page 23 lpn 102 v2 tick 1560994 payload 3a200ee7673cfecb836a
page 28 lpn 109 v1 tick 1951785 payload 219da777bff592574607
page 30 lpn 104 v2 tick 1658694 payload e5a9524cebbec3112e27

Logical pages 100-109 each show up in versions 1, 2 and/or 3, so the question is which version is live. Just taking the highest version is the trap here.

The five overlap slots give five (slot, tick) pairs, all of them version 2. They lie exactly on one line:


tick = 955254 + 19540 * slot

Every version 2 record sits exactly on that line, and that places the pages at slots 1, 7, 10, 16, 31, 36, 37, 40, 51 and 54. That covers all five lost slots. The v1 records land a few ticks before the schedule (-9, -4, -9), and the v3 records a few ticks after it (+1, +8), so those are superseded or never-committed writes. There's also a direct cross-check: the radio payload for slot 54 matches 105 v2 and not 105 v1. So the live state is version 2 for every LPN.

I never figured out how the 6-byte tag is built. It's not RS parity, a CRC, or a plain or keyed hash of any of the obvious fields. The schedule was enough to pick the right versions anyway, so I left it.

5. Clock alignment


wall(slot) = 1000000 + 20000 * slot
tick(slot) =  955254 + 19540 * slot

So Af = 19540/20000 = 977/1000 and Bf = 955254 - 977 1000 = -21746, which gives calibration [977, 1000, -21746, 1]. Checking it against slot 1: 977/1000 1020000 - 21746 = 974794, which is exactly the tick of 107 v2.

Solving


100:2:1678234:da6994d5f6c6770a005d;101:2:1267894:c6d4290062ab517a72e5;102:2:1560994:3a200ee7673cfecb836a;
103:2:1092034:b62aa63bf9ab617c088a;104:2:1658694:e5a9524cebbec3112e27;105:2:2010414:2b7fe3bd1dc0d104d5a4;
106:2:1736854:48fb294db4d212eec5ea;107:2:974794:33b5dea168f4e2851f07;108:2:1150654:120b635e3ff56b1f0bd9;
109:2:1951794:5e29349d50c04b4072a1

Full script (solve.py), takes a few seconds:


import sys, struct, hashlib, json
import numpy as np
from fractions import Fraction as F

D = sys.argv[1] if len(sys.argv) > 1 else "."

def xs(s):
    s ^= (s << 13) & 0xffffffff; s ^= s >> 17; s ^= (s << 5) & 0xffffffff
    return s

def stream(seed, n):
    out, s = b"", seed
    while len(out) < n:
        s = xs(s); out += struct.pack("<I", s)
    return out[:n]

fw = open(f"{D}/recorder.fw", "rb").read()
seed, ln = struct.unpack_from("<IH", fw, 5)
pt = bytes(a ^ b for a, b in zip(fw[11:11 + ln], stream(seed, ln)))
sec = {pt[1 + 5*i]: struct.unpack_from("<HH", pt, 2 + 5*i) for i in range(pt[0])}
o = sec[1][0]
prim, fcr, nsym = struct.unpack_from("<HBB", pt, o)
kA, kB = struct.unpack_from("<II", pt, o + 4)
o = sec[2][0]
sps, sync = pt[o + 4], pt[o + 16:o + 20]
o = sec[3][0]
w0, dw, _, nslots = struct.unpack_from("<QIBB", pt, o)

EXP, LOG, x = [0] * 512, [0] * 256, 1
for i in range(255):
    EXP[i] = EXP[i + 255] = x; LOG[x] = i; x <<= 1
    if x & 0x100: x ^= prim
mul = lambda a, b: EXP[LOG[a] + LOG[b]] if a and b else 0
div = lambda a, b: EXP[(LOG[a] - LOG[b]) % 255] if a else 0

def ev(p, x):
    y = 0
    for c in p: y = mul(y, x) ^ c
    return y

def rs_decode(m):
    n = len(m)
    S = [ev(m, EXP[fcr + i]) for i in range(nsym)]
    if not any(S): return m
    C, B, L, k0, b = [1], [1], 0, 1, 1
    for k in range(nsym):
        d = S[k]
        for i in range(1, L + 1): d ^= mul(C[i], S[k - i])
        if not d: k0 += 1; continue
        T = C[:]; C = C + [0] * (len(B) + k0 - len(C))
        for i, v in enumerate(B): C[i + k0] ^= mul(div(d, b), v)
        if 2 * L <= k: L, B, b, k0 = k + 1 - L, T, d, 1
        else: k0 += 1
    pos = [p for p in range(n) if ev(C[::-1], EXP[(n - 1 - p) * 254 % 255]) == 0]
    if len(pos) != L: return None
    Om = [0] * nsym
    for i in range(nsym):
        for j in range(min(len(C), nsym - i)): Om[i + j] ^= mul(S[i], C[j])
    m = list(m)
    for p in pos:
        X, Xi = EXP[n - 1 - p], EXP[(n - 1 - p) * 254 % 255]
        om = ev(Om[::-1], Xi)
        dl = 0
        for i in range(1, len(C), 2): dl ^= mul(C[i], EXP[LOG[Xi] * (i - 1) % 255])
        m[p] ^= mul(div(om, dl), EXP[LOG[X] * (1 - fcr) % 255])
    return None if any(ev(m, EXP[fcr + i]) for i in range(nsym)) else m

def crc16(b):
    c = 0xffff
    for x in b:
        c ^= x << 8
        for _ in range(8): c = ((c << 1) ^ 0x1021 if c & 0x8000 else c << 1) & 0xffff
    return c

perm, s = list(range(20)), kB
for i in range(19, 0, -1):
    s = xs(s); j = s % (i + 1); perm[i], perm[j] = perm[j], perm[i]
white = stream(kA, 14)

iq = np.fromfile(f"{D}/radio.iq", dtype=np.int8).astype(float)
iq = iq[0::2] + 1j * iq[1::2]
sync_syms = [int(f"{int.from_bytes(sync, 'big'):032b}"[i:i + 2], 2) for i in range(0, 32, 2)]
n = np.arange(sps)
tone = lambda sym, f: np.abs([np.dot(sym, np.exp(-2j * np.pi * (f + 4 * k) * n / sps)) for k in range(4)])
radio = {}
for burst in range(nslots):
    b = iq[400 + 6544 * burst:400 + 6544 * burst + 96 * sps].reshape(96, sps)
    f = max(np.arange(0, 48, 0.1), key=lambda f: sum(tone(b[i], f)[sync_syms[i]] for i in range(16)))
    bits = "".join(f"{int(tone(b[i], f).argmax()):02b}" for i in range(16, 96))
    rx = [int(bits[i:i + 8], 2) for i in range(0, 160, 8)]
    cw = [0] * 20
    for k in range(20): cw[perm[k]] = rx[k]
    cw = rs_decode(cw)
    if cw is None: continue
    fr = bytes(a ^ b for a, b in zip(cw[:14], white))
    if crc16(fr[:12]) == int.from_bytes(fr[12:14], "big"): radio[fr[0]] = fr[2:12]

fl = open(f"{D}/flash.img", "rb").read()
recs = []
for p in range(0, len(fl), 128):
    if fl[p:p + 2] == b"YJ":
        lpn, _, ver, tick, ln = struct.unpack_from("<BBIIB", fl, p + 3)
        recs.append((lpn, ver, tick, fl[p + 20:p + 20 + ln]))

pts = sorted((w0 + dw * s, t) for s, pay in radio.items() for _, _, t, p in recs if p == pay)
Af = F(pts[-1][1] - pts[0][1], pts[-1][0] - pts[0][0])
Bf = pts[0][1] - Af * pts[0][0]
live = {}
for lpn, ver, tick, pay in recs:
    slot = ((tick - Bf) / Af - w0) / dw
    if slot.denominator == 1: live[lpn] = (ver, tick, pay, int(slot))

tx = {s: pay for _, _, pay, s in live.values()} | radio
lost = sorted(set(range(nslots)) - set(radio))
body = {
    "transmission_digest": hashlib.sha256(b"".join(struct.pack("<H", s) + tx[s] for s in range(nslots))).hexdigest(),
    "transmission_len": len(tx),
    "ftl_digest": hashlib.sha256(";".join(f"{l}:{v}:{t}:{p.hex()}" for l, (v, t, p, _) in sorted(live.items())).encode()).hexdigest(),
    "calibration": [Af.numerator, Af.denominator, Bf.numerator, Bf.denominator],
    "cadence": [w0, dw],
    "lost_slots": lost,
}
print(json.dumps(body))

Submitting

Submitted body:


{
  "transmission_digest": "838c341337b87fa35216b641218d9ac0e238b3c7a96e85a43db9e5f0c9ddda37",
  "transmission_len": 56,
  "ftl_digest": "66e72f45dd1732e49fd04f7d955a5f0761baa6e5137f1440eda36fdf80c5a07f",
  "calibration": [977, 1000, -21746, 1],
  "cadence": [1000000, 20000],
  "lost_slots": [7, 16, 37, 40, 51]
}

Response:


{"flag":"PINAKA{jatayu_echoes_of_the_fallen_three_timebases_one_truth}",
 "status":"transmission-recovered"}

it gives the flag:


PINAKA{jatayu_echoes_of_the_fallen_three_timebases_one_truth}

Summary

  1. The firmware is XORed with an xorshift32 stream from the seed in its header. Inside are three sections: the FEC params plus an 8-byte key (kA for whitening, kB for interleaving), the radio params (4-FSK, 64 samples/symbol, sync 36c978e1) and the clock (w0 = 1000000, dw = 20000, 56 slots).
  2. The radio has 56 bursts. Demod with a per-burst carrier search on the sync. Bursts 7, 16, 37, 40 and 51 are pure noise, so they're the lost slots.
  3. Every good burst has exactly 2 deliberately corrupted bytes. De-interleave (Fisher-Yates with xorshift32 seeded with kB), then RS(20,14) decode with fcr = 3, then de-whiten with kA. All 51 frames pass their CRC-16.
  4. Flash: the live version of every LPN is v2, because only v2 ticks fall exactly on 955254 + 19540 * slot. v1 lands a few ticks early and v3 a few late. Highest version is the trap.
  5. Calibration [977, 1000, -21746, 1]. Fill the 5 lost slots from flash, hash everything, submit.

Read this post on CyberHx →