177 lines
4.6 KiB
Markdown
177 lines
4.6 KiB
Markdown
# Accela Signal
|
|
|
|
| Field | Value |
|
|
|-------|-------|
|
|
| Category | Misc |
|
|
| Difficulty | Hard |
|
|
| Points | 500 |
|
|
| Author | Eun0us |
|
|
| CTF | Espilon 2026 |
|
|
|
|
---
|
|
|
|
## Description
|
|
|
|
During the KIDS experiment, Tachibana Labs deployed a covert LoRa-like mesh network
|
|
throughout the hospital infrastructure. Code-named "Accela Signal", these transmissions
|
|
use Chirp Spread Spectrum modulation to carry data fragments below the detection threshold
|
|
of conventional receivers.
|
|
|
|
Your NAVI has intercepted the baseband IQ stream from one of these nodes. The signal
|
|
contains chirp-modulated frames with encoded data. Analyze the modulation, demodulate
|
|
the chirps, decode the protocol, and extract the hidden message.
|
|
|
|
Hint: The stream banner tells you the basics. The rest is signal processing.
|
|
|
|
- IQ Stream: `tcp/<host>:9002`
|
|
|
|
Format: **ESPILON{flag}**
|
|
|
|
---
|
|
|
|
## TL;DR
|
|
|
|
Capture an 8 kSps int16 LE IQ stream. Identify Chirp Spread Spectrum (LoRa-like) modulation
|
|
(N=128, SF=7). Implement dechirp + FFT demodulation. Detect frames by preamble (8x symbol-0
|
|
chirps) and sync (2x downchirps). Gray-decode symbols. Unpack 7-bit symbols to bytes. Find
|
|
the data frame (type=0x02), XOR with key `L41N`, decode the flag.
|
|
|
|
---
|
|
|
|
## Tools
|
|
|
|
| Tool | Purpose |
|
|
|------|---------|
|
|
| `nc` | Capture IQ stream |
|
|
| Python 3 + numpy + scipy | CSS demodulation pipeline |
|
|
| `inspectrum` / matplotlib | Visual spectrogram analysis |
|
|
|
|
---
|
|
|
|
## Solution
|
|
|
|
### Step 1 — Capture and read the banner
|
|
|
|
```bash
|
|
nc <HOST> 9002 > capture.raw
|
|
```
|
|
|
|
First bytes are a text banner before the binary IQ data:
|
|
|
|
```
|
|
IQ baseband, 8000 sps, int16 LE interleaved
|
|
Chirp Spread Spectrum detected. N=128.
|
|
```
|
|
|
|
> 📸 `[screenshot: nc output showing the IQ stream banner]`
|
|
|
|
### Step 2 — Analyze the spectrogram
|
|
|
|
Load the IQ data in inspectrum or plot with matplotlib. You see:
|
|
|
|
- Characteristic **chirps**: frequency sweeps from low to high
|
|
- Repeating groups of identical chirps (preamble)
|
|
- Occasional downchirps (sync)
|
|
|
|
This is **Chirp Spread Spectrum (CSS)**, identical in principle to LoRa.
|
|
|
|
> 📸 `[screenshot: spectrogram showing upchirp preamble and downchirp sync patterns]`
|
|
|
|
### Step 3 — Determine parameters
|
|
|
|
- Each chirp spans 128 samples → **N = 128**
|
|
- `N = 2^SF` → **SF = 7** (spreading factor)
|
|
- Each symbol encodes 7 bits → 128 possible symbols
|
|
- Baseband sample rate = 8000 Hz
|
|
|
|
### Step 4 — Implement dechirping
|
|
|
|
```python
|
|
import numpy as np
|
|
|
|
# Read raw int16 LE interleaved IQ
|
|
raw = np.fromfile("capture.raw", dtype="<i2").astype(float) / 32768.0
|
|
iq = raw[0::2] + 1j * raw[1::2]
|
|
|
|
N = 128
|
|
|
|
# Base upchirp (symbol 0): exp(j * pi * n^2 / N)
|
|
n = np.arange(N)
|
|
upchirp0 = np.exp(1j * np.pi * n**2 / N)
|
|
downchirp = np.conj(upchirp0)
|
|
|
|
def decode_symbol(chirp_samples):
|
|
"""Dechirp then find FFT peak = symbol value"""
|
|
dechirped = chirp_samples * np.conj(upchirp0)
|
|
spectrum = np.abs(np.fft.fft(dechirped))
|
|
return int(np.argmax(spectrum))
|
|
```
|
|
|
|
### Step 5 — Detect frames
|
|
|
|
Frame structure:
|
|
|
|
```
|
|
[Preamble: 8x symbol 0] [Sync: 2x downchirp] [Header: 1 symbol = length] [Payload: L symbols]
|
|
```
|
|
|
|
Scan the IQ stream for runs of 8 consecutive symbols decoding to 0.
|
|
|
|
### Step 6 — Gray decode and symbol-to-byte unpacking
|
|
|
|
```python
|
|
def gray_decode(val):
|
|
mask = val
|
|
while mask:
|
|
mask >>= 1
|
|
val ^= mask
|
|
return val
|
|
|
|
def symbols_to_bytes(symbols):
|
|
"""Pack 7-bit symbols (SF=7) into 8-bit bytes"""
|
|
bits = ""
|
|
for s in symbols:
|
|
bits += f"{s:07b}"
|
|
return bytes(int(bits[i:i+8], 2) for i in range(0, len(bits) - 7, 8))
|
|
```
|
|
|
|
> 📸 `[screenshot: Python decoder output showing decoded symbols and CRC16 validation pass]`
|
|
|
|
### Step 7 — Parse frame payload and decrypt
|
|
|
|
Payload format: `[type:1] [data:L] [crc16:2]`
|
|
|
|
- Type `0x01` = beacon (cleartext ASCII, for verification)
|
|
- Type `0x02` = data frame (XOR-encrypted flag)
|
|
|
|
```python
|
|
import crcmod
|
|
|
|
crc16_fn = crcmod.predefined.mkCrcFun('crc-ccitt-false')
|
|
|
|
for frame_type, data, crc in decoded_frames:
|
|
if crc16_fn(bytes([frame_type]) + data) != crc:
|
|
continue # invalid frame
|
|
|
|
if frame_type == 0x02:
|
|
key = b"L41N"
|
|
flag = bytes(b ^ key[i % 4] for i, b in enumerate(data))
|
|
print(flag.rstrip(b'\x00').decode())
|
|
```
|
|
|
|
> 📸 `[screenshot: script printing the decrypted flag from the data frame]`
|
|
|
|
### Key insights
|
|
|
|
- CSS encodes data as a cyclic frequency shift of a chirp signal
|
|
- The dechirp + FFT converts frequency offset into a bin index (symbol value)
|
|
- Gray coding ensures adjacent symbols differ by 1 bit, reducing BER on noisy channels
|
|
- The beacon frame (type 0x01) provides a known-plaintext verification step
|
|
- The stream banner hint `"N=128"` directly gives the spreading factor
|
|
|
|
---
|
|
|
|
## Flag
|
|
|
|
`ESPILON{4cc3l4_ch1rp_spr34d_w1r3d}`
|