Стеганография: прячем послания в картинках

Перевод статьи Feel like a secret agent: Hidden messages in images with steganography от Pascal Thormeier.

Джеймс Бонд, Итан Хант, Наполеон Соло – секретные агенты, работающие под прикрытием и отсылающие тайные сообщения своему боссу и другим агентам. Давайте честно: они крутые. В фильмах и книгах, по крайней мере. У них есть классные приборы, они ловят злодеев, ходят в модные клубы в шикарной одежде. Ну и в конце концов, они спасают мир. Когда я был ребёнком, мечтал о том, чтобы стать секретным агентом.

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

Но сперва: что же такое стеганография?

Стеганография могла бы быть изобретением инженера Q из Ми-6 в фильмах о Джеймсе Бонде, но на самом деле она гораздо старше. Люди ещё в древности умели прятать изображения хитрыми способами.

Согласно Википедии, в 440 г. до н.э. древнегреческий писатель Геродот однажды сбрил волосы на голове одного из своих самых верных слуг, чтобы написать на его голове послание, а когда волосы снова выросли, отправил слугу к адресату.

Но брить мы сегодня никого не будем. Вместо этого мы спрячем одну картинку в другой.

Чтобы сделать это, нам нужно будет отбросить все незначительные части цвета в одной картинке и заменить их значимыми частями цвета другой.

Чего-чего? Значимыми и незначимыми?

Чтобы понять это, нам нужно знать, как устроены цвета, например, в формате PNG. Веб-разработчики, скорее всего, знакомы с шестнадцатеричным представлением цветов, например: #f60053 или #16ee8a. Цвет в таком виде состоит из 4 отдельных частей:

  • # — префикс
  • две 16-ричных цифры отвечают за красный цвет
  • две — за зелёный
  • еще две — за синий.

Так как значения для каждого цвета могут варьироваться в диапазоне от 00 до FF, в десятичной системе счисления диапазон будет от 0 до 255, в двоичной – от 00000000 до 11111111.

В двоичных числах, как и в десятичных, чем левее цифра, тем больше её значение. Следовательно, чем левее цифра, тем больше «значимость» бита.

Например, 11111111 – это почти в два раза больше, чем 01111111. С другой стороны, 11111110 чуть-чуть меньше 11111111. Человеческий глаз, скорее всего, не заметит разницу между #FFFFFF и #FEFEFE, а вот между #FFFFFF и #7F7F7F заметит.

Прячем изображение на JavaScript

Спрячем вот такую стоковую картинку:

В этом изображении кота:

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

  • путь к основному изображению
  • путь к секретному изображению
  • путь к результату

Сперва напишем вот такой код:

const args = process.argv.slice(2)

const mainImagePath = args[0]
const hiddenImagePath = args[1]
const targetImagePath = args[2]

// Использование:
// node hide-image.js ./cat.png ./hidden.png ./target.png

Отлично. Теперь установим image-size , чтобы узнать размер основного изображения и canvas для node, чтобы работать с изображениями и генерировать новое.

Сперва найдём размерность главного изображения и секретного, и создадим для каждого свой холст (canvas). Также я создам холст для изображения с результатом:

const imageSize = require('image-size')
const { createCanvas, loadImage } = require('canvas')

const args = process.argv.slice(2)

const mainImagePath = args[0]
const hiddenImagePath = args[1]
const targetImagePath = args[2]

const sizeMain = imageSize(mainImagePath)
const sizeHidden = imageSize(hiddenImagePath)

const canvasMain = createCanvas(sizeMain.width, sizeMain.height)
const canvasHidden = createCanvas(sizeHidden.width, sizeHidden.height)
const canvasTarget = createCanvas(sizeMain.width, sizeMain.height)

const contextMain = canvasMain.getContext('2d')
const contextHidden = canvasHidden.getContext('2d')
const contextTarget = canvasTarget.getContext('2d')

Затем мне потребуется загрузить оба изображения в соответствующие холсты. Так как эти методы возвращают промисы, я помещу остаток кода в функцию, вызываемую асинхронно, которая допускает использование async/await:

;(async () => {
  const mainImage = await loadImage(mainImagePath)
  contextMain.drawImage(mainImage, 0, 0, sizeMain.width, sizeMain.height)

  const hiddenImage = await loadImage(hiddenImagePath)
  contextHidden.drawImage(hiddenImage, 0, 0, sizeHidden.width, sizeHidden.height)
})()

Затем я итерируюсь по каждому пикселю в изображениях и получу значения их цветов:

  for (let x = 0; x < sizeHidden.width; x++) {
    for (let y = 0; y < sizeHidden.height; y++) {
      const colorMain = Array.from(contextMain.getImageData(x, y, 1, 1).data)
      const colorHidden = Array.from(contextHidden.getImageData(x, y, 1, 1).data)
    }
  }

С этими значениями я теперь смогу посчитать «смешанный цвет» каждого пикселя в результирующем изображении.

Вычисление нового цвета

Прежде я уже говорил о значимых битах. Чтобы на самом деле рассчитать цвет, проиллюстрирую это немного подробнее.

Скажем, мне нужно объединить красные составляющие цветов A и B. Вот так выглядят их биты (в 8-битном представлении):

A7 A6 A5 A4 A3 A2 A1 A0 (color A)
B7 B6 B5 B4 B3 B2 B1 B0 (color B)

Чтобы спрятать цвет B в цвете A, я заменю первые (справа) 3 бита А последними (слева) битами B. Результирующая последовательность битов выглядит так:

A7 A6 A5 A4 A3 B7 B6 B5

Это значит, что я потеряю какую-то часть информации от обоих цветов, но их объединение не будет сильно отличаться от исходного цвета B.

Наш код будет выглядеть следующим образом:

const combineColors = (a, b) => {
  const aBinary = a.toString(2).padStart(8, '0')
  const bBinary = b.toString(2).padStart(8, '0')

  return parseInt('' +
    aBinary[0] +
    aBinary[1] +
    aBinary[2] +
    aBinary[3] +
    aBinary[4] +
    bBinary[0] +
    bBinary[1] +
    bBinary[2], 
  2)
}

Теперь я могу использовать эту функцию в цикле по пикселям:

const colorMain = Array.from(contextMain.getImageData(x, y, 1, 1).data)
const colorHidden = Array.from(contextHidden.getImageData(x, y, 1, 1).data)

const combinedColor = [
  combineColors(colorMain[0], colorHidden[0]),
  combineColors(colorMain[1], colorHidden[1]),
  combineColors(colorMain[2], colorHidden[2]),
]

contextTarget.fillStyle = `rgb(${combinedColor[0]}, ${combinedColor[1]}, ${combinedColor[2]})`
contextTarget.fillRect(x, y, 1, 1)

Почти готово, теперь остаётся лишь сохранить изображение-результат:

const buffer = canvasTarget.toBuffer('image/png')
fs.writeFileSync(targetImagePath, buffer)

Вот и оно:

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

Но как восстановить спрятанное изображение?

Чтобы извлечь скрытую картинку, всё, что вам нужно – это прочитать последние три бита каждого пикселя и снова сделать их наиболее значимыми:

const extractColor = c => {
  const cBinary = c.toString(2).padStart(8, '0')

  return parseInt('' +
    cBinary[5] + 
    cBinary[6] + 
    cBinary[7] + 
    '00000',
  2)
}

Если применить это к каждому пикселю, мы вновь получим оригинальное фото (с небольшими артефактами):

Теперь вы можете почувствовать себя секретным агентом, пряча потайные изображения и посылая их своим друзьям!

[customscript]techrocks_custom_after_post_html[/customscript]

[customscript]techrocks_custom_script[/customscript]

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Прокрутить вверх