Лаборатория: свой LSB
Решать LSB-задачи проще, когда сам написал встраиватель. Здесь пара скриптов: один прячет, другой достаёт, плюс объяснение, откуда в выводе берётся мусор.
LSB простыми словами
Представь сумму на счету: 1 000 000,00 ₽. Если поменять копейки с ,00 на ,01, никто не заметит. У каждого числа цвета (0–255) тоже есть такие «копейки» — младший бит. Его меняют, и цвет меняется максимум на 1 из 255.
| Канал | Было | В двоичном | Бит буквы «A» | Стало |
|---|---|---|---|---|
| R₁ | 200 | 11001000 | 0 | 200 |
| G₁ | 150 | 10010110 | 1 | 151 |
| B₁ | 100 | 01100100 | 0 | 100 |
| R₂ | 201 | 11001001 | 0 | 200 |
| … | буква «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 отличается от этого скрипта
- Сжимает данные и шифрует паролем (по умолчанию AES).
- Пишет служебный заголовок: размер, имя файла, контрольную сумму.
- Из пароля получает seed и пишет биты вразброс, а не подряд.
- В JPEG пишет в коэффициенты DCT, в BMP и WAV — в младшие биты пикселей и сэмплов.
Поэтому zsteg steghide не видит. Зато stegseek --seed перебирает все 2³² seed-значений и находит сам факт встраивания даже без пароля, а stegseek файл rockyou.txt перебирает пароли по словарю.