Neven Marić
Hidden Investigations CTF 2026

MiscHidden Investigations CTF 2026

Dead Air

Station KESTREL's last recording is a stereo WAV carrying two channels of information, one for each operator. The left channel is audible 700 Hz Morse ("one spoke out loud"); it gives a passphrase.

  • morse
  • spectrogram
  • steganography
  • vigenere
  • glyph-matching
  • wav
  • 7z-encrypted

What this one teaches

  • Checking each channel of a stereo file independently — one carries audible Morse, the other hides text in its spectrogram.
  • Matching stroke glyphs by pixel overlap with rotation/flip variants and scipy binary dilation for tolerance.
  • Applying Vigenère over a non-standard alphabet (mod 37) when the code pad includes digits and underscore.

Task description

At 03:17 UTC, a shortwave station that had sent the same pattern for eleven years went silent. One recording survived the shutdown.

Two operators were on shift. One spoke out loud. The other never made a sound you could hear.

Find out what Station KESTREL was really sending.

Approach

Left channel — Morse (the operator who spoke)

The left channel is a 700 Hz CW tone. Energy segmentation and standard dit/dah timing decode it:

import sys, wave
import numpy as np

w = wave.open(sys.argv[1]); sr = w.getframerate()
d = np.frombuffer(w.readframes(w.getnframes()), dtype='<i2').reshape(-1, 2).astype(float)
L = d[:, 0]

win = sr // 200
e = np.sqrt(np.convolve(L**2, np.ones(win)/win, 'same'))
on = e > e.max() / 2
idx = np.flatnonzero(np.diff(on.astype(int))) + 1
b = np.concatenate([[0], idx, [len(on)]])
runs = [(on[b[i]], (b[i+1]-b[i])/sr) for i in range(len(b)-1)]

on_d = [r[1] for r in runs if r[0]]; u = min(on_d)
M = {'.-':'A','-...':'B','-.-.':'C','-..':'D','.':'E','..-.':'F',
     '--.':'G','....':'H','..':'I','.---':'J','-.-':'K','.-..':'L',
     '--':'M','-.':'N','---':'O','.--.':'P','--.-':'Q','.-.':'R',
     '...':'S','-':'T','..-':'U','...-':'V','.--':'W','-..-':'X',
     '-.--':'Y','--..':'Z'}
out = ''; cur = ''
for st, dur in runs[1:-1]:
    k = dur / u
    if st:
        cur += '.' if k < 2 else '-'
    else:
        if k > 5:
            out += M.get(cur, '['+cur+']') + ' '; cur = ''
        elif k > 2:
            out += M.get(cur, '['+cur+']'); cur = ''
out += M.get(cur, '['+cur+']')
print(out)

Result: KESTREL STATION OFF AIR KEY IS NIGHTJAR LISTEN WITH YOUR EYES. The Vigenère key is NIGHTJAR, and “listen with your eyes” points at the spectrogram.

Right channel — spectrogram text (the silent operator)

The right channel sounds like noise but its spectrogram draws text in the 5–13 kHz band:

Whitened spectrogram of left (noise only) and right (text KSTR-7742-VXQ visible in the 5–13 kHz band) channels

The text reads KSTR-7742-VXQ.

Open the 7z

kestrel_logbook.7z is AES-encrypted with encrypted headers. KSTR-7742-VXQ is the password. Inside:

28 stroke glyphs in a grid, titled STATION KESTREL - LAST LOG ENTRY - 03:17 UTC

37 labelled glyphs (0–36) mapping to A–Z, 0–9, underscore — the operator’s code pad

Glyph matching

Each log-entry glyph is matched to a code-pad glyph by pixel overlap, trying all rotation/flip variants and using binary dilation for tolerance:

import sys
import numpy as np
from PIL import Image
from scipy.ndimage import binary_dilation

def cells(path, n):
    im = np.asarray(Image.open(path).convert('L')).astype(float)
    out = []
    for k in range(n):
        r, c = divmod(k, 10)
        x0 = 40 + 130*c; y0 = 110 + 160*r
        cell = im[y0+8:y0+108, x0+10:x0+110]
        out.append((cell < 100).astype(float))
    return out

lb = cells(sys.argv[1], 28)  # logbook glyphs
cp = cells(sys.argv[2], 37)  # codepad glyphs
alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'

def tf(a):
    res = {}
    for k in range(4):
        r = np.rot90(a, k)
        res['r%d' % (90*k)] = r
        res['r%dF' % (90*k)] = np.fliplr(r)
    return res

def score(a, b):
    A = binary_dilation(a > 0, iterations=3)
    B = binary_dilation(b > 0, iterations=3)
    return (A & B).sum() / (A | B).sum()

idxs = []
for i, g in enumerate(lb):
    best = []
    for name, t in tf(g).items():
        for j, c in enumerate(cp):
            best.append((score(t, c), j, name))
    best.sort(reverse=True)
    idxs.append(best[0][1])

print(''.join(alpha[j] for j in idxs))

The log entry resolves to code-pad indices 31 35 17 36 32 11 29 16 35 1 24 6 1 16 29 16 25 0 24 25 12 15 29 16 8 25 35 28, i.e. ciphertext 59R_6L3Q9BYGBQ3QZAYZMP3QIZ92.

Vigenère mod 37

Over the 37-symbol code-pad alphabet, plaintext = (cipher − key) mod 37 with key NIGHTJAR gives: S1L3NC3_W4S_TH3_M3SS4G3_6R3V.

Case fix (important)

The code pad only contains uppercase letters, so the decode necessarily comes out uppercase. The uppercase flag hiCTF{S1L3NC3_W4S_TH3_M3SS4G3_6R3V} was rejected. The platform’s accepted flag is the lowercase form, so the alphabetic characters must be lowercased when assembling the flag.

Lessons

  • Always check each channel of a stereo file independently — different information can ride on left and right.
  • “Listen with your eyes” is a standard hint for spectrogram steganography.
  • When matching hand-drawn glyphs, try all rotation/flip variants with dilation tolerance rather than exact pixel comparison.
The flagRevealhiCTF{s1l3nc3_w4s_th3_m3ss4g3_6r3v}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.