Лаборатория: свой LSB

свой код   pythonlsbpillow

Решать LSB-задачи проще, когда сам написал встраиватель. Здесь пара скриптов: один прячет, другой достаёт, плюс объяснение, откуда в выводе берётся мусор.

LSB простыми словами

Представь сумму на счету: 1 000 000,00 ₽. Если поменять копейки с ,00 на ,01, никто не заметит. У каждого числа цвета (0–255) тоже есть такие «копейки» — младший бит. Его меняют, и цвет меняется максимум на 1 из 255.

КаналБылоВ двоичномБит буквы «A»Стало
R₁200110010000200
G₁150100101101151
B₁100011001000100
R₂201110010010200
…буква «A» = 65 = 01000001 занимает 8 каналов, почти 3 пикселя

Правило: если младший бит уже совпадает, ничего не трогаем, иначе меняем число на ±1. В коде это одна строка: (v & 0b11111110) | bit.

lsb_hide.py

#!/usr/bin/env python3
"""lsb_hide.py — прячет файл в младшие биты PNG (по 1 биту в R, G, B каждого пикселя).

Формат: [32 бита длины в байтах][данные], биты старшим вперёд, пиксели слева направо,
сверху вниз. Так же читает zsteg в режиме b1,rgb,lsb,xy.

    python3 lsb_hide.py cover.png secret.txt stego.png
"""
import sys
from PIL import Image

cover, secret, out = sys.argv[1:4]
im = Image.open(cover).convert("RGB")
data = open(secret, "rb").read()

bits = f"{len(data):032b}" + "".join(f"{b:08b}" for b in data)   # длина + данные
flat = bytearray(im.tobytes())                                   # R,G,B,R,G,B,... подряд
if len(bits) > len(flat):
    sys.exit(f"Не влезает: нужно {len(bits)} бит, а в картинке {len(flat)}")

for i, bit in enumerate(bits):
    flat[i] = (flat[i] & 0b11111110) | int(bit)                  # обнулить младший бит и записать свой

Image.frombytes("RGB", im.size, bytes(flat)).save(out, "PNG")   # только PNG/BMP: JPEG сотрёт биты
print(f"спрятано {len(data)} байт, занято {len(bits) / len(flat):.1%} ёмкости → {out}")

Откуда мусор после сообщения

Первая версия скрипта писала данные без длины, и zsteg -E после сообщения выдавал кракозябры. Причина: извлекатель не знает, где кончилось сообщение, и читает младшие биты всей картинки. У фото они случайные, как монетка, а случайные биты, собранные по 8, дают случайные байты.

[ сообщение ][ случайные младшие биты остальной картинки → кракозябры ]

Решений четыре: писать длину перед данными (так сделано здесь, и так делает steghide), ставить маркер конца, прятать целый файл, у которого есть своя сигнатура конца (IEND, FF D9), или договориться о фиксированной длине.

lsb_reveal.py

#!/usr/bin/env python3
"""lsb_reveal.py — достаёт данные, спрятанные lsb_hide.py, без мусора в конце.

    python3 lsb_reveal.py stego.png > secret.txt
"""
import sys
from PIL import Image

raw = Image.open(sys.argv[1]).convert("RGB").tobytes()   # R,G,B,R,G,B,... подряд
bits = "".join(str(v & 1) for v in raw)                 # все младшие биты по порядку

n = int(bits[:32], 2)                                    # первые 32 бита — длина в байтах
if n * 8 > len(bits) - 32:
    sys.exit("Длина не помещается в картинку: похоже, данных здесь нет")
payload = bits[32:32 + n * 8]                            # ровно столько, сколько записали
sys.stdout.buffer.write(bytes(int(payload[i:i + 8], 2) for i in range(0, len(payload), 8)))
python3 lsb_hide.py cover.png secret.txt stego.png
спрятано 27 байт, занято 2.0% ёмкости → stego.png
python3 lsb_reveal.py stego.png
hexnotes test Привет

Скачать: lsb_hide.py, lsb_reveal.py.

Важные ограничения

  • Только PNG или BMP. JPEG сжимает с потерями и сотрёт младшие биты. Для JPEG данные прячут в коэффициенты DCT (так работает steghide).
  • Обнаружимость. Последовательный LSB zsteg находит за секунду, а chi-square-атака видит его по гистограмме пар значений 2k/2k+1. Следующий шаг — LSB matching (±1) и псевдослучайный порядок пикселей по ключу.
  • Ёмкость: 3 бита на пиксель RGB. На картинку 850 × 486 влезает около 155 КБ.

Чем steghide отличается от этого скрипта

  1. Сжимает данные и шифрует паролем (по умолчанию AES).
  2. Пишет служебный заголовок: размер, имя файла, контрольную сумму.
  3. Из пароля получает seed и пишет биты вразброс, а не подряд.
  4. В JPEG пишет в коэффициенты DCT, в BMP и WAV — в младшие биты пикселей и сэмплов.

Поэтому zsteg steghide не видит. Зато stegseek --seed перебирает все 2³² seed-значений и находит сам факт встраивания даже без пароля, а stegseek файл rockyou.txt перебирает пароли по словарю.