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

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

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

Комментарии (0)


  1. nin-jin
    14.09.2025 16:20

    А нельзя что ли сразу на вход подавать данные в YUV формате?


    1. Lomakn Автор
      14.09.2025 16:20

      Спасибо за идеи. Будем побовать.


    1. Lomakn Автор
      14.09.2025 16:20

      Проблема заключается в инструментах — то есть в возможностях существующих высокоуровневых Python-библиотек, таких как Pillow или imageio. Они созданы для удобства и обычно скрывают от пользователя детали работы с цветовыми пространствами. Они ожидают на вход стандартный RGB-массив и сами выполняют все необходимые преобразования "под капотом". Найти в них функцию, которая позволила бы сказать: "Вот тебе готовые YUV-данные, ничего не конвертируй, просто закодируй их", — практически невозможно.


      1. nin-jin
        14.09.2025 16:20

        Берём цвет пикселя RGB, конвертим в YUV, меняем бит, конвертим обратно, отдаём AVIF кодеру.


        1. Lomakn Автор
          14.09.2025 16:20

          Спасибо за интересное предложение! Давайте пошагово разберем, что произойдет с данными пикселя при таком подходе, и почему он, к сожалению, не сработает.

          Шаг 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.


  1. 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. Но это уже будет требовать углублённого знания самого формата.


    1. Lomakn Автор
      14.09.2025 16:20

      +1 поставил. Интересные идеи.


  1. dioneo
    14.09.2025 16:20

    import 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
    Файлы идентичны по пикселям: True


    1. Lomakn Автор
      14.09.2025 16:20

      Большое спасибо за ваши отзывы, идеи и предложения по поддержке формата AVIF. Мы внимательно изучили этот вопрос. Ваша реализация действительно работает.

      Цель нашей программм быть полностью самодостаточным и кроссплатформенным приложением, которое работает "из коробки" без установки каких-либо сторонних компонентов на многих ОС.

      Вызов внешних утилит, таких как avifenc действительно решил бы эту техническую проблему. Однако это противоречит нашему главному принципу — созданию единого, монолитного приложения без внешних вызовов.

      Поэтому, пока не появится стабильная кроссплатформенная библиотека, позволяющая выполнять истинно lossless (бит-в-бит) кодирование AVIF напрямую из Python, мы вынуждены отложить реализацию этого формата для LSB-метода.