Привет, Хабр!
Сегодня я хочу поделиться историей одной, казалось бы, простой задачи, которая превратилась в увлекательное техническое расследование. Мы разрабатывали утилиту для стеганографии 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, не выполняя таких критичных преобразований цветового пространства.
Выводы
-
«Lossless» не всегда означает «бит-в-бит идентично». В контексте современных форматов это часто означает «визуально без потерь», что является результатом математически сложных, но не идеально обратимых преобразований.
-
LSB-стеганография требует абсолютной стабильности формата. Она применима только к форматам, которые гарантируют побитовое сохранение данных, таким как PNG, BMP, TIFF и WebP (в режиме lossless).
-
AVIF и JPEG не подходят для LSB. Из-за обязательного использования сжатия и/или преобразования цветовых пространств они всегда будут изменять младшие биты пикселей.
-
Альтернатива есть. Для форматов вроде AVIF и JPEG можно использовать другие методы, например, бинарное добавление данных в конец файла (append method). Это менее изящно, но работает.
Надеюсь, наш опыт поможет другим разработчикам сэкономить время и нервы. AVIF — великолепный формат для сжатия изображений, но для стеганографии он оказался по-настояшему крепким орешком.
Последнюю версию программы «Steganographia» от ChameleonLab для Windows и macOS можно скачать на нашем официальном сайте https://chalab.ru.
Будем рады, если вы опробуете новую версию. Ждем ваших отзывов, сообщений об ошибках и, конечно же, предложений по новым форматам для исследований. Присоединяйтесь к нашему Telegram-каналу https://t.me/ChameleonLab !
Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/articles/946814/
Добавить комментарий