CTF Writeup 150 pts

Waves

Waves is a 150-point audio forensics challenge by White Reaper. The challenge provides tampered.wav, described as an intercepted radio transmission whose frequencies have been scrambled.

Suraj Vishwanath
9 min read

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:
  1. Inspect the WAV format and spectrogram.
  2. Identify the frequency grid and symbol timing.
  3. Convert six tones into six hexadecimal nibbles.
  4. Reassemble three bytes per symbol.
  5. Compare both copies of the transmission.
  6. Extract and decode the Base64 payload.

Initial Reconnaissance

Audio properties and evidence integrity

The attachment is a short, single-channel PCM recording:
PropertyValue
Filenametampered.wav
Channels1
Sample formatSigned 16-bit PCM
Sample rate48,000 Hz
Sample count376,700
DurationApproximately 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:
BandFrequency positionsRole
02400–3300 HzByte 0, low nibble
13360–4260 HzByte 0, high nibble
24320–5220 HzByte 1, low nibble
35280–6180 HzByte 1, high nibble
46240–7140 HzByte 2, low nibble
57200–8100 HzByte 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:
FrequencyGrid indexBandNibble
2580 Hz300x3
3720 Hz2210x6
4500 Hz3520x3
5460 Hz5130x3
6360 Hz6640x2
7440 Hz8450x4
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:
OffsetLengthObserved content
0–23 bytesPrefix: 28 78 50
3–4240 bytesBase64 payload
43–5917 bytesUninterpreted 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:
  1. Frequency structure: every decoded symbol contains one peak in each of the six expected bands.
  2. Transmission agreement: both independently extracted 60-byte packets match exactly.
  3. 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.
BREACH{••••••••••••••••••••••••••••}
Login