white_noise_letter: буквы в разнице кадров

стеганография · задача 12   gifкадрыxorpython

GIF 480 × 270, 21 МБ, 120 кадров по 0,08 с. В описании анекдот: «Штирлиц включил телевизор. По телевизору пошёл белый шум. Штирлиц возмутился и попросил не ходить по телевизору».

Платформа
CTF (флаги вида DUCKERZ{…})
Носитель
анимированный GIF
Приём
буквы видны только в разнице двух соседних кадров
Инструменты
Python (Pillow, NumPy), ImageMagick
Статус
решено

Ложные срабатывания

binwalk нашёл внутри «JBOOT STAG header» и «сертификат DER», да ещё образ на 2,8 ГБ внутри файла на 21 МБ. Это мусор: 21 МБ почти случайных байт неизбежно содержат последовательности, похожие на чьи-то сигнатуры. Абсурдный размер находки — верный признак ложного срабатывания.

Подсказки

  • letter в имени файла — «письмо», а Штирлиц — разведчик: в шуме есть послание.
  • «Не ходить по телевизору» — каламбур: в шуме что-то ходит, то есть двигается.

Гипотезы и их проверка одним скриптом

Если по отдельности кадры — шум, текст может проявиться: в среднем всех кадров (если буквы неподвижны), в неподвижных пикселях или в разнице соседних кадров. Скрипт gif_noise.py проверял всё сразу. Сработала разница: каждая пара соседних кадров давала одну букву, причём каждый раз в новом месте кадра. Вот это и «ходит по телевизору».

Я собрал такой же GIF сам, только короче: 9 кадров, слово «HEXNOTES».

Анимация из шумаанимация
Отдельный кадрлюбой отдельный кадр — шум
Каждый кадр по отдельности случаен
XOR соседних кадров
XOR соседних кадров: по букве на пару, каждая на новом месте

Как такое делается: следующий кадр = предыдущий XOR маска буквы. Тогда кадр[i] ⊕ кадр[i+1] = маска буквы, а каждый кадр сам по себе остаётся случайным.

Скрипт: все XOR одной сеткой

import sys, os
import numpy as np
from PIL import Image, ImageSequence, ImageDraw

fr = [np.array(f.convert('L'), dtype=np.uint8) for f in ImageSequence.Iterator(Image.open(sys.argv[1]))]
tiles = []
for i in range(len(fr) - 1):
    t = Image.fromarray(np.bitwise_xor(fr[i], fr[i + 1])).resize((240, 135))
    ImageDraw.Draw(t).text((3, 2), str(i), fill=255)      # номер пары в углу
    tiles.append(t)

cols = 10
sheet = Image.new('L', (cols * 242, (len(tiles) + cols - 1) // cols * 137))
for k, t in enumerate(tiles):
    sheet.paste(t, ((k % cols) * 242, (k // cols) * 137))
sheet.save('xor_sheet.png')

В задаче сетка читалась слева направо, сверху вниз. Флаг повторялся по кругу (120 кадров на 43 символа), а чёрные клетки — пары одинаковых кадров, то есть паузы между буквами.

показать флаг (спойлер)DUCKERZ{Wh1t3_N0is3_fr0m_th3_4byss_A6F32GB}

Что вынести

  • Для анимаций базовый набор: задержки кадров (как ASCII), среднее, неподвижные пиксели, XOR соседних кадров.
  • convert … -coalesce при ручной разборке GIF обязателен: кадр GIF может хранить только изменившийся кусок.