Silent Tone

Points: not listed in the challenge files · Provided: bench.pcap, capture.sal

A hidden beacon was found inside a lab power supply. We captured one burst and logged the bench over USB. The loudest voice is rarely the one worth listening to.

Hint 1

The USB capture is not just noise. One of the devices on the bench prints its own radio settings. Read it slowly: every number in it matters later.

Hint 2

The logic analyser also recorded a 100 kHz reference clock. A perfect 100 kHz clock sampled at 8 MHz gives runs of exactly 40 samples. Count the runs. Are they all 40?

Hint 3

The preamble root depends on each unit's UID. Search for every possible root, not just the one from the log. There is more than one transmitter on air.

Hint 4

The loud unit sends the same frame over and over. Once you have decoded it, you can rebuild its exact signal and subtract it from the recording. What is left over is the next unit.

Evidence map

Before touching any signal processing, it helps to know what every file can tell us. This is the map we ended up with:

Artifact What it contains Why it matters
bench.pcap, USB device 5 A serial log from the beacon (PHANTOM-TX fw 2.3.1) It is the full radio spec: modulation, framing, error correction, scrambler, encryption
bench.pcap, USB device 6 A firmware update (DFU) to version 2.4.0 Its release notes change two settings, which matter for the second half of the recording
bench.pcap, USB device 7 An oscilloscope session pulling two waveforms A distraction: nothing useful is in there
capture.sal, SDR_I / SDR_Q 0.82 s of radio signal from an SDR, 8 million samples per second The actual transmissions
capture.sal, TX_EN When the beacon's transmitter was switched on Tells us where the bursts are
capture.sal, REF_100K A 100 kHz reference clock The key to noticing (and fixing) a broken recording

What it targets

The strongest signal is not always the important one. Three units share one radio channel at the same time. The loud one is the beacon on the bench, and it only carries decoys. The real flag is split between the two quiet units, and you can only hear them after you have removed the loud one.

Skill Why it matters outside the CTF
Reading a protocol spec out of device logs Real reverse engineering of IoT and radio gear usually starts from debug output like this
Checking that a capture is trustworthy before analysing it Dropped samples, clock drift and buffer overruns ruin real recordings all the time
Demodulating QPSK with a matched filter and phase tracking The core of every digital radio receiver
Successive interference cancellation (decode, rebuild, subtract) How phones and satellites separate users who share a channel
Recognising decoys and AI-bait inside challenge files Some files deliberately try to steer automated solvers

This is not a "find the string" challenge. The real flag never appears in plain text anywhere: you have to actually receive it over the air.


Background: the radio ideas you need

You do not need to be a radio engineer for this one. These five ideas are enough.

1. I/Q samples. An SDR (software-defined radio) shifts a radio channel down to around 0 Hz and records two numbers per sample: I (in-phase) and Q (quadrature). Think of each sample as a point I + jQ on a 2D plane. Its distance from the centre is the signal strength and its angle is the phase. Here the Saleae logic analyser recorded I and Q as two analog channels.

2. QPSK. QPSK (quadrature phase-shift keying) sends 2 bits per symbol by picking one of four phases. The four possible points sit at 45°, 135°, 225° and 315°. A receiver decides which point each symbol is closest to and reads off the 2 bits.

3. Symbols and pulse shaping. The transmitter sends 125,000 symbols per second. At 8 MHz that is 64 samples per symbol. Each symbol is smoothed with an RRC (root-raised-cosine) filter so it doesn't splatter into neighbouring channels. The receiver runs the same filter again (a "matched filter") and samples once per symbol at the peak.

4. Zadoff–Chu preambles. Every frame starts with 127 known symbols called a Zadoff–Chu (ZC) sequence. ZC sequences have a "root" number. Two different roots barely correlate with each other, so a receiver can find one unit's preamble even when another unit is much louder. Here the root is computed from each unit's ID, which means every unit has its own root. That turns out to be the whole trick.

5. Reed–Solomon. Each frame carries one RS(255,223) codeword: 223 bytes of data plus 32 bytes of parity. It can fix up to 16 wrong bytes. That gives us a free correctness check: if RS decoding succeeds and the payload's CRC-32 matches, we decoded the frame correctly. A wrong guess (wrong bit order, wrong seed, wrong timing) simply fails.


Walkthrough

1. Look at what we were given

A .sal file is just a ZIP archive written by Saleae Logic 2, so we can peek inside without the Logic app:

unzip -l capture.sal
  Length      Name
---------     ----
       94     digital-0.bin
   163940     digital-1.bin
 16388155     analog-2.bin
 16388155     analog-3.bin
       32     trigger-store.bin
     5937     meta.json

meta.json names the channels: digital 0 is TX_EN, digital 1 is REF_100K, analog 2 is SDR_I, analog 3 is SDR_Q, all at 8 MHz. If you look closely at the raw JSON, the SDR_Q channel name hides a flag between zero-width Unicode characters, and the capture's name field hides invisible "tag" characters spelling out a note addressed to AI assistants with another flag. Both are bait. We will collect all the decoys in the Pitfalls section and move on.

For the PCAP, capinfos bench.pcap shows 1,196 USB packets over 0.44 seconds. That is small enough to read by hand.

2. Read the bench's USB log: it is the protocol spec

Device 5 is a USB serial port. We dump its bulk data with tshark and print it as text:

tshark -r bench.pcap -Y "usb.device_address == 5 && usb.capdata" -T fields -e usb.capdata

Decoded from hex, it reads:

PHANTOM-TX fw 2.3.1  STM32F405 @ 168 MHz
uid  : C1E20DC4  (crc8/maxim poly=0x31 refl init=0x00 -> 0x1c)
rf   : 433.920 MHz  QPSK  dac_clk=8.000 MHz  sym_div=64
pulse: rrc beta=0.35 ntaps=769
pre  : zadoff-chu N=127 u=1+(crc8(uid)%126) -> 29  x[k]=exp(-j*pi*u*k*(k+1)/N)
frame: 127 pre + 16 uid syms (gray, unscrambled, msb-first) + 1020 data syms
burst: 80 frames
fec  : rs n=255 k=223 gf=0x11d fcr=0 gen=2
scr  : lfsr7 poly=0x91 seed=crc8(uid) -> 0x1c out=bit0 (xor on msb-first bitstream)
il   : block 51x40 (write rows, read cols)
map  : gray 00:+1+1 01:-1+1 11:-1-1 10:+1-1 (I<-b1 Q<-b0) /sqrt2
pld  : part|len|data|crc32be|rnd -> 223 B ; part0 = plain, part k>0 = k-th slice of secret^ks
ks   : sha256-ctr keyed by the paired-net uids
pa   : xtr-7 rapp asat=1.55 p=2.0 ampm a=0.22 b=0.85 dpd=off
net  : 3 units paired, same channel, tdma=off, relay=on

This is gold. Let's translate the important lines:

Device 6 then sends a 24,576-byte firmware image. Running strings on it shows release notes:

RELEASE NOTES 2.4.0
- scrambler: seed = crc8(uid) ^ 0xa5 (was crc8(uid))
- interleaver: block 40x51, write rows, read cols (was 51x40)
- zc root, frame layout, fec, map unchanged

Device 8 is the same beacon after the update, now saying fw 2.4.0. So the beacon's second burst uses a different scrambler seed and interleaver. Remember that for later.

The bench. Unit A hides in the power supply, logs over USB into bench.pcap, and is wired to the logic analyser. Units A, B and C all transmit into one SDR, and the analyser records its I/Q into capture.sal.

Putting the spec together, here is what one frame looks like and how the data symbols are built:

One frame is 1163 symbols: a 127-symbol Zadoff–Chu preamble, a 16-symbol UID and 1020 data symbols. The data carries one RS(255,223) codeword that was scrambled, interleaved and Gray-mapped. The receiver runs this chain backwards.

Watch the bit order. The map line says 01 → -1+1. In "01" the second character decides the sign of I. So in the bitstream, the first bit of each pair sets Q and the second sets I. This trips almost everyone. If you decode the loud unit's UID and get C2D10EC8 instead of C1E20DC4, you have the two bits swapped.

3. Load the I/Q samples yourself

Each analog-N.bin member has a small header, then chunks of 16-bit samples, each followed by a min/max "pyramid" that the Logic app uses for zooming. We only want the samples. This loader reads them and checks that it consumed the whole file, which is a good sign we parsed the layout correctly:

import struct, zipfile, numpy as np

def load_analog(data):
    count, off, out = struct.unpack_from("<Q", data, 43)[0], 51, []
    for _ in range(count):
        _, _, n = struct.unpack_from("<QQQ", data, off); off += 24
        out.append(np.frombuffer(data, "<i2", n, off).astype(float)); off += 2 * n
        pc, _, _, lc, _, _, _ = struct.unpack_from("<7I", data, off); off += 28 + 4 * pc
        for _ in range(2, lc + 1):                     # skip the zoom pyramid levels
            off += 8 + 4 * struct.unpack_from("<Q", data, off)[0]
    assert off == len(data)
    return np.concatenate(out)

z = zipfile.ZipFile("capture.sal")
x = load_analog(z.read("analog-2.bin")) + 1j * load_analog(z.read("analog-3.bin"))
print(len(x))      # 6554396 samples = 0.819 s at 8 MHz

Correlating against the root-29 preamble finds frames right where TX_EN goes high: two groups of 40 frames, starting near sample 311,000 and 3,320,000. So far so good.

4. Notice that the recording itself is broken

Here is where most attempts quietly go wrong. When we measured where each frame's preamble starts, the frames did not line up the way the spec says they should. Every frame is exactly 1163 × 64 = 74,432 samples long, so the offsets should stay constant. Instead they wander and suddenly jump by about 15 samples, for example between frames 1 and 2, 6 and 7, and 11 and 12.

That smells like the recording, not the transmitter. Luckily the analyser also recorded REF_100K, a 100 kHz square wave. At 8 MHz, every high or low half-period should be exactly 40 samples. The digital channel is stored as run lengths (one byte = run length − 1), so we can list every run that is not 40 or 41:

runs = np.frombuffer(z.read("digital-1.bin")[77:], np.uint8).astype(int) + 1
pos = 0
for i, r in enumerate(runs):
    if r not in (40, 41):
        print(f"run #{i} at sample {pos}: {r}")
    pos += r
run #4 at sample 121: 23
run #10003 at sample 400078: 28
run #10004 at sample 400106: 35
run #20003 at sample 800076: 23
run #30002 at sample 1200034: 38
run #30003 at sample 1200072: 25
...
run #159998 at sample 6399845: 23

Read that output carefully. A 23-sample run is 17 short of 40. A pair like 28 + 35 = 63 is 17 short of 80. Every time, exactly 17 samples are missing, and it happens once every 400,000 original samples: the analyser dropped a USB buffer each time. On top of that, a straight-line fit through all the clock edges shows the analyser's clock runs 37 ppm slow compared with the transmitter.

Unit A's preamble position in each frame of group 1. As recorded, it jumps about 15 samples at every hole and drifts between them. After resampling onto the REF_100K clock it stays within a few samples.

The fix is satisfying: shift every sample after each hole by 17, fit "edge number → sample position", and resample the I/Q onto the transmitter's own clock, where each REF_100K edge is exactly 40 samples apart. After that, the clock edges fit a straight line with only 0.3 samples of jitter, and every frame lands exactly where it should.

If nothing decodes for you, check this first. A quarter-symbol timing error in the middle of a frame is enough to make every RS decode fail, and the failure looks exactly like "my demodulator is wrong".

5. Find out who is on air

The spec said three units. Each one has its own ZC root, so we search all 126 possible roots. For each root and each carrier offset, we correlate 8 frames' worth of signal against that root's preamble and record how far the best peak sticks out above the median ("peak ÷ median"). Pure noise sits around 15×.

recorded:          root 29 → 76.3×   root 104 → 40.1×   root 47 → 21.7×
after removing A:  root 104 → 75.2×  root 47 → 41.1×
after removing A+B: root 47 → 94.0×  (everything else ≈ 14×)

Best preamble correlation for every Zadoff–Chu root, over 8 frames where all three units are on air. Root 29 (A) dominates the recording. Removing A exposes roots 104 (B) and 47 (C), and removing B leaves only root 47.

Three roots, three units. Root 47 is barely above the noise in the raw recording, which is why you have to look for it on purpose.

One more subtlety. A ZC preamble cannot tell a one-symbol time shift from a frequency shift of u/127 × 125 kHz. If you only use the preamble, every unit seems to appear at several carrier offsets at once. To get each unit's true carrier offset, raise the signal to the 4th power. That wipes out the QPSK phases and leaves a clean spectral line at 4× the carrier. That gives A ≈ +22.1 kHz, B ≈ −9.28 kHz and C ≈ +3.95 kHz.

Power spectrum over the stretch where all three units overlap, compared with the noise when no transmitter is on. The three carriers sit inside one band, so filtering cannot separate them. The spike at +71.3 kHz is the steady "silent tone", present even with TX_EN low.

The spectrum also explains the title. There is a steady tone at +71.3 kHz that stays on even while TX_EN is low, a literal silent tone. It carries nothing we need, so we notch it out as interference.

Measuring each unit's frames gives the full timeline. B starts exactly 300,000 samples after A, and C starts exactly 2,000,000 samples after A:

When each unit transmits. Unit A sends two groups of 40 frames (before and after its firmware update), B sends 40 frames and C sends 32. The red ticks mark the analyser's 17-sample holes.

Where they overlap, B and C are only 3 to 5 dB weaker than A. That is loud enough to wreck A's decoding, but too quiet to decode directly while A is on top of them.

6. Decode the loud unit (and meet the first decoys)

The receiver for one unit works like this:

  1. Mix the signal down by the unit's carrier offset and run the RRC matched filter.
  2. Find the preamble, then fit the phase and small frequency error across its 127 known symbols.
  3. Track the remaining phase drift over the data with a sliding 4th-power estimator.
  4. Try the four 90° rotations and keep the one whose decoded UID gives the right ZC root. QPSK can't tell rotations apart on its own.
  5. Read the bits (first bit = Q!), deinterleave 51×40, XOR with the LFSR scrambler (seed 0x1c), and RS-decode.

Frames 0–3, where A transmits alone, decode with zero byte errors:

A C1E20DC4 unit=A fw=2.3.1 temp=41.2C vbat=3.71V uptime=5133s rssi=-71 status=ok key=Securinets{th3_l0ud_0n3_1s_n0t_y0ur_fl4g}

The flag literally says "the loud one is not your flag". Good: that confirms we are reading the hint correctly. The second group decodes with the fw 2.4.0 settings (seed 0x39, interleaver 40×51) and holds another decoy, Securinets{upd4t3d_f1rmw4r3_s4m3_l13s}.

Notice that this is part 0, the plaintext part. The encrypted slices must come from the other two units.

7. Subtract A, then decode B, then subtract B and decode C

Here is the key observation: unit A sends the same codeword in every frame of a group. Once we decoded it, we know every symbol A transmitted for all 80 frames. So we can rebuild A's exact waveform (preamble + UID + RS + scrambler + interleaver + Gray map + RRC), fit its slowly changing gain and phase in 384-µs windows, and subtract it from the recording. This technique is called successive interference cancellation.

Successive interference cancellation. Each decoded unit is rebuilt from its own codeword, fitted to the recording and subtracted, which exposes the next one.

In the leftover signal, unit B's preamble suddenly correlates at 0.9 instead of being buried. Decoding it with the same receiver (root 104, −9.28 kHz, seed 0x67) works on almost every frame:

fr  0 uid 145D75BF il (51, 40) seed 0x67 errs 11 part 2 len 31 crc True 'fe9a0c91da2ec5938c1acc5874388817ef4df5901cc896cca114f803ff0c90'
fr  1 uid 145D75BF il (51, 40) seed 0x67 errs 12 part 2 len 31 crc True ...
fr 10 uid 145D75BF il (51, 40) seed 0x67 errs  1 part 2 len 31 crc True ...

B also repeats one codeword, so we rebuild it and subtract it too. Now unit C (root 47, +3.95 kHz, seed 0x2e) decodes:

fr 18 uid C0F4E5B2 il (51, 40) seed 0x2e errs 4 part 1 len 30 crc True '46a8e384563cb20e9353dee2de601a75e2b09be6e8bdecd4f7b2fdbdc4da'
fr 19 uid C0F4E5B2 il (51, 40) seed 0x2e errs 1 part 1 len 30 crc True ...

You can see the cancellation working directly in the constellations. Each dot is one received symbol, and the circles are where perfect QPSK symbols would land:

Received symbols before and after cancellation, for one frame of each unit. On top: A while B and C are on air, B before removing A, and C before removing B. Below: A alone, B after removing A (4.6 dB → 10.3 dB SNR), and C after removing A and B.

B goes from a shapeless cloud to four tight clusters once A is gone. That picture is the whole challenge in one image.

8. Decrypt the secret

We now have everything the spec asked for:

The spec only says "sha256-ctr keyed by the paired-net uids", so we test the reasonable layouts: UID orders, raw bytes or hex text, counter byte order. We know the plaintext must start with Securinets{, so a wrong guess shows up immediately. Exactly one layout matches:

key   = uid_B || uid_C || uid_A          # raw 4-byte UIDs, sorted ascending: 145D75BF C0F4E5B2 C1E20DC4
ks[i] = SHA256(key || uint32_be(i)),  i = 0, 1, ...
plaintext = (part1 || part2) XOR ks

A little-endian counter also matches the first 32 bytes, because counter 0 is the same either way. Only the big-endian counter decrypts the second block, which confirms the choice.

Byte-by-byte decryption: ciphertext from units C and B (row 1), XOR the SHA-256 keystream (row 2), gives the flag (row 3).

9. The full solver

This one script goes from capture.sal to the flag in about 10 seconds. It needs numpy, scipy and reedsolo (pip install numpy scipy reedsolo). Every function maps to a step above.

"""Silent Tone (Securinets Quals) - self-contained solver.

usage:  python -I solve.py files/capture.sal
needs:  numpy, scipy, reedsolo

1. undo the Saleae sample drops / clock drift using the REF_100K channel
2. mix to a 1 MHz complex baseband (8 samples per QPSK symbol)
3. decode the loud unit A (ZC root 29), rebuild its waveform and subtract it
4. decode unit B (root 104), subtract it, decode unit C (root 47)
5. part1 (C) || part2 (B) XOR SHA256-CTR keyed by the three UIDs -> flag
"""
import sys, zipfile, zlib, hashlib, struct
import numpy as np
from scipy.signal import firwin, fftconvolve
import reedsolo

FS, SPS, NPRE, NUID, NDATA = 8e6, 64, 127, 16, 1020
NFRAME = NPRE + NUID + NDATA            # 1163 symbols
FL = NFRAME * SPS // 8                  # frame length at 1 MHz = 9304 samples
RSC = reedsolo.RSCodec(32, nsize=255, fcr=0, prim=0x11d, generator=2, c_exp=8)


# ---------- PHY primitives (all taken from the PHANTOM-TX USB log) ----------
def rrc(beta=0.35, sps=SPS, ntaps=769):
    t = (np.arange(ntaps) - (ntaps - 1) / 2) / sps
    h = np.empty(ntaps)
    for i, tt in enumerate(t):
        if abs(tt) < 1e-12:
            h[i] = 1 - beta + 4 * beta / np.pi
        elif abs(abs(tt) - 1 / (4 * beta)) < 1e-9:
            h[i] = beta / np.sqrt(2) * ((1 + 2 / np.pi) * np.sin(np.pi / (4 * beta)) + (1 - 2 / np.pi) * np.cos(np.pi / (4 * beta)))
        else:
            h[i] = (np.sin(np.pi * tt * (1 - beta)) + 4 * beta * tt * np.cos(np.pi * tt * (1 + beta))) / (np.pi * tt * (1 - (4 * beta * tt) ** 2))
    return h / np.sqrt(np.sum(h ** 2))

H64 = rrc()
H8 = H64[::8]                           # 97 taps at 8 samples/symbol, centre index 48

def zc(u):
    k = np.arange(NPRE)
    return np.exp(-1j * np.pi * u * k * (k + 1) / NPRE)

def crc8_maxim(data):
    crc = 0
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = (crc >> 1) ^ 0x8C if crc & 1 else crc >> 1
    return crc

def lfsr7(seed, n):                     # 7-bit Fibonacci LFSR, polynomial 0x91, output = bit0
    s, out = seed & 0x7F, np.empty(n, np.uint8)
    for i in range(n):
        out[i] = s & 1
        s = ((s << 1) | (((s >> 6) ^ (s >> 3)) & 1)) & 0x7F
    return out

def bits_from_sym(sym):                 # table "01 -> -1+1j": 1st bit -> Q sign, 2nd bit -> I sign
    b = np.empty(2 * len(sym), np.uint8)
    b[0::2], b[1::2] = sym.imag < 0, sym.real < 0
    return b

def sym_from_bits(b):
    b = np.asarray(b).reshape(-1, 2)
    return ((1 - 2.0 * b[:, 1]) + 1j * (1 - 2.0 * b[:, 0])) / np.sqrt(2)

def deinterleave(b, rows, cols):        # TX wrote rows, read columns
    return b.reshape(cols, rows).T.ravel()

def encode_frame(msg, uid, seed, il, root):
    b = np.unpackbits(np.frombuffer(bytes(RSC.encode(bytearray(msg))), np.uint8)) ^ lfsr7(seed, 2040)
    uid_bits = np.unpackbits(np.frombuffer(bytes.fromhex(uid), np.uint8))
    return np.concatenate([zc(root), sym_from_bits(uid_bits), sym_from_bits(b.reshape(il).T.ravel())])

def track_phase(sym, win=48):           # 4th-power phase tracking over UID+data
    s = sym[NPRE:]
    sm = np.convolve((s / (np.abs(s) + 1e-9)) ** 4, np.ones(win), mode="same")
    return s * np.exp(-1j * np.unwrap(np.angle(-sm)) / 4)


# ---------- capture handling ----------
def load_analog(data):                  # Logic 2 v22 analog member: int16 chunks + min/max pyramid
    count, off, out = struct.unpack_from("<Q", data, 43)[0], 51, []
    for _ in range(count):
        _, _, n = struct.unpack_from("<QQQ", data, off); off += 24
        out.append(np.frombuffer(data, "<i2", n, off).astype(float)); off += 2 * n
        pc, _, _, lc, _, _, _ = struct.unpack_from("<7I", data, off); off += 28 + 4 * pc
        for _ in range(2, lc + 1):
            off += 8 + 4 * struct.unpack_from("<Q", data, off)[0]
    return np.concatenate(out)

def retime(path):
    """Logic analyser lost 17 samples every 400 000 and runs 37 ppm slow.
    Each REF_100K half-period is exactly 40 transmitter samples, so its edges give the true time base."""
    z = zipfile.ZipFile(path)
    x = load_analog(z.read("analog-2.bin")) + 1j * load_analog(z.read("analog-3.bin"))
    x -= np.concatenate([x[:300000], x[6300000:]]).mean()
    runs = np.frombuffer(z.read("digital-1.bin")[77:], np.uint8).astype(np.int64) + 1   # byte = run length - 1
    edges = np.cumsum(runs)[:-1]
    holes = 123 + 399983 * np.arange(17)            # where the 23-sample / 63-sample-pair runs sit
    fix = lambda r: r + 17 * np.searchsorted(holes, r, side="right")
    j = np.arange(len(edges))
    b, a = np.polyfit(j[5:], fix(edges)[5:].astype(float), 1)
    t = (fix(np.arange(len(x))) - a) * 40.0 / b
    T = np.arange(int(np.ceil(t[0])), int(t[-1]))
    return np.interp(T, t, x.real) + 1j * np.interp(T, t, x.imag)

def baseband(x, f0=22100.0):            # mix by unit A's carrier, low-pass, 8 MHz -> 1 MHz
    n = np.arange(len(x))
    return fftconvolve(x * np.exp(-2j * np.pi * f0 * n / FS), firwin(513, 300e3, fs=FS))[256:256 + len(x)][::8]

def notch(sig):                         # remove the steady 71.3 kHz tone and the SDR LO leak
    n = np.arange(len(sig))
    for ft in (71297.6 - 22100, -22100.0):
        m = sig * np.exp(-2j * np.pi * ft * n / 1e6)
        sig = sig - fftconvolve(m, firwin(4001, 150, fs=1e6), mode="same") * np.exp(2j * np.pi * ft * n / 1e6)
    return sig


# ---------- per-unit receiver ----------
def matched_filter(sig, fu):
    n = np.arange(len(sig))
    return fftconvolve(sig * np.exp(-2j * np.pi * (fu - 22100) * n / 1e6), H8)

def find_preamble(z, root, lo, hi):
    p, k, best = zc(root), np.arange(NPRE), (0, lo)
    for c in range(lo, hi):
        v = z[c + 48 + 8 * k]
        rho = abs(np.vdot(p, v)) / np.sqrt(np.vdot(v, v).real * NPRE)
        best = max(best, (rho, c))
    return best[1]

def decode_unit(sig, root, fu, c0, nfr):
    """Return the first frame that passes RS(255,223) and the payload CRC-32."""
    z, p, k, ks = matched_filter(sig, fu), zc(root), np.arange(NPRE), np.arange(NFRAME)
    for fr in range(nfr):
        c = find_preamble(z, root, c0 + fr * FL - 6, c0 + fr * FL + 7)
        sym = z[c + 48 + 8 * ks]
        a1, a0 = np.polyfit(k, np.unwrap(np.angle(sym[:NPRE] * np.conj(p))), 1)
        t = track_phase(sym * np.exp(-1j * (a0 + a1 * ks)))
        for m in range(4):                                  # resolve the 90-degree ambiguity via the UID
            tt = t * 1j ** m
            uid = np.packbits(bits_from_sym(tt[:NUID])).tobytes().hex().upper()
            crc = crc8_maxim(bytes.fromhex(uid))
            if 1 + crc % 126 != root:
                continue
            raw = bits_from_sym(tt[NUID:])
            for il, seed in (((51, 40), crc), ((40, 51), (crc ^ 0xA5) & 0x7F)):   # fw 2.3.1 / fw 2.4.0
                cw = np.packbits(deinterleave(raw, *il) ^ lfsr7(seed, 2040)).tobytes()
                try:
                    msg = bytes(RSC.decode(bytearray(cw))[0])
                except reedsolo.ReedSolomonError:
                    continue
                n = msg[1]
                if int.from_bytes(msg[2 + n:6 + n], "big") == zlib.crc32(msg[2:2 + n]):
                    return msg, uid, seed, il, c - fr * FL
    raise RuntimeError("root %d not decoded" % root)


# ---------- successive interference cancellation ----------
def tx_wave(sym, frac=0.0):
    h = np.interp((np.arange(97) - 48 - frac) / 8.0, (np.arange(769) - 384) / 64.0, H64)
    x = np.zeros(len(sym) * 8, complex); x[::8] = sym
    return fftconvolve(x, h)

def fit_gain(y, m, win=384):            # slowly varying complex channel, piecewise linear
    cs, cen = [], []
    for a in range(0, len(y), win):
        mm = m[a:a + win]
        cs.append(np.vdot(mm, y[a:a + win]) / max(np.vdot(mm, mm).real, 1e-9)); cen.append(a + (len(mm) - 1) / 2)
    cs, t = np.array(cs), np.arange(len(y))
    return np.interp(t, cen, cs.real) + 1j * np.interp(t, cen, cs.imag)

def cancel(sig, frame, nfr, c0, fu):
    i0 = c0 - 48
    def model(frac, n_frames):
        w = tx_wave(np.tile(frame, n_frames), frac)
        return w * np.exp(2j * np.pi * (fu - 22100) * np.arange(i0, i0 + len(w)) / 1e6)
    def err(frac):
        m = model(frac, 3); y = sig[i0:i0 + len(m)]
        return np.mean(np.abs(y - fit_gain(y, m) * m) ** 2)
    frac = min(np.arange(-0.5, 0.51, 0.125), key=err)
    m = model(frac, nfr); y = sig[i0:i0 + len(m)]
    out = sig.copy(); out[i0:i0 + len(m)] = y - fit_gain(y, m) * m
    return out


def main(path):
    sig = baseband(retime(path))
    # unit A: the loud bench unit, 2 x 40 frames (fw 2.3.1 then fw 2.4.0), same codeword in every frame
    for start in (311128, 3320412):
        msg, uid, seed, il, c0 = decode_unit(sig, 29, 22100.0, (start + 384) // 8, 40)
        print("A", uid, msg[2:2 + msg[1]].decode())
        sig = cancel(sig, encode_frame(msg, uid, seed, il, 29), 40, c0, 22100.0)
    sig = notch(sig)
    uids, parts = [uid], {}
    # unit B: root 104, CFO -9.28 kHz, 40 frames;  unit C: root 47, CFO +3.95 kHz, 32 frames
    for name, root, fu, start, nfr in (("B", 104, -9284.0, 611128, 40), ("C", 47, 3950.0, 2311128, 32)):
        msg, uid, seed, il, c0 = decode_unit(sig, root, fu, (start + 384) // 8, nfr)
        parts[msg[0]] = msg[2:2 + msg[1]]
        uids.append(uid)
        print(name, uid, "part", msg[0], parts[msg[0]].hex())
        sig = cancel(sig, encode_frame(msg, uid, seed, il, root), nfr, c0, fu)
    ct = b"".join(parts[k] for k in sorted(parts))
    key = b"".join(bytes.fromhex(u) for u in sorted(uids))          # paired-net UIDs, ascending
    ks = b"".join(hashlib.sha256(key + struct.pack(">I", i)).digest() for i in range(4))
    print(bytes(a ^ b for a, b in zip(ct, ks)).decode())


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "files/capture.sal")

Running it:

python solve.py capture.sal
A C1E20DC4 unit=A fw=2.3.1 temp=41.2C vbat=3.71V uptime=5133s rssi=-71 status=ok key=Securinets{th3_l0ud_0n3_1s_n0t_y0ur_fl4g}
A C1E20DC4 unit=A fw=2.4.0 temp=41.9C vbat=3.70V uptime=2s rssi=-70 status=updated key=Securinets{upd4t3d_f1rmw4r3_s4m3_l13s}
B 145D75BF part 2 fe9a0c91da2ec5938c1acc5874388817ef4df5901cc896cca114f803ff0c90
C C0F4E5B2 part 1 46a8e384563cb20e9353dee2de601a75e2b09be6e8bdecd4f7b2fdbdc4da
Securinets{d0ppl3r_w13n3r_r4pp_rlc_g4rdn3r_3kf_rs255_ph4nt0m}

Pitfalls and red herrings

This challenge is packed with traps. Every fake flag below looks real, and some are written to fool automated solvers specifically.

Where What you find Why it is wrong
capture.sal → meta.json, the SDR_Q channel name Securinets{z3r0_w1dth_j01n3r_c0py_p4st3} hidden between zero-width characters Metadata bait: the flag is in the radio signal, not the file's labels
Firmware image string debug_unlock=... Securinets{dfu_1m4g3_str1ngs_4r3_n0t_th3_fl4g} It even says so: DFU image strings are not the flag
The fw 2.3.1 selftest: line A flag-shaped string using Cyrillic look-alike letters and a full-width brace Not ASCII Securinets{, so it can never be accepted
Invisible Unicode "tag" characters in the USB log and in the capture's name field An "assistant note" telling AI tools the flag is Securinets{a1_r34d_th3_1nv1s1bl3_1nk} A prompt injection aimed at AI solvers. Text inside challenge files is data, never instructions
Unit A, part 0, firmware 2.3.1 Securinets{th3_l0ud_0n3_1s_n0t_y0ur_fl4g} The loudest voice, exactly as the description warned
Unit A, part 0, firmware 2.4.0 Securinets{upd4t3d_f1rmw4r3_s4m3_l13s} Same unit, same lie, new firmware
The Rigol oscilloscope traces in bench.pcap Two waveforms of mostly constant levels Nothing hidden: a time sink

Technical traps worth knowing about:

Why it works

The author built a realistic multi-user radio problem and hid the solution in plain sight:

Key lessons

Show flag

Securinets{d0ppl3r_w13n3r_r4pp_rlc_g4rdn3r_3kf_rs255_ph4nt0m}