write-up: Misc/Accela_Signal/README.md

This commit is contained in:
Eun0us 2026-03-26 17:33:40 +00:00
parent 188d3d7f93
commit 5d14fe51df

View File

@ -1,65 +1,124 @@
# Accela Signal -- Solution
# Accela Signal
## Overview
A LoRa-like Chirp Spread Spectrum (CSS) IQ stream containing two types of frames:
beacon (cleartext) and data (XOR-encrypted flag). Players must implement CSS
demodulation from scratch to decode the frames.
| Field | Value |
|-------|-------|
| Category | Misc |
| Difficulty | Hard |
| Points | 500 |
| Author | Eun0us |
| CTF | Espilon 2026 |
## Steps
---
## 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
### 1. Capture IQ Stream
Connect to TCP port 9002. A text banner appears first, followed by raw IQ data.
```bash
nc HOST 9002 > capture.raw
# Or use the solve script
nc <HOST> 9002 > capture.raw
```
The banner tells you: `IQ baseband, 8000 sps, int16 LE interleaved`.
First bytes are a text banner before the binary IQ data:
### 2. Analyze the Signal
Open the IQ data in a spectrogram tool (e.g., inspectrum, Python matplotlib, or
GNU Radio). You'll see:
- Characteristic **chirp** patterns: frequency sweeps from low to high
- Repeating preambles (identical chirps)
- Gaps of noise between transmissions
```
IQ baseband, 8000 sps, int16 LE interleaved
Chirp Spread Spectrum detected. N=128.
```
This is **Chirp Spread Spectrum (CSS)**, the modulation used by LoRa.
> 📸 `[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
### 3. Determine Parameters
- Each chirp spans 128 samples → **N = 128**
- Since N = 2^SF → **SF = 7** (spreading factor)
- Bandwidth = sample rate = 8000 Hz (baseband at Nyquist)
- 7 bits per symbol
- `N = 2^SF`**SF = 7** (spreading factor)
- Each symbol encodes 7 bits → 128 possible symbols
- Baseband sample rate = 8000 Hz
### 4. Implement Dechirping
The key to CSS demodulation is **dechirping**:
### Step 4 — Implement dechirping
1. Generate the base upchirp (symbol 0):
```
x0[n] = exp(j * π * n²/N) for n = 0..127
```
```python
import numpy as np
2. To decode a received chirp, multiply by the **conjugate** of the base chirp:
```
dechirped[n] = received[n] * conj(x0[n])
```
# 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]
3. Take the **DFT/FFT** of the dechirped signal. The peak bin = symbol value.
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
### 5. Detect Frames
Frame structure:
```
[Preamble: 8× symbol 0] [Sync: 2× downchirp] [Header: 1 symbol] [Payload: L symbols]
[Preamble: 8x symbol 0] [Sync: 2x downchirp] [Header: 1 symbol = length] [Payload: L symbols]
```
- **Preamble**: 8 consecutive chirps all decoding to symbol 0
- **Sync**: 2 downchirps (conjugate of upchirps)
- **Header**: 1 symbol = payload length in bytes (Gray-coded)
- **Payload**: L symbols encoding the data bytes
Scan the IQ stream for runs of 8 consecutive symbols decoding to 0.
### Step 6 — Gray decode and symbol-to-byte unpacking
### 6. Gray Decoding
Symbol values are **Gray-coded** (like real LoRa). After finding the FFT peak
bin, apply inverse Gray code:
```python
def gray_decode(val):
mask = val
@ -67,36 +126,51 @@ def gray_decode(val):
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))
```
### 7. Symbol-to-Byte Unpacking
Each symbol carries 7 bits (SF=7). Concatenate all bits from decoded symbols,
then group into 8-bit bytes.
> 📸 `[screenshot: Python decoder output showing decoded symbols and CRC16 validation pass]`
### Step 7 — Parse frame payload and decrypt
### 8. Parse Frame Payload
Payload format: `[type:1] [data:L] [crc16:2]`
- Type 0x01 = beacon (ASCII text, for verification)
- Type 0x02 = data (XOR-encrypted flag)
- CRC-16 CCITT validates the payload
### 9. Decrypt Flag
The data frame's content is XOR'd with the repeating key `"L41N"` (4 bytes).
- Type `0x01` = beacon (cleartext ASCII, for verification)
- Type `0x02` = data frame (XOR-encrypted flag)
```python
xor_key = b"L41N"
flag = bytes(b ^ xor_key[i % 4] for i, b in enumerate(encrypted_data))
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())
```
## Key Insights
- CSS/LoRa modulation encodes data as cyclic frequency shifts of a chirp signal
- The dechirp + FFT technique converts the frequency-domain problem into a simple peak detection
- Gray coding ensures that adjacent symbols (close FFT bins) differ by only 1 bit, reducing errors
- The 7-bit symbol → 8-bit byte packing is standard for non-byte-aligned symbol sizes
- The banner hints at CSS ("Chirp Spread Spectrum detected") to point players in the right direction
> 📸 `[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}`
## Author
Eun0us
`ESPILON{4cc3l4_ch1rp_spr34d_w1r3d}`