# 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.
```

![nc output showing the IQ stream banner](https://git.espilon.net/Eun0us/ESPILON-CTF-2026-Writeups/raw/branch/main/screens/accela_nc.png)

### 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.

![spectrogram showing upchirp preamble and downchirp sync patterns](https://git.espilon.net/Eun0us/ESPILON-CTF-2026-Writeups/raw/branch/main/screens/accela_spectrogram.png)

### 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))
```

![Python decoder output showing decoded symbols and CRC16 validation pass](https://git.espilon.net/Eun0us/ESPILON-CTF-2026-Writeups/raw/branch/main/screens/accela_decoder.png)

### 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())
```

![script printing the decrypted flag from the data frame](https://git.espilon.net/Eun0us/ESPILON-CTF-2026-Writeups/raw/branch/main/screens/accela_flag.png)

### 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}`
