$ cat writeup.md…
$ cat writeup.md…
asisctf2026
Task: a headerless 298 MB float32 IQ capture in four blocks (real baseband, constant-envelope PM, conjugate sideband pair), each an SSTV-like image holding one strip of a QR code. Solution: Robot36 sync-anchored FM demod, bang-bang PM period decode, strip stitching into a V3 QR, pyzbar.
Organizer description fragments as recorded in working notes: "…an unusual transmission… …poor condition… …the signal is a mess."
English summary: the entire artifact is one ~299 MB headerless file, challenge.raw inside an xz archive — a raw little-endian float32 stream with no container, no header, and no stated sample rate. The goal is to find and decode the payload carried by this deliberately mangled "transmission".
Artifact facts: challenge.raw.xz → challenge.raw, 298,828,800 bytes,
sha256 c41eb259855150b97cb6ac8bb99268769dd4385936e59af8a38a6140cc529307.
Interpreted as interleaved float32 I/Q: 74,707,200 floats → 37,353,600 complex samples. Quartering the stream shows four structurally distinct blocks:
| block | span | structure | spectrum |
|---|---|---|---|
| 0 | 0–25% | strictly real (Q == 0 exactly), mean ≈ 0.5, swings ±0.32 | audio-like, energy at DC |
| 1 | 25–50% | complex, constant envelope |s| ≈ 0.90 (std 0.013) → pure phase modulation | energy at DC |
| 2 | 50–75% | exact conjugate of block 3 | one-sided lines at −1900/−2300 Hz |
| 3 | 75–100% | exact conjugate of block 2 | one-sided lines at +1900/+2300 Hz |
The conjugacy is exact (correlation 1+0j over multi-million-sample prefixes, MSE ≈ 2e-20): blocks 2 and 3 are the lower and upper sideband copies of one real signal. That literal reading of the title — baseband + LSB + USB + one odd block out, each needing separate "surgery" — is the core insight of the challenge.
Wrap-safe demodulation (see the unwrap trap below) finds, in each block, 239–240 pulses where the instantaneous frequency drops to 1200 Hz for 6–13 ms, and the pulse spacing is exactly 36000 samples. At fs = 240 kHz that is 150.0 ms per line — Robot36-family SSTV timing: 240 lines × 320 px, each line = 9 ms sync @ 1200 Hz + 3 ms porch + 88 ms Y scan (1500 Hz black … 2300 Hz white) + a 44 ms chroma slot (not needed here; the payload is grayscale). Luma is clip((f − f_black)/(f_white − f_black) · 255).
The "signal is a mess" is by design: there is no VIS header for automatic decoders to lock onto, and each of the four blocks mangles the modulation differently.
audio = 1900 − inst) versus the USB copy (1900 + inst). Equivalent shortcut: Re(block2) == Re(block3), and that real part is the original audio, so taking .real of the raw analytic block also works with no mixing. Important: pre-filter with a proper FIR (scipy.signal.firwin, 511 taps, 2800 Hz cutoff) — the initially used 64-tap moving average smears the 2300 Hz white tone into the black level.f = FS/(2h) — h=100 → 1200 Hz sync, h=80 → 1500, h=63 → 1900, h=50 → 2300. Decode by measuring run lengths of constant sign and mapping each run to its frequency. Its frame uses tones {1200, 1900, 2300}: black is shifted from 1500 to 1900 Hz, so its luma mapping must use f_black = 1900.np.unwrap + diff (i.e. diff(unwrap(angle(z)))) fails on this data: on block1 the phase drifts hugely (median step −3.04 rad — a spurious ~−440 Hz rotation), and block0's analytic amplitude dips to near zero, destroying phase continuity. The estimator that always works:
fm = np.angle(z[1:] * np.conj(z[:-1])) # rad/sample, correct by construction inst_hz = fm * FS / (2 * np.pi)
For real signals apply it to hilbert(x) and median-filter the result (window 31) to clean up samples where the analytic amplitude dips.
Sync-anchored luma decode of each block yields a 320 × 239/240 image whose content occupies only a horizontal band of ~45–60 lines: a piece of one QR code. Module pitch ≈ 7.25 lines vertically and 66 samples per pixel horizontally (14.5 px in the 3× renders); each module is voted by the mean ink of its grid cell after thresholding at 128.
The strips, and the QR structure that pins their offsets deterministically:
| block | QR rows | QR cols | identifying structure inside the strip |
|---|---|---|---|
| 0 | 0–5 | 8–28 | top-right finder present; left side absent |
| 1 | 6–14 | 0–28 | first strip row IS timing row 6 (#.#.#... at cols 8–20 + TR finder bottom bar at cols 22–28); timing column 6, rows 8–14 (#.#.#.#) confirms |
| 2 | 15–22 | 0–28 | row 22 = BL finder top bar AND the alignment pattern center row #.#.# at cols 20–24 |
| 3 | 23–28 | 0–28 | row 28 = BL finder bottom bar (its center modules are damaged/erased in this copy) |
6 + 9 + 8 + 6 = 29 rows exactly → a Version-3 (29×29) QR code sliced into four horizontal strips, one per transmission.
ASIS{...} format.Demodulation pipeline (decode_full.py / decode_sideband.py / decode_r36_if.py condensed):
#!/usr/bin/env python3 """Sideband Surgery: IQ quarter-blocks -> sync-anchored SSTV luma strips.""" import numpy as np from PIL import Image from scipy.signal import hilbert, medfilt, firwin, fftconvolve FS = 240_000.0 Q = 9_338_400 # complex samples per quarter block raw = np.memmap("Sideband_Surgery/challenge.raw", dtype="<f4", mode="r") iq = raw[0::2] + 1j * raw[1::2] # 37,353,600 complex samples def inst_hz(x): # wrap-safe: NEVER unwrap(angle) z = hilbert(x) fm = np.angle(z[1:] * np.conj(z[:-1])) return np.append(fm, fm[-1:]) * FS / (2 * np.pi) def bangbang_inst(z): # block1: half-period h encodes f = FS/(2h) fm = np.angle(z[1:] * np.conj(z[:-1])) sgn = np.sign(fm) f, i = np.zeros(len(sgn)), 0 while i < len(sgn): j = i while j < len(sgn) and sgn[j] == sgn[i]: j += 1 f[i:j] = sgn[i] * FS / (2.0 * (j - i)) i = j return f def block_inst(k): # instantaneous frequency of block k z = np.asarray(iq[k*Q:(k+1)*Q]) if k == 1: return bangbang_inst(z) if k in (2, 3): # analytic sideband copy at +/-1900 Hz n = np.arange(len(z)) f0 = 0.007917404174804688 * (1 if k == 3 else -1) z = z * np.exp(-1j * 2 * np.pi * f0 * n) lp = firwin(511, 2800.0 / (FS / 2)) # proper FIR, NOT a moving average return inst_hz(fftconvolve(z.real, lp, "same")) return inst_hz(z.real) # block0: real baseband def find_syncs(inst): # 1200 Hz runs of 6..13 ms low = inst < 1350.0 d = np.diff(np.concatenate([[0], low.astype(np.int8), [0]])) s, e = np.where(d == 1)[0], np.where(d == -1)[0] ok = np.array([a for a, b in zip(s, e) if 0.006*FS <= b-a <= 0.013*FS], dtype=np.int64) if len(ok) > 3: # reject spacing outliers dif = np.diff(ok); med = np.median(dif) ok = ok[np.concatenate([[True], np.abs(dif - med) < 0.06*med])] return ok def luma_frame(inst, f_black): inst = medfilt(inst, 31) syncs = find_syncs(inst) img = np.zeros((len(syncs), 320)) for r, s0 in enumerate(syncs): # skip sync (9 ms) + porch (3 ms) seg = inst[s0 + int(0.012*FS):] for c in range(320): # 88 ms Y scan -> 66 samples/pixel a = int(0.088*FS*c/320); b = int(0.088*FS*(c+1)/320) img[r, c] = np.median(seg[a:b]) return np.clip((img - f_black)/800.0*255, 0, 255).astype(np.uint8) for k, f_black in ((0, 1500.0), (1, 1900.0), (2, 1500.0), (3, 1500.0)): y = luma_frame(block_inst(k), f_black) Image.fromarray(y, "L").resize((320*3, y.shape[0]*3), Image.Resampling.NEAREST).save(f"strip_b{k}.png")
Thresholding + grid fitting (extract_modules.py logic: boundary-gradient grid fit, pitch ≈ 7.25 lines × 14.5 px at 3×, anchors as listed above) produces module bitmaps mod_b0.npy (6×21), mod_b1_9.npy (9×29), mod_b2.npy (8×29), mod_b3.npy (6×29). Assembly and decode:
#!/usr/bin/env python3 """Assemble the Version-3 QR from the four recovered strips and decode.""" import numpy as np from PIL import Image from pyzbar.pyzbar import decode b0 = np.load("mod_b0.npy") # 6x21 -> rows 0-5, cols 8-28 b1 = np.load("mod_b1_9.npy") # 9x29 -> rows 6-14, cols 0-28 b2 = np.load("mod_b2.npy") # 8x29 -> rows 15-22, cols 0-28 b3 = np.load("mod_b3.npy") # 6x29 -> rows 23-28, cols 0-28 N = 29 m = np.full((N, N), -1, dtype=np.int8) m[0:6, 8:29] = b0 m[6:15, 0:29] = b1 m[15:23, 0:29] = b2 m[23:29, 0:29] = b3 FINDER = np.array([[1,1,1,1,1,1,1],[1,0,0,0,0,0,1],[1,0,1,1,1,0,1], [1,0,1,1,1,0,1],[1,0,1,1,1,0,1],[1,0,0,0,0,0,1], [1,1,1,1,1,1,1]], dtype=np.int8) ALIGN = np.array([[1,1,1,1,1],[1,0,0,0,1],[1,0,1,0,1], [1,0,0,0,1],[1,1,1,1,1]], dtype=np.int8) m[0:7, 0:7] = FINDER # TL finder m[0:7, 22:29] = FINDER # TR finder m[22:29, 0:7] = FINDER # BL finder m[7, 0:8] = 0; m[0:8, 7] = 0 # separators m[7, 21:29] = 0; m[0:8, 21] = 0 m[21, 0:8] = 0; m[22:29, 7] = 0 for c in range(8, 21): m[6, c] = 1 if c % 2 == 0 else 0 # timing row for r in range(8, 21): m[r, 6] = 1 if r % 2 == 0 else 0 # timing col m[20:25, 20:25] = ALIGN # alignment pattern m[21, 8] = 1 # dark module canvas = np.full((N + 8, N + 8), 255, dtype=np.uint8) # quiet zone canvas[4:4+N, 4:4+N] = np.where(m == 1, 0, 255) res = decode(Image.fromarray(canvas, "L") .resize(((N+8)*8, (N+8)*8), Image.Resampling.NEAREST)) print(len(res), "symbol(s) decoded") # payload = the flag
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar