Introduction
We intercepted an encrypted radio transmission from a covert channel. Something is hidden in the signal — but someone got to it first and scrambled the frequencies. Can you clean up the noise and hear what it's really saying?
The recording contains a structured digital transmission beneath broadband noise. Examining its spectrum reveals six simultaneous tones that change at regular intervals. These tones encode hexadecimal nibbles, which reconstruct a packet containing a Base64 message.
The solution is:
- Inspect the WAV format and spectrogram.
- Identify the frequency grid and symbol timing.
- Convert six tones into six hexadecimal nibbles.
- Reassemble three bytes per symbol.
- Compare both copies of the transmission.
- Extract and decode the Base64 payload.
Initial Reconnaissance
Audio properties and evidence integrity
The attachment is a short, single-channel PCM recording:
| Property | Value |
|---|---|
| Filename | tampered.wav |
| Channels | 1 |
| Sample format | Signed 16-bit PCM |
| Sample rate | 48,000 Hz |
| Sample count | 376,700 |
| Duration | Approximately 7.848 seconds |
The source file has the following SHA-256:
text
8656f2cfdac6bea882ee24f91634e2d27b7e0c12f458ac3542dc993c25cb1569
This fingerprint identifies the exact attachment used for the analysis.
Inspecting the spectrogram
A short-time Fourier transform exposes the signal's time-frequency structure. The following script generates an overview:
python
import matplotlib.pyplot as plt
import numpy as np
from scipy.io import wavfile
from scipy.signal import stft
sample_rate, samples = wavfile.read("tampered.wav")
frequencies, times, spectrum = stft(
samples.astype(float),
fs=sample_rate,
nperseg=4096,
noverlap=3840,
)
magnitude = 20 * np.log10(np.abs(spectrum) + 1)
fig, ax = plt.subplots(figsize=(14, 5))
plot = ax.pcolormesh(
times,
frequencies,
magnitude,
shading="auto",
cmap="magma",
vmin=20,
vmax=60,
)
ax.set_ylim(0, 10000)
ax.set_xlabel("Time (seconds)")
ax.set_ylabel("Frequency (Hz)")
ax.set_title("Waves — transmission spectrogram")
fig.colorbar(plot, ax=ax, label="Magnitude (dB, sample units)")
fig.tight_layout()
fig.savefig("waves_spectrogram.png", dpi=200)
The spectrogram reveals several useful properties:
- The recording contains two visually matching transmission blocks.
- The second block is displaced by approximately 3.92 seconds.
- Each data symbol contains six dominant simultaneous tones.
- The tones change approximately every 0.15 seconds.
- Their frequencies align with a 60 Hz grid.
These observations suggest a frequency-coded digital packet.
Key Signal Insight
The useful information is carried by discrete tone positions. Recovering speech or applying aggressive audio denoising is unnecessary: the dominant spectral peaks can be measured directly from the supplied recording.
Encoding Analysis
Recovering the symbol timing
For the first transmission, stable sampling positions are:
text
t(k) = 0.35 + 0.15 × k seconds
k = 0, 1, ..., 19
The second transmission uses:
text
t₂(k) = t(k) + 3.92 seconds
At each position, an 80 ms window captures the tones while avoiding symbol boundaries.
A Hann window reduces spectral leakage. A 48,000-point FFT at a 48,000 Hz sample rate provides a frequency grid spaced at 1 Hz.
The 80 ms input window is zero-padded for this FFT. Zero-padding gives finer frequency-bin sampling; the underlying resolving power still depends on the window duration.
Mapping frequencies to nibbles
The observed tones occupy six bands. Each band contains 16 possible positions separated by 60 Hz:
| Band | Frequency positions | Role |
|---|---|---|
| 0 | 2400–3300 Hz | Byte 0, low nibble |
| 1 | 3360–4260 Hz | Byte 0, high nibble |
| 2 | 4320–5220 Hz | Byte 1, low nibble |
| 3 | 5280–6180 Hz | Byte 1, high nibble |
| 4 | 6240–7140 Hz | Byte 2, low nibble |
| 5 | 7200–8100 Hz | Byte 2, high nibble |
For a measured frequency
f, first calculate its position on the global grid:python
grid_index = round((f - 2400) / 60)
Then recover its band and nibble value:
python
band = grid_index // 16
nibble = grid_index % 16
Equivalently, each tone follows:
text
frequency = 2400 + 960 × band + 60 × nibble
Sorting the six dominant peaks by frequency orders them from band 0 through band 5.
Reconstructing bytes
Each pair of adjacent bands encodes one byte. The lower-frequency band supplies the low nibble, and the next band supplies the high nibble:
python
byte_0 = (nibble_1 << 4) | nibble_0
byte_1 = (nibble_3 << 4) | nibble_2
byte_2 = (nibble_5 << 4) | nibble_4
Consequently:
text
6 tones → 6 nibbles → 3 bytes per symbol
Twenty symbols reconstruct a 60-byte packet.
Worked example: the symbol at 0.50 seconds
The six measured peaks at this position are:
text
2580, 3720, 4500, 5460, 6360, 7440 Hz
Applying the frequency mapping gives:
| Frequency | Grid index | Band | Nibble |
|---|---|---|---|
| 2580 Hz | 3 | 0 | 0x3 |
| 3720 Hz | 22 | 1 | 0x6 |
| 4500 Hz | 35 | 2 | 0x3 |
| 5460 Hz | 51 | 3 | 0x3 |
| 6360 Hz | 66 | 4 | 0x2 |
| 7440 Hz | 84 | 5 | 0x4 |
Pairing the nibbles reconstructs:
text
(0x6 << 4) | 0x3 = 0x63 = 'c'
(0x3 << 4) | 0x3 = 0x33 = '3'
(0x4 << 4) | 0x2 = 0x42 = 'B'
The symbol therefore contributes:
text
c3B
Continuing across the transmission reveals a recognizable Base64 segment.
Encoding Rather Than an Established Cryptographic Break
Although the challenge describes an encrypted transmission, the recovered payload uses frequency encoding followed by Base64. This analysis establishes a working decoding path; it does not establish the original scrambling operation or identify an additional encryption algorithm.
Solver
The solver below reads the original WAV, extracts both transmission blocks independently, and checks that their complete packets match.
It then extracts the 40-byte Base64 segment at offsets
3:43 and decodes it using strict Base64 validation.Dependencies
bash
python3 -m pip install numpy scipy
Complete Python solver
python
#!/usr/bin/env python3
"""
Waves — audio forensics solver
Author: Suraj Vishwanath
Usage:
python3 solve_waves.py tampered.wav
Dependencies:
numpy
scipy
"""
import argparse
import base64
import hashlib
from pathlib import Path
import numpy as np
from scipy.io import wavfile
from scipy.signal import find_peaks
EXPECTED_SAMPLE_RATE = 48_000
FIRST_SYMBOL_CENTER = 0.35
SYMBOL_INTERVAL = 0.15
SYMBOL_COUNT = 20
REPEAT_OFFSET = 3.92
WINDOW_HALF_WIDTH = 0.04
FFT_SIZE = 48_000
BASE_FREQUENCY = 2400
FREQUENCY_STEP = 60
POSITIONS_PER_BAND = 16
BAND_COUNT = 6
PAYLOAD_START = 3
PAYLOAD_END = 43
def recover_packet(
samples: np.ndarray,
sample_rate: int,
offset: float,
) -> bytes:
"""Recover one 60-byte packet from its six-tone symbols."""
packet = bytearray()
for symbol_index in range(SYMBOL_COUNT):
center = (
FIRST_SYMBOL_CENTER
+ SYMBOL_INTERVAL * symbol_index
+ offset
)
start = int((center - WINDOW_HALF_WIDTH) * sample_rate)
end = int((center + WINDOW_HALF_WIDTH) * sample_rate)
if start < 0 or end > len(samples):
raise ValueError(
f"Symbol {symbol_index}: sample window is out of bounds"
)
window = samples[start:end].astype(np.float64)
# Reduce leakage before computing the frequency spectrum.
window *= np.hanning(len(window))
spectrum = np.abs(np.fft.rfft(window, n=FFT_SIZE))
# FFT_SIZE equals the sample rate, so bin indices correspond
# numerically to frequencies in Hz for this recording.
peaks, _ = find_peaks(spectrum, distance=40)
if len(peaks) < BAND_COUNT:
raise ValueError(
f"Symbol {symbol_index}: fewer than six peaks found"
)
# Select the six strongest peaks, then order by frequency.
strongest = sorted(
peaks,
key=lambda index: spectrum[index],
reverse=True,
)[:BAND_COUNT]
frequencies = sorted(int(index) for index in strongest)
grid_indices = [
round((frequency - BASE_FREQUENCY) / FREQUENCY_STEP)
for frequency in frequencies
]
bands = [
index // POSITIONS_PER_BAND
for index in grid_indices
]
# A valid symbol must contain one tone from each band.
if bands != list(range(BAND_COUNT)):
raise ValueError(
f"Symbol {symbol_index}: unexpected bands {bands}; "
f"frequencies={frequencies}"
)
nibbles = [
index % POSITIONS_PER_BAND
for index in grid_indices
]
# Each adjacent pair is transmitted low nibble first.
for low_band in (0, 2, 4):
low = nibbles[low_band]
high = nibbles[low_band + 1]
packet.append((high << 4) | low)
return bytes(packet)
def main() -> None:
parser = argparse.ArgumentParser(
description="Decode the Waves six-band audio transmission."
)
parser.add_argument("wav", type=Path, help="Path to tampered.wav")
args = parser.parse_args()
raw_file = args.wav.read_bytes()
sample_rate, samples = wavfile.read(args.wav)
if sample_rate != EXPECTED_SAMPLE_RATE:
raise ValueError(
f"Expected 48000 Hz; received {sample_rate} Hz"
)
if samples.ndim != 1:
raise ValueError("Expected mono audio")
if samples.dtype != np.dtype("int16"):
raise ValueError("Expected signed 16-bit PCM samples")
first = recover_packet(samples, sample_rate, offset=0.0)
second = recover_packet(
samples,
sample_rate,
offset=REPEAT_OFFSET,
)
if first != second:
raise ValueError("The repeated packets do not match")
# This slice is the observed 40-byte Base64 segment.
# No undocumented header semantics are assumed.
payload = first[PAYLOAD_START:PAYLOAD_END]
decoded = base64.b64decode(payload, validate=True)
message = decoded.decode("ascii")
print("SHA256:", hashlib.sha256(raw_file).hexdigest())
print("Repeated packets match:", first == second)
print("Packet:", first.hex())
print("Base64:", payload.decode("ascii"))
print("Decoded:", message)
print("Flag candidate: BREACH{" + message + "}")
if __name__ == "__main__":
main()
Solution & Flag Capture
Decoder output
Recovering the Hidden Message
$python3 solve_waves.py tampered.wav
SHA256: 8656f2cfdac6bea882ee24f91634e2d27b7e0c12f458ac3542dc993c25cb1569
Repeated packets match: True
Packet: 2878506333426c5933527962326479595731666333426c5957747a5833526f5a563930636e563061413d3de43ddd18d72a4a06e084b1d2b653222000
Base64: c3BlY3Ryb2dyYW1fc3BlYWtzX3RoZV90cnV0aA==
Decoded: spectrogram_speaks_the_truth
Flag candidate: BREACH{spectrogram_speaks_the_truth}
The reconstructed packet separates into:
| Offset | Length | Observed content |
|---|---|---|
0–2 | 3 bytes | Prefix: 28 78 50 |
3–42 | 40 bytes | Base64 payload |
43–59 | 17 bytes | Uninterpreted trailing bytes |
The payload is:
text
c3BlY3Ryb2dyYW1fc3BlYWtzX3RoZV90cnV0aA==
Strict Base64 decoding produces:
text
spectrogram_speaks_the_truth
The trailing bytes are:
text
e43ddd18d72a4a06e084b1d2b653222000
Their purpose was not established. They are retained as evidence rather than labeled as a checksum or cryptographic field without validation.
Evidence supporting the result
The recovery has three concrete checks:
- Frequency structure: every decoded symbol contains one peak in each of the six expected bands.
- Transmission agreement: both independently extracted 60-byte packets match exactly.
- Payload validity: the extracted 40-byte segment passes strict Base64 validation and decodes to an intelligible phrase.
Recovered Message
Both copies of the transmission decode to spectrogram_speaks_the_truth. Applying the BREACH flag convention produces the flag below. Challenge-server acceptance was not independently verified.