CTF-Writeups

Astral Pulses

Flag: MCTF25{1_t0O_can_533_50uNdwav35}


Challenge description

We’re given a single file:

We’re told that flags are always in the format:

MCTF25{flag}

So the goal is to pull some kind of data out of the audio and recover the flag.


1. First look at the audio

On the Ubuntu VM:

# Optional: install tools
sudo apt update
sudo apt install audacity -y

# Open the file in Audacity
audacity output.wav &

Switch Audacity to a spectrogram view:

You should now see:

This strongly suggests a multi-frequency digital encoding:


2. Understanding the encoding

From the spectrogram:

So each symbol:

That gives us values from 0–127, i.e. 7-bit ASCII.

If you look at the very start of the file, you can see a clearly structured pattern:

So the plan:

  1. Detect each symbol using the clock frequency.
  2. For every symbol, measure the energy at each of the 7 data frequencies.
  3. Convert those 7 bits to a value 0–127.
  4. Interpret as ASCII.
  5. Ignore the initial training block of 128 symbols.
  6. Read the remaining plaintext and extract the flag.

3. Decoding with Python

Below is a simplified version of a Python script that does the decoding.
(You might tweak the exact frequencies & thresholds based on your measurements.)

import wave
import numpy as np

FILENAME = "output.wav"

# Parameters (measured from the spectrogram)
SAMPLE_RATE = 44100
SYMBOL_DURATION = 0.1      # seconds per symbol (time between clock ticks)
N_SAMPLES_PER_SYMBOL = int(SYMBOL_DURATION * SAMPLE_RATE)

# Frequencies (one clock + 7 data tones) – approximate values from the spectrogram
CLOCK_FREQ = 11250
DATA_FREQS = [
    9000,  9300,  9600,  9900,
    10200, 10500, 10800
]

def goertzel(samples, freq, sr):
    """Energy at a specific frequency using the Goertzel algorithm."""
    n = len(samples)
    k = int(0.5 + (n * freq / sr))
    w = 2.0 * np.pi * k / n
    cos_w = np.cos(w)
    sin_w = np.sin(w)
    coeff = 2.0 * cos_w

    s_prev = 0
    s_prev2 = 0
    for x in samples:
        s = x + coeff * s_prev - s_prev2
        s_prev2 = s_prev
        s_prev = s
    power = s_prev2**2 + s_prev**2 - coeff * s_prev * s_prev2
    return power

# --- Load WAV ---
with wave.open(FILENAME, "rb") as w:
    assert w.getnchannels() == 1
    assert w.getframerate() == SAMPLE_RATE
    raw = w.readframes(w.getnframes())

# Convert 8-bit unsigned PCM to float centered at 0
data = np.frombuffer(raw, dtype=np.uint8).astype(np.float32) - 128.0

# --- Split into symbols and decode ---
symbols = len(data) // N_SAMPLES_PER_SYMBOL

values = []
for i in range(symbols):
    start = i * N_SAMPLES_PER_SYMBOL
    end = start + N_SAMPLES_PER_SYMBOL
    chunk = data[start:end] * np.hanning(N_SAMPLES_PER_SYMBOL)

    # (Optional) Check clock power to verify this is a valid symbol
    clock_power = goertzel(chunk, CLOCK_FREQ, SAMPLE_RATE)
    if clock_power < 1e6:  # adjust threshold as needed
        continue

    bits = []
    for f in DATA_FREQS:
        p = goertzel(chunk, f, SAMPLE_RATE)
        bits.append(1 if p > 1e6 else 0)   # threshold tuned by trial

    # Convert 7 bits (LSB first) to integer
    val = 0
    for idx, b in enumerate(bits):
        val |= (b << idx)
    values.append(val)

# First 128 symbols are training (0..127)
payload_vals = values[128:]

# Convert to text
text = "".join(chr(v) for v in payload_vals)
print(text)

Running this gives a long text starting with a warped Lorem ipsum-style paragraph.
Somewhere in the middle you’ll see:

... lorem ipsum bla bla MCTF25{1_t0O_can_533_50uNdwav35} ...

So the flag is clearly embedded as a normal ASCII substring.


4. Final flag

Extracting the {...} part gives us the final answer:

MCTF25{1_t0O_can_533_50uNdwav35}