ESPILON-CTF-2026-Writeups/IoT/Wired_Airwave_013
2026-03-26 17:33:39 +00:00
..
README.md write-up: IoT/Wired_Airwave_013/README.md 2026-03-26 17:33:39 +00:00

Wired Airwave 013

Field Value
Category IoT
Difficulty Medium
Points 500
Author Eun0us
CTF Espilon 2026

Description

Clinique Sainte-Mika uses a wireless maintenance channel for Room 013 monitors. The RF backend exposes raw baseband IQ over TCP.

Your objective:

  1. Decode the FSK bursts from the IQ stream.
  2. Recover the maintenance token hidden in service frames.
  3. Submit the token on the control console.
  • IQ Stream: tcp/<host>:9001
  • Maintenance Console: tcp/<host>:31337

Format: ESPILON{flag}


TL;DR

Capture the raw int8 IQ stream (interleaved I/Q). Implement differential FSK demodulation. Locate frames using preamble + sync markers. XOR-deobfuscate with key WIREDMED13. Verify CRC16-CCITT. Reassemble maintenance frame parts (P1:0BS3RV3 + P2:-L41N-868) into token 0BS3RV3-L41N-868. Submit to the console.


Tools

Tool Purpose
nc Capture IQ stream and connect to console
Python 3 + numpy FSK demodulation and frame parsing
CRC16-CCITT library Frame validation

Solution

Step 1 — Capture the IQ stream

nc <host> 9001 > capture.raw
# Wait a few seconds, then Ctrl+C

The stream begins with a text banner:

IQ stream — int8 interleaved, samplerate=200000, encoding=2-FSK

After the banner, raw binary IQ data follows. Save after the newline.

📸 [screenshot: nc output showing the IQ stream banner before binary data]

Step 2 — Demodulate the 2-FSK signal

import numpy as np

with open("capture.raw", "rb") as f:
    raw = np.frombuffer(f.read(), dtype=np.int8).astype(float)

# Reconstruct complex samples from interleaved I/Q
samples = raw[0::2] + 1j * raw[1::2]

# Differential FSK demodulation: sign of imag(s[n] * conj(s[n-1]))
diff = samples[1:] * np.conj(samples[:-1])
bits_raw = (np.imag(diff) > 0).astype(int)

# Symbol slicing at 40 samples per symbol
SAMPLES_PER_SYMBOL = 40
symbols = []
for i in range(0, len(bits_raw) - SAMPLES_PER_SYMBOL, SAMPLES_PER_SYMBOL):
    chunk = bits_raw[i:i+SAMPLES_PER_SYMBOL]
    symbols.append(int(np.mean(chunk) > 0.5))

Step 3 — Find and parse frames

Look for the preamble pattern (eight 1s then a sync marker). Once found, read the 20-byte obfuscated payload.

📸 [screenshot: spectrogram of IQ data showing FSK burst patterns]

Step 4 — XOR-deobfuscate and verify CRC

import crcmod

crc16 = crcmod.predefined.mkCrcFun('crc-ccitt-false')

KEY = b"WIREDMED13"

for frame in detected_frames:
    payload = bytes(frame[:20])
    deobf = bytes(b ^ KEY[i % len(KEY)] for i, b in enumerate(payload))

    frame_type = deobf[0]
    counter    = deobf[1]
    data       = deobf[2:18]
    crc        = (deobf[18] << 8) | deobf[19]

    calculated = crc16(deobf[:18])
    if calculated == crc:
        print(f"type={frame_type:02x} data={data}")

Step 5 — Collect maintenance frame parts

Valid decoded maintenance frames produce:

type=0x10 data=P1:0BS3RV3
type=0x10 data=P2:-L41N-868

Telemetry frames (type=0x01) are noise for this challenge.

Token = 0BS3RV3-L41N-868

📸 [screenshot: decoded frame output showing the two token parts]

Step 6 — Submit to the console

nc <host> 31337
unlock 0BS3RV3-L41N-868

The server returns the flag.

📸 [screenshot: maintenance console returning the flag after unlock]


Flag

ESPILON{sdr_fsk_w1r3d_m3d_013}