Привет, Хабр!
Сегодня я хочу поделиться историей одной, казалось бы, простой задачи, которая превратилась в увлекательное техническое расследование. Мы разрабатывали утилиту для стеганографии 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 !
Спасибо за внимание!