AVIF: Крепкий орешек для стеганографии. Почему LSB-метод пасует там, где справляется WebP

от автора

Привет, Хабр!

Сегодня я хочу поделиться историей одной, казалось бы, простой задачи, которая превратилась в увлекательное техническое расследование. Мы разрабатывали утилиту для стеганографии ChameleonLab и решили добавить поддержку современных форматов изображений, таких как WebP и AVIF. С WebP все прошло гладко, но AVIF оказался на удивление крепким орешком.

Эта статья — рассказ о том, почему классический LSB-метод стеганографии не работает с форматом AVIF, даже в режиме «lossless», и почему WebP в этом плане гораздо сговорчивее.

Что такое LSB и почему он так уязвим?

LSB (Least Significant Bit, Наименее значимый бит) — это один из самых базовых методов стеганографии. Идея проста: у каждого пикселя в изображении есть каналы цвета (красный, зеленый, синий). Значение каждого канала — это байт (число от 0 до 255). Мы берем биты нашего секретного сообщения и поочередно записываем их в самые «младшие» биты цветовых каналов.

Например, у нас есть пиксель с красным каналом 1110101**1**. Если нам нужно спрятать бит 0, мы просто меняем последний бит: 1110101**0**.

Изменение настолько незначительно, что человеческий глаз его не замечает. Но у этого метода есть ахиллесова пята: он требует, чтобы данные пикселей после сохранения и повторного открытия остались бит-в-бит идентичными. Любое, даже самое незначительное, изменение разрушит спрятанное сообщение.

Успешный старт: WebP и его режим Lossless

Сначала мы реализовали поддержку WebP. Этот формат имеет прекрасный режим сохранения без потерь, который идеально подходит для LSB.

Вот как выглядит упрощенный код для встраивания и сохранения в WebP с помощью библиотеки Pillow:

from PIL import Image import numpy as np  def hide_data_in_image(image_data, secret_message):     # ... здесь логика, которая превращает сообщение в биты     # и встраивает их в младшие биты пикселей image_data ...     # Этот код мы опустим, он довольно стандартный.     # Главное, что он возвращает numpy array с измененными пикселями.          # Предположим, функция hide() делает это за нас     stego_data = stego.hide(image_data, secret_message.encode('utf-8'), n_bits=2)     return stego_data  # 1. Открываем оригинальное изображение with Image.open("habr.webp") as img:     # Конвертируем в RGBA для единообразия     img_rgba = img.convert("RGBA")     carrier_data = np.array(img_rgba, dtype=np.uint8)  # 2. Прячем данные secret_text = "Это секретное сообщение для Хабра!" stego_image_data = hide_data_in_image(carrier_data, secret_text)  # 3. Сохраняем результат stego_image = Image.fromarray(stego_image_data) # Ключевой момент: `lossless=True` stego_image.save("habr_Stego_LSB.webp", lossless=True)  print("Данные успешно спрятаны в habr_Stego_LSB.webp") 

Когда мы сохраняем с флагом lossless=True, Pillow гарантирует, что пиксельные данные в habr_Stego_LSB.webp будут в точности такими, какими мы их передали. При последующем чтении этого файла наш алгоритм извлечения без проблем находит и восстанавливает секретное сообщение.

Все работало как часы. Мы были уверены, что с AVIF будет так же просто. Мы ошибались.

Тайна AVIF: Почему «Lossless» — не всегда Lossless

Мы взяли тот же подход для AVIF. Так как Pillow не всегда хорошо справляется с этим форматом, мы использовали более современную библиотеку imageio, которая под капотом использует мощные кодеки. В ней тоже есть настройки качества, и quality=100 должно соответствовать режиму без потерь.

import imageio.v2 as imageio import numpy as np  # ... hide_data_in_image() та же, что и раньше ...  # 1. Читаем оригинальный AVIF carrier_data = imageio.imread("fox.avif") # ... приводим его к RGBA, как мы делали в отладке ...  # 2. Прячем данные secret_text = "AVIF, ты крепкий орешек!" stego_image_data = hide_data_in_image(carrier_data, secret_text)  # 3. Сохраняем результат с максимальным качеством imageio.imwrite("fox_Stego_LSB.avif", stego_image_data, quality=100)  print("Данные вроде бы спрятаны в fox_Stego_LSB.avif") 

И вот тут начались проблемы. При попытке извлечь данные из fox_Stego_LSB.avif наша утилита сообщала, что ничего не найдено. Мы проверяли все: правильность чтения файла, алгоритм извлечения, целостность данных. Все было верно. Но данные исчезали.

Копаем глубже: Цветовые пространства RGB и YUV

После долгих часов отладки мы решили провести простой эксперимент: прочитать оригинальный файл, сохранить его с «lossless» настройками и сравнить, остался ли он бит-в-бит таким же.

Вот код нашего диагностического скрипта:

import numpy as np import imageio.v2 as imageio  def read_avif_as_rgb(filepath):     """Читает AVIF и гарантированно возвращает RGB numpy array."""     # pilmode="RGB" заставляет imageio конвертировать данные в RGB при чтении     return imageio.imread(filepath, pilmode="RGB")  # Пути к файлам original_file = 'fox.avif' temp_saved_file = 'fox_resaved.avif'  # 1. Читаем оригинальный файл print(f"Читаем оригинал: {original_file}") original_data = read_avif_as_rgb(original_file) print(f"Форма оригинала: {original_data.shape}, тип: {original_data.dtype}")  # 2. Сразу же сохраняем его с нашими "lossless" настройками print(f"\nПересохраняем в: {temp_saved_file}") # pixelformat='yuv444p' — это chroma subsampling 4:4:4, самый качественный вариант YUV imageio.imwrite(temp_saved_file, original_data, quality=100, pixelformat='yuv444p') print("Сохранение завершено.")  # 3. Читаем пересохраненный файл print(f"\nЧитаем пересохраненный файл: {temp_saved_file}") resaved_data = read_avif_as_rgb(temp_saved_file) print(f"Форма пересохраненного: {resaved_data.shape}, тип: {resaved_data.dtype}")  # 4. Сравниваем массивы пикселей print("\nСравниваем массивы...") if np.array_equal(original_data, resaved_data):     print("РЕЗУЛЬТАТ: УСПЕХ! Массивы идентичны.") else:     print("РЕЗУЛЬТАТ: ПРОВАЛ! Массивы НЕ идентичны.")     # Считаем, насколько сильно они отличаются     diff = np.sum(original_data.astype("int32") - resaved_data.astype("int32"))     print(f"Сумма разниц значений пикселей: {diff}") 

Результат выполнения этого кода стал моментом истины:

Читаем оригинал: fox.avif Форма оригинала: (800, 1204, 3), тип: uint8  Пересохраняем в: fox_resaved.avif Сохранение завершено.  Читаем пересохраненный файл: fox_resaved.avif Форма пересохраненного: (800, 1204, 3), тип: uint8  Сравниваем массивы... РЕЗУЛЬТАТ: ПРОВАЛ! Массивы НЕ идентичны. Сумма разниц значений пикселей: -13458 

Это и есть доказательство. Цикл «чтение -> сохранение» не является бит-в-бит обратимым. Но почему?

Ответ кроется в спецификации формата AVIF. Он, как и многие современные видеокодеки, работает в цветовом пространстве YUV, а не RGB.

  • RGB хранит цвет как комбинацию красного, зеленого и синего.

  • YUV хранит цвет как яркость (Y) и две цветоразностные компоненты (U, V).

Когда мы даем библиотеке imageio наши RGB-пиксели для сохранения в AVIF, она выполняет преобразование RGB -> YUV. Когда мы читаем AVIF, она выполняет обратное преобразование YUV -> RGB.

Эти преобразования включают в себя математику с плавающей запятой и последующее округление до целых чисел. И хотя для человеческого глаза результат выглядит идентично (визуально без потерь), на уровне байтов значения пикселей немного «плывут». Этого «немного» достаточно, чтобы полностью уничтожить информацию, спрятанную в младших битах.

WebP в режиме lossless, в свою очередь, работает напрямую с данными RGBA, не выполняя таких критичных преобразований цветового пространства.

Выводы

  1. «Lossless» не всегда означает «бит-в-бит идентично». В контексте современных форматов это часто означает «визуально без потерь», что является результатом математически сложных, но не идеально обратимых преобразований.

  2. LSB-стеганография требует абсолютной стабильности формата. Она применима только к форматам, которые гарантируют побитовое сохранение данных, таким как PNG, BMP, TIFF и WebP (в режиме lossless).

  3. AVIF и JPEG не подходят для LSB. Из-за обязательного использования сжатия и/или преобразования цветовых пространств они всегда будут изменять младшие биты пикселей.

  4. Альтернатива есть. Для форматов вроде AVIF и JPEG можно использовать другие методы, например, бинарное добавление данных в конец файла (append method). Это менее изящно, но работает.

Надеюсь, наш опыт поможет другим разработчикам сэкономить время и нервы. AVIF — великолепный формат для сжатия изображений, но для стеганографии он оказался по-настояшему крепким орешком.

Последнюю версию программы «Steganographia» от ChameleonLab для Windows и macOS можно скачать на нашем официальном сайте https://chalab.ru.

Будем рады, если вы опробуете новую версию. Ждем ваших отзывов, сообщений об ошибках и, конечно же, предложений по новым форматам для исследований. Присоединяйтесь к нашему Telegram-каналу https://t.me/ChameleonLab !

Спасибо за внимание!


ссылка на оригинал статьи https://habr.com/ru/articles/946814/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *