Привет, Хабр!
Сегодня я хочу поделиться историей одной, казалось бы, простой задачи, которая превратилась в увлекательное техническое расследование. Мы разрабатывали утилиту для стеганографии 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 !
Спасибо за внимание!
Комментарии (0)
Rend
14.09.2025 16:20"Lossless" не всегда означает "бит-в-бит идентично".
Не путайте “Lossless” и так называемый “Lossless quality”/“Visually lossless”. Обычно в кодеках “Lossless” - это специальный режим, включаемый отдельным параметром, а не абстрактный ползунок quality=100.
LSB-стеганография требует абсолютной стабильности формата.
Форматы вполне стабильны. Вопрос в том, что нужно понимать, как конкретно в каком формате передаются данные. В вашем случае можете передавать в кодер и в формате YUV.
AVIF и JPEG не подходят для LSB.
Вполне подходят, если знать, как они работают. Режим “Lossless” есть в обоих кодеках. А ещё у AVIF(AV1) есть поддержка 10 и даже 12 бит на пиксел.
Альтернатива есть. Для форматов вроде AVIF и JPEG можно использовать другие методы, например, бинарное добавление данных в конец файла (append method). Это менее изящно, но работает.
Если уж говорить именно об LSB, то изящнее добавлять не в сырые данные на вход энкодера, а в данные после DCT/Quantization. Но это уже будет требовать углублённого знания самого формата.
dioneo
14.09.2025 16:20import subprocess import hashlib import os from PIL import Image INPUT = "input.png" AVIF_FILE = "avifenc_lossless.avif" DECODED_PNG = "decoded_avif.png" def sha256_pixels(img_path): """Посчитать SHA256 по сырым байтам RGB изображения""" img = Image.open(img_path).convert("RGB") raw_bytes = img.tobytes() return hashlib.sha256(raw_bytes).hexdigest() def encode_avif(input_file, avif_file): """Закодировать PNG в AVIF lossless через avifenc""" subprocess.run([ "avifenc.exe", "--min", "0", "--max", "0", "--lossless", input_file, avif_file ], check=True) def decode_avif(avif_file, decoded_file): """Декодировать AVIF обратно в PNG через avifdec""" subprocess.run(["avifdec.exe", avif_file, decoded_file], check=True) if not os.path.exists(INPUT): print(f"{INPUT} не найден!") exit(1) # Кодирование encode_avif(INPUT, AVIF_FILE) # Декодирование decode_avif(AVIF_FILE, DECODED_PNG) # SHA256 по пикселям hash_input = sha256_pixels(INPUT) hash_decoded = sha256_pixels(DECODED_PNG) print(f"SHA256 исходного изображения (RGB байты): {hash_input}") print(f"SHA256 декодированного AVIF: {hash_decoded}") print(f"Файлы идентичны по пикселям: {hash_input == hash_decoded}")
У меня так получилось:
SHA256 исходного изображения (RGB байты): ded0d2eb52ddd5aec8d3264cb716c06a44f27d61c8d39bdf4cde627211c78076
SHA256 декодированного AVIF: ded0d2eb52ddd5aec8d3264cb716c06a44f27d61c8d39bdf4cde627211c78076
Файлы идентичны по пикселям: TrueLomakn Автор
14.09.2025 16:20Большое спасибо за ваши отзывы, идеи и предложения по поддержке формата AVIF. Мы внимательно изучили этот вопрос. Ваша реализация действительно работает.
Цель нашей программм быть полностью самодостаточным и кроссплатформенным приложением, которое работает "из коробки" без установки каких-либо сторонних компонентов на многих ОС.
Вызов внешних утилит, таких как avifenc действительно решил бы эту техническую проблему. Однако это противоречит нашему главному принципу — созданию единого, монолитного приложения без внешних вызовов.
Поэтому, пока не появится стабильная кроссплатформенная библиотека, позволяющая выполнять истинно lossless (бит-в-бит) кодирование AVIF напрямую из Python, мы вынуждены отложить реализацию этого формата для LSB-метода.
nin-jin
А нельзя что ли сразу на вход подавать данные в YUV формате?
Lomakn Автор
Спасибо за идеи. Будем побовать.
Lomakn Автор
Проблема заключается в инструментах — то есть в возможностях существующих высокоуровневых Python-библиотек, таких как Pillow или
imageio
. Они созданы для удобства и обычно скрывают от пользователя детали работы с цветовыми пространствами. Они ожидают на вход стандартный RGB-массив и сами выполняют все необходимые преобразования "под капотом". Найти в них функцию, которая позволила бы сказать: "Вот тебе готовые YUV-данные, ничего не конвертируй, просто закодируй их", — практически невозможно.nin-jin
Берём цвет пикселя RGB, конвертим в YUV, меняем бит, конвертим обратно, отдаём AVIF кодеру.
Lomakn Автор
Спасибо за интересное предложение! Давайте пошагово разберем, что произойдет с данными пикселя при таком подходе, и почему он, к сожалению, не сработает.
Шаг 1: Ваше действие (Подготовка) Вы берете исходный пиксель (назовем его
RGB_A
), программно преобразуете его в цветовое пространствоYUV_A
, меняете в нем младший бит (LSB) и преобразуете обратно. На выходе вы получаете новый пиксельRGB_B
, который почти не отличается от оригинала, но несет в себе секретную информацию.Шаг 2: Действие кодера (Получение данных) Вы передаете массив, состоящий из этих новых пикселей
RGB_B
, в библиотеку для сжатия в формат AVIF.Шаг 3: Действие кодера (Внутреннее преобразование) Это ключевой момент. Библиотека получает ваш пиксель
RGB_B
. Так как для AVIF-сжатия требуется цветовое пространство YUV, она выполняет собственное, повторное преобразование этого пикселяRGB_B
в новыйYUV_C
.Шаг 4: Результат (Потеря данных) Из-за математических особенностей и округлений при этом втором преобразовании, полученный
YUV_C
не будет бит-в-бит идентичен вашему исходномуYUV_A
. Ваше LSB-изменение, которое вы так аккуратно внесли на первом шаге, будет утеряно. В итоговый файл сохранитсяYUV_C
, а не тот, что содержал ваш секретный бит.Таким образом, происходит "двойное преобразование", которое не оставляет шансов для LSB-метода. Проблема в том, что мы не можем "запретить" стандартному кодеру выполнять его обязательную внутреннюю конвертацию из RGB в YUV.