Skip to main content

РЭБ: writeup

РЭБ

CategoryStegano
DifficultyEasy
TechniqueSpectrogram steganography (image-in-audio)
Flagflag{SP3CT0GR4M}

Recon

Дан аудиофайл и подсказка. Флаг нужно найти самостоятельно.

Артефакты: sound.wav, Hint.

cat Hint
Для решения используйте - AudaCity Portable

Hint прямо указывает на аудиоредактор с визуальным анализом. Смотреть нужно не ушами.

Базовый triage файла:

python3 -c "
import wave
with wave.open('sound.wav', 'rb') as w:
    print('channels    :', w.getnchannels())
    print('sample_width:', w.getsampwidth(), 'bytes')
    print('framerate   :', w.getframerate(), 'Hz')
    print('nframes     :', w.getnframes())
    print('duration    :', round(w.getnframes() / w.getframerate(), 2), 's')
"
channels    : 1
sample_width: 2 bytes
framerate   : 44100 Hz
nframes     : 220500
duration    : 5.0 s

Стандартный 16-bit mono PCM, 5 секунд. Контейнер чистый: размер файла точно соответствует расчётному (44 + 220500 × 2 = 441044 байт).

Ключевые наблюдения:

  • LSB-проверка: в младших битах PCM-сэмплов нет читаемого текста и сигнатур архивов.
  • На слух — искусственный шум, не речь и не тональный код (Morse, DTMF).
  • strings sound.wav выдаёт только стандартные RIFF/WAVE заголовки.

LSB и контейнер отброшены. Hint указывает на Audacity, который умеет показывать спектрограмму. Переходим в частотную область.


Spectrogram Steganography

Спектрограмма — двумерное представление звука: ось X — время, ось Y — частота, яркость пикселя — энергия этой частоты в данный момент.

Если синтезировать звук из набора синусоид нужных частот в нужные моменты, спектрограмма покажет буквы. Формально, сигнал в каждом временном блоке:

$$s(t) = \sum_{i} A_i \sin(2 \pi f_i t)$$

Каждая точка $(x, y)$ картинки-текста переводится в: $x$ → момент времени, $y$ → частота $f_i$, яркость → амплитуда $A_i$. Обратная задача — восстановить картинку из звука через FFT (быстрое преобразование Фурье):

$$X[k] = \sum_{n=0}^{N-1} x[n] \cdot e^{-2\pi i k n / N}$$

Ручная проверка: для window size $N = 1024$ и sample rate $44100$ Гц частота бина $k = 200$:

$$f = \frac{k \cdot \text{rate}}{N} = \frac{200 \cdot 44100}{1024} \approx 8613 \text{ Гц}$$

Если в бине $k = 200$ энергия высокая — в данном кадре звучит тон ≈8613 Гц, что означает светлую точку на соответствующей высоте спектрограммы. После применения FFT к каждому окну по 1024 сэмпла вместо случайного шума появляется структурированный рисунок.


Extraction

ШагДействиеДетали
1Разбить sound.wav на кадры с перекрытиемwindow = 1024, hop = 256 → 858 кадров
2Применить оконную функцию ХаннаУбирает резкие края кадра, улучшает читаемость
3FFT каждого кадраБерём только первую половину спектра [:N//2]
4log(1 + |X[k]|)Логарифм сжимает динамический диапазон, выделяет слабые детали
5Сжать до 480 колонок, нормализовать в 0–255Ширина итогового изображения
6Сохранить PNG и открытьНа spectrogram_high.png виден флаг

Root cause — автор закодировал картинку с текстом flag{SP3CT0GR4M} в звук: каждый пиксель стал синусоидой. FFT-анализ полностью восстанавливает исходное изображение.

Правильный подход (если нужно скрыть надёжнее):

  • Использовать LSB внутри PCM-сэмплов — спектральный анализ флаг не покажет.
  • Добавить гауссовский шум поверх текста в спектре — символы перестают читаться без шаблона.
  • Шифровать карту «пиксель → частота»: без ключа диапазон неизвестен.
  • Уменьшить длительность символов до предела временного разрешения FFT.

Automation

from __future__ import annotations

import cmath
import math
import struct
import wave
from pathlib import Path

from PIL import Image  # единственная внешняя зависимость — для сохранения PNG


ROOT = Path(__file__).resolve().parent
WAV_PATH = ROOT / "sound.wav"


def fft(values: list[complex]) -> list[complex]:
    # Cooley-Tukey FFT: итеративный алгоритм с bit-reversal перестановкой
    size = len(values)
    index = 0
    for current in range(1, size):
        bit = size >> 1
        while index & bit:
            index ^= bit
            bit >>= 1
        index ^= bit
        if current < index:
            values[current], values[index] = values[index], values[current]

    length = 2
    while length <= size:
        half = length >> 1
        twiddle = cmath.exp(-2j * math.pi / length)  # поворотный множитель e^(-2πi/n)
        for offset in range(0, size, length):
            factor = 1 + 0j
            for current in range(offset, offset + half):
                even = values[current]
                odd = values[current + half] * factor
                values[current] = even + odd          # butterfly: сумма
                values[current + half] = even - odd   # butterfly: разность
                factor *= twiddle
        length <<= 1
    return values


def read_samples(path: Path) -> tuple[list[int], int]:
    with wave.open(str(path), "rb") as wav_file:
        if wav_file.getnchannels() != 1 or wav_file.getsampwidth() != 2:
            raise ValueError("expected 16-bit mono PCM")
        rate = wav_file.getframerate()
        frame_count = wav_file.getnframes()
        raw = wav_file.readframes(frame_count)
    samples = list(struct.unpack("<" + "h" * (len(raw) // 2), raw))
    return samples, rate


def build_spectrogram(samples: list[int], window_size: int = 1024, hop_size: int = 256) -> list[list[float]]:
    # Оконная функция Ханна: плавно обнуляет края кадра → меньше артефактов на границах
    window = [0.5 - 0.5 * math.cos(2 * math.pi * offset / (window_size - 1)) for offset in range(window_size)]
    frames: list[list[float]] = []
    for start in range(0, len(samples) - window_size + 1, hop_size):
        segment = [samples[start + offset] * window[offset] for offset in range(window_size)]
        spectrum = fft([complex(value, 0) for value in segment])[: window_size // 2]
        frames.append([math.log1p(abs(value)) for value in spectrum])  # log сжимает динамику
    return frames


def resample_columns(matrix: list[list[float]], column_count: int) -> list[list[float]]:
    if not matrix:
        return []
    source_columns = len(matrix)
    column_count = min(column_count, source_columns)
    result: list[list[float]] = []
    for column in range(column_count):
        source_index = round(column * (source_columns - 1) / max(column_count - 1, 1))
        result.append(matrix[source_index])
    return result


def frequency_slice(frame: list[float], row_index: int, row_count: int, mode: str) -> tuple[int, int]:
    last = len(frame) - 1
    y0 = (row_count - 1 - row_index) / row_count
    y1 = (row_count - row_index) / row_count
    if mode == "linear":
        low = int(y0 * last)
        high = int(y1 * last) + 1
    elif mode == "sqrt":
        low = int((y0**2) * last)
        high = int((y1**2) * last) + 1
    elif mode == "high":
        base = min(180, last)  # смотрим верхние ~30% спектра — там виден текст
        low = int(base + y0 * (last - base))
        high = int(base + y1 * (last - base)) + 1
    else:
        raise ValueError(f"unknown mode: {mode}")
    low = max(0, min(low, last))
    high = max(low + 1, min(high, len(frame)))
    return low, high


def rasterize(columns: list[list[float]], row_count: int, mode: str) -> list[list[float]]:
    rows: list[list[float]] = []
    for row_index in range(row_count):
        row_values = []
        for frame in columns:
            low, high = frequency_slice(frame, row_index, row_count, mode)
            row_values.append(sum(frame[low:high]) / (high - low))
        rows.append(row_values)
    return rows


def normalize(rows: list[list[float]]) -> list[list[int]]:
    flat = [value for row in rows for value in row]
    minimum = min(flat)
    maximum = max(flat)
    scale = maximum - minimum or 1.0
    return [[int((value - minimum) / scale * 255) for value in row] for row in rows]


def save_png(image_rows: list[list[int]], path: Path) -> None:
    height = len(image_rows)
    width = len(image_rows[0])
    image = Image.new("L", (width, height))
    image.putdata([pixel for row in image_rows for pixel in row])
    image.save(path)


def main() -> None:
    samples, rate = read_samples(WAV_PATH)
    spectrogram = build_spectrogram(samples)
    columns = resample_columns(spectrogram, 480)
    print(f"sample_rate={rate}  frames={len(spectrogram)}  rendered={len(columns)}")
    for mode in ("linear", "sqrt", "high"):
        rows = rasterize(columns, 256, mode)
        image_rows = normalize(rows)
        save_png(image_rows, ROOT / f"spectrogram_{mode}.png")
        print(f"→ spectrogram_{mode}.png")


if __name__ == "__main__":
    main()
python3 solve.py
# sample_rate=44100  frames=858  rendered=480
# → spectrogram_linear.png
# → spectrogram_sqrt.png
# → spectrogram_high.png
# Открыть spectrogram_high.png — читается flag{SP3CT0GR4M}

Key Takeaways

  1. Spectrogram steganography. Звук можно представить как двумерный рисунок: время по X, частота по Y. Если синтезировать WAV из тонов нужных частот в нужные моменты, флаг буквально нарисован звуком — его не слышно, но он виден на спектрограмме.

  2. FFT как стандартный CTF-инструмент. Для любого аудио-артефакта в стегано-задаче — открыть в Audacity (View → Spectrogram) или запустить Python-скрипт с FFT. Оба метода эквивалентны; зависимость от GUI не обязательна.

  3. Ловушка задачи: LSB и контейнер — первый рефлекс, не первый ответ. Контейнер чистый, LSB пустой — типичный ложный ход при работе с аудиофайлом. Настоящий ключ — hint «Audacity», который намекает не на конкретное ПО, а на способ смотреть: переключиться из временной области в частотную.