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

Коротко излагаю суть идеи: в одно фото (PNG) можно встроить другое фото или совсем не фото, а чего сами хотите. Реализация проста: каждый младший бит в RGB матрице несет полезную нагрузку, собрав их вместе, вы получите массив байтов, который хотели спрятать, а изменение в исходном изображении не ощутимо человеческим глазом. Кому интересно, ознакомьтесь с исходной статьей, ну а в этой статье мы попробуем рассмотреть возможные юзкейсы и улучшения.



Реализация выполнена на языке GO и доступна на моем гитхабе. Там же найдете руководство по эксплуатации с примерами запуска с разными ключами. А в папке demo рабочая демка (правда приложение все равно придется сначала скомпилировать). Но, если компилировать лень, то не беда, для ленивых я опубликовал приложение, как веб-сервис. На этой страничке вы можете попробовать спрятать шифрованное послание в своем PNG и расшифровать его.

Итак, теперь об улучшениях исходной идеи. Для того, чтобы было интереснее читать, я буду приводить примеры использования этого приложения двумя секретными агентами, находящимися в разных странах. Они пытаются передать друг-другу защищенные сообщения по открытым каналам связи. А мы им в этом поможем. Ну а тем, кто нетерпелив, я сразу скажу, что плюшки вот такие:

  1. AES шифрование;
  2. XOR ключом длиной равной длине сообщения;
  3. Цифровая подпись.


Нибблы


Но сначала давайте разберемся, как отцепить от исходного массива байтов по одному биту и спрятать в каждом байте каждого RGB вектора в изображении?



Сама концепция проста и понятна: берем первый бит, прячем в R первого вектора RGB, берем второй бит, прячем в G первого вектора RGB и т.д. На рисунке мы видим — в верхней части идет массив данных, который мы хотим спрятать, а в нижней — части RGB изображения. Каждый младший бит мы заменяем на бит данных и не паримся — изменения настолько незначительны, что глазом не отличить. Альфа-канал я не нарисовал умышленно — в нем мы ничего не прячем, потому что палево =).

Для реализации задумки мы “пилим” исходные данные на нибблы по три бита в каждом. Каждый ниббл будет целиком ложится на RGB вектор. Таким образом, в R мы заменим младший бит на nibble & 1, в G заменим младший бит на nibble & 2, а в B заменим младший бит на nibble & 4. Альфа-канал оставляем без изменений.

Под спойлером код, который пилит данные на нибблы.
package nibbles

type nibble struct {
  mask    int16
  size    int
  current int
  data    []byte
}

const (
  MaxNibbleSize     = 6
  MinNibbleSize     = 1
  DefaultNibbleSize = 4
  bitsInByte        = 8
)

func New(size int, data []byte) *nibble {
  var mask int16
  if size < MinNibbleSize || size > MaxNibbleSize {
     size = DefaultNibbleSize
  }
  for i := 0; i < size; i++ {
     mask |= 1 << i
  }
  return &nibble{
     mask: mask,
     size: size,
     data: data,
  }
}

func (n *nibble) Next() (byte, bool) {
  byteIndex := (n.current * n.size) / bitsInByte
  if byteIndex >= len(n.data) {
     return 0, false
  }
  bitIndex := (n.current * n.size) % bitsInByte
  n.current++
  word := int16(n.data[byteIndex])
  if len(n.data) > byteIndex+1 && bitIndex > bitsInByte-n.size {
     word |= int16(n.data[byteIndex+1]) << bitsInByte
  }
  result := (word >> bitIndex) & n.mask
  return byte(result), true
}

func Convert(data []byte, size int) (result []byte) {
  var (
     filledBits int
     bitBuffer  int16
  )
  for _, b := range data {
     bitBuffer |= int16(b) << filledBits
     filledBits += size
     if filledBits >= bitsInByte {
        result = append(result, byte(bitBuffer&0xff))
        bitBuffer = bitBuffer >> bitsInByte
        filledBits -= bitsInByte
     }
  }
  if filledBits >= size {
     result = append(result, byte(bitBuffer&0xff))
  }
  return
}



Итоговый файл сохраняем, как изображение PNG. И теперь, когда все готово для реализации основной идеи, давайте приступим к улучшениям и будем отталкиваться от возникающих в процессе эксплуатации потребностей.

AES шифрование





Агент Маша хочет передать агенту Вите сообщение. Она договаривается со своим другом (который живет в другой стране) о том, что в определенный день и определенный час выложит в сети фотографию внутри которой скрыто послание. Но есть проблема: агенты, ее прослушивающие, узнают об этом ходе и получают файл, анализируют его и восстанавливают исходное сообщение. Почему бы ей не зашифровать сообщение?

Давайте поможем им и добавим немного симметричного шифрования AES. В GO шифрование этим алгоритмом реализуется пакетом crypto/aes. Достаточно просто создать шифрующий блок, вызвав функцию aes.NewCipher(key). И теперь мы можем нарезать данные блоками и применить к каждому из них метод Encrypt.

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

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

Под спойлером шифрующая функция.
func EncryptDataAES(data []byte, key []byte) ([]byte, error) {
  aesEncoder, err := newAES(key)
  if err != nil {
     return nil, err
  }
  chainSize := aesEncoder.blockSize()
  // первым блоком будет блок информации о размере исходного сообщения
  // т.к. мы собираемся выровнять его по chainSize
  infoBlock := newSizeInfoChunk(len(data), chainSize)
  data = alignDataBy(data, chainSize)
  encrypted := make([]byte, len(infoBlock)+len(data))
  // шифруем блок с информацией
  if err = aesEncoder.encode(encrypted[0:len(infoBlock)], infoBlock); err != nil {
     return nil, err
  }
  // шифруем все сообщение
  for n := 0; n < len(data)/chainSize; n++ {
     var dst, src = encrypted[(n+1)*chainSize : (n+2)*chainSize], data[n*chainSize : (n+1)*chainSize]
     if err = aesEncoder.encode(dst, src); err != nil {
        return nil, err
     }
  }
  return encrypted, nil
}

type encoder struct {
  cipher cipher.Block
  initVc []byte
}

func newAES(key []byte) (*encoder, error) {
  block, err := aes.NewCipher(key)
  if err != nil {
     return nil, err
  }
  enc := encoder{
     cipher: block,
     initVc: make([]byte, block.BlockSize()),
  }
  return &enc, nil
}

func (e *encoder) blockSize() int {
. . .

func (e *encoder) encode(dst, src []byte) (err error) {
. . .

func (e *encoder) decode(dst, src []byte) (err error) {
. . .



Длина ключа равна длине сообщения





Шифр AES — дело хорошее, но говорят, что самый криптостойкий ключ — это ключ равный по длине исходному сообщению. Прослушивающие агенты могут проанализировать достаточно сообщений, чтобы по первому блоку (в который не подмешивается шифротекст) получить ключ.
Маша и Витя не дураки, они используют двойное шифрование: сразу после того, как сообщение зашифровано алгоритмом AES, они применяют простой XOR с ключом равным по длине исходному сообщению. Мы добавляем эту возможность в наше приложение: ключом будет какая-нибудь другая фотография (или любой файл), который тоже можно передать по публичным каналам. Дата и время следующей передачи такого ключа Маша прикрепляет к каждому сообщению. Очень важно для каждого следующего сообщения применять новый ключ. Если прослушивающие агенты не смогли расшифровать сообщение — каждый следующий раз ключ будет меняться, что затрудняет криптоанализ.

Теперь немного об энтропии. Ежу понятно, что в качестве ключа необходимо использовать “случайные данные”, а в нашей логике описано использование изображения, которое может содержать невысокую энтропию. Ничего страшного, мы добавим в алгоритм нашей программы функцию moreStrongKey(key []byte) []byte которая “замесит” биты в файле так, что они станут похожи на случайные. Функция скалярная и при выполнении с одним и тем же файлом дает один и тот же массив перемешанных данных.

Под спойлером функция шифровки/расшифровки.
func EncryptDecryptData(data []byte, key []byte) error {
  key = moreStrongKey(key)
  if len(key) < len(data) {
     return ErrKeyShortedThanData
  }
  for i, d := range data {
     data[i] = d ^ key[i]
  }
  return nil
}

func moreStrongKey(key []byte) []byte {
	const (
		salt   = 170
		bufLen = 16
	)
	var (
		buf [bufLen * 2]byte
		unf int
		out []byte
	)
	flush := func() {
		unf = 0
		h := md5.Sum(buf[:])
		out = append(out, h[:]...)
	}
	for i, b := range key {
		r := key[len(key)-i-1]
		p := i % bufLen
		buf[p*2] = b
		buf[p*2+1] = b ^ r ^ salt
		unf++
		if (i+1)%bufLen == 0 {
			flush()
		}
	}
	if unf > 0 {
		flush()
	}
	return out
}



Маша и Витя шифруют свои сообщения двумя каскадами меняя XOR-ключ с каждым новым сообщением. И мы почти уверены, что никто их не подслушивает. Но есть еще один случай, когда всех примененных хитростей будет недостаточно.

Цифровая подпись





Теперь о плохом: Машу накрыли. AES ключи оказались в руках злоумышленников и с помощью них удалось расшифровать какие-то сообщения! Но в последний момент ей удалось сбежать и теперь она должна сообщить Вите, что это провал.
Не доверяй никому” пишет она в последнем сообщении и выкладывает его в условленное время. Но вот незадача. Теперь злоумышленники, воспользовавшись ключами, могут выложить свое сообщение и полностью захватить их канал связи. Как ей доказать, что ее сообщение истинное?

Давайте добавим в наш код возможность ставить цифровую подпись на сообщение с помощью асинхронных ключей? Вот тут можно немного узнать о цифровых подписях. Мы используем ключи RSA и научим наше приложение генерировать такие ключи, хотя подойдут и свои.

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

Маша подписывает новое сообщение в котором говорит о провале и подписывает его приватным ключом, теперь она уверена, что ее сообщению Витя будет доверять и злоумышленникам никак не удастся скомпрометировать их канал связи.

Под спойлером функции цифровой подписи и ее проверки.
func SignData(data []byte, privateKey string) ([]byte, error) {
  private, err := getPrivateKey(privateKey)
  if err != nil {
     return nil, fmt.Errorf("cannot parse private key: %w", err)
  }
  sign, err := rsa.SignPSS(rand.Reader, private, signHashFn, hashData(data), nil)
  if err != nil {
     return nil, fmt.Errorf("error while signing: %w", err)
  }
  return sign, nil
}

func SignVerify(data, sign []byte, publicKey string) error {
  public, err := getPublicKey(publicKey)
  if err != nil {
     return fmt.Errorf("cannot parse public key `%s`: %w", publicKey, err)
  }
  err = rsa.VerifyPSS(public, signHashFn, hashData(data), sign, nil)
  if err != nil {
     return fmt.Errorf("error while sign checking: %w", err)
  }
  return nil
}



Заключение


В заключении хочу поблагодарить читателя за то, что он помог Маше и Вите установить секретный канал связи в публичных сетях. Но как вы понимаете, это просто маленькая игра. В действительности все гораздо сложнее и я тут много о чем умолчал. Например, если Маша прячет секретные данные в картинке PNG, то это палево. Ну согласитесь, если вы выкладываете фотографии в сети, то это наверняка JPEG?
Однако такого приложения явно хватит, чтобы поиграть со своим другом (или подругой) в секретных агентов и просто ощутить, как можно защищать каналы связи в публичных сетях.

Как я уже сказал выше, код можете почитать на моем гитхабе. В каталоге crypt найдете все три описанных алгоритма и еще две хеш-функции — одна для усиления XOR-ключа, а другая для формирования отпечатка для цифровой подписи.

В папке demo найдете мою PNG фотографию с зашифрованным внутри посланием, необходимые для расшифровки ключи прошиты в decode.sh файле, который позволит получить расшифрованное послание и проверить его цифровую подпись.
В папке carrier лежит код, который позволяет разбить сообщение на биты и встроить их в PNG картинку. А разбивать данные на маленькие кусочки битов, которые легко встраиваются в RGB вектор, нам позволяет код, который лежит в папке nibbles. Так что тут все очень интересно.

А те, у кого нет компилятора GO или кому лень, можете попробовать мой онлайн сервис стеганографии, о нем я тоже уже сказал в первой части этой статьи. Ну, а мы с Машей и Витей прощаемся с вами, надеюсь, не надолго.

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


  1. alexs963
    21.06.2022 13:34

    Давайте еще rarjpeg вспомним.


    1. devalio Автор
      21.06.2022 13:52
      +2

      но ведь это совсем другая история

      rarjpeg просто приклеивал архив после завершающего маркера JPEG файла. Даже если вы зашифруете сообщение, определить, что к файлу "пришиты" какие-то данные легко. вот тут (https://habr.com/ru/company/infowatch/blog/337084/) есть статья в которой описываются алгоритмы обнаружения таких данных

      в своей статье я хотел показать, что "спрятать" данные - это только половина дела. необходимо позаботиться об их защите как минимум с помощью шифра или цифровой подписи. и показал юзкейсы


  1. titbit
    21.06.2022 16:53
    +1

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