РЭБ: writeup
РЭБ
| Category | Stegano |
| Difficulty | Easy |
| Technique | Spectrogram steganography (image-in-audio) |
| Flag | flag{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 | Применить оконную функцию Ханна | Убирает резкие края кадра, улучшает читаемость |
| 3 | FFT каждого кадра | Берём только первую половину спектра [:N//2] |
| 4 | log(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
Spectrogram steganography. Звук можно представить как двумерный рисунок: время по X, частота по Y. Если синтезировать WAV из тонов нужных частот в нужные моменты, флаг буквально нарисован звуком — его не слышно, но он виден на спектрограмме.
FFT как стандартный CTF-инструмент. Для любого аудио-артефакта в стегано-задаче — открыть в Audacity (View → Spectrogram) или запустить Python-скрипт с FFT. Оба метода эквивалентны; зависимость от GUI не обязательна.
Ловушка задачи: LSB и контейнер — первый рефлекс, не первый ответ. Контейнер чистый, LSB пустой — типичный ложный ход при работе с аудиофайлом. Настоящий ключ — hint «Audacity», который намекает не на конкретное ПО, а на способ смотреть: переключиться из временной области в частотную.
