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:
net: 3 units paired, same channel, tdma=off: three transmitters, one frequency, no taking turns. They talk over each other. This is the line that explains the whole challenge.pre: ... u=1+(crc8(uid)%126): each unit's preamble root comes from its UID. This unit's is 29, and the other two will have different roots.pld: ... part0 = plain, part k>0 = k-th slice of secret^ks: the secret is split into slices, and the slices are XOR-encrypted.ks: sha256-ctr keyed by the paired-net uids: to decrypt we need the UIDs of all three units, not just this one.
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.
Putting the spec together, here is what one frame looks like and how the data symbols are built:
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 getC2D10EC8instead ofC1E20DC4, 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.

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×)

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.

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:

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:
- Mix the signal down by the unit's carrier offset and run the RRC matched filter.
- Find the preamble, then fit the phase and small frequency error across its 127 known symbols.
- Track the remaining phase drift over the data with a sliding 4th-power estimator.
- 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.
- 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.
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:

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:
- part 1, 30 bytes from unit C
- part 2, 31 bytes from unit B
- all three UIDs:
C1E20DC4,145D75BF,C0F4E5B2
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.

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:
- Not repairing the sample drops. Everything downstream fails, and it looks like a demodulator bug.
- Swapping the QPSK bit order. The UID reads
C2D10EC8and nothing decodes. - Using the old firmware settings for the second group. Unit A's second burst uses seed
crc8 ^ 0xa5(7 bits:0x39) and a 40×51 interleaver. - Trusting near-miss RS decodes. Codewords that only decode right at the 16-error limit can be false positives. Always also check the CRC-32.
- Over-modelling. The spec mentions a Rapp power amplifier. Fitting that model made no measurable difference to cancellation, so plain linear subtraction is enough.
Why it works
The author built a realistic multi-user radio problem and hid the solution in plain sight:
- Each unit's preamble root depends on its UID, so the three preambles are nearly orthogonal. A root search finds all three even when one is much louder.
- Every unit repeats one codeword per group. Once a unit is decoded, its transmission is completely known and can be subtracted almost perfectly.
- The secret is split across the two quiet units and keyed by all three UIDs. You cannot shortcut by decoding only the loud unit: you need every voice in the room.
- The
REF_100Kchannel is the author's gift. It looks like a boring clock, but it is the only way to discover and undo the analyser's dropped samples.
Key lessons
- Read the device logs first. Here they were a complete protocol spec.
- Validate the capture before trusting it. A reference clock lets you check every sample's timing.
- Strong interference can be a resource. If you can decode it, you can subtract it.
- Let the error-correcting code tell you when you are right. RS plus CRC-32 is a perfect oracle for parameter guesses.
- Treat every flag-shaped string as suspicious until it comes out of the real data path, and never follow instructions written inside challenge files.
Show flag
Securinets{d0ppl3r_w13n3r_r4pp_rlc_g4rdn3r_3kf_rs255_ph4nt0m}